[verified] Check trusted-template AC verification classification
This commit is contained in:
+122
@@ -642,6 +642,119 @@ def verify_publication(before, after, exact_review, result, allowed_paths, revie
|
||||
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:
|
||||
@@ -670,6 +783,10 @@ def main(argv=None):
|
||||
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)
|
||||
@@ -703,6 +820,11 @@ def main(argv=None):
|
||||
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))
|
||||
|
||||
Reference in New Issue
Block a user