generic-ml-cache-cli 0.4.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. {generic_ml_cache_cli-0.4.0 → generic_ml_cache_cli-0.5.0}/PKG-INFO +3 -3
  2. {generic_ml_cache_cli-0.4.0 → generic_ml_cache_cli-0.5.0}/README.md +1 -1
  3. {generic_ml_cache_cli-0.4.0 → generic_ml_cache_cli-0.5.0}/pyproject.toml +2 -2
  4. {generic_ml_cache_cli-0.4.0 → generic_ml_cache_cli-0.5.0}/src/generic_ml_cache_cli/cli.py +185 -16
  5. {generic_ml_cache_cli-0.4.0 → generic_ml_cache_cli-0.5.0}/src/generic_ml_cache_cli/config.py +2 -3
  6. {generic_ml_cache_cli-0.4.0 → generic_ml_cache_cli-0.5.0}/tests/conftest.py +1 -1
  7. generic_ml_cache_cli-0.5.0/tests/test_encrypted_run.py +91 -0
  8. generic_ml_cache_cli-0.5.0/tests/test_encryption_cli.py +101 -0
  9. {generic_ml_cache_cli-0.4.0 → generic_ml_cache_cli-0.5.0}/.gitignore +0 -0
  10. {generic_ml_cache_cli-0.4.0 → generic_ml_cache_cli-0.5.0}/LICENSE +0 -0
  11. {generic_ml_cache_cli-0.4.0 → generic_ml_cache_cli-0.5.0}/NOTICE +0 -0
  12. {generic_ml_cache_cli-0.4.0 → generic_ml_cache_cli-0.5.0}/src/generic_ml_cache_cli/__init__.py +0 -0
  13. {generic_ml_cache_cli-0.4.0 → generic_ml_cache_cli-0.5.0}/src/generic_ml_cache_cli/__main__.py +0 -0
  14. {generic_ml_cache_cli-0.4.0 → generic_ml_cache_cli-0.5.0}/tests/fake_client.py +0 -0
  15. {generic_ml_cache_cli-0.4.0 → generic_ml_cache_cli-0.5.0}/tests/test_cli.py +0 -0
  16. {generic_ml_cache_cli-0.4.0 → generic_ml_cache_cli-0.5.0}/tests/test_config.py +0 -0
  17. {generic_ml_cache_cli-0.4.0 → generic_ml_cache_cli-0.5.0}/tests/test_discover.py +0 -0
  18. {generic_ml_cache_cli-0.4.0 → generic_ml_cache_cli-0.5.0}/tests/test_effort.py +0 -0
  19. {generic_ml_cache_cli-0.4.0 → generic_ml_cache_cli-0.5.0}/tests/test_interrupt.py +0 -0
  20. {generic_ml_cache_cli-0.4.0 → generic_ml_cache_cli-0.5.0}/tests/test_models.py +0 -0
  21. {generic_ml_cache_cli-0.4.0 → generic_ml_cache_cli-0.5.0}/tests/test_passthrough.py +0 -0
  22. {generic_ml_cache_cli-0.4.0 → generic_ml_cache_cli-0.5.0}/tests/test_robustness.py +0 -0
  23. {generic_ml_cache_cli-0.4.0 → generic_ml_cache_cli-0.5.0}/tests/test_stdin_delivery.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: generic-ml-cache-cli
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Terminal UI for generic-ml-cache: the gmlcache command. A thin inbound driver over generic-ml-cache-core -- reads config, provides the data source, maps commands onto the core library.
5
5
  Project-URL: Homepage, https://github.com/danielslobozian/generic-ml-cache
6
6
  Project-URL: Repository, https://github.com/danielslobozian/generic-ml-cache
@@ -24,7 +24,7 @@ Classifier: Programming Language :: Python :: 3.13
24
24
  Classifier: Topic :: Utilities
25
25
  Requires-Python: >=3.9
26
26
  Requires-Dist: argcomplete<4,>=3
27
- Requires-Dist: generic-ml-cache-core>=0.4.0
27
+ Requires-Dist: generic-ml-cache-core>=0.5.0
28
28
  Provides-Extra: dev
29
29
  Requires-Dist: coverage>=7; extra == 'dev'
30
30
  Requires-Dist: pytest-cov; extra == 'dev'
@@ -80,7 +80,7 @@ gmlcache doctor | models | status | init # environment & con
80
80
  `gmlcache` is the terminal client — one inbound driver over the engine. The whole cache
