fix(bootstrap): keep the archive unless a checksum truly mismatches

Any verification failure deleted the downloaded archive and forced a
full multi-GB re-download on the next attempt. But most failures are not
a corrupt archive: a missing or unreadable checksum sidecar, or a fetch
blip while pulling the checksum, would throw away a perfectly good file.
On top of that the checksum parser took the first token of whatever came
back, so an HTTP error page could be mistaken for a digest.

- The archive is now removed only on a genuine digest mismatch. A
  missing sidecar, an unusable one, or a transient fetch error keeps the
  file so the next attempt can reuse or resume it; the no-checksum case
  still fails closed and keeps the archive.
- The checksum parser now accepts a token only if it is exactly the
  expected length and all hex, lowercasing it; anything else (an error
  page, a truncated or wrong-width digest) is rejected.
- The small-file fetch moved onto the shared capped HTTP helper, and MD5
  is consulted only as a fallback when no SHA-256 is published.

Tests cover the checksum parser across good, wrong-length, non-hex, and
error-page inputs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 20:57:21 -05:00
co-authored by Claude Opus 4.8
parent b3a1a9e654
commit 4d9d528566
3 changed files with 68 additions and 118 deletions
+44 -112
View File
@@ -17,6 +17,7 @@
#include <sstream>
#include <iomanip>
#include "../util/logger.h"
#include "http_download.h"
namespace fs = std::filesystem;
@@ -277,10 +278,10 @@ void Bootstrap::runDownloadAndInstall(const std::string& dataDir, const std::str
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);
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;
}
@@ -872,59 +873,20 @@ bool Bootstrap::cleanChainData(const std::string& dataDir) {
// 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);
if (str->size() + total > (64u * 1024)) return 0; // refuse an oversized checksum file -> curl aborts
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_MAXFILESIZE_LARGE, static_cast<curl_off_t>(64 * 1024));
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::string Bootstrap::parseChecksumFile(const std::string& content, std::size_t expectedHexLen) {
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 {};
if (!(iss >> token)) return {};
if (token.size() != expectedHexLen) return {}; // reject error pages / wrong digests
for (char c : token)
if (!std::isxdigit(static_cast<unsigned char>(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) {
@@ -1116,91 +1078,61 @@ std::string Bootstrap::computeMD5(const std::string& filePath, float pctBase, fl
// ---------------------------------------------------------------------------
bool Bootstrap::verifyChecksums(const std::string& zipPath, const std::string& baseUrl) {
last_verify_mismatch_ = false;
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);
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();
bool haveSHA256 = !sha256Content.empty();
bool haveMD5 = !md5Content.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) {
// 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");
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;
return false; // fail CLOSED; archive is KEPT
}
// 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);
setProgress(State::Verifying, "Verifying SHA-256...", 0, 1);
std::string actual = computeSHA256(zipPath, 0.0f, 100.0f);
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) {
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",
expected.c_str(), actual.c_str());
expectedSha.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"
"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());
}
// --- MD5 ---
if (haveMD5) {
setProgress(State::Verifying, "Verifying MD5...", (double)md5Base, 100.0);
std::string expected = parseChecksumFile(md5Content);
std::string actual = computeMD5(zipPath, md5Base, md5Range);
} else { // haveMD5
setProgress(State::Verifying, "Verifying MD5...", 0, 1);
std::string actual = computeMD5(zipPath, 0.0f, 100.0f);
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());
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 — the server's checksum file may be out of date.\n"
"Expected: " + expected + "\n"
"Got: " + actual + "\n"
"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;
}
+8 -6
View File
@@ -137,6 +137,10 @@ public:
/// stripped from each component, so "./wallet.dat" and "DRAGONX.conf." are protected too.
static bool isProtectedEntry(const std::string& name);
/// First whitespace-delimited token of a checksum file, lowercased, iff it is exactly
/// expectedHexLen hex chars; "" otherwise (an HTTP error page is never a checksum).
static std::string parseChecksumFile(const std::string& content, std::size_t expectedHexLen);
private:
mutable std::mutex mutex_;
Progress progress_;
@@ -158,9 +162,6 @@ private:
bool cleanChainData(const std::string& dataDir);
void setProgress(State state, const std::string& text, double downloaded = 0, double total = 0);
/// Download a small text file (checksum) into a string. Returns empty on failure.
std::string downloadSmallFile(const std::string& url);
/// Compute SHA-256 of a file, return lowercase hex digest.
/// pctBase/pctRange map file progress onto a portion of the overall percent.
std::string computeSHA256(const std::string& filePath, float pctBase = 0.0f, float pctRange = 100.0f);
@@ -169,9 +170,6 @@ private:
/// pctBase/pctRange map file progress onto a portion of the overall percent.
std::string computeMD5(const std::string& filePath, float pctBase = 0.0f, float pctRange = 100.0f);
/// Parse the first hex token from a checksum file (handles "<hash> <filename>" format).
static std::string parseChecksumFile(const std::string& content);
/// Verify downloaded zip against remote checksums. Returns true if valid.
bool verifyChecksums(const std::string& zipPath, const std::string& baseUrl);
@@ -188,6 +186,10 @@ private:
int last_curl_{0};
std::string download_error_;
/// Set by verifyChecksums (worker thread only): true only on a real digest MISMATCH, so the caller
/// deletes the archive only then - never for a missing/unusable sidecar or a transient fetch error.
bool last_verify_mismatch_{false};
// libcurl progress callback (static → calls into instance via clientp)
static int progressCallback(void* clientp, long long dltotal, long long dlnow,
long long ultotal, long long ulnow);
+16
View File
@@ -3946,6 +3946,21 @@ static void plantOldBootstrapChain(const fs::path& data, const std::string& wall
{ std::ofstream f(data / "wallet.dat", std::ios::binary); f.write(walletBytes.data(), (std::streamsize)walletBytes.size()); }
}
void testBootstrapParseChecksum()
{
using dragonx::util::Bootstrap;
const std::string h64(64, 'a'), h32(32, 'b');
EXPECT_EQ(Bootstrap::parseChecksumFile(h64 + " DRAGONX.zip", 64), h64);
{ std::string up(64, 'A'); EXPECT_EQ(Bootstrap::parseChecksumFile(up, 64), std::string(64, 'a')); }
EXPECT_EQ(Bootstrap::parseChecksumFile("<!DOCTYPE html><html>...", 64), std::string());
EXPECT_EQ(Bootstrap::parseChecksumFile(std::string(63, 'a'), 64), std::string());
EXPECT_EQ(Bootstrap::parseChecksumFile(std::string(65, 'a'), 64), std::string());
{ std::string bad(63, 'a'); bad += 'g'; EXPECT_EQ(Bootstrap::parseChecksumFile(bad, 64), std::string()); }
EXPECT_EQ(Bootstrap::parseChecksumFile(h32, 32), h32);
EXPECT_EQ(Bootstrap::parseChecksumFile(h32, 64), std::string());
EXPECT_EQ(Bootstrap::parseChecksumFile("", 64), std::string());
}
void testBootstrapSpaceSufficient()
{
using dragonx::util::Bootstrap;
@@ -18432,6 +18447,7 @@ int main()
testResourceNeedsWrite();
testXmrigProvisionPolicy();
testBootstrapSpaceSufficient();
testBootstrapParseChecksum();
testBootstrapPlanArchive();
#ifndef _WIN32
testBootstrapReclaimable();