Files
ObsidianDragon/src/util/bootstrap.cpp
T
DanSandClaude Opus 5.5 e7f084e3eb 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>
2026-10-12 13:22:00 -05:00

1211 lines
50 KiB
C++

// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "bootstrap.h"
#include "../daemon/embedded_daemon.h"
#include <curl/curl.h>
#include <miniz.h>
#include <sodium.h>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <filesystem>
#include <algorithm>
#include <sstream>
#include <iomanip>
#include "../util/logger.h"
namespace fs = std::filesystem;
namespace dragonx {
namespace util {
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
static bool endsWith(const std::string& s, const std::string& suffix) {
if (suffix.size() > s.size()) return false;
return s.compare(s.size() - suffix.size(), suffix.size(), suffix) == 0;
}
// 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.empty() && n.front() == '/') safe = false; // absolute POSIX path, UNC (\\server)
size_t start = 0;
for (;;) {
const size_t slash = n.find('/', start);
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;
}
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) {
return (a > UINT64_MAX - b) ? UINT64_MAX : a + b;
}
static std::string lowerAscii(std::string s) {
for (char& c : s)
if (c >= 'A' && c <= 'Z') c = static_cast<char>(c - 'A' + 'a');
return s;
}
// The production probes: the daemon is gone only when its PROCESS has exited, not just its RPC port.
static bool realDaemonUp() {
return daemon::EmbeddedDaemon::isRpcPortInUse() || daemon::EmbeddedDaemon::isDaemonProcessRunning();
}
static fs::space_info realSpace(const std::string& path, std::error_code& ec) {
return fs::space(path, ec);
}
// 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);
return fwrite(contents, 1, total, fp);
}
std::string Bootstrap::formatSize(double bytes) {
const char* units[] = { "B", "KB", "MB", "GB", "TB" };
int idx = 0;
double v = bytes;
while (v >= 1024.0 && idx < 4) {
v /= 1024.0;
idx++;
}
char buf[64];
if (idx == 0)
snprintf(buf, sizeof(buf), "%.0f %s", v, units[idx]);
else
snprintf(buf, sizeof(buf), "%.2f %s", v, units[idx]);
return buf;
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
Bootstrap::~Bootstrap() {
cancel();
if (worker_.joinable()) worker_.join();
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
void Bootstrap::beginRun() {
cancel_requested_ = false;
worker_running_ = true;
std::lock_guard<std::mutex> lk(mutex_);
progress_ = Progress{};
}
void Bootstrap::start(const std::string& dataDir, const std::string& url) {
if (worker_running_) return; // already running
if (worker_.joinable()) worker_.join();
beginRun();
// Ensure data dir exists
std::error_code ec;
fs::create_directories(dataDir, ec);
worker_ = std::thread([this, dataDir, url]() {
runDownloadAndInstall(dataDir, url);
worker_running_ = false; // after the terminal publish: isDone() turns true only now
});
}
void Bootstrap::startFromZip(const std::string& dataDir, const std::string& zipPath,
DaemonUpFn daemonUp, SpaceFn spaceFn) {
if (worker_running_) return; // already running
if (worker_.joinable()) worker_.join();
beginRun();
if (!daemonUp) daemonUp = realDaemonUp;
if (!spaceFn) spaceFn = realSpace;
std::error_code ec;
fs::create_directories(dataDir, ec);
worker_ = std::thread([this, dataDir, zipPath, daemonUp, spaceFn]() {
installFromZip(dataDir, zipPath, daemonUp, spaceFn);
worker_running_ = false;
});
}
void Bootstrap::runDownloadAndInstall(const std::string& dataDir, const std::string& url) {
std::string zipPath = dataDir + "/bootstrap_tmp.zip";
// Step 1: Download (with resume support)
// Check for a partial file from a previous interrupted download.
{
std::error_code ec;
if (fs::exists(zipPath, ec) && fs::file_size(zipPath, ec) > 0 && !ec) {
auto partial = fs::file_size(zipPath, ec);
DEBUG_LOGF("[Bootstrap] Found partial download: %s (%s)\n",
zipPath.c_str(), formatSize((double)partial).c_str());
setProgress(State::Downloading,
"Resuming download (" + formatSize((double)partial) + " already on disk)...");
} else {
setProgress(State::Downloading, "Connecting to dragonx.is...");
}
}
if (!download(url, zipPath)) {
// Keep the partial file so the next attempt can resume.
// Only delete if the file is empty / doesn't exist.
std::error_code ec;
auto sz = fs::exists(zipPath, ec) ? fs::file_size(zipPath, ec) : 0;
if (sz == 0) fs::remove(zipPath, ec);
std::string why;
if (cancel_requested_)
why = "Download cancelled";
else if (!download_error_.empty())
why = download_error_;
else if (last_curl_ == CURLE_WRITE_ERROR)
why = (sz > 0 && sz != static_cast<std::uintmax_t>(-1))
? "Could not write the download to disk (disk full?) - kept " + zipPath + " (" +
formatSize((double)sz) + ") to resume"
: std::string("Could not write the download to disk (disk full?)");
else
why = "Download failed — check your internet connection";
setProgress(State::Failed, why); // last: after the partial-file cleanup
return;
}
if (cancel_requested_) {
setProgress(State::Failed, "Download cancelled");
// Keep partial file for resume on retry.
return;
}
// Step 2: Verify checksums (also for a reused complete download)
{
// Derive base URL from the zip URL (strip filename)
std::string baseUrl = url;
auto lastSlash = baseUrl.rfind('/');
if (lastSlash != std::string::npos)
baseUrl = baseUrl.substr(0, lastSlash);
if (!verifyChecksums(zipPath, baseUrl)) {
if (cancel_requested_) {
setProgress(State::Failed, "Verification cancelled"); // the download is kept
return;
}
// Checksum failure — delete the corrupt file so next attempt re-downloads
const std::string why = getProgress().error; // verifyChecksums' own reason
std::error_code ec;
fs::remove(zipPath, ec);
setProgress(State::Failed, why.empty() ? std::string("Checksum verification failed") : why);
return;
}
}
if (cancel_requested_) {
setProgress(State::Failed, "Verification cancelled");
return;
}
installFromZip(dataDir, zipPath, realDaemonUp, realSpace);
}
void Bootstrap::installFromZip(const std::string& dataDir, const std::string& zipPath,
const DaemonUpFn& daemonUp, const SpaceFn& spaceFn) {
// Step 3: Ensure daemon is fully stopped before touching chain data. Gone means the PROCESS has
// exited, not just its RPC port: dragonxd unbinds RPC early in its shutdown and keeps its files
// (and the datadir lock) until it exits — deleting chain data under it corrupts it, or fails on
// Windows' locked files.
{
setProgress(State::Extracting, "Waiting for daemon to stop...");
int waited = 0;
while (daemonUp() && waited < 120 && !cancel_requested_) {
std::this_thread::sleep_for(std::chrono::seconds(1));
waited++;
if (waited % 5 == 0)
DEBUG_LOGF("[Bootstrap] Still waiting for daemon to stop... (%ds)\n", waited);
}
if (cancel_requested_) {
setProgress(State::Failed, "Cancelled while waiting for daemon");
return;
}
if (daemonUp()) {
setProgress(State::Failed, "Daemon is still running — stop it before using bootstrap");
return;
}
}
// Step 3b: check the archive and the disk BEFORE anything is deleted. Every failure here keeps the
// old chain data and the download.
setProgress(State::Extracting, "Checking the download and free disk space...");
const ArchivePlan plan = planArchive(zipPath);
if (!plan.ok) {
LOGF("[Bootstrap] Preflight refused the archive: %s\n", plan.error.c_str());
setProgress(State::Failed, plan.error);
return;
}
{
std::error_code ec;
const fs::space_info sp = spaceFn(dataDir, ec);
std::string msg;
if (ec) {
LOGF("[Bootstrap] Free space unknown (%s) - not checked\n", ec.message().c_str());
} else if (!spaceSufficient(plan.bytes, sp.available, sp.capacity, reclaimableBytes(dataDir), &msg)) {
LOGF("[Bootstrap] Preflight: %s\n", msg.c_str());
setProgress(State::Failed, msg);
return;
}
}
// The destructive window starts: from here the user cannot cancel (quit still can, see cancel_requested_
// in extract), and a failure removes the partial chain again before the result is published. 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 (cancelledBeforeWindow) {
setProgress(State::Failed, "Cancelled - the old chain data was not touched");
return;
}
// Step 4: Clean old chain data (verified: a locked file must not leave old and new chain files mixed)
setProgress(State::Extracting, "Removing old chain data...");
std::string err;
bool ok = cleanChainData(dataDir);
if (!ok)
err = "Could not remove all of the old chain data (a file may be in use). Close other programs and retry.";
// Step 5: Extract (skipping wallet.dat and the other protected names)
if (ok && !extract(zipPath, dataDir, &err)) ok = false;
if (!ok) {
// Never leave a half-written blocks/ + chainstate/ for dragonxd to start on. wallet.dat, *.conf and
// database/ are untouched by cleanChainData.
cleanChainData(dataDir);
if (cancel_requested_)
err = "Bootstrap stopped - the old chain data had already been removed. Run it again (the download "
"is kept) or let the node resync.";
else if (err.empty())
err = "Extraction failed - zip file may be corrupted";
setProgress(State::Failed, err); // LAST: nothing touches the data dir after this
return;
}
setProgress(State::Completed, "Bootstrap complete!");
}
void Bootstrap::cancel() {
cancel_requested_ = true;
}
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_;
}
bool Bootstrap::isDone() const {
std::lock_guard<std::mutex> lk(mutex_);
// worker_running_ is cleared only after the worker's last action, so a failure that a step reported
// before its own cleanup (download, checksum) is not "done" until that cleanup has finished either.
return (progress_.state == State::Completed || progress_.state == State::Failed) && !worker_running_;
}
// ---------------------------------------------------------------------------
// Progress update (thread-safe)
// ---------------------------------------------------------------------------
void Bootstrap::setProgress(State state, const std::string& text, double downloaded, double total) {
std::lock_guard<std::mutex> lk(mutex_);
progress_.state = state;
progress_.status_text = text;
progress_.downloaded_bytes = downloaded;
progress_.total_bytes = total;
progress_.percent = (total > 0) ? (float)(100.0 * downloaded / total) : 0.0f;
if (state == State::Failed) {
progress_.error = text;
}
}
// ---------------------------------------------------------------------------
// HEAD request for remote file size
// ---------------------------------------------------------------------------
long long Bootstrap::getRemoteFileSize(const std::string& url) {
CURL* curl = curl_easy_init();
if (!curl) return -1;
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
curl_easy_setopt(curl, CURLOPT_NOBODY, 1L); // HEAD request
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(curl, CURLOPT_USERAGENT, "ObsidianDragon/1.0");
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 15L);
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) {
DEBUG_LOGF("[Bootstrap] HEAD request failed: %s\n", curl_easy_strerror(res));
curl_easy_cleanup(curl);
return -1;
}
curl_off_t cl = -1;
curl_easy_getinfo(curl, CURLINFO_CONTENT_LENGTH_DOWNLOAD_T, &cl);
curl_easy_cleanup(curl);
DEBUG_LOGF("[Bootstrap] Remote file size: %lld bytes\n", (long long)cl);
return (long long)cl;
}
// ---------------------------------------------------------------------------
// Download (libcurl)
// ---------------------------------------------------------------------------
int Bootstrap::progressCallback(void* clientp, long long dltotal, long long dlnow,
long long /*ultotal*/, long long /*ulnow*/) {
auto* self = static_cast<Bootstrap*>(clientp);
if (self->cancel_requested_) return 1; // abort transfer
// When resuming, dlnow/dltotal only reflect the *remaining* portion.
// Add resume_offset_ so the user sees true total progress.
double offset = self->resume_offset_;
double realNow = offset + (double)dlnow;
double realTotal = (dltotal > 0) ? (offset + (double)dltotal) : 0.0;
std::string sizeText = formatSize(realNow) + " / " +
(realTotal > 0 ? formatSize(realTotal) : "unknown");
self->setProgress(State::Downloading, sizeText, realNow, realTotal);
return 0;
}
bool Bootstrap::download(const std::string& url, const std::string& destZip) {
// Failures are reported through last_curl_ / download_error_; the caller publishes them.
last_curl_ = 0;
download_error_.clear();
CURL* curl = curl_easy_init();
if (!curl) {
download_error_ = "Failed to initialise libcurl";
return false;
}
// --- Resume support with integrity check ---
// Check if a partial file exists from a previous attempt.
long long existing_bytes = 0;
{
std::error_code ec;
if (fs::exists(destZip, ec) && !ec) {
existing_bytes = (long long)fs::file_size(destZip, ec);
if (ec) existing_bytes = 0;
}
}
// 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) {
// Complete from an earlier attempt (e.g. "free some space, then Retry"): reuse it instead of
// re-downloading several GB. verifyChecksums() still runs on it and deletes it on a mismatch.
DEBUG_LOGF("[Bootstrap] Local file is complete (%lld bytes) - reusing it\n", existing_bytes);
curl_easy_cleanup(curl);
resume_offset_ = 0;
setProgress(State::Downloading,
"Reusing the completed download (" + formatSize((double)existing_bytes) + ")",
(double)existing_bytes, (double)remoteSize);
return true;
}
if (existing_bytes > remoteSize) {
// Bigger than the remote file — stale from an older zip
DEBUG_LOGF("[Bootstrap] Local file (%lld bytes) > remote (%lld bytes), re-downloading\n",
existing_bytes, remoteSize);
std::error_code ec;
fs::remove(destZip, ec);
existing_bytes = 0;
}
} else {
// Could not determine remote size — play it safe and re-download
DEBUG_LOGF("[Bootstrap] Could not determine remote file size, re-downloading to be safe\n");
std::error_code ec;
fs::remove(destZip, ec);
existing_bytes = 0;
}
}
// Early, non-destructive check: the bytes still to download plus 256 MiB must fit (fails open when the
// size or the free space is unknown). Nothing is deleted; a partial file is kept for a later resume.
if (remoteSize > existing_bytes) {
const std::uint64_t need = satAdd(static_cast<std::uint64_t>(remoteSize - existing_bytes),
256ull * 1024ull * 1024ull);
std::error_code ec;
const fs::space_info sp = fs::space(fs::path(destZip).parent_path(), ec);
if (!ec && sp.capacity != 0 && sp.available != static_cast<std::uintmax_t>(-1) &&
static_cast<std::uint64_t>(sp.available) < need) {
curl_easy_cleanup(curl);
download_error_ = "Not enough disk space for the download: need " + formatSize((double)need) +
", have " + formatSize((double)sp.available) + " free. Free some space and retry.";
LOGF("[Bootstrap] %s\n", download_error_.c_str());
return false;
}
}
resume_offset_ = (double)existing_bytes;
FILE* fp = nullptr;
if (existing_bytes > 0) {
// Open in append-binary mode and tell curl to resume
fp = fopen(destZip.c_str(), "ab");
if (!fp) {
curl_easy_cleanup(curl);
download_error_ = "Failed to open output file for resume: " + destZip;
return false;
}
curl_easy_setopt(curl, CURLOPT_RESUME_FROM_LARGE, (curl_off_t)existing_bytes);
DEBUG_LOGF("[Bootstrap] Resuming download from byte %lld\n", existing_bytes);
} else {
fp = fopen(destZip.c_str(), "wb");
if (!fp) {
curl_easy_cleanup(curl);
download_error_ = "Failed to open output file: " + destZip;
return false;
}
}
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeFileCallback);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, fp);
curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L);
curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, progressCallback);
curl_easy_setopt(curl, CURLOPT_XFERINFODATA, this);
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(curl, CURLOPT_USERAGENT, "ObsidianDragon/1.0");
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 0L); // no timeout for large file
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 30L);
curl_easy_setopt(curl, CURLOPT_LOW_SPEED_LIMIT, 1024L); // abort if < 1 KB/s
curl_easy_setopt(curl, CURLOPT_LOW_SPEED_TIME, 60L); // ... for 60 seconds
// HTTPS certificate verification
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
CURLcode res = curl_easy_perform(curl);
fclose(fp);
curl_easy_cleanup(curl);
if (res == CURLE_RANGE_ERROR) {
// Server does not support range requests — restart from scratch
DEBUG_LOGF("[Bootstrap] Server does not support resume, restarting download\n");
resume_offset_ = 0;
std::error_code ec;
fs::remove(destZip, ec);
return download(url, destZip); // recursive retry without resume
}
if (res != CURLE_OK) {
DEBUG_LOGF("[Bootstrap] curl error: %s\n", curl_easy_strerror(res));
last_curl_ = static_cast<int>(res);
return false;
}
return true;
}
// ---------------------------------------------------------------------------
// Preflight helpers (pure, static)
// ---------------------------------------------------------------------------
bool Bootstrap::isProtectedEntry(const std::string& name) {
// 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
}
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 (comps.size() == 1 && endsWith(base, ".dat") &&
base != "fee_estimates.dat" && base != "peers.dat" && base != "banlist.dat")
return true;
return false;
}
Bootstrap::ArchivePlan Bootstrap::planArchive(const std::string& zipPath) {
ArchivePlan plan;
mz_zip_archive zip = {};
if (!mz_zip_reader_init_file(&zip, zipPath.c_str(), 0)) {
plan.error = "Failed to open zip file - the download may be corrupted";
return plan;
}
const mz_uint numFiles = mz_zip_reader_get_num_files(&zip);
for (mz_uint i = 0; i < numFiles; i++) {
mz_zip_archive_file_stat stat;
if (!mz_zip_reader_file_stat(&zip, i, &stat)) {
plan.error = "Unreadable archive entry #" + std::to_string(i) + " - the download may be corrupted";
mz_zip_reader_end(&zip);
return plan;
}
const std::string filename = stat.m_filename;
if (!isSafeArchivePath(filename)) {
plan.error = "Refusing unsafe archive entry: " + printableArchiveName(filename);
mz_zip_reader_end(&zip);
return plan;
}
if (mz_zip_reader_is_file_a_directory(&zip, i) || isProtectedEntry(filename)) continue;
plan.bytes = satAdd(plan.bytes, static_cast<std::uint64_t>(stat.m_uncomp_size));
}
mz_zip_reader_end(&zip);
plan.ok = true;
return plan;
}
std::uint64_t Bootstrap::reclaimableBytes(const std::string& dataDir) {
std::uint64_t total = 0;
for (const char* subdir : {"blocks", "chainstate", "notarizations"}) {
const fs::path p = fs::path(dataDir) / subdir;
std::error_code ec;
const fs::file_status st = fs::symlink_status(p, ec);
if (fs::is_symlink(st)) continue; // remove_all drops the link; the target's space is not freed here
if (fs::is_regular_file(st)) {
const auto sz = fs::file_size(p, ec);
if (!ec) total = satAdd(total, static_cast<std::uint64_t>(sz));
continue;
}
if (!fs::is_directory(st)) continue;
// Directory symlinks inside are not followed (no follow_directory_symlink); file symlinks count 0.
fs::recursive_directory_iterator it(p, fs::directory_options::skip_permission_denied, ec);
const fs::recursive_directory_iterator end;
while (!ec && it != end) {
std::error_code fec;
const fs::file_status est = it->symlink_status(fec);
if (!fec && fs::is_regular_file(est)) {
const auto sz = it->file_size(fec);
if (!fec) total = satAdd(total, static_cast<std::uint64_t>(sz));
}
it.increment(ec);
}
}
return total;
}
bool Bootstrap::spaceSufficient(std::uint64_t needed, std::uint64_t avail, std::uint64_t capacity,
std::uint64_t reclaimable, std::string* msg) {
// Unknown (e.g. some FUSE/network mounts): fail open. A known capacity with avail 0 is a full disk.
if (capacity == 0 || avail == UINT64_MAX) return true;
const std::uint64_t kMinMargin = 256ull * 1024ull * 1024ull;
const std::uint64_t pctMargin = needed / 20; // 5%
const std::uint64_t margin = (pctMargin > kMinMargin) ? pctMargin : kMinMargin;
const std::uint64_t need = satAdd(needed, margin);
const std::uint64_t have = satAdd(avail, reclaimable);
if (have >= need) return true;
if (msg) {
*msg = "Not enough disk space for the bootstrap: need " + formatSize((double)need) + ", have " +
formatSize((double)avail) + " free (+" + formatSize((double)reclaimable) +
" from the old chain data). Free some space and retry - the download is kept.";
}
return false;
}
// ---------------------------------------------------------------------------
// Zip extraction (miniz)
// ---------------------------------------------------------------------------
bool Bootstrap::extract(const std::string& zipPath, const std::string& dataDir, std::string* err) {
// 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;
};
mz_zip_archive zip = {};
if (!mz_zip_reader_init_file(&zip, zipPath.c_str(), 0))
return fail("Failed to open zip file");
int numFiles = (int)mz_zip_reader_get_num_files(&zip);
int nonCriticalFailures = 0;
for (int i = 0; i < numFiles; i++) {
if (cancel_requested_) {
mz_zip_reader_end(&zip);
return fail("Extraction cancelled");
}
mz_zip_archive_file_stat stat;
if (!mz_zip_reader_file_stat(&zip, i, &stat)) {
nonCriticalFailures++;
continue;
}
std::string filename = stat.m_filename;
// *** 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.
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: " + printableArchiveName(filename));
}
// *** CRITICAL: Skip wallet.dat (and every other wallet*, database/, *.conf, .cookie entry) ***
if (isProtectedEntry(filename)) {
LOGF("[Bootstrap] Skipping protected archive entry: %s\n", filename.c_str());
continue;
}
// 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 += relPath;
if (mz_zip_reader_is_file_a_directory(&zip, i)) {
std::error_code ec;
fs::create_directories(destPath, ec);
} else {
// Ensure parent directory exists
std::error_code ec;
fs::create_directories(fs::path(destPath).parent_path(), ec);
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 (relPath.rfind("blocks/", 0) == 0 || relPath.rfind("chainstate/", 0) == 0) {
mz_zip_reader_end(&zip);
return fail("Failed to extract critical file: " + filename);
}
// Non-fatal for other files: continue with remaining
nonCriticalFailures++;
}
}
// Update progress (percent = file count ratio)
setProgress(State::Extracting,
"Extracting: " + filename,
(double)(i + 1), (double)numFiles);
}
mz_zip_reader_end(&zip);
if (nonCriticalFailures > 0)
LOGF("[Bootstrap] %d non-critical archive member(s) could not be extracted\n", nonCriticalFailures);
// Clean up zip file
std::error_code ec;
fs::remove(zipPath, ec);
return true;
}
// ---------------------------------------------------------------------------
// Pre-extraction cleanup
// ---------------------------------------------------------------------------
bool Bootstrap::cleanChainData(const std::string& dataDir) {
// Directories to remove completely.
// NOTE: "database" is intentionally NOT removed here. It contains BDB
// environment/log files for wallet.dat. Deleting it while keeping wallet.dat
// creates a BDB LSN mismatch that causes the daemon to "salvage" the wallet
// (renaming it to wallet.{timestamp}.bak and creating an empty replacement).
// The daemon's DB_RECOVER flag in CDBEnv::Open() handles stale environment
// files gracefully when the environment dir still exists.
static const char* const kChainDirs[] = {"blocks", "chainstate", "notarizations"};
for (const char* subdir : kChainDirs) {
fs::path p = fs::path(dataDir) / subdir;
std::error_code ec;
// symlink_status: a symlinked (even dangling) entry is removed as a link, never followed.
if (fs::symlink_status(p, ec).type() != fs::file_type::not_found) {
fs::remove_all(p, ec);
if (ec) LOGF("[Bootstrap] Warning: could not remove %s: %s\n", subdir, ec.message().c_str());
}
}
// Individual files to remove (will be replaced by bootstrap)
for (const char* file : {"fee_estimates.dat", "peers.dat", "banlist.dat", "db.log", ".lock"}) {
fs::path p = fs::path(dataDir) / file;
std::error_code ec;
if (fs::exists(p, ec)) {
fs::remove(p, ec);
}
}
// NEVER remove: wallet.dat, debug.log, *.conf
// Verified, not assumed: a locked file (Windows: antivirus, a lingering handle) leaves part of the old
// chain behind, and a new chainstate extracted over leftover .ldb files is an inconsistent LevelDB.
bool clean = true;
for (const char* subdir : kChainDirs) {
std::error_code ec;
if (fs::symlink_status(fs::path(dataDir) / subdir, ec).type() != fs::file_type::not_found) {
LOGF("[Bootstrap] %s is still present after removing the old chain data\n", subdir);
clean = false;
}
}
return clean;
}
// ---------------------------------------------------------------------------
// Small-file download (checksums)
// ---------------------------------------------------------------------------
static size_t writeStringCallback(void* contents, size_t size, size_t nmemb, void* userp) {
size_t total = size * nmemb;
auto* str = static_cast<std::string*>(userp);
str->append(static_cast<const char*>(contents), total);
return total;
}
std::string Bootstrap::downloadSmallFile(const std::string& url) {
CURL* curl = curl_easy_init();
if (!curl) return {};
std::string result;
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeStringCallback);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &result);
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(curl, CURLOPT_USERAGENT, "ObsidianDragon/1.0");
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L);
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);
if (res != CURLE_OK) {
DEBUG_LOGF("[Bootstrap] Failed to download %s: %s\n", url.c_str(), curl_easy_strerror(res));
return {};
}
return result;
}
// ---------------------------------------------------------------------------
// Checksum helpers
// ---------------------------------------------------------------------------
std::string Bootstrap::parseChecksumFile(const std::string& content) {
// Typical format: "<hex_hash> <filename>" or just "<hex_hash>"
// Extract the first whitespace-delimited token.
std::istringstream iss(content);
std::string token;
if (iss >> token) {
// Normalise to lowercase
std::transform(token.begin(), token.end(), token.begin(),
[](unsigned char c) { return std::tolower(c); });
return token;
}
return {};
}
std::string Bootstrap::computeSHA256(const std::string& filePath, float pctBase, float pctRange) {
FILE* fp = fopen(filePath.c_str(), "rb");
if (!fp) return {};
// Get file size for progress reporting
fseek(fp, 0, SEEK_END);
long long fileSize = ftell(fp);
fseek(fp, 0, SEEK_SET);
crypto_hash_sha256_state state;
crypto_hash_sha256_init(&state);
unsigned char buf[65536];
size_t n;
long long processed = 0;
while ((n = fread(buf, 1, sizeof(buf), fp)) > 0) {
if (cancel_requested_) {
fclose(fp);
return {};
}
crypto_hash_sha256_update(&state, buf, n);
processed += (long long)n;
// Update progress every ~4MB
if (fileSize > 0 && (processed % (4 * 1024 * 1024)) < (long long)sizeof(buf)) {
float filePct = (float)(100.0 * processed / fileSize);
float overallPct = pctBase + pctRange * (float)processed / (float)fileSize;
char msg[128];
snprintf(msg, sizeof(msg), "Verifying SHA-256... %.0f%% (%s / %s)",
filePct, formatSize((double)processed).c_str(),
formatSize((double)fileSize).c_str());
{
std::lock_guard<std::mutex> lk(mutex_);
progress_.state = State::Verifying;
progress_.status_text = msg;
progress_.percent = overallPct;
}
}
}
fclose(fp);
unsigned char hash[crypto_hash_sha256_BYTES];
crypto_hash_sha256_final(&state, hash);
std::ostringstream oss;
oss << std::hex << std::setfill('0');
for (int i = 0; i < crypto_hash_sha256_BYTES; i++)
oss << std::setw(2) << (int)hash[i];
return oss.str();
}
// ---------------------------------------------------------------------------
// MD5 — minimal embedded implementation (RFC 1321)
// ---------------------------------------------------------------------------
namespace {
struct MD5Context {
uint32_t state[4];
uint64_t count;
uint8_t buffer[64];
};
static const uint32_t md5_T[64] = {
0xd76aa478,0xe8c7b756,0x242070db,0xc1bdceee,0xf57c0faf,0x4787c62a,0xa8304613,0xfd469501,
0x698098d8,0x8b44f7af,0xffff5bb1,0x895cd7be,0x6b901122,0xfd987193,0xa679438e,0x49b40821,
0xf61e2562,0xc040b340,0x265e5a51,0xe9b6c7aa,0xd62f105d,0x02441453,0xd8a1e681,0xe7d3fbc8,
0x21e1cde6,0xc33707d6,0xf4d50d87,0x455a14ed,0xa9e3e905,0xfcefa3f8,0x676f02d9,0x8d2a4c8a,
0xfffa3942,0x8771f681,0x6d9d6122,0xfde5380c,0xa4beea44,0x4bdecfa9,0xf6bb4b60,0xbebfbc70,
0x289b7ec6,0xeaa127fa,0xd4ef3085,0x04881d05,0xd9d4d039,0xe6db99e5,0x1fa27cf8,0xc4ac5665,
0xf4292244,0x432aff97,0xab9423a7,0xfc93a039,0x655b59c3,0x8f0ccc92,0xffeff47d,0x85845dd1,
0x6fa87e4f,0xfe2ce6e0,0xa3014314,0x4e0811a1,0xf7537e82,0xbd3af235,0x2ad7d2bb,0xeb86d391
};
static const int md5_S[64] = {
7,12,17,22,7,12,17,22,7,12,17,22,7,12,17,22,
5, 9,14,20,5, 9,14,20,5, 9,14,20,5, 9,14,20,
4,11,16,23,4,11,16,23,4,11,16,23,4,11,16,23,
6,10,15,21,6,10,15,21,6,10,15,21,6,10,15,21
};
static inline uint32_t md5_rotl(uint32_t x, int n) { return (x << n) | (x >> (32 - n)); }
static void md5_transform(uint32_t state[4], const uint8_t block[64]) {
uint32_t M[16];
for (int i = 0; i < 16; i++)
M[i] = (uint32_t)block[i*4] | ((uint32_t)block[i*4+1]<<8) |
((uint32_t)block[i*4+2]<<16) | ((uint32_t)block[i*4+3]<<24);
uint32_t a=state[0], b=state[1], c=state[2], d=state[3];
for (int i = 0; i < 64; i++) {
uint32_t f, g;
if (i < 16) { f = (b & c) | (~b & d); g = i; }
else if (i < 32) { f = (d & b) | (~d & c); g = (5*i+1) % 16; }
else if (i < 48) { f = b ^ c ^ d; g = (3*i+5) % 16; }
else { f = c ^ (b | ~d); g = (7*i) % 16; }
uint32_t tmp = d; d = c; c = b;
b = b + md5_rotl(a + f + md5_T[i] + M[g], md5_S[i]);
a = tmp;
}
state[0]+=a; state[1]+=b; state[2]+=c; state[3]+=d;
}
static void md5_init(MD5Context* ctx) {
ctx->state[0]=0x67452301; ctx->state[1]=0xefcdab89;
ctx->state[2]=0x98badcfe; ctx->state[3]=0x10325476;
ctx->count = 0;
memset(ctx->buffer, 0, 64);
}
static void md5_update(MD5Context* ctx, const uint8_t* data, size_t len) {
size_t idx = (size_t)(ctx->count % 64);
ctx->count += len;
for (size_t i = 0; i < len; i++) {
ctx->buffer[idx++] = data[i];
if (idx == 64) { md5_transform(ctx->state, ctx->buffer); idx = 0; }
}
}
static void md5_final(MD5Context* ctx, uint8_t digest[16]) {
uint64_t bits = ctx->count * 8;
uint8_t pad = 0x80;
md5_update(ctx, &pad, 1);
pad = 0;
while (ctx->count % 64 != 56) md5_update(ctx, &pad, 1);
uint8_t bitbuf[8];
for (int i = 0; i < 8; i++) bitbuf[i] = (uint8_t)(bits >> (i*8));
md5_update(ctx, bitbuf, 8);
for (int i = 0; i < 4; i++) {
digest[i*4+0] = (uint8_t)(ctx->state[i]);
digest[i*4+1] = (uint8_t)(ctx->state[i]>>8);
digest[i*4+2] = (uint8_t)(ctx->state[i]>>16);
digest[i*4+3] = (uint8_t)(ctx->state[i]>>24);
}
}
} // anon namespace
std::string Bootstrap::computeMD5(const std::string& filePath, float pctBase, float pctRange) {
FILE* fp = fopen(filePath.c_str(), "rb");
if (!fp) return {};
// Get file size for progress reporting
fseek(fp, 0, SEEK_END);
long long fileSize = ftell(fp);
fseek(fp, 0, SEEK_SET);
MD5Context ctx;
md5_init(&ctx);
uint8_t buf[65536];
size_t n;
long long processed = 0;
while ((n = fread(buf, 1, sizeof(buf), fp)) > 0) {
if (cancel_requested_) {
fclose(fp);
return {};
}
md5_update(&ctx, buf, n);
processed += (long long)n;
// Update progress every ~4MB
if (fileSize > 0 && (processed % (4 * 1024 * 1024)) < (long long)sizeof(buf)) {
float filePct = (float)(100.0 * processed / fileSize);
float overallPct = pctBase + pctRange * (float)processed / (float)fileSize;
char msg[128];
snprintf(msg, sizeof(msg), "Verifying MD5... %.0f%% (%s / %s)",
filePct, formatSize((double)processed).c_str(),
formatSize((double)fileSize).c_str());
{
std::lock_guard<std::mutex> lk(mutex_);
progress_.state = State::Verifying;
progress_.status_text = msg;
progress_.percent = overallPct;
}
}
}
fclose(fp);
uint8_t digest[16];
md5_final(&ctx, digest);
std::ostringstream oss;
oss << std::hex << std::setfill('0');
for (int i = 0; i < 16; i++)
oss << std::setw(2) << (int)digest[i];
return oss.str();
}
// ---------------------------------------------------------------------------
// Checksum verification pipeline
// ---------------------------------------------------------------------------
bool Bootstrap::verifyChecksums(const std::string& zipPath, const std::string& baseUrl) {
setProgress(State::Verifying, "Downloading checksums...");
std::string sha256Url = baseUrl + "/" + kZipName + ".sha256";
std::string md5Url = baseUrl + "/" + kZipName + ".md5";
std::string sha256Content = downloadSmallFile(sha256Url);
std::string md5Content = downloadSmallFile(md5Url);
if (cancel_requested_) return false;
bool haveSHA256 = !sha256Content.empty();
bool haveMD5 = !md5Content.empty();
if (!haveSHA256 && !haveMD5) {
// Fail CLOSED: refuse to install an unverified multi-GB archive (mirrors the xmrig/daemon
// updaters, which refuse when no checksum is published). Trusting an unverified download of
// this size is a real integrity/security risk; a missing checksum is a server-side gap to fix.
DEBUG_LOGF("[Bootstrap] No checksum published — refusing to install an unverified archive\n");
setProgress(State::Failed, "Verification failed: no checksum was published for the bootstrap. "
"Refusing to install an unverified archive.");
return false;
}
// Determine progress ranges: if both checksums exist, split 0-50% / 50-100%
float sha256Base = 0.0f, sha256Range = 0.0f;
float md5Base = 0.0f, md5Range = 0.0f;
if (haveSHA256 && haveMD5) {
sha256Base = 0.0f; sha256Range = 50.0f;
md5Base = 50.0f; md5Range = 50.0f;
} else if (haveSHA256) {
sha256Base = 0.0f; sha256Range = 100.0f;
} else {
md5Base = 0.0f; md5Range = 100.0f;
}
// --- SHA-256 ---
if (haveSHA256) {
setProgress(State::Verifying, "Verifying SHA-256...", 0, 1); // percent = 0
std::string expected = parseChecksumFile(sha256Content);
std::string actual = computeSHA256(zipPath, sha256Base, sha256Range);
if (cancel_requested_) return false;
if (expected.empty() || actual.empty()) {
DEBUG_LOGF("[Bootstrap] SHA-256: could not compute/parse (expected=%s, actual=%s)\n",
expected.c_str(), actual.c_str());
setProgress(State::Failed, "SHA-256 verification error");
return false;
}
if (expected != actual) {
DEBUG_LOGF("[Bootstrap] SHA-256 MISMATCH!\n expected: %s\n actual: %s\n",
expected.c_str(), actual.c_str());
setProgress(State::Failed,
"SHA-256 mismatch — the server's checksum file may be out of date.\n"
"Expected: " + expected.substr(0, 16) + "...\n"
"Got: " + actual.substr(0, 16) + "...\n"
"Try again or use a fresh download.");
return false;
}
DEBUG_LOGF("[Bootstrap] SHA-256 verified: %s\n", actual.c_str());
}
// --- MD5 ---
if (haveMD5) {
setProgress(State::Verifying, "Verifying MD5...", (double)md5Base, 100.0);
std::string expected = parseChecksumFile(md5Content);
std::string actual = computeMD5(zipPath, md5Base, md5Range);
if (cancel_requested_) return false;
if (expected.empty() || actual.empty()) {
DEBUG_LOGF("[Bootstrap] MD5: could not compute/parse (expected=%s, actual=%s)\n",
expected.c_str(), actual.c_str());
setProgress(State::Failed, "MD5 verification error");
return false;
}
if (expected != actual) {
DEBUG_LOGF("[Bootstrap] MD5 MISMATCH!\n expected: %s\n actual: %s\n",
expected.c_str(), actual.c_str());
setProgress(State::Failed,
"MD5 mismatch — the server's checksum file may be out of date.\n"
"Expected: " + expected + "\n"
"Got: " + actual + "\n"
"Try again or use a fresh download.");
return false;
}
DEBUG_LOGF("[Bootstrap] MD5 verified: %s\n", actual.c_str());
}
setProgress(State::Verifying, "Checksums verified \xe2\x9c\x93", 100.0, 100.0);
return true;
}
} // namespace util
} // namespace dragonx