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>
1211 lines
50 KiB
C++
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
|