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:
+63
-3
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user