// DragonX Wallet - ImGui Edition // Copyright 2024-2026 The Hush Developers // Released under the GPLv3 #include "bootstrap.h" #include "../daemon/embedded_daemon.h" #include #include #include #include #include #include #include #include #include #include #include "../util/logger.h" #include "http_download.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* comps) { std::vector out; bool safe = !name.empty(); for (unsigned char c : name) if (c < 0x20 || c >= 0x7f) safe = false; // control character (incl. DEL) or non-ASCII std::string n = name; std::replace(n.begin(), n.end(), '\\', '/'); // normalize Windows separators if (!n.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(c)); } else { out += "\\x"; out.push_back(kHex[c >> 4]); out.push_back(kHex[c & 0xF]); } } return out; } static std::string joinArchivePath(const std::vector& comps) { std::string p; for (const std::string& c : comps) { if (!p.empty()) p += '/'; p += c; } return p; } static std::uint64_t satAdd(std::uint64_t a, std::uint64_t b) { return (a > UINT64_MAX - b) ? UINT64_MAX : a + b; } // fs::file_size is 64-bit on every platform; ftell() returns a 32-bit long on Win LLP64. static long long fileSizeOrZero(const std::string& path) { std::error_code ec; auto sz = fs::file_size(path, ec); return ec ? 0 : (long long)sz; } static std::string lowerAscii(std::string s) { for (char& c : s) if (c >= 'A' && c <= 'Z') c = static_cast(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*>(clientp)->load() ? 1 : 0; } static size_t writeFileCallback(void* contents, size_t size, size_t nmemb, void* userp) { size_t total = size * nmemb; FILE* fp = static_cast(userp); 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 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(-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 = (sz > 0 && sz != static_cast(-1)) ? "Download failed — " + formatSize((double)sz) + " kept, will resume on retry" : std::string("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; } const std::string why = getProgress().error; // verifyChecksums' own reason std::error_code ec; if (last_verify_mismatch_) // delete only a genuinely corrupt archive; fs::remove(zipPath, ec); // keep it on a missing/unusable sidecar or fetch blip 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 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 lk(mutex_); if (!progress_.cancellable) return false; // the old chain data is being replaced: a no-op cancel_requested_ = true; return true; } bool Bootstrap::safeToAbandon() const { std::lock_guard lk(mutex_); return !worker_running_ || (cancel_requested_ && progress_.cancellable); } bool Bootstrap::cancelAndWait(std::chrono::steady_clock::time_point deadline) { cancel(); while (worker_running_ && std::chrono::steady_clock::now() < deadline) std::this_thread::sleep_for(std::chrono::milliseconds(10)); if (!worker_running_) { if (worker_.joinable()) worker_.join(); // at once: worker_running_ clears as the worker's last action return true; } LOGF("[Bootstrap] Still running at quit - left to the process exit (%s)\n", safeToAbandon() ? "the chain data was not touched" : "the chain data may be left part-replaced; dragonxd will offer a reindex"); return false; } Bootstrap::Progress Bootstrap::getProgress() const { std::lock_guard lk(mutex_); return progress_; } bool Bootstrap::isDone() const { std::lock_guard 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 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 // --------------------------------------------------------------------------- Bootstrap::RemoteInfo Bootstrap::getRemoteFileSize(const std::string& url) { RemoteInfo info; CURL* curl = curl_easy_init(); if (!curl) return info; 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); // Redirects may only stay on HTTPS (no downgrade / file:// etc.). #if LIBCURL_VERSION_NUM >= 0x075500 curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "https"); #else curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS, (long)CURLPROTO_HTTPS); #endif 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_NOSIGNAL, 1L); // timeouts are safe on a worker thread 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 info; // transportOk=false, size=-1 } info.transportOk = true; curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &info.httpCode); curl_off_t cl = -1; curl_easy_getinfo(curl, CURLINFO_CONTENT_LENGTH_DOWNLOAD_T, &cl); info.size = (long long)cl; curl_easy_cleanup(curl); DEBUG_LOGF("[Bootstrap] HEAD %ld, size %lld\n", info.httpCode, info.size); return info; } Bootstrap::ResumeAction Bootstrap::decideResume(long long existingBytes, bool authoritative, long long remoteSize) { if (existingBytes <= 0) return ResumeAction::Fresh; if (!authoritative) return ResumeAction::RangedGet; // keep the file; a non-206 reply is caught after perform if (existingBytes > remoteSize) return ResumeAction::TruncateAndFresh; // stale: older/larger zip if (existingBytes == remoteSize) return ResumeAction::ReuseComplete; // complete: skip the GET; verifyChecksums runs return ResumeAction::RangedGet; // existing < remote } // --------------------------------------------------------------------------- // Download (libcurl) // --------------------------------------------------------------------------- int Bootstrap::progressCallback(void* clientp, long long dltotal, long long dlnow, long long /*ultotal*/, long long /*ulnow*/) { auto* self = static_cast(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 RemoteInfo info = getRemoteFileSize(url); if (cancel_requested_) { // the HEAD was cut short: keep a partial file for the next resume curl_easy_cleanup(curl); return false; } const bool authoritative = info.transportOk && (info.httpCode / 100 == 2) && info.size > 0; switch (decideResume(existing_bytes, authoritative, info.size)) { case ResumeAction::ReuseComplete: { // 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)info.size); return true; } case ResumeAction::TruncateAndFresh: { DEBUG_LOGF("[Bootstrap] Local file (%lld) > remote (%lld), restarting\n", existing_bytes, info.size); std::error_code ec; fs::remove(destZip, ec); existing_bytes = 0; break; } case ResumeAction::Fresh: existing_bytes = 0; // nothing usable on disk; fresh download break; case ResumeAction::RangedGet: break; // resume from existing_bytes (may be an offline/unverified resume) } // Early, non-destructive free-space check: only when the remote size is authoritative. Nothing is // deleted; a partial file is always kept for a later resume. if (authoritative && info.size > existing_bytes) { const std::uint64_t need = satAdd(static_cast(info.size - 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(-1) && static_cast(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); // Redirects may only stay on HTTPS (no downgrade / file:// etc.). #if LIBCURL_VERSION_NUM >= 0x075500 curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "https"); #else curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS, (long)CURLPROTO_HTTPS); #endif 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); long httpCode = 0; curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &httpCode); 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 } // We asked to resume but the server answered 200 (whole file), not 206: libcurl may append it to the // partial instead of erroring, which would corrupt the archive. Restart cleanly from scratch. if (res == CURLE_OK && existing_bytes > 0 && httpCode != 206) { DEBUG_LOGF("[Bootstrap] Ranged request answered %ld (not 206) - restarting without resume\n", httpCode); resume_offset_ = 0; std::error_code ec; fs::remove(destZip, ec); return download(url, destZip); } if (res != CURLE_OK) { DEBUG_LOGF("[Bootstrap] curl error: %s\n", curl_easy_strerror(res)); last_curl_ = static_cast(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 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=.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(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(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(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 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) // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // Checksum helpers // --------------------------------------------------------------------------- std::string Bootstrap::parseChecksumFile(const std::string& content, std::size_t expectedHexLen) { std::istringstream iss(content); std::string token; if (!(iss >> token)) return {}; if (token.size() != expectedHexLen) return {}; // reject error pages / wrong digests for (char c : token) if (!std::isxdigit(static_cast(c))) return {}; std::transform(token.begin(), token.end(), token.begin(), [](unsigned char c) { return std::tolower(c); }); return token; } std::string Bootstrap::computeSHA256(const std::string& filePath, float pctBase, float pctRange) { FILE* fp = fopen(filePath.c_str(), "rb"); if (!fp) return {}; // File size for progress reporting (not a seek: ftell is 32-bit on Windows LLP64). const long long fileSize = fileSizeOrZero(filePath); 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 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 {}; // File size for progress reporting (not a seek: ftell is 32-bit on Windows LLP64). const long long fileSize = fileSizeOrZero(filePath); 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 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) { last_verify_mismatch_ = false; setProgress(State::Verifying, "Downloading checksums..."); HttpGetOptions opt; opt.cancel = &cancel_requested_; std::string sha256Content = httpGetString(baseUrl + "/" + kZipName + ".sha256", "[bootstrap]", opt); if (cancel_requested_) return false; std::string expectedSha = parseChecksumFile(sha256Content, 64); const bool haveSHA256 = !expectedSha.empty(); std::string expectedMd5; bool haveMD5 = false; if (!haveSHA256) { // MD5 only as a fallback when no SHA-256 is published std::string md5Content = httpGetString(baseUrl + "/" + kZipName + ".md5", "[bootstrap]", opt); if (cancel_requested_) return false; expectedMd5 = parseChecksumFile(md5Content, 32); haveMD5 = !expectedMd5.empty(); } if (!haveSHA256 && !haveMD5) { 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; // fail CLOSED; archive is KEPT } if (haveSHA256) { setProgress(State::Verifying, "Verifying SHA-256...", 0, 1); std::string actual = computeSHA256(zipPath, 0.0f, 100.0f); if (cancel_requested_) return false; if (actual.empty()) { setProgress(State::Failed, "SHA-256 verification error"); return false; } if (expectedSha != actual) { last_verify_mismatch_ = true; DEBUG_LOGF("[Bootstrap] SHA-256 MISMATCH!\n expected: %s\n actual: %s\n", expectedSha.c_str(), actual.c_str()); setProgress(State::Failed, "SHA-256 mismatch \xe2\x80\x94 the server's checksum file may be out of date.\n" "Expected: " + expectedSha.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()); } else { // haveMD5 setProgress(State::Verifying, "Verifying MD5...", 0, 1); std::string actual = computeMD5(zipPath, 0.0f, 100.0f); if (cancel_requested_) return false; if (actual.empty()) { setProgress(State::Failed, "MD5 verification error"); return false; } if (expectedMd5 != actual) { last_verify_mismatch_ = true; DEBUG_LOGF("[Bootstrap] MD5 MISMATCH!\n expected: %s\n actual: %s\n", expectedMd5.c_str(), actual.c_str()); setProgress(State::Failed, "MD5 mismatch \xe2\x80\x94 the server's checksum file may be out of date.\n" "Expected: " + expectedMd5 + "\nGot: " + 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