81
81
  logic and every adapter live in
82
82
  [`generic-ml-cache-core`](https://github.com/danielslobozian/generic-ml-cache/tree/main/packages/core),
83
- a **stateless, dependency-free** library. To embed the cache in your own application
83
+ a **stateless** library. To embed the cache in your own application
84
84
  instead of driving it from a terminal, depend on the core and inject your own data
85
85
  source — you never reimplement the adapters.
86
86
 
@@ -45,7 +45,7 @@ gmlcache doctor | models | status | init # environment & con
45
45
  `gmlcache` is the terminal client — one inbound driver over the engine. The whole cache
46
46
  logic and every adapter live in
47
47
  [`generic-ml-cache-core`](https://github.com/danielslobozian/generic-ml-cache/tree/main/packages/core),
48
- a **stateless, dependency-free** library. To embed the cache in your own application
48
+ a **stateless** library. To embed the cache in your own application
49
49
  instead of driving it from a terminal, depend on the core and inject your own data
50
50
  source — you never reimplement the adapters.
51
51
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "generic-ml-cache-cli"
7
- version = "0.4.0"
7
+ version = "0.5.0"
8
8
  description = "Terminal UI for generic-ml-cache: the gmlcache command. A thin inbound driver over generic-ml-cache-core -- reads config, provides the data source, maps commands onto the core library."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -25,7 +25,7 @@ classifiers = [
25
25
  "Programming Language :: Python :: 3.13",
26
26
  "Topic :: Utilities",
27
27
  ]
28
- dependencies = ["generic-ml-cache-core>=0.4.0", "argcomplete>=3,<4"]
28
+ dependencies = ["generic-ml-cache-core>=0.5.0", "argcomplete>=3,<4"]
29
29
 
30
30
  [project.urls]
31
31
  Homepage = "https://github.com/danielslobozian/generic-ml-cache"
@@ -31,6 +31,11 @@ except ImportError: # completion is a convenience; never let its absence break
31
31
  argcomplete = None
32
32
 
33
33
  from generic_ml_cache_core.adapter.inbound.composition import build_use_cases
34
+ from generic_ml_cache_core.adapter.out.crypto.filesystem_encryption_manifest_store import (
35
+ FilesystemEncryptionManifestStore,
36
+ )
37
+ from generic_ml_cache_core.adapter.out.crypto.store_encryptor import StoreEncryptor
38
+ from generic_ml_cache_core.adapter.out.persistence.sqlite_store_lock import SqliteStoreLock
34
39
  from generic_ml_cache_core.adapter.out.client.registry import registered_names
35
40
  from generic_ml_cache_core.application.domain.model.execution.artifact import (
36
41
  INPUT_ARTIFACT_TYPES,
@@ -44,7 +49,16 @@ from generic_ml_cache_core.application.port.inbound.run_managed_local_execution_
44
49
  RunManagedLocalExecutionCommand,
45
50
  )
46
51
  from generic_ml_cache_core.application.port.out.base import ClientAdapter
47
- from generic_ml_cache_core.common.errors import CacheError, CacheMiss, ConfigError, RunInterrupted
52
+ from generic_ml_cache_core.common.errors import (
53
+ CacheError,
54
+ CacheMiss,
55
+ ConfigError,
56
+ EncryptionStateError,
57
+ EncryptionTokenRequired,
58
+ RunInterrupted,
59
+ StoreLocked,
60
+ WrongEncryptionToken,
61
+ )
48
62
 
49
63
  from . import __version__, config
50
64
 
@@ -186,9 +200,9 @@ def _cmd_run(args: argparse.Namespace) -> int:
186
200
  def executable_override(client: str):
187
201
  return config.executable_for(file_cfg, client, flag=args.executable)
188
202
 
189
- wired = build_use_cases(store_root, executable_override, timeout)
190
-
203
+ token = _resolve_token(args)
191
204
  try:
205
+ wired = build_use_cases(store_root, executable_override, timeout, encryption_token=token)
192
206
  execution = wired.run_managed.execute(command)
193
207
  except RunInterrupted as exc:
194
208
  # A requested stop, not a failure: nothing was recorded. Exit 130 is the
@@ -206,6 +220,9 @@ def _cmd_run(args: argparse.Namespace) -> int:
206
220
  except CacheMiss as exc:
207
221
  print(f"gmlc: {exc}", file=sys.stderr)
208
222
  return 3
223
+ except (EncryptionTokenRequired, WrongEncryptionToken) as exc:
224
+ print(f"gmlc: {exc} (set --token or GMLCACHE_TOKEN)", file=sys.stderr)
225
+ return 4
209
226
  except CacheError as exc:
210
227
  print(f"gmlc: {exc}", file=sys.stderr)
211
228
  return 4
@@ -428,6 +445,7 @@ def _cmd_status(args: argparse.Namespace) -> int:
428
445
 
429
446
  path = config.resolve_config_path()
430
447
  loaded = file_cfg.source is not None
448
+ encryption = FilesystemEncryptionManifestStore(Path(str(settings["store"][0]))).state().value
431
449
 
432
450
  if args.json:
433
451
  import json
@@ -437,6 +455,7 @@ def _cmd_status(args: argparse.Namespace) -> int:
437
455
  {
438
456
  "config_file": str(path),
439
457
  "loaded": loaded,
458
+ "encryption": encryption,
440
459
  "settings": {k: {"value": v[0], "source": v[1]} for k, v in settings.items()},
441
460
  "executables": dict(file_cfg.executables),
442
461
  },
@@ -446,6 +465,7 @@ def _cmd_status(args: argparse.Namespace) -> int:
446
465
  return 0
447
466
 
448
467
  print(f"config file : {path} ({'loaded' if loaded else 'not present'})")
468
+ print(f"encryption : {encryption}")
449
469
  print("effective settings (no run flags applied):")
450
470
  for key in ("mode", "persist", "store", "timeout", "trust_scan", "max_size"):
451
471
  value, source = settings[key]
@@ -702,24 +722,30 @@ def _cmd_export(args: argparse.Namespace) -> int:
702
722
  print(f"gmlc: {exc}", file=sys.stderr)
703
723
  return 4
704
724
 
705
- wired = build_use_cases(Path(str(settings["store"][0])))
706
725
  include = set(getattr(args, "tag", None) or [])
707
726
  exclude = set(getattr(args, "exclude_tag", None) or [])
708
727
 
709
728
  lines = []
710
729
  skipped_no_input = 0
711
- for summary in wired.repository.current_execution_summaries():
712
- tags = wired.repository.tags_for(summary.execution_key)
713
- if include and not include & set(tags):
714
- continue
715
- if exclude and exclude & set(tags):
716
- continue
717
- execution = wired.repository.find_current(summary.execution_key)
718
- # Only DATASET-depth entries carry the input side of the corpus.
719
- if execution is None or not execution.input_persisted:
720
- skipped_no_input += 1
721
- continue
722
- lines.append(json.dumps(_export_record(summary, execution, tags, wired.blob_store)))
730
+ try:
731
+ wired = build_use_cases(
732
+ Path(str(settings["store"][0])), encryption_token=_resolve_token(args)
733
+ )
734
+ for summary in wired.repository.current_execution_summaries():
735
+ tags = wired.repository.tags_for(summary.execution_key)
736
+ if include and not include & set(tags):
737
+ continue
738
+ if exclude and exclude & set(tags):
739
+ continue
740
+ execution = wired.repository.find_current(summary.execution_key)
741
+ # Only DATASET-depth entries carry the input side of the corpus.
742
+ if execution is None or not execution.input_persisted:
743
+ skipped_no_input += 1
744
+ continue
745
+ lines.append(json.dumps(_export_record(summary, execution, tags, wired.blob_store)))
746
+ except (EncryptionTokenRequired, WrongEncryptionToken) as exc:
747
+ print(f"gmlc: {exc} (set --token or GMLCACHE_TOKEN)", file=sys.stderr)
748
+ return 4
723
749
 
724
750
  if args.output:
725
751
  Path(args.output).write_text("".join(line + "\n" for line in lines), encoding="utf-8")
@@ -738,6 +764,119 @@ def _cmd_export(args: argparse.Namespace) -> int:
738
764
  return 0
739
765
 
740
766
 
767
+ # -- encryption -------------------------------------------------------------
768
+
769
+
770
+ def _resolve_token(args: argparse.Namespace) -> Optional[str]:
771
+ """The encryption token for this call: the --token flag, else GMLCACHE_TOKEN.
772
+ A token is a secret, so it is never read from the config file."""
773
+ flag = getattr(args, "token", None)
774
+ return flag if flag else (os.environ.get("GMLCACHE_TOKEN") or None)
775
+
776
+
777
+ def _load_cipher():
778
+ """Build the cipher, with a friendly error if the optional extra is missing."""
779
+ try:
780
+ from generic_ml_cache_core.adapter.out.crypto.aesgcm_cipher import AesGcmCipher
781
+ except ImportError as exc: # pragma: no cover - exercised only without the extra
782
+ raise SystemExit(
783
+ "error: encryption needs an optional dependency — install with "
784
+ '`pip install "generic-ml-cache-core[encryption]"`'
785
+ ) from exc
786
+ return AesGcmCipher()
787
+
788
+
789
+ def _store_encryptor(store_root: Path, cipher=None) -> StoreEncryptor:
790
+ return StoreEncryptor(
791
+ store_root,
792
+ FilesystemEncryptionManifestStore(store_root),
793
+ SqliteStoreLock(store_root),
794
+ cipher,
795
+ )
796
+
797
+
798
+ def _store_root() -> Optional[Path]:
799
+ try:
800
+ return Path(str(config.resolve_settings(config.load())["store"][0]))
801
+ except ConfigError as exc:
802
+ print(f"gmlc: {exc}", file=sys.stderr)
803
+ return None
804
+
805
+
806
+ def _cmd_encrypt(args: argparse.Namespace) -> int:
807
+ store_root = _store_root()
808
+ if store_root is None:
809
+ return 4
810
+ cipher = _load_cipher()
811
+ token = cipher.generate_token()
812
+ try:
813
+ _store_encryptor(store_root, cipher).enable(token)
814
+ except (EncryptionStateError, StoreLocked) as exc:
815
+ print(f"gmlc: {exc}", file=sys.stderr)
816
+ return 4
817
+ print("encryption enabled. Save this token — it is shown once and cannot be recovered:")
818
+ print(f"\n {token}\n")
819
+ print("Pass it with --token or GMLCACHE_TOKEN to read or write this store.")
820
+ return 0
821
+
822
+
823
+ def _cmd_decrypt(args: argparse.Namespace) -> int:
824
+ store_root = _store_root()
825
+ if store_root is None:
826
+ return 4
827
+ token = _resolve_token(args)
828
+ if not token:
829
+ print("gmlc: provide the token with --token or GMLCACHE_TOKEN", file=sys.stderr)
830
+ return 4
831
+ try:
832
+ _store_encryptor(store_root, _load_cipher()).disable(token)
833
+ except (WrongEncryptionToken, EncryptionStateError, StoreLocked) as exc:
834
+ print(f"gmlc: {exc}", file=sys.stderr)
835
+ return 4
836
+ print("encryption disabled. The store is now public; no token is needed.")
837
+ return 0
838
+
839
+
840
+ def _cmd_rotate(args: argparse.Namespace) -> int:
841
+ store_root = _store_root()
842
+ if store_root is None:
843
+ return 4
844
+ old_token = _resolve_token(args)
845
+ if not old_token:
846
+ print("gmlc: provide the current token with --token or GMLCACHE_TOKEN", file=sys.stderr)
847
+ return 4
848
+ cipher = _load_cipher()
849
+ new_token = cipher.generate_token()
850
+ try:
851
+ _store_encryptor(store_root, cipher).rotate(old_token, new_token)
852
+ except (WrongEncryptionToken, EncryptionStateError, StoreLocked) as exc:
853
+ print(f"gmlc: {exc}", file=sys.stderr)
854
+ return 4
855
+ print("token rotated. Save the new token — it is shown once:")
856
+ print(f"\n {new_token}\n")
857
+ return 0
858
+
859
+
860
+ def _cmd_invalidate(args: argparse.Namespace) -> int:
861
+ store_root = _store_root()
862
+ if store_root is None:
863
+ return 4
864
+ if not args.yes:
865
+ print(
866
+ "gmlc: this permanently wipes the cache (crypto-shred) and cannot be undone. "
867
+ "Re-run with --yes to confirm.",
868
+ file=sys.stderr,
869
+ )
870
+ return 4
871
+ try:
872
+ _store_encryptor(store_root).invalidate() # no token needed
873
+ except StoreLocked as exc:
874
+ print(f"gmlc: {exc}", file=sys.stderr)
875
+ return 4
876
+ print("store invalidated: the cache was wiped and is now empty and public.")
877
+ return 0
878
+
879
+
741
880
  def _use_color() -> bool:
742
881
  """Colour only when writing to a real terminal and NO_COLOR is unset, so piped
743
882
  or redirected output never carries escape codes (the conventional contract)."""
@@ -914,6 +1053,9 @@ def build_parser() -> argparse.ArgumentParser:
914
1053
  help="also cache a call that fails (non-zero exit); default is to store only successes",
915
1054
  )
916
1055
  run.add_argument("--executable", help="override the client executable (the seam)")
1056
+ run.add_argument(
1057
+ "--token", help="encryption token for an encrypted store (or set GMLCACHE_TOKEN)"
1058
+ )
917
1059
  run.add_argument(
918
1060
  "--timeout", type=float, default=None, help="seconds before the real call is killed"
919
1061
  )
@@ -1071,8 +1213,35 @@ def build_parser() -> argparse.ArgumentParser:
1071
1213
  metavar="FILE",
1072
1214
  help="write JSONL to FILE instead of stdout (a per-record summary still goes to stderr)",
1073
1215
  )
