fix(sweep): keep the UI sweep away from real wallet data

The full UI sweep (dev builds only) ran its demo surfaces against the
real profile. Its teardown deleted wallet-{cold,trading,mining,donations,
2023,payroll}.dat from the real datadir, it created zero-filled wallet
files and picker-demo/extra-wallets folders, its demo wallet-index upsert
re-minted the active wallet's scopeId (hiding "w:"-scoped contacts), the
Contacts tab could re-attach legacy-scoped contacts to such a throwaway
id in addressbook.json, and it persisted the demo active wallet file and
Mining pool worker.

- Preflight: a full sweep is refused (toast + log line) while a real
  workflow is in flight: a pending seed_migration_* setting or open
  migrate dialog, a wallet switch, an encryption/import workflow, an
  encrypt/PIN/lock operation, the seed-backup dialog, or a bootstrap.
  The DRAGONX_FULL_SWEEP hook then exits as for an empty catalog.
- Persistence latches: AddressBook (snapshot on the first sweepBegin,
  save/load latched while a demo-contacts surface is shown), WalletIndex
  and Settings (active wallet file, contacts view mode, pool URL/worker)
  for the whole sweep. Settings::sweepEnd() is the last statement of
  clearDemoWalletData and flushes once only if a save was suppressed; a
  quit mid-sweep writes nothing to the profile (the 0700 sandbox, with
  zero-filled demo files only, may stay in the temp dir).
- Scope ids: during a full sweep activeWalletScopeId() returns the
  persisted id or "" and never mints one, so the Contacts tab cannot
  move legacy-scoped contacts to a throwaway id. The only address-book
  write a sweep can still cause is the one-time legacy re-attach to the
  wallet's persisted id, which opening Contacts does anyway.
- Sandbox: demo wallet files and folders go to a fresh random 0700 dir
  under the temp dir (exclusive create, a pre-existing path or symlink
  is refused), removed at sweep end. The teardown that deleted those six
  wallet files is gone.
- Cleanup: the migrate-to-seed state, wizard phase, demo chat
  conversations and a fabricated demo chat identity are reset at sweep
  end; the seed-backup surface always shows the demo phrase and a real
  z_exportmnemonic result is dropped while the sweep runs.
- i18n: one new key (screenshot_sweep_refused) in i18n.cpp and the 8
  language files.

Tests: the address book snapshots only on the first sweepBegin, keeps
addressbook.json byte-identical while latched and restores the real
book; the wallet index keeps wallets.json and the real scope id, also
with the background writer attached; Settings restores the latched
values and flushes once only when a save was suppressed; the sandbox
helper refuses a pre-existing path or symlink and removes only a
prefixed dir, never a symlink's target.

