fix: metadata-strip parity, input robustness, and detection/clash coverage

Bug fixes (each with a regression test):
- metadata strip parity across every marker placement: IPTC digitalSourceType
  in XMP, the Samsung post-EOI trailer, the China TC260 AIGC block in EXIF
  UserComment, a bare AIGC block in a non-standard APP segment, and the ISOBMFF
  EXIF path (AIGC + xAI) are all now stripped -- anything a scanner flags, the
  strip reaches
- Samsung genAIType detected when its trailer sits past the 512 KB scan window
  (file-tail read on large photos)
- crashes on edge inputs: Gemini detector on images with a short side < 16px,
  footprint_mask on a zero-size ndarray, the humanizer on chromatic_shift >=
  width, and the CLI on unreadable/corrupt/empty input (clean error, not a
  traceback)
- WebP written losslessly (cv2 quality 101), not lossy at 100
- the IPTC digitalSourceType algorithmicMedia (procedural, not trained on
  sampled data) is no longer flagged as AI-generated, so clean procedural
  content is not scrubbed
- c2pa source-type: compositeWithTrainedAlgorithmicMedia is checked before the
  bare algorithmicMedia token, so an AI-enhanced composite is not misclassified

Detection:
- integrity-clash coverage now normalizes ByteDance / Canva / ElevenLabs /
  Black Forest Labs, so a transplanted manifest next to an independent
  conflicting stamp is caught; the generic China TC260 AIGC label is attributed
  to a co-present TC260 vendor, so a legit Doubao image (its own C2PA + TC260
  label) does not clash (corpus-validated: 0 new clashes on 5000 carriers)

CLI:
- batch exits non-zero (with a warning) when any image errors or a GPU-missing
  SynthID scrub is skipped, and copies the input through so the output dir stays
  complete -- it used to always exit 0 and could silently drop files

Perf:
- GeminiEngine reused as a process-wide singleton with a precomputed template
  ladder: -24% on the identify sparkle path, detection byte-identical

Internal: one shared _ai_exif_targets rule set feeds both EXIF scrubbers so
their coverage cannot drift; docs synced; maintain.sh hardened so the uv-secure
internal teardown crash no longer aborts the gate (still fails on a real finding).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Victor Kuznetsov
2026-07-13 10:49:24 +03:00
co-authored by Claude Opus 4.8
parent 190dc89d23
commit a4c901ff39
25 changed files with 894 additions and 97 deletions
+73
View File
@@ -573,6 +573,16 @@ class TestMetadataCommand:
assert result.exit_code == 0
assert "stripped" in result.output
def test_metadata_remove_in_place(self, runner, tmp_png_with_ai_metadata):
"""With ``-o`` omitted, the strip overwrites the source in place (default
output_path=None). Previously every test passed an explicit ``-o``."""
from remove_ai_watermarks.metadata import has_ai_metadata
assert has_ai_metadata(tmp_png_with_ai_metadata) # precondition
result = runner.invoke(main, ["metadata", str(tmp_png_with_ai_metadata), "--remove"])
assert result.exit_code == 0, result.output
assert not has_ai_metadata(tmp_png_with_ai_metadata) # source overwritten, AI metadata gone
class TestIdentifyCommand:
"""Tests for the 'identify' subcommand."""
@@ -600,6 +610,18 @@ class TestIdentifyCommand:
assert "AI-generated" in result.output
assert "Stable Diffusion" in result.output
def test_identify_reports_generated_source_kind(self, runner):
"""The C2PA trainedAlgorithmicMedia source type sharpens the verdict to
'AI-generated (fully synthetic)' at the CLI (the ai_source_kind branch)."""
from pathlib import Path
sample = Path(__file__).resolve().parent.parent / "data" / "samples" / "chatgpt-1.png"
if not sample.exists():
pytest.skip("chatgpt sample not present")
result = runner.invoke(main, ["identify", str(sample), "--no-visible"])
assert result.exit_code == 0
assert "AI-generated (fully synthetic)" in result.output
def test_identify_json_is_valid(self, runner, tmp_png_with_ai_metadata):
result = runner.invoke(main, ["identify", str(tmp_png_with_ai_metadata), "--no-visible", "--json"])
assert result.exit_code == 0
@@ -774,6 +796,34 @@ class TestBatchCommand:
expected_dir = tmp_path / "input_clean"
assert expected_dir.exists()
def test_batch_errors_exit_nonzero(self, runner, tmp_path):
"""Regression: batch used to always exit 0 even when every image errored,
hiding failure from a wrapping service. A corrupt image must yield a non-zero
exit and an error count."""
input_dir = tmp_path / "input"
input_dir.mkdir()
(input_dir / "corrupt.png").write_bytes(b"this is not a PNG at all" * 50)
result = runner.invoke(main, ["batch", str(input_dir), "--mode", "visible"])
assert result.exit_code != 0, result.output
assert "error" in result.output.lower()
def test_batch_invisible_gpu_missing_writes_output_and_exits_nonzero(self, runner, tmp_path):
"""Regression: batch --mode invisible with a signal-bearing image but no GPU
deps used to write NO output for that image and still exit 0, silently dropping
the files that most needed processing. It must now copy the input through (so the
output dir is complete), warn about the retained SynthID watermark, and exit
non-zero -- mirroring the single ``all`` command."""
input_dir = _make_batch_dir_with_metadata(tmp_path, count=3) # SD params = invisible signal
output_dir = tmp_path / "output"
with patch("remove_ai_watermarks.invisible_engine.is_available", return_value=False):
result = runner.invoke(
main,
["batch", str(input_dir), "-o", str(output_dir), "--mode", "invisible"],
)
assert result.exit_code != 0, result.output
assert "NOT removed" in result.output
assert len(list(output_dir.glob("*.png"))) == 3 # every input copied through, none dropped
class TestGpuHintMarkup:
"""The GPU-extra install hint must reach the user with the ``[gpu]`` token
@@ -887,3 +937,26 @@ def test_visible_backend_runtime_error_exits_cleanly(runner, tmp_path, monkeypat
result = runner.invoke(main, ["visible", str(doubao), "-o", str(out), "--backend", "migan"])
assert result.exit_code == 1
assert not isinstance(result.exception, RuntimeError), "RuntimeError leaked as a traceback"
@pytest.mark.parametrize(
("name", "content"),
[
("empty.png", b""),
("notimage.jpg", b"plain text, not an image at all " * 20),
("truncated.png", b"\x89PNG\r\n\x1a\n" + b"\x00" * 40),
],
)
@pytest.mark.parametrize("cmd", [["metadata", "--remove"], ["visible", "--backend", "cv2"]])
def test_unreadable_input_exits_cleanly(runner, tmp_path, name, content, cmd):
"""Regression: a corrupt / empty / non-image file (real prod uploads include
truncated files) must produce a clean 'Error: cannot read/process' + exit 1, NOT a
raw PIL.UnidentifiedImageError / OSError / ValueError traceback. Found by the runtime
mode fuzz across metadata --remove and visible."""
bad = tmp_path / name
bad.write_bytes(content)
out = tmp_path / "out.png"
result = runner.invoke(main, [cmd[0], str(bad), "-o", str(out), *cmd[1:]])
assert result.exit_code == 1, result.output
assert isinstance(result.exception, SystemExit), f"leaked a raw traceback: {result.exception!r}"
assert "Error" in result.output