"""Read-only binding preflight for recorded test inventories.""" import argparse import hashlib import json import os import re import selectors import signal import stat import subprocess import time import sys from typing import NoReturn class BindingError(ValueError): pass _HEX40 = re.compile(r"^[0-9a-f]{40}$") _HEX64 = re.compile(r"^[0-9a-f]{64}$") _SCHEMA = "gitea-agent.test-inventory-binding/v1" _MAX_JSON = 1024 * 1024 _MAX_OUTPUT = 64 * 1024 * 1024 _GIT_EXECUTABLE = "/usr/bin/git" def _reject(message="rejected inventory binding input") -> NoReturn: raise BindingError(message) def _keys(value, expected): if not isinstance(value, dict) or set(value) != set(expected): _reject() def _nonempty(value): return isinstance(value, str) and bool(value.strip()) and "\x00" not in value def _sha(value, pattern): return isinstance(value, str) and pattern.fullmatch(value) is not None def _safe_path(value, allow_empty=False): if not isinstance(value, str) or (not value and not allow_empty): _reject() if value.startswith("/") or "\\" in value or "\x00" in value: _reject() parts = value.split("/") if any(part in ("", ".", "..") for part in parts): _reject() return value def _safe_paths(values): if not isinstance(values, list) or len(set(values)) != len(values): _reject() return [_safe_path(v) for v in values] def _directory_fd(raw): if not isinstance(raw, str) or not os.path.isabs(raw) or raw != os.path.normpath(raw): _reject() if raw != "/" and any(p in ("", ".", "..") for p in raw.split("/")[1:]): _reject() fd = os.open("/", os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) try: for part in raw.split("/")[1:]: if not part: continue nxt = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd) os.close(fd) fd = nxt st = os.fstat(fd) if not stat.S_ISDIR(st.st_mode): _reject() except (BindingError, OSError, ValueError): os.close(fd) _reject() return fd def _canonical_git_dir(raw): return _directory_fd(raw) def _env(): return { "PATH": os.environ.get("PATH", "/usr/bin:/bin"), "HOME": "/nonexistent", "GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_SYSTEM": os.devnull, "GIT_NO_LAZY_FETCH": "1", "GIT_NO_REPLACE_OBJECTS": "1", "GIT_TERMINAL_PROMPT": "0", } def _git(git_dir, args, check=False, git_fd=None): command = [_GIT_EXECUTABLE, "--git-dir", git_dir, *args] process = None try: deadline = time.monotonic() + 5 process = subprocess.Popen( command, env=_env(), stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, start_new_session=True, pass_fds=() if git_fd is None else (git_fd,), ) if process.stdout is None: raise OSError("Git output pipe unavailable") output = bytearray() with selectors.DefaultSelector() as selector: selector.register(process.stdout, selectors.EVENT_READ) while True: remaining = deadline - time.monotonic() if remaining <= 0 or not selector.select(remaining): raise subprocess.TimeoutExpired(command, 5) chunk = os.read(process.stdout.fileno(), min(65536, _MAX_OUTPUT - len(output) + 1)) if not chunk: break output.extend(chunk) if len(output) > _MAX_OUTPUT: raise subprocess.SubprocessError("bounded Git output exceeded") returncode = process.wait(timeout=max(0.001, deadline - time.monotonic())) result = subprocess.CompletedProcess(command, returncode, bytes(output)) except (OSError, subprocess.SubprocessError): if check: _reject() return None finally: if process is not None: if process.returncode is None: try: os.killpg(process.pid, signal.SIGKILL) except ProcessLookupError: pass process.wait() if process.stdout is not None: process.stdout.close() if check and result.returncode != 0: _reject() return result def _json_file(path): if not isinstance(path, str) or not os.path.isabs(path) or path != os.path.normpath(path): _reject() try: parent = _directory_fd(os.path.dirname(path)) try: fd = os.open(os.path.basename(path), os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent) finally: os.close(parent) try: st = os.fstat(fd) if not stat.S_ISREG(st.st_mode): _reject() raw = os.read(fd, _MAX_JSON + 1) finally: os.close(fd) except BindingError: raise except (OSError, ValueError): _reject() if len(raw) > _MAX_JSON: _reject() def pairs(items): out = {} for key, value in items: if key in out: _reject() out[key] = value return out try: return json.loads(raw.decode("utf-8"), object_pairs_hook=pairs, parse_constant=lambda _: _reject()) except BindingError: raise except (UnicodeError, ValueError, TypeError, RecursionError): _reject() def _parse(data): _keys(data, ("schema", "source_revision", "stage", "ids", "file_sha256", "collection")) if data["schema"] != _SCHEMA or not _sha(data["source_revision"], _HEX40) or not _nonempty(data["stage"]): _reject() ids = data["ids"] if not isinstance(ids, list) or len(set(ids)) != len(ids): _reject() id_paths = [] for item in ids: if not _nonempty(item) or "::" not in item: _reject() path, symbol = item.split("::", 1) _safe_path(path) if not _nonempty(symbol): _reject() id_paths.append(path) files = data["file_sha256"] if not isinstance(files, dict) or set(files) != set(id_paths): _reject() for path, digest in files.items(): _safe_path(path) if not _sha(digest, _HEX64): _reject() c = data["collection"] _keys(c, ("source_revision", "stage", "argv", "scope_paths", "scope_sha256", "evidence")) if c["source_revision"] != data["source_revision"] or c["stage"] != data["stage"]: _reject() if not isinstance(c["argv"], list) or not c["argv"] or any(not _nonempty(x) for x in c["argv"]): _reject() # This is recorded argv, not a shell command; it is never executed here. scope = _safe_paths(c["scope_paths"]) if not _sha(c["scope_sha256"], _HEX64) or not _nonempty(c["evidence"]): _reject() if not set(id_paths).issubset(scope): _reject() return data, ids, id_paths, files, c, scope def _tree(git_dir, revision, paths, git_fd=None): if not paths: return {} p = _git(git_dir, ["ls-tree", "-rz", "-r", "--full-tree", revision, "--", *paths], git_fd=git_fd) if p is None or p.returncode != 0: return None found = {} for record in p.stdout.split(b"\0"): if not record: continue try: header, pathb = record.split(b"\t", 1) mode, kind, obj = header.decode("ascii").split(" ") path = pathb.decode("utf-8") except (ValueError, UnicodeError): _reject() if path in paths: found[path] = (mode, kind, obj) return found def _blob(git_dir, revision, path, git_fd=None): p = _git(git_dir, ["show", f"{revision}:{path}"], git_fd=git_fd) if p is None or p.returncode != 0: return None return p.stdout def _result(status, reasons, counts): return {"status": status, "reason_codes": sorted(set(reasons)), "counts": counts} def verify_inventory(git_dir, data, expected_revision, expected_stage): git_fd = _canonical_git_dir(git_dir) try: if not _sha(expected_revision, _HEX40) or not _nonempty(expected_stage): _reject() data, ids, id_paths, files, collection, scope = _parse(data) counts = {"ids": len(ids), "files": len(files), "scope_paths": len(scope)} reasons = [] if data["source_revision"] != expected_revision: reasons.append("source_revision_mismatch") if data["stage"] != expected_stage: reasons.append("stage_mismatch") if reasons: return _result("block", reasons, counts) # Validate repository kind without consulting a work tree or refs. pinned_git_dir = f"/proc/self/fd/{git_fd}" bare = _git(pinned_git_dir, ["rev-parse", "--is-bare-repository"], git_fd=git_fd) if bare is None or bare.returncode != 0 or bare.stdout.strip() not in (b"true", b"false"): return _result("block", ["git_directory_unavailable"], counts) commit = _git(pinned_git_dir, ["cat-file", "-t", expected_revision], git_fd=git_fd) if commit is None or commit.returncode != 0 or commit.stdout.strip() != b"commit": return _result("block", ["revision_not_found"], counts) tree = _tree(pinned_git_dir, expected_revision, sorted(set(scope)), git_fd=git_fd) if tree is None: return _result("block", ["scope_unavailable"], counts) digest_map = {} for path in scope: entry = tree.get(path) if entry is None: reasons.append("scope_file_missing") continue mode, kind, obj = entry if mode == "120000" or kind != "blob" or mode not in ("100644", "100755"): reasons.append("scope_file_not_regular") continue raw = _blob(pinned_git_dir, expected_revision, path, git_fd=git_fd) if raw is None: reasons.append("scope_blob_unavailable") continue git_blob = hashlib.sha1(b"blob " + str(len(raw)).encode() + bytes([0]) + raw).hexdigest() if git_blob != obj: reasons.append("scope_blob_identity_mismatch") digest_map[path] = hashlib.sha256(raw).hexdigest() if path in files and digest_map[path] != files[path]: reasons.append("recorded_file_hash_mismatch") digest = hashlib.sha256(json.dumps(digest_map, sort_keys=True, separators=(",", ":")).encode()).hexdigest() if digest != collection["scope_sha256"]: reasons.append("scope_digest_mismatch") return _result("block" if reasons else "pass", reasons, counts) except BindingError: raise except (OSError, TypeError, ValueError, KeyError, RecursionError): _reject() finally: os.close(git_fd) class _Parser(argparse.ArgumentParser): def error(self, message) -> NoReturn: # argparse normally echoes rejected argument values to stderr. _reject() def main(argv=None): parser = _Parser(add_help=True, allow_abbrev=False) parser.add_argument("--git-dir", required=True) parser.add_argument("--input", required=True) parser.add_argument("--expected-revision", required=True) parser.add_argument("--expected-stage", required=True) try: args = parser.parse_args(argv) result = verify_inventory(args.git_dir, _json_file(args.input), args.expected_revision, args.expected_stage) print(json.dumps(result, sort_keys=True, separators=(",", ":"))) return 0 if result["status"] == "pass" else 1 except (BindingError, OSError, ValueError, TypeError, KeyError, RecursionError): print(json.dumps({"status": "block", "reason_codes": ["rejected_input"], "counts": {}}, separators=(",", ":"))) return 2 if __name__ == "__main__": raise SystemExit(main())