diff --git a/CMakeLists.txt b/CMakeLists.txt index 832548a..6f2beb0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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} ) diff --git a/src/app.cpp b/src/app.cpp index 51a1f96..7dcb64e 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -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. diff --git a/src/app_wizard.cpp b/src/app_wizard.cpp index c03ebb9..9f77170 100644 --- a/src/app_wizard.cpp +++ b/src/app_wizard.cpp @@ -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; diff --git a/src/ui/windows/bootstrap_download_dialog.h b/src/ui/windows/bootstrap_download_dialog.h index c002195..6769084 100644 --- a/src/ui/windows/bootstrap_download_dialog.h +++ b/src/ui/windows/bootstrap_download_dialog.h @@ -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(); diff --git a/src/util/bootstrap.cpp b/src/util/bootstrap.cpp index 35ea463..79ea592 100644 --- a/src/util/bootstrap.cpp +++ b/src/util/bootstrap.cpp @@ -9,6 +9,7 @@ #include #include +#include #include #include #include @@ -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(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(userp); @@ -91,124 +111,204 @@ Bootstrap::~Bootstrap() { // Public API // --------------------------------------------------------------------------- +void Bootstrap::beginRun() { + cancel_requested_ = false; + worker_running_ = true; + std::lock_guard 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(-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 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 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(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(-1) && + static_cast(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(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=.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(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(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(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; } // --------------------------------------------------------------------------- diff --git a/src/util/bootstrap.h b/src/util/bootstrap.h index 3740b41..797f3ad 100644 --- a/src/util/bootstrap.h +++ b/src/util/bootstrap.h @@ -9,6 +9,10 @@ #include #include #include +#include +#include +#include +#include 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; + using SpaceFn = std::function; + 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 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); diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 5cbd2f5..455f21f 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -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 #include #include +#define MINIZ_NO_ZLIB_COMPATIBLE_NAMES // only the mz_zip_* API is used; keep zlib-style names out of this file +#include 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>& 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 : 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::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 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(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(-1), static_cast(-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();