mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-20 07:57:21 +02:00
The metadata scanners read raw bytes, so random bytes, an empty file or a truncated PNG or MP4 printed the same "No AI metadata found" or "AI metadata stripped" a real file gets, with exit 0. `metadata.unreadable_reason` is now the one readability probe: `identify`, `metadata` and every `video` command exit 1 on such input, and `batch` and `video batch` fail its row. `strip_and_verify` refuses it before writing anything. A `batch` or `video batch` directory with no supported file exits 2 and creates no output directory. `visible` and `erase` strip and verify on a staged sibling and publish nothing when a marker survives, as the library `remove_visible` now does; the diffusion writer raises on a surviving marker. An invisible engine that cannot start exits 1 without a traceback, and the MCP server reports a child that could not start. NotebookLM and Gemini slide-deck templates are recorded as not planned. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
312 lines
14 KiB
Python
312 lines
14 KiB
Python
"""Every command fails closed: an unreadable input or a skipped step never exits 0.
|
|
|
|
Found 2026-10-18 by a sweep that ran each command over random bytes, an empty file
|
|
and a truncated copy of a tracked fixture. The pixel commands already refused all
|
|
three. The metadata-only paths did not: they scan raw bytes, a scan of bytes that
|
|
are not an image finds nothing, and "nothing" printed as "No AI metadata found"
|
|
with exit 0, the same answer a real file without metadata gets. `metadata
|
|
--remove` then copied the garbage through and reported "AI metadata stripped".
|
|
A truncated MP4 without its `moov` box got the same treatment from the video
|
|
metadata commands. The rule pinned here: the exit code of a scan that could not
|
|
read its input is never the exit code of a scan that read it and found nothing.
|
|
|
|
Absent OPTIONAL detectors are not in this set. `identify` on the default install,
|
|
which has no pixel stack, still answers with exit 0 and a caveat naming what did
|
|
not run (decided 2026-10-18); see `test_optional_deps.py`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import pytest
|
|
from click.testing import CliRunner
|
|
|
|
from remove_ai_watermarks import image_io, metadata
|
|
from remove_ai_watermarks._internal.constants import SUPPORTED_FORMATS
|
|
from remove_ai_watermarks.cli import main
|
|
from remove_ai_watermarks.video import VIDEO_EXTENSIONS
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
C2PA_PNG = ROOT / "data/fixtures/provenance/ideogram-c2pa.png"
|
|
MP4 = ROOT / "data/fixtures/provenance/runway-gen45-video.mp4"
|
|
|
|
|
|
def _unreadable_images(tmp_path: Path) -> dict[str, Path]:
|
|
rng = np.random.default_rng(0)
|
|
garbage = tmp_path / "garbage.png"
|
|
garbage.write_bytes(rng.integers(0, 256, 4000, dtype=np.uint8).tobytes())
|
|
empty = tmp_path / "empty.png"
|
|
empty.write_bytes(b"")
|
|
truncated = tmp_path / "truncated.png"
|
|
truncated.write_bytes(C2PA_PNG.read_bytes()[:300])
|
|
return {"garbage": garbage, "empty": empty, "truncated": truncated}
|
|
|
|
|
|
@pytest.fixture
|
|
def clean_png(tmp_path: Path) -> Path:
|
|
path = tmp_path / "clean.png"
|
|
gradient = np.tile(np.linspace(0, 255, 64, dtype=np.uint8), (64, 1))
|
|
assert image_io.imwrite(path, np.dstack([gradient, gradient.T, gradient]))
|
|
return path
|
|
|
|
|
|
@pytest.fixture
|
|
def truncated_mp4(tmp_path: Path) -> Path:
|
|
"""The ftyp box survives, so the suffix sniff passes; the box walk does not."""
|
|
path = tmp_path / "truncated.mp4"
|
|
path.write_bytes(MP4.read_bytes()[:3000])
|
|
return path
|
|
|
|
|
|
@pytest.fixture
|
|
def cuda_refused(monkeypatch):
|
|
"""The invisible engine is installed but cannot start, as on a host without CUDA."""
|
|
import remove_ai_watermarks.invisible_engine as invisible_engine
|
|
|
|
def refuse(*_args, **_kwargs):
|
|
raise ValueError("Invisible-watermark removal is CUDA-only")
|
|
|
|
monkeypatch.setattr(invisible_engine, "is_available", lambda: True)
|
|
monkeypatch.setattr(invisible_engine, "InvisibleEngine", refuse)
|
|
|
|
|
|
def _invoke(args: list[str]):
|
|
return CliRunner().invoke(main, args)
|
|
|
|
|
|
class TestUnreadableImageInputs:
|
|
@pytest.mark.parametrize("kind", ["garbage", "empty", "truncated"])
|
|
@pytest.mark.parametrize("flags", [[], ["--no-visible"], ["--json"]])
|
|
def test_identify_exits_nonzero(self, tmp_path, kind, flags):
|
|
result = _invoke(["identify", *flags, str(_unreadable_images(tmp_path)[kind])])
|
|
assert result.exit_code == 1, result.output
|
|
assert isinstance(result.exception, SystemExit)
|
|
|
|
def test_identify_keeps_exit_zero_on_a_readable_file(self, clean_png):
|
|
result = _invoke(["identify", str(clean_png)])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
@pytest.mark.parametrize("kind", ["garbage", "empty", "truncated"])
|
|
def test_metadata_check_does_not_report_no_metadata(self, tmp_path, kind):
|
|
result = _invoke(["metadata", "--check", str(_unreadable_images(tmp_path)[kind])])
|
|
assert result.exit_code == 1, result.output
|
|
assert "No AI metadata found" not in result.output
|
|
assert "Nothing was scanned" in result.output, "the probe runs before any scan"
|
|
|
|
@pytest.mark.parametrize("kind", ["garbage", "empty", "truncated"])
|
|
def test_metadata_remove_writes_nothing_and_exits_nonzero(self, tmp_path, kind):
|
|
out = tmp_path / "out.png"
|
|
result = _invoke(["metadata", "--remove", str(_unreadable_images(tmp_path)[kind]), "-o", str(out)])
|
|
assert result.exit_code == 1, result.output
|
|
assert "stripped" not in result.output
|
|
assert not out.exists()
|
|
|
|
def test_metadata_remove_keeps_working_on_a_readable_file(self, clean_png, tmp_path):
|
|
result = _invoke(["metadata", "--remove", str(clean_png), "-o", str(tmp_path / "out.png")])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
def test_batch_metadata_counts_an_unreadable_file_as_failed(self, tmp_path, clean_png):
|
|
indir = tmp_path / "in"
|
|
indir.mkdir()
|
|
shutil.copy(clean_png, indir / "clean.png")
|
|
shutil.copy(_unreadable_images(tmp_path)["garbage"], indir / "garbage.png")
|
|
result = _invoke(["batch", str(indir), "--mode", "metadata", "-o", str(tmp_path / "out")])
|
|
assert result.exit_code == 1, result.output
|
|
assert "1 processed" in result.output
|
|
assert "1 errors" in result.output
|
|
|
|
|
|
class TestTruncatedVideo:
|
|
@pytest.mark.parametrize(
|
|
"args",
|
|
[
|
|
["video", "identify", "--no-visible"],
|
|
["video", "metadata", "--check"],
|
|
["video", "metadata", "--remove"],
|
|
["metadata", "--check"],
|
|
],
|
|
)
|
|
def test_metadata_paths_exit_nonzero(self, truncated_mp4, args):
|
|
result = _invoke([*args, str(truncated_mp4)])
|
|
assert result.exit_code == 1, result.output
|
|
assert "No AI metadata found" not in result.output
|
|
assert "stripped" not in result.output
|
|
|
|
def test_video_batch_metadata_counts_it_as_failed(self, tmp_path, truncated_mp4):
|
|
indir = tmp_path / "in"
|
|
indir.mkdir()
|
|
shutil.copy(truncated_mp4, indir / "truncated.mp4")
|
|
result = _invoke(["video", "batch", str(indir), "--mode", "metadata", "-o", str(tmp_path / "out")])
|
|
assert result.exit_code == 1, result.output
|
|
assert "0 processed, 1 failed" in result.output
|
|
|
|
|
|
class TestReadabilityProbe:
|
|
"""The probe must pass every tracked fixture, or the fix trades one lie for another."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"path",
|
|
sorted(p for p in (ROOT / "data/fixtures").rglob("*") if p.is_file() and p.suffix.lower() != ".md"),
|
|
ids=lambda p: str(p.relative_to(ROOT / "data/fixtures")),
|
|
)
|
|
def test_every_tracked_fixture_is_readable(self, path):
|
|
if path.suffix.lower() not in SUPPORTED_FORMATS | VIDEO_EXTENSIONS:
|
|
pytest.skip("not a container the metadata commands accept")
|
|
assert metadata.unreadable_reason(path) is None
|
|
|
|
def test_unreadable_inputs_name_a_reason(self, tmp_path, truncated_mp4):
|
|
for path in [*_unreadable_images(tmp_path).values(), truncated_mp4]:
|
|
assert metadata.unreadable_reason(path), path
|
|
|
|
def test_a_png_cut_inside_its_pixel_data_is_unreadable(self, tmp_path):
|
|
"""The header parses, so only a full decode sees the missing IDAT bytes."""
|
|
path = tmp_path / "cut.png"
|
|
data = (ROOT / "data/fixtures/provenance/chatgpt-1.png").read_bytes()
|
|
path.write_bytes(data[: len(data) * 3 // 5])
|
|
assert metadata.unreadable_reason(path)
|
|
|
|
def test_a_trailing_partial_box_is_unreadable(self, tmp_path):
|
|
"""Cut inside the LAST box, so ftyp and moov both survive and only the walk sees it."""
|
|
data = MP4.read_bytes()
|
|
path = tmp_path / "cut.mp4"
|
|
path.write_bytes(data[:-100])
|
|
assert metadata.unreadable_reason(path)
|
|
|
|
def test_a_truncated_ebml_segment_is_unreadable(self, tmp_path):
|
|
"""A Segment that declares more bytes than the file holds, as a cut WebM does."""
|
|
path = tmp_path / "x.webm"
|
|
ebml_header = b"\x1a\x45\xdf\xa3\x80" # empty EBML header element
|
|
segment = b"\x18\x53\x80\x67\x88" + bytes(4) # declares 8 payload bytes, holds 4
|
|
path.write_bytes(ebml_header + segment)
|
|
assert metadata.unreadable_reason(path)
|
|
path.write_bytes(ebml_header + segment + bytes(4))
|
|
assert metadata.unreadable_reason(path) is None
|
|
|
|
def test_a_truncated_riff_is_unreadable(self, tmp_path):
|
|
path = tmp_path / "x.wav"
|
|
path.write_bytes(b"RIFF" + (100).to_bytes(4, "little") + b"WAVE" + bytes(20))
|
|
assert metadata.unreadable_reason(path)
|
|
path.write_bytes(b"RIFF" + (100).to_bytes(4, "little") + b"WAVE" + bytes(96))
|
|
assert metadata.unreadable_reason(path) is None
|
|
|
|
|
|
class TestSkippedStepsAreFailures:
|
|
@pytest.mark.parametrize(
|
|
"args",
|
|
[
|
|
["visible", "--mark", "doubao", "--no-detect"],
|
|
["erase", "--region", "2,2,10,10"],
|
|
],
|
|
)
|
|
def test_a_metadata_strip_that_left_markers_is_not_success(self, tmp_path, clean_png, monkeypatch, args):
|
|
"""Both commands used to swallow the strip and report only under --verbose."""
|
|
monkeypatch.setattr(metadata, "get_ai_metadata", lambda _path: {"c2pa": "survived"})
|
|
out = tmp_path / "out.png"
|
|
result = _invoke([*args, str(clean_png), "-o", str(out)])
|
|
assert result.exit_code == 1, result.output
|
|
assert "survived" in result.output
|
|
assert not out.exists(), "an output that kept its AI metadata was published"
|
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits")
|
|
def test_a_staged_output_keeps_the_default_permissions(self, tmp_path, clean_png):
|
|
"""The staged sibling is not pre-created, so it is not mkstemp's 0600."""
|
|
out = tmp_path / "out.png"
|
|
reference = tmp_path / "reference"
|
|
reference.write_bytes(b"")
|
|
result = _invoke(["erase", "--region", "2,2,10,10", str(clean_png), "-o", str(out)])
|
|
assert result.exit_code == 0, result.output
|
|
assert out.stat().st_mode & 0o777 == reference.stat().st_mode & 0o777
|
|
|
|
def test_the_library_visible_strip_is_verified(self, tmp_path, clean_png, monkeypatch):
|
|
from remove_ai_watermarks.api import MetadataStripIncomplete, remove_visible
|
|
|
|
monkeypatch.setattr(metadata, "get_ai_metadata", lambda _path: {"c2pa": "survived"})
|
|
with pytest.raises(MetadataStripIncomplete):
|
|
remove_visible(clean_png, tmp_path / "out.png")
|
|
assert list(tmp_path.iterdir()) == [clean_png], "the staged output leaked"
|
|
|
|
def test_the_invisible_output_strip_is_verified(self, tmp_path, monkeypatch):
|
|
"""The diffusion writer used to strip without re-scanning what it wrote."""
|
|
from PIL import Image
|
|
|
|
from remove_ai_watermarks._internal.watermark_remover import WatermarkRemover
|
|
|
|
monkeypatch.setattr(metadata, "get_ai_metadata", lambda _path: {"c2pa": "survived"})
|
|
remover = object.__new__(WatermarkRemover) # the writer needs no model state
|
|
with pytest.raises(RuntimeError, match="survived"):
|
|
remover._write_output(Image.new("RGB", (16, 16)), tmp_path / "out.png")
|
|
|
|
def test_metadata_remove_reports_a_missing_ffmpeg_without_a_traceback(self, tmp_path, monkeypatch):
|
|
monkeypatch.setattr(shutil, "which", lambda _name: None)
|
|
source = tmp_path / "x.webm"
|
|
source.write_bytes(b"\x1a\x45\xdf\xa3\x80") # an empty EBML header tiles the file
|
|
result = _invoke(["metadata", "--remove", str(source), "-o", str(tmp_path / "out.webm")])
|
|
assert result.exit_code == 1, result.output
|
|
assert isinstance(result.exception, SystemExit), result.exception
|
|
assert "ffmpeg" in result.output
|
|
|
|
@pytest.mark.usefixtures("cuda_refused")
|
|
def test_invisible_reports_an_engine_that_cannot_start(self, clean_png):
|
|
result = _invoke(["invisible", "--force", str(clean_png)])
|
|
assert result.exit_code == 1, result.output
|
|
assert isinstance(result.exception, SystemExit), result.exception
|
|
assert "CUDA-only" in result.output
|
|
|
|
@pytest.mark.usefixtures("cuda_refused")
|
|
def test_batch_reports_an_engine_that_cannot_start(self, tmp_path, clean_png):
|
|
indir = tmp_path / "in"
|
|
indir.mkdir()
|
|
shutil.copy(clean_png, indir / "clean.png")
|
|
result = _invoke(["batch", str(indir), "--mode", "invisible", "-o", str(tmp_path / "out")])
|
|
assert result.exit_code == 1, result.output
|
|
assert isinstance(result.exception, SystemExit), result.exception
|
|
assert "CUDA-only" in result.output
|
|
|
|
|
|
class TestEmptyBatch:
|
|
"""Nothing processed is the no-signal code, not success (decided 2026-10-18)."""
|
|
|
|
def test_image_batch(self, tmp_path):
|
|
indir = tmp_path / "in"
|
|
indir.mkdir()
|
|
(indir / "notes.txt").write_text("not an image")
|
|
result = _invoke(["batch", str(indir), "-o", str(tmp_path / "out")])
|
|
assert result.exit_code == 2, result.output
|
|
assert not (tmp_path / "out").exists()
|
|
|
|
def test_video_batch(self, tmp_path):
|
|
indir = tmp_path / "in"
|
|
indir.mkdir()
|
|
result = _invoke(["video", "batch", str(indir), "--mode", "metadata", "-o", str(tmp_path / "out")])
|
|
assert result.exit_code == 2, result.output
|
|
assert not (tmp_path / "out").exists()
|
|
|
|
|
|
def test_mcp_reports_a_child_that_could_not_start():
|
|
from remove_ai_watermarks import mcp_server
|
|
|
|
def broken(_args):
|
|
raise OSError("exec format error")
|
|
|
|
result = mcp_server.call_tool("identify", {"path": "x.png"}, runner=broken)
|
|
assert result["isError"] is True
|
|
assert "exec format error" in result["content"][0]["text"]
|
|
|
|
|
|
def test_mcp_does_not_call_an_empty_batch_a_scanned_file():
|
|
"""An empty batch directory exits 2 like no-signal, but nothing was read."""
|
|
import subprocess
|
|
|
|
from remove_ai_watermarks import mcp_server
|
|
|
|
def empty_batch(args):
|
|
return subprocess.CompletedProcess(args, 2, stdout="No supported images found", stderr="")
|
|
|
|
result = mcp_server.call_tool("batch", {"directory": "in"}, runner=empty_batch)
|
|
assert result["isError"] is False
|
|
assert "nothing to process" in result["content"][0]["text"]
|