fix(metadata): remove_ai_metadata is fail-safe on a truncated/corrupt image

PIL raises OSError decoding a truncated file, which crashed remove_ai_metadata
(the PNG/WebP PIL re-save path) -- a direct library caller like a web worker
500s on a partial upload. ~0.2% of the real upload corpus is truncated. The
strip now probes decodability first and, on failure, copies the input through
unchanged and returns rather than raising (we cannot strip what we cannot parse),
mirroring strip_c2pa_boxes' fail-safe. identify already handled these.

The CLI `metadata --remove` on an unreadable file therefore now exits 0 with the
input passed through, not a clean error (exit 1) -- `visible`, which must decode
to remove a mark, still exits 1. Test updated to the per-command contract.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Victor Kuznetsov
2026-07-13 11:54:33 +03:00
co-authored by Claude Opus 4.8
parent 1dc6fee91a
commit 220803c4d0
4 changed files with 53 additions and 8 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+17
View File
@@ -1120,6 +1120,23 @@ def remove_ai_metadata(
):
return output_path
# Fail-safe for a truncated / corrupt image: PIL raises OSError when it decodes a
# partial file (`img.copy()` / `img.save()` below), which would crash a direct
# library caller (a web worker 500s on a partial upload). Probe decodability first;
# if it fails, copy the input through unchanged and return -- we cannot strip what we
# cannot parse, but we never raise (mirrors strip_c2pa_boxes' fail-safe).
try:
with Image.open(source_path) as _probe:
_probe.load()
except Exception:
logger.warning("Could not decode %s to strip metadata (truncated/corrupt); copied through", source_path)
if output_path != source_path:
import shutil
output_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(source_path, output_path)
return output_path
# Read image and filter metadata
with Image.open(source_path) as img:
img = img.copy()
+14 -7
View File
@@ -949,14 +949,21 @@ 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
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."""
"""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."""
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
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
+21
View File
@@ -226,6 +226,27 @@ class TestHasAiMetadata:
assert np.array_equal(before, after), "pixels changed: the DCT scan was re-encoded"
assert not has_ai_metadata(out), "IPTC AI marker in XMP survived the strip"
def test_remove_ai_metadata_failsafe_on_truncated_png(self, tmp_path: Path):
"""Regression: a truncated / corrupt image must NOT crash remove_ai_metadata (a
direct library caller like a web worker would 500 on a partial upload). PIL raises
OSError decoding a truncated PNG; the strip must fail SAFE -- copy the input through
unchanged and return, mirroring strip_c2pa_boxes. Real prod corpus: ~0.2% of
uploads are truncated."""
import cv2
import numpy as np
from remove_ai_watermarks.metadata import remove_ai_metadata
real = tmp_path / "real.png"
cv2.imwrite(str(real), np.random.default_rng(0).integers(0, 256, (128, 128, 3), dtype=np.uint8))
truncated = tmp_path / "truncated.png"
truncated.write_bytes(real.read_bytes()[: real.stat().st_size // 2]) # chop the IDAT stream
out = tmp_path / "out.png"
# Must not raise; output is the input copied through (undecodable -> nothing to strip).
result = remove_ai_metadata(truncated, out)
assert result == out
assert out.read_bytes() == truncated.read_bytes()
class TestC2paMarkerIn:
"""The C2PA presence check requires a JUMBF wrapper or the C2PA uuid box, so