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:
+44
-112
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user