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.
This commit is contained in:
imSp4rky
2026-10-27 13:58:14 -06:00
parent 88f9af2af6
commit 2e75cc35a1
4 changed files with 70 additions and 10 deletions
+3
View File
@@ -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. 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 | | Option | Description |
|---|---| |---|---|
| `-s`, `--service` | Only copy data for a specific service. | | `-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 ```shell
unshackle kv copy main backup1 backup2 unshackle kv copy main backup1 backup2
unshackle kv copy main backup -s EXAMPLE 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 sync main mysql_vault
unshackle kv add keys.txt EXAMPLE main backup unshackle kv add keys.txt EXAMPLE main backup
unshackle kv search 0123456789abcdef0123456789abcdef -s EXAMPLE -v main unshackle kv search 0123456789abcdef0123456789abcdef -s EXAMPLE -v main
+10
View File
@@ -317,6 +317,16 @@ $ unshackle kv copy local shared community
`--service` and `--local-only` are mutually exclusive. `--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. 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 ### `kv sync`: mirror vaults both ways
+38
View File
@@ -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
+19 -10
View File
@@ -28,12 +28,15 @@ from unshackle.core.vaults import Vaults
def load_vaults(vault_names: list[str]) -> 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() vaults = Vaults()
for vault_name in vault_names: for vault_name in vault_names:
vault_config = next((x for x in config.key_vaults if x["name"] == vault_name), None) vault_config = next((x for x in config.key_vaults if x["name"] == vault_name), None)
if not vault_config: 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_type = vault_config["type"]
vault_args = vault_config.copy() 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 return added
def copy_service_data(to_vault: Vault, from_vault: Vault, service: str, log: logging.Logger) -> int: def copy_service_data(
"""Copy data for a single service between vaults.""" 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": if service.lower() == "bad_keys":
return 0 return 0
try: try:
@@ -117,7 +122,7 @@ def copy_service_data(to_vault: Vault, from_vault: Vault, service: str, log: log
return 0 return 0
try: 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: except PermissionError:
log.warning(f"{service}: No permission to create table in {to_vault}, skipped") log.warning(f"{service}: No permission to create table in {to_vault}, skipped")
return 0 return 0
@@ -165,7 +170,8 @@ def copy(
It should be the name of a Key Vault defined in the config. 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 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: if not from_vault_names:
raise click.ClickException("No Vaults were specified to copy data from.") raise click.ClickException("No Vaults were specified to copy data from.")
@@ -184,8 +190,11 @@ def copy(
if service and local_only: if service and local_only:
raise click.UsageError("--service and --local-only are mutually exclusive.") raise click.UsageError("--service and --local-only are mutually exclusive.")
source_tables: Optional[list[str]] = None
if service: 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}") log.info(f"Filtering by service: {service}")
installed: Optional[set[str]] = None installed: Optional[set[str]] = None
@@ -195,8 +204,8 @@ def copy(
total_added = 0 total_added = 0
for from_vault in from_vaults: for from_vault in from_vaults:
if service: if source_tables:
services_to_copy = [service] services_to_copy = source_tables
else: else:
try: try:
services_to_copy = list(from_vault.get_services()) 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") log.info(f"{from_vault.name}: skipping {skipped} service(s) not installed locally")
for service_tag in services_to_copy: 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 total_added += added
if total_added > 0: if total_added > 0: