fix(bootstrap): check archive and free space before deleting chain data

The bootstrap removed blocks/, chainstate/ and notarizations/ first and
only then opened the archive. An unsafe entry, a full disk or a corrupt
member therefore left no chain, or a half-written one for dragonxd to
start on.

- Preflight before anything is deleted: every archive entry must be
  safe (no absolute path, no "..") and readable, and the disk must hold
  the extracted size plus max(256 MiB, 5%), counting what removing the
  old chain frees (a symlinked chain dir counts 0). Free space counts as
  unknown only when the probe errors, the capacity is 0 or the
  available size is -1; a known disk with 0 bytes free is a refusal.
  The old chain is kept on every preflight failure.
- Protected members are skipped, compared case-insensitively: any path
  component starting with "wallet", any "database" component, a .cookie
  or *.conf file, and top-level *.dat files other than fee_estimates,
  peers and banlist. The number of non-critical member failures is
  logged.
- The removal of the old chain is verified (a locked file fails the run
  instead of mixing old and new chain files). If the extraction fails or
  is aborted, the partial chain is removed again before the failure is
  published; isDone() turns true only after the worker's last
  filesystem change.
- Cancel is disabled while the old chain data is being replaced, and
  quit aborts a running bootstrap.
- A complete zip from an earlier attempt is reused on Retry; its
  checksum is re-verified. A download that cannot fit fails early, and a
  disk-full write error says so.

Tests: the space rule (boundaries, unknown vs. full disk, saturation);
planArchive sizes, zip-slip, garbage and truncated zips; the
protected-name list; reclaimableBytes with symlinks; each preflight
refusal keeps the old chain and wallet.dat byte-identical; a corrupt
critical member removes the partial chain before the failure is
published.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 13:22:00 -05:00
co-authored by Claude Opus 5.5
parent 70d8b0f687
commit 78e38570a8
7 changed files with 831 additions and 139 deletions
+63 -3
View File
@@ -9,6 +9,10 @@
#include <mutex>
#include <atomic>
#include <thread>
#include <cstdint>
#include <filesystem>
#include <functional>
#include <system_error>
namespace dragonx {
namespace util {
@@ -21,6 +25,11 @@ namespace util {
* (skipping wallet.dat), and cleans old chain data before extraction.
* All work runs on a background thread; progress is queried thread-safely
* from the UI thread.
*
* The old chain data is removed only after a preflight passes (every archive entry is safe and the
* disk has room for the extracted data). From that point the run is not cancellable by the user
* (Progress::cancellable), and a failed or aborted extraction removes the partial chain again before
* the result is published, so dragonxd never starts on a half-written blocks/ + chainstate/.
*/
class Bootstrap {
public:
@@ -40,8 +49,19 @@ public:
float percent = 0.0f; // 0..100
std::string status_text; // human-readable status
std::string error; // non-empty on failure
bool cancellable = true; // false from the removal of the old chain data onwards
};
/// What extract() would write: the preflight result for an archive.
struct ArchivePlan {
bool ok = false;
std::uint64_t bytes = 0; // uncompressed bytes of the members that would be extracted
std::string error; // set when !ok
};
using DaemonUpFn = std::function<bool()>;
using SpaceFn = std::function<std::filesystem::space_info(const std::string&, std::error_code&)>;
Bootstrap() = default;
~Bootstrap();
@@ -60,18 +80,44 @@ public:
void start(const std::string& dataDir,
const std::string& url = "https://bootstrap.dragonx.is/DRAGONX.zip");
/// Test seam: run only the install half (daemon-exit wait, preflight, clean, extract) on a local zip,
/// skipping the download and checksum steps. daemonUp defaults to the real probe, spaceFn to
/// std::filesystem::space.
void startFromZip(const std::string& dataDir, const std::string& zipPath,
DaemonUpFn daemonUp = {}, SpaceFn spaceFn = {});
/// Cancel an in-progress download/extract.
void cancel();
/// Thread-safe read of current progress.
Progress getProgress() const;
/// Whether the operation is finished (success or failure).
/// Whether the operation is finished (success or failure). True only once the worker has made its
/// last filesystem change.
bool isDone() const;
/// Format a byte count as human-readable string (e.g. "1.24 GB")
static std::string formatSize(double bytes);
/// Preflight: open the zip, refuse it if any entry is unsafe (zip-slip) or unreadable, and sum the
/// uncompressed size of the members extract() would write (directories and protected names excluded).
static ArchivePlan planArchive(const std::string& zipPath);
/// Bytes that cleanChainData() would free on the data dir's filesystem: regular files under
/// blocks/, chainstate/ and notarizations/. A symlinked directory (or file) counts 0, since only the
/// link is removed.
static std::uint64_t reclaimableBytes(const std::string& dataDir);
/// False = refuse (with *msg set). Needs avail + reclaimable >= needed + max(256 MiB, needed / 20).
/// "Unknown" (capacity 0 or avail == UINT64_MAX; the caller also treats an error as unknown) returns
/// true. avail 0 with a known capacity is a full disk, which is a refusal.
static bool spaceSufficient(std::uint64_t needed, std::uint64_t avail, std::uint64_t capacity,
std::uint64_t reclaimable, std::string* msg);
/// Snapshot members that are never extracted: any wallet* name, database/, *.conf, .cookie, and
/// top-level *.dat other than fee_estimates/peers/banlist.
static bool isProtectedEntry(const std::string& name);
private:
mutable std::mutex mutex_;
Progress progress_;
@@ -79,9 +125,18 @@ private:
std::thread worker_;
std::atomic<bool> worker_running_{false};
// Worker bodies. The caller sets worker_running_ = false after they return; each one publishes its
// terminal state (Failed/Completed) as its last action.
void runDownloadAndInstall(const std::string& dataDir, const std::string& url);
void installFromZip(const std::string& dataDir, const std::string& zipPath,
const DaemonUpFn& daemonUp, const SpaceFn& spaceFn);
void beginRun();
bool download(const std::string& url, const std::string& destZip);
bool extract(const std::string& zipPath, const std::string& dataDir);
void cleanChainData(const std::string& dataDir);
/// Never publishes a state: returns false with *err set.
bool extract(const std::string& zipPath, const std::string& dataDir, std::string* err);
/// True iff blocks/, chainstate/ and notarizations/ are absent afterwards.
bool cleanChainData(const std::string& dataDir);
void setProgress(State state, const std::string& text, double downloaded = 0, double total = 0);
/// Download a small text file (checksum) into a string. Returns empty on failure.
@@ -109,6 +164,11 @@ private:
/// Added to dlnow in progress reports so the bar reflects true total.
double resume_offset_{0};
/// Set by download() on failure (worker thread only): the CURLcode of the transfer and, for a
/// failure that is not a transfer error, the reason.
int last_curl_{0};
std::string download_error_;
// libcurl progress callback (static → calls into instance via clientp)
static int progressCallback(void* clientp, long long dltotal, long long dlnow,
long long ultotal, long long ulnow);