1216
+ exportp.add_argument(
1217
+ "--token", help="encryption token if the store is encrypted (or set GMLCACHE_TOKEN)"
1218
+ )
1074
1219
  exportp.set_defaults(func=_cmd_export)
1075
1220
 
1221
+ encryptp = sub.add_parser(
1222
+ "encrypt", help="enable at-rest encryption of the store (generates and shows a token)"
1223
+ )
1224
+ encryptp.set_defaults(func=_cmd_encrypt)
1225
+
1226
+ decryptp = sub.add_parser(
1227
+ "decrypt", help="disable encryption (decrypts the store back to plaintext; needs the token)"
1228
+ )
1229
+ decryptp.add_argument("--token", help="the encryption token (or set GMLCACHE_TOKEN)")
1230
+ decryptp.set_defaults(func=_cmd_decrypt)
1231
+
1232
+ rotatep = sub.add_parser(
1233
+ "rotate", help="rotate the encryption token (needs the current token; shows the new one)"
1234
+ )
1235
+ rotatep.add_argument("--token", help="the current encryption token (or set GMLCACHE_TOKEN)")
1236
+ rotatep.set_defaults(func=_cmd_rotate)
1237
+
1238
+ invalidatep = sub.add_parser(
1239
+ "invalidate",
1240
+ help="wipe the cache (crypto-shred) — the escape when the token is lost. Needs --yes.",
1241
+ )
1242
+ invalidatep.add_argument("--yes", action="store_true", help="confirm the irreversible wipe")
1243
+ invalidatep.set_defaults(func=_cmd_invalidate)
1244
+
1076
1245
  init = sub.add_parser(
1077
1246
  "init",
1078
1247
  help="create the config file in the default location (if absent), then show the store",
@@ -13,9 +13,8 @@ Three rules keep this predictable:
13
13
  config file, the built-in default. The ``store`` location is the exception -- config file or
14
14
  built-in default only, with **no flag and no environment** -- because where the
15
15
  stored executions live is the cache's own concern, not a per-call knob.
16
- * **Zero dependencies.** The format is INI (stdlib :mod:`configparser`) and the
17
- per-user location is resolved inline, so nothing beyond the standard library is
18
- needed on any supported Python.
16
+ * **Plain INI.** The format is INI (stdlib :mod:`configparser`) and the per-user
17
+ location is resolved inline.
19
18
 
20
19
  Location (override everything with ``GMLCACHE_CONFIG=/path/to/file``):
21
20
 
@@ -118,7 +118,7 @@ def _isolate_config(monkeypatch, tmp_path):
118
118
  monkeypatch.setenv("GMLCACHE_CONFIG", str(tmp_path / "no-such-config.ini"))
119
119
  monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "xdg-data"))
