From 87999b188804dcfdceef58f96f60fbb4815d6370 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 19 Aug 2025 08:53:26 -0400 Subject: [PATCH] test: cover backup restore startup --- README.md | 14 ++++++ src/main.py | 41 ++++++++++++++++- src/seedpass/core/manager.py | 25 +++++++++++ src/tests/test_backup_restore_startup.py | 56 ++++++++++++++++++++++++ 4 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 src/tests/test_backup_restore_startup.py diff --git a/README.md b/README.md index 75f546f..74638ee 100644 --- a/README.md +++ b/README.md @@ -435,6 +435,16 @@ For a full list of commands see [docs/advanced_cli.md](docs/advanced_cli.md). Th ``` *(or `python src/main.py` when running directly from the repository)* + To restore a previously backed up index at launch, provide the backup path + and fingerprint: + + ```bash + seedpass --restore-backup /path/to/backup.json.enc --fingerprint + ``` + + Without the flag, the startup prompt offers a **Restore from backup** option + before the vault is initialized. + 2. **Follow the Prompts:** - **Seed Profile Selection:** If you have existing seed profiles, you'll be prompted to select one or add a new one. @@ -620,6 +630,10 @@ initial setup. You must provide both your 12‑word master seed and the master password that encrypted the vault; without the correct password the retrieved data cannot be decrypted. +Alternatively, a local backup file can be loaded at startup. Launch the +application with `--restore-backup --fingerprint ` or choose the +**Restore from backup** option presented before the vault initializes. + 1. Start SeedPass and choose option **4** when prompted to set up a seed. 2. Paste your BIP‑85 seed phrase when asked. 3. Enter the master password associated with that seed. diff --git a/src/main.py b/src/main.py index a577e45..5ba2fd2 100644 --- a/src/main.py +++ b/src/main.py @@ -19,7 +19,7 @@ from termcolor import colored from utils.color_scheme import color_text import importlib -from seedpass.core.manager import PasswordManager +from seedpass.core.manager import PasswordManager, restore_backup_index from nostr.client import NostrClient from seedpass.core.entry_types import EntryType from seedpass.core.config_manager import ConfigManager @@ -1285,6 +1285,10 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in load_global_config() parser = argparse.ArgumentParser() parser.add_argument("--fingerprint") + parser.add_argument( + "--restore-backup", + help="Restore index from backup file before starting", + ) parser.add_argument( "--no-clipboard", action="store_true", @@ -1315,6 +1319,41 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in args = parser.parse_args(argv) + if args.restore_backup: + fp_target = args.fingerprint or fingerprint + if fp_target is None: + print( + colored( + "Error: --fingerprint is required when using --restore-backup.", + "red", + ) + ) + return 1 + try: + restore_backup_index(Path(args.restore_backup), fp_target) + logger.info("Restored backup from %s", args.restore_backup) + except Exception as e: + logger.error(f"Failed to restore backup: {e}", exc_info=True) + print(colored(f"Error: Failed to restore backup: {e}", "red")) + return 1 + elif args.command is None: + print("Startup Options:") + print("1. Continue") + print("2. Restore from backup") + choice = input("Select an option: ").strip() + if choice == "2": + path = input("Enter backup file path: ").strip() + fp_target = args.fingerprint or fingerprint + if fp_target is None: + fp_target = input("Enter fingerprint for restore: ").strip() + try: + restore_backup_index(Path(path), fp_target) + logger.info("Restored backup from %s", path) + except Exception as e: + logger.error(f"Failed to restore backup: {e}", exc_info=True) + print(colored(f"Error: Failed to restore backup: {e}", "red")) + return 1 + if args.max_prompt_attempts is not None: os.environ["SEEDPASS_MAX_PROMPT_ATTEMPTS"] = str(args.max_prompt_attempts) diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py index 92db5fe..d692752 100644 --- a/src/seedpass/core/manager.py +++ b/src/seedpass/core/manager.py @@ -158,6 +158,31 @@ def calculate_profile_id(seed: str) -> str: return fp or "" +def restore_backup_index(path: Path, fingerprint: str) -> None: + """Restore the encrypted index for ``fingerprint`` from ``path``. + + This helper is intended for use before full :class:`PasswordManager` + initialization. It simply copies the provided backup file into the + profile's directory, replacing the existing index if present. A copy of + the previous index is kept with a ``.bak`` suffix to allow manual + recovery if needed. + """ + + fingerprint_dir = APP_DIR / fingerprint + fingerprint_dir.mkdir(parents=True, exist_ok=True) + dest = fingerprint_dir / "seedpass_entries_db.json.enc" + src = Path(path) + + # Ensure the source file can be read + src.read_bytes() + + if dest.exists(): + shutil.copy2(dest, dest.with_suffix(".bak")) + + shutil.copy2(src, dest) + os.chmod(dest, 0o600) + + @dataclass class Notification: """Simple message container for UI notifications.""" diff --git a/src/tests/test_backup_restore_startup.py b/src/tests/test_backup_restore_startup.py new file mode 100644 index 0000000..3b6256d --- /dev/null +++ b/src/tests/test_backup_restore_startup.py @@ -0,0 +1,56 @@ +import main +from pathlib import Path + + +def test_cli_flag_restores_before_init(monkeypatch, tmp_path): + calls = [] + backup = tmp_path / "bak.json" + backup.write_text("{}") + + def fake_restore(path, fingerprint): + calls.append(("restore", Path(path), fingerprint)) + + class DummyPM: + def __init__(self, fingerprint=None): + calls.append(("init", fingerprint)) + self.secret_mode_enabled = True + self.inactivity_timeout = 0 + + monkeypatch.setattr(main, "restore_backup_index", fake_restore) + monkeypatch.setattr(main, "PasswordManager", DummyPM) + monkeypatch.setattr(main, "display_menu", lambda pm, **k: None) + + rc = main.main(["--fingerprint", "fp", "--restore-backup", str(backup)]) + assert rc == 0 + assert calls[0][0] == "restore" + assert calls[1][0] == "init" + assert calls[0][1] == backup + assert calls[0][2] == "fp" + + +def test_menu_option_restores_before_init(monkeypatch, tmp_path): + calls = [] + backup = tmp_path / "bak.json" + backup.write_text("{}") + + def fake_restore(path, fingerprint): + calls.append(("restore", Path(path), fingerprint)) + + class DummyPM: + def __init__(self, fingerprint=None): + calls.append(("init", fingerprint)) + self.secret_mode_enabled = True + self.inactivity_timeout = 0 + + monkeypatch.setattr(main, "restore_backup_index", fake_restore) + monkeypatch.setattr(main, "PasswordManager", DummyPM) + monkeypatch.setattr(main, "display_menu", lambda pm, **k: None) + inputs = iter(["2", str(backup)]) + monkeypatch.setattr("builtins.input", lambda _prompt="": next(inputs)) + + rc = main.main(["--fingerprint", "fp"]) + assert rc == 0 + assert calls[0][0] == "restore" + assert calls[1][0] == "init" + assert calls[0][1] == backup + assert calls[0][2] == "fp"