US07-03: Harden Media and Metadata Edge Cases

This commit is contained in:
2026-08-17 00:56:39 +02:00
parent d2e44b5657
commit 691bd45825
18 changed files with 1525 additions and 53 deletions

325
tests/fixtures/media_corpus.py vendored Normal file
View File

@@ -0,0 +1,325 @@
"""The golden media corpus: every format, orientation, profile, damage, and
metadata case this application claims to survive (US07-03, concept §18).
``CASES`` is the manifest and the authority. Each entry declares a stable logical
id (never a path), how the file is generated, and what the pipeline must do with
it — decode it, or refuse it with one precise error code. Tests parametrize over the
manifest, so a case that is added here without an expectation, or an expectation
that stops holding, fails the suite rather than quietly going untested.
Everything is generated, never committed: fixed pixel seeds, fixed EXIF strings, no
clock, no network, no personal data. Regeneration is byte-stable, which
``test_media_hardening.py`` proves by building the corpus twice and comparing
checksums — a golden corpus that drifts is not golden.
"""
from __future__ import annotations
import struct
import zlib
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable
import numpy as np
from PIL import Image
# Error codes the pipeline may answer with; ``None`` means "must render".
UNSUPPORTED = "unsupported_image"
TOO_LARGE = "image_too_large"
# One fixed capture time for every metadata case: the corpus must not depend on when
# it was generated.
CAPTURE_TIME = "2019:07:14 10:30:00"
@dataclass(frozen=True)
class MediaCase:
id: str
filename: str
kind: str # format | orientation | profile | damaged | metadata
build: Callable[[Path], None]
expect_error: str | None = None
# Declared for the metadata cases: exiftool arguments applied after the pixels
# are written, and the user fields that must survive every later stage.
exif_args: tuple[str, ...] = ()
preserved_fields: tuple[str, ...] = ()
notes: str = ""
tags: tuple[str, ...] = field(default_factory=tuple)
# ── generators ────────────────────────────────────────────────────────────────
def _pixels(width: int, height: int, seed: int, bands: int = 3) -> np.ndarray:
return np.random.default_rng(seed).integers(0, 256, (height, width, bands), dtype=np.uint8)
def _save(path: Path, image: Image.Image, **kwargs) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
image.save(path, **kwargs)
def _jpeg(width=320, height=240, seed=1, **kwargs):
def build(path: Path) -> None:
_save(path, Image.fromarray(_pixels(width, height, seed)), quality=90, **kwargs)
return build
def _oriented_jpeg(orientation: int):
def build(path: Path) -> None:
image = Image.fromarray(_pixels(400, 200, 5)) # landscape source
exif = image.getexif()
exif[274] = orientation # 0x0112 Orientation
_save(path, image, exif=exif, quality=90)
return build
def _rotated_pixels(path: Path) -> None:
"""The same scene rotated in the pixels instead of in a tag."""
_save(path, Image.fromarray(_pixels(400, 200, 5)).transpose(Image.ROTATE_90), quality=90)
def _png(alpha: bool = False, seed: int = 2):
def build(path: Path) -> None:
if alpha:
_save(path, Image.fromarray(_pixels(120, 90, seed, bands=4), "RGBA"))
else:
_save(path, Image.fromarray(_pixels(120, 90, seed)))
return build
def _webp(path: Path) -> None:
_save(path, Image.fromarray(_pixels(150, 100, 3)), quality=80)
def _tiff(path: Path) -> None:
_save(path, Image.fromarray(_pixels(140, 110, 4)))
def _grayscale(path: Path) -> None:
_save(path, Image.fromarray(_pixels(100, 100, 6)).convert("L"), quality=90)
def _cmyk(path: Path) -> None:
_save(path, Image.fromarray(_pixels(100, 100, 7)).convert("CMYK"), quality=90)
def _tiny(path: Path) -> None:
_save(path, Image.fromarray(_pixels(1, 1, 8)))
def _icc_tagged(path: Path) -> None:
"""A profile-bearing image: the colour-managed decode path must run."""
from PIL import ImageCms
profile = bytearray(ImageCms.ImageCmsProfile(ImageCms.createProfile("sRGB")).tobytes())
# An ICC header carries its creation timestamp at bytes 24..35. Left alone, the
# corpus would be a different corpus every time it is generated.
profile[24:36] = b"\x00" * 12
_save(path, Image.fromarray(_pixels(120, 80, 9)), icc_profile=bytes(profile), quality=90)
def _broken_icc(path: Path) -> None:
"""A profile that is not a profile: a picture is still a picture."""
_save(path, Image.fromarray(_pixels(120, 80, 10)), icc_profile=b"not-a-profile", quality=90)
def _wide_jpeg(path: Path) -> None:
"""Large enough that decoding it at full resolution is visible in memory."""
_save(path, Image.fromarray(_pixels(4000, 3000, 11)), quality=70)
def _zero_byte(path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"")
def _truncated_jpeg(path: Path) -> None:
image = Image.fromarray(_pixels(400, 300, 12))
_save(path, image, quality=90)
data = path.read_bytes()
path.write_bytes(data[: len(data) // 2]) # header intact, pixels missing
def _corrupt_png(path: Path) -> None:
image = Image.fromarray(_pixels(120, 90, 13))
_save(path, image)
data = bytearray(path.read_bytes())
data[40:80] = b"\x00" * 40 # shred the compressed stream, keep the header
path.write_bytes(bytes(data))
def _not_an_image(path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"This is a text file that happens to be named .jpg\n")
def _png_declaring(width: int, height: int):
"""A tiny, structurally valid PNG whose header claims an enormous picture.
A few hundred bytes on disk, gigapixels on paper: the pipeline must refuse it
from the declared dimensions, before a single pixel is allocated. Rewriting the
IHDR of a real PNG (rather than hand-rolling a stub) keeps the file openable, so
the refusal is proven to come from the size check and not from a parse failure.
"""
def build(path: Path) -> None:
import io
buffer = io.BytesIO()
Image.fromarray(_pixels(4, 4, 15)).save(buffer, format="PNG")
data = bytearray(buffer.getvalue())
start = 8 + 4 # PNG signature, then the IHDR length field
struct.pack_into(">II", data, start + 4, width, height)
ihdr = bytes(data[start : start + 4 + 13])
struct.pack_into(">I", data, start + 4 + 13, zlib.crc32(ihdr))
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(bytes(data))
return build
CASES: tuple[MediaCase, ...] = (
# ── formats ───────────────────────────────────────────────────────────────
MediaCase("jpeg", "formats/plain.jpg", "format", _jpeg()),
MediaCase("jpeg_uppercase_ext", "formats/UPPER.JPG", "format", _jpeg(seed=14)),
MediaCase("png", "formats/plain.png", "format", _png()),
MediaCase("png_alpha", "formats/alpha.png", "format", _png(alpha=True)),
MediaCase("webp", "formats/plain.webp", "format", _webp),
MediaCase("tiff", "formats/plain.tiff", "format", _tiff),
# ── orientation ───────────────────────────────────────────────────────────
*(
MediaCase(
f"orientation_{value}",
f"orientation/exif_{value}.jpg",
"orientation",
_oriented_jpeg(value),
notes="EXIF orientation must be applied before resizing",
)
for value in range(1, 9)
),
MediaCase("rotated_pixels", "orientation/rotated.jpg", "orientation", _rotated_pixels),
# ── colour and size profiles ──────────────────────────────────────────────
MediaCase("grayscale", "profiles/gray.jpg", "profile", _grayscale),
MediaCase("cmyk", "profiles/cmyk.jpg", "profile", _cmyk),
MediaCase("tiny", "profiles/tiny.png", "profile", _tiny),
MediaCase("icc_tagged", "profiles/icc.jpg", "profile", _icc_tagged),
MediaCase(
"broken_icc",
"profiles/broken-icc.jpg",
"profile",
_broken_icc,
notes="an unusable ICC profile falls back to a plain conversion, never an error",
),
MediaCase(
"large_jpeg",
"profiles/large.jpg",
"profile",
_wide_jpeg,
notes="12 megapixels: the decode must stay near the requested size",
),
# ── damaged and hostile inputs ────────────────────────────────────────────
MediaCase("zero_byte", "damaged/empty.jpg", "damaged", _zero_byte, UNSUPPORTED),
MediaCase("truncated_jpeg", "damaged/truncated.jpg", "damaged", _truncated_jpeg, UNSUPPORTED),
MediaCase("corrupt_png", "damaged/corrupt.png", "damaged", _corrupt_png, UNSUPPORTED),
MediaCase("text_as_jpeg", "damaged/text.jpg", "damaged", _not_an_image, UNSUPPORTED),
MediaCase(
"bomb_header",
"damaged/bomb.png",
"damaged",
_png_declaring(60_000, 60_000),
TOO_LARGE,
notes="3.6 gigapixels declared in the header and nothing else",
),
MediaCase(
"bomb_warning_band",
"damaged/bomb-warning.png",
"damaged",
_png_declaring(10_000, 10_000),
TOO_LARGE,
notes="inside Pillow's warn-only band; the warning is promoted to a refusal",
),
# ── metadata ──────────────────────────────────────────────────────────────
MediaCase("no_exif", "metadata/bare.jpg", "metadata", _jpeg(seed=20)),
MediaCase(
"user_exif",
"metadata/user.jpg",
"metadata",
_jpeg(seed=21),
exif_args=(
"-Artist=Ada Lovelace",
"-Copyright=(c) Ada",
f"-DateTimeOriginal={CAPTURE_TIME}",
"-GPSLatitude=48.137",
"-GPSLatitudeRef=N",
"-Rating=4",
"-ImageDescription=A day out",
),
preserved_fields=(
"EXIF:IFD0:Artist",
"EXIF:IFD0:Copyright",
"EXIF:ExifIFD:DateTimeOriginal",
"EXIF:IFD0:ImageDescription",
"XMP:XMP-xmp:Rating",
),
notes="user metadata that every stage must leave exactly as it found it",
),
MediaCase(
"prior_safety_keyword",
"metadata/prior-safety.jpg",
"metadata",
_jpeg(seed=22),
exif_args=("-Keywords+=nsfw", "-Subject+=nsfw", "-Artist=Ada Lovelace"),
preserved_fields=("EXIF:IFD0:Artist",),
notes="a safety decision already written by an earlier run",
),
MediaCase(
"prior_analysis_keywords",
"metadata/prior-analysis.jpg",
"metadata",
_jpeg(seed=23),
exif_args=("-Keywords+=beach", "-Keywords+=sunset", "-Subject+=beach", "-Subject+=sunset"),
notes="analysis keywords from an earlier run; a safety write must not drop them",
),
MediaCase(
"conflicting_safety_keywords",
"metadata/conflicting.jpg",
"metadata",
_jpeg(seed=24),
exif_args=("-Keywords+=sfw", "-Keywords+=nsfw", "-Subject+=sfw", "-Subject+=nsfw"),
notes="both safety keywords at once: mutually exclusive means one must go",
),
MediaCase(
"malformed_metadata",
"metadata/malformed.jpg",
"metadata",
_jpeg(seed=25, exif=b"\x00\x01\x02not-a-valid-exif-block"),
notes="a broken EXIF block must not stop the picture from being usable",
),
)
CASES_BY_ID = {case.id: case for case in CASES}
def build_corpus(root: Path, *, ids: tuple[str, ...] | None = None) -> dict[str, Path]:
"""Generate the corpus (or a named subset) under ``root``; return id → path."""
import subprocess
built: dict[str, Path] = {}
for case in CASES:
if ids is not None and case.id not in ids:
continue
path = root / case.filename
case.build(path)
if case.exif_args:
subprocess.run(
["exiftool", "-m", "-overwrite_original", *case.exif_args, str(path)],
capture_output=True,
check=False,
)
built[case.id] = path
return built

View File

@@ -0,0 +1,297 @@
"""US07-03: EXIF checkpoints, asserted with before/after metadata snapshots.
Every test here reads the complete metadata of a real file through exiftool before
the stage runs and again afterwards, then compares the two. That is the only way to
prove the property the concept actually asks for: a stage owns a few fields and must
leave literally everything else — dates, GPS, camera, artist, rating, other people's
keywords — exactly as it found them.
The other half is divergence. When something outside the stage's ownership does move,
the checkpoint must say so, refuse to call itself verified, and change nothing back:
a silent repair is how a library quietly loses the user's metadata.
"""
from __future__ import annotations
import json
import shutil
import subprocess
import uuid
from datetime import datetime, timezone
from types import SimpleNamespace
import pytest
from photo_pipeline.config import Config
from photo_pipeline.db import create_db_engine, create_session_factory, run_migrations
from photo_pipeline.integrations import exiftool
from photo_pipeline.models import Asset, ExifProjection
from photo_pipeline.services import exif_checkpoint
from photo_pipeline.services.analysis import AnalysisService
from photo_pipeline.services.safety import SafetyService
from tests.fixtures.media_corpus import CASES_BY_ID, build_corpus
pytestmark = pytest.mark.skipif(
shutil.which("exiftool") is None, reason="exiftool not installed"
)
@pytest.fixture
def env(tmp_path):
data = tmp_path / "data"
data.mkdir()
lib = tmp_path / "lib"
files = build_corpus(
lib,
ids=(
"user_exif",
"prior_safety_keyword",
"prior_analysis_keywords",
"conflicting_safety_keywords",
"malformed_metadata",
"no_exif",
),
)
config = Config.from_env(
{"PHOTO_PIPELINE_DATA_DIR": str(data), "PHOTO_PIPELINE_LIBRARY_ROOTS": str(lib)}
)
run_migrations(config.database_url)
engine = create_db_engine(config.database_url)
sf = create_session_factory(engine)
assets = {}
with sf() as session:
for case_id, path in files.items():
asset = Asset(
id=str(uuid.uuid4()),
original_path=str(path),
current_path=str(path),
discovered_at=datetime.now(timezone.utc),
hash_version=1,
)
session.add(asset)
assets[case_id] = asset.id
session.commit()
yield SimpleNamespace(config=config, lib=lib, sf=sf, files=files, assets=assets)
engine.dispose()
class Provider:
"""A vision provider whose tags are fixed, so the EXIF assertion is the test."""
def __init__(self, tags):
self.tags = list(tags)
def analyze(self, path, *, album_hint):
return {"description": "a photo", "tags": self.tags}
def snapshot(path):
return exiftool.read_all(str(path))
def stage_fields(before, after):
"""Everything that changed except this application's owned and volatile tags."""
return exif_checkpoint.compare(before, after)
# ── preservation ──────────────────────────────────────────────────────────────
def test_a_safety_decision_preserves_every_user_field(env):
case = CASES_BY_ID["user_exif"]
path = env.files["user_exif"]
before = snapshot(path)
assert before, "the fixture must actually carry user metadata"
result = SafetyService(env.sf).decide(env.assets["user_exif"], "nsfw")
after = snapshot(path)
assert result["exif_verified"] is True
assert stage_fields(before, after) == ()
for field in case.preserved_fields:
assert after[field] == before[field], field
assert "nsfw" in exif_checkpoint.owned_values(after)
assert "sfw" not in exif_checkpoint.owned_values(after)
def test_analysis_keywords_are_additive_and_keep_the_safety_decision(env):
"""The two stages share the Keywords field; the second must merge, not replace."""
asset_id = env.assets["prior_analysis_keywords"]
path = env.files["prior_analysis_keywords"]
SafetyService(env.sf).decide(asset_id, "sfw")
before = snapshot(path)
AnalysisService(
env.sf, provider=Provider(["harbour", "boats"]), library_roots=(env.lib,)
).run([asset_id])
after = snapshot(path)
keywords = exif_checkpoint.owned_values(after)
assert {"sfw", "beach", "sunset", "harbour", "boats"} <= keywords
assert "nsfw" not in keywords
assert stage_fields(before, after) == ()
def test_flipping_a_safety_decision_removes_only_the_opposite_keyword(env):
asset_id = env.assets["prior_analysis_keywords"]
path = env.files["prior_analysis_keywords"]
safety = SafetyService(env.sf)
safety.decide(asset_id, "nsfw")
before = snapshot(path)
safety.decide(asset_id, "sfw")
after = snapshot(path)
keywords = exif_checkpoint.owned_values(after)
assert "sfw" in keywords and "nsfw" not in keywords
assert {"beach", "sunset"} <= keywords, "analysis keywords are not safety's to remove"
assert stage_fields(before, after) == ()
def test_conflicting_safety_keywords_are_resolved_to_one(env):
asset_id = env.assets["conflicting_safety_keywords"]
SafetyService(env.sf).decide(asset_id, "sfw")
keywords = exif_checkpoint.owned_values(snapshot(env.files["conflicting_safety_keywords"]))
assert keywords & {"sfw", "nsfw"} == {"sfw"}
def test_malformed_metadata_does_not_block_the_checkpoint(env):
result = SafetyService(env.sf).decide(env.assets["malformed_metadata"], "nsfw")
assert result["exif_verified"] is True
# ── verification and the refreshed hash ───────────────────────────────────────
def test_a_verified_checkpoint_refreshes_the_recorded_bytes(env):
"""exiftool rewrites the container, so the stored SHA-256 must be the new one —
upload compares against exactly these bytes."""
asset_id = env.assets["no_exif"]
path = env.files["no_exif"]
SafetyService(env.sf).decide(asset_id, "sfw")
with env.sf() as session:
asset = session.get(Asset, asset_id)
row = session.get(ExifProjection, (asset_id, "safety"))
import hashlib
on_disk = hashlib.sha256(path.read_bytes()).hexdigest()
assert asset.current_sha256 == on_disk
assert row.state == "verified" and row.result_file_sha256 == on_disk
assert json.loads(row.desired_json) == {"add": ["sfw"], "remove": ["nsfw"]}
def test_the_projection_survives_a_restart(env):
asset_id = env.assets["user_exif"]
SafetyService(env.sf).decide(asset_id, "nsfw")
engine = create_db_engine(env.config.database_url) # a fresh connection, as a restart is
try:
with create_session_factory(engine)() as session:
row = session.get(ExifProjection, (asset_id, "safety"))
assert row.state == "verified" and row.verified_at is not None
finally:
engine.dispose()
# ── divergence ────────────────────────────────────────────────────────────────
def test_a_field_the_stage_does_not_own_changing_is_divergent(env, monkeypatch):
"""Something rewrote the artist while the safety keyword was being written.
The stage must not call that verified, must record what moved, and must not put
the old value back — the file is now a question for a human.
"""
asset_id = env.assets["user_exif"]
path = env.files["user_exif"]
real_apply = exiftool.apply_keywords
def sabotage(target, *, add=(), remove=()):
ok = real_apply(target, add=add, remove=remove)
subprocess.run(
["exiftool", "-m", "-overwrite_original", "-Artist=Someone Else", str(target)],
capture_output=True,
check=False,
)
return ok
monkeypatch.setattr(exiftool, "apply_keywords", sabotage)
result = SafetyService(env.sf).decide(asset_id, "nsfw")
assert result["exif_verified"] is False, "a divergent checkpoint is not verified"
with env.sf() as session:
row = session.get(ExifProjection, (asset_id, "safety"))
asset = session.get(Asset, asset_id)
assert row.state == "divergent"
assert "EXIF:IFD0:Artist" in json.loads(row.divergent_fields)
assert row.verified_at is None
# Not repaired, and not silently accepted as the current verified bytes.
assert snapshot(path)["EXIF:IFD0:Artist"] == "Someone Else"
assert asset.current_sha256 is None
def test_a_divergent_asset_shows_up_in_the_review_queue(env, monkeypatch):
asset_id = env.assets["user_exif"]
monkeypatch.setattr(
exif_checkpoint,
"run",
lambda *args, **kwargs: exif_checkpoint.CheckpointResult(
exif_checkpoint.DIVERGENT, changed_fields=("EXIF:IFD0:Artist",), sha256="abc"
),
)
SafetyService(env.sf).decide(asset_id, "sfw")
rows = SafetyService(env.sf).review_queue()["items"]
row = next(item for item in rows if item["asset_id"] == asset_id)
assert row["exif_state"] == "divergent"
assert row["exif_verified"] is False
def test_a_write_that_does_not_take_is_a_failure_not_a_verification(env, monkeypatch):
monkeypatch.setattr(exiftool, "apply_keywords", lambda *a, **k: False)
result = SafetyService(env.sf).decide(env.assets["no_exif"], "sfw")
assert result["exif_verified"] is False
assert exif_checkpoint.state_for(env.sf, env.assets["no_exif"], "safety") == "failed"
def test_unreadable_metadata_is_a_failure_not_an_empty_snapshot(env, monkeypatch):
"""``None`` from exiftool means "cannot answer"; treating it as "nothing there"
would make every field look preserved."""
monkeypatch.setattr(exiftool, "read_all", lambda path: None)
result = exif_checkpoint.run(str(env.files["no_exif"]), add=("sfw",))
assert result.state == "failed" and result.reason == "metadata_unreadable"
def test_analysis_records_its_own_projection_separately(env):
asset_id = env.assets["user_exif"]
SafetyService(env.sf).decide(asset_id, "sfw")
AnalysisService(env.sf, provider=Provider(["pier"]), library_roots=(env.lib,)).run([asset_id])
assert exif_checkpoint.state_for(env.sf, asset_id, "safety") == "verified"
assert exif_checkpoint.state_for(env.sf, asset_id, "analysis") == "verified"
# ── the comparison rules themselves ───────────────────────────────────────────
def test_compare_ignores_owned_and_volatile_fields_only():
before = {
"EXIF:IFD0:Artist": "Ada",
"IPTC:Keywords": ["holiday"],
"File:System:FileSize": "3.8 kB",
"File:CurrentIPTCDigest": "aaa",
"XMP:XMP-x:XMPToolkit": "old",
}
after = {
"EXIF:IFD0:Artist": "Ada",
"IPTC:Keywords": ["holiday", "sfw"],
"File:System:FileSize": "3.9 kB",
"File:CurrentIPTCDigest": "bbb",
"XMP:XMP-x:XMPToolkit": "new",
}
assert exif_checkpoint.compare(before, after) == ()
after["EXIF:GPS:GPSLatitude"] = "48.1" # an addition counts as much as a loss
del after["EXIF:IFD0:Artist"]
assert exif_checkpoint.compare(before, after) == ("EXIF:GPS:GPSLatitude", "EXIF:IFD0:Artist")

View File

@@ -0,0 +1,335 @@
"""US07-03: the golden media corpus, driven through the real decode path.
Every case in ``tests/fixtures/media_corpus.py`` is exercised here. The claims:
* a supported format/orientation/profile renders, with the orientation applied and
the decode bounded to roughly the size that was asked for;
* a damaged, empty, lying, or gigapixel file becomes one precise item error — the
scan still finishes, the other assets still render, and a job that meets one keeps
running;
* a failure leaves no temporary file behind, and startup removes only the temporaries
this service recognises;
* cache invalidation follows the pixels, not the metadata.
"""
from __future__ import annotations
import hashlib
import shutil
import time
import tracemalloc
from types import SimpleNamespace
import pytest
from PIL import Image
from photo_pipeline import imaging
from photo_pipeline.config import Config
from photo_pipeline.db import create_db_engine, create_session_factory, run_migrations
from photo_pipeline.models import Asset, Thumbnail
from photo_pipeline.services.inventory import InventoryService
from photo_pipeline.services.thumbnails import (
TEMP_SUFFIX,
ThumbnailError,
ThumbnailService,
)
from tests.fixtures.media_corpus import CASES, CASES_BY_ID, build_corpus
RENDERABLE = [case for case in CASES if case.expect_error is None]
DAMAGED = [case for case in CASES if case.expect_error is not None]
needs_exiftool = pytest.mark.skipif(
shutil.which("exiftool") is None, reason="exiftool not installed"
)
@pytest.fixture(scope="module")
def corpus(tmp_path_factory):
root = tmp_path_factory.mktemp("corpus")
return SimpleNamespace(root=root, files=build_corpus(root))
@pytest.fixture
def env(tmp_path, corpus):
"""A library holding the whole corpus, scanned into a fresh database."""
data = tmp_path / "data"
data.mkdir()
lib = tmp_path / "lib"
shutil.copytree(corpus.root, lib)
config = Config.from_env(
{"PHOTO_PIPELINE_DATA_DIR": str(data), "PHOTO_PIPELINE_LIBRARY_ROOTS": str(lib)}
)
run_migrations(config.database_url)
engine = create_db_engine(config.database_url)
sf = create_session_factory(engine)
scan = InventoryService(sf).scan(lib)
by_id = {
case.id: next(
(aid for path, aid in scan.asset_ids.items() if path.endswith(case.filename)), None
)
for case in CASES
}
yield SimpleNamespace(
config=config, lib=lib, sf=sf, scan=scan, assets=by_id,
thumbs=ThumbnailService(sf, config),
)
engine.dispose()
# ── the manifest itself ───────────────────────────────────────────────────────
def test_the_manifest_is_internally_consistent():
assert len({case.id for case in CASES}) == len(CASES), "logical ids must be unique"
assert len({case.filename for case in CASES}) == len(CASES), "paths must be unique"
for case in CASES:
assert case.kind in {"format", "orientation", "profile", "damaged", "metadata"}
assert case.expect_error in (None, "unsupported_image", "image_too_large")
def test_the_corpus_regenerates_byte_for_byte(tmp_path):
"""A golden corpus that drifts between runs cannot be a golden corpus."""
first = build_corpus(tmp_path / "one")
second = build_corpus(tmp_path / "two")
digests = {
case_id: (
hashlib.sha256(first[case_id].read_bytes()).hexdigest(),
hashlib.sha256(second[case_id].read_bytes()).hexdigest(),
)
for case_id in first
}
drifted = [case_id for case_id, (a, b) in digests.items() if a != b]
assert not drifted, f"non-deterministic fixtures: {drifted}"
# ── bounded decode ────────────────────────────────────────────────────────────
@pytest.mark.parametrize("case", RENDERABLE, ids=lambda case: case.id)
def test_every_supported_case_renders(case, env):
asset_id = env.assets[case.id]
assert asset_id, f"{case.id} was not discovered by the scan"
path = env.thumbs.generate(asset_id, 256)
with Image.open(path) as thumb:
assert thumb.format == "WEBP"
assert max(thumb.size) <= 256
assert min(thumb.size) >= 1
@pytest.mark.parametrize("orientation", range(1, 9))
def test_exif_orientation_is_applied_before_resizing(orientation, env):
"""All eight tags: the 90° ones must come out portrait from a landscape source."""
asset_id = env.assets[f"orientation_{orientation}"]
with Image.open(env.thumbs.generate(asset_id, 256)) as thumb:
rotated = orientation in (5, 6, 7, 8)
assert (thumb.height > thumb.width) is rotated
def test_transparency_and_grayscale_survive_the_pipeline(env):
with Image.open(env.thumbs.generate(env.assets["png_alpha"], 256)) as thumb:
assert "A" in thumb.getbands()
with Image.open(env.thumbs.generate(env.assets["grayscale"], 256)) as thumb:
assert thumb.size == (100, 100) # smaller than the request: never upscaled
def test_a_broken_colour_profile_still_produces_a_picture(env):
"""An unusable ICC profile is a metadata problem, not a reason to lose the preview."""
for case_id in ("icc_tagged", "broken_icc"):
with Image.open(env.thumbs.generate(env.assets[case_id], 256)) as thumb:
assert thumb.size[0] > 0
def test_a_large_jpeg_is_not_decoded_at_full_resolution(env):
"""12 megapixels would be ~36 MB of pixels; the draft decode keeps it far below."""
tracemalloc.start()
try:
env.thumbs.generate(env.assets["large_jpeg"], 256)
_, peak = tracemalloc.get_traced_memory()
finally:
tracemalloc.stop()
assert peak < 12_000_000, f"decode peaked at {peak} bytes"
# ── damaged inputs ────────────────────────────────────────────────────────────
@pytest.mark.parametrize("case", DAMAGED, ids=lambda case: case.id)
def test_every_damaged_case_is_a_precise_item_error(case, env):
asset_id = env.assets[case.id]
assert asset_id, f"{case.id} was not discovered by the scan"
with pytest.raises(ThumbnailError) as raised:
env.thumbs.generate(asset_id, 256)
assert raised.value.code == case.expect_error
# Persisted, so a broken original is not re-decoded on every request...
with env.sf() as session:
rows = [
row
for row in session.query(Thumbnail).all()
if row.asset_id == asset_id and row.state == "error"
]
assert rows and rows[0].error_code == case.expect_error
# ...and the cached failure is the same precise error, not a generic one.
with pytest.raises(ThumbnailError) as again:
env.thumbs.generate(asset_id, 256)
assert again.value.code == case.expect_error
def test_a_gigapixel_header_is_refused_quickly_and_cheaply(env):
"""The refusal must come from the declared size, not from decoding it."""
tracemalloc.start()
started = time.monotonic()
try:
with pytest.raises(ThumbnailError):
env.thumbs.generate(env.assets["bomb_header"], 1280)
_, peak = tracemalloc.get_traced_memory()
finally:
tracemalloc.stop()
assert time.monotonic() - started < 5
assert peak < 5_000_000, f"a 3.6 gigapixel header allocated {peak} bytes"
def test_one_broken_file_stops_neither_the_scan_nor_its_neighbours(env):
"""The whole corpus is in the library, damaged files included."""
assert len(env.scan.asset_ids) >= len(CASES) - 1 # the empty file has no pixels to hash
for case in RENDERABLE[:5]:
assert env.thumbs.generate(env.assets[case.id], 256).exists()
def test_a_job_that_meets_a_broken_file_gets_evidence_not_an_exception(env):
"""``ensure_protected`` is what the archive lane calls; a plan must not die on
one unreadable original."""
evidence = env.thumbs.ensure_protected(env.assets["corrupt_png"])
assert evidence["state"] == "unsupported"
assert evidence["error_code"] == "unsupported_image"
assert env.thumbs.ensure_protected(env.assets["jpeg"])["state"] == "ready"
def test_undecodable_files_do_not_break_hashing(env):
"""Discovery records what it can: byte identity always, pixel identity when the
file has pixels."""
with env.sf() as session:
assets = {a.current_path: a for a in session.query(Asset).all()}
for case in DAMAGED:
asset = next((a for path, a in assets.items() if path.endswith(case.filename)), None)
if asset is None:
continue
assert asset.current_sha256, "byte identity is always available"
assert asset.pixel_sha256 is None, "undecodable files must not invent pixel identity"
# ── cache lifecycle ───────────────────────────────────────────────────────────
def test_a_failed_render_leaves_no_temporary_behind(env):
with pytest.raises(ThumbnailError):
env.thumbs.generate(env.assets["truncated_jpeg"], 256)
leftovers = list(env.config.thumbnail_cache_dir.rglob(f"*{TEMP_SUFFIX}"))
assert leftovers == []
def test_startup_cleanup_removes_only_recognised_temporaries(env):
cache = env.config.thumbnail_cache_dir
env.thumbs.generate(env.assets["jpeg"], 256) # creates the cache directory
stale = cache / f".abc123{TEMP_SUFFIX}"
stale.write_bytes(b"half a thumbnail")
innocent = cache / "keep-me.webp"
innocent.write_bytes(b"not a temporary")
assert env.thumbs.cleanup_temp_files() == 1
assert not stale.exists()
assert innocent.exists()
assert list(cache.rglob("*.webp")), "real cache entries survive"
def test_metadata_only_change_reuses_the_thumbnail_and_a_pixel_change_does_not(env):
"""The cache key follows the pixels: an EXIF edit must not cost a re-render."""
asset_id = env.assets["jpeg"]
first = env.thumbs.generate(asset_id, 256)
with env.sf() as session: # what a rescan records after an EXIF-only edit
asset = session.get(Asset, asset_id)
asset.current_sha256 = "different-bytes"
session.commit()
assert env.thumbs.generate(asset_id, 256) == first
with env.sf() as session: # a genuine pixel change
asset = session.get(Asset, asset_id)
asset.pixel_sha256 = "different-pixels"
session.commit()
assert env.thumbs.generate(asset_id, 256) != first
def test_a_deleted_cache_file_is_regenerated(env):
asset_id = env.assets["png"]
path = env.thumbs.generate(asset_id, 256)
path.unlink()
regenerated = env.thumbs.generate(asset_id, 256)
assert regenerated == path and regenerated.exists()
# ── the imaging door itself ───────────────────────────────────────────────────
def test_open_image_translates_every_decoder_failure(corpus):
"""Callers must be able to catch two typed errors, never bare ``Exception``."""
for case in DAMAGED:
expected = imaging.ImageTooLarge if case.expect_error == "image_too_large" else imaging.UndecodableImage
with pytest.raises(expected):
with imaging.open_image(corpus.files[case.id]) as image:
image.load()
def test_open_image_refuses_more_pixels_than_the_caller_allowed(corpus):
with pytest.raises(imaging.ImageTooLarge):
with imaging.open_image(corpus.files["jpeg"], max_pixels=100):
pass
with imaging.open_image(corpus.files["jpeg"], max_pixels=100_000) as image:
assert image.size == (320, 240)
@needs_exiftool
def test_malformed_metadata_does_not_stop_the_picture(env):
"""A broken EXIF block is a metadata fact, not a decode failure."""
assert env.thumbs.generate(env.assets["malformed_metadata"], 256).exists()
with env.sf() as session:
asset = session.get(Asset, env.assets["malformed_metadata"])
assert asset.pixel_sha256, "pixels are still identifiable"
def test_scoring_never_relaxes_truncated_image_handling(corpus):
"""The donor's process-global ``LOAD_TRUNCATED_IMAGES`` is gone for good.
It is global state: switching it on for the safety model would also switch it on
for hashing and preview rendering in the same process, and half a file would
silently become a valid picture (donor_ledger: nt-score-model).
"""
import inspect
from PIL import ImageFile
from photo_pipeline.integrations import nsfw_model
code = [
line
for line in inspect.getsource(nsfw_model).splitlines()
if not line.strip().startswith("#")
]
assert not any("LOAD_TRUNCATED_IMAGES" in line for line in code)
# And the door holds even when something else in the process turned it on —
# the frozen donor does exactly that when the characterization suite imports it.
previous = ImageFile.LOAD_TRUNCATED_IMAGES
ImageFile.LOAD_TRUNCATED_IMAGES = True
try:
with pytest.raises(imaging.UndecodableImage):
with imaging.open_image(corpus.files["truncated_jpeg"]) as image:
image.load()
assert ImageFile.LOAD_TRUNCATED_IMAGES is True, "the caller's setting is restored"
finally:
ImageFile.LOAD_TRUNCATED_IMAGES = previous
def test_every_manifest_case_is_exercised():
"""The corpus lint: no fixture may sit in the manifest untested."""
covered = {case.id for case in RENDERABLE} | {case.id for case in DAMAGED}
assert covered == set(CASES_BY_ID)

View File

@@ -152,6 +152,10 @@
"tests/unit/test_security_policy.py",
"tests/integration/test_security_boundaries.py",
"tests/e2e/test_security.py"
],
"US07-03": [
"tests/integration/test_media_hardening.py",
"tests/integration/test_exif_checkpoints.py"
]
}
}