fix(wallets): key in-place links on the canonical path (one wallet, one link)
A wallet reached via two different path strings — e.g. a symlinked mount (/mnt/usb) and its real target (/media/usb) — used to hash to two different in-place link names and list as two rows, so the same file looked like two wallets with two datadir links. Give WalletRow a canonPath (fs::canonical, symlinks resolved, computed once at scan time so the every-frame render/active-marking path stays cheap) and key both the link name and the row de-dup on it. Now the same physical wallet maps to one identity regardless of the path used to reach it: one link name (honors the "distinct per-target name" invariant), and scan() lists it once — datadir first, so a datadir wallet wins over the same file seen via an external path. The link also points straight at the resolved real file instead of through the symlinked path. Genuinely distinct wallets keep distinct identities (verified with a standalone symlinked-mount harness). Closes the last deferred finding from the open-in-place adversarial review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
#include "imgui.h"
|
||||
@@ -115,7 +116,7 @@ public:
|
||||
// is the external wallet whose path hashes to it. Precompute per row so the sort stays cheap.
|
||||
const bool linkMode = isLinkName(active);
|
||||
auto rowIsActive = [&](const WalletRow& row) {
|
||||
return linkMode ? (!row.inDatadir && linkNameFor(row.dir + "/" + row.fileName) == active)
|
||||
return linkMode ? (!row.inDatadir && linkNameFor(row.canonPath) == active)
|
||||
: (row.inDatadir && row.fileName == active);
|
||||
};
|
||||
std::vector<char> rowActive(s_rows.size(), 0);
|
||||
@@ -448,6 +449,9 @@ private:
|
||||
struct WalletRow {
|
||||
std::string fileName;
|
||||
std::string dir;
|
||||
std::string canonPath; // symlink-resolved, absolute path — the STABLE identity of the physical
|
||||
// file (two paths reaching the same wallet share it), used for the
|
||||
// in-place link name + to de-dup rows. Computed once at scan time.
|
||||
bool inDatadir = false;
|
||||
long long sizeBytes = 0;
|
||||
};
|
||||
@@ -498,7 +502,8 @@ private:
|
||||
static bool isLinkName(const std::string& n) { return n.rfind(kLinkPrefix, 0) == 0; }
|
||||
|
||||
// Stable per-target bare link name, e.g. "wallet-ip-1a2b3c4d.dat" (FNV-1a of the absolute path — a
|
||||
// deterministic, cross-platform, cross-run hash, unlike std::hash).
|
||||
// deterministic, cross-platform, cross-run hash, unlike std::hash). Feed it a canonicalOf() path so the
|
||||
// SAME physical wallet always maps to the same name regardless of which path reached it.
|
||||
static std::string linkNameFor(const std::string& absPath) {
|
||||
std::uint64_t h = 1469598103934665603ULL;
|
||||
for (unsigned char c : absPath) { h ^= c; h *= 1099511628211ULL; }
|
||||
@@ -508,6 +513,18 @@ private:
|
||||
return buf;
|
||||
}
|
||||
|
||||
// Symlink-resolved, absolute path — the stable identity of a physical wallet file. Two different path
|
||||
// strings that reach the same file (e.g. a symlinked mount /mnt/usb vs the real /media/usb) collapse to
|
||||
// one value, so they share a single in-place link name and de-dup to one row. fs::canonical resolves
|
||||
// symlinks (the file exists at scan time); on failure fall back to a lexical-absolute path.
|
||||
static std::string canonicalOf(const std::filesystem::path& p) {
|
||||
namespace fs = std::filesystem;
|
||||
std::error_code ec;
|
||||
fs::path c = fs::canonical(p, ec);
|
||||
if (ec) c = fs::weakly_canonical(p, ec);
|
||||
return c.empty() ? p.string() : c.string();
|
||||
}
|
||||
|
||||
// Open an out-of-datadir wallet IN PLACE. The daemon only loads a bare filename from its datadir, so
|
||||
// link the real file in under its per-target name and switch to that. On Linux/macOS prefer a SYMLINK
|
||||
// (no privileges, spans volumes so a wallet on a USB / other partition works, and it's visibly a
|
||||
@@ -525,7 +542,10 @@ private:
|
||||
// switch to it, and the daemon guard would silently fall back to wallet.dat — "opened X, got the
|
||||
// default". Refresh the list instead so the vanished row drops out.
|
||||
if (!fs::exists(src, ec)) { scan(); return; }
|
||||
const std::string linkName = linkNameFor(src);
|
||||
// canonPath (symlinks resolved) is the wallet's stable identity: hash it so the same file reached
|
||||
// via any path maps to ONE link name, and point the link straight at the resolved real file.
|
||||
const std::string target = r.canonPath.empty() ? canonicalOf(src) : r.canonPath;
|
||||
const std::string linkName = linkNameFor(target);
|
||||
const std::string linkPath = datadir + "/" + linkName;
|
||||
|
||||
// Reconcile whatever already holds our reserved name. We only ever reclaim a link of OURS — never
|
||||
@@ -550,10 +570,6 @@ private:
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Absolute, lexically-normalized target. weakly_canonical is lexical — it does NOT follow symlinks;
|
||||
// a symlinked src is left for the daemon / filesystem to resolve (fs::equivalent above accounts for
|
||||
// that too).
|
||||
const std::string target = [&]{ auto c = fs::weakly_canonical(src, ec); return c.empty() ? src : c.string(); }();
|
||||
std::error_code lec;
|
||||
#ifdef _WIN32
|
||||
fs::create_hard_link(target, linkPath, lec); // 1) hard link — no privileges, same volume
|
||||
@@ -594,6 +610,9 @@ private:
|
||||
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
|
||||
};
|
||||
std::unordered_set<std::string> seenCanon; // one row per physical wallet. The datadir is scanned
|
||||
// first, so a datadir wallet wins over the same file
|
||||
// reached via an external (possibly symlinked) path.
|
||||
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().
|
||||
@@ -604,8 +623,10 @@ private:
|
||||
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.fileName = name;
|
||||
r.dir = p.parent_path().string(); // the file's ACTUAL parent (may be a subdir) — Open reads r.dir + "/" + r.fileName
|
||||
r.canonPath = canonicalOf(p); // symlink-resolved identity (see below)
|
||||
if (!seenCanon.insert(r.canonPath).second) return; // same physical wallet already listed via another path
|
||||
r.inDatadir = inDatadir;
|
||||
r.sizeBytes = static_cast<long long>(fs::file_size(p, fec));
|
||||
s_rows.push_back(std::move(r)); // encryption/seed flags are filled in asynchronously (see below)
|
||||
|
||||
Reference in New Issue
Block a user