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;
}