"""Content-pinned checkpoints for successful local validation steps.""" from __future__ import annotations import copy import hashlib import json import math import os from pathlib import Path import re import tempfile _SHA256 = re.compile(r"[0-9a-f]{64}\Z") _FAILURE_FLAGS = ( "interrupted", "timedOut", "idleTimedOut", "logLimitExceeded", "providerAborted", "aborted", "cancelled", "outputLimitExceeded", ) def _encoded(value): return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True, allow_nan=False).encode("utf-8") def _digest(path): result = hashlib.sha256() with path.open("rb") as stream: for chunk in iter(lambda: stream.read(1024 * 1024), b""): result.update(chunk) return result.hexdigest() class StepCache: def __init__(self, root, directory): self.root = Path(root).resolve(strict=True) if not self.root.is_dir(): raise ValueError("cache root must be a directory") self.directory = self._path(directory, existing=False) self.directory.mkdir(parents=True, exist_ok=True) self._pins = {} def _path(self, value, *, existing=True): if not isinstance(value, (str, os.PathLike)) or not str(value): raise ValueError("missing path") candidate = Path(value) if ".." in candidate.parts: raise ValueError("path traversal is not permitted") if not candidate.is_absolute(): candidate = self.root / candidate resolved = candidate.resolve(strict=existing) if not resolved.is_relative_to(self.root): raise ValueError("path must remain under root") return resolved def _hashes(self, values): if not isinstance(values, dict) or not values: raise ValueError("nonempty input hashes are required") normalized = {} for name, digest in values.items(): if not isinstance(digest, str) or not _SHA256.fullmatch(digest): raise ValueError("invalid SHA-256") path = self._path(name) if not path.is_file(): raise ValueError("input must be a file") relative = self._relative(name) if relative in normalized and normalized[relative] != digest: raise ValueError("conflicting input hashes") normalized[relative] = digest return normalized def _relative(self, value): path = Path(value) if not path.is_absolute(): path = self.root / path return path.relative_to(self.root).as_posix() def key(self, job, check_id, argv, cwd, env, unset_env, input_hashes): if not isinstance(check_id, str) or not check_id: raise ValueError("check id is required") if not isinstance(argv, (list, tuple)) or not argv or any( not isinstance(part, str) for part in argv): raise ValueError("argv must contain strings") if not isinstance(env, dict) or any( not isinstance(k, str) or not isinstance(v, str) for k, v in env.items()): raise ValueError("environment must contain strings") if not isinstance(unset_env, (list, tuple)) or any( not isinstance(part, str) for part in unset_env): raise ValueError("unset environment must contain strings") directory = self._path(cwd) if not directory.is_dir(): raise ValueError("cwd must be a directory") hashes = self._hashes(input_hashes) payload = { "schemaVersion": 1, "job": job, "checkId": check_id, "argv": list(argv), "cwd": directory.relative_to(self.root).as_posix(), "env": env, "unsetEnv": sorted(set(unset_env)), "inputs": hashes, } key = hashlib.sha256(_encoded(payload)).hexdigest() self._pins[key] = hashes return key def _record_path(self, key): if not isinstance(key, str) or not _SHA256.fullmatch(key): raise ValueError("invalid cache key") directory = self._path(self.directory) return directory / (key + ".json") def _verify_hashes(self, hashes): normalized = self._hashes(hashes) for name, expected in normalized.items(): if _digest(self._path(name)) != expected: raise ValueError("input content changed") return normalized def _verify_receipt(self, receipt): if not isinstance(receipt, dict): raise ValueError("receipt must be an object") if type(receipt.get("exitCode")) is not int or receipt["exitCode"] != 0: raise ValueError("only successful checks can be reused") if any(receipt.get(flag) is not None and receipt.get(flag) is not False for flag in _FAILURE_FLAGS): raise ValueError("interrupted checks cannot be reused") if receipt.get("signal") or receipt.get("stopReason"): raise ValueError("terminated checks cannot be reused") duration = receipt.get("durationSeconds") if type(duration) not in (int, float) or not math.isfinite(duration) or duration < 0: raise ValueError("invalid execution duration") expected = receipt.get("logSha256") if not isinstance(expected, str) or not _SHA256.fullmatch(expected): raise ValueError("log hash is required") log = self._path(receipt.get("logPath", receipt.get("log"))) if not log.is_file() or _digest(log) != expected: raise ValueError("log content changed") for name in ("cwd",): if name in receipt: self._path(receipt[name]) if any(name in receipt for name in ("env", "environment", "environmentVariables")): raise ValueError("raw environment cannot be cached") _encoded(receipt) def _verify_artifacts(self, artifacts): if not isinstance(artifacts, list) or not artifacts: raise ValueError("output artifact hashes are required") normalized = [] seen = set() for artifact in artifacts: if not isinstance(artifact, dict): raise ValueError("artifact must be an object") expected = artifact.get("sha256") if not isinstance(expected, str) or not _SHA256.fullmatch(expected): raise ValueError("artifact hash is required") path = self._path(artifact.get("path")) if not path.is_file() or _digest(path) != expected: raise ValueError("artifact content changed") relative = self._relative(artifact["path"]) if relative in seen: raise ValueError("duplicate artifact path") seen.add(relative) normalized.append({"path": relative, "sha256": expected}) return normalized def store(self, key, process_receipt, artifacts): temporary = None try: target = self._record_path(key) hashes = self._verify_hashes(self._pins.get(key)) self._verify_receipt(process_receipt) outputs = self._verify_artifacts(artifacts) record = {"schemaVersion": 1, "key": key, "inputHashes": hashes, "processReceipt": process_receipt, "artifacts": outputs} data = _encoded(record) with tempfile.NamedTemporaryFile(mode="wb", prefix=".step-", suffix=".tmp", dir=target.parent, delete=False) as stream: temporary = Path(stream.name) stream.write(data) stream.flush() os.fsync(stream.fileno()) os.replace(temporary, target) temporary = None except (OSError, ValueError, TypeError, OverflowError, RuntimeError): return finally: if temporary is not None: temporary.unlink(missing_ok=True) def load(self, key): try: path = self._path(self._record_path(key)) record = json.loads(path.read_text(encoding="utf-8")) if not isinstance(record, dict) or type(record.get("schemaVersion")) is not int: return None if record["schemaVersion"] != 1 or record.get("key") != key: return None hashes = self._verify_hashes(record.get("inputHashes")) if key in self._pins and self._pins[key] != hashes: return None self._verify_artifacts(record.get("artifacts")) receipt = record.get("processReceipt") self._verify_receipt(receipt) result = copy.deepcopy(receipt) result["reusedFrom"] = { "cacheKey": key, "recordPath": path.relative_to(self.root).as_posix(), "logPath": receipt.get("logPath", receipt.get("log")), "logSha256": receipt["logSha256"], "durationSeconds": receipt["durationSeconds"], } return result except (OSError, ValueError, TypeError, OverflowError, RuntimeError): return None