fix(bootstrap): harden archive names, the cancel window and quit wait

The bootstrap preflight's protected names could be bypassed by names
that resolve onto them, and its cancel and quit paths had two timing
gaps.

- Entry names are judged as the file system resolves them: '\' is a
  separator, "." components are dropped and each component loses its
  trailing dots and spaces, as Win32 does ("./wallet.dat" and
  "DRAGONX.conf." name protected files). The whole archive is refused
  for an absolute or drive-rooted name, a ".." component, any ':'
  (drive letter or NTFS alternate data stream, "DRAGONX.conf::$DATA"),
  an 8.3 short name ("DRAGON~1.CON"), a component of only dots or
  spaces (other than "."), and any byte >= 0x80 or ASCII control
  character: macOS folds or ignores some Unicode (U+017F, U+212A,
  U+200C) onto a protected name, and the published bootstrap archive
  uses plain ASCII names. A refused name is echoed
  with \xNN escapes. A member is written at the path it was checked
  under, and the protected-name check runs on the resolved name.
- The user's Cancel (Settings dialog and first-run wizard) goes through
  requestCancel(). It is refused once the old chain data starts being
  replaced, and the start of that window re-checks a cancel under the
  same lock, so a click lands before the old chain is touched or not at
  all.
- Quit cancels the wizard's and the Settings bootstrap when the
  shutdown starts; the HEAD and checksum requests now end at once on
  cancel, and a cancelled HEAD keeps the partial download. A run
  cancelled before the window leaves the old chain as it was. A run
  inside it stops extracting and removes its partial chain while the
  shutdown screen shows "Cleaning up...", so it ends with no chain data
  (wallet.dat untouched; Retry reuses the zip, or the node resyncs).
  That wait is bounded: at most 2 minutes from the start of the
  shutdown, then 3 s in shutdown(), after which a worker still running
  is left to the process exit. Only a cleanup that overruns that cap
  can leave a partial chain, which dragonxd then answers with its
  reindex offer.

Tests: refusal of the Windows-resolved, non-ASCII and control-character
names with the escaped echo; the protected-name list incl. the resolved
forms and a planted -wallet file, conf and database/ that stay
byte-identical; the user-cancel window; the bounded quit wait and
safeToAbandon(); a cancel ends a HEAD request that never answers.

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 a5185737e6
commit e7f084e3eb
7 changed files with 502 additions and 61 deletions
+22 -5
View File
@@ -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<std::string> App::tailDaemonDebugLog(int maxLines) const
{
std::vector<std::string> 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<float>(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.
+8 -1
View File
@@ -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
*/
+1 -1
View File
@@ -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();
+19 -10
View File
@@ -4,6 +4,7 @@
#pragma once
#include <chrono>
#include <string>
#include <memory>
#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();
+139 -41
View File
@@ -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<std::string>* comps) {
std::vector<std::string> 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<char>(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<std::string>& 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<const std::atomic<bool>*>(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<FILE*>(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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::string> 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=<name>.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<std::string> 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);
+22 -3
View File
@@ -8,6 +8,7 @@
#include <vector>
#include <mutex>
#include <atomic>
#include <chrono>
#include <thread>
#include <cstdint>
#include <filesystem>
@@ -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: