fix(bootstrap): check archive and free space before deleting chain data

The bootstrap removed blocks/, chainstate/ and notarizations/ first and
only then opened the archive. An unsafe entry, a full disk or a corrupt
member therefore left no chain, or a half-written one for dragonxd to
start on.

- Preflight before anything is deleted: every archive entry must be
  safe (no absolute path, no "..") and readable, and the disk must hold
  the extracted size plus max(256 MiB, 5%), counting what removing the
  old chain frees (a symlinked chain dir counts 0). Free space counts as
  unknown only when the probe errors, the capacity is 0 or the
  available size is -1; a known disk with 0 bytes free is a refusal.
  The old chain is kept on every preflight failure.
- Protected members are skipped, compared case-insensitively: any path
  component starting with "wallet", any "database" component, a .cookie
  or *.conf file, and top-level *.dat files other than fee_estimates,
  peers and banlist. The number of non-critical member failures is
  logged.
- The removal of the old chain is verified (a locked file fails the run
  instead of mixing old and new chain files). If the extraction fails or
  is aborted, the partial chain is removed again before the failure is
  published; isDone() turns true only after the worker's last
  filesystem change.
- Cancel is disabled while the old chain data is being replaced, and
  quit aborts a running bootstrap.
- A complete zip from an earlier attempt is reused on Retry; its
  checksum is re-verified. A download that cannot fit fails early, and a
  disk-full write error says so.

