app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

rshr_201_step_gate.py (14939B)


      1 #!/usr/bin/env python3
      2 """Emit the source-bound RSHR-201 gate result for HarvestCircle Steps 289/290."""
      3 
      4 from __future__ import annotations
      5 
      6 import argparse
      7 import hashlib
      8 import json
      9 import os
     10 import re
     11 import signal
     12 import stat
     13 import subprocess
     14 import sys
     15 import tempfile
     16 from pathlib import Path
     17 
     18 
     19 ROOT = Path(__file__).resolve().parent.parent
     20 AUTHORITY_PATH = ROOT / "contracts/rshr-201-step-gates.v1.json"
     21 ORIGIN = "ssh://git@github.com/radrootslabs/harvestcircle.git"
     22 BRANCH = "rshr/rcld-201"
     23 MAX_SOURCE_BYTES = 64 * 1024 * 1024
     24 MAX_STREAM_BYTES = 64 * 1024 * 1024
     25 TIMEOUT_SECONDS = 3600
     26 GATE_DEFINITIONS = {
     27     289: "focused Kotlin and source-lock mutations",
     28     290: "focused Gradle and unsigned-package negatives",
     29 }
     30 
     31 
     32 class GateError(RuntimeError):
     33     """A fail-closed gate error."""
     34 
     35 
     36 def canonical(value: object) -> bytes:
     37     return json.dumps(
     38         value,
     39         sort_keys=True,
     40         separators=(",", ":"),
     41         ensure_ascii=False,
     42         allow_nan=False,
     43     ).encode("utf-8")
     44 
     45 
     46 def sha256_bytes(contents: bytes) -> str:
     47     return hashlib.sha256(contents).hexdigest()
     48 
     49 
     50 def read_regular(path: Path, maximum: int = MAX_SOURCE_BYTES) -> bytes:
     51     relative = path.relative_to(ROOT)
     52     if path.is_symlink():
     53         raise GateError(f"source path is a symbolic link: {relative}")
     54     descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
     55     try:
     56         metadata = os.fstat(descriptor)
     57         if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > maximum:
     58             raise GateError(f"source path is not a bounded regular file: {relative}")
     59         chunks: list[bytes] = []
     60         remaining = maximum + 1
     61         while remaining:
     62             chunk = os.read(descriptor, min(1024 * 1024, remaining))
     63             if not chunk:
     64                 break
     65             chunks.append(chunk)
     66             remaining -= len(chunk)
     67         contents = b"".join(chunks)
     68         if len(contents) > maximum:
     69             raise GateError(f"source path exceeds its byte bound: {relative}")
     70         after = os.fstat(descriptor)
     71         if (metadata.st_dev, metadata.st_ino, metadata.st_size) != (
     72             after.st_dev,
     73             after.st_ino,
     74             after.st_size,
     75         ):
     76             raise GateError(f"source path changed during read: {relative}")
     77         return contents
     78     finally:
     79         os.close(descriptor)
     80 
     81 
     82 def run(arguments: list[str], environment: dict[str, str] | None = None) -> bytes:
     83     with tempfile.TemporaryFile() as stdout, tempfile.TemporaryFile() as stderr:
     84         process = subprocess.Popen(
     85             arguments,
     86             cwd=ROOT,
     87             env=environment,
     88             stdin=subprocess.DEVNULL,
     89             stdout=stdout,
     90             stderr=stderr,
     91             start_new_session=True,
     92         )
     93         try:
     94             return_code = process.wait(timeout=TIMEOUT_SECONDS)
     95         except subprocess.TimeoutExpired as error:
     96             os.killpg(process.pid, signal.SIGTERM)
     97             try:
     98                 process.wait(timeout=1)
     99             except subprocess.TimeoutExpired:
    100                 os.killpg(process.pid, signal.SIGKILL)
    101                 process.wait(timeout=1)
    102             raise GateError(f"command exceeded {TIMEOUT_SECONDS}s: {arguments!r}") from error
    103         stdout.seek(0, os.SEEK_END)
    104         stderr.seek(0, os.SEEK_END)
    105         stdout_size = stdout.tell()
    106         stderr_size = stderr.tell()
    107         if stdout_size > MAX_STREAM_BYTES or stderr_size > MAX_STREAM_BYTES:
    108             raise GateError(f"command output exceeded its bound: {arguments!r}")
    109         stdout.seek(0)
    110         stderr.seek(0)
    111         captured_stdout = stdout.read()
    112         captured_stderr = stderr.read()
    113     if return_code:
    114         detail = (captured_stdout + captured_stderr)[-8192:].decode("utf-8", "replace")
    115         raise GateError(f"command failed ({return_code}): {arguments!r}\n{detail}")
    116     return captured_stdout
    117 
    118 
    119 def git(*arguments: str) -> str:
    120     return run(["git", *arguments]).decode("utf-8", "strict").strip()
    121 
    122 
    123 def require_source_state(source_revision: str, source_tree: str) -> None:
    124     if (
    125         git("rev-parse", "HEAD") != source_revision
    126         or git("rev-parse", "HEAD^{tree}") != source_tree
    127         or git("symbolic-ref", "--short", "HEAD") != BRANCH
    128         or git("remote", "get-url", "origin") != ORIGIN
    129         or git("rev-parse", f"origin/{BRANCH}") != source_revision
    130         or run(
    131             [
    132                 "git",
    133                 "status",
    134                 "--porcelain=v1",
    135                 "-z",
    136                 "--untracked-files=all",
    137             ]
    138         )
    139     ):
    140         raise GateError("HarvestCircle source is not clean and tracking-exact")
    141     forbidden = git("ls-files", ".github", ".github/**")
    142     if forbidden or (ROOT / ".github").exists():
    143         raise GateError("forbidden .github surface is present")
    144 
    145 
    146 def source_lock_revision() -> str:
    147     source_lock = read_regular(ROOT / "radroots.lib.source-lock.v1.toml", 16 * 1024)
    148     try:
    149         text = source_lock.decode("utf-8", "strict")
    150     except UnicodeError as error:
    151         raise GateError("Lib source lock is not UTF-8") from error
    152     values = dict(
    153         re.findall(r'^([a-z0-9_]+) = "([^"\r\n]+)"$', text, flags=re.MULTILINE)
    154     )
    155     if values.get("schema") != "radroots.lib.source-lock.v1":
    156         raise GateError("Lib source-lock schema differs")
    157     if values.get("lockfile") != "core/Cargo.lock":
    158         raise GateError("Lib source-lock path differs")
    159     lockfile = read_regular(ROOT / values["lockfile"])
    160     if values.get("lockfile_sha256") != sha256_bytes(lockfile):
    161         raise GateError("Lib source-lock digest differs from actual bounded bytes")
    162     revision = values.get("revision", "")
    163     if re.fullmatch(r"[0-9a-f]{40}", revision) is None:
    164         raise GateError("Lib source-lock revision is not canonical")
    165     return revision
    166 
    167 
    168 def gate_environment(source_revision: str) -> dict[str, str]:
    169     allowed = {
    170         "CARGO_HOME",
    171         "EXT_BUILD_CONFIG",
    172         "EXT_BUILD_MACHINE_CONFIG",
    173         "EXT_BUILD_ROOT",
    174         "GRADLE_USER_HOME",
    175         "HOME",
    176         "JAVA_HOME",
    177         "LANG",
    178         "LC_ALL",
    179         "PATH",
    180         "RUSTUP_HOME",
    181         "RUSTUP_TOOLCHAIN",
    182         "SDKROOT",
    183         "TMPDIR",
    184         "XCODE_DEVELOPER_DIR",
    185         "XCODE_DERIVED_DATA",
    186         "XCODE_PACKAGE_CACHE",
    187         "XCODE_SOURCE_PACKAGES",
    188     }
    189     environment = {name: value for name, value in os.environ.items() if name in allowed}
    190     environment.update(
    191         {
    192             "HARVESTCIRCLE_BUILD_SOURCE_COMMIT": source_revision,
    193             "HARVESTCIRCLE_BUILD_SOURCE_DIRTY": "false",
    194             "HARVESTCIRCLE_BUILD_RADROOTS_REVISION": source_lock_revision(),
    195             "HARVESTCIRCLE_BUILD_RUST_TOOLCHAIN": "1.97.1",
    196             "SOURCE_DATE_EPOCH": git("show", "-s", "--format=%ct", source_revision),
    197         }
    198     )
    199     return environment
    200 
    201 
    202 def run_step(step: int, source_revision: str) -> None:
    203     environment = gate_environment(source_revision)
    204     if step == 289:
    205         run(
    206             [
    207                 "./gradlew",
    208                 "--offline",
    209                 "--no-daemon",
    210                 "-p",
    211                 "build-logic",
    212                 ":contracts:test",
    213                 "--tests",
    214                 "org.harvestcircle.buildlogic.contracts.BuildContractsTest",
    215             ],
    216             environment,
    217         )
    218         run(
    219             [
    220                 "cargo",
    221                 "+1.97.1",
    222                 "test",
    223                 "--offline",
    224                 "--manifest-path",
    225                 "tools/xtask/Cargo.toml",
    226                 "--locked",
    227                 "source_lock",
    228             ],
    229             environment,
    230         )
    231     elif step == 290:
    232         run(["tools/test-build-modes.sh"], environment)
    233         run(
    234             [
    235                 "./gradlew",
    236                 "--offline",
    237                 "--no-daemon",
    238                 "-p",
    239                 "build-logic",
    240                 ":contracts:test",
    241                 ":plugins:test",
    242                 ":plugins:functionalTest",
    243             ],
    244             environment,
    245         )
    246         run(
    247             [
    248                 "./gradlew",
    249                 "--offline",
    250                 "--no-daemon",
    251                 "--no-parallel",
    252                 "--no-configuration-cache",
    253                 ":app:desktop:unsignedReleaseReadiness",
    254             ],
    255             environment,
    256         )
    257     else:
    258         raise GateError(f"unsupported step: {step}")
    259     if run(
    260         ["git", "status", "--porcelain=v1", "-z", "--untracked-files=all"]
    261     ):
    262         raise GateError("verification changed the tracked or untracked source state")
    263 
    264 
    265 def parse_arguments() -> argparse.Namespace:
    266     parser = argparse.ArgumentParser(allow_abbrev=False)
    267     parser.add_argument("--emit-platform-result", action="store_true")
    268     parser.add_argument("--step", type=int, required=True)
    269     parser.add_argument("--check-id")
    270     parser.add_argument("--source-revision", required=True)
    271     parser.add_argument("--source-tree", required=True)
    272     parser.add_argument("--candidate-digest")
    273     parser.add_argument("--platform", required=True)
    274     parser.add_argument("--execution-request-sha256", required=True)
    275     return parser.parse_args()
    276 
    277 
    278 def emit_platform_result(arguments: argparse.Namespace) -> int:
    279     if arguments.check_id is not None or arguments.candidate_digest is not None:
    280         raise GateError("platform probe received gate-only arguments")
    281     require_source_state(arguments.source_revision, arguments.source_tree)
    282     identity = os.uname()
    283     if (
    284         arguments.platform != "macos_aarch64"
    285         or identity.sysname != "Darwin"
    286         or identity.machine not in {"arm64", "aarch64"}
    287     ):
    288         raise GateError("platform probe is not running on macOS aarch64")
    289     xcode_identity = {
    290         "xcodebuild": run(["/usr/bin/xcodebuild", "-version"]).decode(
    291             "utf-8", "strict"
    292         ),
    293         "sdk": {
    294             sdk: run(
    295                 ["/usr/bin/xcrun", "--sdk", sdk, "--show-sdk-version"]
    296             ).decode("utf-8", "strict")
    297             for sdk in ("iphoneos", "iphonesimulator", "macosx")
    298         },
    299     }
    300     verifier_path = Path(__file__).resolve()
    301     result = {
    302         "schema": "radroots.services-hardening.rshr-200-platform-result.v1",
    303         "platform": "macos_aarch64",
    304         "system": "aarch64-darwin",
    305         "os_family": "macos",
    306         "architecture": "aarch64",
    307         "kernel_name": "Darwin",
    308         "kernel_release": identity.release,
    309         "os_build_sha256": sha256_bytes(
    310             canonical(
    311                 {
    312                     "kernel_name": identity.sysname,
    313                     "kernel_release": identity.release,
    314                     "kernel_version": identity.version,
    315                 }
    316             )
    317         ),
    318         "runner_kind": "host",
    319         "runner_image_sha256": "none",
    320         "apple_toolchain_identity_sha256": sha256_bytes(canonical(xcode_identity)),
    321         "probe_source_path": verifier_path.relative_to(ROOT).as_posix(),
    322         "probe_source_sha256": sha256_bytes(read_regular(verifier_path)),
    323         "execution_request_sha256": arguments.execution_request_sha256,
    324         "assertion": [
    325             {"id": identifier, "result": "pass"}
    326             for identifier in (
    327                 "os_family",
    328                 "architecture",
    329                 "kernel_identity",
    330                 "runner_identity",
    331                 "apple_identity",
    332             )
    333         ],
    334         "result": "available",
    335     }
    336     sys.stdout.buffer.write(canonical(result))
    337     return 0
    338 
    339 
    340 def main() -> int:
    341     arguments = parse_arguments()
    342     if arguments.step not in GATE_DEFINITIONS:
    343         raise GateError("step is outside the HarvestCircle gate authority")
    344     for value, label in (
    345         (arguments.source_revision, "source revision"),
    346         (arguments.source_tree, "source tree"),
    347         (arguments.execution_request_sha256, "execution request"),
    348     ):
    349         expected_length = 40 if label in {"source revision", "source tree"} else 64
    350         if re.fullmatch(rf"[0-9a-f]{{{expected_length}}}", value) is None:
    351             raise GateError(f"{label} is not canonical")
    352     if arguments.emit_platform_result:
    353         return emit_platform_result(arguments)
    354     if arguments.check_id is None or re.fullmatch(
    355         r"gate-01-[0-9a-f]{64}", arguments.check_id
    356     ) is None:
    357         raise GateError("check digest is not canonical")
    358     if arguments.candidate_digest != "none" or arguments.platform != "macos_aarch64":
    359         raise GateError("candidate or platform scope differs")
    360     authority_bytes = read_regular(AUTHORITY_PATH, 256 * 1024)
    361     authority = json.loads(authority_bytes)
    362     if canonical(authority) + b"\n" != authority_bytes:
    363         raise GateError("gate authority is not canonical JSON")
    364     selected = [
    365         row
    366         for row in authority.get("gate_command_contract", [])
    367         if row.get("step") == arguments.step
    368     ]
    369     if len(selected) != 1:
    370         raise GateError("gate command authority is absent or duplicated")
    371     contract = selected[0]
    372     gate_digest = sha256_bytes(GATE_DEFINITIONS[arguments.step].encode("utf-8"))
    373     verifier_digest = sha256_bytes(read_regular(Path(__file__).resolve()))
    374     assertion_id = f"step_{arguments.step:03d}_gate_01_{gate_digest}"
    375     if (
    376         contract.get("check_id") != arguments.check_id
    377         or contract.get("gate_definition_sha256") != gate_digest
    378         or contract.get("verifier_sha256") != verifier_digest
    379         or contract.get("assertion_id") != [assertion_id]
    380     ):
    381         raise GateError("gate command authority differs from source bytes")
    382     require_source_state(arguments.source_revision, arguments.source_tree)
    383     run_step(arguments.step, arguments.source_revision)
    384     assertions = [{"id": assertion_id, "result": "pass"}]
    385     result = {
    386         "schema": "radroots.services-hardening.rshr-200-step-check-result.v1",
    387         "step": arguments.step,
    388         "check_id": arguments.check_id,
    389         "gate_definition_sha256": gate_digest,
    390         "source_revision": arguments.source_revision,
    391         "source_tree": arguments.source_tree,
    392         "candidate_generation": 0,
    393         "candidate_digest": "none",
    394         "command_contract_sha256": sha256_bytes(canonical(contract)),
    395         "verifier_sha256": verifier_digest,
    396         "execution_request": [
    397             {
    398                 "platform": arguments.platform,
    399                 "sha256": arguments.execution_request_sha256,
    400             }
    401         ],
    402         "assertion_inventory_sha256": sha256_bytes(canonical(assertions)),
    403         "assertion": assertions,
    404         "result": "pass",
    405     }
    406     sys.stdout.buffer.write(canonical(result) + b"\n")
    407     return 0
    408 
    409 
    410 if __name__ == "__main__":
    411     try:
    412         raise SystemExit(main())
    413     except (GateError, OSError, ValueError, subprocess.SubprocessError) as error:
    414         print(f"HarvestCircle RSHR-201 gate failed: {error}", file=sys.stderr)
    415         raise SystemExit(1)