From 112c5812472d1d9ca6c539bbc94d13e027f27c8a Mon Sep 17 00:00:00 2001 From: DanS Date: Sat, 11 Jul 2026 12:32:59 -0500 Subject: [PATCH] feat(wallets): recursive subdir scan + wallet.dat encryption/seed badges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search user-added wallet folders RECURSIVELY (bounded: depth/hit/visit caps, skip_permission_denied, no symlink-follow, node-junk + node-subtree pruning); the datadir stays top-level. r.dir is each file's real parent so Open/import resolve subdir wallets. Auto-suffix the import destination on a name collision. Add util/wallet_file_probe.h: read encryption/seed/shielded flags straight off a wallet.dat WITHOUT loading it — validate the Berkeley DB btree magic, then a bounded streaming byte-scan for the length-prefixed record markers the daemon writes (mkey -> encrypted; hdseed/hdchain -> seed/HD; zkey/sapzkey -> shielded). Reads no key material. The Wallets dialog shows Encrypted / Seed / Legacy badges, plus an Unknown badge when a large file couldn't be fully scanned (so absence of a lock never falsely reads as unencrypted). Unit-tested + validated read-only against real wallets. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ui/windows/wallets_dialog.h | 159 +++++++++++++++++++++++++++----- src/util/i18n.cpp | 4 + src/util/wallet_file_probe.h | 122 ++++++++++++++++++++++++ tests/test_phase4.cpp | 56 +++++++++++ 4 files changed, 319 insertions(+), 22 deletions(-) create mode 100644 src/util/wallet_file_probe.h diff --git a/src/ui/windows/wallets_dialog.h b/src/ui/windows/wallets_dialog.h index a16b207..439d6ba 100644 --- a/src/ui/windows/wallets_dialog.h +++ b/src/ui/windows/wallets_dialog.h @@ -9,7 +9,9 @@ #pragma once +#include #include +#include #include #include #include @@ -23,6 +25,7 @@ #include "../../util/i18n.h" #include "../../util/platform.h" #include "../../util/text_format.h" +#include "../../util/wallet_file_probe.h" #include "../../embedded/IconsMaterialDesign.h" #include "../notifications.h" #include "../layout.h" @@ -134,7 +137,11 @@ public: for (std::size_t i = 0; i < s_rows.size(); ++i) { const WalletRow& r = s_rows[i]; - const data::WalletIndexEntry* meta = app->walletIndex().find(r.fileName); + // The wallet index is keyed by bare filename and is only ever written for the active + // DATADIR wallet — so an external row (esp. a same-named one surfaced by the recursive + // subdir scan) must NOT borrow a datadir wallet's cached balance/addresses. Only look up + // metadata for datadir rows; external rows show size + "never opened" (accurate). + const data::WalletIndexEntry* meta = r.inDatadir ? app->walletIndex().find(r.fileName) : nullptr; const bool isCurrent = r.inDatadir && r.fileName == active; ImVec2 rMin = ImGui::GetCursorScreenPos(); @@ -164,9 +171,23 @@ public: const float btnX = rMax.x - padX - btnW; const float textR = btnX - Layout::spacingMd(); - // Name (+ info icon for external files) + // Name (+ info icon for external files, + status badges from the no-daemon file probe) const float topY = rMin.y + cardPadY; - float extra = (!r.inDatadir) ? (metaFont->LegacySize + Layout::spacingSm()) : 0.0f; + // Right-aligned status badges: a lock (if encrypted) and a seed/legacy marker. Reserve their + // width up front so the filename truncates before them instead of overlapping. + const float badgeSz = metaFont->LegacySize; + // Only positive detections are definitive; a MISSING marker is trustworthy solely when the + // whole file was scanned (r.probeComplete). So: lock iff encrypted; seed iff hdSeed; "legacy" + // only when complete AND no seed; and when the scan was truncated without confirming + // encryption, an explicit "unknown" badge — so absence-of-lock never falsely reads as + // "unencrypted" on a huge wallet whose mkey record sat past the probe cap. + const bool bLock = r.probed && r.encrypted; + const bool bSeed = r.probed && r.hdSeed; + const bool bLegacy = r.probed && r.probeComplete && !r.hdSeed; + const bool bUnknown = r.probed && !r.probeComplete && !r.encrypted; + const int nBadges = (bLock ? 1 : 0) + (bSeed ? 1 : 0) + (bLegacy ? 1 : 0) + (bUnknown ? 1 : 0); + const float badgeReserve = nBadges > 0 ? nBadges * (badgeSz + Layout::spacingSm()) + Layout::spacingSm() : 0.0f; + float extra = ((!r.inDatadir) ? (metaFont->LegacySize + Layout::spacingSm()) : 0.0f) + badgeReserve; std::string nm = fit(r.fileName, nameFont, std::max(24.0f * dp, textR - x - extra)); dl->AddText(nameFont, nameFont->LegacySize, ImVec2(x, topY), OnSurface(), nm.c_str()); if (!r.inDatadir) { @@ -175,6 +196,20 @@ public: if (ImGui::IsMouseHoveringRect(ip, ImVec2(ip.x + metaFont->LegacySize, ip.y + metaFont->LegacySize))) Tooltip("%s\n%s", TR("wallets_external_tt"), r.dir.c_str()); } + if (nBadges > 0) { + float bx = textR; + auto badge = [&](const char* glyph, ImU32 col, const char* tip) { + bx -= badgeSz; + ImVec2 bp(bx, topY + (nameFont->LegacySize - badgeSz)); + dl->AddText(icoFont, badgeSz, bp, col, glyph); + if (ImGui::IsMouseHoveringRect(bp, ImVec2(bp.x + badgeSz, bp.y + badgeSz))) Tooltip("%s", tip); + bx -= Layout::spacingSm(); + }; + if (bLock) badge(ICON_MD_LOCK, WithAlpha(Warning(), 235), TR("wallets_badge_encrypted")); + if (bSeed) badge(ICON_MD_ECO, WithAlpha(Success(), 220), TR("wallets_badge_seed")); + if (bLegacy) badge(ICON_MD_HISTORY, WithAlpha(OnSurfaceMedium(), 210), TR("wallets_badge_legacy")); + if (bUnknown) badge(ICON_MD_HELP_OUTLINE, WithAlpha(OnSurfaceMedium(), 170), TR("wallets_badge_unknown")); + } // Metadata line (size · N addresses · balance DRGX · last opened) std::string ms = util::Platform::formatFileSize((uint64_t)r.sizeBytes); @@ -288,6 +323,11 @@ private: std::string dir; bool inDatadir = false; long long sizeBytes = 0; + // Light facts read off the wallet.dat file WITHOUT loading it (see util/wallet_file_probe.h). + bool probed = false; // the file validated as a BDB wallet and was scanned + bool probeComplete = false; // scan covered the whole file → a MISSING marker is trustworthy + bool encrypted = false; // passphrase-encrypted (has an mkey record) + bool hdSeed = false; // HD/seed wallet (else legacy) — ties into migrate-to-seed }; // Force a "wallet-...dat" filename (plain, no path) so a new/imported wallet shows in the @@ -312,12 +352,21 @@ private: // from there) under a wallet-*.dat name, then switch to it. Never overwrites an existing file. static void importAndOpen(App* app, const WalletRow& r) { namespace fs = std::filesystem; - std::string dest = normalizeWalletName(r.fileName); - if (dest.empty()) { Notifications::instance().warning(TR("wallets_name_invalid")); return; } + const std::string base = normalizeWalletName(r.fileName); + if (base.empty()) { Notifications::instance().warning(TR("wallets_name_invalid")); return; } std::error_code ec; const std::string datadir = util::Platform::getDragonXDataDir(); fs::create_directories(datadir, ec); - const std::string destPath = datadir + "/" + dest; + // The recursive subdir scan can surface several distinct wallets sharing one basename (e.g. a + // "wallet.dat" in two backup folders). Auto-suffix the datadir destination to the next free name + // instead of hard-failing, so the second one is importable too. Never overwrites an existing file. + const std::string stem = base.substr(0, base.size() - 4); // strip ".dat" + std::string dest = base; + std::string destPath = datadir + "/" + dest; + for (int n = 2; n < 1000 && fs::exists(destPath, ec); ++n) { + dest = stem + "-" + std::to_string(n) + ".dat"; + destPath = datadir + "/" + dest; + } if (fs::exists(destPath, ec)) { Notifications::instance().warning(TR("wallets_exists")); return; } fs::copy_file(r.dir + "/" + r.fileName, destPath, ec); if (ec) { Notifications::instance().error(TR("wallets_import_failed")); return; } @@ -327,32 +376,98 @@ private: } // Scan the datadir (wallet-prefixed *.dat only, to skip peers.dat / asmap.dat / fee_estimates.dat) - // and each user-added folder (any *.dat). Exception-safe: iterates with error_codes. + // and each user-added folder (any *.dat). User-added folders are searched RECURSIVELY into + // subdirectories; the datadir stays top-level only (never descend into blocks/ chainstate/ …). + // Exception-safe: iterates with error_codes. Recursion is bounded (depth / hit / visit caps) and + // never follows directory symlinks (std default), so it can't loop or stall the UI on a huge tree. static void scan() { s_rows.clear(); namespace fs = std::filesystem; - auto addFrom = [](const std::string& dir, bool inDatadir) { + // Guards for recursive external-folder scans (a user could point us at a large tree / home dir). + constexpr int kMaxDepth = 8; // don't descend deeper than this below the chosen folder + constexpr size_t kMaxHits = 200; // cap total wallet files listed + constexpr int kMaxVisited = 40000; // cap total entries walked, so scan() can't stall the UI + + // Shared budget for the no-daemon wallet.dat probes (encryption / seed badges) so a pile of large + // wallets can't turn scan() into a multi-hundred-MB read on the UI thread; each file is capped too. + // Cover realistic wallets fully (the active wallet can be well over 100 MB of tx history, and a + // BDB btree sorts long-keyed records — mkey/hdchain — LATE, so a small cap risks a false-negative + // badge). Per-file 256 MB + a shared budget keep even that a bounded, one-shot read on dialog open. + std::size_t probeBudget = 768u * 1024u * 1024u; + constexpr std::size_t kProbePerFile = 256u * 1024u * 1024u; + + // Node/daemon .dat files that are NOT wallets. The datadir scan already restricts to a "wallet" + // prefix, but recursive external folders accept any *.dat — and a user can easily point the picker + // at a folder that IS or CONTAINS a datadir, so filter these out everywhere or the list fills with + // blk*/rev* block files, peers.dat, etc. presented as importable "wallets". + auto isNodeArtifact = [](const std::string& n) { + static const char* kExact[] = { "peers.dat", "banlist.dat", "fee_estimates.dat", + "mempool.dat", "asmap.dat", "zindex.dat" }; + for (const char* e : kExact) if (n == e) return true; + return n.rfind("blk", 0) == 0 || n.rfind("rev", 0) == 0; // blk00000.dat / rev00000.dat + }; + auto consider = [&](const fs::path& p, bool inDatadir) { + // String-only checks FIRST (no syscalls) — the vast majority of entries in a large tree are + // neither .dat nor wallets, so reject them before paying for a stat(). + const std::string name = p.filename().string(); + if (name.size() <= 4 || name.substr(name.size() - 4) != ".dat") return; + if (inDatadir) { if (name.rfind("wallet", 0) != 0) return; } // datadir: wallet-prefixed only + else { if (isNodeArtifact(name)) return; } // external: reject node junk + std::error_code fec; + if (!fs::is_regular_file(p, fec)) return; + WalletRow r; + r.fileName = name; + r.dir = p.parent_path().string(); // the file's ACTUAL parent (may be a subdir) — Open reads r.dir + "/" + r.fileName + r.inDatadir = inDatadir; + r.sizeBytes = static_cast(fs::file_size(p, fec)); + // Read encryption / seed flags straight off the file (no daemon), within the byte budget. + if (probeBudget > 0) { + const auto pr = util::probeWalletFile(p.string(), std::min(probeBudget, kProbePerFile)); + r.probed = pr.isBerkeleyDB; + r.probeComplete = pr.scanComplete; + r.encrypted = pr.encrypted; + r.hdSeed = pr.hdSeed; + // Charge the budget by bytes ACTUALLY read (early-exit reads far less than a big file), + // so a few large wallets don't starve later rows of their probe. + probeBudget -= std::min(probeBudget, pr.bytesRead); + } + s_rows.push_back(std::move(r)); + }; + // Directory names we never descend into during a recursive external scan — node data subtrees hold + // no user wallets and would otherwise burn the visit budget (and flood the list) with block files. + auto isNodeSubtree = [](const std::string& n) { + return n == "blocks" || n == "chainstate" || n == "database" || + n == "indexes" || n == "index"; + }; + auto addFrom = [&](const std::string& dir, bool inDatadir, bool recursive) { std::error_code ec; if (dir.empty() || !fs::is_directory(dir, ec)) return; - fs::directory_iterator it(dir, ec), end; + if (!recursive) { + fs::directory_iterator it(dir, ec), end; + for (; !ec && it != end; it.increment(ec)) consider(it->path(), inDatadir); + return; + } + // skip_permission_denied keeps a locked subdir from aborting the whole walk; the iterator + // does NOT follow directory symlinks by default, so symlink cycles can't cause infinite loops. + fs::recursive_directory_iterator it(dir, fs::directory_options::skip_permission_denied, ec), end; + int visited = 0; for (; !ec && it != end; it.increment(ec)) { - std::error_code fec; - if (!it->is_regular_file(fec)) continue; - std::string name = it->path().filename().string(); - if (name.size() <= 4 || name.substr(name.size() - 4) != ".dat") continue; - if (inDatadir && name.rfind("wallet", 0) != 0) continue; - WalletRow r; - r.fileName = name; - r.dir = dir; - r.inDatadir = inDatadir; - r.sizeBytes = static_cast(fs::file_size(it->path(), fec)); - s_rows.push_back(std::move(r)); + if (++visited > kMaxVisited) break; + std::error_code dec; + if (it->is_directory(dec)) { + // Prune: don't descend past the depth cap or into node data subtrees. + if (it.depth() >= kMaxDepth || isNodeSubtree(it->path().filename().string())) + it.disable_recursion_pending(); + continue; // directories aren't wallet files + } + consider(it->path(), inDatadir); + if (s_rows.size() >= kMaxHits) break; } }; - addFrom(util::Platform::getDragonXDataDir(), /*inDatadir=*/true); + addFrom(util::Platform::getDragonXDataDir(), /*inDatadir=*/true, /*recursive=*/false); if (s_app) for (const auto& f : s_app->walletIndex().extraFolders()) - addFrom(f, /*inDatadir=*/false); + addFrom(f, /*inDatadir=*/false, /*recursive=*/true); } static inline bool s_open = false; diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index c58a26b..df9fc90 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -262,6 +262,10 @@ void I18n::loadBuiltinEnglish() strings_["wallets_col_opened"] = "Last opened"; strings_["wallets_current"] = "current"; strings_["wallets_active"] = "Active"; + strings_["wallets_badge_encrypted"] = "Encrypted (passphrase-protected)"; + strings_["wallets_badge_seed"] = "Seed phrase wallet (HD)"; + strings_["wallets_badge_legacy"] = "Legacy wallet (no seed phrase)"; + strings_["wallets_badge_unknown"] = "Wallet type not fully determined (large file — open to confirm)"; strings_["wallets_open"] = "Open"; strings_["wallets_never"] = "Never"; strings_["wallets_import_hint"] = "Import to open"; diff --git a/src/util/wallet_file_probe.h b/src/util/wallet_file_probe.h new file mode 100644 index 0000000..0e3a629 --- /dev/null +++ b/src/util/wallet_file_probe.h @@ -0,0 +1,122 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 +// +// wallet_file_probe.h — read light metadata off a wallet.dat WITHOUT loading it into the daemon. +// +// DragonX wallet.dat is a Berkeley DB (btree) key/value store. Only the private-key material is +// encrypted (ckey/czkey/csapzkey/chdseed); the record *keys* and non-secret metadata live in +// plaintext, so a handful of boolean facts can be recovered cheaply by (a) validating the BDB btree +// magic and (b) scanning the raw bytes for the length-prefixed record names the daemon writes +// (a std::string is serialized as CompactSize(len)+bytes, so e.g. an "mkey" record's key begins with +// the 5 bytes 0x04 'm' 'k' 'e' 'y'). This is a heuristic PRESENCE test — reliable for booleans, not +// for exact counts — with no libdb dependency and no daemon. It never reads or exposes key material. + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace dragonx { +namespace util { + +struct WalletFileProbe { + bool isBerkeleyDB = false; ///< file has a valid BDB btree metapage magic (looks like a real wallet.dat) + bool encrypted = false; ///< has an "mkey" master-key record → passphrase-encrypted + bool hdSeed = false; ///< has hdseed/chdseed/hdchain → HD/seed wallet (else legacy) + bool hasShielded = false; ///< has zkey/czkey/sapzkey/csapzkey → holds shielded addresses + // A found marker is always definitive; a MISSING marker is only trustworthy when the scan actually + // covered the whole file. `scanComplete` is true when we reached EOF, or early-exited having found + // everything — false only when the byte cap cut the scan short. Callers should treat negative flags + // (e.g. "legacy" = !hdSeed, or "not encrypted" = !encrypted) as reliable only when scanComplete is true. + bool scanComplete = false; + std::size_t bytesRead = 0; ///< bytes actually consumed (early-exit reads far less than the file) — for budgeting +}; + +// Probe a wallet.dat by header-validating it as a BDB btree then byte-scanning (bounded, streaming, +// early-exit) for record markers. `maxBytes` caps how far we read so a pathologically large file can't +// stall the caller. Returns an all-false probe (isBerkeleyDB=false) for anything that isn't a readable +// BDB btree file. Safe to call on a wallet currently open by the daemon (read-only, pattern scan only). +inline WalletFileProbe probeWalletFile(const std::string& path, + std::size_t maxBytes = 96u * 1024u * 1024u) { + WalletFileProbe out; + std::ifstream f(path, std::ios::binary); + if (!f) return out; + + // --- 1) Validate the Berkeley DB btree metapage magic (offset 12, either endianness). --- + unsigned char hdr[16]; + f.read(reinterpret_cast(hdr), sizeof(hdr)); + if (f.gcount() < static_cast(sizeof(hdr))) return out; + auto rd32 = [](const unsigned char* p, bool le) -> uint32_t { + return le ? (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24) + : (uint32_t)p[3] | ((uint32_t)p[2] << 8) | ((uint32_t)p[1] << 16) | ((uint32_t)p[0] << 24); + }; + constexpr uint32_t kBtreeMagic = 0x00053162u; // DB_BTREEMAGIC + if (rd32(hdr + 12, true) != kBtreeMagic && rd32(hdr + 12, false) != kBtreeMagic) return out; + out.isBerkeleyDB = true; + + // --- 2) Streaming byte-scan for length-prefixed record markers. --- + // group: 0 = encrypted, 1 = hd/seed, 2 = shielded. + const std::vector> patterns = { + {std::string("\x04", 1) + "mkey", 0}, // master key → wallet is encrypted + {std::string("\x06", 1) + "hdseed", 1}, // plaintext HD seed + {std::string("\x07", 1) + "chdseed", 1}, // encrypted HD seed + {std::string("\x07", 1) + "hdchain", 1}, // HD chain counter (present in both HD variants) + {std::string("\x04", 1) + "zkey", 2}, // sprout shielded key + {std::string("\x05", 1) + "czkey", 2}, // encrypted sprout key + {std::string("\x07", 1) + "sapzkey", 2}, // sapling shielded key + {std::string("\x08", 1) + "csapzkey", 2}, // encrypted sapling key + }; + std::size_t maxPat = 0; + for (const auto& p : patterns) maxPat = std::max(maxPat, p.first.size()); + const std::size_t overlap = maxPat > 0 ? maxPat - 1 : 0; // bytes carried between chunks for split matches + + f.clear(); + f.seekg(0, std::ios::beg); + constexpr std::size_t kChunk = 1u << 20; // 1 MiB + std::vector buf(kChunk); + std::string carry; + std::size_t readTotal = 0; + bool enc = false, hd = false, sh = false; + bool eof = false, allFound = false; + while (readTotal < maxBytes) { + const std::size_t want = std::min(kChunk, maxBytes - readTotal); + f.read(buf.data(), static_cast(want)); + const std::streamsize got = f.gcount(); + if (got <= 0) { eof = true; break; } + readTotal += static_cast(got); + + std::string window; + window.reserve(carry.size() + static_cast(got)); + window.assign(carry); + window.append(buf.data(), static_cast(got)); + + for (const auto& p : patterns) { + bool& flag = (p.second == 0) ? enc : (p.second == 1) ? hd : sh; + if (!flag && window.find(p.first) != std::string::npos) flag = true; + } + if (enc && hd && sh) { allFound = true; break; } // nothing more to learn + + if (window.size() > overlap) carry.assign(window.data() + window.size() - overlap, overlap); + else carry.assign(window); + if (static_cast(got) < want) { eof = true; break; } // reached EOF + } + out.encrypted = enc; + out.hdSeed = hd; + out.hasShielded = sh; + out.bytesRead = readTotal; + // false only if the byte cap cut a larger file short. If we stopped because readTotal hit maxBytes on + // an exact chunk boundary, ifstream never set eofbit — peek() tells us whether the file actually ended + // there (EOF → complete) or there's more beyond the cap (a real byte → truncated). + out.scanComplete = allFound || eof; + if (!out.scanComplete && f && f.peek() == std::ifstream::traits_type::eof()) out.scanComplete = true; + return out; +} + +} // namespace util +} // namespace dragonx diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 0a309bc..98adbb0 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -36,6 +36,7 @@ #include "util/pool_registry.h" #include "util/daemon_updater.h" #include "util/lite_server_probe.h" +#include "util/wallet_file_probe.h" #include "wallet/lite_connection_service.h" #include "wallet/lite_diagnostics.h" #include "wallet/lite_owned_string.h" @@ -737,6 +738,60 @@ void testPaymentUri() EXPECT_EQ(invalid.error, std::string("Invalid negative amount")); } +void testWalletFileProbe() +{ + namespace fs = std::filesystem; + using dragonx::util::probeWalletFile; + const fs::path dir = fs::path("/tmp/dragonx") / "probe"; + std::error_code ec; fs::create_directories(dir, ec); + auto writef = [](const fs::path& p, const std::string& d) { + std::ofstream o(p, std::ios::binary); o.write(d.data(), (std::streamsize)d.size()); + }; + // A valid Berkeley DB btree metapage has the magic 0x00053162 at byte offset 12 (little-endian here). + auto bdb = []() { std::string h(16, '\0'); h[12] = 0x62; h[13] = 0x31; h[14] = 0x05; h[15] = 0x00; return h; }; + // Wallet records are keyed by a CompactSize-length-prefixed name, e.g. "mkey" -> {0x04,'m','k','e','y'}. + auto rec = [](const std::string& name) { return std::string(1, (char)name.size()) + name; }; + + // 1) Non-BDB file → not recognized, no flags. + writef(dir / "junk.dat", std::string(4096, 'x')); + { auto p = probeWalletFile((dir / "junk.dat").string()); + EXPECT_FALSE(p.isBerkeleyDB); EXPECT_FALSE(p.encrypted); EXPECT_FALSE(p.hdSeed); } + + // 2) BDB with no markers → legacy plaintext; a complete scan makes the negatives trustworthy. + writef(dir / "legacy.dat", bdb() + std::string(2000, '\0')); + { auto p = probeWalletFile((dir / "legacy.dat").string()); + EXPECT_TRUE(p.isBerkeleyDB); EXPECT_FALSE(p.encrypted); EXPECT_FALSE(p.hdSeed); + EXPECT_FALSE(p.hasShielded); EXPECT_TRUE(p.scanComplete); } + + // 3) Encrypted (mkey) + HD (hdchain) + shielded (csapzkey). + writef(dir / "enc.dat", bdb() + std::string(500, 'a') + rec("mkey") + std::string(300, 'b') + + rec("hdchain") + std::string(300, 'c') + rec("csapzkey") + std::string(500, 'd')); + { auto p = probeWalletFile((dir / "enc.dat").string()); + EXPECT_TRUE(p.encrypted); EXPECT_TRUE(p.hdSeed); EXPECT_TRUE(p.hasShielded); } + + // 4) False-positive guard: the bare word "mkey" WITHOUT the 0x04 length prefix must not count. + writef(dir / "bare.dat", bdb() + std::string(100, '\0') + "mkey" + std::string(100, '\0')); + { auto p = probeWalletFile((dir / "bare.dat").string()); EXPECT_FALSE(p.encrypted); } + + // 5) Byte cap: a marker beyond maxBytes is missed AND the scan is flagged incomplete (so a caller + // won't wrongly assert "legacy"); the same file scanned fully finds it and reports complete. + { std::string d = bdb(); d.resize(2u * 1024 * 1024, 'q'); d += rec("mkey"); writef(dir / "deep.dat", d); + auto capped = probeWalletFile((dir / "deep.dat").string(), 1024u * 1024u); + EXPECT_TRUE(capped.isBerkeleyDB); EXPECT_FALSE(capped.encrypted); EXPECT_FALSE(capped.scanComplete); + EXPECT_TRUE(capped.bytesRead <= 1024u * 1024u); // budget accounting reflects a truncated read + auto full = probeWalletFile((dir / "deep.dat").string()); + EXPECT_TRUE(full.encrypted); EXPECT_TRUE(full.scanComplete); } + + // 6) Exact-boundary case: a file whose length is an exact 1 MiB multiple (and == maxBytes) must still + // report scanComplete=true (the peek() guard), so a fully-scanned wallet isn't mislabeled truncated. + { std::string d = bdb(); d.resize(1024u * 1024u, 'e'); // exactly one 1 MiB chunk, no markers + writef(dir / "exact.dat", d); + auto p = probeWalletFile((dir / "exact.dat").string(), 1024u * 1024u); + EXPECT_TRUE(p.isBerkeleyDB); EXPECT_TRUE(p.scanComplete); EXPECT_FALSE(p.encrypted); EXPECT_FALSE(p.hdSeed); } + + fs::remove_all(dir, ec); +} + void testAmountFormatting() { EXPECT_EQ(dragonx::util::formatAmountFixed(1.0), std::string("1.00000000")); @@ -6263,6 +6318,7 @@ int main() testHushChatOutgoing(); testHushChatTransport(); testHushChatShuffledReceive(); + testWalletFileProbe(); testAddressChecksumValidation(); testLiteServerProbeLive(); testXmrigLiveInstall();