Tests: the space rule (boundaries, unknown vs. full disk, saturation);
planArchive sizes, zip-slip, garbage and truncated zips; the
protected-name list; reclaimableBytes with symlinks; each preflight
refusal keeps the old chain and wallet.dat byte-identical; a corrupt
critical member removes the partial chain before the failure is
published.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 13:22:00 -05:00
co-authored by Claude Opus 5.5
parent 70d8b0f687
commit 78e38570a8
7 changed files with 831 additions and 139 deletions
+354
View File
@@ -80,6 +80,7 @@
#include "util/pool_registry.h"
#include "util/daemon_updater.h"
#include "util/http_download.h"
#include "util/bootstrap.h"
#include "util/background_sampler.h"
#include "util/lite_server_probe.h"
#include "util/wallet_file_probe.h"
@@ -126,6 +127,8 @@
#include <utility>
#include <vector>
#include <sodium.h>
#define MINIZ_NO_ZLIB_COMPATIBLE_NAMES // only the mz_zip_* API is used; keep zlib-style names out of this file
#include <miniz.h>
namespace fs = std::filesystem;
@@ -3550,6 +3553,351 @@ void testResourceNeedsWrite()
removeTree(dir);
}
// ── C222: bootstrap preflight (util/bootstrap.h) ──
// Writes a zip with miniz. level 0 stores members uncompressed, so a test can corrupt member data in place.
static bool writeBootstrapTestZip(const fs::path& zipPath,
const std::vector<std::pair<std::string, std::string>>& entries,
mz_uint level = MZ_DEFAULT_LEVEL)
{
mz_zip_archive zip;
std::memset(&zip, 0, sizeof(zip));
if (!mz_zip_writer_init_file(&zip, zipPath.string().c_str(), 0)) return false;
bool ok = true;
for (const auto& e : entries)
ok = ok && mz_zip_writer_add_mem(&zip, e.first.c_str(), e.second.data(), e.second.size(), level);
ok = mz_zip_writer_finalize_archive(&zip) && ok;
mz_zip_writer_end(&zip);
return ok;
}
static fs::space_info bootstrapTestSpace(std::uintmax_t avail, std::uintmax_t capacity)
{
fs::space_info si{};
si.capacity = capacity;
si.free = avail;
si.available = avail;
return si;
}
static bool bootstrapPathAbsent(const fs::path& p)
{
std::error_code ec;
return fs::symlink_status(p, ec).type() == fs::file_type::not_found;
}
static bool waitBootstrapDone(const dragonx::util::Bootstrap& b, double timeoutSec)
{
const auto t0 = std::chrono::steady_clock::now();
while (secondsSince(t0) < timeoutSec) {
if (b.isDone()) return true;
std::this_thread::sleep_for(std::chrono::milliseconds(2));
}
return b.isDone();
}
// An old chain plus a wallet in <data>: blocks/old, chainstate/old, wallet.dat.
static void plantOldBootstrapChain(const fs::path& data, const std::string& walletBytes)
{
fs::create_directories(data / "blocks");
fs::create_directories(data / "chainstate");
{ std::ofstream(data / "blocks" / "old", std::ios::binary) << "OLD-BLOCKS"; }
{ std::ofstream(data / "chainstate" / "old", std::ios::binary) << "OLD-CHAINSTATE"; }
{ std::ofstream f(data / "wallet.dat", std::ios::binary); f.write(walletBytes.data(), (std::streamsize)walletBytes.size()); }
}
void testBootstrapSpaceSufficient()
{
using dragonx::util::Bootstrap;
const std::uint64_t MiB = 1024ull * 1024ull;
const std::uint64_t GiB = 1024ull * MiB;
const std::uint64_t kMax = std::numeric_limits<std::uint64_t>::max();
std::string msg;
// margin = max(256 MiB, 5%): 5 GiB needs 5 GiB + 256 MiB
EXPECT_TRUE(!Bootstrap::spaceSufficient(5 * GiB, 1 * GiB, 100 * GiB, (36 * GiB) / 10, &msg));
EXPECT_TRUE(msg.find("need") != std::string::npos);
EXPECT_TRUE(Bootstrap::spaceSufficient(5 * GiB, 6 * GiB, 100 * GiB, 0, nullptr));
// A genuinely full disk (known capacity, nothing available) is a refusal, not "unknown".
msg.clear();
EXPECT_TRUE(!Bootstrap::spaceSufficient(5 * GiB, 0, 100 * GiB, 0, &msg));
EXPECT_TRUE(msg.find("need") != std::string::npos);
// Unknown: capacity 0 or avail == (uintmax_t)-1 -> fail open.
EXPECT_TRUE(Bootstrap::spaceSufficient(5 * GiB, 0, 0, 0, nullptr));
EXPECT_TRUE(Bootstrap::spaceSufficient(5 * GiB, kMax, 100 * GiB, 0, nullptr));
// Exact boundary.
EXPECT_TRUE(Bootstrap::spaceSufficient(5 * GiB, 5 * GiB + 256 * MiB, 100 * GiB, 0, nullptr));
EXPECT_TRUE(!Bootstrap::spaceSufficient(5 * GiB, 5 * GiB + 256 * MiB - 1, 100 * GiB, 0, nullptr));
// The old chain's bytes count toward it.
EXPECT_TRUE(Bootstrap::spaceSufficient(5 * GiB, 2 * GiB, 100 * GiB, 3 * GiB + 256 * MiB, nullptr));
EXPECT_TRUE(!Bootstrap::spaceSufficient(5 * GiB, 2 * GiB, 100 * GiB, 3 * GiB + 256 * MiB - 1, nullptr));
// 256 MiB floor for a small archive; 5% above it.
EXPECT_TRUE(!Bootstrap::spaceSufficient(1 * MiB, 256 * MiB, 100 * GiB, 0, nullptr));
EXPECT_TRUE(Bootstrap::spaceSufficient(1 * MiB, 257 * MiB, 100 * GiB, 0, nullptr));
EXPECT_TRUE(!Bootstrap::spaceSufficient(100 * GiB, 105 * GiB - 1, 1000 * GiB, 0, nullptr));
EXPECT_TRUE(Bootstrap::spaceSufficient(100 * GiB, 105 * GiB, 1000 * GiB, 0, nullptr));
// Huge values saturate instead of wrapping around to a small "need".
EXPECT_TRUE(!Bootstrap::spaceSufficient(kMax - 1, 10 * GiB, 100 * GiB, 0, nullptr));
EXPECT_TRUE(!Bootstrap::spaceSufficient(kMax, kMax - 1, kMax, 0, nullptr));
EXPECT_TRUE(Bootstrap::spaceSufficient(5 * GiB, 1 * GiB, 100 * GiB, kMax, nullptr));
}
void testBootstrapPlanArchive()
{
using dragonx::util::Bootstrap;
const fs::path dir = makeTempDir();
const fs::path good = dir / "good.zip";
EXPECT_TRUE(writeBootstrapTestZip(good, {
{"blocks/", ""},
{"blocks/a", std::string(1000, 'b')},
{"chainstate/b", std::string(500, 'c')},
{"peers.dat", std::string(19, 'p')},
{"wallet.dat", std::string(300, 'w')},
{"wallet-cold.dat", "cold"},
{"blocks/sub/wallet.dat", "nested"},
{"DRAGONX.conf", "rpcpassword=x"},
{"database/", ""},
{"database/log.0000000001", "bdb-log"},
{".cookie", "__cookie__:x"},
{"cold.dat", "another wallet file"},
}));
Bootstrap::ArchivePlan plan = Bootstrap::planArchive(good.string());
EXPECT_TRUE(plan.ok);
EXPECT_EQ(plan.bytes, (std::uint64_t)(1000 + 500 + 19)); // wallet*, *.conf, database/, .cookie, cold.dat excluded
const fs::path evil = dir / "evil.zip";
EXPECT_TRUE(writeBootstrapTestZip(evil, {{"blocks/a", std::string(10, 'b')}, {"../evil", "x"}}));
plan = Bootstrap::planArchive(evil.string());
EXPECT_TRUE(!plan.ok);
EXPECT_TRUE(plan.error.find("unsafe") != std::string::npos);
const fs::path garbage = dir / "garbage.zip";
{ std::ofstream(garbage, std::ios::binary) << "this is not a zip archive, just some bytes"; }
plan = Bootstrap::planArchive(garbage.string());
EXPECT_TRUE(!plan.ok);
EXPECT_TRUE(!plan.error.empty());
const fs::path truncated = dir / "truncated.zip"; // the first half: the central directory is gone
{
const std::string bytes = readProvisionFile(good);
std::ofstream f(truncated, std::ios::binary);
f.write(bytes.data(), (std::streamsize)(bytes.size() / 2));
}
plan = Bootstrap::planArchive(truncated.string());
EXPECT_TRUE(!plan.ok);
EXPECT_TRUE(!Bootstrap::planArchive((dir / "missing.zip").string()).ok);
for (const char* n : {"wallet.dat", "Wallet.DAT", "wallet-ip-1.dat", "wallets/w.dat", "blocks/wallet.dat",
"database/", "database/log.1", "DRAGONX.conf", "sub/x.conf", ".cookie", "cold.dat",
"sub\\wallet.dat"})
EXPECT_TRUE(Bootstrap::isProtectedEntry(n) || (std::cerr << " not protected: " << n << "\n", false));
for (const char* n : {"blocks/", "blocks/blk00000.dat", "blocks/index/000001.ldb", "chainstate/000002.ldb",
"notarizations/x", "peers.dat", "fee_estimates.dat", "banlist.dat"})
EXPECT_TRUE(!Bootstrap::isProtectedEntry(n) || (std::cerr << " protected: " << n << "\n", false));
removeTree(dir);
}
#ifndef _WIN32
void testBootstrapReclaimable()
{
using dragonx::util::Bootstrap;
const fs::path dir = makeTempDir();
fs::create_directories(dir / "blocks");
{ std::ofstream(dir / "blocks" / "f", std::ios::binary) << std::string(1000, 'x'); }
fs::create_directories(dir / "elsewhere");
{ std::ofstream(dir / "elsewhere" / "g", std::ios::binary) << std::string(5000, 'y'); }
// chainstate is a symlink to a 5000 B dir: removing it frees nothing on this filesystem.
fs::create_directory_symlink(dir / "elsewhere", dir / "chainstate");
// A file symlink inside blocks/ frees nothing either.
fs::create_symlink(dir / "elsewhere" / "g", dir / "blocks" / "link");
EXPECT_EQ(Bootstrap::reclaimableBytes(dir.string()), (std::uint64_t)1000);
fs::create_directories(dir / "notarizations" / "sub");
{ std::ofstream(dir / "notarizations" / "sub" / "n", std::ios::binary) << std::string(24, 'n'); }
EXPECT_EQ(Bootstrap::reclaimableBytes(dir.string()), (std::uint64_t)1024);
EXPECT_EQ(Bootstrap::reclaimableBytes((dir / "missing").string()), (std::uint64_t)0);
removeTree(dir);
}
#endif
void testBootstrapPreflightKeepsOldChain()
{
using dragonx::util::Bootstrap;
const auto noDaemon = [] { return false; };
const Bootstrap::SpaceFn plenty = [](const std::string&, std::error_code& ec) {
ec.clear();
return bootstrapTestSpace(1ull << 40, 1ull << 41);
};
std::string walletBytes = "WALLET-bytes-must-survive";
walletBytes.push_back('\0');
walletBytes += "\x01\x02 tail";
// (1) An unsafe entry: refused before anything is deleted.
{
const fs::path dir = makeTempDir();
const fs::path data = dir / "DRAGONX";
plantOldBootstrapChain(data, walletBytes);
const fs::path zip = dir / "unsafe.zip";
EXPECT_TRUE(writeBootstrapTestZip(zip, {{"blocks/new", "NEW"}, {"../evil", "x"}}));
Bootstrap b;
b.startFromZip(data.string(), zip.string(), noDaemon, plenty);
EXPECT_TRUE(waitBootstrapDone(b, 20.0));
const Bootstrap::Progress p = b.getProgress();
EXPECT_TRUE(p.state == Bootstrap::State::Failed);
EXPECT_TRUE(p.error.find("unsafe") != std::string::npos);
EXPECT_TRUE(p.cancellable);
EXPECT_TRUE(fs::exists(data / "blocks" / "old"));
EXPECT_TRUE(fs::exists(data / "chainstate" / "old"));
EXPECT_TRUE(!fs::exists(data / "blocks" / "new"));
EXPECT_TRUE(!fs::exists(dir / "evil"));
EXPECT_TRUE(fs::exists(zip)); // the download is kept
EXPECT_EQ(readProvisionFile(data / "wallet.dat"), walletBytes);
removeTree(dir);
}
// (2) A full disk (avail 0, capacity 100 GiB): refused, the old chain untouched.
{
const fs::path dir = makeTempDir();
const fs::path data = dir / "DRAGONX";
plantOldBootstrapChain(data, walletBytes);
const fs::path zip = dir / "full.zip";
EXPECT_TRUE(writeBootstrapTestZip(zip, {{"blocks/new", std::string(4096, 'n')},
{"chainstate/new", std::string(1024, 'c')}}));
std::atomic<int> spaceCalls{0};
Bootstrap b;
b.startFromZip(data.string(), zip.string(), noDaemon,
[&spaceCalls](const std::string&, std::error_code& ec) {
++spaceCalls;
ec.clear();
return bootstrapTestSpace(0, 100ull * 1024ull * 1024ull * 1024ull);
});
EXPECT_TRUE(waitBootstrapDone(b, 20.0));
const Bootstrap::Progress p = b.getProgress();
EXPECT_TRUE(p.state == Bootstrap::State::Failed);
EXPECT_TRUE(p.error.find("need") != std::string::npos);
EXPECT_TRUE(p.cancellable);
EXPECT_EQ(spaceCalls.load(), 1);
EXPECT_TRUE(fs::exists(data / "blocks" / "old"));
EXPECT_TRUE(fs::exists(data / "chainstate" / "old"));
EXPECT_TRUE(!fs::exists(data / "blocks" / "new"));
EXPECT_TRUE(fs::exists(zip));
EXPECT_EQ(readProvisionFile(data / "wallet.dat"), walletBytes);
removeTree(dir);
}
// (3) A corrupt critical member: the partial chain is removed BEFORE the failure is published.
{
const fs::path dir = makeTempDir();
const fs::path data = dir / "DRAGONX";
plantOldBootstrapChain(data, walletBytes);
const fs::path zip = dir / "corrupt.zip";
EXPECT_TRUE(writeBootstrapTestZip(zip, {{"chainstate/y", std::string(2048, 'y')},
{"blocks/x", std::string(4096, 'x')},
{"blocks/z", std::string(64, 'z')}},
MZ_NO_COMPRESSION));
// Flip one byte of blocks/x's stored data (its CRC no longer matches).
std::uint64_t headerOfs = 0;
{
mz_zip_archive zr;
std::memset(&zr, 0, sizeof(zr));
EXPECT_TRUE(mz_zip_reader_init_file(&zr, zip.string().c_str(), 0));
const int idx = mz_zip_reader_locate_file(&zr, "blocks/x", nullptr, 0);
mz_zip_archive_file_stat st;
EXPECT_TRUE(idx >= 0 && mz_zip_reader_file_stat(&zr, (mz_uint)idx, &st));
if (idx >= 0) headerOfs = st.m_local_header_ofs;
mz_zip_reader_end(&zr);
}
{
std::fstream f(zip, std::ios::in | std::ios::out | std::ios::binary);
unsigned char lh[30] = {};
f.seekg((std::streamoff)headerOfs);
f.read(reinterpret_cast<char*>(lh), sizeof(lh));
const std::uint64_t nameLen = lh[26] | (lh[27] << 8);
const std::uint64_t extraLen = lh[28] | (lh[29] << 8);
const std::uint64_t dataOfs = headerOfs + 30 + nameLen + extraLen + 100;
f.seekp((std::streamoff)dataOfs);
f.put('X');
}
Bootstrap b;
b.startFromZip(data.string(), zip.string(), noDaemon, plenty);
bool sawDone = false, chainGoneAtDone = false;
const auto t0 = std::chrono::steady_clock::now();
while (secondsSince(t0) < 20.0) { // tight poll: the first "done" must already see the clean slate
if (b.isDone()) {
chainGoneAtDone = bootstrapPathAbsent(data / "blocks") && bootstrapPathAbsent(data / "chainstate");
sawDone = true;
break;
}
std::this_thread::yield();
}
EXPECT_TRUE(sawDone);
EXPECT_TRUE(chainGoneAtDone);
const Bootstrap::Progress p = b.getProgress();
EXPECT_TRUE(p.state == Bootstrap::State::Failed);
EXPECT_EQ(p.error, std::string("Failed to extract critical file: blocks/x"));
EXPECT_TRUE(!p.cancellable);
EXPECT_TRUE(bootstrapPathAbsent(data / "blocks"));
EXPECT_TRUE(bootstrapPathAbsent(data / "chainstate"));
EXPECT_TRUE(fs::exists(zip)); // kept for a retry
EXPECT_EQ(readProvisionFile(data / "wallet.dat"), walletBytes);
removeTree(dir);
}
// (4) Success: not cancellable at the end, wallet.dat byte-identical, protected members never written.
{
const fs::path dir = makeTempDir();
const fs::path data = dir / "DRAGONX";
plantOldBootstrapChain(data, walletBytes);
const fs::path zip = dir / "good.zip";
EXPECT_TRUE(writeBootstrapTestZip(zip, {{"blocks/", ""},
{"blocks/blk00000.dat", std::string(3000, 'B')},
{"chainstate/000001.ldb", std::string(1500, 'C')},
{"peers.dat", "peers"},
{"wallet.dat", "EVIL-WALLET"},
{"wallet-cold.dat", "EVIL-COLD"},
{"DRAGONX.conf", "rpcpassword=evil"},
{"database/log.0000000001", "evil"},
{".cookie", "evil"}}));
Bootstrap b;
EXPECT_TRUE(b.getProgress().cancellable);
b.startFromZip(data.string(), zip.string(), noDaemon, plenty);
EXPECT_TRUE(waitBootstrapDone(b, 20.0));
const Bootstrap::Progress p = b.getProgress();
EXPECT_TRUE(p.state == Bootstrap::State::Completed);
EXPECT_TRUE(!p.cancellable);
EXPECT_TRUE(!fs::exists(data / "blocks" / "old"));
EXPECT_TRUE(!fs::exists(data / "chainstate" / "old"));
EXPECT_EQ(readProvisionFile(data / "blocks" / "blk00000.dat"), std::string(3000, 'B'));
EXPECT_TRUE(fs::exists(data / "chainstate" / "000001.ldb"));
EXPECT_TRUE(fs::exists(data / "peers.dat"));
EXPECT_EQ(readProvisionFile(data / "wallet.dat"), walletBytes);
EXPECT_TRUE(!fs::exists(data / "wallet-cold.dat"));
EXPECT_TRUE(!fs::exists(data / "DRAGONX.conf"));
EXPECT_TRUE(!fs::exists(data / "database"));
EXPECT_TRUE(!fs::exists(data / ".cookie"));
EXPECT_TRUE(!fs::exists(zip)); // removed after a successful extraction
removeTree(dir);
}
// (5) Unknown free space (the probe fails): fails open, as before.
{
const fs::path dir = makeTempDir();
const fs::path data = dir / "DRAGONX";
plantOldBootstrapChain(data, walletBytes);
const fs::path zip = dir / "unknown.zip";
EXPECT_TRUE(writeBootstrapTestZip(zip, {{"blocks/new", "NEW"}, {"chainstate/new", "NEW"}}));
Bootstrap b;
b.startFromZip(data.string(), zip.string(), noDaemon, [](const std::string&, std::error_code& ec) {
ec = std::make_error_code(std::errc::io_error);
return bootstrapTestSpace(static_cast<std::uintmax_t>(-1), static_cast<std::uintmax_t>(-1));
});
EXPECT_TRUE(waitBootstrapDone(b, 20.0));
EXPECT_TRUE(b.getProgress().state == Bootstrap::State::Completed);
EXPECT_TRUE(fs::exists(data / "blocks" / "new"));
EXPECT_EQ(readProvisionFile(data / "wallet.dat"), walletBytes);
removeTree(dir);
}
}
void testWriteFileAtomic()
{
using namespace dragonx::resources;
@@ -16315,6 +16663,12 @@ int main()
testVerifySaplingParams();
testStartupProvisionState();
testResourceNeedsWrite();
testBootstrapSpaceSufficient();
testBootstrapPlanArchive();
#ifndef _WIN32
testBootstrapReclaimable();
#endif
testBootstrapPreflightKeepsOldChain();
testWriteFileAtomic();
testCopyFileAtomic();
testBundleCopy();