fix(bootstrap): harden archive names, the cancel window and quit wait
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>
This commit is contained in:
@@ -3716,6 +3716,55 @@ void testBootstrapPlanArchive()
|
||||
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));
|
||||
|
||||
// Names that land on a protected file once "." components, trailing dots/spaces and an NTFS stream
|
||||
// suffix are resolved (Win32 does all three; the check applies them everywhere).
|
||||
for (const char* n : {"./foo.dat", "./wallet.dat", ".//./wallet.dat", ".\\wallet.dat", "DRAGONX.conf.",
|
||||
"DRAGONX.conf ", "DRAGONX.conf::$DATA", "database./log.1", "database /log.1",
|
||||
"./database/", "blocks/./wallet.dat", "./.cookie", "./sub/x.conf. . "})
|
||||
EXPECT_TRUE(Bootstrap::isProtectedEntry(n) || (std::cerr << " not protected: " << n << "\n", false));
|
||||
for (const char* n : {"./blocks/./index/000001.ldb", "./peers.dat", "./chainstate/", "./", "."})
|
||||
EXPECT_TRUE(!Bootstrap::isProtectedEntry(n) || (std::cerr << " protected: " << n << "\n", false));
|
||||
|
||||
// The resolved names in a plan: skipped like the plain names; the nested safe path counts.
|
||||
const fs::path dotted = dir / "dotted.zip";
|
||||
EXPECT_TRUE(writeBootstrapTestZip(dotted, {
|
||||
{"./foo.dat", "x"},
|
||||
{"./wallet.dat", "xx"},
|
||||
{"DRAGONX.conf.", "xxx"},
|
||||
{"database./log.1", "xxxx"},
|
||||
{"./blocks/./index/000001.ldb", std::string(700, 'i')},
|
||||
}));
|
||||
plan = Bootstrap::planArchive(dotted.string());
|
||||
EXPECT_TRUE(plan.ok);
|
||||
EXPECT_EQ(plan.bytes, (std::uint64_t)700);
|
||||
|
||||
// Names a Windows file system would resolve onto another file, or that only it would accept: refused.
|
||||
// Non-ASCII and control bytes: refused too. macOS APFS/HFS+ fold U+017F to 's' and U+212A (Kelvin) to 'k'
|
||||
// and ignore U+200C, so these three land on database/, .cookie and DRAGONX.conf there.
|
||||
for (const char* n : {"DRAGONX.conf::$DATA", "C:x.dat", "blocks/a:b", "DRAGON~1.CON", "DATABA~1/log.1",
|
||||
".. /evil", "blocks/.../x", "blocks/ /x", "..\\evil",
|
||||
"databa\xC5\xBF" "e/log.1", // "databa\u017Fe/log.1"
|
||||
".coo\xE2\x84\xAA" "ie", // ".coo\u212Aie"
|
||||
"DRAGONX.co\xE2\x80\x8C" "nf", // "DRAGONX.co\u200Cnf"
|
||||
"blocks/blk\x01" "00000.dat", "blocks/a\nb", "blocks/a\tb", "blocks/a\x7F" "b",
|
||||
"blocks/caf\xC3\xA9"}) {
|
||||
const fs::path z = dir / "refused.zip";
|
||||
EXPECT_TRUE(writeBootstrapTestZip(z, {{"blocks/a", "b"}, {n, "x"}}));
|
||||
plan = Bootstrap::planArchive(z.string());
|
||||
EXPECT_TRUE((!plan.ok && plan.error.find("unsafe") != std::string::npos) ||
|
||||
(std::cerr << " not refused: " << n << "\n", false));
|
||||
}
|
||||
// The refused name is echoed with its non-printable bytes escaped, never raw.
|
||||
{
|
||||
const fs::path z = dir / "refused.zip";
|
||||
EXPECT_TRUE(writeBootstrapTestZip(z, {{"blocks/a\n[Bootstrap] forged\x1B[0m", "x"}}));
|
||||
plan = Bootstrap::planArchive(z.string());
|
||||
EXPECT_TRUE(!plan.ok);
|
||||
EXPECT_TRUE(plan.error.find("blocks/a\\x0A[Bootstrap] forged\\x1B[0m") != std::string::npos);
|
||||
EXPECT_TRUE(std::none_of(plan.error.begin(), plan.error.end(),
|
||||
[](char c) { return (unsigned char)c < 0x20 || (unsigned char)c >= 0x7f; }));
|
||||
}
|
||||
removeTree(dir);
|
||||
}
|
||||
|
||||
@@ -3918,6 +3967,220 @@ void testBootstrapPreflightKeepsOldChain()
|
||||
EXPECT_EQ(readProvisionFile(data / "wallet.dat"), walletBytes);
|
||||
removeTree(dir);
|
||||
}
|
||||
|
||||
// (6) Members whose names resolve onto protected files ("./foo.dat" = a -wallet=foo.dat file,
|
||||
// "DRAGONX.conf.", "database./log.1") are skipped like the plain names; a "./"-prefixed chain
|
||||
// member is written to its resolved path.
|
||||
{
|
||||
const fs::path dir = makeTempDir();
|
||||
const fs::path data = dir / "DRAGONX";
|
||||
plantOldBootstrapChain(data, walletBytes);
|
||||
{ std::ofstream(data / "foo.dat", std::ios::binary) << "SECOND-WALLET"; }
|
||||
{ std::ofstream(data / "DRAGONX.conf", std::ios::binary) << "rpcpassword=mine"; }
|
||||
fs::create_directories(data / "database");
|
||||
{ std::ofstream(data / "database" / "log.1", std::ios::binary) << "MY-BDB-LOG"; }
|
||||
const fs::path zip = dir / "dotted.zip";
|
||||
EXPECT_TRUE(writeBootstrapTestZip(zip, {{"./foo.dat", "EVIL-FOO"},
|
||||
{"./wallet.dat", "EVIL-WALLET"},
|
||||
{"DRAGONX.conf.", "blocknotify=evil"},
|
||||
{"DRAGONX.conf ", "blocknotify=evil"},
|
||||
{"database./log.1", "EVIL-LOG"},
|
||||
{"./blocks/./index/000001.ldb", "IDX"},
|
||||
{"chainstate/000001.ldb", "CS"}}));
|
||||
Bootstrap b;
|
||||
b.startFromZip(data.string(), zip.string(), noDaemon, plenty);
|
||||
EXPECT_TRUE(waitBootstrapDone(b, 20.0));
|
||||
EXPECT_TRUE(b.getProgress().state == Bootstrap::State::Completed);
|
||||
EXPECT_EQ(readProvisionFile(data / "foo.dat"), std::string("SECOND-WALLET"));
|
||||
EXPECT_EQ(readProvisionFile(data / "wallet.dat"), walletBytes);
|
||||
EXPECT_EQ(readProvisionFile(data / "DRAGONX.conf"), std::string("rpcpassword=mine"));
|
||||
EXPECT_EQ(readProvisionFile(data / "database" / "log.1"), std::string("MY-BDB-LOG"));
|
||||
#ifndef _WIN32 // Win32 resolves these names onto the real files checked above
|
||||
EXPECT_TRUE(bootstrapPathAbsent(data / "DRAGONX.conf."));
|
||||
EXPECT_TRUE(bootstrapPathAbsent(data / "DRAGONX.conf "));
|
||||
EXPECT_TRUE(bootstrapPathAbsent(data / "database."));
|
||||
#endif
|
||||
EXPECT_EQ(readProvisionFile(data / "blocks" / "index" / "000001.ldb"), std::string("IDX"));
|
||||
EXPECT_EQ(readProvisionFile(data / "chainstate" / "000001.ldb"), std::string("CS"));
|
||||
EXPECT_TRUE(!fs::exists(data / "blocks" / "old"));
|
||||
removeTree(dir);
|
||||
}
|
||||
}
|
||||
|
||||
// The user's Cancel (requestCancel) works until the old chain data starts being replaced, and is a no-op
|
||||
// from then on; the start of that window re-checks a request made just before it.
|
||||
void testBootstrapUserCancelWindow()
|
||||
{
|
||||
using dragonx::util::Bootstrap;
|
||||
const Bootstrap::SpaceFn plenty = [](const std::string&, std::error_code& ec) {
|
||||
ec.clear();
|
||||
return bootstrapTestSpace(1ull << 40, 1ull << 41);
|
||||
};
|
||||
const std::string walletBytes = "WALLET-cancel-window";
|
||||
|
||||
// (a) Before the window (waiting for the daemon): accepted, the old chain is kept.
|
||||
{
|
||||
const fs::path dir = makeTempDir();
|
||||
const fs::path data = dir / "DRAGONX";
|
||||
plantOldBootstrapChain(data, walletBytes);
|
||||
const fs::path zip = dir / "a.zip";
|
||||
EXPECT_TRUE(writeBootstrapTestZip(zip, {{"blocks/new", "NEW"}, {"chainstate/new", "NEW"}}));
|
||||
Bootstrap b;
|
||||
b.startFromZip(data.string(), zip.string(), [] { return true; }, plenty);
|
||||
EXPECT_TRUE(b.requestCancel());
|
||||
EXPECT_TRUE(waitBootstrapDone(b, 20.0));
|
||||
const Bootstrap::Progress p = b.getProgress();
|
||||
EXPECT_TRUE(p.state == Bootstrap::State::Failed);
|
||||
EXPECT_TRUE(p.cancellable);
|
||||
EXPECT_TRUE(fs::exists(data / "blocks" / "old"));
|
||||
EXPECT_TRUE(!fs::exists(data / "blocks" / "new"));
|
||||
EXPECT_EQ(readProvisionFile(data / "wallet.dat"), walletBytes);
|
||||
removeTree(dir);
|
||||
}
|
||||
|
||||
// (b) A click that lands right before the window opens (here: during the free-space probe) is caught
|
||||
// by the re-check: nothing is deleted.
|
||||
{
|
||||
const fs::path dir = makeTempDir();
|
||||
const fs::path data = dir / "DRAGONX";
|
||||
plantOldBootstrapChain(data, walletBytes);
|
||||
const fs::path zip = dir / "b.zip";
|
||||
EXPECT_TRUE(writeBootstrapTestZip(zip, {{"blocks/new", "NEW"}, {"chainstate/new", "NEW"}}));
|
||||
Bootstrap b;
|
||||
std::atomic<bool> accepted{false};
|
||||
b.startFromZip(data.string(), zip.string(), [] { return false; },
|
||||
[&b, &accepted](const std::string&, std::error_code& ec) {
|
||||
accepted = b.requestCancel();
|
||||
ec.clear();
|
||||
return bootstrapTestSpace(1ull << 40, 1ull << 41);
|
||||
});
|
||||
EXPECT_TRUE(waitBootstrapDone(b, 20.0));
|
||||
EXPECT_TRUE(accepted.load());
|
||||
const Bootstrap::Progress p = b.getProgress();
|
||||
EXPECT_TRUE(p.state == Bootstrap::State::Failed);
|
||||
EXPECT_TRUE(p.error.find("not touched") != std::string::npos);
|
||||
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));
|
||||
removeTree(dir);
|
||||
}
|
||||
|
||||
// (c) Inside the window: refused, and the run completes with the new chain.
|
||||
{
|
||||
const fs::path dir = makeTempDir();
|
||||
const fs::path data = dir / "DRAGONX";
|
||||
plantOldBootstrapChain(data, walletBytes);
|
||||
const fs::path zip = dir / "c.zip";
|
||||
std::vector<std::pair<std::string, std::string>> entries;
|
||||
for (int i = 0; i < 400; ++i)
|
||||
entries.push_back({"blocks/blk" + std::to_string(i) + ".dat", std::string(4096, (char)('a' + i % 26))});
|
||||
entries.push_back({"chainstate/000001.ldb", "CS"});
|
||||
EXPECT_TRUE(writeBootstrapTestZip(zip, entries));
|
||||
Bootstrap b;
|
||||
b.startFromZip(data.string(), zip.string(), [] { return false; }, plenty);
|
||||
const auto t0 = std::chrono::steady_clock::now();
|
||||
while (b.getProgress().cancellable && !b.isDone() && secondsSince(t0) < 20.0)
|
||||
std::this_thread::yield();
|
||||
EXPECT_TRUE(!b.getProgress().cancellable);
|
||||
EXPECT_TRUE(!b.requestCancel()); // a no-op: the old chain data is being replaced
|
||||
EXPECT_TRUE(waitBootstrapDone(b, 20.0));
|
||||
const Bootstrap::Progress p = b.getProgress();
|
||||
EXPECT_TRUE(p.state == Bootstrap::State::Completed);
|
||||
EXPECT_TRUE(!fs::exists(data / "blocks" / "old"));
|
||||
EXPECT_TRUE(fs::exists(data / "blocks" / "blk399.dat"));
|
||||
EXPECT_TRUE(fs::exists(data / "chainstate" / "000001.ldb"));
|
||||
EXPECT_TRUE(!b.requestCancel()); // still refused after the result is published
|
||||
EXPECT_EQ(readProvisionFile(data / "wallet.dat"), walletBytes);
|
||||
removeTree(dir);
|
||||
}
|
||||
}
|
||||
|
||||
// Quit: cancelAndWait() is bounded, and safeToAbandon() is true only where the process exit can no longer
|
||||
// leave a part-replaced chain (done, or cancelled before the destructive window).
|
||||
void testBootstrapQuitWait()
|
||||
{
|
||||
using dragonx::util::Bootstrap;
|
||||
const Bootstrap::SpaceFn plenty = [](const std::string&, std::error_code& ec) {
|
||||
ec.clear();
|
||||
return bootstrapTestSpace(1ull << 40, 1ull << 41);
|
||||
};
|
||||
const std::string walletBytes = "WALLET-quit-wait";
|
||||
|
||||
{ // nothing running
|
||||
Bootstrap b;
|
||||
EXPECT_TRUE(b.safeToAbandon());
|
||||
EXPECT_TRUE(b.cancelAndWait(std::chrono::steady_clock::now()));
|
||||
}
|
||||
|
||||
// (a) The worker is stuck in a slow step before the window: the wait ends at the deadline, the worker may be
|
||||
// left to the process exit (it never touches the chain data), and it ends on its own later.
|
||||
{
|
||||
const fs::path dir = makeTempDir();
|
||||
const fs::path data = dir / "DRAGONX";
|
||||
plantOldBootstrapChain(data, walletBytes);
|
||||
const fs::path zip = dir / "a.zip";
|
||||
EXPECT_TRUE(writeBootstrapTestZip(zip, {{"blocks/new", "NEW"}, {"chainstate/new", "NEW"}}));
|
||||
Bootstrap b;
|
||||
b.startFromZip(data.string(), zip.string(), [] {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1500));
|
||||
return true;
|
||||
}, plenty);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
const auto t0 = std::chrono::steady_clock::now();
|
||||
EXPECT_TRUE(!b.cancelAndWait(t0 + std::chrono::milliseconds(200)));
|
||||
EXPECT_TRUE(secondsSince(t0) < 1.0);
|
||||
EXPECT_TRUE(b.safeToAbandon());
|
||||
EXPECT_TRUE(waitBootstrapDone(b, 20.0));
|
||||
EXPECT_TRUE(b.getProgress().state == Bootstrap::State::Failed);
|
||||
EXPECT_TRUE(fs::exists(data / "blocks" / "old"));
|
||||
EXPECT_TRUE(fs::exists(data / "chainstate" / "old"));
|
||||
EXPECT_TRUE(!fs::exists(data / "blocks" / "new"));
|
||||
EXPECT_TRUE(b.cancelAndWait(std::chrono::steady_clock::now()));
|
||||
removeTree(dir);
|
||||
}
|
||||
|
||||
// (b) Cancelled inside the window: not safe to abandon until the worker has finished, and by then the data
|
||||
// dir holds no partial chain (aborted: blocks/ and chainstate/ removed; finished first: the full chain).
|
||||
{
|
||||
const fs::path dir = makeTempDir();
|
||||
const fs::path data = dir / "DRAGONX";
|
||||
plantOldBootstrapChain(data, walletBytes);
|
||||
const fs::path zip = dir / "b.zip";
|
||||
std::vector<std::pair<std::string, std::string>> entries;
|
||||
for (int i = 0; i < 400; ++i)
|
||||
entries.push_back({"blocks/blk" + std::to_string(i) + ".dat", std::string(4096, (char)('a' + i % 26))});
|
||||
entries.push_back({"chainstate/000001.ldb", "CS"});
|
||||
EXPECT_TRUE(writeBootstrapTestZip(zip, entries));
|
||||
Bootstrap b;
|
||||
b.startFromZip(data.string(), zip.string(), [] { return false; }, plenty);
|
||||
const auto t0 = std::chrono::steady_clock::now();
|
||||
while (b.getProgress().cancellable && !b.isDone() && secondsSince(t0) < 20.0)
|
||||
std::this_thread::yield();
|
||||
b.cancel();
|
||||
bool safe = false, doneWhenSafe = false, cleanWhenSafe = false;
|
||||
while (secondsSince(t0) < 20.0) { // tight poll: the first "safe" must already see a settled data dir
|
||||
if (b.safeToAbandon()) {
|
||||
safe = true;
|
||||
doneWhenSafe = b.isDone();
|
||||
const bool aborted = bootstrapPathAbsent(data / "blocks") && bootstrapPathAbsent(data / "chainstate");
|
||||
const bool complete = fs::exists(data / "blocks" / "blk399.dat") &&
|
||||
fs::exists(data / "chainstate" / "000001.ldb");
|
||||
cleanWhenSafe = aborted || complete;
|
||||
break;
|
||||
}
|
||||
std::this_thread::yield();
|
||||
}
|
||||
EXPECT_TRUE(safe);
|
||||
EXPECT_TRUE(doneWhenSafe);
|
||||
EXPECT_TRUE(cleanWhenSafe);
|
||||
EXPECT_TRUE(b.cancelAndWait(std::chrono::steady_clock::now() + std::chrono::seconds(5)));
|
||||
const Bootstrap::Progress p = b.getProgress();
|
||||
EXPECT_TRUE(p.state == Bootstrap::State::Failed || p.state == Bootstrap::State::Completed);
|
||||
EXPECT_TRUE(!fs::exists(data / "blocks" / "old"));
|
||||
EXPECT_EQ(readProvisionFile(data / "wallet.dat"), walletBytes);
|
||||
removeTree(dir);
|
||||
}
|
||||
}
|
||||
|
||||
void testWriteFileAtomic()
|
||||
@@ -5784,6 +6047,31 @@ void testLongCallNoTimeout()
|
||||
EXPECT_TRUE(ok);
|
||||
}
|
||||
}
|
||||
|
||||
// Quit during the bootstrap's HEAD request (a host that accepts and never answers): the cancel ends the
|
||||
// request at once instead of after its 15 s timeout, and a partial download is kept for the next resume.
|
||||
void testBootstrapCancelEndsHeadRequest()
|
||||
{
|
||||
using dragonx::util::Bootstrap;
|
||||
LoopbackRpcStub stub(-1); // accepts, never answers
|
||||
EXPECT_TRUE(stub.port() > 0);
|
||||
const fs::path dir = makeTempDir();
|
||||
const fs::path data = dir / "DRAGONX";
|
||||
fs::create_directories(data);
|
||||
const std::string partial(1000, 'z');
|
||||
{ std::ofstream(data / "bootstrap_tmp.zip", std::ios::binary) << partial; }
|
||||
Bootstrap b;
|
||||
b.start(data.string(), "http://127.0.0.1:" + std::to_string(stub.port()) + "/DRAGONX.zip");
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(300));
|
||||
const auto t0 = std::chrono::steady_clock::now();
|
||||
EXPECT_TRUE(b.cancelAndWait(t0 + std::chrono::seconds(10)));
|
||||
EXPECT_TRUE(secondsSince(t0) < 3.0);
|
||||
const Bootstrap::Progress p = b.getProgress();
|
||||
EXPECT_TRUE(p.state == Bootstrap::State::Failed);
|
||||
EXPECT_EQ(p.error, std::string("Download cancelled"));
|
||||
EXPECT_EQ(readProvisionFile(data / "bootstrap_tmp.zip"), partial);
|
||||
removeTree(dir);
|
||||
}
|
||||
#endif // !_WIN32
|
||||
|
||||
void testRpcKindClasses()
|
||||
@@ -16938,6 +17226,7 @@ int main()
|
||||
testLongCallLaneAbortBeforeOpen();
|
||||
testLongCallLaneAbortDuringCall();
|
||||
testLongCallNoTimeout();
|
||||
testBootstrapCancelEndsHeadRequest();
|
||||
#endif
|
||||
testRpcKindClasses();
|
||||
testConnectRetryBackoff();
|
||||
@@ -17043,6 +17332,8 @@ int main()
|
||||
testBootstrapReclaimable();
|
||||
#endif
|
||||
testBootstrapPreflightKeepsOldChain();
|
||||
testBootstrapUserCancelWindow();
|
||||
testBootstrapQuitWait();
|
||||
testWriteFileAtomic();
|
||||
testCopyFileAtomic();
|
||||
testBundleCopy();
|
||||
|
||||
Reference in New Issue
Block a user