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 = ''))
+ 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):
"""재시도가 문서를 둘 만들지 않게 하는 것이 이 키의 목적이다."""