"""cache_check.py schema-version mismatch → delete-on-read + MISS. Covers: * manifest.schema_version == config.SCHEMA_VERSION → HIT * manifest.schema_version != config.SCHEMA_VERSION → MISS + CACHE_INVALIDATED_REASON=schema-version-mismatch + cache dir deleted * missing manifest → MISS (existing behavior preserved) * expired TTL + correct schema → MISS + dir NOT deleted * _safe_rmtree_under_cache_root refuses paths outside CACHE_ROOT * module import raises if CACHE_ROOT is a symlink The script reads env vars + writes eval-able K=V lines to stdout + exits 0 on both HIT and MISS. We drive it via subprocess so we exercise the real stdout stream and exit code. """ from __future__ import annotations import datetime as dt import importlib import json import os import pathlib import shutil import subprocess import sys import tempfile import unittest from . import _bootstrap # noqa: F401 import config # type: ignore import cache_check # type: ignore SCRIPTS_DIR = pathlib.Path(__file__).resolve().parent.parent CACHE_CHECK = SCRIPTS_DIR / "cache_check.py" def _parse_kv(stdout: str) -> dict[str, str]: """Parse the shlex-quoted K=V lines cache_check emits. cache_check writes one `K=V` per line, V shlex-quoted. For our tests we don't need full shell parsing — we just strip one surrounding single-quote layer if present. """ out: dict[str, str] = {} for line in stdout.splitlines(): if "=" not in line: continue k, _, v = line.partition("=") if v.startswith("'") and v.endswith("'") and len(v) >= 2: v = v[1:-1] out[k] = v return out class CacheCheckSchemaVersionTests(unittest.TestCase): """strict schema-version match, delete-on-mismatch.""" def setUp(self) -> None: # Isolated tempdir mirroring CACHE_ROOT so we can safely rmtree # anything under it. We override config.CACHE_ROOT for the # duration of each test — the script reads config.CACHE_ROOT at # rmtree-validation time, and we drive the script via subprocess # with CACHE_ROOT propagated through env only via a wrapper # below (subprocess-style overrides). self.tmp = tempfile.TemporaryDirectory() self.root = pathlib.Path(self.tmp.name).resolve() self.cache_root = self.root / "cache" self.data_root = self.root / "data" self.work_dir = self.root / "work" self.cache_root.mkdir() self.data_root.mkdir() self.work_dir.mkdir() # Agent-keyed sub-path under cache_root. self.agent = "TestAgent_v1" self.version = "v1" self.cache_dir = self.cache_root / "00D000000000ABC" / f"{self.agent}__{self.version}" self.data_dir = self.data_root / "00D000000000ABC" / f"{self.agent}__{self.version}" self.cache_dir.mkdir(parents=True) # Data file manifest points at — must exist for the HIT path. self.data_file = self.cache_dir / "metadata_tree.json" self.data_file.write_text(json.dumps({"root": {"kind": "Bot"}})) # summary.md dropped from the output contract — no # longer written by cache seeding. def tearDown(self) -> None: self.tmp.cleanup() def _write_manifest(self, schema_version: str, age_days: int = 0) -> None: built = dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=age_days) manifest = { "schema_version": schema_version, "data_path": str(self.data_file), "built_at_utc": built.isoformat().replace("+00:00", "Z"), "ttl_days": 7, "node_count": 1, "depth": 1, "agent": { "generation": "Einstein", "bot_id": "0Xx000000000000", "master_label": "TestAgent", "planner_name": "p1", }, "kind_counts": {"Bot": 1}, "partial": False, "unresolved_count": 0, } (self.cache_dir / "manifest.json").write_text(json.dumps(manifest)) def _run_with_patched_cache_root(self, extra_env: dict | None = None) -> subprocess.CompletedProcess: """Patch config.CACHE_ROOT via a small wrapper — simpler than the HOME-redirect dance and keeps the test local to the module under test. We write a tiny entry-point that monkeypatches config before invoking cache_check.main, then runs it. """ wrapper = self.root / "wrapper.py" wrapper.write_text( "import sys; sys.path.insert(0, %r); sys.path.insert(0, %r)\n" "import config\n" "config.CACHE_ROOT = __import__('pathlib').Path(%r)\n" "import cache_check\n" "sys.exit(cache_check.main())\n" % (str(SCRIPTS_DIR), str(SCRIPTS_DIR.parent / 'tools'), str(self.cache_root)) ) env = os.environ.copy() env.update({ "CACHE_DIR": str(self.cache_dir), "DATA_DIR": str(self.data_dir), "WORK_DIR": str(self.work_dir), "AGENT_API_NAME": self.agent, "AGENT_VERSION": self.version, }) if extra_env: env.update(extra_env) return subprocess.run( [sys.executable, str(wrapper)], env=env, capture_output=True, text=True, timeout=10, ) def test_matching_schema_version_hits(self): self._write_manifest(config.SCHEMA_VERSION) cp = self._run_with_patched_cache_root() self.assertEqual(cp.returncode, 0, cp.stderr) kv = _parse_kv(cp.stdout) self.assertEqual(kv.get("CACHE_HIT"), "true", cp.stdout) self.assertNotIn("CACHE_INVALIDATED_REASON", kv) # Cache dir survives. self.assertTrue(self.cache_dir.exists()) def test_legacy_schema_version_misses_and_deletes(self): """schema_version=2.4 (legacy) != 3.0 (current) → MISS + rmtree.""" self._write_manifest("2.4") self.assertTrue(self.cache_dir.exists()) cp = self._run_with_patched_cache_root() self.assertEqual(cp.returncode, 0, cp.stderr) kv = _parse_kv(cp.stdout) self.assertEqual(kv.get("CACHE_HIT"), "false") self.assertEqual(kv.get("CACHE_INVALIDATED_REASON"), "schema-version-mismatch") # Cache dir deleted by the safety-gated rmtree. self.assertFalse(self.cache_dir.exists(), "cache_dir must be removed on schema-version mismatch") def test_unexpected_future_schema_version_misses_and_deletes(self): """Any value != current SCHEMA_VERSION invalidates (not just legacy).""" self._write_manifest("99.99") cp = self._run_with_patched_cache_root() kv = _parse_kv(cp.stdout) self.assertEqual(kv.get("CACHE_HIT"), "false") self.assertEqual(kv.get("CACHE_INVALIDATED_REASON"), "schema-version-mismatch") self.assertFalse(self.cache_dir.exists()) def test_missing_manifest_misses_no_deletion(self): """Existing behavior: no manifest → MISS, NO rmtree, no reason emit.""" # Don't write any manifest. cp = self._run_with_patched_cache_root() self.assertEqual(cp.returncode, 0, cp.stderr) kv = _parse_kv(cp.stdout) self.assertEqual(kv.get("CACHE_HIT"), "false") # Missing manifest is a different reason class — no invalidation # reason emitted here (cache is simply cold, not corrupt). self.assertNotIn("CACHE_INVALIDATED_REASON", kv) self.assertTrue(self.cache_dir.exists(), "missing manifest must NOT trigger rmtree") def test_expired_ttl_with_matching_schema_misses_no_deletion(self): """TTL-expired cache with CURRENT schema → MISS but dir stays intact.""" self._write_manifest(config.SCHEMA_VERSION, age_days=30) # 30 > 7 cp = self._run_with_patched_cache_root() kv = _parse_kv(cp.stdout) self.assertEqual(kv.get("CACHE_HIT"), "false") # No schema-mismatch reason — it's a stale cache, not a corrupt one. self.assertNotIn("CACHE_INVALIDATED_REASON", kv) self.assertTrue(self.cache_dir.exists(), "expired TTL must NOT rmtree — just mark stale") class SafeRmtreeUnderCacheRootTests(unittest.TestCase): """defensive: `_safe_rmtree_under_cache_root` refuses out-of-root paths.""" def setUp(self) -> None: self.tmp = tempfile.TemporaryDirectory() self.root = pathlib.Path(self.tmp.name).resolve() self.cache_root = self.root / "cache" self.cache_root.mkdir() # In-scope victim dir. self.in_scope = self.cache_root / "agent_v1" self.in_scope.mkdir() (self.in_scope / "some.txt").write_text("x") # Out-of-scope victim dir (sibling to cache_root). self.out_of_scope = self.root / "other" self.out_of_scope.mkdir() (self.out_of_scope / "important.txt").write_text("do not delete") # Monkeypatch config.CACHE_ROOT for the duration of each test. self._orig_root = config.CACHE_ROOT config.CACHE_ROOT = self.cache_root def tearDown(self) -> None: config.CACHE_ROOT = self._orig_root self.tmp.cleanup() def test_rmtree_under_cache_root_succeeds(self): ok = cache_check._safe_rmtree_under_cache_root(self.in_scope) self.assertTrue(ok) self.assertFalse(self.in_scope.exists()) def test_rmtree_outside_cache_root_refused(self): ok = cache_check._safe_rmtree_under_cache_root(self.out_of_scope) self.assertFalse(ok, "out-of-root path must be refused") # File still present. self.assertTrue((self.out_of_scope / "important.txt").exists()) def test_nonexistent_under_root_is_noop_success(self): """A caller asking to delete a path that doesn't exist under the root gets a silent success — matches rmtree's semantics for already-gone targets and avoids spurious error branches on the miss() path.""" ghost = self.cache_root / "never_existed" ok = cache_check._safe_rmtree_under_cache_root(ghost) self.assertTrue(ok) class SymlinkedCacheRootRejectedAtImportTests(unittest.TestCase): """module import raises if CACHE_ROOT is a symlink. A symlinked CACHE_ROOT would defeat the rmtree safety gate — both sides of `target.resolve().is_relative_to(root.resolve())` would collapse to the same symlink target, so the gate would approve deletion of arbitrary paths the attacker aimed the symlink at. The defence is a fail-fast guard at cache_check import time. """ def setUp(self) -> None: self.tmp = tempfile.TemporaryDirectory() self.root = pathlib.Path(self.tmp.name).resolve() self.real_target = self.root / "evil_target" self.real_target.mkdir() self.symlinked_root = self.root / "symlinked_cache_root" self.symlinked_root.symlink_to(self.real_target) self._orig_root = config.CACHE_ROOT def tearDown(self) -> None: # Restore and reload so the rest of the suite sees a clean module. config.CACHE_ROOT = self._orig_root importlib.reload(cache_check) self.tmp.cleanup() def test_symlinked_cache_root_raises_on_import(self): """Reloading cache_check with a symlinked CACHE_ROOT must raise RuntimeError — proof that the guard actually fires and the fix is load-bearing.""" config.CACHE_ROOT = self.symlinked_root with self.assertRaises(RuntimeError) as ctx: importlib.reload(cache_check) msg = str(ctx.exception) # Message must name the offending path so operators can fix it # without spelunking the source. Don't over-specify wording — # future maintainers may clarify the phrasing. self.assertIn(str(self.symlinked_root), msg) self.assertIn("symlink", msg.lower()) def test_real_directory_cache_root_passes(self): """Sanity: the guard is not over-broad — a real directory (the common case) reloads cleanly.""" real_dir = self.root / "real_cache_root" real_dir.mkdir() config.CACHE_ROOT = real_dir # No raise. importlib.reload(cache_check) def test_nonexistent_cache_root_passes(self): """Pristine install: CACHE_ROOT hasn't been created yet. Path.is_symlink() returns False on non-existent paths, so import must succeed — the directory will be materialized on first write.""" ghost = self.root / "does_not_exist_yet" self.assertFalse(ghost.exists()) config.CACHE_ROOT = ghost # No raise. importlib.reload(cache_check) if __name__ == "__main__": unittest.main()