diff --git a/src/ui/windows/wallets_dialog.h b/src/ui/windows/wallets_dialog.h index 4c0d25b..db6022f 100644 --- a/src/ui/windows/wallets_dialog.h +++ b/src/ui/windows/wallets_dialog.h @@ -20,6 +20,7 @@ #include #include #include +#include #include #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 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 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(fs::file_size(p, fec)); s_rows.push_back(std::move(r)); // encryption/seed flags are filled in asynchronously (see below)