Files
ObsidianDragon/src/data/address_book.cpp
T
DanSandClaude Opus 5.5 62a5577491 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>
2026-10-12 13:22:00 -05:00

198 lines
6.3 KiB
C++

// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "address_book.h"
#include <nlohmann/json.hpp>
#include <fstream>
#include <filesystem>
#include "../util/logger.h"
#include "../util/platform.h"
namespace fs = std::filesystem;
using json = nlohmann::json;
namespace dragonx {
namespace data {
AddressBook::AddressBook() = default;
AddressBook::~AddressBook() = default;
std::string AddressBook::getDefaultPath()
{
// Co-locate with settings.json in the per-variant config dir (Lite -> ObsidianDragonLite/).
// util::Platform::getConfigDir() owns the per-platform + per-variant path in one place.
const std::string dir = util::Platform::getConfigDir();
fs::create_directories(dir);
return (fs::path(dir) / "addressbook.json").string();
}
bool AddressBook::load()
{
if (sweep_active_) return true; // UI sweep: keep the demo list and the real-book snapshot
file_path_ = getDefaultPath();
std::ifstream file(file_path_);
if (!file.is_open()) {
// No file yet - that's OK
return true;
}
try {
json j;
file >> j;
entries_.clear();
if (j.contains("entries") && j["entries"].is_array()) {
size_t skipped = 0;
for (const auto& entry : j["entries"]) {
// W6-3: skip (and count) a malformed element rather than letting one bad entry throw and
// abort the whole load — which would discard EVERY contact (entries_ was already cleared).
if (!entry.is_object()) { ++skipped; continue; }
try {
AddressBookEntry e;
e.label = entry.value("label", "");
e.address = entry.value("address", "");
e.notes = entry.value("notes", "");
// Legacy entries (no "scope") migrate to "global" so nothing disappears when
// multi-wallet scoping lands — a contact you already had stays visible everywhere.
e.scope = entry.value("scope", "global");
e.avatar = entry.value("avatar", "");
if (!e.address.empty()) entries_.push_back(e);
} catch (const std::exception&) { ++skipped; }
}
if (skipped > 0)
DEBUG_LOGF("Address book: skipped %zu malformed entr%s\n", skipped, skipped == 1 ? "y" : "ies");
}
DEBUG_LOGF("Address book loaded: %zu entries\n", entries_.size());
++revision_;
return true;
} catch (const std::exception& e) {
DEBUG_LOGF("Error loading address book: %s\n", e.what());
return false;
}
}
bool AddressBook::save()
{
if (sweep_active_) return true; // UI sweep: demo contacts never reach disk
if (file_path_.empty()) {
file_path_ = getDefaultPath();
}
try {
json j;
j["entries"] = json::array();
for (const auto& entry : entries_) {
json e;
e["label"] = entry.label;
e["address"] = entry.address;
e["notes"] = entry.notes;
e["scope"] = entry.scope.empty() ? std::string("global") : entry.scope;
if (!entry.avatar.empty()) e["avatar"] = entry.avatar;
j["entries"].push_back(e);
}
// Atomic + durable: temp file + fsync + rename, so a crash mid-write can't
// truncate addressbook.json (which is fully rewritten on every entry change).
// Owner-only (0600) — it holds the user's saved contacts.
if (!util::Platform::writeFileAtomically(file_path_, j.dump(2), /*restrictPermissions=*/true)) {
DEBUG_LOGF("Could not write address book: %s\n", file_path_.c_str());
return false;
}
DEBUG_LOGF("Address book saved: %zu entries\n", entries_.size());
return true;
} catch (const std::exception& e) {
DEBUG_LOGF("Error saving address book: %s\n", e.what());
return false;
}
}
bool AddressBook::addEntry(const AddressBookEntry& entry)
{
// Reject a duplicate only within the same visible set (same wallet or global) — the same
// address may legitimately be a contact in two different wallets.
if (hasVisibleDuplicate(entry.address, entry.scope)) {
return false;
}
entries_.push_back(entry);
++revision_;
return save();
}
bool AddressBook::updateEntry(size_t index, const AddressBookEntry& entry)
{
if (index >= entries_.size()) {
return false;
}
// Check for a duplicate visible alongside this entry's scope (excluding the entry being edited)
if (hasVisibleDuplicate(entry.address, entry.scope, static_cast<int>(index))) {
return false;
}
entries_[index] = entry;
++revision_;
return save();
}
bool AddressBook::removeEntry(size_t index)
{
if (index >= entries_.size()) {
return false;
}
entries_.erase(entries_.begin() + index);
++revision_;
return save();
}
int AddressBook::reattachLegacyScopes(const std::string& scopeId)
{
if (scopeId.empty()) return 0;
int rescoped = 0;
for (auto& e : entries_) {
if (e.isGlobal()) continue; // global stays global
if (e.scope.rfind("w:", 0) == 0) continue; // already a stable scope
e.scope = scopeId;
++rescoped;
}
if (rescoped > 0) { ++revision_; save(); }
return rescoped;
}
int AddressBook::findByAddress(const std::string& address) const
{
for (size_t i = 0; i < entries_.size(); i++) {
if (entries_[i].address == address) {
return static_cast<int>(i);
}
}
return -1;
}
bool AddressBook::hasVisibleDuplicate(const std::string& address, const std::string& scope,
int excludeIndex) const
{
AddressBookEntry probe; probe.scope = scope; // reuse the isGlobal()/scope logic
for (size_t i = 0; i < entries_.size(); i++) {
if (static_cast<int>(i) == excludeIndex) continue;
const auto& e = entries_[i];
if (e.address != address) continue;
// Collides if they'd ever be shown together: same wallet scope, or either is global.
if (e.isGlobal() || probe.isGlobal() || e.scope == scope) return true;
}
return false;
}
} // namespace data
} // namespace dragonx