From 2e75cc35a1262423eab0763f39f029765ad1fd68 Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Tue, 27 Oct 2026 13:58:14 -0600 Subject: [PATCH] feat(kv): accept an sqlite vault file path and read plain-tag tables on copy A kv vault argument can be the path to an SQLite vault file. With -s, kv copy reads the VAULT_TAG table and the plain-tag table into the VAULT_TAG table. --- docs/guide/cli-reference.md | 3 +++ docs/guide/vaults.md | 10 ++++++++ tests/commands/test_kv_vault_path.py | 38 ++++++++++++++++++++++++++++ unshackle/commands/kv.py | 29 +++++++++++++-------- 4 files changed, 70 insertions(+), 10 deletions(-) create mode 100644 tests/commands/test_kv_vault_path.py diff --git a/docs/guide/cli-reference.md b/docs/guide/cli-reference.md index 63174b55..36754a14 100644 --- a/docs/guide/cli-reference.md +++ b/docs/guide/cli-reference.md @@ -429,6 +429,8 @@ unshackle kv COMMAND [ARGS]... Copy content keys from one or more source vaults into a destination vault. unshackle skips rows whose KIDs match, unless the existing row has no content key. unshackle never alters or deletes existing data. +A vault argument is the name of a vault in the config, or the path to an SQLite vault file. Use a path to import a vault file that is not in the config. + | Option | Description | |---|---| | `-s`, `--service` | Only copy data for a specific service. | @@ -462,6 +464,7 @@ Make service tables on vaults that use tables, for every installed service, wher ```shell unshackle kv copy main backup1 backup2 unshackle kv copy main backup -s EXAMPLE + unshackle kv copy main ~/Downloads/key_store.db -s EXAMPLE unshackle kv sync main mysql_vault unshackle kv add keys.txt EXAMPLE main backup unshackle kv search 0123456789abcdef0123456789abcdef -s EXAMPLE -v main diff --git a/docs/guide/vaults.md b/docs/guide/vaults.md index 331acc38..4dd7a5e9 100644 --- a/docs/guide/vaults.md +++ b/docs/guide/vaults.md @@ -317,6 +317,16 @@ $ unshackle kv copy local shared community `--service` and `--local-only` are mutually exclusive. +To import an SQLite vault file that is not in the config, give its path as the source: + +```console +$ unshackle kv copy local ~/Downloads/key_store.db -s EXAMPLE +``` + +`kv copy` only reads from a source, so the file stays unchanged. `kv sync` also accepts a path, but it writes content keys into that file. + +With `--service`, `kv copy` reads the table named by the service's vault tag and the table named by its plain tag. It writes both into the vault-tag table, which is the table a download reads. Without `--service`, each table keeps its name. + A pair the destination vault has flagged in `bad_keys` is left behind, so a copy does not undo a flag. `bad_keys` is never treated as a service, so its rows are never copied into a service table. ### `kv sync`: mirror vaults both ways diff --git a/tests/commands/test_kv_vault_path.py b/tests/commands/test_kv_vault_path.py new file mode 100644 index 00000000..4a7e180a --- /dev/null +++ b/tests/commands/test_kv_vault_path.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from unshackle.commands import kv as kv_module +from unshackle.vaults.SQLite import SQLite + +KID = "0" * 31 + "1" +KEY = "a" * 32 + + +def test_kv_copy_moves_a_plain_tag_table_from_a_file_into_the_vault_tag_table( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + source = tmp_path / "key_store.db" + SQLite("seed", source).add_key("TESTSVC", KID, KEY) + dest = tmp_path / "local.db" + monkeypatch.setattr(kv_module.config, "key_vaults", [{"type": "SQLite", "name": "local", "path": str(dest)}]) + monkeypatch.setattr(kv_module.Services, "get_vault_tag", lambda name: "Shared" if name == "TESTSVC" else name) + + result = CliRunner().invoke(kv_module.kv, ["copy", "local", str(source), "-s", "TESTSVC"]) + + assert result.exit_code == 0, result.output + check = SQLite("check", dest) + assert check.get_key(KID, "Shared") == KEY + assert check.get_key(KID, "TESTSVC") is None + + +def test_kv_copy_rejects_a_name_that_is_neither_configured_nor_a_file(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(kv_module.config, "key_vaults", []) + + result = CliRunner().invoke(kv_module.kv, ["copy", "local", "nowhere.db"]) + + assert result.exit_code != 0 + assert "not defined in the config" in result.output diff --git a/unshackle/commands/kv.py b/unshackle/commands/kv.py index 66cea255..d2906f87 100644 --- a/unshackle/commands/kv.py +++ b/unshackle/commands/kv.py @@ -28,12 +28,15 @@ from unshackle.core.vaults import Vaults def load_vaults(vault_names: list[str]) -> Vaults: - """Load and validate vaults by name.""" + """Load and validate vaults by config name, or by the path of an SQLite vault file.""" vaults = Vaults() for vault_name in vault_names: vault_config = next((x for x in config.key_vaults if x["name"] == vault_name), None) if not vault_config: - raise click.ClickException(f"Vault ({vault_name}) is not defined in the config.") + path = Path(vault_name).expanduser() + if not path.is_file(): + raise click.ClickException(f"Vault ({vault_name}) is not defined in the config or an existing file.") + vault_config = {"type": "SQLite", "name": path.stem, "path": path} vault_type = vault_config["type"] vault_args = vault_config.copy() @@ -99,8 +102,10 @@ def add_keys_with_progress(vault: Vault, service: str, kid_keys: dict[str, str], return added -def copy_service_data(to_vault: Vault, from_vault: Vault, service: str, log: logging.Logger) -> int: - """Copy data for a single service between vaults.""" +def copy_service_data( + to_vault: Vault, from_vault: Vault, service: str, log: logging.Logger, to_service: Optional[str] = None +) -> int: + """Copy data for a single service between vaults, into the `to_service` table when given.""" if service.lower() == "bad_keys": return 0 try: @@ -117,7 +122,7 @@ def copy_service_data(to_vault: Vault, from_vault: Vault, service: str, log: log return 0 try: - added = add_keys_with_progress(to_vault, service, content_keys, log) + added = add_keys_with_progress(to_vault, to_service or service, content_keys, log) except PermissionError: log.warning(f"{service}: No permission to create table in {to_vault}, skipped") return 0 @@ -165,7 +170,8 @@ def copy( It should be the name of a Key Vault defined in the config. The `from_vault_names` argument is the key vault(s) you wish to take - data from. You may supply multiple key vaults. + data from. You may supply multiple key vaults. Each one is a vault name + from the config, or the path to an SQLite vault file. """ if not from_vault_names: raise click.ClickException("No Vaults were specified to copy data from.") @@ -184,8 +190,11 @@ def copy( if service and local_only: raise click.UsageError("--service and --local-only are mutually exclusive.") + source_tables: Optional[list[str]] = None if service: - service = Services.get_vault_tag(service) + tag, service = Services.get_tag(service), Services.get_vault_tag(service) + # a vault filled outside unshackle can hold the keys under the plain tag, not the VAULT_TAG + source_tables = [service] if tag.lower() == service.lower() else [service, tag] log.info(f"Filtering by service: {service}") installed: Optional[set[str]] = None @@ -195,8 +204,8 @@ def copy( total_added = 0 for from_vault in from_vaults: - if service: - services_to_copy = [service] + if source_tables: + services_to_copy = source_tables else: try: services_to_copy = list(from_vault.get_services()) @@ -212,7 +221,7 @@ def copy( log.info(f"{from_vault.name}: skipping {skipped} service(s) not installed locally") for service_tag in services_to_copy: - added = copy_service_data(to_vault, from_vault, service_tag, log) + added = copy_service_data(to_vault, from_vault, service_tag, log, to_service=service) total_added += added if total_added > 0: