feat(wallets): recursive subdir scan + wallet.dat encryption/seed badges
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) <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,9 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
@@ -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<long long>(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<long long>(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;
|
||||
|
||||
Reference in New Issue
Block a user