diff --git a/.claude/rules/development.md b/.claude/rules/development.md index cb36882..905dfb6 100644 --- a/.claude/rules/development.md +++ b/.claude/rules/development.md @@ -17,7 +17,7 @@ description: Command contracts, project gate, typing boundaries, model-adjacent Every single-image command declares `source` with `dir_okay=False`; `batch` declares its directory with `file_okay=False`. Keep `tests/test_cli_robustness.py::TestDirectoryInputIsRejected` as the regression guard. -Exit-code and no-signal behavior is a public contract. Read the command-line section of [`../../docs/module-internals.md`](../../docs/module-internals.md) before changing it. Keep [`../../skills/remove-ai-watermarks/`](../../skills/remove-ai-watermarks/) in the same change when command routing, extras, exit codes, mark keys, or the intended-use boundary move. +Exit-code and no-signal behavior is a public contract. Read the command-line section of [`../../docs/module-internals.md`](../../docs/module-internals.md) before changing it. The dev `.venv` always carries the pixel stack, so it cannot show what the default install does. To check a no-signal or fail-closed change there, build a throwaway venv outside the repository (`uv venv /tmp/bare && VIRTUAL_ENV=/tmp/bare uv pip install .`) and run `/tmp/bare/bin/remove-ai-watermarks` against it. Keep [`../../skills/remove-ai-watermarks/`](../../skills/remove-ai-watermarks/) in the same change when command routing, extras, exit codes, mark keys, or the intended-use boundary move. `tests/test_agent_skill.py` enforces that by comparing the skill's prose against the live Click tree. Guard the SET, not the instances: per-flag comparisons only check what the skill already documents, so `--vendor`, `--pipeline auto` and then the whole `classify` and `verify-openai-synthid` commands each shipped with a green suite. `test_every_cli_command_is_named_somewhere_in_the_skill` closes that; raise `MIN_CLI_VERSION` in the skill's probe whenever a reference names a command, flag or value the previous release lacked, or the probe tells an agent its CLI is current right before it types something that build rejects. @@ -92,7 +92,7 @@ procedure in `docs/development.md`; do not revive the old text-matching bypass. Boundary modules for cv2, Torch, and Diffusers may carry narrow per-file relaxations for unknown third-party types. Keep pure-logic files strict, preserve the local piexif stub, and fix real errors before widening a pragma. -From a worktree, `uv run` imports the package from the MAIN checkout -- that is where the editable install points. A script measuring a worktree's edit must insert that worktree's `src` at `sys.path[0]` and assert `module.__file__` resolves inside it, or it silently compares unmodified code against itself. +From a worktree without its own `.venv`, `uv run` imports the package from the MAIN checkout, because that is where the editable install points; a worktree with its own `.venv` imports its own `src`. Either way, a script measuring a worktree's edit must assert `module.__file__` resolves inside that worktree (insert its `src` at `sys.path[0]` when it does not), or it silently compares unmodified code against itself. ## Research harnesses stay out of this repository diff --git a/docs/cli.md b/docs/cli.md index ff9ee7a..a414493 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -705,8 +705,9 @@ the tool contract are in the [MCP server guide](mcp-server.md). The CLI uses nonzero exit codes for meaningful incomplete outcomes, including no detected target on commands that would otherwise regenerate or create a -misleading unchanged result, processing errors, and a required invisible step -that could not run. +misleading unchanged result, a batch directory with nothing to process, +processing errors, an input that is not a readable image or video, and a +required invisible step that could not run. Scripts should check the process exit code and the output path. The detailed per-command contract is maintained in diff --git a/docs/known-limitations.md b/docs/known-limitations.md index dd08ab3..3a59eac 100644 --- a/docs/known-limitations.md +++ b/docs/known-limitations.md @@ -621,8 +621,9 @@ stream safely. raising. User facing callers must use `strip_and_verify` and inspect its surviving marker mapping before reporting success. `strip_and_verify` recovers when `image_io` can still decode the raster by normalizing the container and -checking again. A truly undecodable file still reports the surviving markers. -The CLI uses this verified path. +checking again. A truly undecodable file raises `ValueError` before anything is +written (since 2026-10-18; it used to be copied through with its surviving +markers). The CLI uses this verified path. ### Sixteen bit PNG metadata stripping diff --git a/docs/mcp-server.md b/docs/mcp-server.md index 9812432..d28298b 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -58,7 +58,10 @@ writes over an input: an `output` equal to the source, or an `output_dir` equal to the input directory, is refused before anything runs. Exit codes keep their CLI meaning. `0` and `2` are results, not tool errors, and -`2` means no supported signal was found, which is unknown rather than clean. +`2` means no supported signal was found, or a `batch` directory held nothing to +process, which is unknown rather than clean. An +input that is not a readable image or video exits `1`, so `identify` on it is a +tool error even though the command still printed a report. Click also exits `2` on a usage error such as a missing file; its `Usage:` line on stderr marks that case as a tool error. Every other nonzero exit code is returned with `isError: true` and the command's stderr. diff --git a/docs/module-internals.md b/docs/module-internals.md index c3bdc91..6298238 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -50,7 +50,28 @@ Important contracts: It decodes the source before the scrub gate and exits `1` when it cannot, because the gate reads undecodable bytes as "no target" and would otherwise report the no-signal code for a file it never read (fixed 2026-10-17). -- The two no-signal conditions currently share exit code `2`. +- The two no-signal conditions currently share exit code `2`, and so does a + `batch` or `video batch` directory with no supported file, which writes + nothing (decided 2026-10-18). +- An input that is not a readable container exits `1` from `identify`, + `metadata` and every `video` command, and fails its row in `batch` and `video + batch` (fixed 2026-10-18). The metadata scanners read raw bytes, so random + bytes, an empty file or a truncated PNG or MP4 used to print the same "No AI + metadata found" or "AI metadata stripped" a real file gets, with exit `0`. + `metadata.unreadable_reason` is the one probe: rasters must decode in Pillow or + OpenCV, ISOBMFF boxes and EBML elements must tile the file, and a RIFF file must + hold its declared size. `strip_and_verify` and `video._video_source` ask it, and + `identify` prints its report first, so an AI signal read from a truncated file + is still shown. Absent optional detectors are not unreadable input: without the + pixel stack `identify` keeps exit `0` and its caveat (decided 2026-10-18). + Regression coverage: `tests/test_fail_closed.py`. +- `visible` and `erase` strip and verify AI metadata on a staged sibling and exit + `1` with nothing published when a marker survives; they used to swallow a failed + strip unless `--verbose` was set. The library `remove_visible` does the same and + raises `MetadataStripIncomplete`, as `remove_all` already did, and the diffusion + writer behind `invisible` raises when its own strip leaves a marker. + `_internal.utils.staged_sibling` is the shared stage: it never pre-creates the + name, so the published file keeps the default permissions. - A quiet `metadata --check` and a successful `metadata --remove` (same for `video metadata`) end by repeating the `identify` limit: the pixel channel is untouched and a watermark such as SynthID has no local decoder once its @@ -241,7 +262,8 @@ as `workflow` and `prompt` uses the same structural walk; removal blanks the complete key and value in place. This preserves every box size, `stco`/`co64` offset, encoded stream byte apart from the Kling SEI tag below, and source-sized memory bound. -Publication is atomic, and a malformed top-level walk is copied unchanged. A +Publication is atomic, and a malformed top-level walk is copied unchanged, +although `strip_and_verify` refuses such a source before the stripper runs. A generic `AIGC` key whose value has no TC260 field is ignored. App-export JSON in a keyed or keyless metadata item is blanked the same way. @@ -822,7 +844,7 @@ Key contracts: written output for surviving markers. If the metadata-preserving decoder rejected the container but `image_io` can still decode its raster, `strip_and_verify` normalizes that raster and scans again. A truly undecodable - file keeps the surviving-marker result. + file raises `ValueError` before anything is written. Detection and removal must stay in parity. A new marker is incomplete until the scanner can find it, the remover can reach every supported placement, and a diff --git a/docs/python-api.md b/docs/python-api.md index 69cbc14..ff6751c 100644 --- a/docs/python-api.md +++ b/docs/python-api.md @@ -79,7 +79,8 @@ For a path input, `remove_visible` and `remove_visible_detailed`: - reads metadata provenance for the default `auto` sensitivity; - preserves a separate alpha channel; - writes the output when an output path is supplied; -- strips AI metadata from the written output by default; +- strips AI metadata from the written output by default, and raises + `MetadataStripIncomplete` without publishing it when a marker survives; - preserves the original bytes for a same-format no-op copy. ```python @@ -226,8 +227,9 @@ The complete field set, with the shipped defaults: and not to this table, because this seam had already drifted by eight fields before anything checked it. -If AI metadata survives the strip, `remove_all` raises `MetadataStripIncomplete` -**before** writing anything: an AI-readable output is worse than no output. +If AI metadata survives the strip, `remove_all` and a path-input `remove_visible` +raise `MetadataStripIncomplete` **before** publishing anything: an AI-readable +output is worse than no output. `remove_batch` runs one mode over a directory and never lets a single bad file end the run: @@ -525,7 +527,9 @@ It checks the written output and returns `(output_path, surviving_markers)`. When the first strip leaves markers in a malformed but raster-decodable image, it normalizes the container through `image_io` and checks again. That recovery path preserves the pixels but drops standard metadata. Treat a nonempty -`surviving_markers` mapping as a failure. +`surviving_markers` mapping as a failure. A source that is not a readable image, +video or audio container raises `ValueError` before anything is written; +`metadata.unreadable_reason(path)` returns the same reason without stripping. `remove_ai_metadata` is the lower level fail-safe transformer. It may copy an undecodable input through unchanged, so its return alone must not be presented diff --git a/docs/verification-plan.md b/docs/verification-plan.md index 6fd704f..87ca6fe 100644 --- a/docs/verification-plan.md +++ b/docs/verification-plan.md @@ -92,8 +92,8 @@ the row count is host-dependent rather than the fixed 68 recorded here before. E (~500 images spanning format x provenance x aspect ratio), asserting exit-code semantics rather than just absence of crash. -Known trap to encode: **exit 2 is triply overloaded** (no-visible-mark, no-invisible-signal, -Click usage error). A wrapper cannot distinguish them without parsing stderr. Either the +Known trap to encode: **exit 2 is overloaded four ways** (no-visible-mark, no-invisible-signal, +empty batch directory, Click usage error). A wrapper cannot distinguish them without parsing stderr. Either the sweep asserts on stderr, or the codes get split -- the latter is the better fix. #### Coverage method diff --git a/docs/watermarking-landscape.md b/docs/watermarking-landscape.md index 0d761d7..5c3cbc0 100644 --- a/docs/watermarking-landscape.md +++ b/docs/watermarking-landscape.md @@ -139,8 +139,8 @@ against primary sources before adding jurisdiction-specific claims. is added to generated images and videos "to further help you identify AI-generated content", and AI Pro and Ultra subscribers can turn it off. That makes it an AI-generation label under the [project scope](legal-and-safety.md#out-of-scope). No -template is registered, because no publication-cleared export has been captured; -slide decks export as PDF or PPTX, which the project does not process. +template is planned (decided 2026-10-18), so the mark stays undetected; slide +decks export as PDF or PPTX, which the project does not process. **Google Veo video marks use two incompatible visible designs.** Public raw clips verify the current four-point diamond and the legacy bottom-right `Veo` diff --git a/scripts/assets/visible-mark-candidates/README.md b/scripts/assets/visible-mark-candidates/README.md index dcc5830..cb40958 100644 --- a/scripts/assets/visible-mark-candidates/README.md +++ b/scripts/assets/visible-mark-candidates/README.md @@ -22,7 +22,7 @@ from the provider's canonical name, the provider is named separately. | --- | --- | --- | --- | | `samsung_en_alpha.png` | Samsung Galaxy AI label, English locale ("AI-generated content", bottom-left) | 5 corpus files | POS 0.11-0.30 vs NEG max 0.40 (binary); tophat/gray tried, no separation. The registered Italian engine scores 0.18-0.31 on the same files -- same layout class, wrong glyph template. | | `gemini_text_alpha.png` | "Generated with Gemini" text label (bottom-right; the registered gemini mark is the sparkle icon) | 3 corpus files | POS 0.07-0.22 vs NEG max 0.32; coverage gate finds the blob (0.29-0.35) but the Arial silhouette misses Google's letterforms. | -| `notebooklm_alpha.png` | NotebookLM wordmark (bottom-right) | 12 corpus files | locate geometry not yet fitted; POS max 0.12. | +| `notebooklm_alpha.png` | NotebookLM wordmark (bottom-right; the product is now Gemini Notebook) | 12 corpus files | parked: a template is not planned (decided 2026-10-18). Last measured POS max 0.12, locate geometry never fitted. | | `mindvideo_alpha.png` | MindVideo.AI (top-right) | 11 corpus files | POS 0.29-0.32 vs NEG max 0.30 -- borderline overlap, not shippable. | | `higgsfield_alpha.png` | `HIGGSFIELD AI` wordmark (Higgsfield AI, bottom-right; the boxed `AI` variant shares the cohort) | 5 wordmark files (16 in the boxed-AI OCR cluster) | POS max 0.26 vs NEG max 0.22 -- no separation; the mark may be two-part (wordmark + boxed AI) and needs a composed template. | | `jianying_alpha.png` | `剪映AI` wordmark (Jianying / 剪映, the China product in the CapCut family; bottom-right) | 2 corpus files | POS 0.29 vs NEG max 0.35. | diff --git a/skills/remove-ai-watermarks/SKILL.md b/skills/remove-ai-watermarks/SKILL.md index 080628a..de29ceb 100644 --- a/skills/remove-ai-watermarks/SKILL.md +++ b/skills/remove-ai-watermarks/SKILL.md @@ -157,8 +157,8 @@ Read the process exit code and whether `-o` exists. | Exit | Meaning | What to tell the user | | --- | --- | --- | | 0 | Requested work completed | The output path. Do not add "this is clean". | -| 2 | No targeted signal | Unknown, not clean. No new file from `visible`. | -| 1 | Error or partial `all` | Quote the error. Say which stage ran, if any. | +| 2 | No targeted signal, or a `batch` directory with no supported file | Unknown, not clean. No new file from `visible`. | +| 1 | Error, unreadable input, or partial `all` | Quote the error. Say which stage ran, if any. | Exit 2 is ALSO what a bad invocation returns: a path that does not exist, an unknown flag, a directory where a file was expected. Read the output before @@ -167,7 +167,9 @@ means the command never ran, so fix it and rerun; only a command that reached the image can say "no targeted signal". `identify` with no signal reports origin unknown. Never rephrase that as "no -watermark" or "this is clean". +watermark" or "this is clean". When `identify` or `metadata` exits 1 with +`cannot read`, the file is damaged or not an image or video: any report above +that line is incomplete, so say the file could not be read. `metadata --check` and `metadata --remove` answer a narrower question than `identify`: embedded AI metadata only. Both print that this is not a clean diff --git a/skills/remove-ai-watermarks/references/commands.md b/skills/remove-ai-watermarks/references/commands.md index f4e1938..6e10f6f 100644 --- a/skills/remove-ai-watermarks/references/commands.md +++ b/skills/remove-ai-watermarks/references/commands.md @@ -150,8 +150,8 @@ commands above directly. | Code | Meaning | | --- | --- | | 0 | Requested work completed | -| 2 | No targeted signal (`visible` / no-signal `invisible`) | -| 1 | Hard failure, or `all` skipped a required invisible stage | +| 2 | No targeted signal (`visible` / no-signal `invisible`), or a `batch` directory with no supported file | +| 1 | Hard failure, an input that is not a readable image or video (`identify` and `metadata` included), or `all` skipped a required invisible stage | A written file plus exit `1` from `all` is a partial result. diff --git a/src/remove_ai_watermarks/_internal/ebml.py b/src/remove_ai_watermarks/_internal/ebml.py index d5a2c21..fd303dd 100644 --- a/src/remove_ai_watermarks/_internal/ebml.py +++ b/src/remove_ai_watermarks/_internal/ebml.py @@ -16,7 +16,7 @@ if TYPE_CHECKING: from remove_ai_watermarks.metadata import MAX_TC260_VALUE_BYTES, parse_tc260_aigc_json -_EBML_MAGIC = b"\x1aE\xdf\xa3" +EBML_MAGIC = b"\x1aE\xdf\xa3" _SEGMENT_ID = 0x18538067 _TAGS_ID = 0x1254C367 _TAG_ID = 0x7373 @@ -95,6 +95,16 @@ def _iter_elements( pos = element_end +def iter_top_level_ends(stream: BinaryIO, size: int) -> Iterator[int]: + """End offset of each valid top-level element; stops early where a header fails. + + An element whose declared size runs past ``size`` fails its header, so a truncated + file yields a last end short of ``size``. An unknown-size Segment runs to ``size``. + """ + for _element_id, _payload_start, element_end in _iter_elements(stream, 0, size): + yield element_end + + def _read_bounded( stream: BinaryIO, start: int, @@ -133,7 +143,7 @@ def tc260_aigc_payloads(path: str | Path) -> tuple[bytes, ...]: found: list[bytes] = [] try: with open(path, "rb") as stream: - if stream.read(4) != _EBML_MAGIC: + if stream.read(4) != EBML_MAGIC: return () stream.seek(0, 2) file_size = stream.tell() diff --git a/src/remove_ai_watermarks/_internal/utils.py b/src/remove_ai_watermarks/_internal/utils.py index 63164dd..04ef256 100644 --- a/src/remove_ai_watermarks/_internal/utils.py +++ b/src/remove_ai_watermarks/_internal/utils.py @@ -2,11 +2,15 @@ from __future__ import annotations +import contextlib +import os +import secrets from typing import TYPE_CHECKING from remove_ai_watermarks._internal.constants import JPEG_SUFFIXES, SUPPORTED_FORMATS if TYPE_CHECKING: + from collections.abc import Generator from pathlib import Path @@ -22,3 +26,19 @@ def get_image_format(file_path: Path) -> str: inputs therefore use its PNG fallback, matching the established API contract. """ return "JPEG" if file_path.suffix.casefold() in JPEG_SUFFIXES else "PNG" + + +@contextlib.contextmanager +def staged_sibling(output: Path) -> Generator[Path]: + """Yield an unused sibling of ``output``, published over it only if the block succeeds. + + A strip that leaves AI metadata behind raises inside the block, so nothing reaches + ``output``. The name is not pre-created (``mkstemp`` would), so the writer gives the + published file the default permissions rather than ``mkstemp``'s 0600. + """ + staged = output.with_name(f".{output.stem}-{secrets.token_hex(8)}{output.suffix}") + try: + yield staged + os.replace(staged, output) + finally: + staged.unlink(missing_ok=True) diff --git a/src/remove_ai_watermarks/_internal/watermark_remover.py b/src/remove_ai_watermarks/_internal/watermark_remover.py index 7c949a9..06a5ef2 100644 --- a/src/remove_ai_watermarks/_internal/watermark_remover.py +++ b/src/remove_ai_watermarks/_internal/watermark_remover.py @@ -217,9 +217,12 @@ class WatermarkRemover: bgr = np.ascontiguousarray(np.asarray(image.convert("RGB"))[:, :, ::-1]) if not image_io.imwrite(str(output_path), bgr, display_tags_from=display_tags_from): image.save(output_path) - from remove_ai_watermarks.metadata import remove_ai_metadata + from remove_ai_watermarks.metadata import strip_and_verify - remove_ai_metadata(output_path, output_path, keep_standard=True) + # Verified, so a marker that survives fails the run instead of shipping with it. + _, leftover = strip_and_verify(output_path, output_path, keep_standard=True) + if leftover: + raise RuntimeError(f"AI metadata survived the strip: {', '.join(sorted(leftover))}") def remove_watermark( self, diff --git a/src/remove_ai_watermarks/api.py b/src/remove_ai_watermarks/api.py index 31fbda5..39f7ec4 100644 --- a/src/remove_ai_watermarks/api.py +++ b/src/remove_ai_watermarks/api.py @@ -132,33 +132,36 @@ def _write_visible_result( if not removed and not write_noop: return + import shutil + from remove_ai_watermarks import image_io + from remove_ai_watermarks._internal.utils import staged_sibling out_path = Path(output) out_path.parent.mkdir(parents=True, exist_ok=True) source_path = loaded.path - if not removed and source_path is not None and source_path.suffix.lower() == out_path.suffix.lower(): - # Copy the ORIGINAL bytes instead of lossily re-encoding a no-op. An in-place - # call needs no copy and would otherwise raise shutil.SameFileError. - if source_path.resolve() != out_path.resolve(): - import shutil - - shutil.copyfile(source_path, out_path) - else: + # Staged, so a strip that leaves AI metadata behind publishes nothing -- the + # MetadataStripIncomplete contract. + with staged_sibling(out_path) as staged: + if not removed and source_path is not None and source_path.suffix.lower() == out_path.suffix.lower(): + # Copy the ORIGINAL bytes instead of lossily re-encoding a no-op. + shutil.copyfile(source_path, staged) # imwrite is contractually NON-RAISING, so this bool is the only signal the file # was not created. Unchecked, the metadata strip below ran on a nonexistent path # and surfaced as a confusing "cannot read image " naming the OUTPUT path # (Tier E, 2026-07-20). Raise here so a library caller and the CLI both get an # accurate message about the write. - if not image_io.write_bgr_with_alpha( - out_path, result, loaded.alpha, display_tags_from=source_path, orientation_applied=False + elif not image_io.write_bgr_with_alpha( + staged, result, loaded.alpha, display_tags_from=source_path, orientation_applied=False ): raise OSError(f"failed to write output (is the destination writable?): {out_path}") - if strip_metadata: - from remove_ai_watermarks import metadata + if strip_metadata: + from remove_ai_watermarks.metadata import strip_and_verify - metadata.remove_ai_metadata(out_path, out_path) + _, leftover = strip_and_verify(staged, staged) + if leftover: + raise MetadataStripIncomplete(set(leftover)) def remove_visible( @@ -676,7 +679,9 @@ def remove_batch( from remove_ai_watermarks.optional_deps import pixels_available src_dir, out_dir = Path(directory), Path(output_dir) - out_dir.mkdir(parents=True, exist_ok=True) + sources = sorted(p for p in src_dir.iterdir() if is_supported_format(p)) + if sources: # an empty run leaves no empty output directory behind + out_dir.mkdir(parents=True, exist_ok=True) def say(path: Path, stage: str, detail: str) -> None: if progress is not None: @@ -685,7 +690,7 @@ def remove_batch( unavailable: list[Path] = [] errors: list[tuple[Path, str]] = [] items: list[BatchItemResult] = [] - for img_path in sorted(p for p in src_dir.iterdir() if is_supported_format(p)): + for img_path in sources: out_path = out_dir / img_path.name try: result = _run_batch_one(img_path, out_path, mode, backend, sensitivity, invisible, force, engine, say) diff --git a/src/remove_ai_watermarks/cli.py b/src/remove_ai_watermarks/cli.py index c8a93f3..b1af397 100644 --- a/src/remove_ai_watermarks/cli.py +++ b/src/remove_ai_watermarks/cli.py @@ -125,6 +125,27 @@ def _validate_image(path: Path) -> Path: return path +def _unreadable_input_exit(source: Path, *, after_report: bool, to_stderr: bool = False) -> None: + """Exit 1 when ``source`` is not a readable container; return otherwise. + + The metadata scanners read raw bytes, so on bytes that are not an image or video + they find nothing, and "nothing" used to print as the same no-signal answer a + real file gets, with exit 0. ``after_report`` says whether a partial report was + already printed above. ``to_stderr`` keeps a ``--json`` stdout parseable. + """ + from remove_ai_watermarks.metadata import unreadable_reason + + if (reason := unreadable_reason(source)) is None: + return + scanned = "The scan above is incomplete" if after_report else "Nothing was scanned" + message = f"Error: cannot read {source.name}: {reason}. {scanned}, not a no-signal result." + if to_stderr: + click.echo(message, err=True) + else: + console.print(f" {message}") + raise SystemExit(1) + + def _resolved_strength_for_display( source: Path, strength: float | None, @@ -427,6 +448,8 @@ def _write_output_or_exit( bgr: NDArray[Any], alpha: NDArray[Any] | None, display_source: Path | None = None, + *, + strip_metadata: bool = False, ) -> None: """Write the final image, or fail with a readable error instead of a traceback. @@ -438,11 +461,29 @@ def _write_output_or_exit( Regression: `tests/test_cli_robustness.py::TestFailedWriteIsReported`. `display_source` is the file whose decode produced `bgr`; its ICC profile and EXIF - orientation ride along into the re-encoded output (issue #98).""" + orientation ride along into the re-encoded output (issue #98). + + ``strip_metadata`` strips and verifies AI metadata on a staged copy before it is + published, so a marker that survives exits 1 with nothing written. Both commands + using this used to swallow a failed strip unless ``--verbose`` was set.""" + from remove_ai_watermarks._internal.utils import staged_sibling + from remove_ai_watermarks.metadata import strip_and_verify + output.parent.mkdir(parents=True, exist_ok=True) - if not image_io.write_bgr_with_alpha(output, bgr, alpha, display_tags_from=display_source): - console.print(f" Error: failed to write output (is the destination writable?): {output}") - raise SystemExit(1) + with staged_sibling(output) as staged: + if not image_io.write_bgr_with_alpha(staged, bgr, alpha, display_tags_from=display_source): + console.print(f" Error: failed to write output (is the destination writable?): {output}") + raise SystemExit(1) + if not strip_metadata: + return + try: + _, leftover = strip_and_verify(staged, staged) + except (OSError, RuntimeError, ValueError) as e: + console.print(f" Error: failed to strip AI metadata from {output}: {e}") + raise SystemExit(1) from e + if leftover: + console.print(f" Error: AI metadata survived in {output}: {', '.join(sorted(leftover))}") + raise SystemExit(1) def _no_visible_mark_exit(source: Path) -> NoReturn: @@ -511,6 +552,10 @@ def _no_visible_mark_exit(source: Path) -> NoReturn: # instead of treating an unchanged image as a completed removal. EXIT_NO_INVISIBLE_SIGNAL = 2 +# Also 2: a batch directory with no supported file processed nothing, which is a +# no-signal outcome a wrapper must not read as a cleaned directory. +EXIT_NOTHING_PROCESSED = EXIT_NO_INVISIBLE_SIGNAL + def _no_invisible_signal_exit(source: Path) -> NoReturn: """Explain why the diffusion scrub was skipped, then exit non-zero. @@ -636,7 +681,6 @@ def _run_visible_auto( def _run_visible_explicit( - ctx: click.Context, source: Path, output: Path, *, @@ -685,15 +729,7 @@ def _run_visible_explicit( raise SystemExit(1) from e elapsed = time.monotonic() - t0 - _write_output_or_exit(output, result, alpha, display_source=source) - if strip_metadata: - try: - from remove_ai_watermarks.metadata import remove_ai_metadata - - remove_ai_metadata(output, output) - except Exception as e: - if ctx.obj.get("verbose"): - console.print(f" Warning: Failed to strip metadata: {e}") + _write_output_or_exit(output, result, alpha, display_source=source, strip_metadata=strip_metadata) size_kb = output.stat().st_size / 1024 console.print(f" Saved: {output} ({size_kb:.0f} KB, {elapsed:.2f}s)") @@ -713,10 +749,8 @@ def _run_visible_explicit( @_visible_backend_option @_visible_sensitivity_option @click.option("--strip-metadata/--keep-metadata", default=True, help="Strip AI metadata from output.") -@click.pass_context @_pixels_required def cmd_visible( - ctx: click.Context, source: Path, output: Path | None, detect: bool, @@ -754,7 +788,6 @@ def cmd_visible( return _run_visible_explicit( - ctx, source, output, detect=detect, @@ -795,10 +828,8 @@ def _parse_region(spec: str) -> tuple[int, int, int, int]: @click.option("--inpaint-method", type=click.Choice(["telea", "ns"]), default="telea", help="cv2 inpaint method.") @click.option("--dilate", type=int, default=3, help="Grow the box by this many px before inpainting.") @click.option("--strip-metadata/--keep-metadata", default=True, help="Strip AI metadata from output.") -@click.pass_context @_pixels_required def cmd_erase( - ctx: click.Context, source: Path, regions: tuple[str, ...], output: Path | None, @@ -839,16 +870,7 @@ def cmd_erase( raise SystemExit(1) from e elapsed = time.monotonic() - t0 - _write_output_or_exit(output, result, alpha, display_source=source) - - if strip_metadata: - try: - from remove_ai_watermarks.metadata import remove_ai_metadata - - remove_ai_metadata(output, output) - except Exception as e: - if ctx.obj.get("verbose"): - console.print(f" Warning: Failed to strip metadata: {e}") + _write_output_or_exit(output, result, alpha, display_source=source, strip_metadata=strip_metadata) size_kb = output.stat().st_size / 1024 console.print(f" Erased {len(boxes)} region(s) -> {output} ({size_kb:.0f} KB, {elapsed:.2f}s)") @@ -937,13 +959,17 @@ def cmd_invisible( def progress_cb(msg: str) -> None: console.print(f" {msg}") - engine = InvisibleEngine( - pipeline=pipeline, - hf_token=hf_token, - progress_callback=progress_cb, - controlnet_conditioning_scale=controlnet_scale, - cpu_offload=cpu_offload, - ) + try: + engine = InvisibleEngine( + pipeline=pipeline, + hf_token=hf_token, + progress_callback=progress_cb, + controlnet_conditioning_scale=controlnet_scale, + cpu_offload=cpu_offload, + ) + except (OSError, RuntimeError, ValueError) as exc: # e.g. no CUDA device + console.print(f" Error: {exc}") + raise SystemExit(1) from exc # Detect the SynthID vendor from the ORIGINAL (before processing strips C2PA) so the # displayed and executed strength agree on the vendor-adaptive default. An explicit @@ -1052,6 +1078,7 @@ def cmd_metadata( # accepts video/audio containers, so the image-format warning would misfire. # click's `exists=True` on the argument already enforces the file exists. _banner() + _unreadable_input_exit(source, after_report=False) if check or (not remove): has_ai = has_ai_metadata(source) @@ -1064,14 +1091,13 @@ def cmd_metadata( # Remove try: out, leftover = strip_and_verify(source, output, keep_standard=keep_standard) - except (OSError, ValueError) as e: # unreadable / truncated / non-image (PIL raises OSError subclasses) + except (OSError, RuntimeError, ValueError) as e: # unreadable input, or ffmpeg absent or failing console.print(f" Error: cannot process {source.name}: {e}") raise SystemExit(1) from e if leftover: console.print(f" FAILED: {len(leftover)} AI metadata marker(s) survived in {out}") console.print(f" still present: {', '.join(sorted(leftover))}") - console.print(" the file could not be decoded, so it was copied through unchanged") raise SystemExit(1) console.print(f" AI metadata stripped -> {out}") _print_metadata_not_a_clean_verdict() @@ -1440,6 +1466,9 @@ def cmd_video_batch( except (OSError, RuntimeError, ValueError) as e: raise click.ClickException(str(e)) from e + if not result.items: + console.print(f" No supported videos found in {directory}; nothing was processed") + raise SystemExit(EXIT_NOTHING_PROCESSED) for item in result.items: if item.error is not None: console.print(f" FAILED {item.source.name}: {item.error}") @@ -1488,6 +1517,7 @@ def cmd_identify(ctx: click.Context, source: Path, no_visible: bool, as_json: bo if as_json: click.echo(json.dumps(asdict(report), default=str, indent=2)) + _unreadable_input_exit(source, after_report=True, to_stderr=True) return _banner() @@ -1534,6 +1564,7 @@ def cmd_identify(ctx: click.Context, source: Path, no_visible: bool, as_json: bo console.print("\n Caveats:") for c in report.caveats: console.print(f" - {c}") + _unreadable_input_exit(source, after_report=True) # ── Metadata-free photo classification (opt-in, never a tail of identify) ── @@ -1824,13 +1855,15 @@ def cmd_batch( if output_dir is None: output_dir = directory.parent / (directory.name + "_clean") - output_dir.mkdir(parents=True, exist_ok=True) images = sorted(p for p in directory.iterdir() if is_supported_format(p)) if not images: - console.print(f"No supported images found in {directory}") - return + # Nothing processed is the no-signal code, not success: a wrapper must not read + # an empty run as a cleaned directory. + console.print(f"No supported images found in {directory}; nothing was processed") + raise SystemExit(EXIT_NOTHING_PROCESSED) + output_dir.mkdir(parents=True, exist_ok=True) console.print(f" Found {len(images)} images in {directory}") console.print(f" Output -> {output_dir}") @@ -1859,6 +1892,11 @@ def cmd_batch( if ctx.obj.get("verbose"): console.print(f" {img.name}: {stage}{f' {detail}' if detail else ''}") + try: + engine = _batch_engine(mode, invisible_options) + except (OSError, RuntimeError, ValueError) as e: # e.g. no CUDA device + console.print(f" Error: {e}") + raise SystemExit(1) from e summary = api_remove_batch( directory, output_dir, @@ -1867,7 +1905,7 @@ def cmd_batch( sensitivity=_parse_sensitivity(sensitivity), invisible=invisible_options, force=force, - engine=_batch_engine(mode, invisible_options), + engine=engine, progress=on_progress, ) diff --git a/src/remove_ai_watermarks/mcp_server.py b/src/remove_ai_watermarks/mcp_server.py index 2236960..e55834c 100644 --- a/src/remove_ai_watermarks/mcp_server.py +++ b/src/remove_ai_watermarks/mcp_server.py @@ -207,7 +207,11 @@ def call_tool(name: str, arguments: dict[str, Any], runner: Runner = run_cli) -> args, output = _COMMANDS[name](_validated(name, arguments)) except ToolInputError as exc: return {"content": [{"type": "text", "text": f"Invalid arguments: {exc}"}], "isError": True} - completed = runner(args) + try: + completed = runner(args) + except OSError as exc: # the child never started, so there is no exit code to relay + log.warning("CLI command could not start: %s: %s", args, exc) + return {"content": [{"type": "text", "text": f"The command could not start: {exc}"}], "isError": True} code = completed.returncode text = completed.stdout.strip() stderr = completed.stderr.strip()[-4000:] @@ -219,13 +223,14 @@ def call_tool(name: str, arguments: dict[str, Any], runner: Runner = run_cli) -> structured["stdout"] = text if stderr: structured["stderr"] = stderr - # The CLI's own exit 2 means no supported signal, a result. Click also exits 2 on + # The CLI's own exit 2 means no supported signal, or a batch directory with nothing + # to process, both results. Click also exits 2 on # a usage error, such as a missing file, and prints its usage line on stderr. no_signal = code == 2 and "Usage:" not in stderr is_error = code != 0 and not no_signal summary = f"exit code {code}" if no_signal: - summary += " (no supported signal found; this is unknown, not proof the file is clean)" + summary += " (no supported signal found, or nothing to process; this is unknown, not proof of a clean file)" body = "\n\n".join(part for part in (summary, text, stderr if is_error else "") if part) return {"content": [{"type": "text", "text": body}], "structuredContent": structured, "isError": is_error} diff --git a/src/remove_ai_watermarks/metadata.py b/src/remove_ai_watermarks/metadata.py index 7ed335a..c846c4f 100644 --- a/src/remove_ai_watermarks/metadata.py +++ b/src/remove_ai_watermarks/metadata.py @@ -1856,6 +1856,92 @@ def _sniff_image_format(head: bytes) -> str | None: return None +_JXL_CONTAINER_MAGIC = b"\x00\x00\x00\x0cJXL \r\n\x87\n" +# Accepted on their magic alone: no local parser walks them, and the ffmpeg strip path +# fails on its own when one is unreadable. JPEG XL codestream, FLV, ID3, FLAC, Ogg. +_MAGIC_ONLY: tuple[bytes, ...] = (b"\xff\x0a", b"FLV", b"ID3", b"fLaC", b"OggS") +_PILLOW_RASTERS = frozenset({"png", "jpeg", "webp", "gif", "bmp", "tiff"}) + + +def _tiling_reason(kind: str, ends: Iterable[int], size: int) -> str | None: + """None when the last top-level element ends at EOF; a truncation leaves a gap.""" + end = max(ends, default=0) # top-level end offsets only increase + if end == size: + return None + return f"the {kind} structure stops at byte {end} of {size} (truncated or corrupt)" + + +def _pixel_stack_decodes(path: Path) -> bool: + """Whether the optional pixel stack decodes ``path``; False when it is absent.""" + from remove_ai_watermarks.optional_deps import pixels_available + + if not pixels_available(): + return False + from remove_ai_watermarks import image_io + + return image_io.imread(path) is not None + + +@functools.lru_cache(maxsize=8) +def _unreadable_reason_cached(path_str: str, _mtime_ns: int, size: int) -> str | None: + from pathlib import Path as _Path + + from PIL import Image + + from remove_ai_watermarks._internal.ebml import EBML_MAGIC, iter_top_level_ends + from remove_ai_watermarks._internal.isobmff import iter_file_boxes + from remove_ai_watermarks.forensic_metadata import sniff_format + + path = _Path(path_str) + if size == 0: + return "the file is empty" + with path.open("rb") as stream: + head = stream.read(32) + kind = sniff_format(head) + if kind.startswith("isobmff:") or head.startswith(_JXL_CONTAINER_MAGIC): + return _tiling_reason("ISOBMFF box", (end for _s, end, _t, _p in iter_file_boxes(stream, 0, size)), size) + if head.startswith(EBML_MAGIC): + return _tiling_reason("EBML element", iter_top_level_ends(stream, size), size) + if kind in _PILLOW_RASTERS: + try: + with Image.open(path) as image: + image.load() + except Image.DecompressionBombError: + return None # readable, merely too large for a precautionary full decode + except Exception as exc: + # The pixel stack decodes some rasters Pillow rejects (a PNG with a bad eXIf + # CRC), and strip_and_verify recovers those through it, so they are readable. + return None if _pixel_stack_decodes(path) else f"the image cannot be decoded: {exc}" + return None + if head.startswith(b"RIFF") and len(head) >= 8: + declared = 8 + struct.unpack_from(" 1 and head[0] == 0xFF and head[1] & 0xE0 == 0xE0 + if head.startswith(_MAGIC_ONLY) or mpeg_audio_sync: + return None + return "the content is not a recognized image, video or audio container" + + +def unreadable_reason(path: Path) -> str | None: + """Why ``path`` is not a readable image, video or audio container, or None. + + The metadata scanners read raw bytes, so on a file that is not a container at all + they find nothing and say so, which is the same answer a real file without metadata + gets. Callers that report "no metadata" or "stripped" ask this first, so an + unreadable input fails instead of reading as clean. Rasters must fully decode in + Pillow or, when it is installed, the pixel stack; ISOBMFF boxes and EBML elements + must tile the file, which is what a truncation breaks; a RIFF file must hold its + declared size. The formats in ``_MAGIC_ONLY`` and MPEG audio frames have no local + walker and pass on their magic. Memoized on (path, mtime, size): one command asks + several times about the same input. + """ + try: + stat = path.stat() + return _unreadable_reason_cached(str(path), stat.st_mtime_ns, stat.st_size) + except OSError as exc: + return f"cannot be read: {exc}" + + def strip_and_verify( source_path: Path, output_path: Path | None = None, @@ -1866,10 +1952,12 @@ def strip_and_verify( :func:`remove_ai_metadata` is deliberately fail-safe: a file PIL cannot decode is copied through UNCHANGED rather than crashing a caller, and the path it returns is - indistinguishable from a real strip. Any caller that reports an outcome to a user - therefore cannot tell a no-op from a success. A Samsung C2PA compatibility case - exposed this when `metadata --remove` reported success while the output still read - as AI. + indistinguishable from a real strip. This wrapper is what a caller reporting an + outcome uses instead: it refuses a source :func:`unreadable_reason` rejects with + ``ValueError`` before writing anything, because a copy-through of bytes nothing can + parse may carry no marker a re-scan sees, and then re-scans what it wrote. A + Samsung C2PA compatibility case exposed the original gap when `metadata --remove` + reported success while the output still read as AI. If markers survive but OpenCV can still decode the raster, the verified path normalizes the container through :mod:`image_io` and scans once more. This @@ -1878,6 +1966,8 @@ def strip_and_verify( Returns ``(output_path, surviving_markers)``; an empty mapping means a real strip. """ + if (reason := unreadable_reason(source_path)) is not None: + raise ValueError(f"cannot read {source_path.name}: {reason}") out = remove_ai_metadata(source_path, output_path, keep_standard=keep_standard) remaining = get_ai_metadata(out) if not remaining: diff --git a/src/remove_ai_watermarks/video.py b/src/remove_ai_watermarks/video.py index 79ffb6b..d58418a 100644 --- a/src/remove_ai_watermarks/video.py +++ b/src/remove_ai_watermarks/video.py @@ -32,7 +32,6 @@ _EBML_VIDEO_EXTENSIONS: frozenset[str] = frozenset({".webm", ".mkv"}) _RIFF_VIDEO_EXTENSIONS: frozenset[str] = frozenset({".avi"}) _FLV_VIDEO_EXTENSIONS: frozenset[str] = frozenset({".flv"}) _REGENERATED_VIDEO_EXTENSIONS: frozenset[str] = _ISOBMFF_VIDEO_EXTENSIONS -_EBML_MAGIC = b"\x1aE\xdf\xa3" def _require_video_runtime() -> None: @@ -237,17 +236,23 @@ def _video_source(source: str | Path) -> Path: if path.suffix.lower() not in VIDEO_EXTENSIONS: supported = ", ".join(sorted(VIDEO_EXTENSIONS)) raise ValueError(f"Unsupported video format {path.suffix or ''}; expected one of: {supported}") + from remove_ai_watermarks._internal.ebml import EBML_MAGIC + with path.open("rb") as stream: head = stream.read(12) suffix = path.suffix.lower() matches_container = ( (suffix in _ISOBMFF_VIDEO_EXTENSIONS and len(head) >= 8 and head[4:8] == b"ftyp") - or (suffix in _EBML_VIDEO_EXTENSIONS and head.startswith(_EBML_MAGIC)) + or (suffix in _EBML_VIDEO_EXTENSIONS and head.startswith(EBML_MAGIC)) or (suffix in _RIFF_VIDEO_EXTENSIONS and len(head) >= 12 and head[:4] == b"RIFF" and head[8:12] == b"AVI ") or (suffix in _FLV_VIDEO_EXTENSIONS and head.startswith(b"FLV")) ) if not matches_container: raise ValueError(f"Video content does not match its {suffix} extension: {path}") + from remove_ai_watermarks.metadata import unreadable_reason + + if (reason := unreadable_reason(path)) is not None: + raise ValueError(f"Cannot read video {path}: {reason}") return path @@ -714,13 +719,13 @@ def remove_video_batch( ) if output_path.resolve() == directory_path.resolve(): raise ValueError("Video batch output directory must differ from the source directory") - output_path.mkdir(parents=True, exist_ok=True) - sources = tuple( path for path in sorted(directory_path.iterdir(), key=lambda candidate: candidate.name.lower()) if path.is_file() and path.suffix.lower() in VIDEO_EXTENSIONS ) + if sources: + output_path.mkdir(parents=True, exist_ok=True) items: list[VideoBatchItem] = [] invisible_runtime: VideoVaeRuntime | None = None invisible_runtime_error: str | None = None diff --git a/tests/test_agent_skill.py b/tests/test_agent_skill.py index a52f883..653b6f2 100644 --- a/tests/test_agent_skill.py +++ b/tests/test_agent_skill.py @@ -347,7 +347,7 @@ def test_missing_pixel_stack_names_the_extra_instead_of_a_traceback( source = tmp_path / "image.png" source.write_bytes((ROOT / "data" / "fixtures" / "provenance" / "chatgpt-1.png").read_bytes()) clip = tmp_path / "clip.mp4" - clip.write_bytes(b"\x00\x00\x00\x18ftypmp42") + clip.write_bytes(b"\x00\x00\x00\x10ftypmp42\x00\x00\x00\x00") # one whole box, so the input reads substitutions = { "SOURCE": str(source), "CLIP": str(clip), diff --git a/tests/test_cli.py b/tests/test_cli.py index 0211614..be0e12d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -867,7 +867,7 @@ class TestBatchCommand: empty_dir = tmp_path / "empty" empty_dir.mkdir() result = runner.invoke(main, ["batch", str(empty_dir)]) - assert result.exit_code == 0 + assert result.exit_code == 2 # nothing processed is the no-signal code (2026-10-18) assert "No supported images" in result.output def test_batch_visible_mode(self, runner, tmp_path): @@ -1309,10 +1309,10 @@ def test_visible_backend_runtime_error_exits_cleanly(runner, tmp_path, monkeypat @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 ~0.2% - truncated files) must NEVER leak a raw PIL/OSError/ValueError traceback. `metadata - --remove` is fail-safe -- an undecodable file is copied through unchanged (exit 0), - a strip that cannot parse the file is a no-op, not a crash; `visible` must decode to - remove a mark, so it is a clean error (exit 1). Found by the runtime mode fuzz.""" + truncated files) must NEVER leak a raw PIL/OSError/ValueError traceback, and both + commands fail with a clean exit 1. `metadata --remove` used to copy the bytes + through and exit 0, which read as a completed strip; since 2026-10-18 it writes + nothing (`tests/test_fail_closed.py`). Found by the runtime mode fuzz.""" bad = tmp_path / name bad.write_bytes(content) out = tmp_path / "out.png" @@ -1320,12 +1320,9 @@ def test_unreadable_input_exits_cleanly(runner, tmp_path, name, content, cmd): assert result.exception is None or isinstance(result.exception, SystemExit), ( f"leaked a raw traceback: {result.exception!r}" ) - if cmd[0] == "metadata": - assert result.exit_code == 0, result.output # fail-safe copy-through - assert out.read_bytes() == content # input passed through unchanged - else: - assert result.exit_code == 1, result.output # cannot remove a mark from unreadable input - assert "Error" in result.output + assert result.exit_code == 1, result.output + assert "Error" in result.output + assert not out.exists() @pytest.mark.parametrize( diff --git a/tests/test_fail_closed.py b/tests/test_fail_closed.py new file mode 100644 index 0000000..f04fdfa --- /dev/null +++ b/tests/test_fail_closed.py @@ -0,0 +1,311 @@ +"""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"] diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 6d4b0af..eacc1b6 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -185,7 +185,7 @@ def test_only_failures_are_tool_errors(returncode: int, stderr: str, is_error: b assert result["isError"] is is_error assert result["structuredContent"]["exit_code"] == returncode - assert ("not proof the file is clean" in result["content"][0]["text"]) is (returncode == 2 and not is_error) + assert ("not proof of a clean file" in result["content"][0]["text"]) is (returncode == 2 and not is_error) if is_error: assert stderr.splitlines()[-1] in result["content"][0]["text"] diff --git a/tests/test_metadata.py b/tests/test_metadata.py index e45364b..8bdd0d5 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -334,17 +334,20 @@ class TestHasAiMetadata: assert after is not None assert np.array_equal(after, before) - def test_strip_and_verify_reports_markers_in_undecodable_copy_through(self, tmp_path: Path): - """A truly undecodable file keeps the established fail-safe result.""" + def test_strip_and_verify_refuses_an_undecodable_file(self, tmp_path: Path): + """A truly undecodable file raises before anything is written. + + It used to be copied through and reported with its surviving markers, but a + copy-through of bytes no scanner can parse may carry nothing a re-scan sees, + and then it verified as a real strip (`tests/test_fail_closed.py`). + """ src = _corrupt_c2pa_png(tmp_path / "source.png") src.write_bytes(src.read_bytes()[: src.stat().st_size // 2]) out = tmp_path / "copy.png" - result, remaining = strip_and_verify(src, out) - - assert result == out - assert "c2pa_manifest" in remaining - assert out.read_bytes() == src.read_bytes() + with pytest.raises(ValueError, match=r"cannot read source\.png"): + strip_and_verify(src, out) + assert not out.exists() class TestC2paMarkerIn: