Skip to content

fix: refuse to export captures with no bytes and no custody binding (#158) - #164

Merged
ChelseaKR merged 1 commit into
mainfrom
fix/export-refuses-unexportable-media
Aug 7, 2026
Merged

fix: refuse to export captures with no bytes and no custody binding (#158)#164
ChelseaKR merged 1 commit into
mainfrom
fix/export-refuses-unexportable-media

Conversation

@ChelseaKR

Copy link
Copy Markdown
Owner

The bug (issue #158)

.heic (the iPhone default photo format) is a first-class capture type — capture._MEDIA_TYPES mapped it to image/heic — but was absent from the packet exporter's _EXT_BY_TYPE/_DATA_EXT_BY_TYPE. _build_item's if ext: ... elif data_ext: ... had no else, so a .heic capture exported as an item with shared_name="", shared_hash="", nothing written into media/, and no copied_for_sharing custody entry. The verifier's structural checks (shared_media_ok, custody_binding_ok) are both gated on shared_name being non-empty, so an empty name read as "nothing to check, therefore intact" rather than "nothing was exported, therefore broken." Result: habitable verify printed evidence readiness: READY, exit 0, for a packet containing zero photographs.

Reproduced exactly as the issue describes: synthetic vault, one .heic capture, build_packet(..., make_pdf=False), then verify_packet with the issuer's cert as trust anchor — before this fix, shared_name='', 0 files in media/, 0 copied_for_sharing custody entries, and evidence_ready=True. tests/test_media_types.py::test_every_registered_media_type_has_a_working_export_path[image/heic] now pins the fixed behavior; reverting packet.py's _require_shareable_bytes call reproduces the original bug against that test.

The three decisions

Decision 1 — fail closed at export (the issue's own recommendation, taken as-is). _require_shareable_bytes (packet.py) runs after every capture/artifact item is built and raises PacketError naming the capture id and media type if the item would carry neither a shared copy nor an embedded original. This is a whole-packet refusal, not a per-item skip — packet v4 has no scoped/partial export mode, so silently dropping just the bad item would itself produce a packet whose appendix count no longer matched what the operator expected, with no record of what went missing. --include-originals remains available as an explicit escape hatch (see decision 2).

Decision 2 — close the map gap. I took the issue's preferred shape (one source of truth) rather than the narrower "just add image/heic to the map" fix, for a reason discovered mid-investigation: Pillow (this project's only image-processing dependency) cannot decode HEIC at allPIL.features.check('heic') is False on the pinned floor (12.3.0), and there's no pillow-heif/libheif dependency in this repo. Naively mapping .heic.heic and routing it through the existing Pillow-based sanitizer (exif.py::_strip_with_pillow) would make every real .heic capture's default export raise (Pillow can't open it), which is honest but total — it would strand the iPhone tenant's primary photo format with no escape hatch, since the code path that raises happens before the code ever reaches the --include-originals fallback.

Adding real HEIC decode/strip support means a new native-codec dependency (pillow-heif/libheif), which I confirmed resolves via uv and round-trips EXIF/GPS correctly in a scratch test — but libheif's bundled encoder path carries non-uniform licensing (the PyPI package lists a GPLv2 classifier alongside its own BSD-3-Clause one), and evaluating that against this AGPL/Apache dual-licensed, supply-chain-audited project is its own review, not a rider on a bug fix. So: src/habitable/media_types.py is now the single registry capture.py and packet.py both read (extension → MIME type, and MIME type → export handling in one place); image/heic is registered with export_kind="unsupported" — recognized, hashed, sealed, and timestamped exactly like any other capture, but honestly marked as having no default sanitizer. --include-originals — already-existing, already-tested code, unconditional in _build_item/_build_artifact_item — is HEIC's real, working, disclosed export path, proven end to end (capture → export → independent verify_packet) in the new regression suite.

tests/test_media_types.py::test_every_registered_media_type_has_a_working_export_path is parametrized over every entry in the registry and proves, with a real capture→export→verify round trip (not just data-shape assertions), that no media type is ever a dead end: a supported type exports real hash-verified shared media by default; image/heic refuses the default export and exports real hash-verified bytes via --include-originals. A second test proves the backstop is independent of the registry too: test_a_genuinely_unmapped_media_type_is_refused_at_export captures with an explicit, made-up media_type that appears nowhere in media_types.py and confirms build_packet still refuses it by name.

Decision 3 — tightened the verifier (my judgment). Given decision 1 makes the byteless state unreachable through normal export, I still chose to close it in verify.py for defense-in-depth: ItemVerdict gained evidence_present (True unless the item has neither a shared_name nor an embedded original), folded into structurally_intact. A hash-and-timestamp-only item can now never be evidence_ready, full stop — even with an otherwise-valid, authority-trusted timestamp (see tests/test_packet_verify.py::test_byteless_item_is_never_structurally_intact_or_evidence_ready, which hand-crafts exactly that item). I did not treat "hash+timestamp only, no bytes" as a legitimate disclosure state on its own: this tool's whole value proposition is that READY means something specific to a non-technical reader (tenant, inspector, legal-aid worker, court), and "READY" for an item with literally nothing to look at is exactly the failure mode the issue reports. The --include-originals-without-a-sanitized-preview state (real bytes, just not a browser-viewable shared copy) is different and legitimate — it reads evidence_present=True and can reach evidence_ready, but I made it visible, not just a machine note: packet.html's per-item figure now renders "No shared preview copy was made for this item... download the original (...it may retain full metadata, including location)" with a real link, instead of a blank figure, and the evidence appendix table gained a "Media" column (included / original only (no shared preview) / NONE — no evidence bytes) so both states are visible in the human-readable view, not buried in notes. docs/verifier-decision-table.md §0 and new §4.2b state this precisely; docs/embedding-the-verifier.md documents the new field for embedders.

Incidental fix

The new registry-driven test was the first thing to ever export a PNG/WEBP/TIFF capture end to end, which surfaced a real, pre-existing, unrelated bug: exif.py::_strip_with_pillow called Image.getdata(), deprecated in the pinned Pillow floor (12.3.0), which this project's filterwarnings = ["error"] pytest policy turns into a hard failure. Swapped for the documented replacement (get_flattened_data(), byte-identical semantics, confirmed via a putdata round-trip); added tests/test_evidence_exif.py::test_strip_non_jpeg_raster_removes_embedded_gps as direct regression coverage (GPS-embedded PNG → stripped copy has no location, pixels survive).

What did not change

Per scope: RFC 3161 timestamp trust logic, chain verification, and every already-correctly-exporting capture type (jpeg/png/webp/tiff/mp4/mov/m4a/mp3/wav/csv) are untouched in behavior — _EXT_BY_TYPE/_DATA_EXT_BY_TYPE are now derived from the registry but produce byte-identical dicts to the hand-written originals for every previously-supported type. campaign.UnitHealth.export_ready is unchanged in behavior (it's a vault-level "nothing known to block starting an export" signal, computed without calling build_packet, so it never made a false claim about packet contents) — I added a docstring clarifying its distinct scope from verify_packet's evidence_ready, since a unit can now be export_ready=True and still have habitable export refuse with a named PacketError, which is the correct, honest outcome (loud refusal beats a silent roll-up lie). packet.pdf's analogous per-item rendering path (pdf.py::_render_evidence_item) has the same historical blank-space gap for a byteless item as packet.html did — I left it untouched: the README states packet.html is "the designated accessible rendering" and the PDF is explicitly not tagged/accessible, and decision 1 makes this state unreachable via real export regardless, so I judged expanding into a third rendering module's ReportLab-specific code out of scope for this fix. Noting it here rather than silently leaving it.

Verification

Full gate, from a fresh worktree off origin/main:

$ make verify
uv run ruff format --check ...    → 118 files already formatted
uv run ruff check ...             → All checks passed!
uv run mypy                       → Success: no issues found in 116 source files
uv run pytest -m "not integration" --cov=habitable --cov-fail-under=85
                                   → 1033 passed, 2 deselected in 303.17s
                                   → Total coverage: 90.39% (floor 85%)
uv run coverage report --include=crypto.py,vault.py,tsa.py,verify.py --fail-under=95
                                   → combined 96% (floor 95%)
uv run python scripts/check_i18n_utf8.py       → OK, 313 files
uv run python scripts/check_bcp47.py           → OK, en/es
uv run python scripts/check_i18n_parity.py     → OK, 273 keys, EN/ES in lockstep
uv run python scripts/check_doc_links.py       → OK, 105 Markdown files
markers gate                                    → no bare TODO/FIXME/HACK
habitable: full gate green on Python 3.14.5

uv run habitable demo also re-run manually end to end (unaffected capture types) — still exports and independently verifies evidence readiness: READY for its two synthetic JPEGs.

New/changed test coverage:

  • tests/test_media_types.py (new) — the registry↔exporter consistency guard, the genuinely-unmapped-type backstop test, and the parametrized every-registered-type round trip (11 media types × capture→export→verify).
  • tests/test_packet_verify.py — decision 3's byteless-item unit test, plus two pre-existing hand-crafted-item tests updated (they relied on the now-forbidden byteless state to isolate unrelated timestamp-authority logic; fixed by giving them real embedded-original bytes instead of deleting their assertions).
  • tests/test_htmlpacket.py — both new visible-rendering states (nothing at all / original-only), simulated via a real built packet's bundle mutated to the byteless/original-only shape (since build_packet itself can no longer produce it).
  • tests/test_evidence_exif.py — regression coverage for the incidental Pillow-deprecation fix.

This is safety-relevant software for people documenting housing conditions under retaliation risk; I did not touch anything about what READY is allowed to mean beyond making it stricter, and I'd rather under-claim than over-claim what this fix guarantees — in particular, HEIC photos still have no default, metadata-stripped, browser-viewable export path; --include-originals is real and tested but is a deliberate, disclosed, higher-fidelity/higher-disclosure choice an operator has to make on purpose, not a drop-in equivalent to what jpeg/png/webp/tiff already get by default.

…158)

A capture whose media type had no packet export mapping (.heic, the iPhone
default photo format) shipped with shared_name="", nothing in media/, and no
copied_for_sharing custody entry -- and habitable verify still reported the
packet READY. This closes the gap with the issue's three separable decisions:

1. Fail closed at export. packet.build_packet now refuses to publish any
   item that would carry neither a shared copy nor an embedded original,
   raising a PacketError naming the capture id and media type.

2. Close the map gap. capture.py and packet.py now read one canonical
   registry (habitable.media_types) instead of two independently
   hand-maintained maps, so this class of gap cannot recur unnoticed. .heic
   is registered as export_kind="unsupported" (this project's only
   image-processing dependency, Pillow, cannot decode HEIC without a new
   native codec dependency that needs its own supply-chain/licensing
   review) rather than given a broken sanitizer -- --include-originals
   remains a real, tested, disclosed way to export it byte-exact.

3. Tightened the verifier as defense-in-depth. ItemVerdict gained
   evidence_present, folded into structurally_intact: an item with no
   shared media and no embedded original can never be evidence_ready, even
   with an otherwise-valid, authority-trusted timestamp. packet.html's
   per-item figure and evidence appendix now visibly say when an item has
   no shared preview or no evidence bytes at all, instead of rendering an
   empty figure indistinguishable from an intact one.

Incidentally discovered and fixed: exif.py's non-JPEG raster stripping path
called a Pillow accessor this project's Pillow floor (12.3.0) deprecated,
uncaught until the new registry-driven regression test exercised PNG/WEBP/
TIFF export end to end for the first time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn4wq6C7WeV7zbYyUwcHu5
@ChelseaKR
ChelseaKR merged commit a143694 into main Aug 7, 2026
13 checks passed
@ChelseaKR
ChelseaKR deleted the fix/export-refuses-unexportable-media branch August 7, 2026 03:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant