diff --git a/scripts/studio-save.py b/scripts/studio-save.py index 91aea80..6db0f4f 100644 --- a/scripts/studio-save.py +++ b/scripts/studio-save.py @@ -68,10 +68,84 @@ FIELD_MAP = { BODY_START, BODY_END = "", "" +# SVG 에서 **문자열로 판정되는 것**. 사람 판단이 필요 없으므로 error 로 막는다. +# `plan/01` §8 이 「SVG 의 스크립트·외부 리소스는 저장 단계의 허용 정책으로 제한한다」고 +# 적었는데 그 정책이 코드로 어디에도 없었다. 화살표 방향은 사람이 봐야 하지만 +# 스크립트가 들어 있는지는 안 그렇다 — 그림 쪽에서 기계가 할 수 있는 유일한 일이다 +SVG_FORBIDDEN = ( + (re.compile(r"<\s*script\b", re.I), "스크립트가 들어 있다"), + (re.compile(r"<\s*foreignObject\b", re.I), "foreignObject 는 임의의 HTML 을 싣는다"), + (re.compile(r"\son[a-z]+\s*=", re.I), "이벤트 처리기 속성이 있다"), + # 따옴표는 \x22 \x27 로 적는다. 정규식 안에 두 종류를 함께 쓰면 파이썬 문자열이 먼저 닫힌다 + (re.compile(r"(?:xlink:)?href\s*=\s*[\x22\x27](?:https?:)?//", re.I), + "바깥 주소를 가리킨다"), + (re.compile(r"<\s*(?:image|use)\b[^>]*(?:xlink:)?href\s*=\s*[\x22\x27](?!#|data:)", re.I), + "바깥 리소스를 불러온다"), + (re.compile(r"@import\b", re.I), "바깥 스타일을 불러온다"), +) + +# 그림이 **실제 흐름**을 그린 것인지 **개념 설명**인지. `plan/01` §8 이 표시하라고 적었다. +# 없으면 error 가 아니라 warning 이다 — 지금 저장소의 그림 272장에 이 표시가 없고, +# 없다고 전부 막으면 정상을 막는 쪽으로 넘어간다 +FIGURE_KIND = re.compile( + r"data-figure-kind\s*=\s*[\x22\x27](evidence|illustrative)[\x22\x27]", re.I) + + class Refused(Exception): """어댑터가 스스로 거절한 것. 서버 오류가 아니다.""" +def inspect_svg(path: str) -> tuple[list[str], str | None]: + """(막아야 할 것, 그림 종류). 종류를 못 읽으면 None.""" + try: + text = open(path, encoding="utf-8", errors="replace").read() + except OSError as e: + return [f"열지 못했다: {e}"], None + hits = [why for pat, why in SVG_FORBIDDEN if pat.search(text)] + m = FIGURE_KIND.search(text) + return hits, (m.group(1).lower() if m else None) + + +def inspect_assets(package: dict) -> tuple[list[dict], list[dict]]: + """그림을 본다. 막는 것과 읽어야 할 것을 나눠 낸다. + + **바뀐 것이 무엇인지는 기계가 못 말한다. 바뀌었으니 보라는 말할 수 있다.** + 화살표를 뒤집어도 `gates` 는 전부 0 이고 `warnings` 도 비어 자동 통과했다. 문장 쪽에는 + 다리를 놨는데 그림 쪽에는 안 놨던 자리다. + """ + errors, warnings = [], [] + for asset in package.get("assets") or []: + rel = asset.get("path", "") + full = os.path.join(ROOT, rel) + if not asset.get("exists") or not rel.endswith(".svg"): + continue + hits, kind = inspect_svg(full) + for why in hits: + errors.append({"id": "그림 허용 정책", "asset": rel, "detail": why}) + now = _sha256_file(full) + if asset.get("sha256") and now != asset["sha256"]: + warnings.append({ + "id": "그림이 검토 뒤에 바뀌었다", "asset": rel, + "detail": f"검토 시점 {asset['sha256'][:12]} → 지금 {now[:12]}", + "note": "무엇이 바뀌었는지는 이 어댑터가 말하지 못한다. " + "화살표 방향·주체는 사람이 그림을 열어 봐야 안다", + }) + if kind is None: + warnings.append({ + "id": "그림 종류가 표시돼 있지 않다", "asset": rel, + "detail": 'data-figure-kind="evidence" 또는 "illustrative" 가 없다', + "note": "실제 흐름을 그린 것인지 개념 설명인지 못 가린다. " + "설명용을 증거로 읽는 것을 막을 수 없다", + }) + elif kind == "illustrative": + warnings.append({ + "id": "설명용 그림", "asset": rel, + "detail": "data-figure-kind=\"illustrative\" — 실제 흐름이 아니라 개념 설명이다", + "note": "막지 않는다. 밝힌 대로 읽히는지만 검토가 본다", + }) + return errors, warnings + + def _sha256_file(path: str) -> str: with open(path, "rb") as fh: return hashlib.sha256(fh.read()).hexdigest() @@ -334,6 +408,10 @@ def main() -> int: if not os.path.isfile(p): raise Refused(f"그런 파일이 없다: {p}") pkg = approved(args.record, args.package) + asset_errors, asset_warnings = inspect_assets(pkg) + if asset_errors: + raise Refused("그림 허용 정책에 걸린다 — 저장 단계에서 막는다\n " + + "\n ".join(f"{e['asset']}: {e['detail']}" for e in asset_errors)) doc = build_input(args.record) steps = plan_requests(args.record, doc, args.document_id, args.expected_version) @@ -354,10 +432,14 @@ def main() -> int: "approvedAgainst": pkg["target"]["sha256"], "checkerVersions": pkg.get("checkerVersions"), "requests": steps, + "warnings": asset_warnings, "notes": [ "이 파일의 요청을 아직 보내지 않았다. mode 가 plan 이다.", "게시·게시취소 경로는 이 어댑터가 만들지 않는다. 계획에 들어 있으면 거절한다.", "자격증명은 환경 변수 자리표시자로만 적혀 있다. 값은 이 저장소에 없다.", + "**warnings 가 비어 있어야 자동 통과다.** 관문이 전부 통과해도 그것만으로는 " + "아니다. 그림이 검토 뒤에 바뀌었다는 경고는 무엇이 바뀌었는지 말하지 못한다 — " + "화살표 방향과 주체는 사람이 그림을 열어 봐야 안다.", ], } text = json.dumps(plan, ensure_ascii=False, indent=2) + "\n" @@ -365,7 +447,11 @@ def main() -> int: os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True) open(args.out, "w", encoding="utf-8").write(text) ops = " · ".join(f"{s['op']} {s['method']}" for s in steps) - print(f"{args.out} — {ops} · 무인 저장 {'켬' if UNATTENDED_SAVE_ENABLED else '끔'}") + print(f"{args.out} — {ops} · 그림 {len(pkg.get('assets') or [])}장 " + f"· 경고 {len(asset_warnings)}건 " + f"· 무인 저장 {'켬' if UNATTENDED_SAVE_ENABLED else '끔'}") + if asset_warnings: + print("경고는 통과가 아니다. 근거를 읽는 검토가 항목마다 판정한다", file=sys.stderr) else: print(text, end="") return 0 diff --git a/scripts/tests/test_studio_save.py b/scripts/tests/test_studio_save.py index 8081364..7a39d13 100644 --- a/scripts/tests/test_studio_save.py +++ b/scripts/tests/test_studio_save.py @@ -74,6 +74,118 @@ class ComparatorControlTest(unittest.TestCase): self.assertIn("publishedAt", r["unexpected"]) +SVG_HEAD = 'Guest')) + self.assertEqual([], hits) + self.assertIsNone(kind) + + def test_a_figure_declared_illustrative_passes(self): + """설명용이라 밝힌 그림을 막지 않는다. + + 막으면 V-004 에서 채택된 편집 9건을 막은 것과 같은 실패다. 밝힌 대로 읽히는지는 + 검토가 본다. + """ + hits, kind = ss.inspect_svg( + _svg(self.tmp, SVG_HEAD + ' data-figure-kind="illustrative">')) + self.assertEqual([], hits) + self.assertEqual("illustrative", kind) + + def test_a_figure_declared_evidence_is_read_as_evidence(self): + _, kind = ss.inspect_svg( + _svg(self.tmp, SVG_HEAD + ' data-figure-kind="evidence">')) + self.assertEqual("evidence", kind) + + def test_dangerous_shapes_are_blocked(self): + cases = { + "script": ">", + "foreignObject": ">x", + "event handler": ' onload="x()">', + "external href": '>t', + "protocol relative": '>t', + "external image": '>', + "css import": ">", + } + for name, tail in cases.items(): + with self.subTest(name=name): + hits, _ = ss.inspect_svg(_svg(self.tmp, SVG_HEAD + tail)) + self.assertTrue(hits, f"{name} 이 통과했다") + + def test_internal_and_data_references_are_not_blocked(self): + """`#fragment` 와 `data:` 는 바깥으로 안 나간다. 막으면 정상을 막는 것이다.""" + for tail in ('>', + '>'): + with self.subTest(tail=tail): + hits, _ = ss.inspect_svg(_svg(self.tmp, SVG_HEAD + tail)) + self.assertEqual([], hits) + + def test_every_figure_in_this_repository_passes(self): + """지금 저장소의 그림 전부가 통과해야 한다. 하나라도 막히면 정책이 과하다.""" + import glob + files = glob.glob(os.path.join(ROOT, "docs/*/final/assets/**/*.svg"), recursive=True) + self.assertGreater(len(files), 100, "그림을 못 찾았다 — 이 대조가 무의미해진다") + blocked = [f for f in files if ss.inspect_svg(f)[0]] + self.assertEqual([], blocked) + + +class AssetWarningTest(unittest.TestCase): + """바뀐 것이 무엇인지는 기계가 못 말한다. 바뀌었으니 보라는 말할 수 있다.""" + + def setUp(self): + self.tmp = tempfile.mkdtemp() + self.svg = _svg(self.tmp, SVG_HEAD + ' data-figure-kind="evidence">') + self.rel = os.path.relpath(self.svg, ROOT) + + def _pkg(self, sha): + return {"assets": [{"path": self.rel, "exists": True, "sha256": sha}]} + + def test_an_unchanged_figure_raises_nothing(self): + """대조군.""" + e, w = ss.inspect_assets(self._pkg(ss._sha256_file(self.svg))) + self.assertEqual([], e) + self.assertEqual([], w) + + def test_a_figure_changed_after_review_is_a_warning_not_an_error(self): + """화살표를 뒤집어도 관문은 전부 0 이다. 그것이 보이지 않던 자리다.""" + e, w = ss.inspect_assets(self._pkg("0" * 64)) + self.assertEqual([], e) + self.assertEqual(["그림이 검토 뒤에 바뀌었다"], [x["id"] for x in w]) + + def test_a_figure_without_a_kind_marker_is_a_warning(self): + plain = _svg(self.tmp, SVG_HEAD + ">") + rel = os.path.relpath(plain, ROOT) + e, w = ss.inspect_assets( + {"assets": [{"path": rel, "exists": True, "sha256": ss._sha256_file(plain)}]}) + self.assertEqual([], e) + self.assertIn("그림 종류가 표시돼 있지 않다", [x["id"] for x in w]) + + def test_a_dangerous_figure_is_an_error(self): + bad = _svg(self.tmp, SVG_HEAD + ">") + rel = os.path.relpath(bad, ROOT) + e, _ = ss.inspect_assets( + {"assets": [{"path": rel, "exists": True, "sha256": ss._sha256_file(bad)}]}) + self.assertEqual(["그림 허용 정책"], [x["id"] for x in e]) + + class IdempotencyKeyTest(unittest.TestCase): def test_create_key_is_stable_across_retries(self): """재시도가 문서를 둘 만들지 않게 하는 것이 이 키의 목적이다."""