From 6aaab9e01f3a0f9bc81d17c27f2b5c5c70bee5bb Mon Sep 17 00:00:00 2001 From: DanS Date: Sat, 11 Jul 2026 21:39:57 -0500 Subject: [PATCH] feat(wallets): prefer symlink on Linux/macOS for open-in-place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Unix a symlink is the better datadir link than a hard link: it needs no privilege, spans volumes (a wallet on a USB / other partition just works, where a hard link can't), and is visibly a pointer rather than an indistinguishable second copy. Windows keeps hard-link-first (a symlink there needs admin / Developer Mode), falling back to a symlink. Symlink-first made two latent issues in the pre-clear reachable, fixed here after an adversarial review: - Dangling prior symlink: fs::exists follows the link (false when the source moved), so it was skipped and create_symlink then tripped on the occupied path. Catch it with fs::is_symlink (lstat) and replace it. - Stale reserved-name occupant: the old "hard_link_count == 1 → reuse" path could load the WRONG wallet — a count-1 file at our name is not a valid link to the source (a live hard link has count >= 2), it's a stale orphan (source deleted, or a copy left by a datadir migrated across filesystems). Gate reuse on fs::equivalent(link, src) (same inode) instead, and reclaim the name only when the occupant is provably redundant (another hard link still holds the data); otherwise refuse rather than risk destroying something unique. So hard_link_count is no longer trusted to prove identity — only data-safety. Also guard against a stale row (source moved/deleted between the scan and the click): !exists(src) refreshes the list instead of linking a ghost the daemon would silently swap for wallet.dat. Verified with a standalone harness across fresh / re-open / valid-hardlink-reuse / stale-orphan / redundant-occupant / vanished-source cases. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ui/windows/wallets_dialog.h | 48 ++++++++++++++++++++++++++------- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/src/ui/windows/wallets_dialog.h b/src/ui/windows/wallets_dialog.h index d943543..4c0d25b 100644 --- a/src/ui/windows/wallets_dialog.h +++ b/src/ui/windows/wallets_dialog.h @@ -509,31 +509,59 @@ private: } // 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. Prefer a hard link (no - // privileges, same volume — covers most users incl. non-admin Windows), fall back to a symlink - // (cross-volume). We NEVER copy (that would fork the wallet), and NEVER delete a real file. + // 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 + // pointer — not a duplicate); on Windows prefer a HARD LINK (a symlink there needs admin / Developer + // Mode), falling back to a symlink. Either way we NEVER copy (that would fork the wallet) and NEVER + // delete a real file — only our own link. static void openInPlace(App* app, const WalletRow& r) { namespace fs = std::filesystem; std::error_code ec; const std::string datadir = util::Platform::getDragonXDataDir(); fs::create_directories(datadir, ec); const std::string src = r.dir + "/" + r.fileName; + // The row could be stale (file moved/deleted between the scan and this click). Don't link a ghost: + // on Linux/macOS create_symlink to a missing target would happily succeed (a dangling link), we'd + // 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); const std::string linkPath = datadir + "/" + linkName; - // Clear only a prior LINK for this same wallet — never a standalone regular file (belt-and-braces; - // the reserved prefix already keeps users from creating a real wallet with this name). - if (fs::exists(linkPath, ec)) { - if (fs::is_symlink(linkPath, ec) || fs::hard_link_count(linkPath, ec) > 1) { + // Reconcile whatever already holds our reserved name. We only ever reclaim a link of OURS — never + // destroy an unknown file's unique data. is_symlink uses lstat, so it also catches a DANGLING prior + // symlink (source moved) that fs::exists — which follows the link — would miss and that + // create_symlink would then trip over ("file exists"). + if (fs::is_symlink(linkPath, ec)) { + fs::remove(linkPath, ec); // our prior symlink (maybe dangling) — replace it + } else if (fs::exists(linkPath, ec)) { + if (fs::equivalent(linkPath, src, ec)) { // already a valid hard link to THIS wallet + app->switchToWallet(linkName); s_open = false; return; // → reuse as-is (same inode) + } + // A DIFFERENT file holds our reserved name. scan()/create() forbid real wallets at this prefix, + // so it's a stale orphan (a hard link whose source moved, or a copy left by a datadir that was + // migrated across filesystems) — NOT the current wallet; reusing it would load the wrong + // balance. Reclaim the name only if the file is provably redundant (another hard link still + // holds the data); otherwise refuse rather than risk destroying something unique. + if (fs::hard_link_count(linkPath, ec) > 1) { fs::remove(linkPath, ec); } else { - app->switchToWallet(linkName); s_open = false; return; // already a real file at our name — reuse, don't touch + Notifications::instance().error(TR("wallets_open_failed")); + return; } } - const std::string target = [&]{ auto c = fs::weakly_canonical(src, ec); return c.empty() ? src : c.string(); }(); // resolve a symlinked source + // 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 - if (lec) { lec.clear(); fs::create_symlink(target, linkPath, lec); } // 2) symlink — cross-volume (needs privilege on Windows) + if (lec) { lec.clear(); fs::create_symlink(target, linkPath, lec); } // 2) symlink — cross-volume (needs admin / Developer Mode) +#else + fs::create_symlink(target, linkPath, lec); // 1) symlink — no privileges, spans volumes + if (lec) { lec.clear(); fs::create_hard_link(target, linkPath, lec); } // 2) hard link — same-volume fallback +#endif if (lec) { Notifications::instance().error(TR("wallets_open_failed")); return; } // no copy fallback (would fork the wallet) app->switchToWallet(linkName); // distinct per wallet → the index tracks rescan/cache correctly