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.