diff --git a/.claude/rules/development.md b/.claude/rules/development.md index bf2b780..e447580 100644 --- a/.claude/rules/development.md +++ b/.claude/rules/development.md @@ -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 diff --git a/docs/module-internals.md b/docs/module-internals.md index 68ba3ef..c167325 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -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 diff --git a/docs/python-api.md b/docs/python-api.md index 2cb45bb..1944a28 100644 --- a/docs/python-api.md +++ b/docs/python-api.md @@ -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 diff --git a/docs/watermarking-landscape.md b/docs/watermarking-landscape.md index 28ab49e..586ba57 100644 --- a/docs/watermarking-landscape.md +++ b/docs/watermarking-landscape.md @@ -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 diff --git a/scripts/detection_timing.py b/scripts/detection_timing.py index 73756e0..bb3bfb2 100644 --- a/scripts/detection_timing.py +++ b/scripts/detection_timing.py @@ -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 --limit 200 """ diff --git a/scripts/scan_dataset.py b/scripts/scan_dataset.py index 45bb043..0fe4b8d 100644 --- a/scripts/scan_dataset.py +++ b/scripts/scan_dataset.py @@ -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", diff --git a/src/remove_ai_watermarks/_internal/c2pa.py b/src/remove_ai_watermarks/_internal/c2pa.py index 0eb2b94..d89255a 100644 --- a/src/remove_ai_watermarks/_internal/c2pa.py +++ b/src/remove_ai_watermarks/_internal/c2pa.py @@ -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 diff --git a/src/remove_ai_watermarks/identify.py b/src/remove_ai_watermarks/identify.py index 53efc1b..ec92a76 100644 --- a/src/remove_ai_watermarks/identify.py +++ b/src/remove_ai_watermarks/identify.py @@ -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, *, diff --git a/src/remove_ai_watermarks/metadata.py b/src/remove_ai_watermarks/metadata.py index aeadca1..0c097f3 100644 --- a/src/remove_ai_watermarks/metadata.py +++ b/src/remove_ai_watermarks/metadata.py @@ -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("= 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 diff --git a/src/remove_ai_watermarks/metadata_record.py b/src/remove_ai_watermarks/metadata_record.py index 26ed88d..8c414e2 100644 --- a/src/remove_ai_watermarks/metadata_record.py +++ b/src/remove_ai_watermarks/metadata_record.py @@ -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) diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 4c863c6..9753b23 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -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"trainedAlgorithmicMedia" + ) + 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).""" diff --git a/tests/test_metadata_internals.py b/tests/test_metadata_internals.py index 0218d0c..89afe0c 100644 --- a/tests/test_metadata_internals.py +++ b/tests/test_metadata_internals.py @@ -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 == [] diff --git a/tests/test_metadata_record.py b/tests/test_metadata_record.py index 9d1b17c..ee6c0a7 100644 --- a/tests/test_metadata_record.py +++ b/tests/test_metadata_record.py @@ -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" diff --git a/tests/test_security_clamp.py b/tests/test_security_clamp.py index ffdaae5..4e2a30f 100644 --- a/tests/test_security_clamp.py +++ b/tests/test_security_clamp.py @@ -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