Files
gitea-agent/harness/lib/test_inventory_binding.py
T

340 lines
12 KiB
Python

"""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())