326 lines
12 KiB
Python
326 lines
12 KiB
Python
"""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
|