120
120
  monkeypatch.setenv("LOCALAPPDATA", str(tmp_path / "localappdata"))
121
- for var in ("GMLCACHE_MODE", "GMLCACHE_PERSIST", "GMLCACHE_TIMEOUT"):
121
+ for var in ("GMLCACHE_MODE", "GMLCACHE_PERSIST", "GMLCACHE_TIMEOUT", "GMLCACHE_TOKEN"):
122
122
  monkeypatch.delenv(var, raising=False)
123
123
 
124
124
 
@@ -0,0 +1,91 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """End-to-end: a managed run against an encrypted store (via build_use_cases)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import pytest
8
+
9
+ pytest.importorskip("cryptography")
10
+
11
+ from generic_ml_cache_core.adapter.inbound.composition import build_use_cases # noqa: E402
12
+ from generic_ml_cache_core.adapter.out.crypto.aesgcm_cipher import AesGcmCipher # noqa: E402
13
+ from generic_ml_cache_core.adapter.out.crypto.filesystem_encryption_manifest_store import ( # noqa: E402
14
+ FilesystemEncryptionManifestStore,
15
+ )
16
+ from generic_ml_cache_core.application.domain.model.execution.artifact import ( # noqa: E402
17
+ ArtifactType,
18
+ )
19
+ from generic_ml_cache_core.application.domain.model.execution.execution_state import ( # noqa: E402
20
+ ExecutionState,
21
+ )
22
+ from generic_ml_cache_core.application.port.inbound.run_managed_local_execution_command import ( # noqa: E402
23
+ RunManagedLocalExecutionCommand,
24
+ )
25
+ from generic_ml_cache_core.common.errors import ( # noqa: E402
26
+ EncryptionTokenRequired,
27
+ WrongEncryptionToken,
28
+ )
29
+
30
+ _MARKER = "ENCRYPTME-distinctive-123"
31
+
32
+
33
+ def _encrypt_store(store_root, token):
34
+ manifest, _ = AesGcmCipher().create_envelope(token)
35
+ FilesystemEncryptionManifestStore(store_root).save(manifest)
36
+
37
+
38
+ def _command():
39
+ return RunManagedLocalExecutionCommand(
40
+ client="fake", model="m1", effort="high", context="", prompt=f"STDOUT {_MARKER}"
41
+ )
42
+
43
+
44
+ def _blob_bytes(store_root):
45
+ return b"".join(p.read_bytes() for p in (store_root / "blobs").rglob("*") if p.is_file())
46
+
47
+
48
+ def test_encrypted_run_stores_ciphertext_and_replays_with_token(tmp_path):
49
+ store = tmp_path / "store"
50
+ token = AesGcmCipher().generate_token()
51
+ _encrypt_store(store, token)
52
+
53
+ execution = build_use_cases(store, encryption_token=token).run_managed.execute(_command())
54
+ assert execution.execution_state is ExecutionState.SUCCESS
55
+
56
+ # the marker must NOT appear in the persisted blobs — they are ciphertext
57
+ assert _MARKER.encode() not in _blob_bytes(store)
58
+
59
+ # replay (a cache hit) with the token decrypts the output correctly
60
+ served = build_use_cases(store, encryption_token=token).run_managed.execute(_command())
61
+ stdout = next(a.content for a in served.artifacts if a.artifact_type is ArtifactType.STDOUT)
62
+ assert _MARKER.encode() in stdout
63
+
64
+
65
+ def test_public_store_keeps_plaintext_and_ignores_a_token(tmp_path):
66
+ store = tmp_path / "store" # no manifest -> public
67
+ execution = build_use_cases(store, encryption_token="ignored").run_managed.execute(_command())
68
+ assert execution.execution_state is ExecutionState.SUCCESS
69
+ assert _MARKER.encode() in _blob_bytes(store) # plaintext on disk
70
+
71
+
72
+ def test_encrypted_store_without_token_blocks_content_but_not_metadata(tmp_path):
73
+ store = tmp_path / "store"
74
+ token = AesGcmCipher().generate_token()
75
+ _encrypt_store(store, token)
76
+ recorded = build_use_cases(store, encryption_token=token).run_managed.execute(_command())
77
+ key = recorded.call_identity.generate_key()
78
+
79
+ wired = build_use_cases(store) # no token
80
+ # metadata is still readable (it is not encrypted) ...
81
+ assert wired.repository.find_current(key) is not None
82
+ # ... but serving the hit must hydrate the blob, which needs the token.
83
+ with pytest.raises(EncryptionTokenRequired):
84
+ wired.run_managed.execute(_command())
85
+
86
+
87
+ def test_wrong_token_is_rejected_when_opening_the_store(tmp_path):
88
+ store = tmp_path / "store"
89
+ _encrypt_store(store, AesGcmCipher().generate_token())
90
+ with pytest.raises(WrongEncryptionToken):
91
+ build_use_cases(store, encryption_token=AesGcmCipher().generate_token())
@@ -0,0 +1,101 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """End-to-end CLI tests for encrypt / decrypt / rotate / invalidate."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import pytest
8
+
9
+ pytest.importorskip("cryptography")
10
+
11
+ from generic_ml_cache_cli.cli import main # noqa: E402
12
+
13
+ _RUN = ["run", "--client", "fake", "--model", "m1", "--effort", "high"]
14
+
15
+
16
+ def _token_from(out: str) -> str:
17
+ """The token is printed on its own indented, space-free line."""
18
+ for line in out.splitlines():
19
+ s = line.strip()
20
+ if s and " " not in s and len(s) >= 20:
21
+ return s
22
+ raise AssertionError(f"no token found in:\n{out}")
23
+
24
+
25
+ def _enable(capsys) -> str:
26
+ assert main(["encrypt"]) == 0
27
+ return _token_from(capsys.readouterr().out)
28
+
29
+
30
+ def test_encrypt_then_run_with_token_round_trips(capsys, monkeypatch):
31
+ token = _enable(capsys)
32
+ monkeypatch.setenv("GMLCACHE_TOKEN", token)
33
+
34
+ assert main(_RUN + ["--prompt", "STDOUT SECRETMARKER"]) == 0
35
+ assert "SECRETMARKER" in capsys.readouterr().out
36
+ # offline replay against the encrypted store, with the token
37
+ assert main(_RUN + ["--prompt", "STDOUT SECRETMARKER", "--offline"]) == 0
38
+ capsys.readouterr()
39
+
40
+ assert main(["status"]) == 0
41
+ assert "encryption : encrypted" in capsys.readouterr().out
42
+
43
+
44
+ def test_run_without_token_on_encrypted_store_is_blocked(capsys, monkeypatch):
45
+ token = _enable(capsys)
46
+ monkeypatch.setenv("GMLCACHE_TOKEN", token)
47
+ main(_RUN + ["--prompt", "STDOUT hi"]) # record one entry
48
+ capsys.readouterr()
49
+
50
+ monkeypatch.delenv("GMLCACHE_TOKEN", raising=False)
51
+ rc = main(_RUN + ["--prompt", "STDOUT hi"]) # hit -> hydrate -> needs the token
52
+ assert rc == 4
53
+ assert "token" in capsys.readouterr().err.lower()
54
+
55
+
56
+ def test_decrypt_returns_store_to_public(capsys):
57
+ token = _enable(capsys)
58
+ assert main(["decrypt", "--token", token]) == 0
59
+ capsys.readouterr()
60
+ main(["status"])
61
+ assert "encryption : public" in capsys.readouterr().out
62
+
63
+
64
+ def test_decrypt_with_wrong_token_fails(capsys):
65
+ _enable(capsys)
66
+ assert main(["decrypt", "--token", "definitely-not-the-right-token-xyz"]) == 4
67
+ assert "gmlc:" in capsys.readouterr().err
68
+
69
+
70
+ def test_rotate_swaps_the_token(capsys, monkeypatch):
71
+ run = _RUN + ["--prompt", "STDOUT zz"]
72
+ old = _enable(capsys)
73
+ monkeypatch.setenv("GMLCACHE_TOKEN", old)
74
+ main(run)
75
+ capsys.readouterr()
76
+
77
+ assert main(["rotate", "--token", old]) == 0
78
+ new = _token_from(capsys.readouterr().out)
79
+ assert new != old
80
+
81
+ monkeypatch.setenv("GMLCACHE_TOKEN", new)
82
+ assert main(run + ["--offline"]) == 0 # new token reads
83
+ capsys.readouterr()
84
+ monkeypatch.setenv("GMLCACHE_TOKEN", old)
85
+ assert main(run + ["--offline"]) == 4 # old token rejected
86
+
87
+
88
+ def test_invalidate_requires_yes_then_wipes_to_public(capsys, monkeypatch):
89
+ token = _enable(capsys)
90
+ monkeypatch.setenv("GMLCACHE_TOKEN", token)
91
+ main(_RUN + ["--prompt", "STDOUT x"])
92
+ capsys.readouterr()
93
+
94
+ assert main(["invalidate"]) == 4 # refused without --yes
95
+ assert "yes" in capsys.readouterr().err.lower()
96
+
97
+ assert main(["invalidate", "--yes"]) == 0
98
+ capsys.readouterr()
99
+ monkeypatch.delenv("GMLCACHE_TOKEN", raising=False)
100
+ main(["status"])
101
+ assert "encryption : public" in capsys.readouterr().out