From e3ca353efb5c9fa1a55e36b299e52102e7f0a7e9 Mon Sep 17 00:00:00 2001 From: "Guillaume Meyer (The Opinionated Man)" <1385518+guillaumemeyer@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:01:21 -0700 Subject: [PATCH] fix: prune dangling DOCX relationships after customXml removal (#73) (#80) --- service/scripts/container_meta.py | 64 +++++++++++++++++++++++++-- tests/test_container_meta.py | 73 +++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 4 deletions(-) diff --git a/service/scripts/container_meta.py b/service/scripts/container_meta.py index a1b5feb..512953e 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 @@ -493,13 +494,51 @@ def inspect_docx(data: bytes) -> tuple[bool, bool, list[str], dict]: return has_c2pa, has_ai or has_c2pa, findings, {"parts": len(parts)} +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) -> tuple[bytes, list[str]]: actions: list[str] = [] - out_buf = io.BytesIO() budget = [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) @@ -569,6 +608,23 @@ def clean_docx(data: bytes) -> tuple[bytes, list[str]]: if n: actions.append(f"drop Content_Types customXml overrides x{n}") 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 not actions: actions.append("no DOCX metadata parts removed") diff --git a/tests/test_container_meta.py b/tests/test_container_meta.py index f09e213..44bba86 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: