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
+1
View File
@@ -1280,6 +1280,7 @@ if(BUILD_TESTING)
src/util/xmrig_updater_core.cpp
src/util/daemon_updater.cpp
src/util/daemon_updater_core.cpp
src/util/bootstrap.cpp
${MINIZ_SOURCES}
)
+1
View File
@@ -7619,6 +7619,7 @@ void App::shutdown()
bootstrap_->cancel();
bootstrap_.reset();
}
ui::BootstrapDownloadDialog::abortForShutdown(); // the Settings dialog's own bootstrap (both exit paths)
// If beginShutdown() was never called (e.g. direct exit),
// do synchronous shutdown as fallback.
+3
View File
@@ -826,9 +826,12 @@ void App::renderFirstRunWizard() {
float cancelBX = rightX + (colW - cancelW) * 0.5f;
ImGui::SetCursorScreenPos(ImVec2(cancelBX, cy));
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
// Disabled while the old chain data is being replaced (not cancellable).
ImGui::BeginDisabled(!prog.cancellable);
if (ui::material::TactileButton(TR("cancel"), ImVec2(cancelW, cancelH))) {
bootstrap_->cancel();
}
ImGui::EndDisabled();
ImGui::PopStyleVar();
cy += cancelH;
+13 -2
View File
@@ -50,6 +50,16 @@ public:
// Close the dialog (used by the UI sweep teardown).
static void hide() { s_open = false; s_state = State::Confirm; }
// Quit: cancel a running Settings bootstrap and wait for its worker. main() ends in _Exit, so without this
// a quit during extraction would kill the worker mid-file and leave a half-written chain; a cancelled
// extraction removes the partial chain before the worker exits.
static void abortForShutdown() {
if (s_bootstrap) {
s_bootstrap->cancel();
s_bootstrap.reset();
}
}
static void render() {
if (!s_app) return;
if (!s_open) {
@@ -211,9 +221,10 @@ private:
ImGui::Spacing();
// Cancel button — once clicked, disable it so the request is visibly
// acknowledged (and can't be re-fired) until the worker actually stops.
// acknowledged (and can't be re-fired) until the worker actually stops. It is also disabled while
// the old chain data is being replaced (cancelling then would only leave no chain at all).
float btnW = 100.0f * dp;
ImGui::BeginDisabled(s_cancelling);
ImGui::BeginDisabled(s_cancelling || !prog.cancellable);
if (TactileButton(TR("cancel"), ImVec2(btnW, 0))) {
s_cancelling = true;
s_bootstrap->cancel();
+396 -134
View File
@@ -9,6 +9,7 @@
#include <miniz.h>
#include <sodium.h>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <filesystem>
@@ -56,6 +57,25 @@ static bool isSafeArchivePath(const std::string& name) {
return true;
}
static std::uint64_t satAdd(std::uint64_t a, std::uint64_t b) {
return (a > UINT64_MAX - b) ? UINT64_MAX : a + b;
}
static std::string lowerAscii(std::string s) {
for (char& c : s)
if (c >= 'A' && c <= 'Z') c = static_cast<char>(c - 'A' + 'a');
return s;
}
// The production probes: the daemon is gone only when its PROCESS has exited, not just its RPC port.
static bool realDaemonUp() {
return daemon::EmbeddedDaemon::isRpcPortInUse() || daemon::EmbeddedDaemon::isDaemonProcessRunning();
}
static fs::space_info realSpace(const std::string& path, std::error_code& ec) {
return fs::space(path, ec);
}
static size_t writeFileCallback(void* contents, size_t size, size_t nmemb, void* userp) {
size_t total = size * nmemb;
FILE* fp = static_cast<FILE*>(userp);
@@ -91,124 +111,204 @@ Bootstrap::~Bootstrap() {
// Public API
// ---------------------------------------------------------------------------
void Bootstrap::beginRun() {
cancel_requested_ = false;
worker_running_ = true;
std::lock_guard<std::mutex> lk(mutex_);
progress_ = Progress{};
}
void Bootstrap::start(const std::string& dataDir, const std::string& url) {
if (worker_running_) return; // already running
if (worker_.joinable()) worker_.join();
cancel_requested_ = false;
worker_running_ = true;
beginRun();
// Ensure data dir exists
fs::create_directories(dataDir);
std::error_code ec;
fs::create_directories(dataDir, ec);
worker_ = std::thread([this, dataDir, url]() {
std::string zipPath = dataDir + "/bootstrap_tmp.zip";
runDownloadAndInstall(dataDir, url);
worker_running_ = false; // after the terminal publish: isDone() turns true only now
});
}
// Step 1: Download (with resume support)
// Check for a partial file from a previous interrupted download.
{
std::error_code ec;
if (fs::exists(zipPath, ec) && fs::file_size(zipPath, ec) > 0 && !ec) {
auto partial = fs::file_size(zipPath, ec);
DEBUG_LOGF("[Bootstrap] Found partial download: %s (%s)\n",
zipPath.c_str(), formatSize((double)partial).c_str());
setProgress(State::Downloading,
"Resuming download (" + formatSize((double)partial) + " already on disk)...");
} else {
setProgress(State::Downloading, "Connecting to dragonx.is...");
}
}
if (!download(url, zipPath)) {
if (cancel_requested_)
setProgress(State::Failed, "Download cancelled");
else
setProgress(State::Failed, "Download failed — check your internet connection");
// Keep the partial file so the next attempt can resume.
// Only delete if the file is empty / doesn't exist.
std::error_code ec;
auto sz = fs::exists(zipPath, ec) ? fs::file_size(zipPath, ec) : 0;
if (sz == 0) fs::remove(zipPath, ec);
worker_running_ = false;
return;
}
void Bootstrap::startFromZip(const std::string& dataDir, const std::string& zipPath,
DaemonUpFn daemonUp, SpaceFn spaceFn) {
if (worker_running_) return; // already running
if (worker_.joinable()) worker_.join();
if (cancel_requested_) {
setProgress(State::Failed, "Download cancelled");
// Keep partial file for resume on retry.
worker_running_ = false;
return;
}
// Step 2: Verify checksums
{
// Derive base URL from the zip URL (strip filename)
std::string baseUrl = url;
auto lastSlash = baseUrl.rfind('/');
if (lastSlash != std::string::npos)
baseUrl = baseUrl.substr(0, lastSlash);
beginRun();
if (!daemonUp) daemonUp = realDaemonUp;
if (!spaceFn) spaceFn = realSpace;
if (!verifyChecksums(zipPath, baseUrl)) {
if (!cancel_requested_) {
// Checksum failure — delete the corrupt file so next attempt re-downloads
std::error_code ec;
fs::remove(zipPath, ec);
}
worker_running_ = false;
return;
}
}
std::error_code ec;
fs::create_directories(dataDir, ec);
if (cancel_requested_) {
setProgress(State::Failed, "Verification cancelled");
worker_running_ = false;
return;
}
// Step 3: Ensure daemon is fully stopped before touching chain data. Gone means the PROCESS has
// exited, not just its RPC port: dragonxd unbinds RPC early in its shutdown and keeps its files
// (and the datadir lock) until it exits — deleting chain data under it corrupts it, or fails on
// Windows' locked files.
{
setProgress(State::Extracting, "Waiting for daemon to stop...");
auto daemonUp = [] {
return daemon::EmbeddedDaemon::isRpcPortInUse() || daemon::EmbeddedDaemon::isDaemonProcessRunning();
};
int waited = 0;
while (daemonUp() && waited < 120 && !cancel_requested_) {
std::this_thread::sleep_for(std::chrono::seconds(1));
waited++;
if (waited % 5 == 0)
DEBUG_LOGF("[Bootstrap] Still waiting for daemon to stop... (%ds)\n", waited);
}
if (cancel_requested_) {
setProgress(State::Failed, "Cancelled while waiting for daemon");
worker_running_ = false;
return;
}
if (daemonUp()) {
setProgress(State::Failed, "Daemon is still running — stop it before using bootstrap");
worker_running_ = false;
return;
}
}
// Step 4: Clean old chain data
setProgress(State::Extracting, "Removing old chain data...");
cleanChainData(dataDir);
// Step 5: Extract (skipping wallet.dat)
if (!extract(zipPath, dataDir)) {
if (cancel_requested_)
setProgress(State::Failed, "Extraction cancelled");
else
setProgress(State::Failed, "Extraction failed — zip file may be corrupted");
worker_running_ = false;
return;
}
setProgress(State::Completed, "Bootstrap complete!");
worker_ = std::thread([this, dataDir, zipPath, daemonUp, spaceFn]() {
installFromZip(dataDir, zipPath, daemonUp, spaceFn);
worker_running_ = false;
});
}
void Bootstrap::runDownloadAndInstall(const std::string& dataDir, const std::string& url) {
std::string zipPath = dataDir + "/bootstrap_tmp.zip";
// Step 1: Download (with resume support)
// Check for a partial file from a previous interrupted download.
{
std::error_code ec;
if (fs::exists(zipPath, ec) && fs::file_size(zipPath, ec) > 0 && !ec) {
auto partial = fs::file_size(zipPath, ec);
DEBUG_LOGF("[Bootstrap] Found partial download: %s (%s)\n",
zipPath.c_str(), formatSize((double)partial).c_str());
setProgress(State::Downloading,
"Resuming download (" + formatSize((double)partial) + " already on disk)...");
} else {
setProgress(State::Downloading, "Connecting to dragonx.is...");
}
}
if (!download(url, zipPath)) {
// Keep the partial file so the next attempt can resume.
// Only delete if the file is empty / doesn't exist.
std::error_code ec;
auto sz = fs::exists(zipPath, ec) ? fs::file_size(zipPath, ec) : 0;
if (sz == 0) fs::remove(zipPath, ec);
std::string why;
if (cancel_requested_)
why = "Download cancelled";
else if (!download_error_.empty())
why = download_error_;
else if (last_curl_ == CURLE_WRITE_ERROR)
why = (sz > 0 && sz != static_cast<std::uintmax_t>(-1))
? "Could not write the download to disk (disk full?) - kept " + zipPath + " (" +
formatSize((double)sz) + ") to resume"
: std::string("Could not write the download to disk (disk full?)");
else
why = "Download failed — check your internet connection";
setProgress(State::Failed, why); // last: after the partial-file cleanup
return;
}
if (cancel_requested_) {
setProgress(State::Failed, "Download cancelled");
// Keep partial file for resume on retry.
return;
}
// Step 2: Verify checksums (also for a reused complete download)
{
// Derive base URL from the zip URL (strip filename)
std::string baseUrl = url;
auto lastSlash = baseUrl.rfind('/');
if (lastSlash != std::string::npos)
baseUrl = baseUrl.substr(0, lastSlash);
if (!verifyChecksums(zipPath, baseUrl)) {
if (cancel_requested_) {
setProgress(State::Failed, "Verification cancelled"); // the download is kept
return;
}
// Checksum failure — delete the corrupt file so next attempt re-downloads
const std::string why = getProgress().error; // verifyChecksums' own reason
std::error_code ec;
fs::remove(zipPath, ec);
setProgress(State::Failed, why.empty() ? std::string("Checksum verification failed") : why);
return;
}
}
if (cancel_requested_) {
setProgress(State::Failed, "Verification cancelled");
return;
}
installFromZip(dataDir, zipPath, realDaemonUp, realSpace);
}
void Bootstrap::installFromZip(const std::string& dataDir, const std::string& zipPath,
const DaemonUpFn& daemonUp, const SpaceFn& spaceFn) {
// Step 3: Ensure daemon is fully stopped before touching chain data. Gone means the PROCESS has
// exited, not just its RPC port: dragonxd unbinds RPC early in its shutdown and keeps its files
// (and the datadir lock) until it exits — deleting chain data under it corrupts it, or fails on
// Windows' locked files.
{
setProgress(State::Extracting, "Waiting for daemon to stop...");
int waited = 0;
while (daemonUp() && waited < 120 && !cancel_requested_) {
std::this_thread::sleep_for(std::chrono::seconds(1));
waited++;
if (waited % 5 == 0)
DEBUG_LOGF("[Bootstrap] Still waiting for daemon to stop... (%ds)\n", waited);
}
if (cancel_requested_) {
setProgress(State::Failed, "Cancelled while waiting for daemon");
return;
}
if (daemonUp()) {
setProgress(State::Failed, "Daemon is still running — stop it before using bootstrap");
return;
}
}
// Step 3b: check the archive and the disk BEFORE anything is deleted. Every failure here keeps the
// old chain data and the download.
setProgress(State::Extracting, "Checking the download and free disk space...");
const ArchivePlan plan = planArchive(zipPath);
if (!plan.ok) {
LOGF("[Bootstrap] Preflight refused the archive: %s\n", plan.error.c_str());
setProgress(State::Failed, plan.error);
return;
}
{
std::error_code ec;
const fs::space_info sp = spaceFn(dataDir, ec);
std::string msg;
if (ec) {
LOGF("[Bootstrap] Free space unknown (%s) - not checked\n", ec.message().c_str());
} else if (!spaceSufficient(plan.bytes, sp.available, sp.capacity, reclaimableBytes(dataDir), &msg)) {
LOGF("[Bootstrap] Preflight: %s\n", msg.c_str());
setProgress(State::Failed, msg);
return;
}
}
// The destructive window starts: from here the user cannot cancel (quit still can, see cancel_requested_
// in extract), and a failure removes the partial chain again before the result is published.
{
std::lock_guard<std::mutex> lk(mutex_);
progress_.cancellable = false;
}
if (cancel_requested_) {
setProgress(State::Failed, "Cancelled - the old chain data was not touched");
return;
}
// Step 4: Clean old chain data (verified: a locked file must not leave old and new chain files mixed)
setProgress(State::Extracting, "Removing old chain data...");
std::string err;
bool ok = cleanChainData(dataDir);
if (!ok)
err = "Could not remove all of the old chain data (a file may be in use). Close other programs and retry.";
// Step 5: Extract (skipping wallet.dat and the other protected names)
if (ok && !extract(zipPath, dataDir, &err)) ok = false;
if (!ok) {
// Never leave a half-written blocks/ + chainstate/ for dragonxd to start on. wallet.dat, *.conf and
// database/ are untouched by cleanChainData.
cleanChainData(dataDir);
if (cancel_requested_)
err = "Bootstrap stopped - the old chain data had already been removed. Run it again (the download "
"is kept) or let the node resync.";
else if (err.empty())
err = "Extraction failed - zip file may be corrupted";
setProgress(State::Failed, err); // LAST: nothing touches the data dir after this
return;
}
setProgress(State::Completed, "Bootstrap complete!");
}
void Bootstrap::cancel() {
cancel_requested_ = true;
}
@@ -220,7 +320,9 @@ Bootstrap::Progress Bootstrap::getProgress() const {
bool Bootstrap::isDone() const {
std::lock_guard<std::mutex> lk(mutex_);
return progress_.state == State::Completed || progress_.state == State::Failed;
// worker_running_ is cleared only after the worker's last action, so a failure that a step reported
// before its own cleanup (download, checksum) is not "done" until that cleanup has finished either.
return (progress_.state == State::Completed || progress_.state == State::Failed) && !worker_running_;
}
// ---------------------------------------------------------------------------
@@ -293,9 +395,12 @@ int Bootstrap::progressCallback(void* clientp, long long dltotal, long long dlno
}
bool Bootstrap::download(const std::string& url, const std::string& destZip) {
// Failures are reported through last_curl_ / download_error_; the caller publishes them.
last_curl_ = 0;
download_error_.clear();
CURL* curl = curl_easy_init();
if (!curl) {
setProgress(State::Failed, "Failed to initialise libcurl");
download_error_ = "Failed to initialise libcurl";
return false;
}
@@ -310,14 +415,25 @@ bool Bootstrap::download(const std::string& url, const std::string& destZip) {
}
}
// If we have a partial file, check the remote file size to detect
// server-side changes that would corrupt a resumed download.
// The remote size (HEAD) detects server-side changes that would corrupt a resumed download, lets a
// complete earlier download be reused, and feeds the early free-space check below.
const long long remoteSize = getRemoteFileSize(url);
if (existing_bytes > 0) {
long long remoteSize = getRemoteFileSize(url);
if (remoteSize > 0) {
if (existing_bytes >= remoteSize) {
// File is already complete (or bigger — stale from an older zip)
DEBUG_LOGF("[Bootstrap] Local file (%lld bytes) >= remote (%lld bytes), re-downloading\n",
if (existing_bytes == remoteSize) {
// Complete from an earlier attempt (e.g. "free some space, then Retry"): reuse it instead of
// re-downloading several GB. verifyChecksums() still runs on it and deletes it on a mismatch.
DEBUG_LOGF("[Bootstrap] Local file is complete (%lld bytes) - reusing it\n", existing_bytes);
curl_easy_cleanup(curl);
resume_offset_ = 0;
setProgress(State::Downloading,
"Reusing the completed download (" + formatSize((double)existing_bytes) + ")",
(double)existing_bytes, (double)remoteSize);
return true;
}
if (existing_bytes > remoteSize) {
// Bigger than the remote file — stale from an older zip
DEBUG_LOGF("[Bootstrap] Local file (%lld bytes) > remote (%lld bytes), re-downloading\n",
existing_bytes, remoteSize);
std::error_code ec;
fs::remove(destZip, ec);
@@ -332,6 +448,23 @@ bool Bootstrap::download(const std::string& url, const std::string& destZip) {
}
}
// Early, non-destructive check: the bytes still to download plus 256 MiB must fit (fails open when the
// size or the free space is unknown). Nothing is deleted; a partial file is kept for a later resume.
if (remoteSize > existing_bytes) {
const std::uint64_t need = satAdd(static_cast<std::uint64_t>(remoteSize - existing_bytes),
256ull * 1024ull * 1024ull);
std::error_code ec;
const fs::space_info sp = fs::space(fs::path(destZip).parent_path(), ec);
if (!ec && sp.capacity != 0 && sp.available != static_cast<std::uintmax_t>(-1) &&
static_cast<std::uint64_t>(sp.available) < need) {
curl_easy_cleanup(curl);
download_error_ = "Not enough disk space for the download: need " + formatSize((double)need) +
", have " + formatSize((double)sp.available) + " free. Free some space and retry.";
LOGF("[Bootstrap] %s\n", download_error_.c_str());
return false;
}
}
resume_offset_ = (double)existing_bytes;
FILE* fp = nullptr;
@@ -340,7 +473,7 @@ bool Bootstrap::download(const std::string& url, const std::string& destZip) {
fp = fopen(destZip.c_str(), "ab");
if (!fp) {
curl_easy_cleanup(curl);
setProgress(State::Failed, "Failed to open output file for resume: " + destZip);
download_error_ = "Failed to open output file for resume: " + destZip;
return false;
}
curl_easy_setopt(curl, CURLOPT_RESUME_FROM_LARGE, (curl_off_t)existing_bytes);
@@ -349,7 +482,7 @@ bool Bootstrap::download(const std::string& url, const std::string& destZip) {
fp = fopen(destZip.c_str(), "wb");
if (!fp) {
curl_easy_cleanup(curl);
setProgress(State::Failed, "Failed to open output file: " + destZip);
download_error_ = "Failed to open output file: " + destZip;
return false;
}
}
@@ -386,31 +519,147 @@ bool Bootstrap::download(const std::string& url, const std::string& destZip) {
if (res != CURLE_OK) {
DEBUG_LOGF("[Bootstrap] curl error: %s\n", curl_easy_strerror(res));
last_curl_ = static_cast<int>(res);
return false;
}
return true;
}
// ---------------------------------------------------------------------------
// Preflight helpers (pure, static)
// ---------------------------------------------------------------------------
bool Bootstrap::isProtectedEntry(const std::string& name) {
std::string n = name;
std::replace(n.begin(), n.end(), '\\', '/');
while (!n.empty() && n.back() == '/') n.pop_back(); // a directory entry ("database/")
n = lowerAscii(n); // Windows/macOS data dirs are case-insensitive
if (n.empty()) return false;
size_t start = 0;
for (;;) {
const size_t slash = n.find('/', start);
const std::string comp =
n.substr(start, slash == std::string::npos ? std::string::npos : slash - start);
if (comp.rfind("wallet", 0) == 0) return true; // wallet.dat, wallet-*.dat (incl. symlinks), wallets/
if (comp == "database") return true; // the wallet's BDB environment
if (slash == std::string::npos) break;
start = slash + 1;
}
const size_t lastSlash = n.rfind('/');
const std::string base = (lastSlash == std::string::npos) ? n : n.substr(lastSlash + 1);
if (base == ".cookie" || endsWith(base, ".conf")) return true;
// Top-level *.dat other than the node's own caches: -wallet=<name>.dat files can have any name.
if (lastSlash == std::string::npos && endsWith(base, ".dat") &&
base != "fee_estimates.dat" && base != "peers.dat" && base != "banlist.dat")
return true;
return false;
}
Bootstrap::ArchivePlan Bootstrap::planArchive(const std::string& zipPath) {
ArchivePlan plan;
mz_zip_archive zip = {};
if (!mz_zip_reader_init_file(&zip, zipPath.c_str(), 0)) {
plan.error = "Failed to open zip file - the download may be corrupted";
return plan;
}
const mz_uint numFiles = mz_zip_reader_get_num_files(&zip);
for (mz_uint i = 0; i < numFiles; i++) {
mz_zip_archive_file_stat stat;
if (!mz_zip_reader_file_stat(&zip, i, &stat)) {
plan.error = "Unreadable archive entry #" + std::to_string(i) + " - the download may be corrupted";
mz_zip_reader_end(&zip);
return plan;
}
const std::string filename = stat.m_filename;
if (!isSafeArchivePath(filename)) {
plan.error = "Refusing unsafe archive entry: " + filename;
mz_zip_reader_end(&zip);
return plan;
}
if (mz_zip_reader_is_file_a_directory(&zip, i) || isProtectedEntry(filename)) continue;
plan.bytes = satAdd(plan.bytes, static_cast<std::uint64_t>(stat.m_uncomp_size));
}
mz_zip_reader_end(&zip);
plan.ok = true;
return plan;
}
std::uint64_t Bootstrap::reclaimableBytes(const std::string& dataDir) {
std::uint64_t total = 0;
for (const char* subdir : {"blocks", "chainstate", "notarizations"}) {
const fs::path p = fs::path(dataDir) / subdir;
std::error_code ec;
const fs::file_status st = fs::symlink_status(p, ec);
if (fs::is_symlink(st)) continue; // remove_all drops the link; the target's space is not freed here
if (fs::is_regular_file(st)) {
const auto sz = fs::file_size(p, ec);
if (!ec) total = satAdd(total, static_cast<std::uint64_t>(sz));
continue;
}
if (!fs::is_directory(st)) continue;
// Directory symlinks inside are not followed (no follow_directory_symlink); file symlinks count 0.
fs::recursive_directory_iterator it(p, fs::directory_options::skip_permission_denied, ec);
const fs::recursive_directory_iterator end;
while (!ec && it != end) {
std::error_code fec;
const fs::file_status est = it->symlink_status(fec);
if (!fec && fs::is_regular_file(est)) {
const auto sz = it->file_size(fec);
if (!fec) total = satAdd(total, static_cast<std::uint64_t>(sz));
}
it.increment(ec);
}
}
return total;
}
bool Bootstrap::spaceSufficient(std::uint64_t needed, std::uint64_t avail, std::uint64_t capacity,
std::uint64_t reclaimable, std::string* msg) {
// Unknown (e.g. some FUSE/network mounts): fail open. A known capacity with avail 0 is a full disk.
if (capacity == 0 || avail == UINT64_MAX) return true;
const std::uint64_t kMinMargin = 256ull * 1024ull * 1024ull;
const std::uint64_t pctMargin = needed / 20; // 5%
const std::uint64_t margin = (pctMargin > kMinMargin) ? pctMargin : kMinMargin;
const std::uint64_t need = satAdd(needed, margin);
const std::uint64_t have = satAdd(avail, reclaimable);
if (have >= need) return true;
if (msg) {
*msg = "Not enough disk space for the bootstrap: need " + formatSize((double)need) + ", have " +
formatSize((double)avail) + " free (+" + formatSize((double)reclaimable) +
" from the old chain data). Free some space and retry - the download is kept.";
}
return false;
}
// ---------------------------------------------------------------------------
// Zip extraction (miniz)
// ---------------------------------------------------------------------------
bool Bootstrap::extract(const std::string& zipPath, const std::string& dataDir) {
mz_zip_archive zip = {};
if (!mz_zip_reader_init_file(&zip, zipPath.c_str(), 0)) {
setProgress(State::Failed, "Failed to open zip file");
bool Bootstrap::extract(const std::string& zipPath, const std::string& dataDir, std::string* err) {
// Never publishes a state: the caller removes a partial chain first and publishes last.
auto fail = [err](const std::string& why) {
if (err) *err = why;
return false;
}
};
mz_zip_archive zip = {};
if (!mz_zip_reader_init_file(&zip, zipPath.c_str(), 0))
return fail("Failed to open zip file");
int numFiles = (int)mz_zip_reader_get_num_files(&zip);
int nonCriticalFailures = 0;
for (int i = 0; i < numFiles; i++) {
if (cancel_requested_) {
mz_zip_reader_end(&zip);
return false;
return fail("Extraction cancelled");
}
mz_zip_archive_file_stat stat;
if (!mz_zip_reader_file_stat(&zip, i, &stat)) continue;
if (!mz_zip_reader_file_stat(&zip, i, &stat)) {
nonCriticalFailures++;
continue;
}
std::string filename = stat.m_filename;
@@ -419,14 +668,13 @@ bool Bootstrap::extract(const std::string& zipPath, const std::string& dataDir)
// indicates a malicious/corrupt archive, so abort rather than silently skip.
if (!isSafeArchivePath(filename)) {
DEBUG_LOGF("[Bootstrap] Unsafe archive path rejected: %s\n", filename.c_str());
setProgress(State::Failed, "Refusing to extract unsafe archive entry: " + filename);
mz_zip_reader_end(&zip);
return false;
return fail("Refusing to extract unsafe archive entry: " + filename);
}
// *** CRITICAL: Skip wallet.dat ***
if (filename == "wallet.dat" || endsWith(filename, "/wallet.dat")) {
DEBUG_LOGF("[Bootstrap] Skipping wallet.dat (protected)\n");
// *** CRITICAL: Skip wallet.dat (and every other wallet*, database/, *.conf, .cookie entry) ***
if (isProtectedEntry(filename)) {
LOGF("[Bootstrap] Skipping protected archive entry: %s\n", filename.c_str());
continue;
}
@@ -445,26 +693,26 @@ bool Bootstrap::extract(const std::string& zipPath, const std::string& dataDir)
fs::create_directories(fs::path(destPath).parent_path(), ec);
if (!mz_zip_reader_extract_to_file(&zip, i, destPath.c_str(), 0)) {
DEBUG_LOGF("[Bootstrap] Failed to extract: %s\n", filename.c_str());
LOGF("[Bootstrap] Failed to extract: %s\n", filename.c_str());
// Critical chain data must extract successfully
if (filename.rfind("blocks/", 0) == 0 || filename.rfind("chainstate/", 0) == 0) {
setProgress(State::Failed, "Failed to extract critical file: " + filename);
mz_zip_reader_end(&zip);
return false;
return fail("Failed to extract critical file: " + filename);
}
// Non-fatal for other files: continue with remaining
nonCriticalFailures++;
}
}
// Update progress
float pct = (numFiles > 0) ? (100.0f * (i + 1) / numFiles) : 0.0f;
// Update progress (percent = file count ratio)
setProgress(State::Extracting,
"Extracting: " + filename,
(double)(i + 1), (double)numFiles);
progress_.percent = pct; // override to use file count ratio
}
mz_zip_reader_end(&zip);
if (nonCriticalFailures > 0)
LOGF("[Bootstrap] %d non-critical archive member(s) could not be extracted\n", nonCriticalFailures);
// Clean up zip file
std::error_code ec;
@@ -477,7 +725,7 @@ bool Bootstrap::extract(const std::string& zipPath, const std::string& dataDir)
// Pre-extraction cleanup
// ---------------------------------------------------------------------------
void Bootstrap::cleanChainData(const std::string& dataDir) {
bool Bootstrap::cleanChainData(const std::string& dataDir) {
// Directories to remove completely.
// NOTE: "database" is intentionally NOT removed here. It contains BDB
// environment/log files for wallet.dat. Deleting it while keeping wallet.dat
@@ -485,12 +733,14 @@ void Bootstrap::cleanChainData(const std::string& dataDir) {
// (renaming it to wallet.{timestamp}.bak and creating an empty replacement).
// The daemon's DB_RECOVER flag in CDBEnv::Open() handles stale environment
// files gracefully when the environment dir still exists.
for (const char* subdir : {"blocks", "chainstate", "notarizations"}) {
static const char* const kChainDirs[] = {"blocks", "chainstate", "notarizations"};
for (const char* subdir : kChainDirs) {
fs::path p = fs::path(dataDir) / subdir;
std::error_code ec;
if (fs::exists(p, ec)) {
// symlink_status: a symlinked (even dangling) entry is removed as a link, never followed.
if (fs::symlink_status(p, ec).type() != fs::file_type::not_found) {
fs::remove_all(p, ec);
if (ec) DEBUG_LOGF("[Bootstrap] Warning: could not remove %s: %s\n", subdir, ec.message().c_str());
if (ec) LOGF("[Bootstrap] Warning: could not remove %s: %s\n", subdir, ec.message().c_str());
}
}
// Individual files to remove (will be replaced by bootstrap)
@@ -502,6 +752,18 @@ void Bootstrap::cleanChainData(const std::string& dataDir) {
}
}
// NEVER remove: wallet.dat, debug.log, *.conf
// Verified, not assumed: a locked file (Windows: antivirus, a lingering handle) leaves part of the old
// chain behind, and a new chainstate extracted over leftover .ldb files is an inconsistent LevelDB.
bool clean = true;
for (const char* subdir : kChainDirs) {
std::error_code ec;
if (fs::symlink_status(fs::path(dataDir) / subdir, ec).type() != fs::file_type::not_found) {
LOGF("[Bootstrap] %s is still present after removing the old chain data\n", subdir);
clean = false;
}
}
return clean;
}
// ---------------------------------------------------------------------------
+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);
+354
View File
@@ -80,6 +80,7 @@
#include "util/pool_registry.h"
#include "util/daemon_updater.h"
#include "util/http_download.h"
#include "util/bootstrap.h"
#include "util/background_sampler.h"
#include "util/lite_server_probe.h"
#include "util/wallet_file_probe.h"
@@ -126,6 +127,8 @@
#include <utility>
#include <vector>
#include <sodium.h>
#define MINIZ_NO_ZLIB_COMPATIBLE_NAMES // only the mz_zip_* API is used; keep zlib-style names out of this file
#include <miniz.h>
namespace fs = std::filesystem;
@@ -3550,6 +3553,351 @@ void testResourceNeedsWrite()
removeTree(dir);
}
// ── C222: bootstrap preflight (util/bootstrap.h) ──
// Writes a zip with miniz. level 0 stores members uncompressed, so a test can corrupt member data in place.
static bool writeBootstrapTestZip(const fs::path& zipPath,
const std::vector<std::pair<std::string, std::string>>& entries,
mz_uint level = MZ_DEFAULT_LEVEL)
{
mz_zip_archive zip;
std::memset(&zip, 0, sizeof(zip));
if (!mz_zip_writer_init_file(&zip, zipPath.string().c_str(), 0)) return false;
bool ok = true;
for (const auto& e : entries)
ok = ok && mz_zip_writer_add_mem(&zip, e.first.c_str(), e.second.data(), e.second.size(), level);
ok = mz_zip_writer_finalize_archive(&zip) && ok;
mz_zip_writer_end(&zip);
return ok;
}
static fs::space_info bootstrapTestSpace(std::uintmax_t avail, std::uintmax_t capacity)
{
fs::space_info si{};
si.capacity = capacity;
si.free = avail;
si.available = avail;
return si;
}
static bool bootstrapPathAbsent(const fs::path& p)
{
std::error_code ec;
return fs::symlink_status(p, ec).type() == fs::file_type::not_found;
}
static bool waitBootstrapDone(const dragonx::util::Bootstrap& b, double timeoutSec)
{
const auto t0 = std::chrono::steady_clock::now();
while (secondsSince(t0) < timeoutSec) {
if (b.isDone()) return true;
std::this_thread::sleep_for(std::chrono::milliseconds(2));
}
return b.isDone();
}
// An old chain plus a wallet in <data>: blocks/old, chainstate/old, wallet.dat.
static void plantOldBootstrapChain(const fs::path& data, const std::string& walletBytes)
{
fs::create_directories(data / "blocks");
fs::create_directories(data / "chainstate");
{ std::ofstream(data / "blocks" / "old", std::ios::binary) << "OLD-BLOCKS"; }
{ std::ofstream(data / "chainstate" / "old", std::ios::binary) << "OLD-CHAINSTATE"; }
{ std::ofstream f(data / "wallet.dat", std::ios::binary); f.write(walletBytes.data(), (std::streamsize)walletBytes.size()); }
}
void testBootstrapSpaceSufficient()
{
using dragonx::util::Bootstrap;
const std::uint64_t MiB = 1024ull * 1024ull;
const std::uint64_t GiB = 1024ull * MiB;
const std::uint64_t kMax = std::numeric_limits<std::uint64_t>::max();
std::string msg;
// margin = max(256 MiB, 5%): 5 GiB needs 5 GiB + 256 MiB
EXPECT_TRUE(!Bootstrap::spaceSufficient(5 * GiB, 1 * GiB, 100 * GiB, (36 * GiB) / 10, &msg));
EXPECT_TRUE(msg.find("need") != std::string::npos);
EXPECT_TRUE(Bootstrap::spaceSufficient(5 * GiB, 6 * GiB, 100 * GiB, 0, nullptr));
// A genuinely full disk (known capacity, nothing available) is a refusal, not "unknown".
msg.clear();
EXPECT_TRUE(!Bootstrap::spaceSufficient(5 * GiB, 0, 100 * GiB, 0, &msg));
EXPECT_TRUE(msg.find("need") != std::string::npos);
// Unknown: capacity 0 or avail == (uintmax_t)-1 -> fail open.
EXPECT_TRUE(Bootstrap::spaceSufficient(5 * GiB, 0, 0, 0, nullptr));
EXPECT_TRUE(Bootstrap::spaceSufficient(5 * GiB, kMax, 100 * GiB, 0, nullptr));
// Exact boundary.
EXPECT_TRUE(Bootstrap::spaceSufficient(5 * GiB, 5 * GiB + 256 * MiB, 100 * GiB, 0, nullptr));
EXPECT_TRUE(!Bootstrap::spaceSufficient(5 * GiB, 5 * GiB + 256 * MiB - 1, 100 * GiB, 0, nullptr));
// The old chain's bytes count toward it.
EXPECT_TRUE(Bootstrap::spaceSufficient(5 * GiB, 2 * GiB, 100 * GiB, 3 * GiB + 256 * MiB, nullptr));
EXPECT_TRUE(!Bootstrap::spaceSufficient(5 * GiB, 2 * GiB, 100 * GiB, 3 * GiB + 256 * MiB - 1, nullptr));
// 256 MiB floor for a small archive; 5% above it.
EXPECT_TRUE(!Bootstrap::spaceSufficient(1 * MiB, 256 * MiB, 100 * GiB, 0, nullptr));
EXPECT_TRUE(Bootstrap::spaceSufficient(1 * MiB, 257 * MiB, 100 * GiB, 0, nullptr));
EXPECT_TRUE(!Bootstrap::spaceSufficient(100 * GiB, 105 * GiB - 1, 1000 * GiB, 0, nullptr));
EXPECT_TRUE(Bootstrap::spaceSufficient(100 * GiB, 105 * GiB, 1000 * GiB, 0, nullptr));
// Huge values saturate instead of wrapping around to a small "need".
EXPECT_TRUE(!Bootstrap::spaceSufficient(kMax - 1, 10 * GiB, 100 * GiB, 0, nullptr));
EXPECT_TRUE(!Bootstrap::spaceSufficient(kMax, kMax - 1, kMax, 0, nullptr));
EXPECT_TRUE(Bootstrap::spaceSufficient(5 * GiB, 1 * GiB, 100 * GiB, kMax, nullptr));
}
void testBootstrapPlanArchive()
{
using dragonx::util::Bootstrap;
const fs::path dir = makeTempDir();
const fs::path good = dir / "good.zip";
EXPECT_TRUE(writeBootstrapTestZip(good, {
{"blocks/", ""},
{"blocks/a", std::string(1000, 'b')},
{"chainstate/b", std::string(500, 'c')},
{"peers.dat", std::string(19, 'p')},
{"wallet.dat", std::string(300, 'w')},
{"wallet-cold.dat", "cold"},
{"blocks/sub/wallet.dat", "nested"},
{"DRAGONX.conf", "rpcpassword=x"},
{"database/", ""},
{"database/log.0000000001", "bdb-log"},
{".cookie", "__cookie__:x"},
{"cold.dat", "another wallet file"},
}));
Bootstrap::ArchivePlan plan = Bootstrap::planArchive(good.string());
EXPECT_TRUE(plan.ok);
EXPECT_EQ(plan.bytes, (std::uint64_t)(1000 + 500 + 19)); // wallet*, *.conf, database/, .cookie, cold.dat excluded
const fs::path evil = dir / "evil.zip";
EXPECT_TRUE(writeBootstrapTestZip(evil, {{"blocks/a", std::string(10, 'b')}, {"../evil", "x"}}));
plan = Bootstrap::planArchive(evil.string());
EXPECT_TRUE(!plan.ok);
EXPECT_TRUE(plan.error.find("unsafe") != std::string::npos);
const fs::path garbage = dir / "garbage.zip";
{ std::ofstream(garbage, std::ios::binary) << "this is not a zip archive, just some bytes"; }
plan = Bootstrap::planArchive(garbage.string());
EXPECT_TRUE(!plan.ok);
EXPECT_TRUE(!plan.error.empty());
const fs::path truncated = dir / "truncated.zip"; // the first half: the central directory is gone
{
const std::string bytes = readProvisionFile(good);
std::ofstream f(truncated, std::ios::binary);
f.write(bytes.data(), (std::streamsize)(bytes.size() / 2));
}
plan = Bootstrap::planArchive(truncated.string());
EXPECT_TRUE(!plan.ok);
EXPECT_TRUE(!Bootstrap::planArchive((dir / "missing.zip").string()).ok);
for (const char* n : {"wallet.dat", "Wallet.DAT", "wallet-ip-1.dat", "wallets/w.dat", "blocks/wallet.dat",
"database/", "database/log.1", "DRAGONX.conf", "sub/x.conf", ".cookie", "cold.dat",
"sub\\wallet.dat"})
EXPECT_TRUE(Bootstrap::isProtectedEntry(n) || (std::cerr << " not protected: " << n << "\n", false));
for (const char* n : {"blocks/", "blocks/blk00000.dat", "blocks/index/000001.ldb", "chainstate/000002.ldb",
"notarizations/x", "peers.dat", "fee_estimates.dat", "banlist.dat"})
EXPECT_TRUE(!Bootstrap::isProtectedEntry(n) || (std::cerr << " protected: " << n << "\n", false));
removeTree(dir);
}
#ifndef _WIN32
void testBootstrapReclaimable()
{
using dragonx::util::Bootstrap;
const fs::path dir = makeTempDir();
fs::create_directories(dir / "blocks");
{ std::ofstream(dir / "blocks" / "f", std::ios::binary) << std::string(1000, 'x'); }
fs::create_directories(dir / "elsewhere");
{ std::ofstream(dir / "elsewhere" / "g", std::ios::binary) << std::string(5000, 'y'); }
// chainstate is a symlink to a 5000 B dir: removing it frees nothing on this filesystem.
fs::create_directory_symlink(dir / "elsewhere", dir / "chainstate");
// A file symlink inside blocks/ frees nothing either.
fs::create_symlink(dir / "elsewhere" / "g", dir / "blocks" / "link");
EXPECT_EQ(Bootstrap::reclaimableBytes(dir.string()), (std::uint64_t)1000);
fs::create_directories(dir / "notarizations" / "sub");
{ std::ofstream(dir / "notarizations" / "sub" / "n", std::ios::binary) << std::string(24, 'n'); }
EXPECT_EQ(Bootstrap::reclaimableBytes(dir.string()), (std::uint64_t)1024);
EXPECT_EQ(Bootstrap::reclaimableBytes((dir / "missing").string()), (std::uint64_t)0);
removeTree(dir);
}
#endif
void testBootstrapPreflightKeepsOldChain()
{
using dragonx::util::Bootstrap;
const auto noDaemon = [] { return false; };
const Bootstrap::SpaceFn plenty = [](const std::string&, std::error_code& ec) {
ec.clear();
return bootstrapTestSpace(1ull << 40, 1ull << 41);
};
std::string walletBytes = "WALLET-bytes-must-survive";
walletBytes.push_back('\0');
walletBytes += "\x01\x02 tail";
// (1) An unsafe entry: refused before anything is deleted.
{
const fs::path dir = makeTempDir();
const fs::path data = dir / "DRAGONX";
plantOldBootstrapChain(data, walletBytes);
const fs::path zip = dir / "unsafe.zip";
EXPECT_TRUE(writeBootstrapTestZip(zip, {{"blocks/new", "NEW"}, {"../evil", "x"}}));
Bootstrap b;
b.startFromZip(data.string(), zip.string(), noDaemon, plenty);
EXPECT_TRUE(waitBootstrapDone(b, 20.0));
const Bootstrap::Progress p = b.getProgress();
EXPECT_TRUE(p.state == Bootstrap::State::Failed);
EXPECT_TRUE(p.error.find("unsafe") != std::string::npos);
EXPECT_TRUE(p.cancellable);
EXPECT_TRUE(fs::exists(data / "blocks" / "old"));
EXPECT_TRUE(fs::exists(data / "chainstate" / "old"));
EXPECT_TRUE(!fs::exists(data / "blocks" / "new"));
EXPECT_TRUE(!fs::exists(dir / "evil"));
EXPECT_TRUE(fs::exists(zip)); // the download is kept
EXPECT_EQ(readProvisionFile(data / "wallet.dat"), walletBytes);
removeTree(dir);
}
// (2) A full disk (avail 0, capacity 100 GiB): refused, the old chain untouched.
{
const fs::path dir = makeTempDir();
const fs::path data = dir / "DRAGONX";
plantOldBootstrapChain(data, walletBytes);
const fs::path zip = dir / "full.zip";
EXPECT_TRUE(writeBootstrapTestZip(zip, {{"blocks/new", std::string(4096, 'n')},
{"chainstate/new", std::string(1024, 'c')}}));
std::atomic<int> spaceCalls{0};
Bootstrap b;
b.startFromZip(data.string(), zip.string(), noDaemon,
[&spaceCalls](const std::string&, std::error_code& ec) {
++spaceCalls;
ec.clear();
return bootstrapTestSpace(0, 100ull * 1024ull * 1024ull * 1024ull);
});
EXPECT_TRUE(waitBootstrapDone(b, 20.0));
const Bootstrap::Progress p = b.getProgress();
EXPECT_TRUE(p.state == Bootstrap::State::Failed);
EXPECT_TRUE(p.error.find("need") != std::string::npos);
EXPECT_TRUE(p.cancellable);
EXPECT_EQ(spaceCalls.load(), 1);
EXPECT_TRUE(fs::exists(data / "blocks" / "old"));
EXPECT_TRUE(fs::exists(data / "chainstate" / "old"));
EXPECT_TRUE(!fs::exists(data / "blocks" / "new"));
EXPECT_TRUE(fs::exists(zip));
EXPECT_EQ(readProvisionFile(data / "wallet.dat"), walletBytes);
removeTree(dir);
}
// (3) A corrupt critical member: the partial chain is removed BEFORE the failure is published.
{
const fs::path dir = makeTempDir();
const fs::path data = dir / "DRAGONX";
plantOldBootstrapChain(data, walletBytes);
const fs::path zip = dir / "corrupt.zip";
EXPECT_TRUE(writeBootstrapTestZip(zip, {{"chainstate/y", std::string(2048, 'y')},
{"blocks/x", std::string(4096, 'x')},
{"blocks/z", std::string(64, 'z')}},
MZ_NO_COMPRESSION));
// Flip one byte of blocks/x's stored data (its CRC no longer matches).
std::uint64_t headerOfs = 0;
{
mz_zip_archive zr;
std::memset(&zr, 0, sizeof(zr));
EXPECT_TRUE(mz_zip_reader_init_file(&zr, zip.string().c_str(), 0));
const int idx = mz_zip_reader_locate_file(&zr, "blocks/x", nullptr, 0);
mz_zip_archive_file_stat st;
EXPECT_TRUE(idx >= 0 && mz_zip_reader_file_stat(&zr, (mz_uint)idx, &st));
if (idx >= 0) headerOfs = st.m_local_header_ofs;
mz_zip_reader_end(&zr);
}
{
std::fstream f(zip, std::ios::in | std::ios::out | std::ios::binary);
unsigned char lh[30] = {};
f.seekg((std::streamoff)headerOfs);
f.read(reinterpret_cast<char*>(lh), sizeof(lh));
const std::uint64_t nameLen = lh[26] | (lh[27] << 8);
const std::uint64_t extraLen = lh[28] | (lh[29] << 8);
const std::uint64_t dataOfs = headerOfs + 30 + nameLen + extraLen + 100;
f.seekp((std::streamoff)dataOfs);
f.put('X');
}
Bootstrap b;
b.startFromZip(data.string(), zip.string(), noDaemon, plenty);
bool sawDone = false, chainGoneAtDone = false;
const auto t0 = std::chrono::steady_clock::now();
while (secondsSince(t0) < 20.0) { // tight poll: the first "done" must already see the clean slate
if (b.isDone()) {
chainGoneAtDone = bootstrapPathAbsent(data / "blocks") && bootstrapPathAbsent(data / "chainstate");
sawDone = true;
break;
}
std::this_thread::yield();
}
EXPECT_TRUE(sawDone);
EXPECT_TRUE(chainGoneAtDone);
const Bootstrap::Progress p = b.getProgress();
EXPECT_TRUE(p.state == Bootstrap::State::Failed);
EXPECT_EQ(p.error, std::string("Failed to extract critical file: blocks/x"));
EXPECT_TRUE(!p.cancellable);
EXPECT_TRUE(bootstrapPathAbsent(data / "blocks"));
EXPECT_TRUE(bootstrapPathAbsent(data / "chainstate"));
EXPECT_TRUE(fs::exists(zip)); // kept for a retry
EXPECT_EQ(readProvisionFile(data / "wallet.dat"), walletBytes);
removeTree(dir);
}
// (4) Success: not cancellable at the end, wallet.dat byte-identical, protected members never written.
{
const fs::path dir = makeTempDir();
const fs::path data = dir / "DRAGONX";
plantOldBootstrapChain(data, walletBytes);
const fs::path zip = dir / "good.zip";
EXPECT_TRUE(writeBootstrapTestZip(zip, {{"blocks/", ""},
{"blocks/blk00000.dat", std::string(3000, 'B')},
{"chainstate/000001.ldb", std::string(1500, 'C')},
{"peers.dat", "peers"},
{"wallet.dat", "EVIL-WALLET"},
{"wallet-cold.dat", "EVIL-COLD"},
{"DRAGONX.conf", "rpcpassword=evil"},
{"database/log.0000000001", "evil"},
{".cookie", "evil"}}));
Bootstrap b;
EXPECT_TRUE(b.getProgress().cancellable);
b.startFromZip(data.string(), zip.string(), noDaemon, plenty);
EXPECT_TRUE(waitBootstrapDone(b, 20.0));
const Bootstrap::Progress p = b.getProgress();
EXPECT_TRUE(p.state == Bootstrap::State::Completed);
EXPECT_TRUE(!p.cancellable);
EXPECT_TRUE(!fs::exists(data / "blocks" / "old"));
EXPECT_TRUE(!fs::exists(data / "chainstate" / "old"));
EXPECT_EQ(readProvisionFile(data / "blocks" / "blk00000.dat"), std::string(3000, 'B'));
EXPECT_TRUE(fs::exists(data / "chainstate" / "000001.ldb"));
EXPECT_TRUE(fs::exists(data / "peers.dat"));
EXPECT_EQ(readProvisionFile(data / "wallet.dat"), walletBytes);
EXPECT_TRUE(!fs::exists(data / "wallet-cold.dat"));
EXPECT_TRUE(!fs::exists(data / "DRAGONX.conf"));
EXPECT_TRUE(!fs::exists(data / "database"));
EXPECT_TRUE(!fs::exists(data / ".cookie"));
EXPECT_TRUE(!fs::exists(zip)); // removed after a successful extraction
removeTree(dir);
}
// (5) Unknown free space (the probe fails): fails open, as before.
{
const fs::path dir = makeTempDir();
const fs::path data = dir / "DRAGONX";
plantOldBootstrapChain(data, walletBytes);
const fs::path zip = dir / "unknown.zip";
EXPECT_TRUE(writeBootstrapTestZip(zip, {{"blocks/new", "NEW"}, {"chainstate/new", "NEW"}}));
Bootstrap b;
b.startFromZip(data.string(), zip.string(), noDaemon, [](const std::string&, std::error_code& ec) {
ec = std::make_error_code(std::errc::io_error);
return bootstrapTestSpace(static_cast<std::uintmax_t>(-1), static_cast<std::uintmax_t>(-1));
});
EXPECT_TRUE(waitBootstrapDone(b, 20.0));
EXPECT_TRUE(b.getProgress().state == Bootstrap::State::Completed);
EXPECT_TRUE(fs::exists(data / "blocks" / "new"));
EXPECT_EQ(readProvisionFile(data / "wallet.dat"), walletBytes);
removeTree(dir);
}
}
void testWriteFileAtomic()
{
using namespace dragonx::resources;
@@ -16315,6 +16663,12 @@ int main()
testVerifySaplingParams();
testStartupProvisionState();
testResourceNeedsWrite();
testBootstrapSpaceSufficient();
testBootstrapPlanArchive();
#ifndef _WIN32
testBootstrapReclaimable();
#endif
testBootstrapPreflightKeepsOldChain();
testWriteFileAtomic();
testCopyFileAtomic();
testBundleCopy();