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