Read WebP metadata past the scan window and surface C2PA reader failures

Three gaps found while measuring the record path against the file path, each one
a signal the library could not see:

WebP stores `XMP ` after the pixels, so on any WebP above the scan window a fixed
read stops short of the label. `_riff_late_metadata` steps over the coded image to
reach it, the RIFF analogue of the existing PNG and ISOBMFF readers. Three corpus
files hid an IPTC "Made with AI" tag and a C2PA `trainedAlgorithmicMedia` there.
The decoder-backed fallback now covers only what it is actually for -- metadata the
raw bytes do not spell, such as a compressed PNG `zTXt` packet.

A C2PA reader failure returned the same `None` as a file with no manifest, so a
verdict could fall back to the raw byte scan with no trace anywhere. Failures now
log at warning and only genuine ones do: a file without credentials never reaches
that branch, and an unsupported container is demoted to debug through the reader's
own `C2paError.NotSupported`. The first corpus run with it found a truncated PNG.

`scan_dataset.py` never registered the pillow-heif opener it declares as a
dependency, so every HEIC was scanned as unreadable -- no EXIF, and a pixel layer
that was 397 of 406 features NaN instead of 136.

`_riff_late_metadata` caps its total like `isobmff.scan_c2pa_region` does. Clamping
each chunk to the bytes remaining is not enough on its own: one chunk can declare a
length spanning most of the file, and this runs on the memoized verdict path over
images from arbitrary sources.

Also lands `identify_metadata_record` and `ProvenanceReport.to_dict()`, the
one-call entry point and the versioned JSON contract for the record path.

Record-vs-file equality holds over 3,478 corpus images, and the eight files these
fixes recovered still report AI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Victor Kuznetsov
2026-08-05 21:10:38 -07:00
co-authored by Claude Opus 5
parent 0c5961a0ed
commit 2668f1302d
14 changed files with 300 additions and 55 deletions
+3 -5
View File
@@ -81,8 +81,7 @@ rules follow, and both were broken in practice before they were written down:
builder; a mask path that re-runs its own sweep is how the two drift apart.
The C2PA manifest-store JSON is NOT stable across reads: the reader regenerates manifest
URNs and instance ids, so two reads of one unchanged file matched in 54 of 120 measured
cases. Compare the derived `c2pa_info`, never the raw store.
URNs and instance ids. Compare the derived `c2pa_info`, never the raw store.
A third seam reaches the same verdict: `collect_metadata_record` ->
`evidence_from_metadata_record` -> `identify_from_evidence`, the path a caller uses when
@@ -90,9 +89,8 @@ collection and verdict run on different machines. Its contract is equality with
`identify(path, check_visible=False, check_invisible=False)` on the same image, and it
can break from EITHER side -- a region the collector stops walking, or a placement the
file path learns to read and the record does not. `tests/test_metadata_record.py` pins
it over the tracked fixtures; the corpus comparison is what actually finds the gaps
(three real ones so far, recorded in `docs/module-internals.md`). Change either side and
re-run both.
it over the tracked fixtures; a separate local evaluation corpus catches placements the
fixtures do not cover. Change either side and re-run both.
Before changing anything in the detection path, record the detectors' exact verdicts
over a local sample first and diff them after. A refactor here is only correct if that
+18 -10
View File
@@ -340,7 +340,16 @@ metadata scanners and `remove_ai_metadata`.
Key contracts:
- `scan_head` is the shared cached input for bounded byte scans.
- `scan_head` is the shared cached input for bounded byte scans. It fills the buffer
in two layers. Structural readers first, one per container, each seeking past the
pixel payload to reach metadata placed beyond the window: `isobmff.scan_c2pa_region`,
`_png_late_metadata`, `_riff_late_metadata`. A decoder-backed fallback last,
`_decoder_visible_text`, for metadata the raw bytes do not spell at all — a
zlib-compressed PNG `zTXt` packet is readable only after inflation. The layers are
ordered that way because the structural readers work on files no decoder can open.
- A C2PA reader failure is logged at warning, not debug. It returns the same `None` as
a file with no manifest, so nothing downstream can distinguish "no credentials" from
"the credentials could not be read", and the second silently downgrades a verdict.
- JPEG stripping walks metadata segments and preserves the entropy-coded image
scan.
- ISOBMFF containers use
@@ -395,21 +404,20 @@ defects found while establishing that equality are the reason each rule exists:
- It walks the file's RAW head, never the `scan_head` buffer. That buffer is the
head concatenated with late metadata payloads, so a structural walk runs off the
end of the real head and parses appended bytes as chunks — which produced 11 MB
records and a phantom AIGC signal.
end of the real head and parses appended bytes as chunks, inflating the record and
creating false signals.
- Samsung Galaxy AI splits its evidence: the `PhotoEditor_Re_Edit_Data` marker sits
in the post-EOI trailer while the `genAIType` value it is gated on can sit inside
the entropy-coded scan (measured on one file: marker at 580 619, value at
382 953). A marked file therefore keeps the whole tail window, not just the
trailer.
the entropy-coded scan. A marked file therefore keeps the whole tail window, not
just the trailer.
- PIL's info keys are emitted in the file path's own candidate order
(`Software`, `Source`, `Title`, `Description`, then EXIF). `generator_from_metadata`
returns the FIRST candidate carrying a known token, so dict order alone changed
the reported platform on nine NovelAI files.
returns the FIRST candidate carrying a known token, so preserving candidate order
is part of verdict equivalence.
Pixel forensics are deliberately absent: nothing in the provenance path reads them.
Verified over 3,609 research-scan records — dropping every pixel section changed no
verdict.
Verdict equivalence is checked over tracked fixtures and a separate local evaluation
corpus.
The DWT-DCT detector and the visible-mark stage share a single decode of the
source, held by
+10 -10
View File
@@ -191,29 +191,29 @@ built from on another machine, in another process, or later.
```python
import json
from remove_ai_watermarks.identify import evidence_from_metadata_record, identify_from_evidence
from remove_ai_watermarks.identify import identify_metadata_record
from remove_ai_watermarks.metadata_record import collect_metadata_record
record = collect_metadata_record(Path("input.png")) # reads the file
blob = json.dumps(record) # ship it anywhere
evidence = evidence_from_metadata_record(json.loads(blob), path=Path("input.png"))
report = identify_from_evidence(evidence) # reads nothing
report = identify_metadata_record(json.loads(blob), path=Path("input.png")) # reads nothing
payload = report.to_dict() # versioned JSON contract
```
The verdict is the same one `identify(path, check_visible=False,
check_invisible=False)` returns for that file. That equality is the record's whole
contract, and it is verified two ways: over the tracked provenance fixtures in
`tests/test_metadata_record.py`, and over a local corpus, where 3,478 images
(every file carrying a rare signal, plus a random slice) produced identical
reports through both paths.
contract and is verified over the tracked provenance fixtures and a separate local
evaluation corpus. `ProvenanceReport.to_dict()` is the stable service boundary: it
adds a `schema_version`, contains only JSON-safe values, and deliberately omits the
local source path.
A record carries metadata regions, never pixels: marker segments before the JPEG
scan, every PNG chunk except `IDAT`, RIFF chunks except the coded image, the
ISOBMFF provenance boxes, the container's trailer, the parsed EXIF tags the
verdict reads by name, PIL's info mapping, and the C2PA manifest store. Typical
size is 13 kB (p90 93 kB); the tail belongs to images carrying a large embedded
manifest, where the store itself dominates.
verdict reads by name, PIL's info mapping, and the C2PA manifest store. Record size
is bounded by those metadata regions and trailers; images with large embedded
manifests naturally produce larger records.
The `path` argument is metadata: it labels the report and is never opened by
either function, so a record collected elsewhere can be judged against a path that
+1 -1
View File
@@ -68,7 +68,7 @@ payloads. Removal remuxes either container through ffmpeg with stream copy.
Metadata stripping for supported audio containers is a separate implemented
path.
**Box detection window — now handled (v0.6.8):** detection no longer relies on a fixed first-MB read. `metadata.scan_head(path, size)` reads the first `size` bytes and, for ISOBMFF, appends the payloads of late provenance boxes found by `isobmff.scan_c2pa_region` (a file-seeking top-level box walker that skips past `mdat` by size without reading it), so a C2PA/AIGC/IPTC manifest placed AFTER a large `mdat` in a streaming/non-faststart MP4 is now caught. Every C2PA/marker byte scan (`has_ai_metadata`, `aigc_label`, `iptc_ai_system`, `synthid_source`, `exif_generator` XMP, `get_ai_metadata` soft-binding, and `identify`) goes through `scan_head`; for PNG it likewise appends the payloads of `tEXt` / `iTXt` / `zTXt` / `eXIf` / `iCCP` chunks that start beyond the window (`_png_late_metadata`, seeking past `IDAT`), which is how a TC260 AIGC label appended after the pixel stream is caught; for a file at least `size` bytes long it also appends the metadata text the decoder reaches but a raw read cannot (`_decoder_visible_text`) a compressed PNG `zTXt` packet, or a WebP XMP chunk past the window, both of which hid real AI labels in a corpus; for any file that fits inside `size`, it is exactly `f.read(size)`.
**Box detection window — now handled (v0.6.8):** detection no longer relies on a fixed first-MB read. `metadata.scan_head(path, size)` reads the first `size` bytes and, for ISOBMFF, appends the payloads of late provenance boxes found by `isobmff.scan_c2pa_region` (a file-seeking top-level box walker that skips past `mdat` by size without reading it), so a C2PA/AIGC/IPTC manifest placed AFTER a large `mdat` in a streaming/non-faststart MP4 is now caught. Every C2PA/marker byte scan (`has_ai_metadata`, `aigc_label`, `iptc_ai_system`, `synthid_source`, `exif_generator` XMP, `get_ai_metadata` soft-binding, and `identify`) goes through `scan_head`; for PNG it likewise appends the payloads of `tEXt` / `iTXt` / `zTXt` / `eXIf` / `iCCP` chunks that start beyond the window (`_png_late_metadata`, seeking past `IDAT`), which is how a TC260 AIGC label appended after the pixel stream is caught; for WebP it appends the `EXIF` / `XMP ` / `ICCP` / `C2PA` chunks past the window (`_riff_late_metadata`, stepping over the coded image), which is how an IPTC "Made with AI" tag stored after the pixels is caught; and for a file at least `size` bytes long it finally appends the metadata text the decoder reaches but a raw read cannot (`_decoder_visible_text`), which covers a compressed PNG `zTXt` packet no byte scan can spell; for any file that fits inside `size`, it is exactly `f.read(size)`.
Native TC260 MP4/MOV tags do not live in those top-level provenance boxes.
`tc260_aigc_payloads` separately seeks through `moov.udta.meta.keys/ilst`, so
-1
View File
@@ -39,7 +39,6 @@ DATA SAFETY
Read-only over a local dataset. Writes only to the given output prefix, which
belongs outside the repository.
uv run python scripts/detection_timing.py data/spaces/originals .local-eval/timing/run
uv run python scripts/detection_timing.py <dataset> <prefix> --limit 200
"""
+12
View File
@@ -72,6 +72,18 @@ from typing import Any
from PIL import Image
from PIL.IptcImagePlugin import getiptcinfo
# Pillow cannot open HEIC/HEIF without this opener, and it does not auto-register.
# Skipping it does not fail loudly: HEIC records lose EXIF and most pixel features
# while the scan continues as if the container were merely unreadable. Deliberately a
# copy of `image_io._register_heif` rather than an import: this script keeps the
# minimal dependency set its docstring advertises and never imports the package. The
# suppression is as wide as the original's, so a broken libheif degrades the scan
# instead of killing it at import.
with contextlib.suppress(Exception):
import pillow_heif
pillow_heif.register_heif_opener()
SUPPORTED = {
".png",
".jpg",
+18 -2
View File
@@ -29,7 +29,9 @@ if TYPE_CHECKING:
from typing import BinaryIO
_C2paReader: Any = None
_C2paError: Any = None
with contextlib.suppress(Exception):
from c2pa import C2paError as _C2paError # pyright: ignore[reportMissingTypeStubs]
from c2pa import Reader as _C2paReader # pyright: ignore[reportMissingTypeStubs]
_C2PA_READER_AVAILABLE = _C2paReader is not None
@@ -48,10 +50,22 @@ def reader_available() -> bool:
def _manifest_json_uncached(path: str) -> str | None:
"""The manifest store as JSON, or None when this file has no readable manifest.
Two outcomes are routine and stay at debug: a file with no manifest (``try_create``
returns None) and a container the reader does not support. ANY other failure is
logged at warning, because the caller cannot tell the difference from the return
value and the consequence is severe: the verdict silently falls back to the raw
byte scan and can lose a high-confidence signal. The log line preserves the
diagnostic context needed to investigate an intermittent reader failure.
"""
try:
reader = _C2paReader.try_create(path)
except _C2paError.NotSupported as error:
logger.debug("C2PA reader does not support %s: %s", path, error)
return None
except Exception as error:
logger.debug("C2PA reader rejected %s: %s", path, error)
logger.warning("C2PA reader failed to open %s: %s: %s", path, type(error).__name__, error)
return None
if reader is None:
return None
@@ -59,7 +73,9 @@ def _manifest_json_uncached(path: str) -> str | None:
with reader:
return cast("str", reader.json())
except Exception as error:
logger.debug("C2PA reader could not serialize %s: %s", path, error)
# The reader opened the file, so a manifest is there; failing to serialize it
# is never routine.
logger.warning("C2PA reader could not serialize %s: %s: %s", path, type(error).__name__, error)
return None
+40
View File
@@ -70,6 +70,10 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
# Stable JSON contract for callers that pass a verdict between services. Bump this
# only for a breaking shape or semantic change; adding optional fields is compatible.
PROVENANCE_REPORT_SCHEMA_VERSION = 1
# How much of a non-PNG container to binary-scan for the C2PA issuer.
_SCAN_BYTES = 1024 * 1024
@@ -383,6 +387,32 @@ class ProvenanceReport:
# inconsistent -- a strong tell of spoofed, transplanted, or laundered metadata.
integrity_clashes: list[str] = field(default_factory=list[str])
def to_dict(self) -> dict[str, Any]:
"""Return the versioned, JSON-safe verdict contract.
``path`` is deliberately omitted. It is extraction context, not part of the
verdict, and local filesystem paths should not cross a service boundary.
"""
return {
"schema_version": PROVENANCE_REPORT_SCHEMA_VERSION,
"is_ai_generated": self.is_ai_generated,
"platform": self.platform,
"confidence": self.confidence,
"ai_source_kind": self.ai_source_kind,
"ai_from_metadata": self.ai_from_metadata,
"watermarks": list(self.watermarks),
"signals": [
{
"name": signal.name,
"detail": signal.detail,
"confidence": signal.confidence,
}
for signal in self.signals
],
"caveats": list(self.caveats),
"integrity_clashes": list(self.integrity_clashes),
}
def extract_provenance_evidence(image_path: Path) -> ProvenanceEvidence:
"""Read all file-backed metadata needed by provenance verdict logic once."""
@@ -1170,6 +1200,16 @@ def identify_from_evidence(
)
def identify_metadata_record(record: dict[str, Any], *, path: Path) -> ProvenanceReport:
"""Build a metadata-only verdict from a portable metadata record.
This is the service-integration entry point: the source file is never opened,
and callers receive the same verdict as the explicit
``evidence_from_metadata_record`` / ``identify_from_evidence`` sequence.
"""
return identify_from_evidence(evidence_from_metadata_record(record, path=path))
def identify(
image_path: Path,
*,
+58 -9
View File
@@ -285,6 +285,54 @@ def _png_late_metadata(image_path: Path, window: int) -> bytes:
return bytes(out)
# RIFF/WebP chunks that carry metadata rather than coded pixels.
_RIFF_META_CHUNKS: frozenset[bytes] = frozenset({b"EXIF", b"XMP ", b"ICCP", b"C2PA"})
def _riff_late_metadata(image_path: Path, window: int, *, max_total: int = 4 * 1024 * 1024) -> bytes:
"""Payloads of RIFF metadata chunks that start *beyond* the first ``window``
bytes, found by stepping over the (large) coded-image chunk.
The WebP layout puts ``XMP ``/``EXIF`` AFTER the pixels, so a fixed read can stop
before an IPTC or C2PA AI label. This is the RIFF analogue of
:func:`_png_late_metadata`; it returns only chunks past ``window`` so bytes
already in the head are not duplicated, and empty when there are none.
``max_total`` caps what a metadata scan can pull into memory, the same ceiling
``isobmff.scan_c2pa_region`` applies. Clamping each chunk to the bytes that remain
is not enough on its own: a corrupt or crafted file can declare one ``XMP `` chunk
spanning most of itself, and this runs on the memoized verdict path for images from
arbitrary sources. A label that needs more than 4 MB of XMP does not exist.
"""
out = bytearray()
try:
with open(image_path, "rb") as f:
if f.read(4) != b"RIFF":
return b""
f.seek(0, 2)
file_size = f.tell()
position = 12 # 'RIFF' + size + form type
while position + 8 <= file_size and len(out) < max_total:
f.seek(position)
header = f.read(8)
if len(header) < 8:
break
chunk_type = header[:4]
(length,) = struct.unpack("<I", header[4:8])
start = position + 8
# Clamp to what remains: a malformed 32-bit length must not push the
# walk past EOF and abandon a genuine label chunk after it.
safe_length = max(0, min(length, file_size - start))
if chunk_type in _RIFF_META_CHUNKS and start >= window:
f.seek(start)
out += f.read(min(safe_length, max_total - len(out)))
position = start + safe_length + (safe_length & 1) # chunks are word-aligned
except OSError as exc:
logger.debug("RIFF late-metadata scan failed on %s: %s", image_path, exc)
return b""
return bytes(out)
def _stat_key(image_path: Path) -> tuple[str, int, int] | None:
"""Cache key identifying this file's exact CONTENT, or None when it cannot stat.
@@ -352,6 +400,8 @@ def _scan_head_impl(image_path: Path, size: int) -> bytes:
# len(head) == size means the file is at least `size` bytes, so metadata
# chunks may lie beyond the window; otherwise the whole PNG is in `head`.
head += _png_late_metadata(image_path, size)
elif head[:4] == b"RIFF" and head[8:12] == b"WEBP" and len(head) == size:
head += _riff_late_metadata(image_path, size)
if len(head) >= size:
head += _decoder_visible_text(image_path, head)
return head
@@ -369,17 +419,16 @@ _DECODER_BINARY_KEYS = frozenset({"icc_profile"})
def _decoder_visible_text(image_path: Path, head: bytes) -> bytes:
"""Metadata text PIL can decode but the raw window does not contain.
Two placements defeat a fixed byte read, and both were found in a real corpus
rather than imagined:
This is the last of two layers, not the first. Metadata placed BEYOND the window
is the structural readers' job (``_png_late_metadata``, ``_riff_late_metadata``,
the ISOBMFF box walk), and they work on a file no decoder can open. What is left
for this one is metadata the bytes do not spell at all:
* COMPRESSED -- a PNG ``zTXt`` chunk is zlib-deflated, so an XMP packet carrying
a TC260 AIGC label is unreadable as bytes while PIL inflates it on open. Five
corpus files carried a China AIGC label that ``identify`` reported as no signal
at all.
* BEYOND THE WINDOW in a container with no late-chunk reader -- a WebP XMP chunk
at offset 1 093 039 sits 44 kB past the 1 MiB window, and unlike PNG and
ISOBMFF, RIFF has no seek-past-the-pixels extension here. Three corpus files
hid an IPTC "Made with AI" tag and a C2PA ``trainedAlgorithmicMedia`` that way.
a TC260 AIGC label is unreadable as bytes while PIL inflates it on open.
It stays container-agnostic on purpose: it is the net under a placement no
structural reader here knows about yet.
Only text ALREADY MISSING from ``head`` is appended, so the common case adds
nothing and no detector sees a value twice. Skipped entirely when the file fits
+27 -16
View File
@@ -82,6 +82,11 @@ def _jpeg_regions(data: bytes) -> bytes:
The scan itself is skipped by walking to SOS and then jumping to the trailing
EOI, so a 20 MB photo contributes only its markers.
TWIN: ``metadata._strip_jpeg_metadata_lossless`` walks the same marker chain. The
two were left separate on purpose -- that one couples the walk to "return False and
fall back to a PIL re-encode", a decision the lossless strip path owns and this one
must not inherit -- so a fix to marker handling belongs in BOTH.
"""
out = bytearray()
index, size = 2, len(data)
@@ -106,7 +111,13 @@ def _jpeg_regions(data: bytes) -> bytes:
def _png_regions(data: bytes) -> bytes:
"""Every chunk except the ``IDAT`` payloads, plus whatever follows IEND."""
"""Every chunk except the ``IDAT`` payloads, plus whatever follows IEND.
TWIN: ``metadata._png_late_metadata`` walks the same chunk chain by SEEKING over
the file rather than over a buffer, and keeps an allowlist rather than skipping
``IDAT``. Both filters are deliberate: inside the window the file path sees every
chunk type raw, past it only the allowlist survives.
"""
out = bytearray()
size = len(data)
position = len(PNG_SIGNATURE)
@@ -127,7 +138,12 @@ def _png_regions(data: bytes) -> bytes:
def _riff_regions(data: bytes) -> bytes:
"""Every RIFF chunk except the coded image payloads."""
"""Every RIFF chunk except the coded image payloads.
TWIN: ``metadata._riff_late_metadata`` (seek-based, past the scan window) and
``_internal.riff`` (AVI ``LIST/INFO``). Same chunk-stepping arithmetic, three
input models.
"""
out = bytearray(data[:12]) # 'RIFF' + size + 'WEBP'
size = len(data)
position = 12
@@ -168,10 +184,9 @@ def _container_regions(image_path: Path, head: bytes) -> tuple[str, bytes]:
"""(container label, metadata bytes) for the container ``head`` starts with.
``head`` must be the file's raw first bytes. Handing this the ``scan_head``
buffer instead is a trap that was walked into once: that buffer is the head
CONCATENATED with late metadata payloads, so a structural walk runs off the end
of the real head and parses the appended bytes as chunks -- which produced 11 MB
records and a phantom AIGC signal before the two were separated.
buffer instead is a trap: that buffer is the head CONCATENATED with late metadata
payloads, so a structural walk runs off the end of the real head and parses the
appended bytes as chunks, inflating the record and creating false signals.
"""
from remove_ai_watermarks._internal.isobmff import is_isobmff
from remove_ai_watermarks.metadata import png_late_metadata
@@ -211,9 +226,8 @@ def _trailer(image_path: Path, container: str) -> bytes:
tail = read_file_tail(image_path, TAIL_WINDOW)
if SAMSUNG_EDITOR_MARKER in tail:
# Galaxy AI splits its evidence: the marker sits in the post-EOI trailer, but
# the `genAIType` value it is gated on can sit INSIDE the entropy-coded scan
# (measured: marker at 580 619, value at 382 953 in the same file). Keeping
# only the trailer therefore carries the marker without the value and the
# the `genAIType` value it is gated on can sit INSIDE the entropy-coded scan.
# Keeping only the trailer therefore carries the marker without the value and the
# verdict silently drops the Samsung signal, so a marked file keeps the whole
# window. Only Samsung-marked files pay for it.
return tail
@@ -250,8 +264,7 @@ def _exif_pairs(info: dict[str, Any]) -> dict[str, str]:
in the regions. ``xai_signature_pair`` wants an (ImageDescription, Artist) pair,
and ``_external_exif_generator`` looks for Software / Make / Artist /
ImageDescription. Ship the bytes alone and both silently return nothing, which
is exactly how a first draft of this collector lost every Grok and NovelAI
verdict in a corpus run.
is how a collector can silently lose Grok and NovelAI verdicts.
"""
exif_bytes = info.get("exif")
if not exif_bytes:
@@ -283,11 +296,9 @@ def _pil_info(info: dict[str, Any]) -> dict[str, str]:
return value.decode("utf-8", "replace") if isinstance(value, bytes) else str(value)
# Emitted in the file path's own candidate order. ``generator_from_metadata``
# returns the FIRST candidate carrying a known token, and a record that listed
# PIL's keys in their natural dict order picked a different string for the same
# image -- nine NovelAI files came back as "NovelAI generated image" where the
# file path says "NovelAI". Same verdict, different platform text, and the two
# paths are supposed to be indistinguishable.
# returns the FIRST candidate carrying a known token. A record using PIL's natural
# dict order can therefore choose a different platform string than the file path,
# even though the two paths are supposed to be indistinguishable.
out: dict[str, str] = {}
for key in _GENERATOR_TEXT_KEYS:
value = info.get(key)
+24
View File
@@ -1316,6 +1316,30 @@ class TestAIGCLabel:
assert b"ContentProducer" in _png_late_metadata(p, 8)
assert b"ContentProducer" in scan_head(p, 8)
def test_scan_head_collects_webp_metadata_past_window(self, tmp_path: Path):
"""WebP stores ``XMP `` AFTER the pixels, so on any WebP above the window a
fixed read can stop short of an IPTC "Made with AI" tag."""
from remove_ai_watermarks.metadata import _riff_late_metadata, scan_head
p = tmp_path / "late.webp"
xmp = (
b"<x:xmpmeta><photoshop:DigitalSourceType>trainedAlgorithmicMedia</photoshop:DigitalSourceType></x:xmpmeta>"
)
Image.new("RGB", (16, 16)).save(p, "WEBP", xmp=xmp)
assert b"trainedAlgorithmicMedia" in _riff_late_metadata(p, 12)
assert b"trainedAlgorithmicMedia" in scan_head(p, 12)
def test_riff_late_metadata_ignores_the_coded_image(self, tmp_path: Path):
"""The point of stepping chunk by chunk rather than reading through: the
pixel payload never enters the scan buffer."""
from remove_ai_watermarks.metadata import _riff_late_metadata
p = tmp_path / "plain.webp"
Image.new("RGB", (64, 64), (200, 30, 30)).save(p, "WEBP")
assert _riff_late_metadata(p, 12) == b""
class TestHuggingFaceJob:
"""HuggingFace-hosted job marker (``hf-job-id`` PNG text chunk)."""
+37
View File
@@ -3,10 +3,12 @@ consolidated metadata strip (formerly legacy metadata helper)."""
from __future__ import annotations
import logging
import struct
from pathlib import Path
import pytest
from PIL import Image
from remove_ai_watermarks._internal.c2pa import (
_parse_c2pa_chunk,
@@ -835,3 +837,38 @@ class TestTc260ContainerRouting:
readers = _tc260_container_readers()
assert [r.__module__.rsplit(".", 1)[-1] for r in readers] == ["isobmff", "ebml", "riff", "flv"]
class TestC2paReaderFailureIsVisible:
"""A reader failure and a file with no manifest both return None, so the caller
cannot tell them apart -- and the consequence is not symmetric. A file with no
manifest is a normal verdict; a reader that could not read a file it was handed
can silently downgrade one, so the log level must make the failure observable."""
def _records(self, caplog, path: str) -> list[str]:
from remove_ai_watermarks._internal import c2pa
with caplog.at_level(logging.DEBUG, logger="remove_ai_watermarks._internal.c2pa"):
assert c2pa._manifest_json_uncached(path) is None
return [f"{r.levelname} {r.getMessage()}" for r in caplog.records]
def test_an_unreadable_file_warns(self, caplog):
records = self._records(caplog, "/nonexistent/definitely-not-here.png")
assert any(r.startswith("WARNING") for r in records), records
def test_an_unsupported_container_stays_quiet(self, caplog, tmp_path: Path):
target = tmp_path / "notes.txt"
target.write_text("plain text, not a container the reader handles")
records = self._records(caplog, str(target))
assert not any(r.startswith("WARNING") for r in records), records
def test_a_plain_image_without_a_manifest_logs_nothing(self, caplog, tmp_path: Path):
target = tmp_path / "plain.png"
Image.new("RGB", (8, 8)).save(target)
records = self._records(caplog, str(target))
assert records == []
+23 -1
View File
@@ -17,10 +17,12 @@ import pytest
from PIL import Image
from remove_ai_watermarks.identify import (
PROVENANCE_REPORT_SCHEMA_VERSION,
ProvenanceReport,
evidence_from_metadata_record,
identify,
identify_from_evidence,
identify_metadata_record,
)
from remove_ai_watermarks.metadata_record import HEAD_WINDOW, collect_metadata_record
@@ -31,7 +33,7 @@ COMPARED = ("is_ai_generated", "platform", "confidence", "ai_source_kind", "ai_f
def _verdict_via_record(path: Path) -> ProvenanceReport:
"""The contractor's path: collect, serialize, judge -- without the file."""
record = json.loads(json.dumps(collect_metadata_record(path)))
return identify_from_evidence(evidence_from_metadata_record(record, path=path))
return identify_metadata_record(record, path=path)
def _assert_same_verdict(path: Path) -> None:
@@ -168,6 +170,26 @@ class TestRecordShape:
assert record["metadata_base64"] == ""
class TestReportTransport:
def test_report_contract_is_versioned_json_and_omits_local_path(self, tmp_path: Path):
path = _noise_png(tmp_path / "plain.png")
payload = identify_metadata_record(collect_metadata_record(path), path=path).to_dict()
assert payload["schema_version"] == PROVENANCE_REPORT_SCHEMA_VERSION == 1
assert "path" not in payload
assert json.loads(json.dumps(payload)) == payload
def test_convenience_entry_point_matches_explicit_sequence(self, tmp_path: Path):
path = _noise_png(tmp_path / "plain.png")
record = collect_metadata_record(path)
explicit = identify_from_evidence(evidence_from_metadata_record(record, path=path))
convenience = identify_metadata_record(record, path=path)
assert convenience == explicit
def test_a_webp_record_matches(tmp_path: Path):
"""RIFF has its own walk; a chunk kept or dropped wrongly shows up here."""
path = tmp_path / "image.webp"
+29
View File
@@ -18,10 +18,14 @@ from __future__ import annotations
import struct
import tracemalloc
from typing import TYPE_CHECKING
from remove_ai_watermarks import metadata
from remove_ai_watermarks._internal import c2pa, isobmff
if TYPE_CHECKING:
from pathlib import Path
PNG_SIG = b"\x89PNG\r\n\x1a\n"
_HUGE = 0x7FFFFFFF # ~2 GiB declared length on a tiny file
@@ -128,3 +132,28 @@ class TestIsobmffStripFailSafe:
assert stripped == 0
assert cleaned == data
assert len(cleaned) == len(data)
class TestRiffLateMetadataIsBounded:
"""A metadata scan must not become a near-full-file read because one chunk lied
about its length. This runs on the memoized verdict path, over images from
arbitrary sources."""
def _webp_with_declared_length(self, path: Path, declared: int, payload: bytes) -> Path:
chunk = b"XMP " + declared.to_bytes(4, "little") + payload
body = b"WEBP" + b"VP8 " + (4).to_bytes(4, "little") + b"\x00\x00\x00\x00" + chunk
path.write_bytes(b"RIFF" + len(body).to_bytes(4, "little") + body)
return path
def test_a_chunk_claiming_the_whole_file_is_clamped_to_what_remains(self, tmp_path: Path):
target = self._webp_with_declared_length(tmp_path / "liar.webp", 1 << 30, b"AI" * 64)
collected = metadata._riff_late_metadata(target, 12)
assert collected == b"AI" * 64
def test_the_total_is_capped(self, tmp_path: Path):
payload = b"x" * 4096
target = self._webp_with_declared_length(tmp_path / "big.webp", len(payload), payload)
assert len(metadata._riff_late_metadata(target, 12, max_total=512)) == 512