diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml
new file mode 100644
index 0000000..3e4e3d5
--- /dev/null
+++ b/.cargo/mutants.toml
@@ -0,0 +1,35 @@
+# cargo-mutants config — see https://mutants.rs/configuration
+#
+# WHY this file exists: per-PR mutation testing wired as a CI workflow
+# (.github/workflows/mutants.yml) needs centralised scope + test-runner
+# config that survives independent of the workflow file. See spec
+# docs/superpowers/specs/2026-04-24-cargo-mutants-design.md.
+
+# WHY test_tool = "nextest" (mandatory): CLAUDE.md "Test stack" bans
+# `cargo test` for non-doc tests because of the SQLite lock-order
+# inversion deadlock (GH #131). cargo-mutants defaults to `cargo test`;
+# this override routes through `cargo nextest` instead. The repo-level
+# scripts/no-cargo-test.sh enforces the ban only on yml/sh/justfile
+# string scans, so cargo-mutants invoked via `cargo mutants` would NOT
+# be caught — but the deadlock still hits, hence the override is
+# load-bearing for correctness.
+test_tool = "nextest"
+
+# WHY exclude_globs targets crates/desktop/**: Tauri/GTK system-dep
+# friction (see Batch D known env limitation in
+# docs/superpowers/plans/architecture-audit-done-cheatsheet.md). Most
+# desktop code is wire-up; mutation surface is low value. Re-introducing
+# desktop coverage requires a separate Linux-deps setup commit + a
+# justification for the CPU cost. This is the SOLE mechanism scoping
+# mutation generation — do NOT also pass `--workspace --exclude
+# perima-desktop` via `additional_cargo_args` (that key is for build
+# flags like `--release`/`--all-features` and would clash with
+# cargo-mutants' own per-package selection layer).
+exclude_globs = ["crates/desktop/**"]
+
+# WHY timeout_multiplier = 5.0: ScanUseCase + WriteCmd dispatch are
+# fundamentally async + spawned tasks; cargo-mutants' default per-mutant
+# timeout heuristics over-trigger on async fns. 5x default ≈ 25s per
+# mutant kill attempt before cargo-mutants logs `TIMEOUT`. Tune down
+# once baseline is observed across ~10 PRs.
+timeout_multiplier = 5.0
diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml
new file mode 100644
index 0000000..8485c5d
--- /dev/null
+++ b/.github/workflows/bench.yml
@@ -0,0 +1,80 @@
+# criterion benchmarks weekly cron + manual workflow_dispatch.
+# Slice 4 of T1 test-architecture decomposition.
+# See spec: docs/superpowers/specs/2026-04-25-criterion-benches-design.md
+#
+# WHY a separate workflow (not added to ci.yml): criterion runs are
+# bounded MINUTES, not seconds; per-PR queue cost is disproportionate.
+# Decoupling lets bench fail-or-skip independently of the main matrix.
+
+name: bench
+
+on:
+ schedule:
+ - cron: '0 7 * * 1' # Mondays 07:00 UTC (1h after slice 3 cargo-fuzz)
+ workflow_dispatch:
+ inputs:
+ sample_size:
+ description: 'criterion sample size per benchmark'
+ type: string
+ default: '30'
+
+permissions:
+ contents: read
+
+env:
+ CARGO_TERM_COLOR: always
+
+jobs:
+ bench:
+ name: cargo bench
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: dtolnay/rust-toolchain@stable
+ - uses: Swatinem/rust-cache@v2
+
+ # WHY GTK deps even though no benched crate uses them: matches
+ # slice 1/3 workflow-scaffolding pattern. perima-hash + perima-db
+ # don't transitively need GTK; the install is defensive.
+ - name: Install image/system deps
+ run: |
+ sudo apt-get update -q
+ sudo apt-get install -yq \
+ libgtk-3-dev \
+ libwebkit2gtk-4.1-dev \
+ libayatana-appindicator3-dev \
+ librsvg2-dev \
+ patchelf
+
+ # WHY continue-on-error: per spec D-4, observability-only mode.
+ # Bench step might fail on transient runner issues; swallowing
+ # the exit lets artifact upload still run. Consistent posture
+ # with slice 1/3 workflows.
+ #
+ # WHY scoped to `-p perima-hash -p perima-db` (NOT --workspace):
+ # this slice pins exactly 2 benched crates. `cargo bench --workspace`
+ # would compile + invoke benches in any future workspace crate that
+ # gets a [[bench]] entry, scope-creeping the slice's contract.
+ - name: Run cargo bench (perima-hash + perima-db)
+ continue-on-error: true
+ env:
+ SAMPLE_SIZE: ${{ github.event.inputs.sample_size || '30' }}
+ run: |
+ # Tee bench output to GITHUB_STEP_SUMMARY so the run page shows
+ # the latest numbers without artifact download.
+ {
+ echo "## criterion benchmark results"
+ echo ""
+ echo '```'
+ cargo bench -p perima-hash -p perima-db -- --sample-size "$SAMPLE_SIZE" 2>&1
+ echo '```'
+ } | tee -a "$GITHUB_STEP_SUMMARY"
+
+ - name: Upload criterion target dir (raw HTML reports)
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: criterion-reports
+ path: target/criterion/
+ if-no-files-found: ignore
diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml
new file mode 100644
index 0000000..4b003f2
--- /dev/null
+++ b/.github/workflows/fuzz.yml
@@ -0,0 +1,91 @@
+# cargo-fuzz weekly cron + manual workflow_dispatch.
+# Slice 3 of T1 test-architecture decomposition.
+# See spec: docs/superpowers/specs/2026-04-25-cargo-fuzz-design.md
+#
+# WHY a separate workflow (not added to ci.yml): fuzz runs are bounded
+# minutes, NOT seconds; they would dominate per-PR queue if added to
+# the main matrix. Decoupling lets fuzz fail-or-skip independently of
+# the main `just ci` matrix.
+
+name: fuzz
+
+on:
+ schedule:
+ - cron: '0 6 * * 1' # Mondays 06:00 UTC
+ workflow_dispatch:
+ inputs:
+ max_total_time:
+ description: 'Per-target fuzz duration in seconds'
+ type: string
+ default: '300'
+
+permissions:
+ contents: read
+
+env:
+ CARGO_TERM_COLOR: always
+
+jobs:
+ fuzz:
+ name: cargo fuzz / ${{ matrix.target }}
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ target: [exif, blake3]
+ steps:
+ - uses: actions/checkout@v4
+
+ # WHY install nightly here (not via dtolnay/rust-toolchain@stable):
+ # the fuzz/ subdir's rust-toolchain.toml pins nightly; rustup honors
+ # the toolchain file when invoked from inside fuzz/.
+ - name: Install nightly via fuzz/rust-toolchain.toml
+ run: |
+ cd fuzz
+ rustup show
+
+ - uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: fuzz -> target
+
+ - name: Install cargo-fuzz
+ uses: taiki-e/install-action@v2
+ with:
+ tool: cargo-fuzz
+
+ # WHY install GTK deps defensively: matches slice 1 cargo-mutants
+ # workflow pattern to keep workflow scaffolding uniform across the
+ # T1 test-architecture slices. Strictly speaking, perima-hash +
+ # perima-media do NOT transitively require GTK on ubuntu-latest's
+ # default features; keeping the install avoids silent breakage if
+ # a future cargo-features change pulls in something system-dep-ful.
+ - name: Install image/system deps
+ run: |
+ sudo apt-get update -q
+ sudo apt-get install -yq \
+ libgtk-3-dev \
+ libwebkit2gtk-4.1-dev \
+ libayatana-appindicator3-dev \
+ librsvg2-dev \
+ patchelf
+
+ # WHY continue-on-error: per spec D-4, observability-only mode.
+ # libFuzzer exits non-zero on a crash; without continue-on-error
+ # the very first crash blocks all future runs of this workflow
+ # until the crash is fixed. Precedent: kani.yml + mutants.yml use
+ # the same pattern.
+ - name: Run cargo fuzz target ${{ matrix.target }}
+ continue-on-error: true
+ env:
+ MAX_TIME: ${{ github.event.inputs.max_total_time || '300' }}
+ run: |
+ cd fuzz
+ cargo fuzz run ${{ matrix.target }} -- -max_total_time=$MAX_TIME
+
+ - name: Upload artifacts (if any crashes)
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: fuzz-artifacts-${{ matrix.target }}
+ path: fuzz/artifacts/${{ matrix.target }}/
+ if-no-files-found: ignore
diff --git a/.github/workflows/mutants.yml b/.github/workflows/mutants.yml
new file mode 100644
index 0000000..1f8d6d7
--- /dev/null
+++ b/.github/workflows/mutants.yml
@@ -0,0 +1,82 @@
+# cargo-mutants per-PR mutation testing — Linux-only, observability-only.
+# See spec: docs/superpowers/specs/2026-04-24-cargo-mutants-design.md
+#
+# WHY a separate workflow (not added to ci.yml's matrix): mutation
+# testing is CPU-heavy + slow (minutes per file); decoupling lets it
+# fail-or-skip independently of the main `just ci` matrix. PR contributors
+# see two checks instead of one. Failure of mutants.yml does NOT block
+# merge during slice 1 (continue-on-error swallows non-zero exit).
+
+name: mutants
+on:
+ pull_request:
+
+permissions:
+ contents: read
+
+env:
+ CARGO_TERM_COLOR: always
+
+jobs:
+ mutants:
+ name: cargo mutants --in-diff
+ runs-on: ubuntu-latest
+ if: github.event_name == 'pull_request'
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ # WHY fetch-depth: 0 — `git diff origin/..` requires the
+ # base branch in local history; depth 0 fetches the full graph.
+ fetch-depth: 0
+
+ - uses: dtolnay/rust-toolchain@stable
+ - uses: Swatinem/rust-cache@v2
+
+ # WHY taiki-e/install-action (NOT cargo-binstall): this workflow
+ # runs on ubuntu-latest only; the BASH_FUNC_* leak that broke
+ # taiki-e/install-action on windows-latest (GH #137) does NOT
+ # affect Linux. Multi-tool comma syntax fetches both binaries in
+ # one step.
+ - uses: taiki-e/install-action@v2
+ with:
+ tool: cargo-mutants,cargo-nextest
+
+ # WHY Tauri Linux deps even though crates/desktop is excluded from
+ # mutation: `cargo metadata` (which cargo-mutants invokes
+ # internally) resolves the WHOLE workspace including
+ # perima-desktop's transitive deps. Without GTK headers, metadata
+ # resolution fails before any mutation happens.
+ - name: Install Tauri Linux deps
+ run: |
+ sudo apt-get update -q
+ sudo apt-get install -yq \
+ libgtk-3-dev \
+ libwebkit2gtk-4.1-dev \
+ libayatana-appindicator3-dev \
+ librsvg2-dev \
+ patchelf
+
+ # WHY `| tee git.diff` (vs `> git.diff`): tee echoes the diff into
+ # the CI log so reviewers can see what cargo-mutants will operate
+ # on without downloading the artifact. Matches the canonical
+ # mutants.rs/in-diff example.
+ - name: Compute diff against base branch
+ run: git diff origin/${{ github.base_ref }}.. | tee git.diff
+
+ # WHY continue-on-error: per spec D-4, this slice is observability-
+ # only. cargo-mutants exits 2 when mutants survive, 3 on timeout,
+ # 4 on baseline failure. Without continue-on-error the very first
+ # PR with a survivor blocks the workflow + defeats the
+ # observability-first calibration goal. Precedent: kani.yml uses
+ # the same pattern. Drop this in a future slice once the survivor
+ # baseline is stable.
+ - name: cargo mutants --in-diff
+ continue-on-error: true
+ run: cargo mutants --no-shuffle -vV --in-diff git.diff
+
+ - name: Upload mutants.out
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: mutants-out
+ path: mutants.out
diff --git a/.gitignore b/.gitignore
index 1e3509a..8cd7ec3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,10 @@
# .github/; the blanket **/*.md rule would otherwise exclude markdown
# files added there (e.g. pull_request_template.md).
!.github/**
+# WHY: fuzz/README.md is a contributor-facing quick-start doc for the
+# cargo-fuzz harness; it needs to be tracked so new contributors can
+# find invocation + triage instructions without reading source files.
+!fuzz/README.md
target/
.DS_Store
*.swp
diff --git a/Cargo.lock b/Cargo.lock
index 1993e78..01d77a3 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -86,6 +86,12 @@ dependencies = [
"libc",
]
+[[package]]
+name = "anes"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
+
[[package]]
name = "anstream"
version = "1.0.0"
@@ -539,6 +545,12 @@ dependencies = [
"toml 0.9.12+spec-1.1.0",
]
+[[package]]
+name = "cast"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
+
[[package]]
name = "cc"
version = "1.2.60"
@@ -615,6 +627,33 @@ dependencies = [
"windows-link 0.2.1",
]
+[[package]]
+name = "ciborium"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
+dependencies = [
+ "ciborium-io",
+ "ciborium-ll",
+ "serde",
+]
+
+[[package]]
+name = "ciborium-io"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
+
+[[package]]
+name = "ciborium-ll"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
+dependencies = [
+ "ciborium-io",
+ "half",
+]
+
[[package]]
name = "clap"
version = "4.6.1"
@@ -786,6 +825,42 @@ dependencies = [
"cfg-if",
]
+[[package]]
+name = "criterion"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f"
+dependencies = [
+ "anes",
+ "cast",
+ "ciborium",
+ "clap",
+ "criterion-plot",
+ "is-terminal",
+ "itertools 0.10.5",
+ "num-traits",
+ "once_cell",
+ "oorandom",
+ "plotters",
+ "rayon",
+ "regex",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "tinytemplate",
+ "walkdir",
+]
+
+[[package]]
+name = "criterion-plot"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
+dependencies = [
+ "cast",
+ "itertools 0.10.5",
+]
+
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
@@ -1931,6 +2006,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+[[package]]
+name = "hermit-abi"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
+
[[package]]
name = "hex"
version = "0.4.3"
@@ -2329,6 +2410,17 @@ dependencies = [
"serde",
]
+[[package]]
+name = "is-terminal"
+version = "0.4.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
+dependencies = [
+ "hermit-abi",
+ "libc",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "is_ci"
version = "1.2.0"
@@ -2351,6 +2443,15 @@ dependencies = [
"nom 7.1.3",
]
+[[package]]
+name = "itertools"
+version = "0.10.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
+dependencies = [
+ "either",
+]
+
[[package]]
name = "itertools"
version = "0.14.0"
@@ -3283,6 +3384,12 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+[[package]]
+name = "oorandom"
+version = "11.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
+
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -3447,6 +3554,7 @@ version = "0.6.4"
dependencies = [
"blake3",
"chrono",
+ "criterion",
"flume",
"insta",
"minijinja",
@@ -3519,6 +3627,7 @@ name = "perima-hash"
version = "0.6.4"
dependencies = [
"blake3",
+ "criterion",
"perima-core",
"rayon",
"tempfile",
@@ -3758,6 +3867,34 @@ dependencies = [
"time",
]
+[[package]]
+name = "plotters"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747"
+dependencies = [
+ "num-traits",
+ "plotters-backend",
+ "plotters-svg",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "plotters-backend"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a"
+
+[[package]]
+name = "plotters-svg"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670"
+dependencies = [
+ "plotters-backend",
+]
+
[[package]]
name = "png"
version = "0.17.16"
@@ -4160,7 +4297,7 @@ dependencies = [
"built",
"cfg-if",
"interpolate_name",
- "itertools",
+ "itertools 0.14.0",
"libc",
"libfuzzer-sys",
"log",
@@ -5666,6 +5803,16 @@ dependencies = [
"zerovec",
]
+[[package]]
+name = "tinytemplate"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc"
+dependencies = [
+ "serde",
+ "serde_json",
+]
+
[[package]]
name = "tinyvec"
version = "1.11.0"
diff --git a/crates/db/Cargo.toml b/crates/db/Cargo.toml
index b170117..d67b334 100644
--- a/crates/db/Cargo.toml
+++ b/crates/db/Cargo.toml
@@ -38,6 +38,14 @@ insta.workspace = true
# pattern for this (used by serde, tokio, etc.) — it's surprising the
# first time you see it but standard.
perima-db = { path = ".", features = ["test-utils"] }
+# WHY criterion: slice 4 of T1 test-architecture decomposition adds
+# FTS5 search-latency benchmark on a seeded SQLite DB. Observability-
+# only per spec D-4.
+criterion = "0.5"
+
+[[bench]]
+name = "fts"
+harness = false # WHY: criterion installs its own harness; libtest off.
[lints]
workspace = true
diff --git a/crates/db/benches/fts.rs b/crates/db/benches/fts.rs
new file mode 100644
index 0000000..007304d
--- /dev/null
+++ b/crates/db/benches/fts.rs
@@ -0,0 +1,122 @@
+//! `SqliteSearchRepository::search` FTS5 latency benchmark.
+//!
+//! Seeds a `SQLite` DB with 100 rows in `search_content` via raw rusqlite
+//! (mirroring `crates/app/src/search.rs::seed_via_conn` lines 202-223 —
+//! the canonical working pattern). The V007 FTS5 trigger on
+//! `search_content` fans out to `search_index`, which is what
+//! `SqliteSearchRepository::search` queries.
+//!
+//! Slice 4 of T1 test-architecture decomposition. Observability-only —
+//! the workflow prints results; no threshold gate.
+
+// WHY allow(missing_docs): workspace `#![warn(missing_docs)]` plus the
+// `-D warnings` clippy gate trips on `criterion_group!`'s macro
+// expansion (the generated `benches` const + helpers are undocumented
+// by design). Bench files are not part of the public API.
+#![allow(missing_docs)]
+
+use std::sync::Arc;
+
+use criterion::{BatchSize, Criterion, criterion_group, criterion_main};
+// WHY both trait imports: `SearchRepository::search` is a trait method;
+// without `SearchRepository` in scope, `state.search_repo.search(...)`
+// fails method-resolution. `EventBus` import same reason for
+// `Arc`.
+use perima_core::{EventBus, SearchRepository};
+use perima_db::SqliteSearchRepository;
+use perima_db::pool::ReadPool;
+use perima_db::test_utils::noop_bus::NoopBus;
+use perima_db::writer::{SqliteWriter, SqliteWriterHandle};
+
+const SEED_ROW_COUNT: usize = 100;
+
+fn bench_fts(c: &mut Criterion) {
+ let mut group = c.benchmark_group("fts/search");
+
+ group.bench_function("search_q_match_half_rows", |b| {
+ // WHY iter_batched + LargeInput: setup_db is expensive (writer
+ // spawn + 100 INSERTs + refinery migration). LargeInput batches
+ // multiple iterations per setup, amortizing the spawn cost.
+ b.iter_batched(
+ setup_db,
+ |state| {
+ let _ = state.search_repo.search("tag_42", 20);
+ },
+ BatchSize::LargeInput,
+ );
+ });
+
+ group.finish();
+}
+
+struct BenchState {
+ _td: tempfile::TempDir,
+ _writer: SqliteWriterHandle,
+ search_repo: SqliteSearchRepository,
+}
+
+fn setup_db() -> BenchState {
+ let td = tempfile::tempdir().expect("tempdir");
+ let db_path = td.path().join("bench.db");
+ let bus: Arc = Arc::new(NoopBus);
+
+ // Spawn the writer — its `start()` runs the refinery migrations,
+ // installs the FTS5 triggers, and is the sole writable Connection
+ // owner per Batch C invariants.
+ let writer = SqliteWriter::start(&db_path, bus).expect("writer start");
+ let reads = ReadPool::open(&db_path).expect("pool open");
+ let search_repo = SqliteSearchRepository::new(writer.sender(), reads);
+
+ seed_search_content(&db_path);
+
+ BenchState {
+ _td: td,
+ _writer: writer,
+ search_repo,
+ }
+}
+
+#[allow(clippy::disallowed_methods)] // WHY: bench-only seed path, mirrors
+// crates/app/src/search.rs::seed_via_conn.
+fn seed_search_content(db_path: &std::path::Path) {
+ use rusqlite::{Connection, params};
+ // WHY Connection::open (NOT open_with_flags + RW|NO_MUTEX): mirrors
+ // seed_via_conn line 212 verbatim — Connection::open opens RW by
+ // default and is the proven pattern for this seed shape.
+ let conn = Connection::open(db_path).expect("open rw");
+
+ // V007 search_content schema:
+ // (blake3_hash, filename, relative_path, mime_type,
+ // camera_model, captured_at, tags)
+ // The FTS5 trigger on search_content fans out to search_index;
+ // SearchRepository::search queries search_index. Sprinkle "tag_42"
+ // into the tags column for ~half the rows so the query has a
+ // non-trivial result set.
+ let mut stmt = conn
+ .prepare(
+ "INSERT INTO search_content \
+ (blake3_hash, filename, relative_path, mime_type, camera_model, captured_at, tags) \
+ VALUES (?1, ?2, ?3, ?4, '', '', ?5)",
+ )
+ .expect("prepare");
+ for i in 0..SEED_ROW_COUNT {
+ let hash = format!("{i:064x}");
+ let name = format!("file_{i}.jpg");
+ let rel = format!("photos/{name}");
+ let mime = "image/jpeg";
+ let tags = if i % 2 == 0 {
+ "tag_42 other"
+ } else {
+ "different"
+ };
+ stmt.execute(params![hash, name, rel, mime, tags])
+ .expect("insert search_content");
+ }
+}
+
+criterion_group!(
+ name = benches;
+ config = Criterion::default().sample_size(30);
+ targets = bench_fts
+);
+criterion_main!(benches);
diff --git a/crates/hash/Cargo.toml b/crates/hash/Cargo.toml
index 9ef4bbc..f42ea8f 100644
--- a/crates/hash/Cargo.toml
+++ b/crates/hash/Cargo.toml
@@ -16,6 +16,15 @@ tracing.workspace = true
[dev-dependencies]
tempfile.workspace = true
+# WHY criterion: slice 4 of T1 test-architecture decomposition adds
+# Blake3Service hash-throughput benchmarks (3 sizes × 2 hash modes).
+# Observability-only per spec D-4: weekly cron prints results, no
+# threshold gate.
+criterion = "0.5"
+
+[[bench]]
+name = "blake3"
+harness = false # WHY: criterion installs its own harness; libtest off.
[lints]
workspace = true
diff --git a/crates/hash/benches/blake3.rs b/crates/hash/benches/blake3.rs
new file mode 100644
index 0000000..8c1eb71
--- /dev/null
+++ b/crates/hash/benches/blake3.rs
@@ -0,0 +1,89 @@
+//! `Blake3Service` hash-throughput benchmark.
+//!
+//! Runs `quick_hash` + `full_hash` over 3 input sizes (1 KiB, 64 KiB,
+//! 1 MiB). Reports per-iteration time + throughput (MiB/s) per criterion.
+//!
+//! Slice 4 of T1 test-architecture decomposition. Observability-only —
+//! the workflow prints results; no threshold gate.
+
+// WHY allow missing_docs: the workspace lint set denies missing_docs, but
+// `criterion_group!` expands to an undocumented `pub fn benches()`. The
+// macro is the third-party public-API entry point — annotating its
+// expansion with rustdoc is impossible. Bench targets are not part of
+// the library's public surface either (compiled only with --benches).
+#![allow(missing_docs)]
+
+use std::io::Write;
+
+use criterion::{BatchSize, Criterion, Throughput, criterion_group, criterion_main};
+// WHY HashService trait import: Blake3Service's quick_hash/full_hash methods
+// are defined inside `impl HashService for Blake3Service` (NOT inherent
+// methods). Without `use perima_core::HashService;` in scope, method
+// resolution fails at compile time.
+use perima_core::HashService;
+use perima_hash::Blake3Service;
+
+const SIZES: &[usize] = &[1024, 64 * 1024, 1024 * 1024];
+
+fn bench_blake3(c: &mut Criterion) {
+ let svc = Blake3Service::new();
+
+ for &size in SIZES {
+ let mut group = c.benchmark_group(format!("blake3/{}", human_size(size)));
+ // WHY u64 cast: Throughput::Bytes takes u64; usize-to-u64 is
+ // lossless on 32-bit + 64-bit. clippy::cast_possible_truncation
+ // would not fire (lossless cast).
+ group.throughput(Throughput::Bytes(size as u64));
+
+ // WHY iter_batched: setup writes a fresh tempfile per batch; the
+ // measurement is just the hash call. Keeps file I/O setup out of
+ // the timing window.
+ group.bench_function("quick_hash", |b| {
+ b.iter_batched(
+ || setup_tempfile(size),
+ |(_td, path)| {
+ let _ = svc.quick_hash(&path);
+ },
+ BatchSize::SmallInput,
+ );
+ });
+ group.bench_function("full_hash", |b| {
+ b.iter_batched(
+ || setup_tempfile(size),
+ |(_td, path)| {
+ let _ = svc.full_hash(&path);
+ },
+ BatchSize::SmallInput,
+ );
+ });
+
+ group.finish();
+ }
+}
+
+/// Build a tempfile of `size` bytes filled with `0xFF`. Returns the
+/// `TempDir` (must outlive the path) + the path.
+fn setup_tempfile(size: usize) -> (tempfile::TempDir, std::path::PathBuf) {
+ let td = tempfile::tempdir().expect("tempdir");
+ let path = td.path().join("bench.bin");
+ let mut f = std::fs::File::create(&path).expect("create");
+ f.write_all(&vec![0xFFu8; size]).expect("write");
+ (td, path)
+}
+
+fn human_size(n: usize) -> String {
+ if n >= 1024 * 1024 {
+ format!("{}MiB", n / (1024 * 1024))
+ } else if n >= 1024 {
+ format!("{}KiB", n / 1024)
+ } else {
+ format!("{n}B")
+ }
+}
+
+criterion_group!(
+ name = benches;
+ config = Criterion::default().sample_size(30);
+ targets = bench_blake3
+);
+criterion_main!(benches);
diff --git a/crates/media/tests/common/mod.rs b/crates/media/tests/common/mod.rs
new file mode 100644
index 0000000..7095f6e
--- /dev/null
+++ b/crates/media/tests/common/mod.rs
@@ -0,0 +1,805 @@
+//! Shared synthesis helpers for `perima-media` integration tests.
+//!
+//! WHY a `common/` module: per the Batch F/G test architecture convention
+//! (see CLAUDE.md "Test architecture (Batch F + G)" section), integration
+//! tests live at `crates//tests/*.rs` and share helpers via
+//! `tests/common/mod.rs`. Extracting helpers here keeps `integration.rs`
+//! under the 700-LOC soft-limit as new edge-case tests are added.
+//!
+//! All fixtures are synthesised at test runtime — see CLAUDE.md "Test
+//! stack" + the WHY comments per helper.
+
+#![allow(clippy::missing_errors_doc)]
+// WHY load-bearing: every integration-test binary compiles `common/mod.rs`
+// but only uses a subset of helpers. Without the two allows below, unused
+// helpers in any given test file would warn or fail-with-`-D warnings`.
+// See rust-lang/rust#46379.
+#![allow(unreachable_pub)]
+#![allow(dead_code)]
+
+use std::fs::File;
+use std::io::{BufWriter, Write};
+use std::path::{Path, PathBuf};
+
+use image::{ImageBuffer, Rgb};
+use perima_core::BlakeHash;
+
+pub fn dummy_hash() -> BlakeHash {
+ BlakeHash::from_bytes(*blake3::hash(b"test-fixture").as_bytes())
+}
+
+/// Write a solid-red PNG at `path`.
+pub fn make_test_png(width: u32, height: u32, path: &Path) -> PathBuf {
+ let img: ImageBuffer, Vec> =
+ ImageBuffer::from_fn(width, height, |_, _| Rgb([255, 0, 0]));
+ img.save_with_format(path, image::ImageFormat::Png)
+ .expect("save png");
+ path.to_path_buf()
+}
+
+/// Build a minimal JPEG containing SOI + APP0(JFIF) + APP1(EXIF) + EOI.
+///
+/// WHY this shape (not a real encoded image):
+/// - `nom-exif`'s `MediaParser` requires a preceding APP0 (JFIF) marker
+/// before the APP1 (EXIF) segment for its streaming JPEG parser to
+/// recognise the file as a valid JPEG container.
+/// - `image::image_dimensions` is NOT called against this fixture —
+/// the EXIF test focuses on the text fields, and the PNG-dimensions
+/// test uses a real PNG.
+/// - Avoiding a fully-encoded JPEG keeps the helper small and removes
+/// any dependency on the `image` JPEG encoder.
+///
+/// WHY hand-crafted TIFF bytes (not a third-party writer):
+/// We no longer depend on `kamadak-exif`, which previously provided
+/// `exif::experimental::Writer`. Instead we build a minimal little-endian
+/// TIFF block with a proper `ExifIFDPointer` chain (IFD0 → `ExifSubIFD`)
+/// that nom-exif can follow. The byte layout follows EXIF 2.3 §4.5.
+pub fn make_jpeg_with_exif(
+ datetime_original: &str,
+ make: &str,
+ model: &str,
+ path: &Path,
+) -> PathBuf {
+ let tiff_bytes = build_tiff_exif(datetime_original, make, model);
+
+ // Assemble JPEG: SOI + APP0(JFIF) + APP1(Exif) + EOI.
+ // APP1 length covers [len_hi, len_lo, "Exif\0\0", TIFF] — i.e.
+ // 2 (len itself) + 6 (identifier) + tiff.len().
+ let app1_payload_len = 2 + 6 + tiff_bytes.len();
+ let app1_len =
+ u16::try_from(app1_payload_len).expect("APP1 payload too large for a single segment");
+
+ let file = File::create(path).expect("create jpeg");
+ let mut w = BufWriter::new(file);
+ w.write_all(&[0xFF, 0xD8]).expect("SOI");
+ // APP0 (JFIF) — 16-byte minimal header required by nom-exif's JPEG parser.
+ w.write_all(&[0xFF, 0xE0, 0x00, 0x10])
+ .expect("APP0 marker+len");
+ w.write_all(b"JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00")
+ .expect("JFIF payload");
+ // APP1 (EXIF)
+ w.write_all(&[0xFF, 0xE1]).expect("APP1 marker");
+ w.write_all(&app1_len.to_be_bytes()).expect("APP1 length");
+ w.write_all(b"Exif\0\0").expect("Exif identifier");
+ w.write_all(&tiff_bytes).expect("TIFF body");
+ w.write_all(&[0xFF, 0xD9]).expect("EOI");
+ w.flush().expect("flush jpeg");
+ path.to_path_buf()
+}
+
+/// Build a minimal little-endian TIFF block with the proper EXIF IFD
+/// chain for three tags: `Make` and `Model` in IFD0, and
+/// `DateTimeOriginal` in the EXIF `SubIFD` (pointed to from IFD0 via tag
+/// `ExifIFDPointer` 0x8769).
+///
+/// Layout (offsets relative to start of TIFF block):
+/// ```text
+/// 0x00 TIFF header (8 bytes): "II" + 0x002A + IFD0_offset=8
+/// 0x08 IFD0 (42 bytes): count=3, [Make, Model, ExifIFDPointer], next=0
+/// 0x32 ExifSubIFD (18 bytes): count=1, [DateTimeOriginal], next=0
+/// 0x44 String area: Make\0, Model\0, DateTimeOriginal\0
+/// ```
+///
+/// Each IFD entry is 12 bytes: tag(2) + type(2) + count(4) + offset(4).
+pub fn build_tiff_exif(datetime_original: &str, make: &str, model: &str) -> Vec {
+ // Tag / type constants (EXIF 2.3).
+ const TAG_MAKE: u16 = 0x010F;
+ const TAG_MODEL: u16 = 0x0110;
+ const TAG_EXIF_IFD_POINTER: u16 = 0x8769;
+ const TAG_DATETIME_ORIGINAL: u16 = 0x9003;
+ const TYPE_ASCII: u16 = 2;
+ const TYPE_LONG: u16 = 4;
+
+ // Offset layout (all LE, relative to TIFF block start).
+ // TIFF header: 8. IFD0: 2 + 3*12 + 4 = 42 → ends at 50.
+ // ExifSubIFD: 2 + 1*12 + 4 = 18 → ends at 68. String area from 68.
+ const IFD0_OFFSET: u32 = 8;
+ const IFD0_ENTRY_COUNT: u16 = 3;
+ const EXIF_SUBIFD_OFFSET: u32 = IFD0_OFFSET + 2 + (IFD0_ENTRY_COUNT as u32 * 12) + 4; // 50
+ const EXIF_SUBIFD_ENTRY_COUNT: u16 = 1;
+ const STRING_AREA: u32 = EXIF_SUBIFD_OFFSET + 2 + (EXIF_SUBIFD_ENTRY_COUNT as u32 * 12) + 4; // 68
+
+ // ASCII values are NUL-terminated per TIFF/EXIF spec.
+ let make_bytes: Vec = {
+ let mut v = make.as_bytes().to_vec();
+ v.push(0);
+ v
+ };
+ let model_bytes: Vec = {
+ let mut v = model.as_bytes().to_vec();
+ v.push(0);
+ v
+ };
+ let dt_bytes: Vec = {
+ let mut v = datetime_original.as_bytes().to_vec();
+ v.push(0);
+ v
+ };
+
+ let make_len = u32::try_from(make_bytes.len()).expect("make too long for TIFF");
+ let model_len = u32::try_from(model_bytes.len()).expect("model too long for TIFF");
+ let dt_len = u32::try_from(dt_bytes.len()).expect("datetime too long for TIFF");
+
+ let make_offset = STRING_AREA;
+ let model_offset = make_offset + make_len;
+ let dt_offset = model_offset + model_len;
+
+ let mut buf = Vec::new();
+
+ // TIFF header.
+ buf.extend_from_slice(b"II"); // little-endian byte order
+ buf.extend_from_slice(&42_u16.to_le_bytes()); // TIFF magic
+ buf.extend_from_slice(&IFD0_OFFSET.to_le_bytes());
+
+ // IFD0 (entries must be sorted ascending by tag).
+ buf.extend_from_slice(&IFD0_ENTRY_COUNT.to_le_bytes());
+ // Make (0x010F)
+ buf.extend_from_slice(&TAG_MAKE.to_le_bytes());
+ buf.extend_from_slice(&TYPE_ASCII.to_le_bytes());
+ buf.extend_from_slice(&make_len.to_le_bytes());
+ buf.extend_from_slice(&make_offset.to_le_bytes());
+ // Model (0x0110)
+ buf.extend_from_slice(&TAG_MODEL.to_le_bytes());
+ buf.extend_from_slice(&TYPE_ASCII.to_le_bytes());
+ buf.extend_from_slice(&model_len.to_le_bytes());
+ buf.extend_from_slice(&model_offset.to_le_bytes());
+ // ExifIFDPointer (0x8769) — inline LONG offset to ExifSubIFD.
+ buf.extend_from_slice(&TAG_EXIF_IFD_POINTER.to_le_bytes());
+ buf.extend_from_slice(&TYPE_LONG.to_le_bytes());
+ buf.extend_from_slice(&1_u32.to_le_bytes()); // count = 1
+ buf.extend_from_slice(&EXIF_SUBIFD_OFFSET.to_le_bytes());
+ // IFD0 next-IFD pointer.
+ buf.extend_from_slice(&0_u32.to_le_bytes());
+
+ // ExifSubIFD.
+ buf.extend_from_slice(&EXIF_SUBIFD_ENTRY_COUNT.to_le_bytes());
+ // DateTimeOriginal (0x9003)
+ buf.extend_from_slice(&TAG_DATETIME_ORIGINAL.to_le_bytes());
+ buf.extend_from_slice(&TYPE_ASCII.to_le_bytes());
+ buf.extend_from_slice(&dt_len.to_le_bytes());
+ buf.extend_from_slice(&dt_offset.to_le_bytes());
+ // ExifSubIFD next-IFD pointer.
+ buf.extend_from_slice(&0_u32.to_le_bytes());
+
+ // String area.
+ buf.extend_from_slice(&make_bytes);
+ buf.extend_from_slice(&model_bytes);
+ buf.extend_from_slice(&dt_bytes);
+
+ buf
+}
+
+/// Build a minimal JPEG containing SOI + APP0(JFIF) + APP1(EXIF) + EOI with
+/// both `DateTimeOriginal` (0x9003) and `OffsetTimeOriginal` (0x9011) set in
+/// the `ExifSubIFD`.
+///
+/// WHY separate helper (not a flag on `make_jpeg_with_exif`): keeping the
+/// two helpers independent avoids a boolean parameter that would change the
+/// byte layout mid-function and make the offset arithmetic harder to follow.
+pub fn make_jpeg_with_exif_offset(
+ datetime_original: &str,
+ offset: &str,
+ make: &str,
+ model: &str,
+ path: &Path,
+) -> PathBuf {
+ let tiff_bytes = build_tiff_exif_with_offset(datetime_original, offset, make, model);
+
+ let app1_payload_len = 2 + 6 + tiff_bytes.len();
+ let app1_len =
+ u16::try_from(app1_payload_len).expect("APP1 payload too large for a single segment");
+
+ let file = File::create(path).expect("create jpeg");
+ let mut w = BufWriter::new(file);
+ w.write_all(&[0xFF, 0xD8]).expect("SOI");
+ w.write_all(&[0xFF, 0xE0, 0x00, 0x10])
+ .expect("APP0 marker+len");
+ w.write_all(b"JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00")
+ .expect("JFIF payload");
+ w.write_all(&[0xFF, 0xE1]).expect("APP1 marker");
+ w.write_all(&app1_len.to_be_bytes()).expect("APP1 length");
+ w.write_all(b"Exif\0\0").expect("Exif identifier");
+ w.write_all(&tiff_bytes).expect("TIFF body");
+ w.write_all(&[0xFF, 0xD9]).expect("EOI");
+ w.flush().expect("flush jpeg");
+ path.to_path_buf()
+}
+
+/// Build a minimal little-endian TIFF block with two `ExifSubIFD` entries:
+/// `DateTimeOriginal` (0x9003) and `OffsetTimeOriginal` (0x9011).
+///
+/// Layout (offsets relative to start of TIFF block):
+/// ```text
+/// 0x00 TIFF header (8 bytes): "II" + 0x002A + IFD0_offset=8
+/// 0x08 IFD0 (42 bytes): count=3, [Make, Model, ExifIFDPointer], next=0
+/// 0x32 ExifSubIFD (30 bytes): count=2, [DateTimeOriginal, OffsetTimeOriginal], next=0
+/// 0x50 String area: Make\0, Model\0, DateTimeOriginal\0, OffsetTimeOriginal\0
+/// ```
+pub fn build_tiff_exif_with_offset(
+ datetime_original: &str,
+ offset: &str,
+ make: &str,
+ model: &str,
+) -> Vec {
+ const TAG_MAKE: u16 = 0x010F;
+ const TAG_MODEL: u16 = 0x0110;
+ const TAG_EXIF_IFD_POINTER: u16 = 0x8769;
+ const TAG_DATETIME_ORIGINAL: u16 = 0x9003;
+ const TAG_OFFSET_TIME_ORIGINAL: u16 = 0x9011;
+ const TYPE_ASCII: u16 = 2;
+ const TYPE_LONG: u16 = 4;
+
+ // IFD0: 2 + 3*12 + 4 = 42 → [8, 50)
+ // ExifSubIFD: 2 + 2*12 + 4 = 30 → [50, 80)
+ // String area from 80.
+ const IFD0_OFFSET: u32 = 8;
+ const IFD0_ENTRY_COUNT: u16 = 3;
+ const EXIF_SUBIFD_OFFSET: u32 = IFD0_OFFSET + 2 + (IFD0_ENTRY_COUNT as u32 * 12) + 4; // 50
+ const EXIF_SUBIFD_ENTRY_COUNT: u16 = 2;
+ const STRING_AREA: u32 = EXIF_SUBIFD_OFFSET + 2 + (EXIF_SUBIFD_ENTRY_COUNT as u32 * 12) + 4; // 80
+
+ let make_bytes: Vec = {
+ let mut v = make.as_bytes().to_vec();
+ v.push(0);
+ v
+ };
+ let model_bytes: Vec = {
+ let mut v = model.as_bytes().to_vec();
+ v.push(0);
+ v
+ };
+ let dt_bytes: Vec = {
+ let mut v = datetime_original.as_bytes().to_vec();
+ v.push(0);
+ v
+ };
+ // EXIF spec: OffsetTimeOriginal is ASCII, count includes the NUL.
+ let offset_bytes: Vec = {
+ let mut v = offset.as_bytes().to_vec();
+ v.push(0);
+ v
+ };
+
+ let make_len = u32::try_from(make_bytes.len()).expect("make too long");
+ let model_len = u32::try_from(model_bytes.len()).expect("model too long");
+ let dt_len = u32::try_from(dt_bytes.len()).expect("datetime too long");
+ let offset_len = u32::try_from(offset_bytes.len()).expect("offset too long");
+
+ let make_offset = STRING_AREA;
+ let model_offset = make_offset + make_len;
+ let dt_offset = model_offset + model_len;
+ let offset_str_offset = dt_offset + dt_len;
+
+ let mut buf = Vec::new();
+
+ // TIFF header.
+ buf.extend_from_slice(b"II");
+ buf.extend_from_slice(&42_u16.to_le_bytes());
+ buf.extend_from_slice(&IFD0_OFFSET.to_le_bytes());
+
+ // IFD0.
+ buf.extend_from_slice(&IFD0_ENTRY_COUNT.to_le_bytes());
+ // Make (0x010F)
+ buf.extend_from_slice(&TAG_MAKE.to_le_bytes());
+ buf.extend_from_slice(&TYPE_ASCII.to_le_bytes());
+ buf.extend_from_slice(&make_len.to_le_bytes());
+ buf.extend_from_slice(&make_offset.to_le_bytes());
+ // Model (0x0110)
+ buf.extend_from_slice(&TAG_MODEL.to_le_bytes());
+ buf.extend_from_slice(&TYPE_ASCII.to_le_bytes());
+ buf.extend_from_slice(&model_len.to_le_bytes());
+ buf.extend_from_slice(&model_offset.to_le_bytes());
+ // ExifIFDPointer (0x8769)
+ buf.extend_from_slice(&TAG_EXIF_IFD_POINTER.to_le_bytes());
+ buf.extend_from_slice(&TYPE_LONG.to_le_bytes());
+ buf.extend_from_slice(&1_u32.to_le_bytes());
+ buf.extend_from_slice(&EXIF_SUBIFD_OFFSET.to_le_bytes());
+ // IFD0 next-IFD pointer.
+ buf.extend_from_slice(&0_u32.to_le_bytes());
+
+ // ExifSubIFD (entries must be sorted ascending by tag).
+ buf.extend_from_slice(&EXIF_SUBIFD_ENTRY_COUNT.to_le_bytes());
+ // DateTimeOriginal (0x9003)
+ buf.extend_from_slice(&TAG_DATETIME_ORIGINAL.to_le_bytes());
+ buf.extend_from_slice(&TYPE_ASCII.to_le_bytes());
+ buf.extend_from_slice(&dt_len.to_le_bytes());
+ buf.extend_from_slice(&dt_offset.to_le_bytes());
+ // OffsetTimeOriginal (0x9011)
+ buf.extend_from_slice(&TAG_OFFSET_TIME_ORIGINAL.to_le_bytes());
+ buf.extend_from_slice(&TYPE_ASCII.to_le_bytes());
+ buf.extend_from_slice(&offset_len.to_le_bytes());
+ buf.extend_from_slice(&offset_str_offset.to_le_bytes());
+ // ExifSubIFD next-IFD pointer.
+ buf.extend_from_slice(&0_u32.to_le_bytes());
+
+ // String area.
+ buf.extend_from_slice(&make_bytes);
+ buf.extend_from_slice(&model_bytes);
+ buf.extend_from_slice(&dt_bytes);
+ buf.extend_from_slice(&offset_bytes);
+
+ buf
+}
+
+/// Synthesise a minimal valid MP4 with one ~1 second AVC video track.
+///
+/// WHY programmatic: checking binary fixtures into git bloats the repo
+/// and pins a toolchain. The `mp4` crate (dev-dep) builds a valid
+/// container at runtime. The output is designed to satisfy `mp4parse`'s
+/// demuxer — one video track, one AVC sample, non-zero duration.
+pub fn make_test_mp4(path: &Path) -> PathBuf {
+ use mp4::{AvcConfig, Mp4Config, Mp4Sample, Mp4Writer, TrackConfig, TrackType};
+
+ let config = Mp4Config {
+ major_brand: "isom".parse().expect("major_brand"),
+ minor_version: 512,
+ compatible_brands: vec![
+ "isom".parse().expect("brand"),
+ "iso2".parse().expect("brand"),
+ "avc1".parse().expect("brand"),
+ "mp41".parse().expect("brand"),
+ ],
+ timescale: 1000,
+ };
+
+ // WHY File::options: `Mp4Writer` needs both `Write` and `Seek`; a
+ // raw `File` opened read/write/create satisfies both without the
+ // BufWriter wrapper (BufWriter does not implement Seek).
+ let mut file = File::options()
+ .read(true)
+ .write(true)
+ .create(true)
+ .truncate(true)
+ .open(path)
+ .expect("open mp4 rw");
+
+ let mut writer = Mp4Writer::write_start(&mut file, &config).expect("write_start");
+
+ // Minimal AVC SPS/PPS — the smallest byte sequences that keep the
+ // `mp4` crate writer satisfied. mp4parse does not re-validate these
+ // bytes, it just surfaces the codec type and stored dimensions.
+ let track_config = TrackConfig {
+ track_type: TrackType::Video,
+ timescale: 1000,
+ language: "und".into(),
+ media_conf: mp4::MediaConfig::AvcConfig(AvcConfig {
+ width: 16,
+ height: 16,
+ seq_param_set: vec![0x67, 0x42, 0xC0, 0x0A, 0xDB, 0x02, 0x80, 0xBF, 0xE5, 0x01],
+ pic_param_set: vec![0x68, 0xCE, 0x38, 0x80],
+ }),
+ };
+ writer.add_track(&track_config).expect("add_track");
+
+ // One sync sample of duration = timescale (1 second).
+ let sample = Mp4Sample {
+ start_time: 0,
+ duration: 1000,
+ rendering_offset: 0,
+ is_sync: true,
+ bytes: mp4::Bytes::from(vec![0u8; 64]),
+ };
+ writer.write_sample(1, &sample).expect("write_sample");
+ writer.write_end().expect("write_end");
+
+ // Flush + return path.
+ file.sync_all().expect("sync mp4");
+ path.to_path_buf()
+}
+
+/// Synthesise a minimal valid MP4 with an audio track FIRST, then a
+/// video track. Used to exercise `video_tracks_summary`'s "first video
+/// track wins; non-video tracks skipped" branch (spec §4 D-2 B8).
+///
+/// WHY: the existing `make_test_mp4` emits a single-video-track file,
+/// which exercises the loop's first iteration only. This helper places
+/// audio at index 0 so the loop must skip past it before finding video.
+pub fn make_mp4_audio_first_then_video(path: &Path) -> PathBuf {
+ use mp4::{
+ AacConfig, AudioObjectType, AvcConfig, ChannelConfig, MediaConfig, Mp4Config, Mp4Sample,
+ Mp4Writer, SampleFreqIndex, TrackConfig, TrackType,
+ };
+
+ let config = Mp4Config {
+ major_brand: "isom".parse().expect("major_brand"),
+ minor_version: 512,
+ compatible_brands: vec![
+ "isom".parse().expect("brand"),
+ "iso2".parse().expect("brand"),
+ "avc1".parse().expect("brand"),
+ "mp41".parse().expect("brand"),
+ ],
+ timescale: 1000,
+ };
+
+ let mut file = File::options()
+ .read(true)
+ .write(true)
+ .create(true)
+ .truncate(true)
+ .open(path)
+ .expect("open mp4 rw");
+
+ let mut writer = Mp4Writer::write_start(&mut file, &config).expect("write_start");
+
+ // AUDIO TRACK FIRST — track_id = 1
+ let audio_config = TrackConfig {
+ track_type: TrackType::Audio,
+ timescale: 48000,
+ language: "und".into(),
+ media_conf: MediaConfig::AacConfig(AacConfig {
+ bitrate: 128_000,
+ profile: AudioObjectType::AacLowComplexity,
+ freq_index: SampleFreqIndex::Freq48000,
+ chan_conf: ChannelConfig::Stereo,
+ }),
+ };
+ writer.add_track(&audio_config).expect("add_track audio");
+
+ // VIDEO TRACK SECOND — track_id = 2
+ let video_config = TrackConfig {
+ track_type: TrackType::Video,
+ timescale: 1000,
+ language: "und".into(),
+ media_conf: MediaConfig::AvcConfig(AvcConfig {
+ width: 32,
+ height: 16,
+ seq_param_set: vec![0x67, 0x42, 0xC0, 0x0A, 0xDB, 0x02, 0x80, 0xBF, 0xE5, 0x01],
+ pic_param_set: vec![0x68, 0xCE, 0x38, 0x80],
+ }),
+ };
+ writer.add_track(&video_config).expect("add_track video");
+
+ // One audio sample (track 1), one video sample (track 2).
+ let audio_sample = Mp4Sample {
+ start_time: 0,
+ duration: 1024,
+ rendering_offset: 0,
+ is_sync: true,
+ bytes: mp4::Bytes::from(vec![0u8; 32]),
+ };
+ writer
+ .write_sample(1, &audio_sample)
+ .expect("write audio sample");
+
+ let video_sample = Mp4Sample {
+ start_time: 0,
+ duration: 1000,
+ rendering_offset: 0,
+ is_sync: true,
+ bytes: mp4::Bytes::from(vec![0u8; 64]),
+ };
+ writer
+ .write_sample(2, &video_sample)
+ .expect("write video sample");
+
+ writer.write_end().expect("write_end");
+ file.sync_all().expect("sync");
+ path.to_path_buf()
+}
+
+/// Build a minimal JPEG with EXIF Make + Model + `DateTimeOriginal` where
+/// Make/Model are passed as raw bytes (caller may include trailing
+/// padding via NUL `'\0'` or space `' '`). Used to exercise `read_exif`'s
+/// `trim_end_matches(['\0', ' '])` branch (spec §4 D-2 B3).
+///
+/// WHY raw bytes (not `&str`): real cameras emit Make/Model with
+/// trailing NUL or space padding (e.g. Nikon `"NIKON CORPORATION \0"`).
+/// `nom-exif`'s `as_str()` returns the bytes as-is including padding;
+/// our `read_exif` trims via `trim_end_matches`. To exercise the trim
+/// deterministically we need the byte-level ability to control the
+/// trailing pad pattern.
+pub fn make_jpeg_with_padded_ascii(
+ make_padded: &[u8],
+ model_padded: &[u8],
+ datetime_original: &str,
+ path: &Path,
+) -> PathBuf {
+ let tiff_bytes = build_tiff_exif_padded(make_padded, model_padded, datetime_original);
+ let app1_payload_len = 2 + 6 + tiff_bytes.len();
+ let app1_len = u16::try_from(app1_payload_len).expect("APP1 too large");
+
+ let file = File::create(path).expect("create jpeg");
+ let mut w = BufWriter::new(file);
+ w.write_all(&[0xFF, 0xD8]).expect("SOI");
+ w.write_all(&[0xFF, 0xE0, 0x00, 0x10])
+ .expect("APP0 marker+len");
+ w.write_all(b"JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00")
+ .expect("JFIF payload");
+ w.write_all(&[0xFF, 0xE1]).expect("APP1 marker");
+ w.write_all(&app1_len.to_be_bytes()).expect("APP1 length");
+ w.write_all(b"Exif\0\0").expect("Exif identifier");
+ w.write_all(&tiff_bytes).expect("TIFF body");
+ w.write_all(&[0xFF, 0xD9]).expect("EOI");
+ w.flush().expect("flush");
+ path.to_path_buf()
+}
+
+/// Variant of `build_tiff_exif` that takes raw byte sequences for Make
+/// and Model (caller controls NUL/space padding). `DateTimeOriginal`
+/// remains a `&str` (no padding scenario tested for it).
+///
+/// EXIF spec: ASCII type's count includes the trailing NUL terminator.
+/// This function ALWAYS appends one NUL after the caller's bytes.
+fn build_tiff_exif_padded(
+ make_bytes_in: &[u8],
+ model_bytes_in: &[u8],
+ datetime_original: &str,
+) -> Vec {
+ const TAG_MAKE: u16 = 0x010F;
+ const TAG_MODEL: u16 = 0x0110;
+ const TAG_EXIF_IFD_POINTER: u16 = 0x8769;
+ const TAG_DATETIME_ORIGINAL: u16 = 0x9003;
+ const TYPE_ASCII: u16 = 2;
+ const TYPE_LONG: u16 = 4;
+
+ // Offset layout identical to build_tiff_exif (IFD0 3 entries, ExifSubIFD 1 entry).
+ const IFD0_OFFSET: u32 = 8;
+ const IFD0_ENTRY_COUNT: u16 = 3;
+ const EXIF_SUBIFD_OFFSET: u32 = IFD0_OFFSET + 2 + (IFD0_ENTRY_COUNT as u32 * 12) + 4; // 50
+ const EXIF_SUBIFD_ENTRY_COUNT: u16 = 1;
+ const STRING_AREA: u32 = EXIF_SUBIFD_OFFSET + 2 + (EXIF_SUBIFD_ENTRY_COUNT as u32 * 12) + 4; // 68
+
+ // Caller's bytes may already include padding; we ALWAYS append a final NUL
+ // so the TIFF reader knows the string boundary.
+ let make_bytes: Vec = {
+ let mut v = make_bytes_in.to_vec();
+ v.push(0);
+ v
+ };
+ let model_bytes: Vec = {
+ let mut v = model_bytes_in.to_vec();
+ v.push(0);
+ v
+ };
+ let dt_bytes: Vec = {
+ let mut v = datetime_original.as_bytes().to_vec();
+ v.push(0);
+ v
+ };
+
+ let make_len = u32::try_from(make_bytes.len()).expect("make too long");
+ let model_len = u32::try_from(model_bytes.len()).expect("model too long");
+ let dt_len = u32::try_from(dt_bytes.len()).expect("datetime too long");
+
+ let make_offset = STRING_AREA;
+ let model_offset = make_offset + make_len;
+ let dt_offset = model_offset + model_len;
+
+ let mut buf = Vec::new();
+
+ // TIFF header.
+ buf.extend_from_slice(b"II");
+ buf.extend_from_slice(&42_u16.to_le_bytes());
+ buf.extend_from_slice(&IFD0_OFFSET.to_le_bytes());
+
+ // IFD0 (entries sorted ascending by tag).
+ buf.extend_from_slice(&IFD0_ENTRY_COUNT.to_le_bytes());
+ // Make (0x010F)
+ buf.extend_from_slice(&TAG_MAKE.to_le_bytes());
+ buf.extend_from_slice(&TYPE_ASCII.to_le_bytes());
+ buf.extend_from_slice(&make_len.to_le_bytes());
+ buf.extend_from_slice(&make_offset.to_le_bytes());
+ // Model (0x0110)
+ buf.extend_from_slice(&TAG_MODEL.to_le_bytes());
+ buf.extend_from_slice(&TYPE_ASCII.to_le_bytes());
+ buf.extend_from_slice(&model_len.to_le_bytes());
+ buf.extend_from_slice(&model_offset.to_le_bytes());
+ // ExifIFDPointer (0x8769) — inline LONG offset to ExifSubIFD.
+ buf.extend_from_slice(&TAG_EXIF_IFD_POINTER.to_le_bytes());
+ buf.extend_from_slice(&TYPE_LONG.to_le_bytes());
+ buf.extend_from_slice(&1_u32.to_le_bytes());
+ buf.extend_from_slice(&EXIF_SUBIFD_OFFSET.to_le_bytes());
+ // IFD0 next-IFD pointer.
+ buf.extend_from_slice(&0_u32.to_le_bytes());
+
+ // ExifSubIFD.
+ buf.extend_from_slice(&EXIF_SUBIFD_ENTRY_COUNT.to_le_bytes());
+ // DateTimeOriginal (0x9003)
+ buf.extend_from_slice(&TAG_DATETIME_ORIGINAL.to_le_bytes());
+ buf.extend_from_slice(&TYPE_ASCII.to_le_bytes());
+ buf.extend_from_slice(&dt_len.to_le_bytes());
+ buf.extend_from_slice(&dt_offset.to_le_bytes());
+ // ExifSubIFD next-IFD pointer.
+ buf.extend_from_slice(&0_u32.to_le_bytes());
+
+ // String area.
+ buf.extend_from_slice(&make_bytes);
+ buf.extend_from_slice(&model_bytes);
+ buf.extend_from_slice(&dt_bytes);
+
+ buf
+}
+
+/// Build a minimal JPEG containing SOI + APP0(JFIF) + APP1(EXIF) + EOI
+/// where the TIFF magic bytes inside APP1 are corrupted. Used to
+/// exercise `read_exif`'s `parser.parse(ms)` Err arm (spec §4 D-2 B2).
+///
+/// WHY this shape: nom-exif's `MediaParser` opens the file, sees the APP1
+/// segment, reports `has_exif() == true` (because the segment exists),
+/// then fails when `parser.parse()` encounters the bogus TIFF magic. This
+/// reaches a different branch than `image_extractor_jpeg_without_exif`
+/// (which fails at `has_exif() == false`).
+///
+/// WHY Strategy A (`b"XX"` magic) vs Strategy B (valid magic + corrupt IFD0):
+/// `has_exif()` in nom-exif checks for the APP1 segment's `Exif\0\0` identifier
+/// presence only (not the TIFF magic itself), so `b"XX"` magic causes
+/// `has_exif() == true` while `parse()` fails. Strategy B (valid `b"II"` +
+/// `tag_count = 0xFFFF` garbage) is the fallback if Strategy A accidentally
+/// routes through the wrong branch.
+pub fn make_jpeg_with_corrupt_tiff(path: &Path) -> PathBuf {
+ // Build a TIFF block with WRONG magic ("XX" instead of "II"/"MM").
+ // The TIFF body still has plausible bytes after, so nom-exif's
+ // streaming parser doesn't bail at has_exif() but does fail in parse().
+ let bogus_tiff: Vec = {
+ let mut v = Vec::new();
+ v.extend_from_slice(b"XX"); // WRONG magic (real: "II" or "MM")
+ v.extend_from_slice(&42_u16.to_le_bytes()); // TIFF version
+ v.extend_from_slice(&8_u32.to_le_bytes()); // IFD0 offset
+ // 16 bytes of garbage where IFD0 would be
+ v.extend_from_slice(&[0u8; 16]);
+ v
+ };
+
+ let app1_payload_len = 2 + 6 + bogus_tiff.len();
+ let app1_len =
+ u16::try_from(app1_payload_len).expect("APP1 payload too large for a single segment");
+
+ let file = File::create(path).expect("create jpeg");
+ let mut w = BufWriter::new(file);
+ w.write_all(&[0xFF, 0xD8]).expect("SOI");
+ w.write_all(&[0xFF, 0xE0, 0x00, 0x10])
+ .expect("APP0 marker+len");
+ w.write_all(b"JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00")
+ .expect("JFIF payload");
+ w.write_all(&[0xFF, 0xE1]).expect("APP1 marker");
+ w.write_all(&app1_len.to_be_bytes()).expect("APP1 length");
+ w.write_all(b"Exif\0\0").expect("Exif identifier");
+ w.write_all(&bogus_tiff).expect("bogus TIFF body");
+ w.write_all(&[0xFF, 0xD9]).expect("EOI");
+ w.flush().expect("flush jpeg");
+ path.to_path_buf()
+}
+
+/// Build a minimal JPEG with EXIF IFD0 containing ONLY Make + Model
+/// (no `ExifIFDPointer`, no `ExifSubIFD`). Used to exercise `read_exif`'s
+/// outer `.and_then(EntryValue::as_time_components)` collapsing to
+/// `None` when `DateTimeOriginal` is absent (spec §4 D-2 B9).
+///
+/// WHY this is distinct from the existing "no EXIF" test: that test
+/// fails at `has_exif() == false` (no APP1 segment at all). THIS test
+/// has `has_exif() == true` and `parse()` succeeds, but the parsed
+/// `EntryValue` for `DateTimeOriginal` is absent — so `exif.get(...)`
+/// returns `None` and the outer `.and_then` collapses `captured_at` to
+/// `None` while Make/Model populate normally.
+pub fn make_jpeg_with_make_model_only(make: &str, model: &str, path: &Path) -> PathBuf {
+ let tiff_bytes = build_tiff_make_model_only(make, model);
+ let app1_payload_len = 2 + 6 + tiff_bytes.len();
+ let app1_len = u16::try_from(app1_payload_len).expect("APP1 too large");
+
+ let file = File::create(path).expect("create jpeg");
+ let mut w = BufWriter::new(file);
+ w.write_all(&[0xFF, 0xD8]).expect("SOI");
+ w.write_all(&[0xFF, 0xE0, 0x00, 0x10])
+ .expect("APP0 marker+len");
+ w.write_all(b"JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00")
+ .expect("JFIF payload");
+ w.write_all(&[0xFF, 0xE1]).expect("APP1 marker");
+ w.write_all(&app1_len.to_be_bytes()).expect("APP1 length");
+ w.write_all(b"Exif\0\0").expect("Exif identifier");
+ w.write_all(&tiff_bytes).expect("TIFF body");
+ w.write_all(&[0xFF, 0xD9]).expect("EOI");
+ w.flush().expect("flush");
+ path.to_path_buf()
+}
+
+/// IFD0 with 2 entries (Make, Model), no `ExifIFDPointer`, no `ExifSubIFD`.
+/// String area immediately follows IFD0, then a NUL padding block.
+///
+/// Layout (offsets relative to start of TIFF block):
+/// ```text
+/// 0x00 TIFF header (8 bytes): "II" + 0x002A + IFD0_offset=8
+/// 0x08 IFD0 (30 bytes): count=2, [Make, Model], next=0
+/// 0x26 String area: Make\0, Model\0
+/// ... Padding: NUL bytes to push total JPEG > 128 bytes (see WHY)
+/// ```
+///
+/// WHY padding: `nom-exif`'s `MediaParser::parse` calls `fill_buf` on the
+/// underlying `File` reader AFTER copying the `MediaSource` pre-read buffer
+/// (128 bytes = `HEADER_PARSE_BUF_SIZE`) into its internal buffer. If the
+/// file is ≤ 128 bytes the pre-read fully exhausts it, leaving the reader at
+/// EOF; the subsequent `fill_buf` call returns `n = 0` → `UnexpectedEof` →
+/// parse error → `read_exif` returns `(None, None, None)` — not because TIFF
+/// is broken but because the file is too small for `MediaParser`'s
+/// two-phase read protocol.
+///
+/// The TIFF parser ignores bytes after the IFD chain terminates (next = 0)
+/// and after the string data area, so the padding is structurally inert.
+fn build_tiff_make_model_only(make: &str, model: &str) -> Vec {
+ const TAG_MAKE: u16 = 0x010F;
+ const TAG_MODEL: u16 = 0x0110;
+ const TYPE_ASCII: u16 = 2;
+
+ // IFD0 layout: 2 (count) + 2*12 (entries) + 4 (next pointer) = 30 bytes.
+ // String area starts at TIFF_HEADER (8) + 30 = 38.
+ const IFD0_OFFSET: u32 = 8;
+ const IFD0_ENTRY_COUNT: u16 = 2;
+ const STRING_AREA: u32 = IFD0_OFFSET + 2 + (IFD0_ENTRY_COUNT as u32 * 12) + 4; // 38
+
+ // The assembled JPEG is:
+ // SOI(2) + APP0(18) + APP1(2+2+6+TIFF) + EOI(2)
+ // = 32 + TIFF bytes. To exceed MediaParser's 128-byte HEADER_PARSE_BUF_SIZE
+ // threshold, TIFF must be > 96 bytes. We append 100 NUL pad bytes so the
+ // TIFF is structurally valid but always > 96 bytes regardless of Make/Model
+ // string lengths.
+ const TIFF_PAD_LEN: usize = 100;
+
+ let make_bytes: Vec = {
+ let mut v = make.as_bytes().to_vec();
+ v.push(0);
+ v
+ };
+ let model_bytes: Vec = {
+ let mut v = model.as_bytes().to_vec();
+ v.push(0);
+ v
+ };
+
+ let make_len = u32::try_from(make_bytes.len()).expect("make too long");
+ let model_len = u32::try_from(model_bytes.len()).expect("model too long");
+
+ let make_offset = STRING_AREA;
+ let model_offset = make_offset + make_len;
+
+ let mut buf = Vec::new();
+
+ // TIFF header.
+ buf.extend_from_slice(b"II"); // little-endian byte order
+ buf.extend_from_slice(&42_u16.to_le_bytes()); // TIFF magic
+ buf.extend_from_slice(&IFD0_OFFSET.to_le_bytes());
+
+ // IFD0 (entries sorted ascending by tag).
+ buf.extend_from_slice(&IFD0_ENTRY_COUNT.to_le_bytes());
+ // Make (0x010F)
+ buf.extend_from_slice(&TAG_MAKE.to_le_bytes());
+ buf.extend_from_slice(&TYPE_ASCII.to_le_bytes());
+ buf.extend_from_slice(&make_len.to_le_bytes());
+ buf.extend_from_slice(&make_offset.to_le_bytes());
+ // Model (0x0110)
+ buf.extend_from_slice(&TAG_MODEL.to_le_bytes());
+ buf.extend_from_slice(&TYPE_ASCII.to_le_bytes());
+ buf.extend_from_slice(&model_len.to_le_bytes());
+ buf.extend_from_slice(&model_offset.to_le_bytes());
+ // No ExifIFDPointer entry; next-IFD pointer = 0.
+ buf.extend_from_slice(&0_u32.to_le_bytes());
+
+ // String area.
+ buf.extend_from_slice(&make_bytes);
+ buf.extend_from_slice(&model_bytes);
+
+ // Padding: inert NUL bytes; structurally ignored by TIFF parsers.
+ // See WHY in the doc comment above.
+ buf.extend(std::iter::repeat_n(0u8, TIFF_PAD_LEN));
+
+ buf
+}
diff --git a/crates/media/tests/integration.rs b/crates/media/tests/integration.rs
index 7a98cbb..12f41ac 100644
--- a/crates/media/tests/integration.rs
+++ b/crates/media/tests/integration.rs
@@ -1,402 +1,24 @@
//! Integration tests for `perima-media` extractors.
//!
-//! All fixtures are synthesised at test runtime to avoid checking binary
-//! blobs into git — see the per-fixture WHY comments below for the
-//! rationale on each generator.
+//! Synthesis helpers live in `tests/common/mod.rs` per the Batch F/G
+//! test-architecture convention.
#![allow(clippy::missing_errors_doc)]
+mod common;
+
use std::fs::{self, File};
use std::io::{BufWriter, Write};
-use std::path::{Path, PathBuf};
+use std::path::Path;
use std::sync::Arc;
-use image::{ImageBuffer, Rgb};
use perima_core::{BlakeHash, MediaMetadata, MetadataExtractor};
use perima_media::{CompositeExtractor, ImageExtractor, VideoExtractor};
use tempfile::tempdir;
-// ---------------------------------------------------------------------
-// Fixture helpers
-// ---------------------------------------------------------------------
-
-fn dummy_hash() -> BlakeHash {
- BlakeHash::from_bytes(*blake3::hash(b"test-fixture").as_bytes())
-}
-
-/// Write a solid-red PNG at `path`.
-fn make_test_png(width: u32, height: u32, path: &Path) -> PathBuf {
- let img: ImageBuffer, Vec> =
- ImageBuffer::from_fn(width, height, |_, _| Rgb([255, 0, 0]));
- img.save_with_format(path, image::ImageFormat::Png)
- .expect("save png");
- path.to_path_buf()
-}
-
-/// Build a minimal JPEG containing SOI + APP0(JFIF) + APP1(EXIF) + EOI.
-///
-/// WHY this shape (not a real encoded image):
-/// - `nom-exif`'s `MediaParser` requires a preceding APP0 (JFIF) marker
-/// before the APP1 (EXIF) segment for its streaming JPEG parser to
-/// recognise the file as a valid JPEG container.
-/// - `image::image_dimensions` is NOT called against this fixture —
-/// the EXIF test focuses on the text fields, and the PNG-dimensions
-/// test uses a real PNG.
-/// - Avoiding a fully-encoded JPEG keeps the helper small and removes
-/// any dependency on the `image` JPEG encoder.
-///
-/// WHY hand-crafted TIFF bytes (not a third-party writer):
-/// We no longer depend on `kamadak-exif`, which previously provided
-/// `exif::experimental::Writer`. Instead we build a minimal little-endian
-/// TIFF block with a proper `ExifIFDPointer` chain (IFD0 → `ExifSubIFD`)
-/// that nom-exif can follow. The byte layout follows EXIF 2.3 §4.5.
-fn make_jpeg_with_exif(datetime_original: &str, make: &str, model: &str, path: &Path) -> PathBuf {
- let tiff_bytes = build_tiff_exif(datetime_original, make, model);
-
- // Assemble JPEG: SOI + APP0(JFIF) + APP1(Exif) + EOI.
- // APP1 length covers [len_hi, len_lo, "Exif\0\0", TIFF] — i.e.
- // 2 (len itself) + 6 (identifier) + tiff.len().
- let app1_payload_len = 2 + 6 + tiff_bytes.len();
- let app1_len =
- u16::try_from(app1_payload_len).expect("APP1 payload too large for a single segment");
-
- let file = File::create(path).expect("create jpeg");
- let mut w = BufWriter::new(file);
- w.write_all(&[0xFF, 0xD8]).expect("SOI");
- // APP0 (JFIF) — 16-byte minimal header required by nom-exif's JPEG parser.
- w.write_all(&[0xFF, 0xE0, 0x00, 0x10])
- .expect("APP0 marker+len");
- w.write_all(b"JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00")
- .expect("JFIF payload");
- // APP1 (EXIF)
- w.write_all(&[0xFF, 0xE1]).expect("APP1 marker");
- w.write_all(&app1_len.to_be_bytes()).expect("APP1 length");
- w.write_all(b"Exif\0\0").expect("Exif identifier");
- w.write_all(&tiff_bytes).expect("TIFF body");
- w.write_all(&[0xFF, 0xD9]).expect("EOI");
- w.flush().expect("flush jpeg");
- path.to_path_buf()
-}
-
-/// Build a minimal little-endian TIFF block with the proper EXIF IFD
-/// chain for three tags: `Make` and `Model` in IFD0, and
-/// `DateTimeOriginal` in the EXIF `SubIFD` (pointed to from IFD0 via tag
-/// `ExifIFDPointer` 0x8769).
-///
-/// Layout (offsets relative to start of TIFF block):
-/// ```text
-/// 0x00 TIFF header (8 bytes): "II" + 0x002A + IFD0_offset=8
-/// 0x08 IFD0 (42 bytes): count=3, [Make, Model, ExifIFDPointer], next=0
-/// 0x32 ExifSubIFD (18 bytes): count=1, [DateTimeOriginal], next=0
-/// 0x44 String area: Make\0, Model\0, DateTimeOriginal\0
-/// ```
-///
-/// Each IFD entry is 12 bytes: tag(2) + type(2) + count(4) + offset(4).
-fn build_tiff_exif(datetime_original: &str, make: &str, model: &str) -> Vec {
- // Tag / type constants (EXIF 2.3).
- const TAG_MAKE: u16 = 0x010F;
- const TAG_MODEL: u16 = 0x0110;
- const TAG_EXIF_IFD_POINTER: u16 = 0x8769;
- const TAG_DATETIME_ORIGINAL: u16 = 0x9003;
- const TYPE_ASCII: u16 = 2;
- const TYPE_LONG: u16 = 4;
-
- // Offset layout (all LE, relative to TIFF block start).
- // TIFF header: 8. IFD0: 2 + 3*12 + 4 = 42 → ends at 50.
- // ExifSubIFD: 2 + 1*12 + 4 = 18 → ends at 68. String area from 68.
- const IFD0_OFFSET: u32 = 8;
- const IFD0_ENTRY_COUNT: u16 = 3;
- const EXIF_SUBIFD_OFFSET: u32 = IFD0_OFFSET + 2 + (IFD0_ENTRY_COUNT as u32 * 12) + 4; // 50
- const EXIF_SUBIFD_ENTRY_COUNT: u16 = 1;
- const STRING_AREA: u32 = EXIF_SUBIFD_OFFSET + 2 + (EXIF_SUBIFD_ENTRY_COUNT as u32 * 12) + 4; // 68
-
- // ASCII values are NUL-terminated per TIFF/EXIF spec.
- let make_bytes: Vec = {
- let mut v = make.as_bytes().to_vec();
- v.push(0);
- v
- };
- let model_bytes: Vec = {
- let mut v = model.as_bytes().to_vec();
- v.push(0);
- v
- };
- let dt_bytes: Vec = {
- let mut v = datetime_original.as_bytes().to_vec();
- v.push(0);
- v
- };
-
- let make_len = u32::try_from(make_bytes.len()).expect("make too long for TIFF");
- let model_len = u32::try_from(model_bytes.len()).expect("model too long for TIFF");
- let dt_len = u32::try_from(dt_bytes.len()).expect("datetime too long for TIFF");
-
- let make_offset = STRING_AREA;
- let model_offset = make_offset + make_len;
- let dt_offset = model_offset + model_len;
-
- let mut buf = Vec::new();
-
- // TIFF header.
- buf.extend_from_slice(b"II"); // little-endian byte order
- buf.extend_from_slice(&42_u16.to_le_bytes()); // TIFF magic
- buf.extend_from_slice(&IFD0_OFFSET.to_le_bytes());
-
- // IFD0 (entries must be sorted ascending by tag).
- buf.extend_from_slice(&IFD0_ENTRY_COUNT.to_le_bytes());
- // Make (0x010F)
- buf.extend_from_slice(&TAG_MAKE.to_le_bytes());
- buf.extend_from_slice(&TYPE_ASCII.to_le_bytes());
- buf.extend_from_slice(&make_len.to_le_bytes());
- buf.extend_from_slice(&make_offset.to_le_bytes());
- // Model (0x0110)
- buf.extend_from_slice(&TAG_MODEL.to_le_bytes());
- buf.extend_from_slice(&TYPE_ASCII.to_le_bytes());
- buf.extend_from_slice(&model_len.to_le_bytes());
- buf.extend_from_slice(&model_offset.to_le_bytes());
- // ExifIFDPointer (0x8769) — inline LONG offset to ExifSubIFD.
- buf.extend_from_slice(&TAG_EXIF_IFD_POINTER.to_le_bytes());
- buf.extend_from_slice(&TYPE_LONG.to_le_bytes());
- buf.extend_from_slice(&1_u32.to_le_bytes()); // count = 1
- buf.extend_from_slice(&EXIF_SUBIFD_OFFSET.to_le_bytes());
- // IFD0 next-IFD pointer.
- buf.extend_from_slice(&0_u32.to_le_bytes());
-
- // ExifSubIFD.
- buf.extend_from_slice(&EXIF_SUBIFD_ENTRY_COUNT.to_le_bytes());
- // DateTimeOriginal (0x9003)
- buf.extend_from_slice(&TAG_DATETIME_ORIGINAL.to_le_bytes());
- buf.extend_from_slice(&TYPE_ASCII.to_le_bytes());
- buf.extend_from_slice(&dt_len.to_le_bytes());
- buf.extend_from_slice(&dt_offset.to_le_bytes());
- // ExifSubIFD next-IFD pointer.
- buf.extend_from_slice(&0_u32.to_le_bytes());
-
- // String area.
- buf.extend_from_slice(&make_bytes);
- buf.extend_from_slice(&model_bytes);
- buf.extend_from_slice(&dt_bytes);
-
- buf
-}
-
-/// Build a minimal JPEG containing SOI + APP0(JFIF) + APP1(EXIF) + EOI with
-/// both `DateTimeOriginal` (0x9003) and `OffsetTimeOriginal` (0x9011) set in
-/// the `ExifSubIFD`.
-///
-/// WHY separate helper (not a flag on `make_jpeg_with_exif`): keeping the
-/// two helpers independent avoids a boolean parameter that would change the
-/// byte layout mid-function and make the offset arithmetic harder to follow.
-fn make_jpeg_with_exif_offset(
- datetime_original: &str,
- offset: &str,
- make: &str,
- model: &str,
- path: &Path,
-) -> PathBuf {
- let tiff_bytes = build_tiff_exif_with_offset(datetime_original, offset, make, model);
-
- let app1_payload_len = 2 + 6 + tiff_bytes.len();
- let app1_len =
- u16::try_from(app1_payload_len).expect("APP1 payload too large for a single segment");
-
- let file = File::create(path).expect("create jpeg");
- let mut w = BufWriter::new(file);
- w.write_all(&[0xFF, 0xD8]).expect("SOI");
- w.write_all(&[0xFF, 0xE0, 0x00, 0x10])
- .expect("APP0 marker+len");
- w.write_all(b"JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00")
- .expect("JFIF payload");
- w.write_all(&[0xFF, 0xE1]).expect("APP1 marker");
- w.write_all(&app1_len.to_be_bytes()).expect("APP1 length");
- w.write_all(b"Exif\0\0").expect("Exif identifier");
- w.write_all(&tiff_bytes).expect("TIFF body");
- w.write_all(&[0xFF, 0xD9]).expect("EOI");
- w.flush().expect("flush jpeg");
- path.to_path_buf()
-}
-
-/// Build a minimal little-endian TIFF block with two `ExifSubIFD` entries:
-/// `DateTimeOriginal` (0x9003) and `OffsetTimeOriginal` (0x9011).
-///
-/// Layout (offsets relative to start of TIFF block):
-/// ```text
-/// 0x00 TIFF header (8 bytes): "II" + 0x002A + IFD0_offset=8
-/// 0x08 IFD0 (42 bytes): count=3, [Make, Model, ExifIFDPointer], next=0
-/// 0x32 ExifSubIFD (30 bytes): count=2, [DateTimeOriginal, OffsetTimeOriginal], next=0
-/// 0x50 String area: Make\0, Model\0, DateTimeOriginal\0, OffsetTimeOriginal\0
-/// ```
-fn build_tiff_exif_with_offset(
- datetime_original: &str,
- offset: &str,
- make: &str,
- model: &str,
-) -> Vec {
- const TAG_MAKE: u16 = 0x010F;
- const TAG_MODEL: u16 = 0x0110;
- const TAG_EXIF_IFD_POINTER: u16 = 0x8769;
- const TAG_DATETIME_ORIGINAL: u16 = 0x9003;
- const TAG_OFFSET_TIME_ORIGINAL: u16 = 0x9011;
- const TYPE_ASCII: u16 = 2;
- const TYPE_LONG: u16 = 4;
-
- // IFD0: 2 + 3*12 + 4 = 42 → [8, 50)
- // ExifSubIFD: 2 + 2*12 + 4 = 30 → [50, 80)
- // String area from 80.
- const IFD0_OFFSET: u32 = 8;
- const IFD0_ENTRY_COUNT: u16 = 3;
- const EXIF_SUBIFD_OFFSET: u32 = IFD0_OFFSET + 2 + (IFD0_ENTRY_COUNT as u32 * 12) + 4; // 50
- const EXIF_SUBIFD_ENTRY_COUNT: u16 = 2;
- const STRING_AREA: u32 = EXIF_SUBIFD_OFFSET + 2 + (EXIF_SUBIFD_ENTRY_COUNT as u32 * 12) + 4; // 80
-
- let make_bytes: Vec = {
- let mut v = make.as_bytes().to_vec();
- v.push(0);
- v
- };
- let model_bytes: Vec = {
- let mut v = model.as_bytes().to_vec();
- v.push(0);
- v
- };
- let dt_bytes: Vec = {
- let mut v = datetime_original.as_bytes().to_vec();
- v.push(0);
- v
- };
- // EXIF spec: OffsetTimeOriginal is ASCII, count includes the NUL.
- let offset_bytes: Vec = {
- let mut v = offset.as_bytes().to_vec();
- v.push(0);
- v
- };
-
- let make_len = u32::try_from(make_bytes.len()).expect("make too long");
- let model_len = u32::try_from(model_bytes.len()).expect("model too long");
- let dt_len = u32::try_from(dt_bytes.len()).expect("datetime too long");
- let offset_len = u32::try_from(offset_bytes.len()).expect("offset too long");
-
- let make_offset = STRING_AREA;
- let model_offset = make_offset + make_len;
- let dt_offset = model_offset + model_len;
- let offset_str_offset = dt_offset + dt_len;
-
- let mut buf = Vec::new();
-
- // TIFF header.
- buf.extend_from_slice(b"II");
- buf.extend_from_slice(&42_u16.to_le_bytes());
- buf.extend_from_slice(&IFD0_OFFSET.to_le_bytes());
-
- // IFD0.
- buf.extend_from_slice(&IFD0_ENTRY_COUNT.to_le_bytes());
- // Make (0x010F)
- buf.extend_from_slice(&TAG_MAKE.to_le_bytes());
- buf.extend_from_slice(&TYPE_ASCII.to_le_bytes());
- buf.extend_from_slice(&make_len.to_le_bytes());
- buf.extend_from_slice(&make_offset.to_le_bytes());
- // Model (0x0110)
- buf.extend_from_slice(&TAG_MODEL.to_le_bytes());
- buf.extend_from_slice(&TYPE_ASCII.to_le_bytes());
- buf.extend_from_slice(&model_len.to_le_bytes());
- buf.extend_from_slice(&model_offset.to_le_bytes());
- // ExifIFDPointer (0x8769)
- buf.extend_from_slice(&TAG_EXIF_IFD_POINTER.to_le_bytes());
- buf.extend_from_slice(&TYPE_LONG.to_le_bytes());
- buf.extend_from_slice(&1_u32.to_le_bytes());
- buf.extend_from_slice(&EXIF_SUBIFD_OFFSET.to_le_bytes());
- // IFD0 next-IFD pointer.
- buf.extend_from_slice(&0_u32.to_le_bytes());
-
- // ExifSubIFD (entries must be sorted ascending by tag).
- buf.extend_from_slice(&EXIF_SUBIFD_ENTRY_COUNT.to_le_bytes());
- // DateTimeOriginal (0x9003)
- buf.extend_from_slice(&TAG_DATETIME_ORIGINAL.to_le_bytes());
- buf.extend_from_slice(&TYPE_ASCII.to_le_bytes());
- buf.extend_from_slice(&dt_len.to_le_bytes());
- buf.extend_from_slice(&dt_offset.to_le_bytes());
- // OffsetTimeOriginal (0x9011)
- buf.extend_from_slice(&TAG_OFFSET_TIME_ORIGINAL.to_le_bytes());
- buf.extend_from_slice(&TYPE_ASCII.to_le_bytes());
- buf.extend_from_slice(&offset_len.to_le_bytes());
- buf.extend_from_slice(&offset_str_offset.to_le_bytes());
- // ExifSubIFD next-IFD pointer.
- buf.extend_from_slice(&0_u32.to_le_bytes());
-
- // String area.
- buf.extend_from_slice(&make_bytes);
- buf.extend_from_slice(&model_bytes);
- buf.extend_from_slice(&dt_bytes);
- buf.extend_from_slice(&offset_bytes);
-
- buf
-}
-
-/// Synthesise a minimal valid MP4 with one ~1 second AVC video track.
-///
-/// WHY programmatic: checking binary fixtures into git bloats the repo
-/// and pins a toolchain. The `mp4` crate (dev-dep) builds a valid
-/// container at runtime. The output is designed to satisfy `mp4parse`'s
-/// demuxer — one video track, one AVC sample, non-zero duration.
-fn make_test_mp4(path: &Path) -> PathBuf {
- use mp4::{AvcConfig, Mp4Config, Mp4Sample, Mp4Writer, TrackConfig, TrackType};
-
- let config = Mp4Config {
- major_brand: "isom".parse().expect("major_brand"),
- minor_version: 512,
- compatible_brands: vec![
- "isom".parse().expect("brand"),
- "iso2".parse().expect("brand"),
- "avc1".parse().expect("brand"),
- "mp41".parse().expect("brand"),
- ],
- timescale: 1000,
- };
-
- // WHY File::options: `Mp4Writer` needs both `Write` and `Seek`; a
- // raw `File` opened read/write/create satisfies both without the
- // BufWriter wrapper (BufWriter does not implement Seek).
- let mut file = File::options()
- .read(true)
- .write(true)
- .create(true)
- .truncate(true)
- .open(path)
- .expect("open mp4 rw");
-
- let mut writer = Mp4Writer::write_start(&mut file, &config).expect("write_start");
-
- // Minimal AVC SPS/PPS — the smallest byte sequences that keep the
- // `mp4` crate writer satisfied. mp4parse does not re-validate these
- // bytes, it just surfaces the codec type and stored dimensions.
- let track_config = TrackConfig {
- track_type: TrackType::Video,
- timescale: 1000,
- language: "und".into(),
- media_conf: mp4::MediaConfig::AvcConfig(AvcConfig {
- width: 16,
- height: 16,
- seq_param_set: vec![0x67, 0x42, 0xC0, 0x0A, 0xDB, 0x02, 0x80, 0xBF, 0xE5, 0x01],
- pic_param_set: vec![0x68, 0xCE, 0x38, 0x80],
- }),
- };
- writer.add_track(&track_config).expect("add_track");
-
- // One sync sample of duration = timescale (1 second).
- let sample = Mp4Sample {
- start_time: 0,
- duration: 1000,
- rendering_offset: 0,
- is_sync: true,
- bytes: mp4::Bytes::from(vec![0u8; 64]),
- };
- writer.write_sample(1, &sample).expect("write_sample");
- writer.write_end().expect("write_end");
-
- // Flush + return path.
- file.sync_all().expect("sync mp4");
- path.to_path_buf()
-}
+use common::{
+ dummy_hash, make_jpeg_with_exif, make_jpeg_with_exif_offset, make_test_mp4, make_test_png,
+};
// ---------------------------------------------------------------------
// ImageExtractor tests
@@ -612,3 +234,210 @@ fn composite_dispatches_by_mime() {
);
assert!(unknown.codec.is_none());
}
+
+#[test]
+fn image_extractor_directory_path_returns_default() {
+ // WHY: exercises the read_exif `MediaSource::file_path` Err arm
+ // (warn log path). Passing a directory path causes nom-exif to fail
+ // at File::open (EISDIR on Linux; kind may differ on macOS/Windows).
+ // The contract: extract() must NOT propagate the I/O error — it
+ // returns Ok(MediaMetadata) with EXIF fields = None and mime_type
+ // populated. Regression target: GH #110-style "real I/O bugs in
+ // logs" surfacing.
+ let td = tempdir().expect("tempdir");
+ // Pass the directory itself as the path — File::open on a dir errs.
+ let dir_path = td.path();
+
+ let extractor = ImageExtractor::new();
+ let meta = extractor
+ .extract(dummy_hash(), dir_path, "image/jpeg")
+ .expect("extract must not propagate the I/O error");
+
+ assert_eq!(meta.captured_at, None);
+ assert_eq!(meta.camera_make, None);
+ assert_eq!(meta.camera_model, None);
+ assert_eq!(meta.mime_type.as_deref(), Some("image/jpeg"));
+ // image::image_dimensions also fails on a dir → dims also None.
+ assert_eq!(meta.width, None);
+ assert_eq!(meta.height, None);
+}
+
+#[test]
+fn image_extractor_garbage_png_returns_no_dims() {
+ // WHY: exercises the `image::image_dimensions` Err arm in
+ // ImageExtractor::extract (debug log path). 16 zero bytes are
+ // not a valid PNG header (PNG signature is "\x89PNG\r\n\x1a\n").
+ // Contract: extract returns Ok with width=None, height=None,
+ // mime_type populated. read_exif also fails (not a parseable
+ // image) but does not propagate.
+ use std::fs;
+ let td = tempdir().expect("tempdir");
+ let path = td.path().join("garbage.png");
+ fs::write(&path, [0u8; 16]).expect("write garbage");
+
+ let extractor = ImageExtractor::new();
+ let meta = extractor
+ .extract(dummy_hash(), &path, "image/png")
+ .expect("extract must not propagate decode errors");
+
+ assert_eq!(meta.width, None, "garbage PNG must have width = None");
+ assert_eq!(meta.height, None, "garbage PNG must have height = None");
+ assert_eq!(meta.mime_type.as_deref(), Some("image/png"));
+}
+
+#[test]
+fn video_extractor_garbage_mp4_returns_default() {
+ // WHY: exercises VideoExtractor::extract `mp4parse::read_mp4` Err
+ // arm (debug log) per slice 2 spec §4 D-2 B5. 16 zero bytes lack
+ // any valid ISO box structure. Contract: extract returns Ok with
+ // an empty MediaMetadata (mime_type populated, every other field
+ // None).
+ use std::fs;
+ let td = tempdir().expect("tempdir");
+ let path = td.path().join("garbage.mp4");
+ fs::write(&path, [0u8; 16]).expect("write garbage");
+
+ let extractor = VideoExtractor::new();
+ let meta = extractor
+ .extract(dummy_hash(), &path, "video/mp4")
+ .expect("extract must not propagate mp4parse errors");
+
+ assert_eq!(meta.duration_ms, None);
+ assert_eq!(meta.width, None);
+ assert_eq!(meta.height, None);
+ assert_eq!(meta.codec, None);
+ assert_eq!(meta.mime_type.as_deref(), Some("video/mp4"));
+}
+
+#[test]
+fn video_extractor_mp4_audio_first_then_video_picks_video() {
+ // WHY: exercises video_tracks_summary's loop-then-break branch
+ // (spec §4 D-2 B8). With audio at track index 0 and video at
+ // track index 1, video_tracks_summary must skip the audio track
+ // (it isn't TrackType::Video) and pick up width/height/codec
+ // from the video track. Existing test (mp4_duration) only has
+ // a video track at index 0 so the loop's "skip non-video" arm
+ // is never exercised.
+ let td = tempdir().expect("tempdir");
+ let path = td.path().join("audio_first.mp4");
+ common::make_mp4_audio_first_then_video(&path);
+
+ let extractor = VideoExtractor::new();
+ let meta = extractor
+ .extract(dummy_hash(), &path, "video/mp4")
+ .expect("extract mp4");
+
+ // The video track is at index 1; width/height come from it (32x16).
+ assert_eq!(
+ meta.width,
+ Some(32),
+ "width must come from video track, not audio"
+ );
+ assert_eq!(
+ meta.height,
+ Some(16),
+ "height must come from video track, not audio"
+ );
+ assert!(
+ matches!(meta.codec.as_deref(), Some("h264")),
+ "codec must be h264 (video track), got {:?}",
+ meta.codec,
+ );
+ assert_eq!(meta.mime_type.as_deref(), Some("video/mp4"));
+ // Duration should be from the video track (1000 ms = 1s @ 1000 timescale).
+ assert!(
+ meta.duration_ms.unwrap_or(0) > 0,
+ "duration should be > 0, got {:?}",
+ meta.duration_ms,
+ );
+}
+
+#[test]
+fn image_extractor_jpeg_corrupt_tiff_returns_default() {
+ // WHY: exercises read_exif's `parser.parse(ms)` Err arm (debug log)
+ // per slice 2 spec §4 D-2 B2. The fixture has has_exif() == true
+ // (APP1 segment present with Exif\0\0 identifier) but TIFF magic is
+ // bogus ("XX"), so parse() fails. Must NOT propagate; returns
+ // (None, None, None) for EXIF fields.
+ let td = tempdir().expect("tempdir");
+ let path = td.path().join("corrupt.jpg");
+ common::make_jpeg_with_corrupt_tiff(&path);
+
+ let extractor = ImageExtractor::new();
+ let meta = extractor
+ .extract(dummy_hash(), &path, "image/jpeg")
+ .expect("extract must not propagate parse errors");
+
+ assert_eq!(meta.captured_at, None);
+ assert_eq!(meta.camera_make, None);
+ assert_eq!(meta.camera_model, None);
+ // Note: image_dimensions ALSO fails on this synthetic JPEG (no real
+ // image data after EOI), so width/height = None too. That's
+ // acceptable; the test point is the EXIF parse arm.
+ assert_eq!(meta.mime_type.as_deref(), Some("image/jpeg"));
+}
+
+#[test]
+fn image_extractor_jpeg_padded_ascii_is_trimmed() {
+ // WHY: exercises read_exif's `trim_end_matches(['\0', ' '])` branch
+ // (spec §4 D-2 B3). Real-world Nikon-style padding pattern: trailing
+ // spaces + NUL on the Make field. extractor.rs comment line 134
+ // documents "NIKON CORPORATION \0" as the trigger pattern; the
+ // test mirrors that.
+ let td = tempdir().expect("tempdir");
+ let path = td.path().join("padded.jpg");
+ // "NIKON CORPORATION" + 3 spaces (no trailing NUL in the bytes we pass
+ // — build_tiff_exif_padded appends ONE NUL terminator, which
+ // trim_end_matches will also strip).
+ common::make_jpeg_with_padded_ascii(
+ b"NIKON CORPORATION ",
+ b"D850 ",
+ "2024:06:01 12:34:56",
+ &path,
+ );
+
+ let extractor = ImageExtractor::new();
+ let meta = extractor
+ .extract(dummy_hash(), &path, "image/jpeg")
+ .expect("extract jpeg");
+
+ assert_eq!(
+ meta.camera_make.as_deref(),
+ Some("NIKON CORPORATION"),
+ "trailing spaces + NUL must be trimmed; got {:?}",
+ meta.camera_make,
+ );
+ assert_eq!(
+ meta.camera_model.as_deref(),
+ Some("D850"),
+ "trailing space + NUL must be trimmed; got {:?}",
+ meta.camera_model,
+ );
+}
+
+#[test]
+fn image_extractor_jpeg_make_model_only_no_datetime() {
+ // WHY: exercises the outer `.and_then(EntryValue::as_time_components)`
+ // collapse-to-None branch in read_exif (spec §4 D-2 B9). The fixture
+ // has EXIF Make+Model populated but NO DateTimeOriginal entry —
+ // distinct from `image_extractor_jpeg_without_exif_returns_default`
+ // (which fails at has_exif() == false) and B2 corrupt-TIFF (which
+ // fails at parser.parse()). Real-world example: scanned/edited
+ // images that retain camera identification but lose capture timestamps.
+ let td = tempdir().expect("tempdir");
+ let path = td.path().join("make_model_only.jpg");
+ common::make_jpeg_with_make_model_only("Canon", "EOS 5D Mark IV", &path);
+
+ let extractor = ImageExtractor::new();
+ let meta = extractor
+ .extract(dummy_hash(), &path, "image/jpeg")
+ .expect("extract jpeg");
+
+ assert_eq!(meta.camera_make.as_deref(), Some("Canon"));
+ assert_eq!(meta.camera_model.as_deref(), Some("EOS 5D Mark IV"));
+ assert_eq!(
+ meta.captured_at, None,
+ "no DateTimeOriginal must collapse captured_at to None even when EXIF parses cleanly",
+ );
+ assert_eq!(meta.mime_type.as_deref(), Some("image/jpeg"));
+}
diff --git a/fuzz/.gitignore b/fuzz/.gitignore
new file mode 100644
index 0000000..8f84131
--- /dev/null
+++ b/fuzz/.gitignore
@@ -0,0 +1,3 @@
+target/
+corpus/
+artifacts/
diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock
new file mode 100644
index 0000000..09d0434
--- /dev/null
+++ b/fuzz/Cargo.lock
@@ -0,0 +1,2019 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "ahash"
+version = "0.8.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "version_check",
+ "zerocopy",
+]
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "aligned"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685"
+dependencies = [
+ "as-slice",
+]
+
+[[package]]
+name = "aligned-vec"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b"
+dependencies = [
+ "equator",
+]
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
+
+[[package]]
+name = "approx"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "arbitrary"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
+
+[[package]]
+name = "arg_enum_proc_macro"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "arrayref"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
+
+[[package]]
+name = "arrayvec"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
+
+[[package]]
+name = "as-slice"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516"
+dependencies = [
+ "stable_deref_trait",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
+
+[[package]]
+name = "av-scenechange"
+version = "0.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394"
+dependencies = [
+ "aligned",
+ "anyhow",
+ "arg_enum_proc_macro",
+ "arrayvec",
+ "log",
+ "num-rational",
+ "num-traits",
+ "pastey",
+ "rayon",
+ "thiserror",
+ "v_frame",
+ "y4m",
+]
+
+[[package]]
+name = "av1-grain"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8"
+dependencies = [
+ "anyhow",
+ "arrayvec",
+ "log",
+ "nom 8.0.0",
+ "num-rational",
+ "v_frame",
+]
+
+[[package]]
+name = "avif-serialize"
+version = "0.8.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "375082f007bd67184fb9c0374614b29f9aaa604ec301635f72338bb65386a53d"
+dependencies = [
+ "arrayvec",
+]
+
+[[package]]
+name = "bit_field"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6"
+
+[[package]]
+name = "bitflags"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
+
+[[package]]
+name = "bitreader"
+version = "0.3.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "886559b1e163d56c765bc3a985febb4eee8009f625244511d8ee3c432e08c066"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "bitstream-io"
+version = "4.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f"
+dependencies = [
+ "no_std_io2",
+]
+
+[[package]]
+name = "blake3"
+version = "1.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
+dependencies = [
+ "arrayref",
+ "arrayvec",
+ "cc",
+ "cfg-if",
+ "constant_time_eq",
+ "cpufeatures",
+]
+
+[[package]]
+name = "built"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64"
+
+[[package]]
+name = "bumpalo"
+version = "3.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
+
+[[package]]
+name = "bytemuck"
+version = "1.25.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "byteorder-lite"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
+
+[[package]]
+name = "bytes"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+
+[[package]]
+name = "cc"
+version = "1.2.61"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d"
+dependencies = [
+ "find-msvc-tools",
+ "jobserver",
+ "libc",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "chrono"
+version = "0.4.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
+dependencies = [
+ "iana-time-zone",
+ "js-sys",
+ "num-traits",
+ "serde",
+ "wasm-bindgen",
+ "windows-link",
+]
+
+[[package]]
+name = "color_quant"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
+
+[[package]]
+name = "constant_time_eq"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "cpufeatures"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "crossbeam-deque"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
+dependencies = [
+ "crossbeam-epoch",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-epoch"
+version = "0.9.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+
+[[package]]
+name = "crunchy"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
+
+[[package]]
+name = "document-features"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
+dependencies = [
+ "litrs",
+]
+
+[[package]]
+name = "either"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
+
+[[package]]
+name = "equator"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc"
+dependencies = [
+ "equator-macro",
+]
+
+[[package]]
+name = "equator-macro"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "exr"
+version = "1.74.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be"
+dependencies = [
+ "bit_field",
+ "half",
+ "lebe",
+ "miniz_oxide",
+ "rayon-core",
+ "smallvec",
+ "zune-inflate",
+]
+
+[[package]]
+name = "fallible_collections"
+version = "0.4.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a88c69768c0a15262df21899142bc6df9b9b823546d4b4b9a7bc2d6c448ec6fd"
+dependencies = [
+ "hashbrown 0.13.2",
+]
+
+[[package]]
+name = "fast_image_resize"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12dd43e5011e8d8411a3215a0d57a2ec5c68282fb90eb5d7221fab0113442174"
+dependencies = [
+ "bytemuck",
+ "cfg-if",
+ "document-features",
+ "image",
+ "num-traits",
+ "thiserror",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
+
+[[package]]
+name = "fax"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab"
+dependencies = [
+ "fax_derive",
+]
+
+[[package]]
+name = "fax_derive"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "fdeflate"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
+dependencies = [
+ "simd-adler32",
+]
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "flate2"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
+
+[[package]]
+name = "futures-task"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+
+[[package]]
+name = "futures-util"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+dependencies = [
+ "futures-core",
+ "futures-macro",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "geo-types"
+version = "0.7.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94776032c45f950d30a13af6113c2ad5625316c9abfbccee4dd5a6695f8fe0f5"
+dependencies = [
+ "approx",
+ "num-traits",
+ "serde",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 5.3.0",
+ "wasip2",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 6.0.0",
+ "wasip2",
+ "wasip3",
+]
+
+[[package]]
+name = "gif"
+version = "0.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
+dependencies = [
+ "color_quant",
+ "weezl",
+]
+
+[[package]]
+name = "half"
+version = "2.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
+dependencies = [
+ "cfg-if",
+ "crunchy",
+ "zerocopy",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e"
+dependencies = [
+ "ahash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "foldhash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "iana-time-zone"
+version = "0.1.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "id-arena"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
+
+[[package]]
+name = "image"
+version = "0.25.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
+dependencies = [
+ "bytemuck",
+ "byteorder-lite",
+ "color_quant",
+ "exr",
+ "gif",
+ "image-webp",
+ "moxcms",
+ "num-traits",
+ "png",
+ "qoi",
+ "ravif",
+ "rayon",
+ "rgb",
+ "tiff",
+ "zune-core",
+ "zune-jpeg",
+]
+
+[[package]]
+name = "image-webp"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
+dependencies = [
+ "byteorder-lite",
+ "quick-error",
+]
+
+[[package]]
+name = "imgref"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8"
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.0",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "interpolate_name"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "iso6709parse"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5090db9c6a716d1f4eeb729957e889e9c28156061c825cbccd44950cf0f3c66"
+dependencies = [
+ "geo-types",
+ "nom 7.1.3",
+]
+
+[[package]]
+name = "itertools"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "jobserver"
+version = "0.1.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
+dependencies = [
+ "getrandom 0.3.4",
+ "libc",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.95"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca"
+dependencies = [
+ "once_cell",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "leb128fmt"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
+
+[[package]]
+name = "lebe"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
+
+[[package]]
+name = "libc"
+version = "0.2.186"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
+[[package]]
+name = "libfuzzer-sys"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d"
+dependencies = [
+ "arbitrary",
+ "cc",
+]
+
+[[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "litrs"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+
+[[package]]
+name = "loop9"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062"
+dependencies = [
+ "imgref",
+]
+
+[[package]]
+name = "maybe-rayon"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519"
+dependencies = [
+ "cfg-if",
+ "rayon",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "mime_guess"
+version = "2.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
+dependencies = [
+ "mime",
+ "unicase",
+]
+
+[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "mio"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys",
+]
+
+[[package]]
+name = "moxcms"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
+dependencies = [
+ "num-traits",
+ "pxfm",
+]
+
+[[package]]
+name = "mp4parse"
+version = "0.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63a35203d3c6ce92d5251c77520acb2e57108c88728695aa883f70023624c570"
+dependencies = [
+ "bitreader",
+ "byteorder",
+ "fallible_collections",
+ "log",
+ "num-traits",
+ "static_assertions",
+]
+
+[[package]]
+name = "new_debug_unreachable"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
+
+[[package]]
+name = "no_std_io2"
+version = "0.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b51ed7824b6e07d354605f4abb3d9d300350701299da96642ee084f5ce631550"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
+[[package]]
+name = "nom"
+version = "8.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "nom-exif"
+version = "2.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e78a8215f056e78a6887b4872e61bab5690dec4648b37396b6721be6f89c4ea"
+dependencies = [
+ "bytes",
+ "chrono",
+ "iso6709parse",
+ "nom 7.1.3",
+ "regex",
+ "thiserror",
+ "tracing",
+]
+
+[[package]]
+name = "noop_proc_macro"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
+
+[[package]]
+name = "num-bigint"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-derive"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "num-integer"
+version = "0.1.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-rational"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
+dependencies = [
+ "num-bigint",
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+ "libm",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "paste"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
+
+[[package]]
+name = "pastey"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
+
+[[package]]
+name = "perima-core"
+version = "0.6.4"
+dependencies = [
+ "serde",
+ "thiserror",
+ "unicode-normalization",
+ "uuid",
+]
+
+[[package]]
+name = "perima-fuzz"
+version = "0.0.0"
+dependencies = [
+ "libfuzzer-sys",
+ "perima-core",
+ "perima-hash",
+ "perima-media",
+ "tempfile",
+]
+
+[[package]]
+name = "perima-hash"
+version = "0.6.4"
+dependencies = [
+ "blake3",
+ "perima-core",
+ "rayon",
+ "thiserror",
+ "tracing",
+]
+
+[[package]]
+name = "perima-media"
+version = "0.6.4"
+dependencies = [
+ "chrono",
+ "fast_image_resize",
+ "image",
+ "mime_guess",
+ "mp4parse",
+ "nom-exif",
+ "perima-core",
+ "thiserror",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "png"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
+dependencies = [
+ "bitflags",
+ "crc32fast",
+ "fdeflate",
+ "flate2",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "prettyplease"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
+dependencies = [
+ "proc-macro2",
+ "syn",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "profiling"
+version = "1.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773"
+dependencies = [
+ "profiling-procmacros",
+]
+
+[[package]]
+name = "profiling-procmacros"
+version = "1.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b"
+dependencies = [
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "pxfm"
+version = "0.1.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
+
+[[package]]
+name = "qoi"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001"
+dependencies = [
+ "bytemuck",
+]
+
+[[package]]
+name = "quick-error"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rand"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
+dependencies = [
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom 0.3.4",
+]
+
+[[package]]
+name = "rav1e"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b"
+dependencies = [
+ "aligned-vec",
+ "arbitrary",
+ "arg_enum_proc_macro",
+ "arrayvec",
+ "av-scenechange",
+ "av1-grain",
+ "bitstream-io",
+ "built",
+ "cfg-if",
+ "interpolate_name",
+ "itertools",
+ "libc",
+ "libfuzzer-sys",
+ "log",
+ "maybe-rayon",
+ "new_debug_unreachable",
+ "noop_proc_macro",
+ "num-derive",
+ "num-traits",
+ "paste",
+ "profiling",
+ "rand",
+ "rand_chacha",
+ "simd_helpers",
+ "thiserror",
+ "v_frame",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "ravif"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45"
+dependencies = [
+ "avif-serialize",
+ "imgref",
+ "loop9",
+ "quick-error",
+ "rav1e",
+ "rayon",
+ "rgb",
+]
+
+[[package]]
+name = "rayon"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
+dependencies = [
+ "either",
+ "rayon-core",
+]
+
+[[package]]
+name = "rayon-core"
+version = "1.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
+dependencies = [
+ "crossbeam-deque",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "regex"
+version = "1.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
+
+[[package]]
+name = "rgb"
+version = "0.8.53"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.149"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "shlex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
+
+[[package]]
+name = "simd_helpers"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6"
+dependencies = [
+ "quote",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+
+[[package]]
+name = "socket2"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "static_assertions"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom 0.4.2",
+ "once_cell",
+ "rustix",
+ "windows-sys",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tiff"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
+dependencies = [
+ "fax",
+ "flate2",
+ "half",
+ "quick-error",
+ "weezl",
+ "zune-jpeg",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "tokio"
+version = "1.52.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "parking_lot",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "windows-sys",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "futures-util",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "unicase"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-normalization"
+version = "0.1.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "unicode-xid"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+
+[[package]]
+name = "uuid"
+version = "1.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
+dependencies = [
+ "getrandom 0.4.2",
+ "js-sys",
+ "serde_core",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "v_frame"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2"
+dependencies = [
+ "aligned-vec",
+ "num-traits",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasip2"
+version = "1.0.3+wasi-0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
+dependencies = [
+ "wit-bindgen 0.57.1",
+]
+
+[[package]]
+name = "wasip3"
+version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
+dependencies = [
+ "wit-bindgen 0.51.0",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.118"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.118"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.118"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.118"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "wasm-encoder"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
+dependencies = [
+ "leb128fmt",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasm-metadata"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
+dependencies = [
+ "anyhow",
+ "indexmap",
+ "wasm-encoder",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasmparser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
+dependencies = [
+ "bitflags",
+ "hashbrown 0.15.5",
+ "indexmap",
+ "semver",
+]
+
+[[package]]
+name = "weezl"
+version = "0.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
+dependencies = [
+ "wit-bindgen-rust-macro",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "wit-bindgen-core"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
+dependencies = [
+ "anyhow",
+ "heck",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-bindgen-rust"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
+dependencies = [
+ "anyhow",
+ "heck",
+ "indexmap",
+ "prettyplease",
+ "syn",
+ "wasm-metadata",
+ "wit-bindgen-core",
+ "wit-component",
+]
+
+[[package]]
+name = "wit-bindgen-rust-macro"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
+dependencies = [
+ "anyhow",
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wit-bindgen-core",
+ "wit-bindgen-rust",
+]
+
+[[package]]
+name = "wit-component"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
+dependencies = [
+ "anyhow",
+ "bitflags",
+ "indexmap",
+ "log",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "wasm-encoder",
+ "wasm-metadata",
+ "wasmparser",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-parser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
+dependencies = [
+ "anyhow",
+ "id-arena",
+ "indexmap",
+ "log",
+ "semver",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "unicode-xid",
+ "wasmparser",
+]
+
+[[package]]
+name = "y4m"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
+
+[[package]]
+name = "zerocopy"
+version = "0.8.48"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.48"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+
+[[package]]
+name = "zune-core"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
+
+[[package]]
+name = "zune-inflate"
+version = "0.2.54"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02"
+dependencies = [
+ "simd-adler32",
+]
+
+[[package]]
+name = "zune-jpeg"
+version = "0.5.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
+dependencies = [
+ "zune-core",
+]
diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml
new file mode 100644
index 0000000..06305ea
--- /dev/null
+++ b/fuzz/Cargo.toml
@@ -0,0 +1,41 @@
+[package]
+name = "perima-fuzz"
+version = "0.0.0"
+publish = false
+# WHY edition 2024: matches the workspace's [workspace.package] edition = "2024"
+# (workspace rust-version = "1.88"). cargo-fuzz templates default to 2021 but
+# the libfuzzer-sys macros work fine on 2024; aligning eliminates a needless
+# mismatch that would surface as a clippy warning under -D warnings.
+edition = "2024"
+
+[package.metadata]
+cargo-fuzz = true
+
+[dependencies]
+libfuzzer-sys = "0.4"
+tempfile = "3"
+perima-core = { path = "../crates/core" }
+perima-hash = { path = "../crates/hash" }
+perima-media = { path = "../crates/media" }
+
+[[bin]]
+name = "exif"
+path = "fuzz_targets/exif.rs"
+test = false
+doc = false
+bench = false
+
+[[bin]]
+name = "blake3"
+path = "fuzz_targets/blake3.rs"
+test = false
+doc = false
+bench = false
+
+# WHY [workspace] members = [] (load-bearing): tells Cargo this is a leaf
+# workspace independent of the parent (../Cargo.toml). Without it, Cargo
+# would treat the parent as the workspace root and force the parent's
+# stable toolchain on the fuzz crate, breaking the nightly pin in
+# rust-toolchain.toml.
+[workspace]
+members = []
diff --git a/fuzz/README.md b/fuzz/README.md
new file mode 100644
index 0000000..c0220bf
--- /dev/null
+++ b/fuzz/README.md
@@ -0,0 +1,46 @@
+# perima-fuzz
+
+cargo-fuzz / libFuzzer targets for `perima-hash` (BLAKE3 streaming wrapper) and `perima-media` (EXIF/JPEG metadata extraction).
+
+## Quick start (local)
+
+Requires `rustup` + nightly toolchain (auto-installed via `rust-toolchain.toml` on first invocation) + the `cargo-fuzz` CLI:
+
+```bash
+cargo install --locked cargo-fuzz # one-time per dev box
+cd fuzz
+cargo fuzz list # → exif, blake3
+cargo fuzz run exif -- -max_total_time=60
+cargo fuzz run blake3 -- -max_total_time=60
+```
+
+Crashes land in `fuzz/artifacts//crash-`. Reproduce with:
+
+```bash
+cargo fuzz run artifacts//crash-
+```
+
+Minimise a reproducer with `cargo fuzz tmin`:
+
+```bash
+cargo fuzz tmin artifacts//crash-
+```
+
+## Toolchain isolation
+
+`fuzz/rust-toolchain.toml` pins nightly. The `[workspace] members = []` line in `fuzz/Cargo.toml` declares this as a leaf workspace independent of the parent — production builds stay on stable.
+
+## Triage flow
+
+When a crash is found in CI:
+
+1. Download the `fuzz-artifacts-` artifact from the workflow run.
+2. Reproduce locally with the commands above.
+3. **Distinguish:**
+ - **Panic in our wrapper code** (`perima_hash` or `perima_media`): fix here. Add the minimised reproducer as a regression test in the appropriate `crates//tests/` file before fixing.
+ - **Panic in nom-exif / image / blake3 / mp4parse**: file an upstream issue with the reproducer. Optionally add a defensive guard in our wrapper if the upstream fix is blocking.
+4. Drop a comment on the GH baseline-anchor issue with the finding + reproducer link.
+
+## CI cadence
+
+`.github/workflows/fuzz.yml` runs Mondays 06:00 UTC (cron) + on manual `workflow_dispatch` (with optional `max_total_time` input). Linux-only. Observability-only — crashes do not block the workflow; reviewers act on artifact contents.
diff --git a/fuzz/fuzz_targets/blake3.rs b/fuzz/fuzz_targets/blake3.rs
new file mode 100644
index 0000000..1ca7629
--- /dev/null
+++ b/fuzz/fuzz_targets/blake3.rs
@@ -0,0 +1,28 @@
+#![no_main]
+use libfuzzer_sys::fuzz_target;
+use perima_core::HashService;
+use perima_hash::Blake3Service;
+use std::hint::black_box;
+use std::io::Write;
+
+fuzz_target!(|data: &[u8]| {
+ // WHY tempfile (NOT Cursor / byte slice): Blake3Service exposes only
+ // `quick_hash(&Path)` and `full_hash(&Path)` (verified against
+ // crates/hash/src/blake3_service.rs lines 30-37). There is no Read /
+ // byte-slice / Cursor entry point. Adding a `hash_bytes(&[u8])`
+ // method purely for fuzz testability would be production-API bloat
+ // (YAGNI per spec §10 Q2). Mirror the exif.rs tempfile pattern.
+ let Ok(td) = tempfile::tempdir() else { return; };
+ let path = td.path().join("fuzz.bin");
+ if std::fs::File::create(&path).and_then(|mut f| f.write_all(data)).is_err() {
+ return;
+ }
+
+ let svc = Blake3Service::new();
+ // Both hash modes share the inner `hash_file` helper (lines 39-62);
+ // calling both per iteration covers the cap-Some (quick) and cap-None
+ // (full) branches with near-zero added cost (BLAKE3 on ≤64 KiB is
+ // microsecond-range). black_box prevents optimizer discard.
+ let _ = black_box(svc.quick_hash(&path));
+ let _ = black_box(svc.full_hash(&path));
+});
diff --git a/fuzz/fuzz_targets/exif.rs b/fuzz/fuzz_targets/exif.rs
new file mode 100644
index 0000000..73ba1d3
--- /dev/null
+++ b/fuzz/fuzz_targets/exif.rs
@@ -0,0 +1,38 @@
+#![no_main]
+use libfuzzer_sys::fuzz_target;
+// WHY MetadataExtractor from perima_core (NOT perima_media): the trait is
+// defined at crates/core/src/metadata.rs and re-exported from
+// perima_core's lib.rs root; perima_media only re-exports the concrete
+// extractor structs (ImageExtractor / VideoExtractor / CompositeExtractor),
+// NOT the trait. Trait method resolution requires the trait to be in scope.
+use perima_core::{BlakeHash, MetadataExtractor};
+use perima_media::ImageExtractor;
+use std::hint::black_box;
+use std::io::Write;
+
+fuzz_target!(|data: &[u8]| {
+ // WHY tempfile: nom-exif's MediaSource::file_path takes a Path, not bytes.
+ // We pay the file-create cost per fuzz iteration; alternative is a
+ // Read+Seek wrapper, but the production path goes through file_path so
+ // we mirror that. tempfile cleanup happens via TempDir Drop at end of
+ // each iteration.
+ let Ok(td) = tempfile::tempdir() else { return; };
+ let path = td.path().join("fuzz.jpg");
+ if std::fs::File::create(&path).and_then(|mut f| f.write_all(data)).is_err() {
+ return;
+ }
+
+ // WHY this fuzzes the FULL extract pipeline (not just nom-exif):
+ // ImageExtractor::extract calls `image::image_dimensions` BEFORE
+ // read_exif (per crates/media/src/extractor.rs lines 44-54). With
+ // JPEG-mime'd random bytes, the `image` crate's JPEG header parser
+ // runs first; nom-exif only runs after image_dimensions has touched
+ // the input. Both surfaces must be panic-free on any input — Result-Err
+ // is fine; panic is the bug. Widening `read_exif` to `pub` purely to
+ // fuzz it in isolation is undesirable production-API growth.
+ let extractor = ImageExtractor::new();
+ let hash = BlakeHash::from_bytes([0u8; 32]);
+ // black_box prevents the optimizer from discarding the call when the
+ // result is unused.
+ let _ = black_box(extractor.extract(hash, &path, "image/jpeg"));
+});
diff --git a/fuzz/rust-toolchain.toml b/fuzz/rust-toolchain.toml
new file mode 100644
index 0000000..ff32172
--- /dev/null
+++ b/fuzz/rust-toolchain.toml
@@ -0,0 +1,3 @@
+[toolchain]
+channel = "nightly-2026-04-15"
+components = ["rust-src", "llvm-tools-preview"]