835 lines
41 KiB
Python
835 lines
41 KiB
Python
#!/usr/bin/env python3
|
|
"""Pure, standard-library support for implementation-spec review artifacts."""
|
|
from __future__ import annotations
|
|
import argparse, grp, hashlib, json, math, os, pwd, re, stat, sys
|
|
from typing import NoReturn
|
|
from pathlib import Path
|
|
|
|
SCHEMA = "gitea-agent.implementation-spec-review-result/v1"
|
|
TOP = ("schema", "target", "contract_sha256", "verdict", "criteria", "scope", "conclusion")
|
|
TARGET = ("owner", "repository", "issue", "pull", "head")
|
|
CRITERION = ("criterion", "contract", "implementation_paths", "test_paths", "evidence", "result", "reason")
|
|
SCOPE = ("missing", "excess", "spec_conflicts")
|
|
VERDICTS = {"approve", "request_changes", "blocked_spec"}
|
|
RESULTS = {"satisfied", "unsatisfied", "indeterminate"}
|
|
HEX40 = re.compile(r"^[0-9a-f]{40}$")
|
|
HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
|
IDENT = re.compile(r"^[^\s/\\\x00<>`=@]+$")
|
|
CONTROL = re.compile(r"[\x00-\x1f\x7f-\x9f\u2028\u2029]")
|
|
MARKUP = re.compile(r"[`<>\[\]{}]|(?:^|\s)[#*!|]|[a-z][a-z0-9+.-]*://|www\.", re.I)
|
|
|
|
def _require_adoption(declaration, revision, stage):
|
|
if declaration is None:
|
|
_fail("delegation adoption declaration required")
|
|
try:
|
|
repo_root = Path(__file__).resolve().parents[6]
|
|
if str(repo_root) not in sys.path:
|
|
sys.path.insert(0, str(repo_root))
|
|
from harness.lib.delegation_adoption import AdoptionError, require_adoption
|
|
except Exception:
|
|
_fail("delegation adoption unavailable")
|
|
try:
|
|
return require_adoption(declaration, expected_source={"kind": "git_tree", "revision": revision}, expected_stage=stage)
|
|
except AdoptionError as exc:
|
|
_fail(str(exc))
|
|
except Exception:
|
|
_fail("delegation adoption unavailable")
|
|
|
|
class ValidationError(ValueError):
|
|
pass
|
|
|
|
def _fail(msg="invalid review artifact") -> NoReturn:
|
|
raise ValidationError(msg)
|
|
|
|
def _obj(v, keys, label):
|
|
if type(v) is not dict or set(v) != set(keys) or len(v) != len(keys): _fail("invalid " + label)
|
|
return v
|
|
|
|
def _str(v, label, lo=1, hi=4000, human=False):
|
|
if type(v) is not str or not lo <= len(v) <= hi: _fail("invalid " + label)
|
|
if CONTROL.search(v) or v != v.rstrip(): _fail("invalid " + label)
|
|
try:
|
|
v.encode("utf-8")
|
|
except UnicodeError:
|
|
_fail("invalid " + label)
|
|
if human and (MARKUP.search(v) or "@" in v): _fail("invalid " + label)
|
|
return v
|
|
|
|
def _array(v, label, hi=512, human=False):
|
|
if type(v) is not list or len(v) > hi: _fail("invalid " + label)
|
|
out=[]; seen=set()
|
|
for x in v:
|
|
_str(x, label + " item", 1, 4000, human)
|
|
if x in seen: _fail("duplicate " + label)
|
|
seen.add(x); out.append(x)
|
|
return out
|
|
|
|
def loads_json(text):
|
|
if type(text) is not str: _fail("invalid JSON")
|
|
def hook(pairs):
|
|
d={}
|
|
for k,v in pairs:
|
|
if k in d: _fail("duplicate JSON key")
|
|
d[k]=v
|
|
return d
|
|
try:
|
|
return json.loads(text, object_pairs_hook=hook, parse_constant=lambda x: _fail("nonfinite JSON"),
|
|
parse_float=lambda x: float(x) if math.isfinite(float(x)) else _fail("nonfinite JSON"))
|
|
except ValidationError: raise
|
|
except Exception: _fail("invalid JSON")
|
|
|
|
def _path(p):
|
|
_str(p, "path", 1, 1024)
|
|
if p.startswith("/") or "\\" in p or "\x00" in p: _fail("invalid path")
|
|
bits=p.split("/")
|
|
if any(x in ("", ".", "..") for x in bits): _fail("invalid path")
|
|
return p
|
|
|
|
def validate_result(result, allowed_paths, sensitive_values=()):
|
|
_obj(result, TOP, "result schema")
|
|
if result["schema"] != SCHEMA: _fail("invalid schema")
|
|
t=_obj(result["target"], TARGET, "target")
|
|
for k in ("owner", "repository"):
|
|
_str(t[k], k, 1, 255);
|
|
if not IDENT.fullmatch(t[k]) or t[k] in (".", ".."): _fail("invalid target")
|
|
for k in ("issue", "pull"):
|
|
if type(t[k]) is not int or t[k] < 1: _fail("invalid target")
|
|
if type(t["head"]) is not str or not HEX40.fullmatch(t["head"]): _fail("invalid target")
|
|
if type(result["contract_sha256"]) is not str or not HEX64.fullmatch(result["contract_sha256"]): _fail("invalid contract hash")
|
|
if type(result["verdict"]) is not str or result["verdict"] not in VERDICTS: _fail("invalid verdict")
|
|
if type(allowed_paths) not in (list, tuple, set, frozenset): _fail("invalid allowed paths")
|
|
for path in allowed_paths:
|
|
_path(path)
|
|
allowed = set(allowed_paths)
|
|
if len(allowed) != len(allowed_paths): _fail("duplicate allowed path")
|
|
criteria=result["criteria"]
|
|
if type(criteria) is not list or not 1 <= len(criteria) <= 512: _fail("invalid criteria")
|
|
ids=set()
|
|
for c in criteria:
|
|
_obj(c, CRITERION, "criterion")
|
|
_str(c["criterion"], "criterion", 1, 200, True)
|
|
if c["criterion"] in ids: _fail("duplicate criterion")
|
|
ids.add(c["criterion"])
|
|
for k in ("contract", "evidence", "reason"): _str(c[k], k, 1, 4000, True)
|
|
for k in ("implementation_paths", "test_paths"):
|
|
for p in _array(c[k], k):
|
|
_path(p)
|
|
if p not in allowed: _fail("path outside allowed set")
|
|
if type(c["result"]) is not str or c["result"] not in RESULTS: _fail("invalid criterion result")
|
|
s=_obj(result["scope"], SCOPE, "scope")
|
|
for k in SCOPE: _array(s[k], k, 512, True)
|
|
_str(result["conclusion"], "conclusion", 1, 4000, True)
|
|
human = [result["conclusion"]] + [c[k] for c in criteria for k in ("criterion", "contract", "evidence", "reason")]
|
|
human += [x for k in SCOPE for x in s[k]]
|
|
for secret in sensitive_values:
|
|
if type(secret) is not str: _fail("invalid sensitive values")
|
|
if secret and any(secret in value for value in human):
|
|
_fail("sensitive value in human data")
|
|
states={c["result"] for c in criteria}
|
|
if result["verdict"] == "approve" and (states != {"satisfied"} or any(s[k] for k in SCOPE)): _fail("verdict contradiction")
|
|
if result["verdict"] == "request_changes" and not ({"unsatisfied", "indeterminate"} & states): _fail("verdict contradiction")
|
|
if result["verdict"] == "blocked_spec" and not (s["spec_conflicts"] or "indeterminate" in states): _fail("verdict contradiction")
|
|
return result
|
|
|
|
def _sha(b): return hashlib.sha256(b).hexdigest()
|
|
def _human_paths(xs):
|
|
if not xs:
|
|
return "없음"
|
|
# Quote complete list elements only when plain comma-separated prose is ambiguous.
|
|
unsafe = "<>@`[]{}*|~"
|
|
if any(", " in x or "; " in x or x == "없음" or '"' in x or any(c in x for c in unsafe) for x in xs):
|
|
def quoted(value):
|
|
text = json.dumps(value, ensure_ascii=False)
|
|
for char in unsafe:
|
|
text = text.replace(char, "\\u%04x" % ord(char))
|
|
return text
|
|
return ", ".join(quoted(x) for x in xs)
|
|
return ", ".join(xs)
|
|
|
|
|
|
def _parse_list(text):
|
|
if text == "없음":
|
|
return []
|
|
if not text.startswith('"'):
|
|
return text.split(", ")
|
|
decoder = json.JSONDecoder()
|
|
out = []
|
|
while text:
|
|
try:
|
|
value, end = decoder.raw_decode(text)
|
|
except ValueError:
|
|
_fail("invalid list encoding")
|
|
if type(value) is not str:
|
|
_fail("invalid list encoding")
|
|
out.append(value)
|
|
text = text[end:]
|
|
if text:
|
|
if not text.startswith(", "):
|
|
_fail("invalid list encoding")
|
|
text = text[2:]
|
|
if not text:
|
|
_fail("invalid list encoding")
|
|
return out
|
|
|
|
|
|
def _parse_evidence(text):
|
|
# Find the first path/evidence separator outside quoted path elements.
|
|
quoted = escaped = False
|
|
for i, char in enumerate(text):
|
|
if quoted:
|
|
if escaped:
|
|
escaped = False
|
|
elif char == "\\":
|
|
escaped = True
|
|
elif char == '"':
|
|
quoted = False
|
|
elif char == '"':
|
|
quoted = True
|
|
elif text[i:i + 2] == "; ":
|
|
return _parse_list(text[:i]), text[i + 2:]
|
|
_fail("invalid criterion block")
|
|
|
|
def _marker(r, digest):
|
|
t=r["target"]
|
|
raw="\x00".join([t["owner"],t["repository"],str(t["issue"]),str(t["pull"]),t["head"],r["contract_sha256"],digest]).encode()
|
|
corr=_sha(raw)
|
|
return ("<!-- gitea-agent:implementation-spec-review/v1 owner=%s repository=%s issue=%d pr=%d head=%s contract-sha256=%s result-sha256=%s correlation=%s -->" %
|
|
(t["owner"],t["repository"],t["issue"],t["pull"],t["head"],r["contract_sha256"],digest,corr))
|
|
|
|
def render_review(result, allowed_paths, sensitive_values=(), *, declaration=None):
|
|
if type(result) is dict and type(result.get("target")) is dict:
|
|
_require_adoption(declaration, result["target"].get("head"), "spec-review")
|
|
r=validate_result(result, allowed_paths, sensitive_values); t=r["target"]
|
|
lines=["## 구현 명세 적합성 검토", "", f"- Source Issue: #{t['issue']}", f"- Pull Request: #{t['pull']}",
|
|
f"- Reviewed head: `{t['head']}`", f"- Contract SHA-256: `{r['contract_sha256']}`", f"- Verdict: `{r['verdict']}`", "",
|
|
"### Acceptance criteria 판정", ""]
|
|
for c in r["criteria"]:
|
|
lines += [f"- `{c['criterion']}` — `{c['result']}`", f" - 계약: {c['contract']}",
|
|
f" - 구현 근거: {_human_paths(c['implementation_paths'])}; {c['evidence']}", f" - 테스트 근거: {_human_paths(c['test_paths'])}",
|
|
f" - 판정 근거: {c['reason']}", ""]
|
|
s=r["scope"]
|
|
lines += ["### 범위 판정", "", f"- 누락: {_human_paths(s['missing'])}", f"- 범위 초과: {_human_paths(s['excess'])}",
|
|
f"- 명세 모순: {_human_paths(s['spec_conflicts'])}", "", "### 결론", "", r["conclusion"], ""]
|
|
body="\n".join(lines)
|
|
return body + "\n" + _marker(r, _sha(body.encode("utf-8"))) + "\n"
|
|
|
|
def parse_review(body, allowed_paths, sensitive_values=()):
|
|
if type(body) is not str or not body.endswith("\n"): _fail("invalid review body")
|
|
lines=body.splitlines()
|
|
if not lines or not lines[-1].startswith("<!-- gitea-agent:implementation-spec-review/v1 ") or not lines[-1].endswith(" -->"): _fail("invalid review marker")
|
|
marker=lines[-1]; m=re.fullmatch(r"<!-- gitea-agent:implementation-spec-review/v1 owner=([^ ]+) repository=([^ ]+) issue=(\d+) pr=(\d+) head=([0-9a-f]{40}) contract-sha256=([0-9a-f]{64}) result-sha256=([0-9a-f]{64}) correlation=([0-9a-f]{64}) -->", marker)
|
|
if not m: _fail("invalid review marker")
|
|
# Parse the stable prose emitted by render_review, then validate by re-rendering.
|
|
try:
|
|
if lines[0] != "## 구현 명세 적합성 검토" or lines[8] != "### Acceptance criteria 판정": _fail("invalid review body")
|
|
issue=int(lines[2][len("- Source Issue: #") :]); pull=int(lines[3][len("- Pull Request: #"):])
|
|
head=lines[4][len("- Reviewed head: `"):-1]; contract=lines[5][len("- Contract SHA-256: `"):-1]; verdict=lines[6][len("- Verdict: `"):-1]
|
|
i=9; criteria=[]
|
|
while i < len(lines) and lines[i] != "### 범위 판정":
|
|
if not lines[i]: i+=1; continue
|
|
cm=re.fullmatch(r"- `([^`]+)` — `([^`]+)`", lines[i]);
|
|
if not cm: _fail("invalid criterion block")
|
|
c={"criterion":cm.group(1),"result":cm.group(2)}
|
|
for key,prefix in (("contract"," - 계약: "),("implementation_paths"," - 구현 근거: "),("test_paths"," - 테스트 근거: "),("reason"," - 판정 근거: ")):
|
|
i+=1
|
|
if i>=len(lines) or not lines[i].startswith(prefix): _fail("invalid criterion block")
|
|
val=lines[i][len(prefix):]
|
|
if key == "implementation_paths":
|
|
c[key], c["evidence"] = _parse_evidence(val)
|
|
else:
|
|
c[key] = _parse_list(val) if key.endswith("paths") else val
|
|
c={"criterion":c["criterion"],"contract":c["contract"],"implementation_paths":c["implementation_paths"],"test_paths":c["test_paths"],"evidence":c["evidence"],"result":c["result"],"reason":c["reason"]}
|
|
criteria.append(c); i+=1
|
|
if i+6 >= len(lines) or lines[i+1] != "": _fail("invalid scope block")
|
|
scope={}; i += 1
|
|
for key,prefix in (("missing","- 누락: "),("excess","- 범위 초과: "),("spec_conflicts","- 명세 모순: ")):
|
|
i += 1
|
|
if not lines[i].startswith(prefix): _fail("invalid scope block")
|
|
scope[key] = _parse_list(lines[i][len(prefix):])
|
|
i+=2
|
|
if lines[i] != "### 결론" or lines[i+1] != "": _fail("invalid conclusion")
|
|
conclusion=lines[i+2]
|
|
except (IndexError, ValueError): _fail("invalid review body")
|
|
r={"schema":SCHEMA,"target":{"owner":m.group(1),"repository":m.group(2),"issue":issue,"pull":pull,"head":head},"contract_sha256":contract,"verdict":verdict,"criteria":criteria,"scope":scope,"conclusion":conclusion}
|
|
validate_result(r, allowed_paths, sensitive_values)
|
|
canonical=render_review(r, allowed_paths, sensitive_values, declaration={"mode": "none"})
|
|
if canonical != body or _sha(canonical[:canonical.rfind("<!--") - 1].encode()) != m.group(7): _fail("review digest mismatch")
|
|
if _marker(r,m.group(7)) != marker: _fail("review marker mismatch")
|
|
return r
|
|
|
|
def _lstat(path, kind=None):
|
|
try: st=os.lstat(path)
|
|
except OSError: _fail("cache path unavailable")
|
|
if stat.S_ISLNK(st.st_mode): _fail("cache symlink")
|
|
if kind and not kind(st.st_mode): _fail("cache path type")
|
|
return st
|
|
|
|
def _safe_name(x):
|
|
if type(x) is not str or not x or x in (".", "..") or "/" in x or "\\" in x or CONTROL.search(x):
|
|
_fail("invalid cache identifier")
|
|
|
|
|
|
def _role_group_ok(st, uid):
|
|
if not (st.st_mode & stat.S_IWGRP):
|
|
return True
|
|
try:
|
|
group = grp.getgrgid(st.st_gid)
|
|
role = pwd.getpwuid(uid)
|
|
accounts = pwd.getpwall()
|
|
if group.gr_gid != st.st_gid or role.pw_uid != uid or not accounts:
|
|
return False
|
|
members = set(group.gr_mem)
|
|
members.update(account.pw_name for account in accounts if account.pw_gid == st.st_gid)
|
|
if role.pw_gid == st.st_gid:
|
|
members.add(role.pw_name)
|
|
return members == {role.pw_name}
|
|
except (KeyError, OSError):
|
|
return False
|
|
|
|
|
|
def _optional_stat(path, kind):
|
|
try:
|
|
os.lstat(path)
|
|
except FileNotFoundError:
|
|
return None
|
|
except OSError:
|
|
_fail("cache path unavailable")
|
|
return _lstat(path, kind)
|
|
|
|
|
|
def validate_cache_paths(contract_path, owner, repository, issue, uid=None):
|
|
"""Inspect existing paths only; acquire locks and verify Git separately."""
|
|
_safe_name(owner)
|
|
_safe_name(repository)
|
|
if type(issue) is not int or issue < 1:
|
|
_fail("invalid cache issue")
|
|
uid = os.getuid() if uid is None else uid
|
|
if type(uid) is not int or uid < 0:
|
|
_fail("invalid role UID")
|
|
try:
|
|
raw = os.fspath(contract_path)
|
|
except TypeError:
|
|
_fail("invalid cache contract path")
|
|
if type(raw) is not str or not raw.startswith("/") or any(x in (".", "..", "") for x in raw.split("/")[1:]):
|
|
_fail("invalid cache contract path")
|
|
cp = Path(raw)
|
|
if tuple(cp.parts[-3:]) != ("harness", "operations", "repository-cache.md"):
|
|
_fail("invalid cache contract path")
|
|
root = cp.parent.parent.parent
|
|
for ancestor in reversed(cp.parents):
|
|
_lstat(ancestor, stat.S_ISDIR)
|
|
_lstat(cp, stat.S_ISREG)
|
|
# NFS permits a different root owner, but never a world-writable root.
|
|
if os.lstat(root).st_mode & stat.S_IWOTH:
|
|
_fail("invalid harness root")
|
|
rs = root / "repositories"
|
|
rst = _lstat(rs, stat.S_ISDIR)
|
|
if rst.st_uid != uid or rst.st_mode & stat.S_IWOTH or not _role_group_ok(rst, uid):
|
|
_fail("invalid repositories directory")
|
|
base = rs / owner / repository
|
|
for path in (rs / owner, base, base / "locks"):
|
|
st = _lstat(path, stat.S_ISDIR)
|
|
if st.st_uid != uid or stat.S_IMODE(st.st_mode) != 0o700:
|
|
_fail("invalid cache directory")
|
|
git = base / "repository.git"
|
|
gst = _lstat(git, stat.S_ISDIR)
|
|
if gst.st_uid != uid or gst.st_mode & stat.S_IWOTH:
|
|
_fail("invalid repository cache")
|
|
# Bootstrap paths are optional for an already-existing cache.
|
|
bootstrap_root = rs / ".locks"
|
|
if _optional_stat(bootstrap_root, stat.S_ISDIR) is not None:
|
|
for path in (bootstrap_root, bootstrap_root / owner):
|
|
st = _optional_stat(path, stat.S_ISDIR)
|
|
if st is not None and (st.st_uid != uid or stat.S_IMODE(st.st_mode) != 0o700):
|
|
_fail("invalid bootstrap directory")
|
|
bootstrap = bootstrap_root / owner / (repository + ".bootstrap.lock")
|
|
st = _optional_stat(bootstrap, stat.S_ISREG)
|
|
if st is not None and (st.st_uid != uid or stat.S_IMODE(st.st_mode) != 0o600):
|
|
_fail("invalid bootstrap lock")
|
|
out = {"root": str(root), "repositories": str(rs), "owner": str(rs / owner),
|
|
"repository": str(base), "repository_git": str(git), "locks": str(base / "locks")}
|
|
for name in (f"issue-{issue}.lock", "repository.lock"):
|
|
path = base / "locks" / name
|
|
st = _optional_stat(path, stat.S_ISREG)
|
|
if st is not None:
|
|
if st.st_uid != uid or stat.S_IMODE(st.st_mode) != 0o600:
|
|
_fail("invalid lock file")
|
|
out[name] = str(path)
|
|
return out
|
|
|
|
def collect_pages(fetch_page, key, page_size=50, max_pages=10000):
|
|
"""Consume provider-verified envelopes with one-based page numbers."""
|
|
if key not in ("id", "filename", "sha") or type(key) is not str:
|
|
_fail("invalid identity key")
|
|
if type(page_size) is not int or page_size < 1 or type(max_pages) is not int or max_pages < 1:
|
|
_fail("invalid pagination limits")
|
|
all_items, seen, total = [], set(), None
|
|
for page in range(1, max_pages + 1):
|
|
try:
|
|
env = fetch_page(page)
|
|
except Exception:
|
|
_fail("page read failed")
|
|
if type(env) is not dict or "items" not in env or set(env) - {"items", "total_count", "has_more"}:
|
|
_fail("invalid page envelope")
|
|
declared, more = env.get("total_count"), env.get("has_more")
|
|
if declared is not None and (type(declared) is not int or declared < 0):
|
|
_fail("invalid page count")
|
|
if more is not None and type(more) is not bool:
|
|
_fail("invalid page metadata")
|
|
if declared is not None:
|
|
if total is not None and total != declared:
|
|
_fail("conflicting page count")
|
|
total = declared
|
|
items = env["items"]
|
|
if items is None and declared == 0 and more is False:
|
|
items = []
|
|
if type(items) is not list or len(items) > page_size:
|
|
_fail("invalid page items")
|
|
for item in items:
|
|
if type(item) is not dict or key not in item:
|
|
_fail("invalid page identity")
|
|
ident = item[key]
|
|
if key == "id":
|
|
_positive_id(ident)
|
|
elif key == "filename":
|
|
_path(ident)
|
|
elif type(ident) is not str or not HEX40.fullmatch(ident):
|
|
_fail("invalid page identity")
|
|
if ident in seen:
|
|
_fail("duplicate page identity")
|
|
seen.add(ident)
|
|
all_items.append(item)
|
|
if total is not None and len(all_items) > total:
|
|
_fail("page count exceeded")
|
|
if more is True:
|
|
if len(items) != page_size or (total is not None and len(all_items) >= total):
|
|
_fail("truncated or inconsistent continuation")
|
|
continue
|
|
done = more is False or (more is None and (
|
|
(total is not None and len(all_items) == total) or len(items) < page_size))
|
|
if done:
|
|
if total is not None and len(all_items) != total:
|
|
_fail("truncated pages")
|
|
return all_items
|
|
_fail("pagination limit reached")
|
|
|
|
def _json_bytes(value):
|
|
def check(item):
|
|
if type(item) is dict:
|
|
if any(type(key) is not str for key in item):
|
|
_fail("invalid JSON object")
|
|
for child in item.values():
|
|
check(child)
|
|
elif type(item) is list:
|
|
for child in item:
|
|
check(child)
|
|
elif item is not None and type(item) not in (str, int, float, bool):
|
|
_fail("invalid JSON value")
|
|
check(value)
|
|
try:
|
|
return json.dumps(value, sort_keys=True, ensure_ascii=False, allow_nan=False,
|
|
separators=(",", ":")).encode("utf-8")
|
|
except (TypeError, ValueError, UnicodeError):
|
|
_fail("invalid JSON value")
|
|
|
|
|
|
def _same(a, b):
|
|
return _json_bytes(a) == _json_bytes(b)
|
|
|
|
|
|
def verify_unchanged(before, after):
|
|
if not _same(before, after):
|
|
_fail("snapshot changed")
|
|
return True
|
|
|
|
|
|
def _positive_id(value):
|
|
if type(value) is not int or value < 1:
|
|
_fail("invalid identity")
|
|
return value
|
|
|
|
|
|
def _user_id(record):
|
|
if type(record) is not dict or type(record.get("user")) is not dict:
|
|
_fail("invalid author identity")
|
|
return _positive_id(record["user"].get("id"))
|
|
|
|
|
|
def _index(collection, key="id"):
|
|
if type(collection) is not list:
|
|
_fail("invalid collection")
|
|
out = {}
|
|
for item in collection:
|
|
if type(item) is not dict or key not in item:
|
|
_fail("invalid collection identity")
|
|
ident = item[key]
|
|
if key == "id":
|
|
_positive_id(ident)
|
|
elif key == "filename":
|
|
_path(ident)
|
|
_str(item.get("status"), "file status", 1, 100)
|
|
if ident in out:
|
|
_fail("duplicate collection identity")
|
|
out[ident] = item
|
|
return out
|
|
|
|
|
|
def _timestamp(value):
|
|
from datetime import datetime
|
|
if type(value) is not str or not re.fullmatch(
|
|
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})", value):
|
|
_fail("invalid review timestamp")
|
|
try:
|
|
datetime.fromisoformat(value)
|
|
except ValueError:
|
|
_fail("invalid review timestamp")
|
|
return value
|
|
|
|
|
|
def _without(value, fields):
|
|
return {key: child for key, child in value.items() if key not in fields}
|
|
|
|
|
|
def _target_snapshot(snapshot, target):
|
|
fields = ("issue", "issue_comments", "pr", "pr_comments", "reviews", "timeline", "files")
|
|
_obj(snapshot, fields, "publication snapshot")
|
|
issue, pr = snapshot["issue"], snapshot["pr"]
|
|
if type(issue) is not dict or type(pr) is not dict:
|
|
_fail("invalid source snapshot")
|
|
if _positive_id(issue.get("number")) != target["issue"] or _positive_id(pr.get("number")) != target["pull"]:
|
|
_fail("snapshot target mismatch")
|
|
fullname = target["owner"] + "/" + target["repository"]
|
|
if type(issue.get("repository")) is not dict or issue["repository"].get("full_name") != fullname:
|
|
_fail("snapshot repository mismatch")
|
|
if type(pr.get("base")) is not dict or type(pr["base"].get("repo")) is not dict or pr["base"]["repo"].get("full_name") != fullname:
|
|
_fail("snapshot repository mismatch")
|
|
if type(pr.get("head")) is not dict or pr["head"].get("sha") != target["head"]:
|
|
_fail("snapshot head mismatch")
|
|
if pr.get("state") != "open" or pr.get("merged") is not False:
|
|
_fail("snapshot PR state mismatch")
|
|
from urllib.parse import urlsplit
|
|
url = pr.get("html_url")
|
|
if type(url) is not str:
|
|
_fail("invalid PR URL")
|
|
try:
|
|
parts = urlsplit(url)
|
|
except ValueError:
|
|
_fail("invalid PR URL")
|
|
if parts.scheme not in ("https", "http") or not parts.hostname or parts.username or parts.password or parts.query or parts.fragment or not parts.path.endswith("/" + fullname + "/pulls/" + str(target["pull"])):
|
|
_fail("snapshot PR URL mismatch")
|
|
for key in ("issue_comments", "pr_comments", "reviews", "timeline"):
|
|
_index(snapshot[key])
|
|
_index(snapshot["files"], "filename")
|
|
return url
|
|
|
|
|
|
def _projection_contract(contract):
|
|
if contract is None:
|
|
return False, False, None, False
|
|
if type(contract) is not dict or type(contract.get("version")) is not dict or contract["version"].get("version") != "1.27.3":
|
|
_fail("unsupported provider projection contract")
|
|
flags = []
|
|
for key in ("supersession_same_author_only", "already_dismissed_review_timestamp_projection",
|
|
"requested_reviewers_presentation_order"):
|
|
value = contract.get(key, False)
|
|
if type(value) is not bool:
|
|
_fail("invalid provider projection contract")
|
|
flags.append(value)
|
|
official = contract.get("expected_official")
|
|
if official is not None and type(official) is not bool:
|
|
_fail("invalid provider review policy")
|
|
return flags[0], flags[1], official, flags[2]
|
|
|
|
|
|
def verify_publication(before, after, exact_review, result, allowed_paths, reviewer_id, inline_comments, provider_contract=None, *, expected_pr_url):
|
|
"""Verify saved readbacks of one native create; never authenticate or publish."""
|
|
validate_result(result, allowed_paths)
|
|
_positive_id(reviewer_id)
|
|
if type(inline_comments) is not list or inline_comments:
|
|
_fail("inline comments are not supported")
|
|
target = result["target"]
|
|
_str(expected_pr_url, "canonical PR URL", 1, 4000)
|
|
url = _target_snapshot(before, target)
|
|
if url != expected_pr_url or _target_snapshot(after, target) != expected_pr_url:
|
|
_fail("snapshot PR URL mismatch")
|
|
if _user_id(before["pr"]) == reviewer_id:
|
|
_fail("reviewer is PR author")
|
|
supersede, retouch, expected_official, reviewer_presentation_order = _projection_contract(provider_contract)
|
|
old_reviews, reviews = _index(before["reviews"]), _index(after["reviews"])
|
|
if type(exact_review) is not dict:
|
|
_fail("invalid exact review")
|
|
rid = _positive_id(exact_review.get("id"))
|
|
if set(reviews) - set(old_reviews) != {rid} or set(old_reviews) - set(reviews):
|
|
_fail("review cardinality mismatch")
|
|
if [x for x in reviews if x != rid] != list(old_reviews):
|
|
_fail("previous review order changed")
|
|
er = reviews[rid]
|
|
if not _same(er, exact_review):
|
|
_fail("exact review readback mismatch")
|
|
expected = {"approve": "APPROVED", "request_changes": "REQUEST_CHANGES", "blocked_spec": "COMMENT"}[result["verdict"]]
|
|
if er.get("body") != render_review(result, allowed_paths, declaration={"mode": "none"}) or er.get("commit_id") != target["head"] or er.get("state") != expected:
|
|
_fail("review binding mismatch")
|
|
if _user_id(er) != reviewer_id or er.get("pull_request_url") != url:
|
|
_fail("review identity mismatch")
|
|
if er.get("stale") is not False or er.get("dismissed") is not False or type(er.get("official")) is not bool:
|
|
_fail("review status mismatch")
|
|
if type(er.get("comments_count")) is not int or er["comments_count"] != 0:
|
|
_fail("review comment count mismatch")
|
|
if expected_official is not None and er["official"] != expected_official:
|
|
_fail("review policy mismatch")
|
|
submitted = _timestamp(er.get("submitted_at"))
|
|
_timestamp(er.get("updated_at"))
|
|
old_events, events = _index(before["timeline"]), _index(after["timeline"])
|
|
additions = set(events) - set(old_events)
|
|
if len(additions) != 1 or set(old_events) - set(events) or not _same(after["timeline"][:len(before["timeline"])], before["timeline"]):
|
|
_fail("timeline cardinality or preservation mismatch")
|
|
event = after["timeline"][-1]
|
|
if event["id"] not in additions or _positive_id(event.get("review_id")) != rid or event.get("type") != "review":
|
|
_fail("timeline review binding mismatch")
|
|
if _user_id(event) != reviewer_id or event.get("body") != er["body"] or event.get("pull_request_url") != url:
|
|
_fail("timeline author or body mismatch")
|
|
created = _timestamp(event.get("created_at"))
|
|
event_updated = _timestamp(event.get("updated_at"))
|
|
# Bind the event by exact record ID/author/body, not coincident wall clocks.
|
|
causal_times = {submitted, created, event_updated}
|
|
for section in ("issue", "pr"):
|
|
b, a = before[section], after[section]
|
|
permitted = {"updated_at"} if section == "issue" else {"updated_at", "review_comments", "requested_reviewers"}
|
|
if not _same(_without(b, permitted), _without(a, permitted)) or set(b) != set(a):
|
|
_fail("source or PR drift")
|
|
if not _same(b.get("updated_at"), a.get("updated_at")) and a.get("updated_at") not in causal_times:
|
|
_fail("timestamp projection mismatch")
|
|
bpr, apr = before["pr"], after["pr"]
|
|
old_count, count = bpr.get("review_comments"), apr.get("review_comments")
|
|
if type(old_count) is not int or old_count < 0 or type(count) is not int or count != old_count + 1:
|
|
_fail("review count projection mismatch")
|
|
old_requested = _index(bpr.get("requested_reviewers"))
|
|
requested = _index(apr.get("requested_reviewers"))
|
|
if reviewer_presentation_order:
|
|
# Only this explicitly version-bound collection has presentation order.
|
|
if set(old_requested) - set(requested) or (set(requested) - set(old_requested)) - {reviewer_id}:
|
|
_fail("requested reviewers projection mismatch")
|
|
for old_id, old in old_requested.items():
|
|
if not _same(old, requested[old_id]):
|
|
_fail("previous requested reviewers changed")
|
|
if reviewer_id in set(requested) - set(old_requested) and not _same(requested[reviewer_id], exact_review.get("user")):
|
|
_fail("new requested reviewer user mismatch")
|
|
elif not _same(bpr["requested_reviewers"], apr["requested_reviewers"]):
|
|
if reviewer_id in old_requested or set(requested) - set(old_requested) != {reviewer_id} or set(old_requested) - set(requested):
|
|
_fail("requested reviewers projection mismatch")
|
|
if not _same([x for x in apr["requested_reviewers"] if x["id"] != reviewer_id], bpr["requested_reviewers"]):
|
|
_fail("previous requested reviewers changed")
|
|
for collection in ("issue_comments", "pr_comments"):
|
|
if not _same(before[collection], after[collection]):
|
|
_fail("comment collection changed")
|
|
if not _same(_index(before["files"], "filename"), _index(after["files"], "filename")):
|
|
_fail("changed file set drift")
|
|
projected = []
|
|
for old_id, old in old_reviews.items():
|
|
current = reviews[old_id]
|
|
if _same(old, current):
|
|
continue
|
|
if _user_id(old) != reviewer_id or old.get("pull_request_url") != url or old.get("state") not in ("APPROVED", "REQUEST_CHANGES"):
|
|
_fail("previous review drift")
|
|
fields = {"official", "dismissed", "updated_at"}
|
|
if set(old) != set(current) or not _same(_without(old, fields), _without(current, fields)) or current.get("updated_at") not in causal_times:
|
|
_fail("previous review drift")
|
|
superseded = supersede and old.get("official") is True and old.get("dismissed") is False
|
|
retouched = retouch and old.get("official") is False and old.get("dismissed") is True
|
|
if not (superseded or retouched) or current.get("official") is not False or current.get("dismissed") is not True:
|
|
_fail("previous review projection not authorized")
|
|
projected.append(old_id)
|
|
return {"review_id": rid, "state": expected, "review_records_added": 1,
|
|
"timeline_events_added": 1, "projected_review_ids": projected, "read_only": True}
|
|
|
|
def _classification_bytes(path):
|
|
"""Bounded regular-file read; caller independently admits source provenance."""
|
|
fd = None
|
|
try:
|
|
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
|
_fail("classification input unavailable")
|
|
with os.fdopen(fd, "rb") as stream:
|
|
fd = None
|
|
raw = stream.read(1048577)
|
|
if len(raw) > 1048576:
|
|
_fail("classification input too large")
|
|
raw.decode("utf-8")
|
|
return raw
|
|
except (OSError, UnicodeError, ValueError):
|
|
_fail("classification input unavailable")
|
|
finally:
|
|
if fd is not None:
|
|
os.close(fd)
|
|
|
|
|
|
def _classification_lines(text):
|
|
if type(text) is not str or len(text.encode("utf-8")) > 1048576:
|
|
_fail("invalid classification text")
|
|
text = text.replace("\r\n", "\n")
|
|
if any(c in text for c in ("\r", "\x00", "\u2028", "\u2029")):
|
|
_fail("unsupported classification text")
|
|
visible, fence = [], None
|
|
for line in text.split("\n"):
|
|
match = re.fullmatch(r" {0,3}(`{3,}|~{3,})(.*)", line)
|
|
if fence is not None:
|
|
if match and match[1][0] == fence[0] and len(match[1]) >= fence[1] and not match[2].strip():
|
|
fence = None
|
|
continue
|
|
if match:
|
|
if match[1][0] == "`" and "`" in match[2]:
|
|
_fail("ambiguous classification fence")
|
|
fence = (match[1][0], len(match[1]))
|
|
continue
|
|
if '<!--' in line or '-->' in line or re.search(r'</?[A-Za-z][^>]*>', line):
|
|
_fail("unsupported classification HTML")
|
|
visible.append(line)
|
|
if fence is not None:
|
|
_fail("unterminated classification fence")
|
|
return visible
|
|
|
|
|
|
def check_ac_classification(body, expected_template_sha256):
|
|
"""Check only canonical section-7 AC classification, not full compliance."""
|
|
if type(expected_template_sha256) is not str or not HEX64.fullmatch(expected_template_sha256):
|
|
_fail("invalid trusted template digest")
|
|
template = _classification_bytes(Path(__file__).resolve().parents[5] / "docs/requirements/TEMPLATE.md")
|
|
if hashlib.sha256(template).hexdigest() != expected_template_sha256:
|
|
_fail("trusted template drift")
|
|
template_text = template.decode("utf-8")
|
|
try:
|
|
template_block = template_text.split("## 7. 인수 기준\n", 1)[1].split("## 8.", 1)[0]
|
|
classification = template_block.split("**검증 분류**\n", 1)[1].split("**상태 불변조건**", 1)[0]
|
|
except IndexError:
|
|
_fail("unsupported trusted template")
|
|
fields = re.findall(r"^- ([^:]+): \$\{[a-z_]+\}$", classification, flags=re.M)
|
|
if fields != ["수준", "백엔드", "케이스"]:
|
|
_fail("unsupported trusted template")
|
|
lines = _classification_lines(body)
|
|
section = "## 7. 인수 기준"
|
|
if lines.count(section) != 1:
|
|
_fail("ambiguous AC section")
|
|
start = lines.index(section) + 1
|
|
end = next((i for i in range(start, len(lines)) if lines[i].startswith("## ")), len(lines))
|
|
if end == len(lines) or lines[end] != "## 8. 오류 및 충돌 계약":
|
|
_fail("unsupported AC section boundary")
|
|
blocks, current = [], None
|
|
for line in lines[start:end]:
|
|
if line.startswith("### "):
|
|
match = re.fullmatch(r"### (REQ-[0-9]{3}\.[0-9]{2}-AC[0-9]{2}) — (\S.*)", line)
|
|
if not match:
|
|
_fail("unsupported AC heading")
|
|
current = (match[1], [])
|
|
blocks.append(current)
|
|
elif current is None:
|
|
if line.strip():
|
|
_fail("unsupported AC preamble")
|
|
else:
|
|
if line.lstrip().startswith("#"):
|
|
_fail("ambiguous AC heading")
|
|
current[1].append(line)
|
|
ids = [item[0] for item in blocks]
|
|
if not ids or len(ids) != len(set(ids)):
|
|
_fail("missing or duplicate AC")
|
|
for _, block in blocks:
|
|
marker = "**검증 분류**"
|
|
if block.count(marker) != 1:
|
|
_fail("missing or duplicate AC classification")
|
|
pos = block.index(marker) + 1
|
|
stop = next((i for i in range(pos, len(block)) if block[i].startswith("**")), len(block))
|
|
values = {}
|
|
for line in block[pos:stop]:
|
|
if not line.strip():
|
|
continue
|
|
match = re.fullmatch(r"- ([^:]+): (.*)", line)
|
|
if not match or match[1] not in fields or match[1] in values:
|
|
_fail("ambiguous AC classification field")
|
|
value = match[2].strip()
|
|
if not value or "${" in value:
|
|
_fail("empty or placeholder AC classification")
|
|
values[match[1]] = value
|
|
if set(values) != set(fields):
|
|
_fail("missing AC classification field")
|
|
return {"ac_ids": ids, "checked_fields": fields, "read_only": True,
|
|
"template_sha256": expected_template_sha256,
|
|
"document_sha256": hashlib.sha256(body.encode("utf-8")).hexdigest()}
|
|
|
|
|
|
def _read(path):
|
|
try:
|
|
with open(path, encoding="utf-8", newline="") as stream:
|
|
return stream.read()
|
|
except (OSError, ValueError, UnicodeError):
|
|
_fail("input unavailable")
|
|
def _json_file(path): return loads_json(_read(path))
|
|
def _emit_error(exc):
|
|
sys.stderr.write(json.dumps({"error":"invalid input"}, ensure_ascii=False)+"\n"); return 2
|
|
|
|
class _Parser(argparse.ArgumentParser):
|
|
def error(self, message):
|
|
_fail("invalid command arguments")
|
|
|
|
|
|
def main(argv=None):
|
|
p=_Parser(prog="review_support", add_help=True); sub=p.add_subparsers(dest="cmd",required=True)
|
|
def common(name):
|
|
q=sub.add_parser(name); q.add_argument("--result",required=True); q.add_argument("--allowed-paths",required=True); return q
|
|
common("validate-result"); common("render")
|
|
q=sub.add_parser("parse"); q.add_argument("--body",required=True); q.add_argument("--allowed-paths",required=True)
|
|
render_parser = sub.choices["render"]
|
|
adoption = render_parser.add_mutually_exclusive_group(required=True)
|
|
adoption.add_argument("--no-delegated-evidence", action="store_true")
|
|
adoption.add_argument("--delegation-adoption")
|
|
q=sub.add_parser("check-cache"); q.add_argument("--contract-path",required=True); q.add_argument("--owner",required=True); q.add_argument("--repository",required=True); q.add_argument("--issue",required=True,type=int)
|
|
q=sub.add_parser("check-pages"); q.add_argument("--pages",required=True); q.add_argument("--key",required=True)
|
|
q.add_argument("--page-size", type=int, default=50); q.add_argument("--max-pages", type=int, default=10000)
|
|
q=sub.add_parser("check-ac-classification")
|
|
q.add_argument("--document", required=True)
|
|
q.add_argument("--expected-document-sha256", required=True)
|
|
q.add_argument("--expected-template-sha256", required=True)
|
|
q=sub.add_parser("check-unchanged"); q.add_argument("--before",required=True); q.add_argument("--after",required=True)
|
|
q=sub.add_parser("verify-publication");
|
|
for n in ("before","after","exact-review","result","allowed-paths","inline-comments"): q.add_argument("--"+n,required=True)
|
|
q.add_argument("--reviewer-id",required=True,type=int); q.add_argument("--provider-contract")
|
|
q.add_argument("--expected-pr-url", required=True)
|
|
try:
|
|
a=p.parse_args(argv)
|
|
if a.cmd in ("validate-result","render"):
|
|
r=_json_file(a.result); paths=_json_file(a.allowed_paths); validate_result(r,paths)
|
|
if a.cmd == "validate-result": out=r
|
|
else:
|
|
if a.no_delegated_evidence:
|
|
declaration={"mode":"none"}
|
|
else:
|
|
repo_root=Path(__file__).resolve().parents[6]
|
|
if str(repo_root) not in sys.path:
|
|
sys.path.insert(0,str(repo_root))
|
|
from harness.lib.delegation_evidence import _json_file as load_declaration
|
|
declaration=load_declaration(a.delegation_adoption)
|
|
out=render_review(r,paths,declaration=declaration)
|
|
print(json.dumps(out,ensure_ascii=False,sort_keys=True) if a.cmd=="validate-result" else out,end="" if a.cmd=="render" else "\n")
|
|
elif a.cmd=="parse": print(json.dumps(parse_review(_read(a.body),_json_file(a.allowed_paths)),ensure_ascii=False,sort_keys=True))
|
|
elif a.cmd=="check-cache": print(json.dumps(validate_cache_paths(a.contract_path,a.owner,a.repository,a.issue),sort_keys=True))
|
|
elif a.cmd=="check-pages":
|
|
pages=_json_file(a.pages)
|
|
if type(pages) is not list: _fail("invalid pages")
|
|
used = []
|
|
def f(i):
|
|
used.append(i)
|
|
return pages[i - 1]
|
|
items = collect_pages(f, a.key, a.page_size, a.max_pages)
|
|
if len(used) != len(pages): _fail("extra saved pages")
|
|
print(json.dumps(items,ensure_ascii=False,sort_keys=True))
|
|
elif a.cmd=="check-ac-classification":
|
|
raw = _classification_bytes(a.document)
|
|
if type(a.expected_document_sha256) is not str or not HEX64.fullmatch(a.expected_document_sha256) or hashlib.sha256(raw).hexdigest() != a.expected_document_sha256:
|
|
_fail("classification document drift")
|
|
print(json.dumps(check_ac_classification(raw.decode("utf-8"), a.expected_template_sha256), ensure_ascii=False, sort_keys=True))
|
|
elif a.cmd=="check-unchanged": print(json.dumps({"unchanged":verify_unchanged(_json_file(a.before),_json_file(a.after))}))
|
|
else:
|
|
print(json.dumps(verify_publication(_json_file(a.before),_json_file(a.after),_json_file(a.exact_review),_json_file(a.result),_json_file(a.allowed_paths),a.reviewer_id,_json_file(a.inline_comments),_json_file(a.provider_contract) if a.provider_contract else None, expected_pr_url=a.expected_pr_url),sort_keys=True))
|
|
return 0
|
|
except (ValidationError, OSError, ValueError, TypeError): return _emit_error(None)
|
|
|
|
if __name__ == "__main__": sys.exit(main())
|