diff --git a/service/scripts/container_meta.py b/service/scripts/container_meta.py index 06506ac..8126924 100644 --- a/service/scripts/container_meta.py +++ b/service/scripts/container_meta.py @@ -7,6 +7,7 @@ Stdlib-first; PDF prefers optional exiftool/c2patool when present. from __future__ import annotations import io +import posixpath import re import subprocess import zipfile @@ -548,15 +549,53 @@ def _scrub_odt_text(xml_text: str) -> tuple[str, int, int]: return new, removed, replaced +def _prune_dangling_relationships( + rels_name: str, raw: bytes, kept_names: set[str] +) -> tuple[bytes, int]: + """Drop entries whose internal target part no longer exists. + + Removing a part (e.g. a customXml tree) must also remove the relationships + that point at it, or the package is malformed: python-docx refuses to open + it and Word offers to repair it. External relationships (``TargetMode``) + and the package root (``Target="/"``) are left alone. ``rels_name`` is the + archive member like ``word/_rels/document.xml.rels``; ``kept_names`` is the + set of archive members that survive cleaning. + """ + base = posixpath.dirname(posixpath.dirname(rels_name)) + text = raw.decode("utf-8", errors="replace") + dropped = [0] + + def _target_attr(tag: str) -> str: + m = re.search(r'\bTarget\s*=\s*"([^"]*)"', tag, re.I) + return m.group(1) if m else "" + + def _drop(m: re.Match[str]) -> str: + tag = m.group(0) + if re.search(r"\bTargetMode\s*=", tag, re.I): + return tag # external (http / mailto / ...) — never pruned + target = _target_attr(tag) + if target.startswith("/"): + resolved = posixpath.normpath(target.lstrip("/")) + else: + resolved = posixpath.normpath(posixpath.join(base, target)) + if resolved in ("", "."): + return tag # points at the package root + if resolved in kept_names: + return tag + dropped[0] += 1 + return "" + + new = re.sub(r"]*/>", _drop, text, flags=re.I) + return new.encode("utf-8"), dropped[0] + + def clean_docx(data: bytes, *, also_layer_a_text: bool = True) -> tuple[bytes, list[str]]: actions: list[str] = [] - out_buf = io.BytesIO() budget = [0] layer_removed = 0 layer_replaced = 0 - with zipfile.ZipFile(io.BytesIO(data)) as zin, zipfile.ZipFile( - out_buf, "w", compression=zipfile.ZIP_DEFLATED - ) as zout: + kept: list[tuple[zipfile.ZipInfo, bytes]] = [] + with zipfile.ZipFile(io.BytesIO(data)) as zin: for info in zin.infolist(): name = info.filename _check_zip_budget(info, budget) @@ -634,6 +673,23 @@ def clean_docx(data: bytes, *, also_layer_a_text: bool = True) -> tuple[bytes, l layer_removed += r layer_replaced += rp raw = new.encode("utf-8") + kept.append((info, raw)) + + # Removing parts must not leave relationships pointing at them: prune every + # rels member against the set of parts that actually survive. + kept_names = {info.filename for info, _ in kept} + final: list[tuple[zipfile.ZipInfo, bytes]] = [] + for info, raw in kept: + if info.filename.endswith(".rels"): + new_raw, n = _prune_dangling_relationships(info.filename, raw, kept_names) + if n: + actions.append(f"prune dangling relationships x{n} in {info.filename}") + raw = new_raw + final.append((info, raw)) + + out_buf = io.BytesIO() + with zipfile.ZipFile(out_buf, "w", compression=zipfile.ZIP_DEFLATED) as zout: + for info, raw in final: zout.writestr(info, raw) if layer_removed or layer_replaced: actions.append(f"layer A text: removed={layer_removed} replaced={layer_replaced}") diff --git a/tests/test_container_meta.py b/tests/test_container_meta.py index 13a308f..00fc13c 100644 --- a/tests/test_container_meta.py +++ b/tests/test_container_meta.py @@ -3,6 +3,8 @@ from __future__ import annotations import io +import posixpath +import re import sys import zipfile from pathlib import Path @@ -204,6 +206,77 @@ def test_docx_strips_app_and_customxml(tmp_path: Path): assert "Claude" not in app +def _dangling_rels(zip_bytes: bytes) -> list[str]: + """Return every internal relationship whose target part is missing.""" + bad: list[str] = [] + with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf: + names = set(zf.namelist()) + for rels in (n for n in names if n.endswith(".rels")): + base = posixpath.dirname(posixpath.dirname(rels)) + text = zf.read(rels).decode() + for m in re.finditer( + r']*Target="([^"]*)"[^>]*/>', text, re.I + ): + target, tag = m.group(1), m.group(0) + if re.search(r"\bTargetMode\s*=", tag, re.I): + continue # external + if target.startswith("/"): + resolved = posixpath.normpath(target.lstrip("/")) + else: + resolved = posixpath.normpath(posixpath.join(base, target)) + if resolved not in ("", ".") and resolved not in names: + bad.append(f"{rels} -> {target}") + return bad + + +def _make_docx_with_rels() -> bytes: + """DOCX whose document rels reference customXml, a kept part and a URL.""" + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr( + "[Content_Types].xml", + """ + + + + +""", + ) + zf.writestr( + "word/document.xml", + 'Hello', + ) + zf.writestr( + "customXml/item1.xml", + 'c2pa contentcredentials', + ) + zf.writestr( + "word/_rels/document.xml.rels", + """ + + + + +""", + ) + return buf.getvalue() + + +def test_docx_dropped_customxml_prunes_dangling_relationships(): + data = _make_docx_with_rels() + assert _dangling_rels(data) == [] + cleaned, actions = clean_docx(data) + with zipfile.ZipFile(io.BytesIO(cleaned)) as zf: + names = zf.namelist() + assert not any(n.startswith("customXml/") for n in names) + rels = zf.read("word/_rels/document.xml.rels").decode() + assert "../customXml/item1.xml" not in rels + assert 'Target="document.xml"' in rels + assert 'TargetMode="External"' in rels + assert _dangling_rels(cleaned) == [] + assert any("prune dangling relationships" in a for a in actions) + + def _make_docx_with_body_text(body_text: str = "Claude wrote this.") -> bytes: buf = io.BytesIO() with zipfile.ZipFile(buf, "w") as zf: