Files
1cf93d0d60 fix: keep collected evidence when a later zip member fails to read (#175)
* fix: keep collected evidence when a later zip member fails to read

The OOXML, ODT, and EPUB inspectors accumulated has_c2pa/has_ai/
findings member by member, then discarded all of it if any later
member raised: the except returned hardcoded False, False, ["not a
valid X zip"] — markers already found in earlier members were thrown
away and a container that could not be fully read reported as one
that was read and found clean. The pre-commit gate then exited 0 on
exactly the file that carried evidence (#164).

Keep the accumulated evidence and append a partial-read note naming
the exception class; only a wholly-garbage container (nothing
accumulated) keeps the not-a-valid-zip shape. EPUB's except is
widened from BadZipFile to the shared _ZIP_PARSE_ERRORS tuple,
closing the asymmetry where a zlib error crashed the scan while a
CRC failure discarded everything.

* fix: silence RUF100/RUF059 lint errors in zip partial-evidence tests

---------

Co-authored-by: yzxcj797 <yzxcj797@users.noreply.github.com>
Co-authored-by: Guillaume Meyer (The Opinionated Man) <1385518+guillaumemeyer@users.noreply.github.com>
Co-authored-by: guillaumemeyer <guillaumemeyer@users.noreply.github.com>
2026-08-19 11:59:24 -07:00

2125 lines
81 KiB
Python

"""Inspect/clean AI provenance metadata in non-raster containers.
Formats: SVG, PDF (best-effort), DOCX, ODT, HTML, Markdown frontmatter, EPUB.
Stdlib-first; PDF prefers optional exiftool/c2patool when present.
"""
import base64
import io
import posixpath
import re
import subprocess
import urllib.parse
import zipfile
import zlib
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from common import (
c2patool_probe_note,
classify_finding_confidence,
safe_arg,
safe_write_bytes,
safe_write_text,
subprocess_preexec_fn,
which,
)
from image_meta import (
AI_META_HINTS,
C2PA_MARKERS,
inspect_bmp,
inspect_gif,
inspect_isobmff,
inspect_jpeg,
inspect_png,
inspect_tiff,
inspect_webp,
run_optional_tools,
strip_bmp,
strip_gif,
strip_isobmff,
strip_jpeg,
strip_png,
strip_tiff,
strip_webp,
)
from image_meta import (
detect_format as detect_image_format,
)
class ZipBudgetExceeded(Exception):
"""A zip's declared decompressed size exceeds the processing cap.
Kept separate from the parse errors below so a refused zip bomb keeps
propagating (as it already does out of the clean_* helpers) instead of
being reported as an unparseable container.
"""
# A corrupt, truncated, encrypted, or unsupported-compression zip surfaces as
# more than just BadZipFile: reading a member can raise NotImplementedError
# (unknown compression/version), RuntimeError (encrypted), zlib.error (bad
# deflate stream), EOFError (truncated), OSError (invalid stream), or
# ValueError (e.g. a negative seek).
_ZIP_PARSE_ERRORS = (
zipfile.BadZipFile,
zipfile.LargeZipFile,
NotImplementedError,
RuntimeError,
EOFError,
OSError,
ValueError,
zlib.error,
)
# Frontmatter / meta keys that often carry AI provenance
AI_FRONTMATTER_KEYS = frozenset(
{
"generator",
"ai",
"ai_generated",
"ai-generated",
"claude",
"anthropic",
"openai",
"gemini",
"synthid",
"c2pa",
"content_credentials",
"contentcredentials",
"provenance",
"digital_source_type",
"digitalsourcetype",
"created_with",
"createdwith",
"model",
"llm",
}
)
AI_META_NAME_RE = re.compile(
r"generator|ai[-_ ]?generated|claude|anthropic|openai|gemini|synthid|"
r"c2pa|content.?credential|provenance|digital.?source|aigc",
re.I,
)
SVG_DROP_TAGS = frozenset(
{
"{http://www.w3.org/2000/svg}metadata",
"metadata",
"{http://www.w3.org/1999/02/22-rdf-syntax-ns#}RDF",
"{adobe:ns:meta/}xmpmeta",
}
)
@dataclass
class ContainerInspectReport:
path: str
format: str
has_c2pa: bool
has_ai_metadata: bool
findings: list[str] = field(default_factory=list)
tools: dict[str, Any] = field(default_factory=dict)
details: dict[str, Any] = field(default_factory=dict)
notes: list[str] = field(default_factory=list)
# Layer A (invisible/format Unicode) scan of the text body, populated only
# for the formats clean_container() actually scrubs. Without it, inspect
# reported a markdown/html file carrying invisible carriers as clean while
# clean then went on to remove them.
layer_a_total: int = 0
layer_a_hits: list[dict] = field(default_factory=list)
def to_dict(self) -> dict:
return {
"path": self.path,
"format": self.format,
"has_c2pa": self.has_c2pa,
"has_ai_metadata": self.has_ai_metadata,
"findings": self.findings,
"findings_confidence": [classify_finding_confidence(f) for f in self.findings],
"tools": self.tools,
"details": self.details,
"notes": self.notes,
# Same key as TextInspectReport so every caller — including the
# HTTP server's `suspicious` flag — reads both report kinds alike.
"suspicious_total": self.layer_a_total,
"layer_a_hits": self.layer_a_hits,
}
def detect_container_format(path: Path, data: bytes | None = None) -> str:
ext = path.suffix.lower()
if ext in (".svg",):
return "svg"
if ext in (".pdf",):
return "pdf"
if ext in (".docx",):
return "docx"
if ext in (".xlsx",):
return "xlsx"
if ext in (".pptx",):
return "pptx"
if ext in (".odt",):
return "odt"
if ext in (".epub",):
return "epub"
if ext in (".html", ".htm"):
return "html"
if ext in (".md", ".markdown", ".mdx"):
return "markdown"
if data is not None:
if data[:4] == b"%PDF":
return "pdf"
if data[:100].lstrip().startswith(b"<") and b"svg" in data[:500].lower():
return "svg"
if data[:2] == b"PK":
# zip-based; sniff
try:
with zipfile.ZipFile(io.BytesIO(data)) as zf:
names = set(zf.namelist())
if "word/document.xml" in names:
return "docx"
if "xl/workbook.xml" in names:
return "xlsx"
if "ppt/presentation.xml" in names:
return "pptx"
if "content.xml" in names and "meta.xml" in names:
return "odt"
if "META-INF/container.xml" in names and any(n.endswith(".opf") for n in names):
return "epub"
except _ZIP_PARSE_ERRORS:
pass
return "unknown"
def _blob_hits(blob: bytes) -> tuple[bool, bool, list[str]]:
lower = blob.lower()
findings: list[str] = []
has_c2pa = False
has_ai = False
for n in C2PA_MARKERS:
if n.lower() in lower:
has_c2pa = True
findings.append(f"marker:{n.decode('ascii', errors='replace')}")
for n in AI_META_HINTS:
if n.lower() in lower:
has_ai = True
label = n.decode("ascii", errors="replace")
if label not in {f.split(":", 1)[-1] for f in findings}:
findings.append(f"ai:{label}")
return has_c2pa, has_ai or has_c2pa, findings[:30]
RE_DATA_IMAGE_URI = re.compile(
r"data:image\/(?P<mime>[a-zA-Z0-9\+\-\.]+)(?P<params>;[^\s\"'\)<>]+)?,(?P<payload>[A-Za-z0-9+/=\s%]+)",
re.I,
)
def _inspect_embedded_data_uris(text: str) -> tuple[bool, bool, list[str]]:
has_c2pa = False
has_ai = False
findings: list[str] = []
for m in RE_DATA_IMAGE_URI.finditer(text):
mime = m.group("mime").lower()
params = (m.group("params") or "").lower()
payload = m.group("payload")
is_b64 = "base64" in params
try:
if is_b64:
raw_b64 = re.sub(r"\s+", "", payload)
pad = len(raw_b64) % 4
if pad:
raw_b64 += "=" * (4 - pad)
data = base64.b64decode(raw_b64)
else:
data = urllib.parse.unquote_to_bytes(payload)
except Exception: # noqa: S112 - malformed data URI; skip to next match
continue
if not data:
continue
fmt = detect_image_format(data)
if fmt == "png":
sub_c2pa, sub_ai, sub_findings = inspect_png(data)
elif fmt == "jpeg":
sub_c2pa, sub_ai, sub_findings = inspect_jpeg(data)
elif fmt == "webp":
sub_c2pa, sub_ai, sub_findings = inspect_webp(data)
elif fmt in ("avif", "heic"):
sub_c2pa, sub_ai, sub_findings = inspect_isobmff(data, fmt)
elif "svg" in mime or data.lstrip().startswith(b"<"):
sub_c2pa, sub_ai, sub_findings, _ = inspect_svg(data)
else:
sub_c2pa, sub_ai, sub_findings = _blob_hits(data)
if sub_c2pa:
has_c2pa = True
if sub_ai or sub_c2pa:
has_ai = True
for f in sub_findings:
findings.append(f"embedded data:image/{mime}: {f}")
return has_c2pa, has_ai, findings
def _clean_embedded_data_uris(
text: str, *, strip_all_metadata: bool = True
) -> tuple[str, list[str]]:
actions: list[str] = []
def _replace_uri(m: re.Match[str]) -> str:
full_match = m.group(0)
mime = m.group("mime")
params = m.group("params") or ""
payload = m.group("payload")
is_b64 = "base64" in params.lower()
try:
if is_b64:
raw_b64 = re.sub(r"\s+", "", payload)
pad = len(raw_b64) % 4
if pad:
raw_b64 += "=" * (4 - pad)
data = base64.b64decode(raw_b64)
else:
data = urllib.parse.unquote_to_bytes(payload)
except Exception:
return full_match
if not data:
return full_match
fmt = detect_image_format(data)
sub_actions: list[str] = []
cleaned_bytes = data
try:
if fmt == "png":
cleaned_bytes, sub_actions = strip_png(data, strip_all_text=strip_all_metadata)
elif fmt == "jpeg":
cleaned_bytes, sub_actions = strip_jpeg(data, strip_all_app=strip_all_metadata)
elif fmt == "webp":
cleaned_bytes, sub_actions = strip_webp(data, strip_all_metadata=strip_all_metadata)
elif fmt in ("avif", "heic"):
cleaned_bytes, sub_actions = strip_isobmff(
data, fmt, strip_all_metadata=strip_all_metadata
)
elif "svg" in mime.lower() or data.lstrip().startswith(b"<"):
cleaned_bytes, sub_actions = clean_svg(data)
except Exception:
return full_match
if not any("drop" in a.lower() for a in sub_actions) or cleaned_bytes == data:
return full_match
actions.append(f"cleaned embedded data:image/{mime} ({', '.join(sub_actions[:2])})")
if is_b64:
new_b64 = base64.b64encode(cleaned_bytes).decode("ascii")
return f"data:image/{mime}{params},{new_b64}"
else:
new_payload = urllib.parse.quote_from_bytes(cleaned_bytes)
return f"data:image/{mime}{params},{new_payload}"
out = RE_DATA_IMAGE_URI.sub(_replace_uri, text)
return out, actions
# ---------------------------------------------------------------------------
# Linear tag-block scanning
# ---------------------------------------------------------------------------
#
# The lazy ".*?</close>" idiom is quadratic on adversarial input: with many
# opening tags and no closing tag, the engine rescans to end-of-input from
# every candidate start position (CPython's "re" holds the GIL, so one such
# request stalls the whole single-process service for its duration). The
# helpers below locate every opening and closing tag once and pair them with a
# forward pointer - O(n) total regardless of input shape - while preserving
# the exact match semantics of re.subn with a lazy ".*?" (the first closing
# tag at/after each opening tag's end, blocks never overlapping).
def _iter_tag_blocks(text, open_re, close_re):
"""Yield (open_start, open_end, close_start, close_end) for each block.
Works on str and bytes alike. Linear in len(text): closing tags are
collected once with finditer and consumed by a forward pointer, so an
unbounded run of unclosed opening tags costs one pass, not one rescan per
opening tag.
"""
closes = list(close_re.finditer(text))
ci = 0
last_end = 0
for om in open_re.finditer(text):
if om.start() < last_end:
continue
while ci < len(closes) and closes[ci].start() < om.end():
ci += 1
if ci >= len(closes):
return
cm = closes[ci]
yield om.start(), om.end(), cm.start(), cm.end()
last_end = cm.end()
def _drop_tag_blocks(text, open_re, close_re):
"""Remove every open...close block. Return (text, count). Linear time."""
out = []
last = 0
count = 0
for os_, _oe, _cs, ce in _iter_tag_blocks(text, open_re, close_re):
out.append(text[last:os_])
last = ce
count += 1
if not count:
return text, 0
out.append(text[last:])
return text[:0].join(out), count
def _drop_blocks_if(text, open_re, close_re, predicate):
"""Drop blocks whose full text satisfies predicate. Return (text, count).
Blocks failing the predicate are kept verbatim; predicate is called with
the whole block (open tag through closing tag), matching the
callback-based re.sub sites this replaces.
"""
out = []
last = 0
count = 0
for os_, _oe, _cs, ce in _iter_tag_blocks(text, open_re, close_re):
if not predicate(text[os_:ce]):
continue
out.append(text[last:os_])
last = ce
count += 1
if not count:
return text, 0
out.append(text[last:])
return text[:0].join(out), count
# ---------------------------------------------------------------------------
# Markdown frontmatter
# ---------------------------------------------------------------------------
_FM_RE = re.compile(r"\A---\r?\n(.*?)\r?\n---\r?\n?", re.DOTALL)
def _parse_simple_yaml_keys(block: str) -> list[tuple[str, str, int]]:
"""Return list of (key, full_line, line_index) for top-level keys only."""
rows: list[tuple[str, str, int]] = []
for i, line in enumerate(block.splitlines()):
if not line.strip() or line.strip().startswith("#"):
continue
if line[0] in (" ", "\t", "-"):
continue # nested / list — leave alone
m = re.match(r"^([A-Za-z0-9_.-]+)\s*:", line)
if m:
rows.append((m.group(1), line, i))
return rows
def inspect_markdown(text: str) -> tuple[bool, bool, list[str], dict]:
findings: list[str] = []
has_ai = False
has_fm = False
keys = []
m = _FM_RE.match(text)
if m:
has_fm = True
block = m.group(1)
for key, _line, _i in _parse_simple_yaml_keys(block):
keys.append(key)
if key.lower() in AI_FRONTMATTER_KEYS or AI_META_NAME_RE.search(key):
has_ai = True
findings.append(f"frontmatter key: {key}")
# also check value
val = _line.split(":", 1)[1] if ":" in _line else ""
if AI_META_NAME_RE.search(val):
has_ai = True
findings.append(f"frontmatter value hit on {key}")
uri_c2pa, uri_ai, uri_findings = _inspect_embedded_data_uris(text)
if uri_c2pa:
has_ai = True
if uri_ai:
has_ai = True
findings.extend(uri_findings)
c2pa = uri_c2pa or any("c2pa" in f.lower() or "content" in f.lower() for f in findings)
return c2pa, has_ai, findings, {"has_frontmatter": has_fm, "keys": keys}
def clean_markdown(text: str) -> tuple[str, list[str]]:
actions: list[str] = []
m = _FM_RE.match(text)
if m:
block = m.group(1)
body = text[m.end() :]
kept: list[str] = []
dropping = False # inside the nested block of a dropped top-level key
for line in block.splitlines():
stripped = line.strip()
# Blank lines and comments belong to whichever block we are inside.
if not stripped or stripped.startswith("#"):
if not dropping:
kept.append(line)
continue
# Continuation lines (nested mappings, list items) follow their parent.
if line[0] in (" ", "\t", "-"):
if not dropping:
kept.append(line)
continue
km = re.match(r"^([A-Za-z0-9_.-]+)\s*:", line)
if not km:
dropping = False
kept.append(line)
continue
key = km.group(1)
val = line.split(":", 1)[1] if ":" in line else ""
if key.lower() in AI_FRONTMATTER_KEYS or AI_META_NAME_RE.search(key):
actions.append(f"drop frontmatter key: {key}")
dropping = True
continue
if AI_META_NAME_RE.search(val):
actions.append(f"drop frontmatter key (value hit): {key}")
dropping = True
continue
dropping = False
kept.append(line)
new_block = "\n".join(kept).strip("\n")
if new_block:
out = f"---\n{new_block}\n---\n{body}"
else:
out = body.lstrip("\n")
actions.append("removed empty frontmatter block")
else:
out = text
out, uri_actions = _clean_embedded_data_uris(out)
if uri_actions:
actions.extend(uri_actions)
if not actions:
actions.append("no AI frontmatter keys or embedded data URIs removed")
return out, actions
# ---------------------------------------------------------------------------
# HTML
# ---------------------------------------------------------------------------
_META_TAG_RE = re.compile(
r"<meta\b[^>]*>",
re.I,
)
_META_ATTR_RE = re.compile(
r"""(name|property|content|generator)\s*=\s*["']([^"']*)["']""",
re.I,
)
# Known AI vendor names for the "generator" meta tag. A plain CMS generator
# (WordPress, Elementor) is CMS provenance, not AI-generator metadata.
_GENERATOR_AI_RE = re.compile(
r"claude|anthropic|openai|chatgpt|gemini|synthid|copilot|midjourney|dall.?e|stable.?diffusion",
re.I,
)
def _meta_attrs(tag: str) -> dict[str, str]:
return {name.lower(): value for name, value in _META_ATTR_RE.findall(tag)}
def _is_cms_generator_meta(tag: str) -> bool:
"""Return True for a generator meta tag that is CMS provenance, not AI."""
attrs = _meta_attrs(tag)
name_or_prop = (
attrs.get("name") or attrs.get("property") or attrs.get("generator") or ""
).lower()
if name_or_prop != "generator":
return False
return not (_GENERATOR_AI_RE.search(attrs.get("content", "")) or _GENERATOR_AI_RE.search(tag))
_JSONLD_OPEN_RE = re.compile(
r"""<script\b[^>]*type\s*=\s*["']application/ld\+json["'][^>]*>""",
re.I,
)
_JSONLD_CLOSE_RE = re.compile(r"</script>", re.I)
def inspect_html(text: str) -> tuple[bool, bool, list[str], dict]:
findings: list[str] = []
has_ai = False
has_c2pa = False
for tag in _META_TAG_RE.findall(text):
if re.search(r"c2pa|content.?credential", tag, re.I):
has_c2pa = True
if _is_cms_generator_meta(tag):
findings.append(f"info: cms generator: {tag[:120]}")
continue
if AI_META_NAME_RE.search(tag) or any(
h.decode("ascii", "ignore").lower() in tag.lower() for h in AI_META_HINTS[:12]
):
has_ai = True
findings.append(f"meta: {tag[:120]}")
for os_, _oe, _cs, ce in _iter_tag_blocks(text, _JSONLD_OPEN_RE, _JSONLD_CLOSE_RE):
blob = text[os_:ce]
if AI_META_NAME_RE.search(blob) or re.search(
r"DigitalSourceType|trainedAlgorithmicMedia|SoftwareAgent", blob, re.I
):
has_ai = True
findings.append("json-ld provenance-like block")
if re.search(r"c2pa|contentcredential", blob, re.I):
has_c2pa = True
# data-ai* attributes
for m in re.finditer(r"\bdata-ai[\w-]*\s*=\s*[\"'][^\"']*[\"']", text, re.I):
has_ai = True
findings.append(f"attr: {m.group(0)[:80]}")
uri_c2pa, uri_ai, uri_findings = _inspect_embedded_data_uris(text)
if uri_c2pa:
has_c2pa = True
if uri_ai:
has_ai = True
findings.extend(uri_findings)
return has_c2pa, has_ai, findings, {}
def clean_html(text: str) -> tuple[str, list[str]]:
actions: list[str] = []
def _meta_sub(m: re.Match[str]) -> str:
tag = m.group(0)
if _is_cms_generator_meta(tag):
return tag
if AI_META_NAME_RE.search(tag) or re.search(
r"generator|claude|anthropic|openai|gemini|synthid|c2pa|aigc", tag, re.I
):
actions.append(f"drop meta: {tag[:80]}")
return ""
return tag
out = _META_TAG_RE.sub(_meta_sub, text)
def _jsonld_is_ai(blob: str) -> bool:
return AI_META_NAME_RE.search(blob) or re.search(
r"DigitalSourceType|trainedAlgorithmicMedia|SoftwareAgent", blob, re.I
)
new, n = _drop_blocks_if(out, _JSONLD_OPEN_RE, _JSONLD_CLOSE_RE, _jsonld_is_ai)
if n:
actions.extend(["drop json-ld provenance-like script"] * n)
out = new
out2, n = re.subn(r"\sdata-ai[\w-]*\s*=\s*[\"'][^\"']*[\"']", "", out, flags=re.I)
if n:
actions.append(f"drop data-ai* attributes x{n}")
out = out2
out, uri_actions = _clean_embedded_data_uris(out)
if uri_actions:
actions.extend(uri_actions)
if not actions:
actions.append("no HTML AI meta removed")
return out, actions
# ---------------------------------------------------------------------------
# SVG
# ---------------------------------------------------------------------------
# Opening/closing tag patterns for the linear block scans below. Kept as
# separate open/close halves: the lazy ".*?</close>" form is quadratic when
# many opening tags have no closing tag (see _iter_tag_blocks).
_SVG_METADATA_OPEN_RE = re.compile(r"<metadata\b[^>]*>", re.I)
_SVG_METADATA_CLOSE_RE = re.compile(r"</metadata\s*>", re.I)
_SVG_XMPMETA_OPEN_RE = re.compile(r"<x:xmpmeta\b[^>]*>", re.I)
_SVG_XMPMETA_CLOSE_RE = re.compile(r"</x:xmpmeta\s*>", re.I)
_SVG_COMMENT_OPEN_RE = re.compile(r"<!--")
# HTML comment end tags are "-->" or "--!>" (CodeQL: bad HTML filtering
# regexp otherwise). XML/SVG only emits "-->", but accepting both keeps the
# scrub effective on HTML-flavoured inputs.
_SVG_COMMENT_CLOSE_RE = re.compile(r"--!?>")
def inspect_svg(data: bytes) -> tuple[bool, bool, list[str], dict]:
findings: list[str] = []
has_c2pa, has_ai, hits = _blob_hits(data)
findings.extend(hits)
try:
text = data.decode("utf-8", errors="replace")
if re.search(r"<metadata[\s>]", text, re.I):
findings.append("svg <metadata> present")
has_ai = True # often XMP; treat as inspect signal
if re.search(r"xmpmeta|rdf:RDF|contentcredentials", text, re.I):
has_ai = True
findings.append("XMP/RDF-like content in SVG")
if re.search(r"c2pa|jumbf", text, re.I):
has_c2pa = True
uri_c2pa, uri_ai, uri_findings = _inspect_embedded_data_uris(text)
if uri_c2pa:
has_c2pa = True
if uri_ai:
has_ai = True
findings.extend(uri_findings)
except Exception as e:
findings.append(f"svg decode note: {e}")
return has_c2pa, has_ai or has_c2pa, findings, {}
def clean_svg(data: bytes) -> tuple[bytes, list[str]]:
actions: list[str] = []
text = data.decode("utf-8", errors="surrogateescape")
# Drop metadata blocks (linear scan - lazy .*? is quadratic on unclosed tags)
new, n = _drop_tag_blocks(text, _SVG_METADATA_OPEN_RE, _SVG_METADATA_CLOSE_RE)
if n:
actions.append(f"drop <metadata> x{n}")
text = new
# Drop adobe xmp packets
new, n = _drop_tag_blocks(text, _SVG_XMPMETA_OPEN_RE, _SVG_XMPMETA_CLOSE_RE)
if n:
actions.append(f"drop xmpmeta x{n}")
text = new
# Drop comments that look like provenance (linear scan)
def _cmt(block: str) -> bool:
return bool(AI_META_NAME_RE.search(block))
new, n = _drop_blocks_if(text, _SVG_COMMENT_OPEN_RE, _SVG_COMMENT_CLOSE_RE, _cmt)
if n:
actions.extend(["drop SVG comment with AI markers"] * n)
text = new
# Clean embedded data URIs
text, uri_actions = _clean_embedded_data_uris(text)
if uri_actions:
actions.extend(uri_actions)
if not actions:
# still strip generator attribute on root if present
new, n = re.subn(
r'\s(inkscape:version|sodipodi:docname|generator)\s*=\s*"[^"]*"',
"",
text,
flags=re.I,
)
if n:
actions.append(f"drop generator-like attrs x{n}")
text = new
if not actions:
actions.append("no SVG metadata removed")
return text.encode("utf-8", errors="surrogateescape"), actions
# ---------------------------------------------------------------------------
# DOCX / ODT (zip + XML)
# ---------------------------------------------------------------------------
# Open/close tag halves for the linear metadata-block scans in clean_odt.
_ODT_GENERATOR_OPEN_RE = re.compile(r"<meta:generator\b[^>]*>", re.I)
_ODT_GENERATOR_CLOSE_RE = re.compile(r"</meta:generator\s*>", re.I)
_DC_CREATOR_OPEN_RE = re.compile(r"<dc:creator\b[^>]*>", re.I)
_DC_CREATOR_CLOSE_RE = re.compile(r"</dc:creator\s*>", re.I)
DOCX_META_PARTS = (
"docProps/core.xml",
"docProps/app.xml",
"docProps/custom.xml",
)
DOCX_CUSTOM_PREFIXES = (
"customXml/",
"docProps/",
)
# Provenance fields in docProps/core.xml and docProps/app.xml that always come
# out empty. dc:title is deliberately not listed: it is the document's own
# heading, not provenance.
DOCX_SCRUB_FIELDS = (
("dc:creator", "dc:creator"),
("cp:lastModifiedBy", "cp:lastModifiedBy"),
("dc:description", "dc:description"),
("cp:keywords", "cp:keywords"),
("dc:subject", "dc:subject"),
("cp:category", "cp:category"),
("Application", "Application"),
("AppVersion", "AppVersion"),
("Company", "Company"),
("Manager", "Manager"),
)
def _zip_namelist(data: bytes) -> list[str]:
with zipfile.ZipFile(io.BytesIO(data)) as zf:
return zf.namelist()
MAX_ZIP_DECOMPRESSED_BYTES = 128 * 1024 * 1024
def _check_zip_budget(info: zipfile.ZipInfo, budget: list[int]) -> None:
"""Fast-path zip-bomb guard on the declared member size.
A single member whose *declared* size already exceeds the cap is
rejected before any decompression. The authoritative accounting lives in
_read_zip_member, which charges **actual** decompressed bytes to the
shared budget: ZipInfo.file_size comes from the archive central
directory and is attacker-controlled, so trusting it for the cumulative
limit would let a crafted archive declare a tiny size and still expand
to gigabytes.
"""
if info.file_size > MAX_ZIP_DECOMPRESSED_BYTES:
raise ZipBudgetExceeded(
"zip decompressed size exceeds cap "
f"({MAX_ZIP_DECOMPRESSED_BYTES} bytes); refusing to process"
)
def _read_zip_member(zf: zipfile.ZipFile, info: zipfile.ZipInfo, budget: list[int]) -> bytes:
"""Read one zip member, charging real decompressed bytes to the budget.
Streams the member in chunks so the cumulative cap is enforced on bytes
actually produced, not on the declared ``file_size``; raises
``ZipBudgetExceeded`` the moment the cap is crossed, before the whole
member is buffered.
"""
_check_zip_budget(info, budget)
with zf.open(info) as stream:
chunks: list[bytes] = []
while True:
chunk = stream.read(1 << 16)
if not chunk:
break
budget[0] += len(chunk)
if budget[0] > MAX_ZIP_DECOMPRESSED_BYTES:
raise ZipBudgetExceeded(
"zip decompressed size exceeds cap "
f"({MAX_ZIP_DECOMPRESSED_BYTES} bytes); refusing to process"
)
chunks.append(chunk)
return b"".join(chunks)
def _is_docx_meta_part(name: str) -> bool:
"""Return True for DOCX/XLSX/PPTX parts that carry provenance, not visible content."""
return name.startswith(("docProps/", "customXml/"))
def _inspect_ooxml_zip(data: bytes, fmt: str) -> tuple[bool, bool, list[str], dict]:
findings: list[str] = []
has_c2pa = False
has_ai = False
parts: list[str] = []
budget = [0]
try:
with zipfile.ZipFile(io.BytesIO(data)) as zf:
parts = zf.namelist()
for info in zf.infolist():
_check_zip_budget(info, budget)
name = info.filename
# Check media parts for C2PA/AI metadata
if re.search(
r"^(?:word|xl|ppt)/media/.+\.(png|jpe?g|webp|avif|heic|gif|bmp|tiff?|svg)$",
name,
re.I,
):
raw = _read_zip_member(zf, info, budget)
img_fmt = detect_image_format(raw)
sub_c2pa, sub_ai, sub_findings = False, False, []
if img_fmt == "png":
sub_c2pa, sub_ai, sub_findings = inspect_png(raw)
elif img_fmt == "jpeg":
sub_c2pa, sub_ai, sub_findings = inspect_jpeg(raw)
elif img_fmt == "webp":
sub_c2pa, sub_ai, sub_findings = inspect_webp(raw)
elif img_fmt in ("avif", "heic"):
sub_c2pa, sub_ai, sub_findings = inspect_isobmff(raw, img_fmt)
elif img_fmt == "gif":
sub_c2pa, sub_ai, sub_findings = inspect_gif(raw)
elif img_fmt == "tiff":
sub_c2pa, sub_ai, sub_findings = inspect_tiff(raw)
elif img_fmt == "bmp":
sub_c2pa, sub_ai, sub_findings = inspect_bmp(raw)
elif name.lower().endswith(".svg") or raw.lstrip().startswith(b"<"):
sub_c2pa, sub_ai, sub_findings, _ = inspect_svg(raw)
if sub_c2pa:
has_c2pa = True
if sub_ai or sub_c2pa:
has_ai = True
for sf in sub_findings:
findings.append(f"{name}: {sf}")
continue
# Only metadata/provenance parts carry AI markers. The visible
# body (word/*.xml, xl/*.xml, ppt/*.xml) may legitimately mention
# vendor names such as "Claude" without being AI-generated metadata.
if not _is_docx_meta_part(name):
continue
raw = _read_zip_member(zf, info, budget)
c2, ai, hits = _blob_hits(raw)
if c2 or ai:
if c2:
has_c2pa = True
if ai:
has_ai = True
findings.append(f"{name}: {', '.join(hits[:6])}")
# always flag customXml presence lightly
custom = [n for n in parts if n.startswith("customXml/")]
if custom:
findings.append(f"customXml parts: {len(custom)}")
except _ZIP_PARSE_ERRORS as exc:
# A member that failed to read must not discard the evidence already
# collected from earlier members, nor read as "opened and found
# clean": keep the flags/findings, append a partial-read note, and
# only a wholly-garbage container (nothing accumulated) keeps the old
# not-a-valid-zip shape (#164).
if findings:
findings.append(
f"partial read of {fmt.upper()} zip ({exc.__class__.__name__}); "
"evidence above survives, later members were not scanned"
)
return has_c2pa, has_ai or has_c2pa, findings, {"parts": len(parts)}
return False, False, [f"not a valid {fmt.upper()} zip"], {}
return has_c2pa, has_ai or has_c2pa, findings, {"parts": len(parts)}
def inspect_docx(data: bytes) -> tuple[bool, bool, list[str], dict]:
return _inspect_ooxml_zip(data, "docx")
def inspect_xlsx(data: bytes) -> tuple[bool, bool, list[str], dict]:
return _inspect_ooxml_zip(data, "xlsx")
def inspect_pptx(data: bytes) -> tuple[bool, bool, list[str], dict]:
return _inspect_ooxml_zip(data, "pptx")
_XML_CHAR_REF_RE = re.compile(r"&#(?:x([0-9A-Fa-f]+)|([0-9]+));|&(amp|lt|gt|quot|apos);")
_XML_NAMED_ENTITIES = {"amp": "&", "lt": "<", "gt": ">", "quot": '"', "apos": "'"}
def _decode_xml_entities(s: str) -> str:
"""Decode what a conforming XML parser would resolve: numeric character
references and the five predefined entities. A watermark carrier encoded as
``&#x200B;`` otherwise reaches Layer A as nine ASCII characters and survives
cleaning, because the parser in Word/Writer decodes the entity afterwards
(#129). Anything a parser would leave literal stays literal here.
"""
def _sub(m: re.Match[str]) -> str:
hex_digits, decimal, named = m.group(1), m.group(2), m.group(3)
if named:
return _XML_NAMED_ENTITIES[named]
codepoint = int(hex_digits, 16) if hex_digits else int(decimal)
if codepoint == 0 or codepoint > 0x10FFFF or 0xD800 <= codepoint <= 0xDFFF:
return m.group(0) # not a character; leave for the real parser to reject
try:
return chr(codepoint)
except ValueError:
return m.group(0)
return _XML_CHAR_REF_RE.sub(_sub, s)
def _reencode_xml_text(s: str) -> str:
"""Escape text content the way a serializer would: ``&``, ``<`` and ``>``.
``>`` is legal literally in text content but re-escaping it is a semantic
no-op, and ``<``/``&`` must be escaped anyway — so a decode/clean/re-encode
round trip is stable for any well-formed input.
"""
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
def _scrub_text_runs(
xml_text: str, open_re: re.Pattern[str], close_re: re.Pattern[str]
) -> tuple[str, int, int]:
"""Run Layer A over the text runs delimited by open_re/close_re.
XML character references are decoded first (a carrier written as
``&#x200B;`` otherwise reaches Layer A as nine ASCII characters and
survives cleaning, only to be resurrected by the parser in Word/Writer —
#129) and the surviving text is re-encoded on the way out, so the round
trip is stable for any well-formed input.
Shared by the DOCX/XLSX/PPTX body scrubs. Linear scan (see
_iter_tag_blocks) - the previous "(<tag>)(.*?)(</tag>)" lazy pattern was
quadratic on a run of unclosed opening tags.
"""
from text_unicode import clean_text # local import to avoid cycles
removed = 0
replaced = 0
out = []
last = 0
for os_, oe, cs_, ce in _iter_tag_blocks(xml_text, open_re, close_re):
open_tag = xml_text[os_:oe]
inner = xml_text[oe:cs_]
close_tag = xml_text[cs_:ce]
new_inner, stats = clean_text(_decode_xml_entities(inner))
if not (stats["removed_count"] or stats["replaced_count"]):
continue
removed += stats["removed_count"]
replaced += stats["replaced_count"]
if (new_inner[:1].isspace() or new_inner[-1:].isspace()) and "xml:space" not in open_tag:
open_tag = open_tag[:-1] + ' xml:space="preserve">'
out.append(xml_text[last:os_])
out.append(open_tag + _reencode_xml_text(new_inner) + close_tag)
last = ce
out.append(xml_text[last:])
return "".join(out), removed, replaced
def _scrub_docx_text(xml_text: str) -> tuple[str, int, int]:
"""Run Layer A over the ``<w:t>`` text runs of a DOCX part.
Only ``w:t`` nodes are touched: field codes (``w:instrText``), run/paragraph
properties and the surrounding XML are left byte-identical. If leading or
trailing whitespace survives the clean, the node keeps
``xml:space="preserve"`` so Word does not trim it.
"""
return _scrub_text_runs(xml_text, re.compile(r"<w:t\b[^>]*>"), re.compile(r"</w:t>"))
def _scrub_xlsx_text(xml_text: str) -> tuple[str, int, int]:
"""Run Layer A over the ``<t>`` text elements of an XLSX part."""
return _scrub_text_runs(xml_text, re.compile(r"<t\b[^>]*>"), re.compile(r"</t>"))
def _scrub_pptx_text(xml_text: str) -> tuple[str, int, int]:
"""Run Layer A over the ``<a:t>`` text elements of a PPTX part."""
return _scrub_text_runs(xml_text, re.compile(r"<a:t\b[^>]*>"), re.compile(r"</a:t>"))
def _scrub_odt_text(xml_text: str) -> tuple[str, int, int]:
"""Run Layer A over ODF paragraph text (``text:p`` content, incl. spans).
``text:span``/``text:tab``/``text:s`` children live inside the paragraph,
so cleaning the paragraph content covers the visible text. The markup
itself is untouched: entities are decoded and re-encoded per text segment
only — a whole-paragraph round trip would escape the nested markup.
Linear scan (see _iter_tag_blocks).
"""
from text_unicode import clean_text # local import to avoid cycles
removed = 0
replaced = 0
out = []
last = 0
for os_, oe, cs_, ce in _iter_tag_blocks(
xml_text, re.compile(r"<text:p\b[^>]*>"), re.compile(r"</text:p>")
):
open_tag = xml_text[os_:oe]
inner = xml_text[oe:cs_]
close_tag = xml_text[cs_:ce]
parts = re.split(r"(<[^>]+>)", inner)
new_parts: list[str] = []
changed = False
for segment in parts:
if not segment or segment.startswith("<"):
new_parts.append(segment)
continue
new_segment, stats = clean_text(_decode_xml_entities(segment))
if stats["removed_count"] or stats["replaced_count"]:
removed += stats["removed_count"]
replaced += stats["replaced_count"]
changed = True
new_parts.append(_reencode_xml_text(new_segment))
else:
new_parts.append(segment)
if not changed:
continue
new_para = "".join(new_parts)
if (new_para[:1].isspace() or new_para[-1:].isspace()) and "xml:space" not in open_tag:
open_tag = open_tag[:-1] + ' xml:space="preserve">'
out.append(xml_text[last:os_])
out.append(open_tag + new_para + close_tag)
last = ce
out.append(xml_text[last:])
return "".join(out), removed, replaced
def _prune_dangling_relationships(
rels_name: str, raw: bytes, kept_names: set[str]
) -> tuple[bytes, int]:
"""Drop <Relationship> 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:
# XML allows single- or double-quoted attribute values; matching only
# double quotes made every single-quoted Relationship look like an
# empty target, which resolved to the package base and got pruned —
# corrupting packages from tools that emit single quotes (#130).
m = re.search(r"\bTarget\s*=\s*([\"'])(.*?)\1", tag, re.I)
return m.group(2) 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"<Relationship\b[^>]*/>", _drop, text, flags=re.I)
return new.encode("utf-8"), dropped[0]
def _prune_odt_manifest_entries(raw: bytes, dropped: set[str]) -> tuple[bytes, int]:
"""Remove manifest file-entry elements pointing at dropped parts.
ODF packages list every member in META-INF/manifest.xml; dropping a part
(e.g. one carrying AI/C2PA markers) without removing its entry makes
readers flag the package as damaged. full-path is matched
attribute-order-independently, mirroring _prune_dangling_relationships.
The package-root entry (full-path="/") and entries for surviving parts
are left untouched.
"""
text = raw.decode("utf-8", errors="replace")
removed = [0]
def _full_path(tag: str) -> str:
# Same single/double-quote tolerance as _target_attr (#130).
m = re.search(r"\bfull-path\s*=\s*([\"'])(.*?)\1", tag, re.I)
return m.group(2) if m else ""
def _drop(m: re.Match[str]) -> str:
tag = m.group(0)
path = posixpath.normpath(_full_path(tag).lstrip("/"))
if path in ("", "."):
return tag # package root — never pruned
if path in dropped:
removed[0] += 1
return ""
return tag
new = re.sub(r"<manifest:file-entry\b[^>]*/>", _drop, text, flags=re.I)
return new.encode("utf-8"), removed[0]
def _prune_opf_manifest(raw: bytes, opf_name: str, dropped: set[str]) -> tuple[bytes, int]:
"""Remove OPF <item> entries pointing at dropped parts (and spine refs).
The package document lists every content part; a dropped part that is
still referenced makes EPUB readers reject the book. href values are
relative to the package document directory, and <itemref> spine entries
for removed items are pruned too so no idref dangles.
"""
base = posixpath.dirname(opf_name)
text = raw.decode("utf-8", errors="replace")
removed = [0]
removed_ids: set[str] = set()
def _attr(tag: str, name: str) -> str:
m = re.search(rf'\b{name}\s*=\s*"([^"]*)"', tag, re.I)
return m.group(1) if m else ""
def _drop_item(m: re.Match[str]) -> str:
tag = m.group(0)
href = _attr(tag, "href")
if not href:
return tag
resolved = posixpath.normpath(posixpath.join(base, href))
if resolved in dropped:
removed[0] += 1
item_id = _attr(tag, "id")
if item_id:
removed_ids.add(item_id)
return ""
return tag
new = re.sub(r"<item\b[^>]*/>", _drop_item, text, flags=re.I)
if removed_ids:
def _drop_itemref(m: re.Match[str]) -> str:
tag = m.group(0)
if _attr(tag, "idref") in removed_ids:
removed[0] += 1
return ""
return tag
new = re.sub(r"<itemref\b[^>]*/>", _drop_itemref, new, flags=re.I)
return new.encode("utf-8"), removed[0]
def _scrub_ooxml_zip(
data: bytes, fmt: str, *, also_layer_a_text: bool = True
) -> tuple[bytes, list[str]]:
actions: list[str] = []
budget = [0]
layer_removed = 0
layer_replaced = 0
kept: list[tuple[zipfile.ZipInfo, bytes]] = []
with zipfile.ZipFile(io.BytesIO(data)) as zin:
for info in zin.infolist():
_check_zip_budget(info, budget)
name = info.filename
raw = _read_zip_member(zin, info, budget)
# 1. Clean embedded media (PNG, JPEG, WebP, AVIF, HEIC, GIF, BMP, TIFF, SVG)
if re.search(
r"^(?:word|xl|ppt)/media/.+\.(png|jpe?g|webp|avif|heic|gif|bmp|tiff?|svg)$",
name,
re.I,
):
img_fmt = detect_image_format(raw)
sub_actions: list[str] = []
cleaned_bytes = raw
try:
if img_fmt == "png":
cleaned_bytes, sub_actions = strip_png(raw, strip_all_text=True)
elif img_fmt == "jpeg":
cleaned_bytes, sub_actions = strip_jpeg(raw, strip_all_app=True)
elif img_fmt == "webp":
cleaned_bytes, sub_actions = strip_webp(raw, strip_all_metadata=True)
elif img_fmt in ("avif", "heic"):
cleaned_bytes, sub_actions = strip_isobmff(
raw, img_fmt, strip_all_metadata=True
)
elif img_fmt == "gif":
cleaned_bytes, sub_actions = strip_gif(raw, strip_all_metadata=True)
elif img_fmt == "bmp":
cleaned_bytes, sub_actions = strip_bmp(raw, strip_all_metadata=True)
elif img_fmt == "tiff":
cleaned_bytes, sub_actions = strip_tiff(raw, strip_all_metadata=True)
elif name.lower().endswith(".svg") or raw.lstrip().startswith(b"<"):
cleaned_bytes, sub_actions = clean_svg(raw)
except Exception: # noqa: S110 - malformed embedded media; keep original part
pass
if any("drop" in a.lower() for a in sub_actions) and cleaned_bytes != raw:
actions.append(f"clean embedded media in {name} ({', '.join(sub_actions[:2])})")
raw = cleaned_bytes
kept.append((info, raw))
continue
# 2. Drop customXml trees
if name.startswith("customXml/"):
actions.append(f"drop part {name}")
continue
# 3. docProps/ provenance
if name in DOCX_META_PARTS or name.startswith("docProps/"):
if name.endswith("custom.xml"):
actions.append(f"drop part {name}")
continue
text = raw.decode("utf-8", errors="replace")
new = text
for tag, label in DOCX_SCRUB_FIELDS:
open_re = re.compile(rf"<{tag}\b[^>]*>", re.I)
close_re = re.compile(rf"</{tag}>", re.I)
out = []
last = 0
n = 0
for os_, oe, cs_, ce in _iter_tag_blocks(new, open_re, close_re):
out.append(new[last:os_])
out.append(new[os_:oe] + new[cs_:ce])
last = ce
if new[oe:cs_]:
n += 1
actions.append(f"scrub {name} field {label}")
if n:
out.append(new[last:])
new = "".join(out)
raw = new.encode("utf-8")
# 4. [Content_Types].xml overrides
if name == "[Content_Types].xml":
text = raw.decode("utf-8", errors="replace")
new, n = re.subn(
r'<Override\b[^>]*PartName="/customXml/[^"]*"[^>]*/>',
"",
text,
)
if n:
actions.append(f"drop Content_Types customXml overrides x{n}")
raw = new.encode("utf-8")
new, n = re.subn(
r'<Override\b[^>]*PartName="/docProps/custom\.xml"[^>]*/>',
"",
raw.decode("utf-8", errors="replace"),
)
if n:
actions.append(f"drop Content_Types custom.xml override x{n}")
raw = new.encode("utf-8")
# 5. Layer A text runs
if also_layer_a_text and name.endswith(".xml"):
if fmt == "docx" and name.startswith("word/"):
text = raw.decode("utf-8", errors="replace")
new, r, rp = _scrub_docx_text(text)
if r or rp:
layer_removed += r
layer_replaced += rp
raw = new.encode("utf-8")
elif fmt == "xlsx" and name.startswith("xl/"):
text = raw.decode("utf-8", errors="replace")
new, r, rp = _scrub_xlsx_text(text)
if r or rp:
layer_removed += r
layer_replaced += rp
raw = new.encode("utf-8")
elif fmt == "pptx" and name.startswith("ppt/"):
text = raw.decode("utf-8", errors="replace")
new, r, rp = _scrub_pptx_text(text)
if r or rp:
layer_removed += r
layer_replaced += rp
raw = new.encode("utf-8")
kept.append((info, raw))
kept_names = {info.filename for info, _ in kept}
final: list[tuple[zipfile.ZipInfo, bytes]] = []
for info, raw in kept:
part_raw = raw
if info.filename.endswith(".rels"):
part_raw, n = _prune_dangling_relationships(info.filename, raw, kept_names)
if n:
actions.append(f"prune dangling relationships x{n} in {info.filename}")
final.append((info, part_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}")
if not actions:
actions.append(f"no {fmt.upper()} metadata parts removed")
return out_buf.getvalue(), actions
def clean_docx(data: bytes, *, also_layer_a_text: bool = True) -> tuple[bytes, list[str]]:
return _scrub_ooxml_zip(data, "docx", also_layer_a_text=also_layer_a_text)
def clean_xlsx(data: bytes, *, also_layer_a_text: bool = True) -> tuple[bytes, list[str]]:
return _scrub_ooxml_zip(data, "xlsx", also_layer_a_text=also_layer_a_text)
def clean_pptx(data: bytes, *, also_layer_a_text: bool = True) -> tuple[bytes, list[str]]:
return _scrub_ooxml_zip(data, "pptx", also_layer_a_text=also_layer_a_text)
def inspect_odt(data: bytes) -> tuple[bool, bool, list[str], dict]:
findings: list[str] = []
has_c2pa = False
has_ai = False
budget = [0]
try:
with zipfile.ZipFile(io.BytesIO(data)) as zf:
for info in zf.infolist():
_check_zip_budget(info, budget)
raw = _read_zip_member(zf, info, budget)
c2, ai, hits = _blob_hits(raw)
if c2 or ai:
if c2:
has_c2pa = True
if ai:
has_ai = True
findings.append(f"{info.filename}: {', '.join(hits[:6])}")
if "meta.xml" in zf.namelist():
meta = _read_zip_member(zf, zf.getinfo("meta.xml"), budget).decode(
"utf-8", errors="replace"
)
if re.search(r"generator|claude|openai|anthropic|gemini", meta, re.I):
has_ai = True
findings.append("meta.xml generator-like fields")
except _ZIP_PARSE_ERRORS as exc:
if findings:
findings.append(
f"partial read of ODT zip ({exc.__class__.__name__}); "
"evidence above survives, later members were not scanned"
)
return has_c2pa, has_ai or has_c2pa, findings, {}
return False, False, ["not a valid ODT zip"], {}
return has_c2pa, has_ai or has_c2pa, findings, {}
def clean_odt(data: bytes, *, also_layer_a_text: bool = True) -> tuple[bytes, list[str]]:
actions: list[str] = []
budget = [0]
layer_removed = 0
layer_replaced = 0
kept: list[tuple[zipfile.ZipInfo, bytes]] = []
dropped: set[str] = set()
with zipfile.ZipFile(io.BytesIO(data)) as zin:
for info in zin.infolist():
_check_zip_budget(info, budget)
name = info.filename
raw = _read_zip_member(zin, info, budget)
if name == "meta.xml":
text = raw.decode("utf-8", errors="replace")
# Drop meta:generator blocks (linear scan - lazy .*? is
# quadratic on unclosed tags, see _iter_tag_blocks)
new, n = _drop_tag_blocks(text, _ODT_GENERATOR_OPEN_RE, _ODT_GENERATOR_CLOSE_RE)
if n:
actions.append("drop meta:generator")
text = new
# scrub creator-like if AI (linear scan)
def _is_ai_creator(block: str) -> bool:
return bool(AI_META_NAME_RE.search(block))
new, n = _drop_blocks_if(
text, _DC_CREATOR_OPEN_RE, _DC_CREATOR_CLOSE_RE, _is_ai_creator
)
if n:
actions.extend(["scrub creator-like meta"] * n)
text = new
raw = text.encode("utf-8")
else:
c2, ai, _ = _blob_hits(raw)
if (c2 or ai) and name not in (
"content.xml",
"styles.xml",
"mimetype",
"META-INF/manifest.xml",
):
actions.append(f"drop part {name} (AI/C2PA markers)")
dropped.add(name)
continue
# Layer A over the visible paragraph text of the body part.
if also_layer_a_text and name == "content.xml":
text = raw.decode("utf-8", errors="replace")
new, r, rp = _scrub_odt_text(text)
if r or rp:
layer_removed += r
layer_replaced += rp
raw = new.encode("utf-8")
kept.append((info, raw))
# Two-pass: rewrite META-INF/manifest.xml now that the dropped set is
# known. The manifest precedes the dropped parts in the archive, so a
# single pass cannot remove entries for parts dropped later in the loop.
if dropped:
rewritten: list[tuple[zipfile.ZipInfo, bytes]] = []
for info, part_raw in kept:
out_raw = part_raw
if info.filename == "META-INF/manifest.xml":
pruned, n = _prune_odt_manifest_entries(part_raw, dropped)
if n:
actions.append(f"drop manifest entries x{n}")
out_raw = pruned
rewritten.append((info, out_raw))
kept = rewritten
out_buf = io.BytesIO()
with zipfile.ZipFile(out_buf, "w", compression=zipfile.ZIP_DEFLATED) as zout:
for info, raw in kept:
zout.writestr(info, raw)
if layer_removed or layer_replaced:
actions.append(f"layer A text: removed={layer_removed} replaced={layer_replaced}")
if not actions:
actions.append("no ODT metadata removed")
return out_buf.getvalue(), actions
# ---------------------------------------------------------------------------
# EPUB
# ---------------------------------------------------------------------------
_EPUB_MEDIA_RE = re.compile(r"\.(png|jpe?g|webp|avif|heic|gif|bmp|tiff?|svg)$", re.I)
def _epub_content_part(name: str) -> bool:
"""True for parts that carry visible content or structure and must never be dropped.
XHTML/HTML, CSS, JS, navigation (ncx), the package document (opf), raster
and vector media, fonts, the 'mimetype' part, relationship files, and the
OCF control files are content or structure. Everything else (META-INF
custom parts, stray XML at the archive root) is a candidate for dropping
when it carries AI/C2PA markers.
"""
low = name.lower()
return bool(
low == "mimetype"
or low.endswith(".rels")
or low
in (
"meta-inf/container.xml",
"meta-inf/encryption.xml",
"meta-inf/signatures.xml",
"meta-inf/rights.xml",
)
or re.search(
r"\.(xhtml|html?|css|js|ncx|opf|svg|png|jpe?g|webp|avif|heic|gif|bmp|tiff?|ttf|otf|woff2?)$",
low,
)
)
def _epub_encrypted_parts(data: bytes) -> set[str]:
"""Return part names listed as encrypted in META-INF/encryption.xml (OCF)."""
try:
with zipfile.ZipFile(io.BytesIO(data)) as zf:
if "META-INF/encryption.xml" not in zf.namelist():
return set()
budget: list[int] = [0]
xml = _read_zip_member(zf, zf.getinfo("META-INF/encryption.xml"), budget).decode(
"utf-8", errors="replace"
)
except (zipfile.BadZipFile, KeyError):
return set()
names: set[str] = set()
for uri in re.findall(r'CipherReference\s+URI="([^"]+)"', xml, re.I):
# The URI is relative to META-INF/ per OCF, but producers are not
# consistent, so accept every plausible normalization.
names.add(posixpath.normpath(posixpath.join("META-INF", uri)))
names.add(posixpath.normpath(uri))
if uri.startswith("../"):
names.add(posixpath.normpath(uri[3:]))
return names
def inspect_epub(data: bytes) -> tuple[bool, bool, list[str], dict]:
findings: list[str] = []
has_c2pa = False
has_ai = False
budget = [0]
encrypted = _epub_encrypted_parts(data)
names: list[str] = []
try:
with zipfile.ZipFile(io.BytesIO(data)) as zf:
names = zf.namelist()
for info in zf.infolist():
_check_zip_budget(info, budget)
name = info.filename
if name in encrypted:
findings.append(f"{name}: encrypted content (skipped)")
continue
raw = _read_zip_member(zf, info, budget)
if name.lower().endswith((".xhtml", ".html", ".htm")):
text = raw.decode("utf-8", errors="surrogateescape")
c2, ai, sub, _ = inspect_html(text)
if c2:
has_c2pa = True
if ai:
has_ai = True
for f in sub:
findings.append(f"{name}: {f}")
continue
if name.lower().endswith(".opf"):
text = raw.decode("utf-8", errors="surrogateescape")
if AI_META_NAME_RE.search(text):
has_ai = True
findings.append(f"{name}: AI-ish metadata in package document")
c2, ai, hits = _blob_hits(raw)
if c2 or ai:
has_c2pa = has_c2pa or c2
has_ai = has_ai or ai
findings.append(f"{name}: {', '.join(hits[:6])}")
continue
c2, ai, hits = _blob_hits(raw)
if c2 or ai:
has_c2pa = has_c2pa or c2
has_ai = has_ai or ai
findings.append(f"{name}: {', '.join(hits[:6])}")
except _ZIP_PARSE_ERRORS as exc:
# EPUB previously caught only BadZipFile, so a zlib error propagated
# (crashing the scan) while a CRC failure discarded everything —
# neither is right. Same partial-read contract as the OOXML/ODT
# inspectors (#164).
if findings:
findings.append(
f"partial read of EPUB zip ({exc.__class__.__name__}); "
"evidence above survives, later members were not scanned"
)
return has_c2pa, has_ai or has_c2pa, findings, {"parts": len(names)}
return False, False, ["not a valid EPUB zip"], {}
return has_c2pa, has_ai or has_c2pa, findings, {"parts": len(names)}
# Open/close tag halves for the linear OPF scans in _scrub_epub_opf.
_OPF_META_OPEN_RE = re.compile(r"<meta\b[^>]*>", re.I)
_OPF_META_CLOSE_RE = re.compile(r"</meta\s*>", re.I)
_DC_FIELDS_OPEN_RE = re.compile(
r"<(dc:(?:creator|contributor|publisher|description|rights|source))\b[^>]*>",
re.I,
)
_DC_FIELDS_CLOSE_RE = re.compile(
r"</(dc:(?:creator|contributor|publisher|description|rights|source))\s*>",
re.I,
)
def _scrub_epub_opf(text: str) -> tuple[str, list[str]]:
"""Scrub AI-ish metadata from the EPUB package document (OPF)."""
actions: list[str] = []
def _meta(m: re.Match[str]) -> str:
tag = m.group(0)
if AI_META_NAME_RE.search(tag):
actions.append("drop OPF meta tag")
return ""
return tag
new = re.sub(r"<meta\b[^>]*/>", _meta, text, flags=re.I)
# Block-form <meta>...</meta> (linear scan; the lazy .*? form is
# quadratic on unclosed opening tags, see _iter_tag_blocks)
def _meta_block_is_ai(block: str) -> bool:
if AI_META_NAME_RE.search(block):
actions.append("drop OPF meta tag")
return True
return False
# The predicate itself records the per-block action, so the count is unused.
new, _n = _drop_blocks_if(new, _OPF_META_OPEN_RE, _OPF_META_CLOSE_RE, _meta_block_is_ai)
# dc:... provenance fields. The original pattern paired each opening tag
# with its backreferenced closing tag via a lazy .*? - quadratic on
# unclosed openings. Pair opens with same-name closes directly: linear,
# exact same match semantics (first same-name close at/after the open).
out = []
last = 0
dc_closes: dict[str, list[re.Match[str]]] = {}
for m in re.finditer(_DC_FIELDS_CLOSE_RE, new):
dc_closes.setdefault(m.group(1), []).append(m)
ptr: dict[str, int] = {name: 0 for name in dc_closes}
for om in re.finditer(_DC_FIELDS_OPEN_RE, new):
name = om.group(1)
if om.start() < last:
continue
closes = dc_closes.get(name, ())
i = ptr.get(name, 0)
while i < len(closes) and closes[i].start() < om.end():
i += 1
ptr[name] = i
if i >= len(closes):
continue # no same-name close - block never matches (kept)
cm = closes[i]
block = new[om.start() : cm.end()]
if AI_META_NAME_RE.search(block):
out.append(new[last : om.start()])
out.append(f"<{name}/>")
last = cm.end()
actions.append(f"scrub {name} (AI vendor name)")
if last:
out.append(new[last:])
new = "".join(out)
if not actions:
actions.append("no OPF metadata removed")
return new, actions
def clean_epub(data: bytes, *, also_layer_a_text: bool = True) -> tuple[bytes, list[str]]:
"""Rewrite the EPUB: scrub OPF metadata, XHTML meta/JSON-LD, and Layer A.
Embedded raster/SVG media get their own metadata stripped; structural and
OCF control parts (mimetype, container.xml, encryption.xml, relationships,
fonts) are kept so the book stays readable. Parts listed in
META-INF/encryption.xml are copied verbatim — their bytes are ciphertext,
so any rewrite would corrupt them. Non-content parts carrying AI/C2PA
markers are dropped, and the OPF manifest is pruned afterwards so no
dropped part stays referenced.
"""
from text_unicode import clean_text # local import to avoid cycles
actions: list[str] = []
budget = [0]
layer_removed = 0
layer_replaced = 0
encrypted = _epub_encrypted_parts(data)
kept: list[tuple[zipfile.ZipInfo, bytes]] = []
dropped: set[str] = set()
with zipfile.ZipFile(io.BytesIO(data)) as zin:
for info in zin.infolist():
_check_zip_budget(info, budget)
name = info.filename
raw = _read_zip_member(zin, info, budget)
low = name.lower()
# Encrypted parts are opaque ciphertext: pass through untouched.
if name in encrypted:
kept.append((info, raw))
continue
# 1. Embedded raster / vector media: strip metadata
if _EPUB_MEDIA_RE.search(low):
img_fmt = detect_image_format(raw)
sub_actions: list[str] = []
cleaned = raw
try:
if img_fmt == "png":
cleaned, sub_actions = strip_png(raw, strip_all_text=True)
elif img_fmt == "jpeg":
cleaned, sub_actions = strip_jpeg(raw, strip_all_app=True)
elif img_fmt == "webp":
cleaned, sub_actions = strip_webp(raw, strip_all_metadata=True)
elif img_fmt in ("avif", "heic"):
cleaned, sub_actions = strip_isobmff(raw, img_fmt, strip_all_metadata=True)
elif img_fmt == "gif":
cleaned, sub_actions = strip_gif(raw, strip_all_metadata=True)
elif img_fmt == "bmp":
cleaned, sub_actions = strip_bmp(raw, strip_all_metadata=True)
elif img_fmt == "tiff":
cleaned, sub_actions = strip_tiff(raw, strip_all_metadata=True)
elif low.endswith(".svg") or raw.lstrip().startswith(b"<"):
cleaned, sub_actions = clean_svg(raw)
except Exception: # noqa: S110 - malformed embedded media; keep original
pass
if any("drop" in a.lower() for a in sub_actions) and cleaned != raw:
actions.append(f"clean embedded media in {name} ({', '.join(sub_actions[:2])})")
raw = cleaned
kept.append((info, raw))
continue
# 2. XHTML content: strip AI meta/JSON-LD, then Layer A
if low.endswith((".xhtml", ".html", ".htm")):
text = raw.decode("utf-8", errors="surrogateescape")
text, sub_actions = clean_html(text)
if sub_actions and sub_actions != ["no HTML AI meta removed"]:
actions.append(f"{name}: {', '.join(sub_actions[:2])}")
if also_layer_a_text:
text2, stats = clean_text(text)
if stats["removed_count"] or stats["replaced_count"]:
layer_removed += stats["removed_count"]
layer_replaced += stats["replaced_count"]
text = text2
raw = text.encode("utf-8", errors="surrogateescape")
kept.append((info, raw))
continue
# 3. Package document (OPF): scrub AI-ish metadata
if low.endswith(".opf"):
text = raw.decode("utf-8", errors="surrogateescape")
new_text, sub_actions = _scrub_epub_opf(text)
if sub_actions and sub_actions != ["no OPF metadata removed"]:
actions.extend(f"{name}: {a}" for a in sub_actions)
raw = new_text.encode("utf-8", errors="surrogateescape")
kept.append((info, raw))
continue
# 4. Other parts: drop non-content parts carrying AI/C2PA markers
c2, ai, _hits = _blob_hits(raw)
if (c2 or ai) and not _epub_content_part(name):
actions.append(f"drop part {name} (AI/C2PA markers)")
dropped.add(name)
continue
# 5. mimetype must stay first and stored — it already is, since we
# write in the original entry order; force stored compression.
if low == "mimetype":
info.compress_type = zipfile.ZIP_STORED
kept.append((info, raw))
# Two-pass: prune the OPF manifest now that the dropped set is known.
if dropped:
rewritten: list[tuple[zipfile.ZipInfo, bytes]] = []
for info, part_raw in kept:
out_raw = part_raw
if info.filename.lower().endswith(".opf"):
pruned, n = _prune_opf_manifest(part_raw, info.filename, dropped)
if n:
actions.append(f"prune OPF manifest entries x{n}")
out_raw = pruned
rewritten.append((info, out_raw))
kept = rewritten
out_buf = io.BytesIO()
with zipfile.ZipFile(out_buf, "w", compression=zipfile.ZIP_DEFLATED) as zout:
for info, raw in kept:
zout.writestr(info, raw)
if layer_removed or layer_replaced:
actions.append(f"layer A text: removed={layer_removed} replaced={layer_replaced}")
if not actions:
actions.append("no EPUB metadata removed")
return out_buf.getvalue(), actions
# ---------------------------------------------------------------------------
# PDF
# ---------------------------------------------------------------------------
# Open/close halves for the linear XMP packet scan below. The lazy
# ".*?</\?xpacket end..." form was quadratic on a flood of "<?xpacket begin"
# markers with no end marker.
_XMP_PACKET_OPEN_RE = re.compile(rb"<\?xpacket begin", re.I)
_XMP_PACKET_CLOSE_RE = re.compile(rb"<\?xpacket end[^?]*\?>", re.I)
_PDF_STREAM_OPEN_RE = re.compile(rb"stream\r?\n")
_PDF_STREAM_CLOSE_RE = re.compile(rb"endstream")
def _pdf_structured_blob(data: bytes) -> bytes:
"""Return PDF bytes with stream payloads removed, plus XMP packets.
Stream payloads are often compressed binary where an AI-marker byte
sequence (e.g. "AIGC") can occur by chance. Scanning only dictionaries and
XMP packets avoids treating those collisions as metadata findings.
"""
parts = []
last = 0
for os_, _oe, _cs, ce in _iter_tag_blocks(data, _PDF_STREAM_OPEN_RE, _PDF_STREAM_CLOSE_RE):
parts.append(data[last:os_])
parts.append(b"stream endstream")
last = ce
parts.append(data[last:])
no_streams = b"".join(parts)
xmp = b"\n".join(
data[os_:ce]
for os_, _oe, _cs, ce in _iter_tag_blocks(data, _XMP_PACKET_OPEN_RE, _XMP_PACKET_CLOSE_RE)
)
return no_streams + b"\n" + xmp
def inspect_pdf(path: Path, data: bytes) -> tuple[bool, bool, list[str], dict]:
findings: list[str] = []
has_c2pa, has_ai, hits = _blob_hits(_pdf_structured_blob(data))
findings.extend(f"pdf-structured:{h}" for h in hits)
# XMP packet scan
xmp_blob = b"\n".join(
data[os_:ce]
for os_, _oe, _cs, ce in _iter_tag_blocks(data, _XMP_PACKET_OPEN_RE, _XMP_PACKET_CLOSE_RE)
)
if xmp_blob:
findings.append("XMP packet present")
has_ai = has_ai or bool(
re.search(
rb"digitalSourceType|trainedAlgorithmicMedia|SoftwareAgent|c2pa",
xmp_blob,
re.I,
)
)
tools = run_optional_tools(path)
ct = tools.get("c2patool") or {}
if ct.get("has_manifest"):
has_c2pa = True
findings.append("c2patool reports C2PA-related manifest")
probe_note = c2patool_probe_note(tools)
if probe_note:
findings.append(probe_note)
return has_c2pa, has_ai or has_c2pa, findings, {"tools": tools}
def _pdf_structural_rewrite(dest: Path, actions: list[str]) -> bool:
"""Rebuild a PDF so unreferenced objects are dropped.
exiftool's PDF edits are incremental, so freed metadata objects survive in
the byte stream. qpdf re-serializes from the object graph, which is what
actually removes them. No-op (with a warning) when qpdf is absent.
"""
qpdf = which("qpdf")
if not qpdf:
actions.append(
"warning: exiftool PDF edits are incremental — the original metadata "
"bytes remain recoverable; install qpdf for a structural rewrite"
)
return False
tmp = dest.with_name(dest.name + ".qpdf-tmp")
try:
r = subprocess.run(
[qpdf, "--linearize", "--", safe_arg(str(dest)), safe_arg(str(tmp))],
capture_output=True,
text=True,
timeout=120,
check=False,
preexec_fn=subprocess_preexec_fn,
)
except Exception as e:
tmp.unlink(missing_ok=True)
actions.append(f"qpdf rewrite failed: {e}; metadata bytes may remain recoverable")
return False
# qpdf exit codes: 0 = clean, 3 = succeeded with warnings (output written).
if r.returncode in (0, 3) and tmp.is_file() and tmp.stat().st_size > 0:
tmp.replace(dest)
actions.append(f"qpdf --linearize structural rewrite (rc={r.returncode})")
return True
tmp.unlink(missing_ok=True)
actions.append(
f"qpdf rewrite skipped (rc={r.returncode}); metadata bytes may remain recoverable"
)
return False
def clean_pdf(path: Path, dest: Path) -> tuple[list[str], dict]:
"""Best-effort PDF clean. Prefers exiftool; falls back to XMP strip warning."""
actions: list[str] = []
data = path.read_bytes()
dest.parent.mkdir(parents=True, exist_ok=True)
exiftool = which("exiftool")
if exiftool:
safe_write_bytes(dest, data)
try:
r = subprocess.run(
[
exiftool,
"-all=",
"-overwrite_original",
safe_arg(str(dest)),
],
capture_output=True,
text=True,
timeout=60,
check=False,
preexec_fn=subprocess_preexec_fn,
)
actions.append(f"exiftool -all= (rc={r.returncode})")
except Exception as e:
actions.append(f"exiftool failed: {e}")
# exiftool writes PDFs *incrementally*: it appends a
# %BeginExifToolUpdate block that frees the Info object and drops
# /Info from the trailer, but the original metadata bytes stay in the
# file verbatim and are trivially recoverable (exiftool itself can
# revert them with -PDF-update:all=). A structural rewrite is what
# actually drops the now-unreferenced objects.
rewritten = _pdf_structural_rewrite(dest, actions)
c2patool = which("c2patool")
# c2patool does not always strip; leave note
if c2patool:
actions.append("c2patool available for inspect; strip via exiftool/re-export")
return actions, {"mode": "exiftool", "structural_rewrite": rewritten}
# Degraded: strip obvious XMP packets between <?xpacket begin and end
# (linear scan - the lazy .*? form is quadratic on unclosed begin markers)
text = data
new, n = _drop_tag_blocks(text, _XMP_PACKET_OPEN_RE, _XMP_PACKET_CLOSE_RE)
if n:
actions.append(f"stripped XMP xpacket x{n} (degraded; may leave offsets broken)")
# PDF structural risk: document degraded mode clearly
safe_write_bytes(dest, new)
actions.append("warning: pure-stdlib PDF strip is best-effort; prefer exiftool")
return actions, {"mode": "stdlib-xmp", "degraded": True}
safe_write_bytes(dest, data)
actions.append(
"no PDF cleaner available (install exiftool for reliable metadata strip); copied as-is"
)
return actions, {"mode": "copy", "degraded": True}
# ---------------------------------------------------------------------------
# Unified API
# ---------------------------------------------------------------------------
def inspect_container(path: Path) -> ContainerInspectReport:
data = path.read_bytes()
fmt = detect_container_format(path, data)
tools: dict[str, Any] = {}
details: dict[str, Any] = {}
if fmt == "svg":
has_c2pa, has_ai, findings, details = inspect_svg(data)
elif fmt == "pdf":
has_c2pa, has_ai, findings, details = inspect_pdf(path, data)
tools = details.pop("tools", {})
elif fmt == "docx":
has_c2pa, has_ai, findings, details = inspect_docx(data)
elif fmt == "xlsx":
has_c2pa, has_ai, findings, details = inspect_xlsx(data)
elif fmt == "pptx":
has_c2pa, has_ai, findings, details = inspect_pptx(data)
elif fmt == "odt":
has_c2pa, has_ai, findings, details = inspect_odt(data)
elif fmt == "epub":
has_c2pa, has_ai, findings, details = inspect_epub(data)
elif fmt == "html":
# surrogateescape, not replace: clean_container() decodes the same way,
# and U+FFFD substitutions would make the two disagree on the counts.
body = data.decode("utf-8", errors="surrogateescape")
has_c2pa, has_ai, findings, details = inspect_html(body)
elif fmt == "markdown":
body = data.decode("utf-8", errors="surrogateescape")
has_c2pa, has_ai, findings, details = inspect_markdown(body)
else:
has_c2pa, has_ai, findings = False, False, [f"unsupported container: {fmt}"]
details = {"unsupported": True}
# Layer A body scan for exactly the formats clean_container() scrubs, so
# inspect predicts clean rather than contradicting it.
layer_a_total = 0
layer_a_hits: list[dict] = []
if fmt in ("markdown", "html"):
from text_unicode import inspect_text # local import to avoid cycles
ta = inspect_text(body).to_dict()
layer_a_total = ta["suspicious_total"]
layer_a_hits = ta["hits"]
for h in layer_a_hits:
findings.append(f"layer-a: {h['codepoint']} {h['label']} x{h['count']} ({h['kind']})")
elif fmt == "epub":
from text_unicode import inspect_text # local import to avoid cycles
encrypted = _epub_encrypted_parts(data)
budget: list[int] = [0]
try:
with zipfile.ZipFile(io.BytesIO(data)) as zf:
for info in zf.infolist():
if info.filename in encrypted:
continue
if info.filename.lower().endswith((".xhtml", ".html", ".htm")):
ta = inspect_text(
_read_zip_member(zf, info, budget).decode(
"utf-8", errors="surrogateescape"
)
).to_dict()
layer_a_total += ta["suspicious_total"]
for h in ta["hits"]:
layer_a_hits.append(h)
findings.append(
f"layer-a ({info.filename}): {h['codepoint']} {h['label']} "
f"x{h['count']} ({h['kind']})"
)
except zipfile.BadZipFile:
pass
notes: list[str] = []
if fmt == "pdf":
notes.append(
"PDF inspection is best-effort; exiftool/c2patool give more reliable metadata detection"
)
elif fmt in ("docx", "xlsx", "pptx"):
notes.append(f"{fmt.upper()}: metadata/provenance and embedded media are scanned")
elif fmt == "epub":
notes.append(
"EPUB: package-document metadata, XHTML meta/JSON-LD, and embedded media are scanned"
)
if "unsupported" in details:
notes.append(f"format not fully inspected: {fmt}")
if layer_a_total:
notes.append(
f"layer A: {layer_a_total} invisible/format codepoint(s) in body text; "
"clean removes these"
)
if fmt in ("svg", "pdf", "docx", "xlsx", "pptx") and not tools:
tools = run_optional_tools(path)
return ContainerInspectReport(
path=str(path),
format=fmt,
has_c2pa=has_c2pa,
has_ai_metadata=has_ai,
findings=findings,
tools=tools,
details=details,
notes=notes,
layer_a_total=layer_a_total,
layer_a_hits=layer_a_hits,
)
def clean_container(
path: Path,
dest: Path,
fmt: str | None = None,
*,
also_layer_a_text: bool = True,
) -> dict[str, Any]:
"""Clean container metadata; optionally Layer-A scrub text bodies for md/html.
``fmt`` pins the container format when the caller already knows it. This
matters for ``--in-place`` flows where *path* is a ``.bak`` copy whose
suffix would otherwise make markdown/HTML (which have no magic bytes)
classify as ``unknown``.
"""
from text_unicode import clean_text # local import to avoid cycles
data = path.read_bytes()
fmt = fmt or detect_container_format(path, data)
actions: list[str] = []
dest.parent.mkdir(parents=True, exist_ok=True)
meta: dict[str, Any] = {"format": fmt}
if fmt == "svg":
cleaned, actions = clean_svg(data)
safe_write_bytes(dest, cleaned)
elif fmt == "pdf":
actions, meta_extra = clean_pdf(path, dest)
meta.update(meta_extra)
elif fmt == "docx":
cleaned, actions = clean_docx(data, also_layer_a_text=also_layer_a_text)
safe_write_bytes(dest, cleaned)
elif fmt == "xlsx":
cleaned, actions = clean_xlsx(data, also_layer_a_text=also_layer_a_text)
safe_write_bytes(dest, cleaned)
elif fmt == "pptx":
cleaned, actions = clean_pptx(data, also_layer_a_text=also_layer_a_text)
safe_write_bytes(dest, cleaned)
elif fmt == "odt":
cleaned, actions = clean_odt(data, also_layer_a_text=also_layer_a_text)
safe_write_bytes(dest, cleaned)
elif fmt == "epub":
cleaned, actions = clean_epub(data, also_layer_a_text=also_layer_a_text)
safe_write_bytes(dest, cleaned)
elif fmt == "html":
text = data.decode("utf-8", errors="surrogateescape")
text, actions = clean_html(text)
if also_layer_a_text:
text2, stats = clean_text(text)
if stats["removed_count"] or stats["replaced_count"]:
actions.append(
f"layer A text: removed={stats['removed_count']} replaced={stats['replaced_count']}"
)
text = text2
safe_write_text(dest, text)
elif fmt == "markdown":
text = data.decode("utf-8", errors="surrogateescape")
text, actions = clean_markdown(text)
if also_layer_a_text:
text2, stats = clean_text(text)
if stats["removed_count"] or stats["replaced_count"]:
actions.append(
f"layer A text: removed={stats['removed_count']} replaced={stats['replaced_count']}"
)
text = text2
safe_write_text(dest, text)
else:
raise ValueError(f"unsupported container format: {fmt}")
after = inspect_container(dest)
return {
"input": str(path),
"output": str(dest),
"format": fmt,
"actions": actions,
"bytes_in": len(data),
"bytes_out": dest.stat().st_size,
"still_has_c2pa": after.has_c2pa,
"still_has_ai_metadata": after.has_ai_metadata,
"post_findings": after.findings,
"meta": meta,
}