Release note: v2.0.x UI sweeps could delete wallet-{cold,trading,mining,
donations,2023,payroll}.dat from the data directory and re-scope
contacts; this cannot be undone automatically.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 13:22:00 -05:00
co-authored by Claude Opus 5.5
parent 3ac46abe1a
commit 62a5577491
22 changed files with 625 additions and 40 deletions
+267
View File
@@ -73,6 +73,7 @@
#include "util/amount_format.h"
#include "util/payment_uri.h"
#include "util/platform.h"
#include "util/private_temp_dir.h"
#include "util/i18n.h"
#include "util/xmrig_updater.h"
#include "util/pool_registry.h"
@@ -12361,6 +12362,268 @@ void testWalletIndex()
#endif // !_WIN32
}
// C91: the full UI sweep's persistence latches and its sandbox helper.
static std::string sweepReadBytes(const fs::path& p)
{
std::ifstream in(p, std::ios::binary);
return std::string(std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>());
}
#ifndef _WIN32
// Run `body` with HOME and XDG_CONFIG_HOME pointing into a fresh temp dir (the config dir follows
// $HOME), then put both back. Not on Windows: its config dir can't be redirected.
static void withSweepTempHome(const std::function<void()>& body)
{
const char* oldHome = std::getenv("HOME");
const std::string savedHome = oldHome ? oldHome : "";
const char* oldXdg = std::getenv("XDG_CONFIG_HOME");
const bool hadXdg = oldXdg != nullptr;
const std::string savedXdg = hadXdg ? oldXdg : "";
const fs::path tmp = makeTempDir();
setenv("HOME", tmp.string().c_str(), 1);
setenv("XDG_CONFIG_HOME", (tmp / ".config").string().c_str(), 1);
body();
if (!savedHome.empty()) setenv("HOME", savedHome.c_str(), 1); else unsetenv("HOME");
if (hadXdg) setenv("XDG_CONFIG_HOME", savedXdg.c_str(), 1); else unsetenv("XDG_CONFIG_HOME");
removeTree(tmp);
}
#endif
// The sweep re-runs its contacts setup every frame: only the FIRST sweepBegin may snapshot (the old
// per-frame snapshot "restored" demo contacts), and nothing may reach addressbook.json meanwhile.
void testSweepAddressBookLatch()
{
#ifdef _WIN32
std::printf("[skip] testSweepAddressBookLatch (Windows config dir can't be redirected)\n");
#else
using dragonx::data::AddressBook;
using dragonx::data::AddressBookEntry;
withSweepTempHome([] {
AddressBook book;
book.load();
EXPECT_TRUE(book.addEntry(AddressBookEntry("Alice", "zs1alicereal", "", "w:abc")));
EXPECT_TRUE(book.addEntry(AddressBookEntry("Bob", "zs1bobreal", "", "global")));
const fs::path file = AddressBook::getDefaultPath();
const std::string before = sweepReadBytes(file);
EXPECT_TRUE(!before.empty());
book.sweepBegin({ AddressBookEntry("DemoA", "zs1demoa", "", "global") });
book.sweepBegin({ AddressBookEntry("DemoB", "zs1demob", "", "legacyhash") }); // next frame
EXPECT_TRUE(book.sweepActive());
EXPECT_EQ(book.size(), (size_t)1);
EXPECT_TRUE(book.addEntry(AddressBookEntry("DemoC", "zs1democ", "", "global")));
EXPECT_EQ(book.reattachLegacyScopes("w:x"), 1); // re-scopes DemoB; its save is latched
EXPECT_TRUE(book.load()); // latched: keeps the demo list and the snapshot
EXPECT_EQ(book.size(), (size_t)2);
EXPECT_EQ(sweepReadBytes(file), before);
book.sweepEnd();
book.sweepEnd(); // idempotent
EXPECT_TRUE(!book.sweepActive());
EXPECT_EQ(book.size(), (size_t)2);
EXPECT_TRUE(book.findByAddress("zs1alicereal") >= 0 && book.findByAddress("zs1bobreal") >= 0);
EXPECT_TRUE(book.findByAddress("zs1demob") < 0 && book.findByAddress("zs1democ") < 0);
EXPECT_EQ(sweepReadBytes(file), before); // the disk never saw the demo book
AddressBook fresh;
EXPECT_TRUE(fresh.load());
EXPECT_EQ(fresh.size(), (size_t)2);
const int ia = fresh.findByAddress("zs1alicereal");
EXPECT_TRUE(ia >= 0 && fresh.entries()[(size_t)ia].scope == "w:abc");
// After the sweep, saves work again.
EXPECT_TRUE(book.addEntry(AddressBookEntry("Carol", "zs1carolreal", "", "global")));
AddressBook fresh2;
EXPECT_TRUE(fresh2.load());
EXPECT_EQ(fresh2.size(), (size_t)3);
});
#endif
}
// The sweep's demo upsert drops the real entry's scopeId, and activeWalletScopeId() would then mint and
// save a new one (hiding "w:"-scoped contacts for good). The latch keeps wallets.json and restores the id.
void testSweepWalletIndexLatch()
{
#ifdef _WIN32
std::printf("[skip] testSweepWalletIndexLatch (Windows config dir can't be redirected)\n");
#else
using dragonx::data::WalletIndex;
using dragonx::data::WalletIndexEntry;
withSweepTempHome([] {
WalletIndex idx;
idx.load();
WalletIndexEntry real; real.fileName = "wallet.dat"; real.displayName = "Main";
real.scopeId = "w:abc"; real.lastOpenedEpoch = 1720000000;
idx.upsert(real);
idx.addExtraFolder("/real/wallet-folder");
EXPECT_TRUE(idx.save()); // no writer attached: synchronous
const fs::path file = WalletIndex::getDefaultPath();
const std::string before = sweepReadBytes(file);
std::error_code ec;
const auto mtime = fs::last_write_time(file, ec);
EXPECT_TRUE(!before.empty() && !ec);
idx.sweepBegin();
WalletIndexEntry demo; demo.fileName = "wallet.dat"; demo.displayName = "wallet.dat";
demo.cachedBalance = 15.7526; demo.syncedHere = true;
EXPECT_TRUE(idx.upsert(demo)); // replaces the entry: scopeId lost
EXPECT_TRUE(idx.find("wallet.dat") && idx.find("wallet.dat")->scopeId.empty());
demo.scopeId = "w:minted"; idx.upsert(demo); // what activeWalletScopeId() would mint
EXPECT_TRUE(idx.addExtraFolder("/tmp/sweep-sandbox/extra-wallets"));
EXPECT_TRUE(idx.save()); // latched
EXPECT_EQ(sweepReadBytes(file), before);
idx.sweepEnd();
idx.sweepEnd(); // idempotent
EXPECT_TRUE(!idx.sweepActive());
const auto* w = idx.find("wallet.dat");
EXPECT_TRUE(w != nullptr);
if (w) {
EXPECT_EQ(w->scopeId, std::string("w:abc"));
EXPECT_EQ(w->displayName, std::string("Main"));
}
EXPECT_TRUE(idx.extraFolders() == std::vector<std::string>{"/real/wallet-folder"});
EXPECT_EQ(sweepReadBytes(file), before);
EXPECT_TRUE(fs::last_write_time(file, ec) == mtime);
// With the App's background writer attached, a latched save queues nothing either.
{
dragonx::util::CoalescingWriter writer;
idx.setFileWriter(&writer);
idx.sweepBegin();
idx.upsert(demo);
EXPECT_TRUE(idx.save());
EXPECT_EQ(writer.pendingCount(), (size_t)0);
idx.sweepEnd();
idx.setFileWriter(nullptr);
}
EXPECT_EQ(sweepReadBytes(file), before);
});
#endif
}
// Settings: the demo active-wallet file / contacts view never reach disk; sweepEnd restores them and
// flushes once only when a save was suppressed (so a real change made meanwhile still lands).
void testSweepSettingsLatch()
{
#ifdef _WIN32
std::printf("[skip] testSweepSettingsLatch (Windows config dir can't be redirected)\n");
#else
using dragonx::config::Settings;
withSweepTempHome([] {
Settings s; // no writer attached: synchronous saves
s.load(); // no file yet: defaults
s.setActiveWalletFile("x.dat");
s.setContactsViewMode(2);
s.setUIOpacity(0.55f);
EXPECT_TRUE(s.save());
const fs::path file = Settings::getDefaultPath();
const std::string before = sweepReadBytes(file);
EXPECT_TRUE(!before.empty());
s.sweepBegin();
s.setActiveWalletFile("wallet.dat"); // what the wallets surfaces set
s.setContactsViewMode(1);
s.setPoolWorker("zs1demoprimaryshieldedaddressforuisweep"); // the Mining tab's demo default
s.setUIOpacity(0.65f); // a real change made during the sweep
EXPECT_TRUE(s.save()); // suppressed
EXPECT_EQ(sweepReadBytes(file), before);
s.sweepEnd();
EXPECT_TRUE(!s.sweepActive());
EXPECT_EQ(s.getActiveWalletFile(), std::string("x.dat"));
EXPECT_EQ(s.getContactsViewMode(), 2);
EXPECT_TRUE(s.getPoolWorker().empty());
{
Settings back;
EXPECT_TRUE(back.load());
EXPECT_EQ(back.getActiveWalletFile(), std::string("x.dat"));
EXPECT_EQ(back.getContactsViewMode(), 2);
EXPECT_TRUE(back.getPoolWorker().empty());
EXPECT_NEAR(back.getUIOpacity(), 0.65, 1e-6); // the one flush carried the real change
}
// No suppressed save: sweepEnd restores and writes nothing.
const std::string bytes0 = sweepReadBytes(file);
std::error_code ec;
const auto t0 = fs::last_write_time(file, ec);
s.sweepBegin();
s.setActiveWalletFile("wallet.dat");
s.setContactsViewMode(0);
s.sweepEnd();
s.sweepEnd(); // idempotent
EXPECT_EQ(s.getActiveWalletFile(), std::string("x.dat"));
EXPECT_EQ(s.getContactsViewMode(), 2);
EXPECT_EQ(sweepReadBytes(file), bytes0);
EXPECT_TRUE(fs::last_write_time(file, ec) == t0);
});
#endif
}
// The sandbox: a fresh random owner-only dir; a pre-existing path or a symlink (even dangling) is refused,
// and the remover only touches a path that still carries the prefix.
void testSweepSandboxDir()
{
using dragonx::util::createExclusivePrivateDir;
using dragonx::util::makePrivateTempDir;
using dragonx::util::removePrivateTempDir;
const fs::path parent = makeTempDir();
const std::string prefix = "obsidiandragon-sweep-";
const std::string a = makePrivateTempDir(parent, prefix);
const std::string b = makePrivateTempDir(parent, prefix);
EXPECT_TRUE(!a.empty() && !b.empty());
EXPECT_TRUE(a != b);
EXPECT_TRUE(fs::is_directory(fs::symlink_status(a)) && fs::is_directory(fs::symlink_status(b)));
EXPECT_EQ(fs::path(a).filename().string().size(), prefix.size() + 16);
EXPECT_EQ(fs::path(a).filename().string().rfind(prefix, 0), (size_t)0);
#ifndef _WIN32
EXPECT_TRUE(fs::status(a).permissions() == fs::perms::owner_all);
#endif
// A pre-existing directory or file is refused.
EXPECT_TRUE(!createExclusivePrivateDir(a));
writeTestFile(parent / "plain-file", "x");
EXPECT_TRUE(!createExclusivePrivateDir(parent / "plain-file"));
EXPECT_TRUE(!createExclusivePrivateDir(fs::path()));
EXPECT_TRUE(makePrivateTempDir(fs::path(), prefix).empty());
#ifndef _WIN32
// A symlink planted at the name (e.g. pointing at a victim's datadir) is refused, and its target is
// neither reused nor re-permissioned.
const fs::path victim = parent / "victim";
fs::create_directories(victim);
fs::permissions(victim, fs::perms::owner_all | fs::perms::group_read | fs::perms::group_exec);
writeTestFile(victim / "wallet.dat", "real");
const fs::path link = parent / (prefix + "0123456789abcdef");
fs::create_directory_symlink(victim, link);
EXPECT_TRUE(!createExclusivePrivateDir(link));
EXPECT_TRUE(fs::is_symlink(fs::symlink_status(link)));
EXPECT_TRUE(fs::status(victim).permissions() ==
(fs::perms::owner_all | fs::perms::group_read | fs::perms::group_exec));
const fs::path dangling = parent / (prefix + "fedcba9876543210");
fs::create_symlink(parent / "nowhere", dangling);
EXPECT_TRUE(!createExclusivePrivateDir(dangling));
EXPECT_TRUE(!fs::exists(parent / "nowhere"));
// Removing the link unlinks it and never follows it into the target.
EXPECT_TRUE(removePrivateTempDir(link.string(), prefix));
EXPECT_TRUE(fs::exists(victim / "wallet.dat"));
EXPECT_EQ(sweepReadBytes(victim / "wallet.dat"), std::string("real"));
#endif
// The remover: refuses a path without the prefix, removes the whole sandbox tree otherwise.
fs::create_directories(fs::path(a) / "DRAGONX");
writeTestFile(fs::path(a) / "DRAGONX" / "wallet.dat", std::string(64, '\0'));
EXPECT_TRUE(!removePrivateTempDir(parent.string(), prefix));
EXPECT_TRUE(fs::exists(parent));
EXPECT_TRUE(!removePrivateTempDir(std::string(), prefix));
EXPECT_TRUE(removePrivateTempDir(a, prefix));
EXPECT_TRUE(!fs::exists(a));
EXPECT_TRUE(removePrivateTempDir(a, prefix)); // already gone: nothing left
EXPECT_TRUE(fs::exists(b));
removeTree(parent);
}
// Tracked sends that only paid the wallet itself (shield, merge/consolidate, chat note split) show no
// outgoing output in z_viewtransaction. They used to stay tracked for the whole session, forcing a full
// history refresh past the scan throttle on every tab; once mined or conflicted they are now retired.
@@ -15782,6 +16045,10 @@ int main()
testTransactionHistoryCacheBackgroundWrite();
testAddressBookScope();
testWalletIndex();
testSweepAddressBookLatch();
testSweepWalletIndexLatch();
testSweepSettingsLatch();
testSweepSandboxDir();
testTransactionHistoryCacheRefreshApply();
testLiteBridgeOwnedStringCopiesBeforeFreeOnSuccess();
testLiteBridgeOwnedStringClassifiesNullWithoutFree();