mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-11 19:45:06 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user