Accumulated uncommitted work: GHOST Web backend, WebGL/GPU web layer, web workflow engine and protocol, tooling, docs and task records. Large generated evidence, user-testing archives, build snapshots and imported test assets remain untracked. Co-Authored-By: Claude Code <noreply@anthropic.com>
214 lines
9.0 KiB
Python
214 lines
9.0 KiB
Python
"""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
|