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
+1
View File
@@ -539,6 +539,7 @@ void I18n::loadBuiltinEnglish()
strings_["screenshot_open_dir"] = "Open location";
strings_["sweep_current_theme_only"] = "Current theme only";
strings_["tt_sweep_current_theme_only"] = "Sweep only the active theme instead of cycling every theme";
strings_["screenshot_sweep_refused"] = "Full UI sweep refused; see the debug log";
strings_["screenshot_sweep_desc"] = "Cycles every theme across every tab and saves a screenshot of each into per-tab subfolders under the config directory's screenshots folder (overwriting the previous sweep). Runs for a few seconds.";
strings_["mine_when_idle"] = "Mine when idle";
strings_["setup_wizard"] = "Run Setup Wizard…";
+75
View File
@@ -0,0 +1,75 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#pragma once
// A fresh, owner-only temporary directory that nobody else could have prepared. The full UI sweep uses
// one as its sandbox: its demo wallet files and folders go there, never into the real datadir or config
// dir. Header-only so the test suite can exercise it without the app's UI sources.
#include <sodium.h>
#include <filesystem>
#include <string>
#include <system_error>
#ifndef _WIN32
#include <sys/stat.h>
#include <sys/types.h>
#endif
namespace dragonx {
namespace util {
// Create exactly `dir`. Fails when anything already exists at that path, a symlink (dangling or not)
// included, so a name someone pre-created in a shared temp dir is refused rather than reused. On success
// the directory is owner-only (0700 on POSIX).
inline bool createExclusivePrivateDir(const std::filesystem::path& dir)
{
namespace fs = std::filesystem;
if (dir.empty()) return false;
std::error_code ec;
#ifdef _WIN32
if (!fs::create_directory(dir, ec) || ec) return false; // false: something already exists there
#else
if (::mkdir(dir.c_str(), 0700) != 0) return false; // EEXIST covers files, dirs and symlinks
#endif
// Check what is there now without following links: a real directory, not a symlink.
const fs::file_status st = fs::symlink_status(dir, ec);
if (ec || fs::is_symlink(st) || !fs::is_directory(st)) return false;
fs::permissions(dir, fs::perms::owner_all, fs::perm_options::replace, ec);
if (ec) { fs::remove(dir, ec); return false; }
return true;
}
// "<parent>/<prefix><16 random hex chars>", made with createExclusivePrivateDir. Returns the path, or ""
// when it could not be created (the caller must then not run whatever needed the sandbox).
inline std::string makePrivateTempDir(const std::filesystem::path& parent, const std::string& prefix)
{
if (parent.empty() || sodium_init() < 0) return std::string();
unsigned char rnd[8];
randombytes_buf(rnd, sizeof(rnd));
static const char* kHex = "0123456789abcdef";
std::string name = prefix;
for (unsigned char b : rnd) { name.push_back(kHex[b >> 4]); name.push_back(kHex[b & 0x0F]); }
const std::filesystem::path dir = parent / name;
return createExclusivePrivateDir(dir) ? dir.string() : std::string();
}
// Remove a directory made by makePrivateTempDir, and only that: its last path component must still start
// with `prefix`. A symlink in its place is unlinked, never followed. True when nothing is left there.
inline bool removePrivateTempDir(const std::string& dir, const std::string& prefix)
{
namespace fs = std::filesystem;
if (dir.empty() || prefix.empty()) return false;
const fs::path p(dir);
if (p.filename().string().rfind(prefix, 0) != 0) return false;
std::error_code ec;
fs::remove_all(p, ec);
std::error_code sec;
return fs::symlink_status(p, sec).type() == fs::file_type::not_found;
}
} // namespace util
} // namespace dragonx