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