"""Tests for the metadata-only forensic collector.""" from __future__ import annotations import base64 import json import zlib from typing import TYPE_CHECKING import piexif import pytest from PIL import Image from PIL.PngImagePlugin import PngInfo from remove_ai_watermarks.forensic_metadata import ( FORENSIC_METADATA_RECORD_TYPE, FORENSIC_METADATA_SCHEMA_VERSION, SUPPORTED_EXTENSIONS, _b64, _decode_exif_value, _jpeg_forensics_bytes, _png_text_decode, _safe_str, apple_live_photo_id, collect_forensic_metadata, read_full_exif, read_isobmff_inventory, read_isobmff_provenance_path, read_jpeg_segments, read_pil_info, read_png_chunks, read_png_late_metadata_path, read_webp_chunks, sha256_of, sniff_format, xattr_quarantine, xattr_where_from, ) if TYPE_CHECKING: from pathlib import Path def _jpeg(path: Path) -> Path: Image.new("RGB", (48, 32), (20, 80, 160)).save(path, "JPEG", quality=87) return path def _png_chunk(chunk_type: bytes, payload: bytes) -> bytes: crc = zlib.crc32(chunk_type + payload).to_bytes(4, "big") return len(payload).to_bytes(4, "big") + chunk_type + payload + crc def test_supported_extensions_are_media_not_documents(): assert {".jpg", ".png", ".webp", ".heic", ".mp4"}.issubset(SUPPORTED_EXTENSIONS) assert ".pdf" not in SUPPORTED_EXTENSIONS def test_json_helpers_and_format_sniffer(): class BadString: def __str__(self): raise RuntimeError("no string") assert _safe_str("ok") == "ok" assert "BadString" in _safe_str(BadString()) assert _b64(b"abc") == base64.b64encode(b"abc").decode("ascii") assert _b64(b"x" * 20, cap=4) == "eHh4eA==...TRUNCATED(20 bytes total)" assert _decode_exif_value(b"ascii") == "ascii" assert _decode_exif_value(b"\xff").startswith("hex:") assert _decode_exif_value((1, b"two")) == [1, "two"] assert sniff_format(b"\x89PNG\r\n\x1a\n") == "png" assert sniff_format(b"\xff\xd8\xff\xe0") == "jpeg" assert sniff_format(b"RIFF....WEBP") == "webp" assert sniff_format(b"....ftypheic").startswith("isobmff:") assert sniff_format(b"unknown").startswith("unknown:") def test_png_text_and_container_metadata_are_preserved(tmp_path: Path): info = PngInfo() info.add_text("parameters", "Steps: 20, Model: SDXL", zip=True) path = tmp_path / "workflow.png" Image.new("RGB", (32, 32)).save(path, pnginfo=info) trailer = b'{"Label":"1"}' path.write_bytes(path.read_bytes() + trailer) record = collect_forensic_metadata(path) assert record["schema_version"] == FORENSIC_METADATA_SCHEMA_VERSION == 1 assert record["record_type"] == FORENSIC_METADATA_RECORD_TYPE == "forensic_metadata" assert record["content_format"] == "png" assert any(chunk.get("type") == "zTXt" for chunk in record["png_chunks"]) assert record["png_post_iend_bytes"] == len(trailer) assert base64.b64decode(record["png_post_iend_base64"]) == trailer assert "Steps: 20" in json.dumps(record) assert json.loads(json.dumps(record, allow_nan=False)) == record def test_png_text_decoders_and_direct_chunk_reader(tmp_path: Path): assert "hello" in _png_text_decode("tEXt", b"key\x00hello") compressed = b"prompt\x00\x00" + zlib.compress(b"workflow") assert "workflow" in _png_text_decode("zTXt", compressed) assert "value" in _png_text_decode("iTXt", b"key\x00\x00\x00\x00\x00value") path = tmp_path / "plain.png" Image.new("RGB", (8, 8)).save(path) chunks, trailer = read_png_chunks(path.read_bytes()) assert chunks[0]["type"] == "IHDR" assert trailer == b"" def test_jpeg_exif_segments_encoder_and_trailer(tmp_path: Path): path = tmp_path / "camera.jpg" exif = piexif.dump( { "0th": { piexif.ImageIFD.Make: b"Camera Corp", piexif.ImageIFD.Software: b"Camera Firmware", }, "Exif": {}, "GPS": {}, "1st": {}, } ) Image.new("RGB", (64, 48)).save(path, "JPEG", exif=exif, quality=82) trailer = b'PhotoEditor_Re_Edit_Data{"genAIType":1}' path.write_bytes(path.read_bytes() + trailer) record = collect_forensic_metadata(path) assert record["exif"]["0th"]["Make"] == "Camera Corp" assert record["jpeg"]["post_eoi_bytes"] == len(trailer) assert base64.b64decode(record["jpeg"]["post_eoi_base64"]) == trailer assert record["jpeg_forensics"]["quant_tables"] assert sha256_of(path.read_bytes()) == record["sha256"] def test_direct_exif_pil_and_jpeg_readers(tmp_path: Path): path = _jpeg(tmp_path / "plain.jpg") exif, thumbnail = read_full_exif(path) pil, iptc, exif_blob = read_pil_info(path) segments = read_jpeg_segments(path.read_bytes()) assert isinstance(exif, dict) assert thumbnail is None assert pil["width"] == 48 assert pil["height"] == 32 assert isinstance(iptc, dict) assert exif_blob is None or isinstance(exif_blob, bytes) assert isinstance(segments["segments"], list) assert _jpeg_forensics_bytes(path.read_bytes())["quant_tables"] assert _jpeg_forensics_bytes(b"not a jpeg") == {} def test_webp_inventory_keeps_metadata_but_not_frame_pixels(tmp_path: Path): path = tmp_path / "image.webp" xmp = b"metadata" Image.new("RGB", (32, 32), (30, 40, 50)).save(path, "WEBP", xmp=xmp) chunks = read_webp_chunks(path.read_bytes()) xmp_chunk = next(chunk for chunk in chunks if chunk["type"] == "XMP ") assert xmp_chunk["text"] == xmp.decode() assert all("base64" not in chunk for chunk in chunks if chunk["type"] in {"VP8 ", "VP8L", "ANMF"}) def test_isobmff_inventory_and_streaming_provenance(tmp_path: Path): path = tmp_path / "signed.mp4" ftyp = b"\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00mp42isom" payload = b"jumb c2pa trainedAlgorithmicMedia" uuid_box = (8 + len(payload)).to_bytes(4, "big") + b"uuid" + payload path.write_bytes(ftyp + b"\x00\x00\x00\x08mdat" + uuid_box) inventory = read_isobmff_inventory(path.read_bytes()) streamed = read_isobmff_provenance_path(path) assert "ftyp" in inventory["boxes"] assert base64.b64decode(inventory["provenance_boxes"][0]["base64"]) == payload assert base64.b64decode(streamed["provenance_boxes"][0]["base64"]) == payload def test_oversized_path_keeps_bounded_windows_and_late_png_metadata(tmp_path: Path, monkeypatch): path = tmp_path / "late.png" Image.new("RGB", (16, 16)).save(path) source = path.read_bytes() iend = source.rfind(b"\x00\x00\x00\x00IEND") padding = _png_chunk(b"vpAg", b"\x00" * ((1 << 20) + 1)) metadata = b'AIGC\x00{"Label":"1"}' path.write_bytes(source[:iend] + padding + _png_chunk(b"tEXt", metadata) + source[iend:]) monkeypatch.setattr("remove_ai_watermarks.forensic_metadata._MAX_FULL_READ", 1) record = collect_forensic_metadata(path) assert record["oversized"]["head_scanned_bytes"] == path.stat().st_size assert base64.b64decode(record["raw_metadata_windows"]["head_base64"]) assert base64.b64decode(record["png_late_metadata_chunks"][0]["base64"]) == metadata def test_collection_registers_optional_heif_and_missing_file_raises(tmp_path: Path, monkeypatch): registered = False def mark_registered(): nonlocal registered registered = True monkeypatch.setattr("remove_ai_watermarks.image_io._register_heif", mark_registered) collect_forensic_metadata(_jpeg(tmp_path / "plain.jpg")) assert registered is True with pytest.raises(FileNotFoundError): collect_forensic_metadata(tmp_path / "missing.jpg") @pytest.mark.parametrize("schema_version", [2, True, 1.0]) def test_collection_rejects_unsupported_output_schema_before_reading(tmp_path: Path, schema_version: object): with pytest.raises(ValueError, match="Unsupported forensic metadata schema"): collect_forensic_metadata( tmp_path / "missing.jpg", schema_version=schema_version, # type: ignore[arg-type] ) def test_xattrs_and_live_photo_probe_are_safe_on_plain_file(tmp_path: Path): path = _jpeg(tmp_path / "plain.jpg") assert xattr_where_from(path) == [] or isinstance(xattr_where_from(path), list) assert xattr_quarantine(path) is None or isinstance(xattr_quarantine(path), str) assert apple_live_photo_id(path.read_bytes()) is None def test_late_png_reader_soft_fails_on_non_png(tmp_path: Path): path = tmp_path / "plain.bin" path.write_bytes(b"not png") assert read_png_late_metadata_path(path) == []