diff --git a/src/app.cpp b/src/app.cpp index 0cb3a0c..5f5adcf 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -6489,6 +6489,10 @@ void App::beginShutdown() stop_user_choice_ = 0; stop_kill_offered_ = false; shutdown_start_time_ = std::chrono::steady_clock::now(); + // A running bootstrap starts its abort now, while the shutdown screen is up: one that is replacing the chain + // data removes the partial chain first (shouldQuit waits for it), not under main()'s exit watchdog. + if (bootstrap_) bootstrap_->cancel(); + ui::BootstrapDownloadDialog::cancelForShutdown(); // Queue the final tx-history snapshot now so its write overlaps the daemon stop; shutdown() drains it. storeTransactionHistoryCacheIfAvailable(data::HistoryStoreMode::Now); async_tasks_.cancelAll(); @@ -6589,6 +6593,13 @@ void App::beginShutdown() }); } +bool App::bootstrapQuitPending() const +{ + // Capped: a chain-data removal that is still running after this is left to the bounded join in shutdown(). + if (std::chrono::steady_clock::now() - shutdown_start_time_ >= std::chrono::minutes(2)) return false; + return (bootstrap_ && !bootstrap_->safeToAbandon()) || ui::BootstrapDownloadDialog::quitPending(); +} + std::vector App::tailDaemonDebugLog(int maxLines) const { std::vector out; @@ -6938,7 +6949,9 @@ void App::renderShutdownScreen() // accumulated delta would count the idle gap before the close request as shutdown time. shutdown_timer_ = std::chrono::duration(std::chrono::steady_clock::now() - shutdown_start_time_).count(); - const std::string curShut = shutdown_status_.get(); // one consistent snapshot per frame (M-05) + // One consistent snapshot per frame (M-05). A cancelled bootstrap removing its partial chain holds the + // screen (bootstrapQuitPending): say so rather than a stale daemon status. + const std::string curShut = bootstrapQuitPending() ? std::string(TR("appx_cleaning_up")) : shutdown_status_.get(); // After a few seconds, offer to close the wallet and leave the node running: stopping can take as long // as the node needs (a witness rebuild can hold it for an hour), and it is never killed unasked. const bool allowChoices = shutdown_timer_ >= 3.0f; @@ -7646,12 +7659,16 @@ void App::shutdown() // (ImGui context is still alive here; App::shutdown() runs before ImGui::DestroyContext().) clearSecretClipboardIfArmed(); - // Clean up bootstrap if running + // Clean up bootstrap if running. Bounded: main()'s 15 s watchdog is running. On the normal path + // beginShutdown() cancelled it and the shutdown screen waited out a chain-data replacement + // (bootstrapQuitPending), so this returns at once; a worker still running at the deadline is left to the + // process exit (never destroyed: ~Bootstrap would join). + const auto bootstrapDeadline = std::chrono::steady_clock::now() + std::chrono::seconds(3); if (bootstrap_) { - bootstrap_->cancel(); - bootstrap_.reset(); + if (bootstrap_->cancelAndWait(bootstrapDeadline)) bootstrap_.reset(); + else (void)bootstrap_.release(); } - ui::BootstrapDownloadDialog::abortForShutdown(); // the Settings dialog's own bootstrap (both exit paths) + ui::BootstrapDownloadDialog::abortForShutdown(bootstrapDeadline); // the Settings dialog's own (both exit paths) // If beginShutdown() was never called (e.g. direct exit), // do synchronous shutdown as fallback. diff --git a/src/app.h b/src/app.h index 349e68d..4307950 100644 --- a/src/app.h +++ b/src/app.h @@ -180,9 +180,16 @@ public: bool shouldQuit() const { if (!quit_requested_ || !shutdown_complete_) return false; auto elapsed = std::chrono::steady_clock::now() - shutdown_start_time_; - return elapsed >= std::chrono::seconds(1); + return elapsed >= std::chrono::seconds(1) && !bootstrapQuitPending(); } + /** + * @brief A bootstrap cancelled by beginShutdown() is still replacing the chain data (its abort removes the + * partial chain). The shutdown screen stays up until it is done (capped), so main()'s exit watchdog never + * cuts it off. + */ + bool bootstrapQuitPending() const; + /** * @brief Request application exit — begins async shutdown */ diff --git a/src/app_wizard.cpp b/src/app_wizard.cpp index 9f77170..4b9d387 100644 --- a/src/app_wizard.cpp +++ b/src/app_wizard.cpp @@ -829,7 +829,7 @@ void App::renderFirstRunWizard() { // 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(); + bootstrap_->requestCancel(); // a no-op once the old chain data is being replaced } ImGui::EndDisabled(); ImGui::PopStyleVar(); diff --git a/src/ui/windows/bootstrap_download_dialog.h b/src/ui/windows/bootstrap_download_dialog.h index 4865591..57f5a77 100644 --- a/src/ui/windows/bootstrap_download_dialog.h +++ b/src/ui/windows/bootstrap_download_dialog.h @@ -4,6 +4,7 @@ #pragma once +#include #include #include #include "../../app.h" @@ -50,14 +51,22 @@ 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(); - } + // Quit, from beginShutdown(): cancel a running Settings bootstrap without waiting for it. + static void cancelForShutdown() { + if (s_bootstrap) s_bootstrap->cancel(); + } + + // Quit: true while a cancelled Settings bootstrap is still replacing the chain data. Its abort removes the + // partial chain; the shutdown screen stays up until that is done, so main()'s exit watchdog never cuts it. + static bool quitPending() { return s_bootstrap && !s_bootstrap->safeToAbandon(); } + + // Quit, from App::shutdown(): cancel a running Settings bootstrap and wait for its worker until `deadline` + // (main() ends in _Exit, and its 15 s watchdog is already running). A worker still running then is left to + // the process exit and never destroyed (~Bootstrap would join). + static void abortForShutdown(std::chrono::steady_clock::time_point deadline) { + if (!s_bootstrap) return; + if (s_bootstrap->cancelAndWait(deadline)) s_bootstrap.reset(); + else (void)s_bootstrap.release(); } static void render() { @@ -226,8 +235,8 @@ private: float btnW = 100.0f * dp; ImGui::BeginDisabled(s_cancelling || !prog.cancellable); if (TactileButton(TR("cancel"), ImVec2(btnW, 0))) { - s_cancelling = true; - s_bootstrap->cancel(); + // Refused (no-op) if the destructive window started after this frame read prog.cancellable. + s_cancelling = s_bootstrap->requestCancel(); } ImGui::EndDisabled(); diff --git a/src/util/bootstrap.cpp b/src/util/bootstrap.cpp index 79ea592..a2935af 100644 --- a/src/util/bootstrap.cpp +++ b/src/util/bootstrap.cpp @@ -32,29 +32,84 @@ static bool endsWith(const std::string& s, const std::string& suffix) { return s.compare(s.size() - suffix.size(), suffix.size(), suffix) == 0; } -// Reject archive member names that would escape the extraction root (zip-slip). -// The snapshot legitimately carries sub-paths (blocks/, chainstate/), so — unlike the -// updaters, which flatten to baseName() — we keep the relative path but refuse any entry -// that is absolute, drive/UNC-rooted, or contains a ".." component. -static bool isSafeArchivePath(const std::string& name) { - if (name.empty()) return false; +// The components of an archive member name as the file system resolves them, so that the zip-slip check +// and the deny-list judge the path a member really lands on. '\' is a separator, empty and "." components +// are dropped ("./wallet.dat" is wallet.dat), and each component loses its trailing dots and spaces, which +// Win32 strips ("DRAGONX.conf." opens DRAGONX.conf). Anything from a ':' on is cut off: an NTFS alternate +// data stream ("DRAGONX.conf::$DATA" is the file itself). The Windows rules apply on every platform; a +// chain snapshot carries none of these names, so treating them alike everywhere costs nothing. +// Returns false (unsafe) for an absolute or drive-rooted name, a ".." component, any ':' (drive letter, +// ADS), an 8.3 short name ("DRAGON~1.CON" can open DRAGONX.conf), a component that the stripping +// empties ("...", ".. "), and any byte >= 0x80 or ASCII control character. A chain snapshot's names are +// plain ASCII, while macOS APFS/HFS+ fold or ignore some Unicode ("databa\u017Fe" is database, +// ".coo\u212Aie" is .cookie, "DRAGONX.co\u200Cnf" is DRAGONX.conf), which no byte-wise check here could +// follow. *comps is filled either way. +static bool canonicalArchivePath(const std::string& name, std::vector* comps) { + std::vector out; + bool safe = !name.empty(); + for (unsigned char c : name) + if (c < 0x20 || c >= 0x7f) safe = false; // control character (incl. DEL) or non-ASCII std::string n = name; std::replace(n.begin(), n.end(), '\\', '/'); // normalize Windows separators - if (n.front() == '/') return false; // absolute POSIX path - const char c0 = n[0]; - if (n.size() >= 2 && n[1] == ':' && - ((c0 >= 'A' && c0 <= 'Z') || (c0 >= 'a' && c0 <= 'z'))) - return false; // Windows drive letter (C:...) + if (!n.empty() && n.front() == '/') safe = false; // absolute POSIX path, UNC (\\server) size_t start = 0; - while (start <= n.size()) { + 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 == "..") return false; // path traversal component + std::string comp = n.substr(start, slash == std::string::npos ? std::string::npos : slash - start); + if (comp == "..") { + safe = false; // path traversal component + } else if (!comp.empty() && comp != ".") { + const size_t colon = comp.find(':'); + if (colon != std::string::npos) { // drive letter (C:...) or alternate data stream + safe = false; + comp.erase(colon); + } + for (size_t t = comp.find('~'); t != std::string::npos; t = comp.find('~', t + 1)) + if (t + 1 < comp.size() && comp[t + 1] >= '0' && comp[t + 1] <= '9') safe = false; // 8.3 alias + while (!comp.empty() && (comp.back() == '.' || comp.back() == ' ')) comp.pop_back(); + if (comp.empty()) safe = false; // "...", ".. ", " ": no name of its own + else out.push_back(comp); + } if (slash == std::string::npos) break; start = slash + 1; } - return true; + if (comps) *comps = std::move(out); + return safe; +} + +// Reject archive member names that would escape the extraction root (zip-slip) or that a Windows file +// system would resolve onto another name (see canonicalArchivePath). The snapshot legitimately carries +// sub-paths (blocks/, chainstate/), so — unlike the updaters, which flatten to baseName() — we keep the +// relative path. +static bool isSafeArchivePath(const std::string& name) { + return canonicalArchivePath(name, nullptr); +} + +// A member name as it may appear in an error or a log line: bytes outside printable ASCII become \xNN, so a +// refused name cannot break the log into forged lines or carry terminal escapes. +static std::string printableArchiveName(const std::string& name) { + static const char kHex[] = "0123456789ABCDEF"; + std::string out; + out.reserve(name.size()); + for (unsigned char c : name) { + if (c >= 0x20 && c < 0x7f) { + out.push_back(static_cast(c)); + } else { + out += "\\x"; + out.push_back(kHex[c >> 4]); + out.push_back(kHex[c & 0xF]); + } + } + return out; +} + +static std::string joinArchivePath(const std::vector& comps) { + std::string p; + for (const std::string& c : comps) { + if (!p.empty()) p += '/'; + p += c; + } + return p; } static std::uint64_t satAdd(std::uint64_t a, std::uint64_t b) { @@ -76,6 +131,12 @@ static fs::space_info realSpace(const std::string& path, std::error_code& ec) { return fs::space(path, ec); } +// HEAD and checksum transfers: end at once on cancel. libcurl calls this about once a second even while it +// resolves or connects, so a quit never waits out their 15-30 s timeouts. +static int abortOnCancelCallback(void* clientp, curl_off_t, curl_off_t, curl_off_t, curl_off_t) { + return static_cast*>(clientp)->load() ? 1 : 0; +} + static size_t writeFileCallback(void* contents, size_t size, size_t nmemb, void* userp) { size_t total = size * nmemb; FILE* fp = static_cast(userp); @@ -274,12 +335,15 @@ void Bootstrap::installFromZip(const std::string& dataDir, const std::string& zi } // 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. + // in extract), and a failure removes the partial chain again before the result is published. The flip and + // the re-check share requestCancel()'s lock: a user cancel lands before this point or is refused. + bool cancelledBeforeWindow = false; { std::lock_guard lk(mutex_); progress_.cancellable = false; + cancelledBeforeWindow = cancel_requested_; } - if (cancel_requested_) { + if (cancelledBeforeWindow) { setProgress(State::Failed, "Cancelled - the old chain data was not touched"); return; } @@ -313,6 +377,32 @@ void Bootstrap::cancel() { cancel_requested_ = true; } +bool Bootstrap::requestCancel() { + std::lock_guard lk(mutex_); + if (!progress_.cancellable) return false; // the old chain data is being replaced: a no-op + cancel_requested_ = true; + return true; +} + +bool Bootstrap::safeToAbandon() const { + std::lock_guard lk(mutex_); + return !worker_running_ || (cancel_requested_ && progress_.cancellable); +} + +bool Bootstrap::cancelAndWait(std::chrono::steady_clock::time_point deadline) { + cancel(); + while (worker_running_ && std::chrono::steady_clock::now() < deadline) + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + if (!worker_running_) { + if (worker_.joinable()) worker_.join(); // at once: worker_running_ clears as the worker's last action + return true; + } + LOGF("[Bootstrap] Still running at quit - left to the process exit (%s)\n", + safeToAbandon() ? "the chain data was not touched" + : "the chain data may be left part-replaced; dragonxd will offer a reindex"); + return false; +} + Bootstrap::Progress Bootstrap::getProgress() const { std::lock_guard lk(mutex_); return progress_; @@ -357,6 +447,9 @@ long long Bootstrap::getRemoteFileSize(const std::string& url) { curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 10L); curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L); curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L); + curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L); + curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, abortOnCancelCallback); + curl_easy_setopt(curl, CURLOPT_XFERINFODATA, &cancel_requested_); CURLcode res = curl_easy_perform(curl); if (res != CURLE_OK) { @@ -418,6 +511,10 @@ bool Bootstrap::download(const std::string& url, const std::string& destZip) { // 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 (cancel_requested_) { // the HEAD was cut short: keep a partial file for the next resume + curl_easy_cleanup(curl); + return false; + } if (existing_bytes > 0) { if (remoteSize > 0) { if (existing_bytes == remoteSize) { @@ -530,27 +627,20 @@ bool Bootstrap::download(const std::string& url, const std::string& destZip) { // --------------------------------------------------------------------------- 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); + // Judged on the name the member lands on: "./wallet.dat", "DRAGONX.conf." and "database./log.1" are + // wallet.dat, DRAGONX.conf and database/log.1 (a trailing '/' of a directory entry is an empty component). + std::vector comps; + canonicalArchivePath(name, &comps); // an unsafe name is refused separately (isSafeArchivePath) + if (comps.empty()) return false; + for (std::string& comp : comps) { + comp = lowerAscii(comp); // Windows/macOS data dirs are case-insensitive 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); + const std::string& base = comps.back(); 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") && + if (comps.size() == 1 && endsWith(base, ".dat") && base != "fee_estimates.dat" && base != "peers.dat" && base != "banlist.dat") return true; return false; @@ -574,7 +664,7 @@ Bootstrap::ArchivePlan Bootstrap::planArchive(const std::string& zipPath) { } const std::string filename = stat.m_filename; if (!isSafeArchivePath(filename)) { - plan.error = "Refusing unsafe archive entry: " + filename; + plan.error = "Refusing unsafe archive entry: " + printableArchiveName(filename); mz_zip_reader_end(&zip); return plan; } @@ -638,7 +728,8 @@ bool Bootstrap::spaceSufficient(std::uint64_t needed, std::uint64_t avail, std:: // --------------------------------------------------------------------------- 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. + // Never publishes a failure or a final state (only Extracting progress): the caller removes a partial chain first + // and publishes the outcome last. auto fail = [err](const std::string& why) { if (err) *err = why; return false; @@ -666,10 +757,11 @@ bool Bootstrap::extract(const std::string& zipPath, const std::string& dataDir, // *** SECURITY: reject zip-slip / path-traversal entries before building any path *** // A legitimate snapshot from the project host never contains these; an entry that does // 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()); + std::vector comps; + if (!canonicalArchivePath(filename, &comps)) { + DEBUG_LOGF("[Bootstrap] Unsafe archive path rejected: %s\n", printableArchiveName(filename).c_str()); mz_zip_reader_end(&zip); - return fail("Refusing to extract unsafe archive entry: " + filename); + return fail("Refusing to extract unsafe archive entry: " + printableArchiveName(filename)); } // *** CRITICAL: Skip wallet.dat (and every other wallet*, database/, *.conf, .cookie entry) *** @@ -677,12 +769,15 @@ bool Bootstrap::extract(const std::string& zipPath, const std::string& dataDir, LOGF("[Bootstrap] Skipping protected archive entry: %s\n", filename.c_str()); continue; } + // The member is written where it was checked: the canonical path ("./blocks/x" is blocks/x). + const std::string relPath = joinArchivePath(comps); + if (relPath.empty()) continue; // "./": the data dir itself std::string destPath = dataDir; // Ensure trailing separator if (!destPath.empty() && destPath.back() != '/' && destPath.back() != '\\') destPath += '/'; - destPath += filename; + destPath += relPath; if (mz_zip_reader_is_file_a_directory(&zip, i)) { std::error_code ec; @@ -695,7 +790,7 @@ bool Bootstrap::extract(const std::string& zipPath, const std::string& dataDir, if (!mz_zip_reader_extract_to_file(&zip, i, destPath.c_str(), 0)) { 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) { + if (relPath.rfind("blocks/", 0) == 0 || relPath.rfind("chainstate/", 0) == 0) { mz_zip_reader_end(&zip); return fail("Failed to extract critical file: " + filename); } @@ -791,6 +886,9 @@ std::string Bootstrap::downloadSmallFile(const std::string& url) { curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 15L); curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L); curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L); + curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L); + curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, abortOnCancelCallback); + curl_easy_setopt(curl, CURLOPT_XFERINFODATA, &cancel_requested_); CURLcode res = curl_easy_perform(curl); curl_easy_cleanup(curl); diff --git a/src/util/bootstrap.h b/src/util/bootstrap.h index 797f3ad..a28d206 100644 --- a/src/util/bootstrap.h +++ b/src/util/bootstrap.h @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -86,9 +87,24 @@ public: void startFromZip(const std::string& dataDir, const std::string& zipPath, DaemonUpFn daemonUp = {}, SpaceFn spaceFn = {}); - /// Cancel an in-progress download/extract. + /// Cancel an in-progress download/extract. Unconditional: for quit and the destructor, which may abort + /// the destructive window too (the partial chain is then removed again). void cancel(); + /// The user's Cancel: refused (returns false, nothing changes) once the destructive window has started + /// (Progress::cancellable false). The window's start re-checks the request under the same lock, so a + /// click either lands before the old chain data is touched or not at all. + bool requestCancel(); + + /// Quit: true once leaving the worker to the process exit can no longer leave a part-replaced chain: it + /// has finished, or it is cancelled and has not opened the destructive window (the window's start + /// re-checks the cancel under the same lock, so it never will). + bool safeToAbandon() const; + + /// Quit: cancel() and wait for the worker until `deadline`. True = finished (the object can be destroyed); + /// false = still running, and the caller must leave it to the process exit (destroying it would join). + bool cancelAndWait(std::chrono::steady_clock::time_point deadline); + /// Thread-safe read of current progress. Progress getProgress() const; @@ -99,7 +115,8 @@ public: /// 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 + /// Preflight: open the zip, refuse it if any entry is unsafe (zip-slip; also any ':', an 8.3 "~N" + /// alias, or a component of only dots/spaces other than ".") 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); @@ -115,7 +132,9 @@ public: 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. + /// top-level *.dat other than fee_estimates/peers/banlist. Judged case-insensitively on the name the + /// member lands on: "." components dropped, trailing dots/spaces and an NTFS stream suffix (":...") + /// stripped from each component, so "./wallet.dat" and "DRAGONX.conf." are protected too. static bool isProtectedEntry(const std::string& name); private: diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index f6c8666..46f10f6 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -3716,6 +3716,55 @@ void testBootstrapPlanArchive() 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)); + + // Names that land on a protected file once "." components, trailing dots/spaces and an NTFS stream + // suffix are resolved (Win32 does all three; the check applies them everywhere). + for (const char* n : {"./foo.dat", "./wallet.dat", ".//./wallet.dat", ".\\wallet.dat", "DRAGONX.conf.", + "DRAGONX.conf ", "DRAGONX.conf::$DATA", "database./log.1", "database /log.1", + "./database/", "blocks/./wallet.dat", "./.cookie", "./sub/x.conf. . "}) + EXPECT_TRUE(Bootstrap::isProtectedEntry(n) || (std::cerr << " not protected: " << n << "\n", false)); + for (const char* n : {"./blocks/./index/000001.ldb", "./peers.dat", "./chainstate/", "./", "."}) + EXPECT_TRUE(!Bootstrap::isProtectedEntry(n) || (std::cerr << " protected: " << n << "\n", false)); + + // The resolved names in a plan: skipped like the plain names; the nested safe path counts. + const fs::path dotted = dir / "dotted.zip"; + EXPECT_TRUE(writeBootstrapTestZip(dotted, { + {"./foo.dat", "x"}, + {"./wallet.dat", "xx"}, + {"DRAGONX.conf.", "xxx"}, + {"database./log.1", "xxxx"}, + {"./blocks/./index/000001.ldb", std::string(700, 'i')}, + })); + plan = Bootstrap::planArchive(dotted.string()); + EXPECT_TRUE(plan.ok); + EXPECT_EQ(plan.bytes, (std::uint64_t)700); + + // Names a Windows file system would resolve onto another file, or that only it would accept: refused. + // Non-ASCII and control bytes: refused too. macOS APFS/HFS+ fold U+017F to 's' and U+212A (Kelvin) to 'k' + // and ignore U+200C, so these three land on database/, .cookie and DRAGONX.conf there. + for (const char* n : {"DRAGONX.conf::$DATA", "C:x.dat", "blocks/a:b", "DRAGON~1.CON", "DATABA~1/log.1", + ".. /evil", "blocks/.../x", "blocks/ /x", "..\\evil", + "databa\xC5\xBF" "e/log.1", // "databa\u017Fe/log.1" + ".coo\xE2\x84\xAA" "ie", // ".coo\u212Aie" + "DRAGONX.co\xE2\x80\x8C" "nf", // "DRAGONX.co\u200Cnf" + "blocks/blk\x01" "00000.dat", "blocks/a\nb", "blocks/a\tb", "blocks/a\x7F" "b", + "blocks/caf\xC3\xA9"}) { + const fs::path z = dir / "refused.zip"; + EXPECT_TRUE(writeBootstrapTestZip(z, {{"blocks/a", "b"}, {n, "x"}})); + plan = Bootstrap::planArchive(z.string()); + EXPECT_TRUE((!plan.ok && plan.error.find("unsafe") != std::string::npos) || + (std::cerr << " not refused: " << n << "\n", false)); + } + // The refused name is echoed with its non-printable bytes escaped, never raw. + { + const fs::path z = dir / "refused.zip"; + EXPECT_TRUE(writeBootstrapTestZip(z, {{"blocks/a\n[Bootstrap] forged\x1B[0m", "x"}})); + plan = Bootstrap::planArchive(z.string()); + EXPECT_TRUE(!plan.ok); + EXPECT_TRUE(plan.error.find("blocks/a\\x0A[Bootstrap] forged\\x1B[0m") != std::string::npos); + EXPECT_TRUE(std::none_of(plan.error.begin(), plan.error.end(), + [](char c) { return (unsigned char)c < 0x20 || (unsigned char)c >= 0x7f; })); + } removeTree(dir); } @@ -3918,6 +3967,220 @@ void testBootstrapPreflightKeepsOldChain() EXPECT_EQ(readProvisionFile(data / "wallet.dat"), walletBytes); removeTree(dir); } + + // (6) Members whose names resolve onto protected files ("./foo.dat" = a -wallet=foo.dat file, + // "DRAGONX.conf.", "database./log.1") are skipped like the plain names; a "./"-prefixed chain + // member is written to its resolved path. + { + const fs::path dir = makeTempDir(); + const fs::path data = dir / "DRAGONX"; + plantOldBootstrapChain(data, walletBytes); + { std::ofstream(data / "foo.dat", std::ios::binary) << "SECOND-WALLET"; } + { std::ofstream(data / "DRAGONX.conf", std::ios::binary) << "rpcpassword=mine"; } + fs::create_directories(data / "database"); + { std::ofstream(data / "database" / "log.1", std::ios::binary) << "MY-BDB-LOG"; } + const fs::path zip = dir / "dotted.zip"; + EXPECT_TRUE(writeBootstrapTestZip(zip, {{"./foo.dat", "EVIL-FOO"}, + {"./wallet.dat", "EVIL-WALLET"}, + {"DRAGONX.conf.", "blocknotify=evil"}, + {"DRAGONX.conf ", "blocknotify=evil"}, + {"database./log.1", "EVIL-LOG"}, + {"./blocks/./index/000001.ldb", "IDX"}, + {"chainstate/000001.ldb", "CS"}})); + Bootstrap b; + b.startFromZip(data.string(), zip.string(), noDaemon, plenty); + EXPECT_TRUE(waitBootstrapDone(b, 20.0)); + EXPECT_TRUE(b.getProgress().state == Bootstrap::State::Completed); + EXPECT_EQ(readProvisionFile(data / "foo.dat"), std::string("SECOND-WALLET")); + EXPECT_EQ(readProvisionFile(data / "wallet.dat"), walletBytes); + EXPECT_EQ(readProvisionFile(data / "DRAGONX.conf"), std::string("rpcpassword=mine")); + EXPECT_EQ(readProvisionFile(data / "database" / "log.1"), std::string("MY-BDB-LOG")); +#ifndef _WIN32 // Win32 resolves these names onto the real files checked above + EXPECT_TRUE(bootstrapPathAbsent(data / "DRAGONX.conf.")); + EXPECT_TRUE(bootstrapPathAbsent(data / "DRAGONX.conf ")); + EXPECT_TRUE(bootstrapPathAbsent(data / "database.")); +#endif + EXPECT_EQ(readProvisionFile(data / "blocks" / "index" / "000001.ldb"), std::string("IDX")); + EXPECT_EQ(readProvisionFile(data / "chainstate" / "000001.ldb"), std::string("CS")); + EXPECT_TRUE(!fs::exists(data / "blocks" / "old")); + removeTree(dir); + } +} + +// The user's Cancel (requestCancel) works until the old chain data starts being replaced, and is a no-op +// from then on; the start of that window re-checks a request made just before it. +void testBootstrapUserCancelWindow() +{ + using dragonx::util::Bootstrap; + const Bootstrap::SpaceFn plenty = [](const std::string&, std::error_code& ec) { + ec.clear(); + return bootstrapTestSpace(1ull << 40, 1ull << 41); + }; + const std::string walletBytes = "WALLET-cancel-window"; + + // (a) Before the window (waiting for the daemon): accepted, the old chain is kept. + { + const fs::path dir = makeTempDir(); + const fs::path data = dir / "DRAGONX"; + plantOldBootstrapChain(data, walletBytes); + const fs::path zip = dir / "a.zip"; + EXPECT_TRUE(writeBootstrapTestZip(zip, {{"blocks/new", "NEW"}, {"chainstate/new", "NEW"}})); + Bootstrap b; + b.startFromZip(data.string(), zip.string(), [] { return true; }, plenty); + EXPECT_TRUE(b.requestCancel()); + EXPECT_TRUE(waitBootstrapDone(b, 20.0)); + const Bootstrap::Progress p = b.getProgress(); + EXPECT_TRUE(p.state == Bootstrap::State::Failed); + EXPECT_TRUE(p.cancellable); + EXPECT_TRUE(fs::exists(data / "blocks" / "old")); + EXPECT_TRUE(!fs::exists(data / "blocks" / "new")); + EXPECT_EQ(readProvisionFile(data / "wallet.dat"), walletBytes); + removeTree(dir); + } + + // (b) A click that lands right before the window opens (here: during the free-space probe) is caught + // by the re-check: nothing is deleted. + { + const fs::path dir = makeTempDir(); + const fs::path data = dir / "DRAGONX"; + plantOldBootstrapChain(data, walletBytes); + const fs::path zip = dir / "b.zip"; + EXPECT_TRUE(writeBootstrapTestZip(zip, {{"blocks/new", "NEW"}, {"chainstate/new", "NEW"}})); + Bootstrap b; + std::atomic accepted{false}; + b.startFromZip(data.string(), zip.string(), [] { return false; }, + [&b, &accepted](const std::string&, std::error_code& ec) { + accepted = b.requestCancel(); + ec.clear(); + return bootstrapTestSpace(1ull << 40, 1ull << 41); + }); + EXPECT_TRUE(waitBootstrapDone(b, 20.0)); + EXPECT_TRUE(accepted.load()); + const Bootstrap::Progress p = b.getProgress(); + EXPECT_TRUE(p.state == Bootstrap::State::Failed); + EXPECT_TRUE(p.error.find("not touched") != std::string::npos); + 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)); + removeTree(dir); + } + + // (c) Inside the window: refused, and the run completes with the new chain. + { + const fs::path dir = makeTempDir(); + const fs::path data = dir / "DRAGONX"; + plantOldBootstrapChain(data, walletBytes); + const fs::path zip = dir / "c.zip"; + std::vector> entries; + for (int i = 0; i < 400; ++i) + entries.push_back({"blocks/blk" + std::to_string(i) + ".dat", std::string(4096, (char)('a' + i % 26))}); + entries.push_back({"chainstate/000001.ldb", "CS"}); + EXPECT_TRUE(writeBootstrapTestZip(zip, entries)); + Bootstrap b; + b.startFromZip(data.string(), zip.string(), [] { return false; }, plenty); + const auto t0 = std::chrono::steady_clock::now(); + while (b.getProgress().cancellable && !b.isDone() && secondsSince(t0) < 20.0) + std::this_thread::yield(); + EXPECT_TRUE(!b.getProgress().cancellable); + EXPECT_TRUE(!b.requestCancel()); // a no-op: the old chain data is being replaced + EXPECT_TRUE(waitBootstrapDone(b, 20.0)); + const Bootstrap::Progress p = b.getProgress(); + EXPECT_TRUE(p.state == Bootstrap::State::Completed); + EXPECT_TRUE(!fs::exists(data / "blocks" / "old")); + EXPECT_TRUE(fs::exists(data / "blocks" / "blk399.dat")); + EXPECT_TRUE(fs::exists(data / "chainstate" / "000001.ldb")); + EXPECT_TRUE(!b.requestCancel()); // still refused after the result is published + EXPECT_EQ(readProvisionFile(data / "wallet.dat"), walletBytes); + removeTree(dir); + } +} + +// Quit: cancelAndWait() is bounded, and safeToAbandon() is true only where the process exit can no longer +// leave a part-replaced chain (done, or cancelled before the destructive window). +void testBootstrapQuitWait() +{ + using dragonx::util::Bootstrap; + const Bootstrap::SpaceFn plenty = [](const std::string&, std::error_code& ec) { + ec.clear(); + return bootstrapTestSpace(1ull << 40, 1ull << 41); + }; + const std::string walletBytes = "WALLET-quit-wait"; + + { // nothing running + Bootstrap b; + EXPECT_TRUE(b.safeToAbandon()); + EXPECT_TRUE(b.cancelAndWait(std::chrono::steady_clock::now())); + } + + // (a) The worker is stuck in a slow step before the window: the wait ends at the deadline, the worker may be + // left to the process exit (it never touches the chain data), and it ends on its own later. + { + const fs::path dir = makeTempDir(); + const fs::path data = dir / "DRAGONX"; + plantOldBootstrapChain(data, walletBytes); + const fs::path zip = dir / "a.zip"; + EXPECT_TRUE(writeBootstrapTestZip(zip, {{"blocks/new", "NEW"}, {"chainstate/new", "NEW"}})); + Bootstrap b; + b.startFromZip(data.string(), zip.string(), [] { + std::this_thread::sleep_for(std::chrono::milliseconds(1500)); + return true; + }, plenty); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + const auto t0 = std::chrono::steady_clock::now(); + EXPECT_TRUE(!b.cancelAndWait(t0 + std::chrono::milliseconds(200))); + EXPECT_TRUE(secondsSince(t0) < 1.0); + EXPECT_TRUE(b.safeToAbandon()); + EXPECT_TRUE(waitBootstrapDone(b, 20.0)); + EXPECT_TRUE(b.getProgress().state == Bootstrap::State::Failed); + EXPECT_TRUE(fs::exists(data / "blocks" / "old")); + EXPECT_TRUE(fs::exists(data / "chainstate" / "old")); + EXPECT_TRUE(!fs::exists(data / "blocks" / "new")); + EXPECT_TRUE(b.cancelAndWait(std::chrono::steady_clock::now())); + removeTree(dir); + } + + // (b) Cancelled inside the window: not safe to abandon until the worker has finished, and by then the data + // dir holds no partial chain (aborted: blocks/ and chainstate/ removed; finished first: the full chain). + { + const fs::path dir = makeTempDir(); + const fs::path data = dir / "DRAGONX"; + plantOldBootstrapChain(data, walletBytes); + const fs::path zip = dir / "b.zip"; + std::vector> entries; + for (int i = 0; i < 400; ++i) + entries.push_back({"blocks/blk" + std::to_string(i) + ".dat", std::string(4096, (char)('a' + i % 26))}); + entries.push_back({"chainstate/000001.ldb", "CS"}); + EXPECT_TRUE(writeBootstrapTestZip(zip, entries)); + Bootstrap b; + b.startFromZip(data.string(), zip.string(), [] { return false; }, plenty); + const auto t0 = std::chrono::steady_clock::now(); + while (b.getProgress().cancellable && !b.isDone() && secondsSince(t0) < 20.0) + std::this_thread::yield(); + b.cancel(); + bool safe = false, doneWhenSafe = false, cleanWhenSafe = false; + while (secondsSince(t0) < 20.0) { // tight poll: the first "safe" must already see a settled data dir + if (b.safeToAbandon()) { + safe = true; + doneWhenSafe = b.isDone(); + const bool aborted = bootstrapPathAbsent(data / "blocks") && bootstrapPathAbsent(data / "chainstate"); + const bool complete = fs::exists(data / "blocks" / "blk399.dat") && + fs::exists(data / "chainstate" / "000001.ldb"); + cleanWhenSafe = aborted || complete; + break; + } + std::this_thread::yield(); + } + EXPECT_TRUE(safe); + EXPECT_TRUE(doneWhenSafe); + EXPECT_TRUE(cleanWhenSafe); + EXPECT_TRUE(b.cancelAndWait(std::chrono::steady_clock::now() + std::chrono::seconds(5))); + const Bootstrap::Progress p = b.getProgress(); + EXPECT_TRUE(p.state == Bootstrap::State::Failed || p.state == Bootstrap::State::Completed); + EXPECT_TRUE(!fs::exists(data / "blocks" / "old")); + EXPECT_EQ(readProvisionFile(data / "wallet.dat"), walletBytes); + removeTree(dir); + } } void testWriteFileAtomic() @@ -5784,6 +6047,31 @@ void testLongCallNoTimeout() EXPECT_TRUE(ok); } } + +// Quit during the bootstrap's HEAD request (a host that accepts and never answers): the cancel ends the +// request at once instead of after its 15 s timeout, and a partial download is kept for the next resume. +void testBootstrapCancelEndsHeadRequest() +{ + using dragonx::util::Bootstrap; + LoopbackRpcStub stub(-1); // accepts, never answers + EXPECT_TRUE(stub.port() > 0); + const fs::path dir = makeTempDir(); + const fs::path data = dir / "DRAGONX"; + fs::create_directories(data); + const std::string partial(1000, 'z'); + { std::ofstream(data / "bootstrap_tmp.zip", std::ios::binary) << partial; } + Bootstrap b; + b.start(data.string(), "http://127.0.0.1:" + std::to_string(stub.port()) + "/DRAGONX.zip"); + std::this_thread::sleep_for(std::chrono::milliseconds(300)); + const auto t0 = std::chrono::steady_clock::now(); + EXPECT_TRUE(b.cancelAndWait(t0 + std::chrono::seconds(10))); + EXPECT_TRUE(secondsSince(t0) < 3.0); + const Bootstrap::Progress p = b.getProgress(); + EXPECT_TRUE(p.state == Bootstrap::State::Failed); + EXPECT_EQ(p.error, std::string("Download cancelled")); + EXPECT_EQ(readProvisionFile(data / "bootstrap_tmp.zip"), partial); + removeTree(dir); +} #endif // !_WIN32 void testRpcKindClasses() @@ -16938,6 +17226,7 @@ int main() testLongCallLaneAbortBeforeOpen(); testLongCallLaneAbortDuringCall(); testLongCallNoTimeout(); + testBootstrapCancelEndsHeadRequest(); #endif testRpcKindClasses(); testConnectRetryBackoff(); @@ -17043,6 +17332,8 @@ int main() testBootstrapReclaimable(); #endif testBootstrapPreflightKeepsOldChain(); + testBootstrapUserCancelWindow(); + testBootstrapQuitWait(); testWriteFileAtomic(); testCopyFileAtomic(); testBundleCopy();