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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user