feat(wallets): prefer symlink on Linux/macOS for open-in-place

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) <noreply@anthropic.com>
This commit is contained in:
2026-07-11 21:39:57 -05:00
parent b7da6335d1
commit 6aaab9e01f

View File

@@ -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