From cf88e2df86afbd89d868adcc755fb6ebe74f6e77 Mon Sep 17 00:00:00 2001 From: mptyl Date: Tue, 11 Aug 2026 12:11:41 +0200 Subject: [PATCH] test: add deterministic runtime config chain race regression --- .../workspace-runtime-config-lease.test.ts | 49 ++++++++++++++ harness/tht/config.py | 64 ++++++++++++++++--- 2 files changed, 103 insertions(+), 10 deletions(-) diff --git a/backend/test/workspace-runtime-config-lease.test.ts b/backend/test/workspace-runtime-config-lease.test.ts index 3c6578af..b054a1b9 100644 --- a/backend/test/workspace-runtime-config-lease.test.ts +++ b/backend/test/workspace-runtime-config-lease.test.ts @@ -560,3 +560,52 @@ test.each(["leaf", "ancestor"] as const)("actual harness rejects canonical %s sw })).toThrow(); } finally { rmSync(f.root, { recursive: true, force: true }); } }); + + +test("actual harness rejects a workspace chain swap between config and manifest traversal", () => { + const f = fixture(); + try { + const lease = f.factory.acquireSession(f.snapshotPath); + const python = join(process.cwd(), "..", "harness", ".venv", "bin", "python"); + const helper = join(process.cwd(), "..", "harness", "tht"); + const workspaceRoot = join(f.factoryInput.dataRoot, "sessions", workspace); + const code = `import os,sys +from pathlib import Path +sys.path.insert(0, sys.argv[3]) +from tht.config import ConfigError, _read_runtime_config_source +config_path = Path(sys.argv[1]) +workspace = Path(sys.argv[2]) +revision = config_path.stem + +def swap_workspace_chain(): + moved = Path(str(workspace) + ".moved") + os.rename(workspace, moved) + (workspace / "preprocessing").mkdir(parents=True, mode=0o700) + for name, leaf in (("runtime-config", revision + ".yaml"), + ("runtime-config-manifests", revision + ".json")): + source = moved / "preprocessing" / name + destination = workspace / "preprocessing" / name + original_inode = os.stat(source).st_ino + original_leaf_inode = os.stat(source / leaf).st_ino + os.rename(source, destination) + assert os.stat(destination).st_ino == original_inode + assert os.stat(destination / leaf).st_ino == original_leaf_inode + +try: + _read_runtime_config_source( + config_path, + between_config_and_manifest_traversal=swap_workspace_chain, + ) +except ConfigError: + print("rejected") +else: + raise SystemExit("secure reader accepted a replaced workspace chain") +`; + const output = execFileSync(python, ["-c", code, lease.path, workspaceRoot, helper], { + cwd: join(process.cwd(), "..", "harness"), + env: { ...process.env, THT_RUNTIME_CONFIG_MANIFEST_SHA256: lease.manifestSha256 }, + encoding: "utf8", + }); + expect(output.trim()).toBe("rejected"); + } finally { rmSync(f.root, { recursive: true, force: true }); } +}); diff --git a/harness/tht/config.py b/harness/tht/config.py index eec41d22..0124b067 100644 --- a/harness/tht/config.py +++ b/harness/tht/config.py @@ -5,6 +5,7 @@ import re import stat import sys import warnings +from collections.abc import Callable from ipaddress import ip_address from pathlib import Path from typing import Annotated, Any, Literal @@ -694,8 +695,25 @@ def _runtime_directory_identities(paths: list[Path]) -> list[dict[str, str]]: return out -def _verify_runtime_directory_identities(config_path: Path, manifest_path: Path, expected: object) -> None: - current = _runtime_directory_identities([config_path.parent, manifest_path.parent]) +def _verify_runtime_directory_identities( + config_path: Path, + manifest_path: Path, + expected: object, + *, + between_config_and_manifest_traversal: Callable[[], None] | None = None, +) -> None: + # Traverse the two destination branches separately. The callback is a narrow, + # package-private seam for deterministic direct tests of a replacement between + # those traversals; production always passes None. + current = _runtime_directory_identities([config_path.parent]) + if between_config_and_manifest_traversal is not None: + between_config_and_manifest_traversal() + for identity in _runtime_directory_identities([manifest_path.parent]): + previous = next((item for item in current if item["path"] == identity["path"]), None) + if previous is None: + current.append(identity) + elif previous != identity: + raise ConfigError("Destinazione config runtime modificata") if current != expected: raise ConfigError("Destinazione config runtime modificata") @@ -746,11 +764,20 @@ def _runtime_manifest_path(config_path: Path) -> Path: raise OSError("runtime config path is not canonical") return config_path.parent.parent / "runtime-config-manifests" / f"{config_path.stem}.json" -def load_config(path: Path) -> Config: +def _read_runtime_config_source( + path: Path, + *, + between_config_and_manifest_traversal: Callable[[], None] | None = None, +) -> tuple[str | None, dict[str, object] | None]: + """Read a trusted runtime config, with a scoped direct-test race seam. + + The callback is deliberately available only on this package-private helper. + Production callers go through :func:`load_config`, which always passes ``None``; + no environment variable or process-global hook can alter this traversal. + """ # Registry leases authenticate the canonical pathname through a durable manifest # digest. The config and manifest are opened component-by-component; FD 3/4 are # reserved for the maintenance writer/root ABI. - runtime_manifest: dict[str, object] | None = None expected_manifest = os.environ.get("THT_RUNTIME_CONFIG_MANIFEST_SHA256") runtime_fd = os.environ.get("THT_CONFIG_FD") manifest_fd = os.environ.get("THT_CONFIG_MANIFEST_FD") @@ -767,7 +794,12 @@ def load_config(path: Path) -> Config: if hashlib.sha256(manifest_bytes).hexdigest() != expected_manifest: raise ConfigError("Manifest runtime modificato") runtime_manifest = _strict_runtime_manifest(json.loads(manifest_bytes.decode("utf-8"))) - _verify_runtime_directory_identities(path, _runtime_manifest_path(path), runtime_manifest["directory_identities"]) + _verify_runtime_directory_identities( + path, + _runtime_manifest_path(path), + runtime_manifest["directory_identities"], + between_config_and_manifest_traversal=between_config_and_manifest_traversal, + ) if (runtime_manifest["config_sha256"] != hashlib.sha256(config_bytes).hexdigest() or int(runtime_manifest["config_dev"]) != config_info.st_dev or int(runtime_manifest["config_ino"]) != config_info.st_ino @@ -776,7 +808,7 @@ def load_config(path: Path) -> Config: or int(runtime_manifest["config_nlink"]) != config_info.st_nlink or config_info.st_dev == manifest_info.st_dev and config_info.st_ino == manifest_info.st_ino): raise ConfigError("Identità config runtime non valida") - source_text = config_bytes.decode("utf-8") + return config_bytes.decode("utf-8"), runtime_manifest except (OSError, UnicodeError, ValueError, json.JSONDecodeError) as exc: if isinstance(exc, ConfigError): raise @@ -786,7 +818,7 @@ def load_config(path: Path) -> Config: os.close(config_fd) if manifest_fd_local is not None: os.close(manifest_fd_local) - elif runtime_fd is not None or manifest_fd is not None or legacy_expected is not None: + if runtime_fd is not None or manifest_fd is not None or legacy_expected is not None: # Compatibility for direct /dev/fd callers. New backend leases never use it. if runtime_fd is None or manifest_fd is None or legacy_expected is None or not re.fullmatch(r"[0-9a-f]{64}", legacy_expected): raise ConfigError("Handoff runtime incompleto") @@ -796,7 +828,12 @@ def load_config(path: Path) -> Config: if hashlib.sha256(manifest_bytes).hexdigest() != legacy_expected: raise ConfigError("Manifest runtime modificato") runtime_manifest = _strict_runtime_manifest(json.loads(manifest_bytes.decode("utf-8"))) - _verify_runtime_directory_identities(path, _runtime_manifest_path(path), runtime_manifest["directory_identities"]) + _verify_runtime_directory_identities( + path, + _runtime_manifest_path(path), + runtime_manifest["directory_identities"], + between_config_and_manifest_traversal=between_config_and_manifest_traversal, + ) if (runtime_manifest["config_sha256"] != hashlib.sha256(config_bytes).hexdigest() or int(runtime_manifest["config_dev"]) != config_info.st_dev or int(runtime_manifest["config_ino"]) != config_info.st_ino @@ -805,12 +842,19 @@ def load_config(path: Path) -> Config: or int(runtime_manifest["config_nlink"]) != config_info.st_nlink or config_info.st_dev == manifest_info.st_dev and config_info.st_ino == manifest_info.st_ino): raise ConfigError("Identità config runtime non valida") - source_text = config_bytes.decode("utf-8") + return config_bytes.decode("utf-8"), runtime_manifest except (OSError, UnicodeError, ValueError, json.JSONDecodeError) as exc: if isinstance(exc, ConfigError): raise raise ConfigError("File di configurazione runtime non attendibile") from exc - else: + return None, None + + +def load_config(path: Path) -> Config: + source_text, runtime_manifest = _read_runtime_config_source( + path, between_config_and_manifest_traversal=None + ) + if source_text is None: if not path.exists(): raise ConfigError(f"File di configurazione non trovato: {path}") try: