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:
+22
-5
@@ -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.
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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<bool> 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<std::pair<std::string, std::string>> 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<std::pair<std::string, std::string>> 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();
|
||||
|
||||
Reference in New Issue
Block a user