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.
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
+10
View File
@@ -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
+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:
"""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: