Status: Phases 1–2 complete; revised for Phase 3 hardening · Last updated: 2026-09-12
This document must be revisited every time a format handler is added or substantially changed. A new format brings new places for data to hide, and a threat model that lags the code is worse than none — it describes protection the tool no longer provides.
The identity, location, affiliations, devices, and activity patterns of the person who created or handled a file — all of which can be inferred from metadata that no ordinary viewer displays.
Adversaries are listed by capability. strypt's protection is meaningful against all of them for the metadata it removes; what differs is how much the residue matters and how much effort the adversary will spend on it.
A. The casual recipient. Anyone who opens a file's properties dialog or drags it into an online EXIF viewer. Sees author name, GPS, timestamps. No special skill or intent. strypt is highly effective here.
B. The motivated individual. A harasser, a stalker, an abusive ex-partner. Will use
ExifTool, will read forum guides, will spend hours. This adversary is the reason the
domestic-violence-support persona exists in docs/PRD.md, and often the most dangerous in
practice because they already know who the target is and need only where.
strypt is effective, and the residue that matters most is what strypt does not touch — see §4.
C. The organisational adversary. A corporate legal or security team, or a newsroom's opponent, investigating a leak. Has forensic tooling, has the original documents to compare against, and can correlate across many files. Can exploit differences between documents, not just their contents. strypt helps, but correlation attacks (§4.7) become significant.
D. The state-level actor. Full forensic capability, access to intermediary infrastructure, ability to compel third parties, patience, and the ability to combine metadata with signals strypt never sees. May also target strypt itself — the binary, its distribution channel, or its dependencies. strypt is one control among many and must not be treated as sufficient. Anyone facing this adversary needs operational security advice far beyond a metadata scrubber.
We also assume the file itself may be hostile (§5): a document sent to a journalist specifically to exploit whatever tool they run it through.
Within its supported formats, strypt removes:
- Location data — EXIF GPS coordinates, altitude, direction, and timestamps that correlate with location.
- Device identity — camera make, model, lens, and body serial numbers. Serial numbers are particularly dangerous: they link every photograph a device ever produced, so a single un-stripped image can retroactively deanonymise an entire archive.
- Personal identity — author, creator, and last-modified-by names; organisation names; registered software owner strings.
- Software fingerprints — producing application and version. Rarely identifying alone, frequently identifying in combination (§4.7).
- Timestamps — creation and modification times, which reveal working patterns, time zones, and whether a document was prepared before or after a claimed event.
- Embedded thumbnails and previews — which can survive cropping and visual redaction of the main image, meaning a "redacted" photograph may carry an unredacted copy of itself.
- Editing traces — where the format exposes them: revision identifiers, editing-cycle counts, total editing time.
This section matters more than §3. A user who over-trusts the tool is in a worse position than one who understands its limits, because they will take risks based on a guarantee that was never made. Nothing here should be softened for marketing reasons.
4.1 Content. strypt does not read what your document says. A name in the body text, a recognisable street in a photograph, a reflection in a window, a visible badge or screen — all untouched. strypt removes metadata, not information.
4.2 Writing style. Stylometry can attribute authorship from text alone. strypt does nothing about this, and no metadata tool can.
4.3 Redaction. strypt does not redact. A PDF with a black rectangle drawn over text still contains that text and strypt will not remove it. This is a distinct and frequently-fatal mistake, and the README should say so plainly.
4.4 Metadata added after strypt runs. Uploading a file to any platform hands it to a pipeline you do not control. Servers re-encode images, add their own identifiers, and record upload time and source IP. strypt cleans the file you have; it cannot clean what a service does afterwards.
4.5 Network and traffic analysis. strypt makes no network connections (ADR-0004) and therefore reveals nothing itself — but it also protects nothing about how you transmit the file. Who you sent it to, when, and from where are outside its scope entirely.
4.6 Filesystem and out-of-band metadata. Filenames (budget_final_jsmith_home.pdf),
directory structure, filesystem timestamps, extended attributes, macOS resource forks,
Windows alternate data streams, and cloud-sync sidecar files all carry information. strypt
operates on file contents. Phase 1 should warn about identifying filenames in show
output, but the user remains responsible.
4.7 Correlation and fingerprinting. Even fully stripped files carry a fingerprint. JPEG quantisation tables and encoder quirks identify the producing software. PDF object ordering and structure identify the generator. Sensor pattern noise (PRNU) can identify an individual camera from pixel data alone, with no metadata whatsoever. A cluster of individually innocuous traits — "produced by this LaTeX version, on this platform, with these fonts" — can narrow authorship dramatically. strypt cannot defeat this class of attack, and against adversary C or D it may be the attack that matters.
4.8 Unknown-unknowns within supported formats. Complex formats hide data in places no
implementation enumerates completely. mat2's own README is explicit that seeing no metadata
does not mean a file is clean. strypt inherits that honesty (docs/PRD.md §12). The
post-strip verification pass (docs/ARCHITECTURE.md §1) catches only what the inspector
knows to look for — it makes the tool internally consistent, not omniscient.
4.9 A compromised machine. If the endpoint is compromised, the original file was already readable before strypt ran. Metadata removal is irrelevant at that point.
4.10 The user's own mistakes. Sending the original by accident. Forgetting one file in a batch. Keeping an un-stripped backup in the same directory. Safe defaults (copy-out, explicit opt-in for destructive operations, loud failures) mitigate this, but design cannot eliminate it.
5.1 Malicious input files. The primary technical threat: a file crafted to exploit
strypt's parsers. Handled by the adversarial-input architecture in
docs/ARCHITECTURE.md §5.1 — safe Rust with forbid(unsafe_code), no panics, bounded
allocation, recursion limits, and per-handler fuzzing. Memory-safety exploitation is ruled
out by construction; resource exhaustion and non-termination are the realistic residual
risks, which is why fuzzing must treat hangs and OOMs as findings equal in severity to
crashes.
Phase 3 finding: no hang or OOM has been found. Four of the five PDF findings were wrong output
caught by the idempotence assertion, not crashes, so correctness assertions in the harness earn
more than crash-only fuzzing. Hours do not replace structure: ogg did not certify in 84
CPU-hours, then certified in 24 once its mutator repaired page CRCs (ADR-0044). jxl and png
still do not certify (scripts/fuzz-tally.py).
5.2 Supply-chain compromise. A malicious or compromised dependency runs with full access
to the user's most sensitive documents, and is the attack path least visible to users.
Mitigated by minimal dependencies (ADR-0008), cargo-deny as a hard CI gate, a committed
Cargo.lock, and release SBOMs. Not eliminated.
Phase 3 finding: no sandbox contains a compromised dependency (ADR-0048), and the no-network gate
matches crate names, so it cannot see a dependency calling std::net directly. The stricter
cargo-deny caught a yanked crate on its first run (ADR-0045).
5.3 A malicious build or distribution channel. A tampered binary could exfiltrate everything it touches. Mitigated by reproducible builds, published checksums, and provenance back to a source commit (Phase 4). This is why Phase 4 treats verifiable provenance as a feature rather than packaging polish.
5.4 Silent failure — the most dangerous bug class in this project. A bug where strypt
reports success while leaving metadata in place is worse than a crash, because the user acts
on the report and publishes. Treated as a security vulnerability, not a defect
(docs/ARCHITECTURE.md §5.4). Mitigations: the verification pass, fail-closed handlers,
differential testing against mat2 and ExifTool, and an explicit rule that unsupported
formats are reported as unsupported and never silently passed through.
5.5 Leakage through strypt's own outputs. Log files, error messages, JSON output, and
temp files can each contain the metadata the user just removed — a durable copy of the
secret. Hence: never log metadata values above trace level; temp files go alongside the
destination with restrictive permissions and are removed on failure; error messages name
fields, not values. Restrictive means owner-only on Unix filesystems only: on vfat/exfat the
output takes the mount's mode, and on Windows the folder's ACL (ADR-0043, ADR-0047).
5.6 Over-trust induced by the tool's own confidence. If strypt's output reads as an unqualified guarantee, users will take risks they would not otherwise take. This is a threat created by documentation and UI, not by code, and it is the reason for the ban on "complete", "guaranteed", and "100%" in user-facing text.
Stated so they can be challenged; each is a place the model could be wrong.
- The user's machine is not already compromised (§4.9).
- The user obtained an authentic strypt binary (§5.3).
- The user understands strypt handles file contents only, not filenames or transmission.
- Supported-format handlers are more thorough than a naive manual attempt — validated by differential testing, not assumed.
- Removing metadata does not itself create a distinguishing signal. This assumption is weak. A stripped file may be conspicuous precisely because it is unusually clean, and in a small population of documents "the one with no metadata" can itself be a lead. strypt cannot resolve this; users in that situation need to consider whether a plausible-looking file is safer than a clean one.
- strypt's dependencies are not malicious. Nothing contains one that is (§5.2, ADR-0048).
Added as each handler lands, from what implementing and testing it actually taught us — not from what the specification says ought to be true. §4's general limitations still apply on top of everything here.
Fuzzing run history is in CHANGELOG.md and fuzz-runs/; certification is
scripts/fuzz-tally.py's (ADR-0044). Each subsection keeps one line on it.
What strypt removes. The Document Information Dictionary, including keys no specification
ever defined — applications invent their own freely, and a custom key is no less identifying
for being non-standard. XMP metadata packets at document and object level. The trailer /ID.
/PieceInfo, which is a scratch area where an application may store whatever private state
it likes between editing sessions. /LastModified. Markup-annotation authorship (/T),
dates, and identifiers. Embedded-file parameter dates and checksums. The Exif, XMP and other
segments of every JPEG image and page thumbnail, through the JPEG handler (ADR-0056).
Objects left behind by incremental updates are the finding that shaped the design. A PDF saved more than once contains every earlier version of itself: the old bytes stay, and a new cross-reference section declares what supersedes what. Nothing in a normal reader shows this, and neither ExifTool nor mat2 reported the superseded author in our own test fixture — but it sits in the file in plain text. strypt rebuilds the document from what the catalogue can reach, so those objects are never written out (ADR-0020). Any tool that patches a PDF in place rather than rewriting it leaves them there while reporting the file clean.
What remains, and why.
- Annotation contents. The comment text a reviewer wrote is preserved; only their name and the dates are removed. This is §4.1 applied deliberately — content is not strypt's to delete. It is a real difference from tools that re-render the page, which lose the comment along with its author.
- Form field names.
/Ton a/Widgetannotation is the field name that the form's logic and its saved data depend on, not a person's name. It is kept. Breaking a user's document to protect them is not a trade this tool makes silently. - JPEG 2000 images, JPEGs inside another filter, and inline images are not opened (ADR-0056). The first two are named in the report; inline images are not. 0.1.0 opened no image at all, and reported a placed photo's GPS as clean.
- Embedded attachments are not opened. Their parameter metadata goes; whatever is inside them is untouched, and the report says so. Recursing into nested files is a zip-bomb-shaped problem that Phase 2 has to decide about explicitly, with a depth and expansion limit.
- Compressed XMP packets are removed but not itemised. ISO 32000-1 §14.3.2 recommends metadata streams be left uncompressed, and in practice they nearly always are. Inflating the rare compressed one to produce a more detailed report would mean accepting a decompression bomb in exchange for a nicer listing. The packet is still found and still removed.
- Structural fingerprints. Object ordering, the producer's layout conventions, font subsetting, and compression choices all survive and can identify the generating software. This is §4.7 and strypt does not address it. A full rewrite changes the fingerprint to strypt's own rather than erasing the notion of one — which, per assumption 6.5, may itself be distinguishing in a small population of documents.
What strypt refuses. Encrypted documents. lopdf can open one protected by an empty owner
password, and emitting a decrypted copy would strip the user's protection along with their
metadata — a change to their document's security they did not ask for and might not notice.
A trailer with no /Root is also refused, by both show and strip. ISO 32000-1 §7.5.5 makes
the entry required: it names the document catalogue, the single root every other object hangs
off, so without it the file has no defined entry point and no viewer opens it. strypt used to
accept such a file, and accepting was worse than refusing. The rewrite walks reachable objects
from the root and drops the rest (ADR-0020); with no root, which objects survive is not stable
between runs. The pdf fuzz target found it: one strip produced 609 bytes and a second produced
485, because the second pass dropped an annotation object a page still referenced through
/Annots, and renumbering then put the catalogue in that slot — so the page's annotation array
pointed at the document catalogue. That is corruption strypt introduced itself while reporting
success both times, which is §6 fail-closed inverted. Refusing costs the user nothing real: a
PDF this broken cannot be published either way. corpus/pdf/malformed/no-root-trailer.pdf pins
the behaviour, and both the triggering shapes are in the pdf seed corpus.
A panic inside lopdf reached the shipped binary, and is now contained. A sustained fuzz
run found an integer overflow in lopdf 0.44.0's cross-reference parser (parser/mod.rs:516,
computing start + index where start is read from the file). Because Cargo.toml
deliberately enables overflow-checks in release — an overflow parsing an attacker-controlled
field should abort rather than wrap into a nonsensical offset (ADR-0006) — the release binary
panicked with exit 101 and a stack trace, not merely the debug build. 0.44.0 was already the
newest release.
Calls into lopdf that touch untrusted bytes are now wrapped so an unwinding panic becomes a
typed DependencyPanic refusal (ADR-0024). What that is worth stating precisely: the panic was
already fail-closed — the process died before writing anything, so no partially-sanitised file
escaped and no success was reported on an unprocessed file. What containment buys is that the
user gets an intelligible refusal instead of a crash indistinguishable from a bug in strypt, and
that exit criterion 2 is met by fixing the behaviour rather than by redefining it as acceptable.
It buys nothing at all against a dependency that returns a wrong answer quietly, which no
guard detects.
New attack surface this handler introduces. lopdf is a third-party PDF parser processing
attacker-controlled bytes, and this project's no-panic rule does not extend to it (ADR-0018).
#![forbid(unsafe_code)] rules out memory-corruption exploitation; it does not rule out a
panic, a hang, or unbounded allocation originating inside the dependency. This is the largest
piece of untrusted-input surface in the tree and it is not code we control. It is why the PDF
fuzz target exists, and it is a specific input to Phase 3's sandboxing decision — containing a
compromised or merely fragile dependency is one of the few things sandboxing genuinely buys a
safe-Rust parser.
One value is rewritten rather than copied: negative zero. lopdf writes Real(-0.0) as
-0, dropping the decimal point that made it a real; reading -0 back therefore yields
Integer(0), which writes as 0. Stripping once and stripping twice produced different bytes,
breaking the byte-identical idempotence invariant, and the value's type changed silently as
well. The handler now collapses negative zero to zero before writing.
Rewriting a number in someone's document deserves justifying in a handler that elsewhere
refuses rather than repairs. ISO 32000-1 §7.3.3 gives PDF numbers no signed zero: -0 and 0
denote the same value, no operator distinguishes them, and no renderer can. The rejected
alternative was refusing the file — which would have cost a user their entirely valid document
to preserve a distinction the format does not make. This is the opposite trade from the
19-byte-xref gap in §7.5, and deliberately so: there, accepting would have meant rewriting
untrusted bytes ahead of the parser to widen what strypt accepts; here, the document has
already parsed and the change provably preserves meaning.
The first version of this fix walked only the object graph and passed every test locally. CI's
fuzz smoke run then moved a negative zero into the trailer — which lopdf keeps outside
objects — and the assertion fired again within minutes, on a document whose object graph was
entirely clean. Both are now walked. The lesson generalises past this bug: a normalisation pass
is only as complete as its traversal, and "all the objects" was not all the document.
Found by the PDF fuzz target 6985 seconds into a two-hour run, through the harness's
idempotence assertion — the second real PDF defect that one assertion has caught, after the
stream-length bug in §7.5. Both were invisible to the verification pass, which searches output
for residual metadata and so cannot see a defect that leaves no metadata behind. The trigger
was a valid file: negative zero in a /CropBox is legal, and nothing about such a document
would strike a user as unusual.
Renumbering runs to a fixed point, and a document that will not settle is refused. The
rewrite renumbers objects so that output depends on the object graph rather than on whatever
numbering the input happened to use (ADR-0020). lopdf::renumber_objects turns out not to be
idempotent: before renumbering sequentially it checks whether page order matches ascending
object ids and, if not, permutes the page objects until it does. That check reads the numbering
the previous step produced, so one pass can leave a document a second pass would reorder again.
The reachable case is a self-referential page tree — a /Page whose own /Kids array lists
itself. Pruning and renumbering then changed both which objects page_iter yields and their
order, so the first strip produced pages ordered [3, 2] and the second swapped objects 2 and
3: same length, same content, 145 differing bytes. It converged from the third strip onward,
so this was never an endless flip — but the invariant is stated byte-for-byte on the first
re-strip, and a user who strips a file twice must not get two different files.
The handler now renumbers until the id set and page order both stop changing, and only then
serialises, so re-loading that output cannot move anything either. A document still moving
after four rounds is refused as CyclicReference rather than written at whatever state the
last round left — emitting a file whose numbering strypt could not settle would mean promising
reproducibility it cannot deliver (CLAUDE.md §3 rule 6). Output for documents that were
already stable is unchanged; for those the extra round is the confirmation, not a permutation.
Found by the PDF fuzz target 5268 seconds into the twelve-hour seven-target run of 2026-08-23, through the same idempotence assertion — the third real PDF defect it has caught, after the stream-length bug in §7.5 and negative zero above. Unlike negative zero, the trigger here is a genuinely malformed file rather than a valid one.
Output is read back before it is returned, and a file that does not round-trip is refused.
The rewrite assumes that serialising a parsed document and re-parsing it yields the same
document (ADR-0020). For a lenient parser on hostile input that does not hold, and when it
fails it fails silently: lopdf accepts a dictionary whose keys came out of mangled bytes and
then writes it back in a form it cannot itself read, so the object is written and is gone when
the file is next opened.
The file that found this had exactly one /Page, and it was the object that vanished. strypt's
output was therefore a document whose /Pages node still claimed /Count 1 with a /Kids
array pointing at an object that no longer existed — and strypt reported success. Stripping
that output pruned what had become unreachable and produced a 230-byte file with a dangling
page reference, reporting success again.
The verification pass could not have caught this, and it is worth being precise about why. That pass searches output for residual metadata, and no metadata survived either write. It looks for what should be absent; this is a failure of something that should still be present. The two are not the same check, and only the idempotence assertion in the fuzz harness was positioned to notice.
The handler now re-loads its own output and refuses it as NotRoundTrippable unless every
object written is present on reload and a page tree that existed before writing still exists
after. A full structural equivalence check would be a second implementation of the rewrite;
these are the two properties whose failure means the output is not the document. Refusing costs
the user a file already too damaged to survive a rewrite. Returning it cost them a document
they believed was clean, which is the trade CLAUDE.md §3 rule 6 exists to settle.
Found by the PDF fuzz target 27998 seconds into the twelve-hour seven-target run of 2026-08-24,
the fourth real PDF defect the idempotence assertion has caught. This one is the same shape as
the /Root corruption above rather than the numbering instability: strypt introduced the
damage itself and reported success.
What strypt removes. Every APPn segment except the two named below, and every COM
comment. That covers Exif — including the GPS directory, the maker note, and the thumbnail
directory — XMP packets and their extension segments, Photoshop image resources and the IPTC
block inside them, ICC colour profiles, the FlashPix and multi-picture segments, and the
vendor blocks several camera makers put in APP12. Exif is reported tag by tag rather than as
one lump, because "GPSLatitude, BodySerialNumber, DateTimeOriginal" is what lets someone judge
a file they already published.
Data after the end-of-image marker is the finding worth knowing about. A JPEG ends at its
EOI marker and nothing stops a file continuing past it. In practice that is where a phone's
multi-picture extension keeps a second, full-resolution frame: a viewer shows the picture that
was cropped, and the file contains the one that was not. strypt drops everything after EOI
and reports it. It is worth checking what a tool you rely on does with those bytes.
A body serial number is the tag with the longest reach. It links every photograph a camera ever took. One image published with it intact retroactively attributes an entire archive that was otherwise clean — which is why the report names it rather than counting it.
What remains, and why.
APP0(JFIF) andAPP14(Adobe). Both are kept deliberately and both appear in the strip report'sretainedlist, so a reader sees them rather than discovers them.APP0carries the pixel aspect ratio;APP14declares the colour transform, and a CMYK or YCCK file without it renders with wrong colours. Neither names a person, a place, or a device. KeepingAPP14is a documented gap against mat2, which removes it (ADR-0021).- The encoder's fingerprint. Quantisation tables, Huffman tables, chroma subsampling, and scan structure all survive, and together they identify the software and often the device that produced the file. This is §4.7, it is not addressed, and it cannot be addressed without re-encoding — which would destroy the picture to hide the camera. A tool that re-encodes replaces the camera's fingerprint with its own rather than removing the notion of one (assumption 6.5).
- The picture itself. strypt never decodes or re-encodes an image, so anything visible in the frame — a face, a street sign, a screen — is exactly as it was. §4.3 applies: visual content is the user's to redact, and metadata removal is not redaction.
What strypt refuses. A file that ends without an EOI marker, or whose segment lengths do
not agree with its size. Completing a damaged file would hand the user something that is not
what they gave us, presented as a clean version of it.
What is removed even though it changes how the file renders. Exif Orientation and the
ICC profile. An image that relied on Orientation may afterwards display rotated, and a
wide-gamut image is interpreted as sRGB. Both are identifying — a per-device ICC profile is a
fingerprint and its description tag routinely names the vendor — so both go, and the
consequence is documented rather than hidden.
New attack surface this handler introduces. None from dependencies: the JPEG segment
walker and the Exif reader are written in this repository, under the crate's panic-freedom
lints, over the shared checked-reading primitive. The residual risks are the ones safe Rust
still has — a hang or unbounded allocation on a hostile file — which is what the jpeg fuzz
target exists to find.
What strypt removes. Every text chunk — tEXt, zTXt, and iTXt — the tIME
modification timestamp, the eXIf block, the iCCP colour profile, sPLT, every ancillary
chunk strypt does not recognise, and anything after IEND.
PNG's text chunks are a general-purpose store, and that is the finding. Unlike Exif, there
is no fixed field list: a keyword is any Latin-1 string up to 79 characters, and applications
use that freely. In practice the contents are worse than the format's reputation suggests.
Freedesktop thumbnailers write Thumb::URI, which is the full path of the original file —
it names a home directory, and a home directory names a person. ImageMagick stores entire
Exif and IPTC blocks as hex text under Raw profile type keywords, so a PNG converted from a
JPEG can carry the camera's GPS coordinates in a chunk that a tool looking only at eXIf
walks straight past. Screenshot and editing tools write their own names. strypt classifies by
keyword so that the report ranks these the way this document does, rather than filing
everything under "text".
Measured against other tools on 2026-08-19. ExifTool 13.55 finds nothing but structural
image properties — dimensions, bit depth, colour type — in strypt's output for every fixture in
corpus/png. Against mat2 0.15.0 over corpus/png/text-chunks.png: both remove the text
chunks, and the difference is what happens to the image. strypt's IDAT is byte-identical to
the input's; mat2's PNG path re-encodes through Pillow, which rewrote the 8-bit greyscale image
as 8-bit RGB — colour type 0 to colour type 2, tripling the pixel data — and dropped gAMA,
sRGB, and pHYs while adding a bKGD chunk of its own. ImageMagick reports zero differing
pixels, so nothing visible changed; the file did. That is a deliberate trade on their side
and a real one — re-encoding is robust against structures a parser does not understand — but
it means the output is no longer the file the user had, and it replaces the original encoder's
fingerprint with Pillow's rather than removing the notion of one (assumption 6.5). mat2 also
refuses corpus/png/unknown-chunks.png outright, which is a reasonable outcome for the same
reason any conforming decoder refuses it.
What remains, and why.
- Compressed text is removed but not itemised.
zTXtis compressed by definition, andiTXtis when its flag says so. strypt carries no decompressor (ADR-0022) and does not need one: the keyword, the compression flag, the language tag, the translated keyword, andiCCP's profile name are all outside the compression, and the chunk is removed whole regardless. What is lost is granularity — an XMP packet in an uncompressediTXtis broken down by property, and the same packet compressed is one finding. This is the same trade §7.1 records for aFlateDecoded PDF metadata stream, made for the same reason: a decompression bomb is a real cost and a nicer listing is not worth it. - Rendering chunks survive.
gAMA,cHRM,sRGB,sBIT,tRNS,bKGD,hIST, the HDR chunks, and the APNG animation chunks are all copied through. None of them names a person, a place, or a device, and several change how the image looks if they go.pHYs— the pixel dimensions and DPI — is additionally declared in the strip report'sretainedlist, because it is the chunk a careful user is most likely to expect to have gone. - An unknown critical chunk is kept, and the report says it was not examined. Critical
means the producer marked it as required in order to interpret the image, so strypt cannot
know what it holds or what depends on it. It is copied through and reported as an unparsed
region, so the user is told plainly that some bytes went by unexamined. The honest
consequence: a conforming decoder already refuses such a file, so keeping the chunk leaves
it exactly as unreadable as it arrived —
corpus/png/unknown-chunks.pngis deliberately one of those files. Dropping the chunk to make the file open would be strypt deciding what the document is, which is a bigger decision than the user asked for. An unknown ancillary chunk is removed: a private chunk can hold anything, and copying through what you do not understand is not scrubbing. - The encoder's fingerprint. Filter choices per scanline, the deflate implementation's output, chunk ordering, and interlacing all survive and together identify the producing software. This is §4.7 and it is not addressed; addressing it would mean re-encoding the image, which for a lossless format is exactly what a user chose PNG to avoid.
- The picture itself. strypt never decodes or re-encodes an image, so anything visible in the frame — a face, a screen, a filename in a screenshot's title bar — is exactly as it was. §4.3 applies, and for PNG it applies hardest: a screenshot is made of content that a metadata tool cannot help with.
What strypt refuses. A file whose first chunk is not IHDR, one that ends before IEND,
one whose chunk length runs past the end of the file or sets the high bit the specification
reserves, and one whose chunk type is not four letters. Each of those means the walk is not
where it thinks it is, and a "cleaned" copy would be a guess presented as a fact.
New attack surface this handler introduces. None from dependencies, and this is where the
decision in ADR-0022 pays: the obvious implementation of PNG text handling pulls in a zlib
decompressor and feeds it attacker-controlled bytes, and strypt's does not. The chunk walker
is written in this repository, under the crate's panic-freedom lints, over the shared
checked-reading primitive. CRCs are copied rather than recomputed, so there is no checksum
code either. The residual risks are the ones safe Rust still has — a hang or unbounded
allocation on a hostile file — which is what the png fuzz target exists to find.
It is punctuated under ADR-0044 (KNOWN_LIMITATIONS).
What strypt removes. The ICCP colour profile, the EXIF block, the XMP packet, every
chunk strypt does not recognise — at the top level and inside an animation frame alike — and
anything after the length the RIFF header declares. The VP8X header's ICC, Exif, and XMP
flag bits are cleared so that the file stops claiming metadata it no longer has (ADR-0023).
WebP concentrates its metadata in three chunks, and that is the good news. Unlike PNG's open-ended text-chunk store, there is no general-purpose key-value area a producer can invent fields in: RFC 9649 §2.7.1.5 gives Exif and XMP one chunk each, and colour management one more. The corresponding bad news is §2.7.1.6, which asks writers to preserve chunks they do not recognise, and §2.7.1.1, which explicitly allows unknown chunks inside an animation frame. Both are hiding places with the specification's blessing, and both are places a conforming WebP writer will carry data through untouched. strypt removes them in both positions. It cannot break a decoder by doing so, because the same section that asks writers to keep unknown chunks tells readers to ignore them.
Measured against other tools on 2026-08-19. ExifTool 13.55 finds nothing but structural
image properties — dimensions, flags, animation timing — in strypt's output for every fixture
in corpus/webp.
The mat2 differential for WebP is now run, and it passes. From 2026-08-19 to 2026-08-21
this section recorded it as not run: mat2 0.15.0 lists image/webp as supported but reaches
it through GdkPixbuf, and the verification machine had no WebP pixbuf loader, so mat2 failed
identically on the original fixtures and the comparison said nothing about strypt.
Installing webp-pixbuf-loader 0.2.7 resolved it. On 2026-08-21,
scripts/webp-differential.sh compared strypt against mat2 0.15.0 across all 14 fixtures in
corpus/webp and all 30 WebPs in the real-producer corpus: no tag that mat2 removes survives
in strypt's output, in either set. The script refuses to run when the loader is absent, rather
than reporting a clean sweep that would prove nothing.
Two results are worth recording, neither of them a strypt finding:
- mat2 flattens an animation. Its WebP path decodes and re-encodes through GdkPixbuf, so a
two-frame
animated.webpreturns as a single stillVP8chunk with noANIM, noANMFand no frame timing; a six-frame real-producer file likewise. strypt keeps every frame and removes only theEXIFchunk. This is the other face of "the encoder's fingerprint" below: re-encoding destroys the fingerprint strypt deliberately leaves alone, and destroys the animation with it. Neither behaviour is wrong — they are different answers to whether a metadata tool may alter the picture. A user who needs the encoder fingerprint gone, and does not need the animation, is better served by mat2. - Re-encoding can expose properties the input did not. On
1_webp_ll.webp, mat2's output carriesALPHparameters absent from the input. These are bitstream encoding choices, not metadata, and leak nothing the user had — noted because a differential that merely counts tags would misread it as mat2 adding metadata.
What remains, and why.
- An extended file is not returned byte-identical. Two fields change beyond the removals:
the
VP8Xflags byte, and the RIFF chunk's own size. A simple-format file — noVP8X— is a guaranteed byte-identical pass-through, because §2.7 requires the extended header before any metadata chunk, so such a file has nowhere to put any. So is an extended file whose flags describe only the picture. This is a weaker property than PNG's and it is the price of the flags decision in ADR-0023; the alternative was leaving a file that lies about itself. - A file can change with nothing reported as removed. A header claiming an Exif chunk that
the file does not contain is corrected on strip, while
showreports nothing — the flags byte names nobody, so it is not a finding. The two commands genuinely disagree here, andcorpus/webp/stale-flags.webpis the case. - An animation frame that does not parse is kept, and the report says it was not examined.
strypt does not re-serialise a frame it only partly understands, and it does not refuse the
whole file over one. It copies the frame through and emits a
Note::UnparsedRegion, so the user is told plainly that some bytes went by unread rather than left to assume the frame was scrubbed.corpus/webp/unparsable-frame.webpis deliberately such a file. - The ICC profile is removed but never read. A profile's internal tags carry the device manufacturer, the model, and a creation date, and strypt reports the chunk without naming any of them: parsing an ICC profile means another format parser on attacker-controlled bytes, for report granularity on a chunk that is going regardless. Same trade as compressed text in §7.3.
- The encoder's fingerprint. The VP8 or VP8L bitstream carries its encoder's choices — quantisation, partitioning, prediction modes, the lossless transforms selected — and chunk ordering carries the muxer's. Together they identify the producing software. This is §4.7 and it is not addressed; addressing it would mean re-encoding, which for a lossy format also means degrading the picture a second time.
- The picture itself. strypt never decodes or re-encodes an image, so anything visible in the frame is exactly as it was. §4.3 applies.
What strypt refuses. A file that is not RIFF, or is RIFF but not WEBP; one whose declared
RIFF size or chunk size runs past what is available; one that does not open with VP8X,
VP8 , or VP8L; one whose VP8X is not the ten bytes §2.7 fixes it at; one whose
four-character code is not ASCII; and — the one worth naming separately — one that contains
no bitstream and no animation frame, which would otherwise strip to a valid-looking container
with no picture in it and be reported as a success. That is the failure mode in §5.4, reached
by a file consisting of nothing but a header and an EXIF chunk.
New attack surface this handler introduces. None from dependencies. The chunk walker is
written in this repository, under the crate's panic-freedom lints, over the shared
checked-reading primitive, and it shares the Exif and XMP readers with the JPEG and PNG
handlers rather than adding parsers of its own. WebP carries no checksums at all, so unlike PNG
there is not even a CRC field to reason about. The residual risks are the ones safe Rust still
has — a hang or unbounded allocation on a hostile file — which is what the webp fuzz target
exists to find.
The findings in §7.1–7.4 were reached against synthetic fixtures — files built to the
specification. On 2026-08-20 all four handlers were run over the fetch-on-demand corpus in
real-producer-corpus/: 102 files, comprising 23 camera and phone JPEGs (Canon, Nikon, Sony,
Samsung, HMD, Jolla, Apple), 23 PDFs (pdfLaTeX, LibreOffice, Google Docs, Acrobat,
ImageMagick), 27 PNGs and 28 WebPs. Each was put through show, then strip, then show
again on the output.
No panic, no hang, and no silent pass-through occurred. Ninety-six files processed and re-inspected clean. Five were refused, four of them correctly: three deliberately truncated generated files, and one password-protected LibreOffice document, which exercised the encryption refusal in §7.1 against a real file for the first time. Real Canon, Nikon and Sony MakerNote blocks — the least standardised region in Exif, and the one §7.2 calls out as the most likely to hide a parser bug — were handled without incident.
Refreshing the PDF fuzz seeds found a real bug, and it is worth reading as a lesson about
where the verification pass does not reach. ISO 32000-1 §7.3.8.2 requires a stream's
/Length to be an integer. Given 45. instead, lopdf 0.44 parses the document, reports no
error, keeps the malformed value, and stores empty stream content because it cannot locate
the stream's end. strypt accepted that document, rewrote it, and emitted a PDF whose /Length
still claimed 45 bytes over an empty stream — structurally invalid by qpdf's reading, with the
page's content gone — while reporting "nothing to remove; wrote a clean copy". That is a §5.4
failure: a success message about a file that was not correctly processed.
The verification pass did not catch it, and could not have. It searches the output for residual metadata, and a stream that has been emptied has none — the check and the defect were looking at different properties. What caught it was the fuzz target's byte-for-byte idempotence assertion, because a second strip produced different bytes again. This is the argument for keeping invariants in the fuzz targets that duplicate no production check.
load() now refuses any document with a stream whose declared length disagrees with its
parsed content. The refusal costs nothing on real files: across the synthetic corpus and all
23 parseable real-producer PDFs, not one stream disagrees.
One genuine gap surfaced, and it is a capability gap rather than a safety failure.
019-grayscale-image.pdf writes cross-reference entries of 19 bytes instead of the 20 that
ISO 32000-1 §7.5.4 fixes them at, dropping the padding space before each newline. qpdf --check reports no syntax errors on that file and mat2 strips it successfully, but
lopdf 0.44 — the parser strypt uses (ADR-0018), and already the newest release — rejects
the trailer, so strypt refuses a document that the tool it is most often compared with
handles. Padding the entries to 20 bytes makes it parse, confirming the cause.
This is recorded as a known gap rather than worked around. Normalising a cross-reference table before parsing would mean rewriting untrusted bytes ahead of the parser, in the most security-sensitive path in the project, to widen what strypt accepts — the wrong trade for this tool. Refusing is the correct fail-closed behaviour (§5.4): the user is told the file was not processed and can reach for another tool, which is the outcome that keeps them safe. Where mat2 handles a file strypt cannot, mat2 is the better recommendation, and this is such a case.
corpus/pdf/malformed/xref-19-byte-entries.pdf reproduces the structure synthetically — the
upstream file carries a real person's name, which §3 of docs/TESTING_STRATEGY.md keeps out
of the committed corpus. The accompanying test pins the refusal as a typed Malformed
error, so that if a future lopdf becomes tolerant here, the change is noticed rather than
absorbed silently.
Both WebP coverage gaps were closed on 2026-08-21, and both passed.
- The format-conversion path — metadata surviving a change of container — is now exercised
by
generated/webp/from-jpeg-exif.webp, produced bycwebp -metadata allfrom the realCanon_40D.jpgalready in the corpus. It carries 2468 bytes of genuine Canon Exif and a 3144-byte ICC profile across into WebP, including the IFD1 thumbnail — a small picture of the original scene, which is the most under-appreciated leak in this class. strypt removes all of it: 92 ExifTool tags before, none after, and the output holds onlyVP8Xwith an empty flags byte andVP8. Every other WebP in the corpus was born a WebP, so nothing else tested this. - A genuinely browser-encoded WebP is now
webp/browser/chrome-canvas.webp, produced by Chrome 151's own encoder viacanvas.toDataURL('image/webp'). Chrome writes an extended file with anICCPchunk; the profile is a generic sRGB one stamped1998:02:09and naming no device, which is Chrome declining to fingerprint the display rather than an oversight. strypt removes it and clears the flag. This file is built only underbuild_real_corpus.py --with-browser, because browsers auto-update and its bytes would otherwise churn the committed manifest on every contributor's machine.
What this sweep still does not establish. Every category directory beneath
real-producer-corpus/real-corpus/ is a coverage bucket, not a verified producer claim — the
chrome/, firefox/ and android/ WebP directories hold reference-encoder conformance files,
and the manifest says so per row. 29 of the files are marked LOW provenance, and the file
above is one of them: its "scanner" bucket is unverified. chrome-canvas.webp is the corpus's
only WebP with a HIGH-confidence producer claim, and even it exercises a canvas export rather
than a browser re-encoding a photograph that arrived with Exif.
The corpus is not committed (.gitignore), because its files carry real names, a device
serial number, and live GPS coordinates, which §3 of docs/TESTING_STRATEGY.md keeps out of
this repository. build_real_corpus.py and the manifests are committed, and a rebuild
reproduces all 102 fixtures byte-identically, so this sweep is repeatable by anyone.
Written 2026-08-23, from what the handler, the fuzz targets, and the mat2/ExifTool differential actually showed — not from the specification.
The structural difference from every Phase 1 format. A JPEG is one file with metadata segments in it. An OOXML document is a ZIP archive of XML parts, and one class of part is whole files with their own metadata — the photographs the author pasted in, arriving with whatever their cameras wrote. Phase 1's mental model, "walk the container and drop the metadata regions", covers about half of what this format needs.
The leak that matters most here is the one that is not in the document's own metadata. A
user strips a report, publishes it, and has published every geotag in every picture inside it,
while holding a success message saying the document was cleaned. That is the failure in §5.4
arriving by a new route, and it is why ADR-0029 descends one level into embedded images rather
than stopping at docProps/.
Where the metadata was, in the order a user would be surprised by it:
| Location | What it carries |
|---|---|
word/media/*, xl/media/*, ppt/media/* |
Whole JPEG/PNG/WebP files with GPS, camera serial numbers, and Exif thumbnails of the uncropped original |
docProps/core.xml |
dc:creator, cp:lastModifiedBy, dcterms:created, dcterms:modified, cp:revision |
docProps/app.xml |
Application, AppVersion, Company, Manager, and TotalTime — cumulative editing minutes |
docProps/custom.xml |
Arbitrary named properties; document management systems write internal matter numbers and usernames here |
docProps/thumbnail.* |
A rendered preview of the first page, which survives every redaction applied to the text |
w:rsid* attributes, w:rsids in settings.xml |
Revision-save identifiers. Two documents sharing one were edited in the same session on the same machine |
w14:paraId, w14:textId |
Per-paragraph identifiers, stable across saves and across copies |
w:ins, w:del, w:comment, p:cmAuthor, xl <author> |
Author names, initials, and timestamps sitting inline in the body |
| ZIP entry headers | A modification time per part — a record of the author's working hours that no application displays |
| ZIP extra fields | Unix UID/GID (0x7875), NTFS times (0x000A), extended timestamps (0x5455) |
| External relationships | file: and UNC targets. An attached template under someone's home directory names that person |
What was learned that the specification does not say.
- Removing a part is not enough, and the failure is loud.
[Content_Types].xmland_rels/.relsstill refer to what went, and Word offers to repair the result. For a user trying not to draw attention to a document, a repair prompt is a worse outcome than a slightly larger file. Both index parts are rewritten (ADR-0030). - Writing an index part twice is silently tolerated. An early version of the handler emitted
[Content_Types].xmlin the entry loop and again in a post-pass. Every reader tried — Python'szipfileincluded — accepted the duplicate without complaint, preferring one copy arbitrarily. Nothing caught it except the byte-identical idempotence check. This is recorded because it generalises: a ZIP reader's tolerance hides writer bugs, so a container handler needs an invariant that does not depend on a reader noticing. - The package's own declaration is the only reliable way to tell these formats apart.
.docx,.xlsx,.pptxand every OpenDocument file share one magic number. Searching the raw bytes forword/document.xmlwould work until it did not: the string appears verbatim in any archive that merely contains a Word document, and an attacker can put it in a comment. Detection opens the container and reads the declared main-part content type (ADR-0027). - The
create_systembyte is a producer fingerprint that no one thinks about. strypt writes a constant 0 (MS-DOS/FAT), which is what Word writes. mat2 normalises the same field to 3, which says "made on Linux". Neither leaks the real host; the difference is which constant blends in.
What is deliberately kept, and why.
- The words of comments and tracked changes. Removing a tracked insertion means deciding
whether the document accepts or rejects it, and that changes what the document says.
docs/PRD.md§8.1 gives the payload priority, and a tool that silently accepted every pending revision would hand a journalist a document different from the one they reviewed. Their author names, initials, and dates are removed — those are metadata sitting on content, and removing them changes no words. ANotereports that the revision content remains. - Any part strypt does not recognise, copied through with a
Note::UnparsedRegionnaming it. Unlike WebP's unknown chunks (ADR-0023), an unrecognised OOXML part may be load-bearing — dropping a theme or a font table breaks the document — so the honest move is to copy it and say plainly that nobody looked inside.
What is refused rather than half-processed. Each of these produces no output file at all:
- A macro-enabled document (
.docm,.xlsm,.pptm). ItsvbaProject.binis an OLE compound file with its own directory and its own metadata streams that strypt cannot read. - A document containing a nested archive, an embedded PDF, or an OLE object. ADR-0029 fixes
the descent at one level; a
.docxinside a.docxis not something to partially clean. This refuses real documents — a chart's cached workbook atword/embeddings/*.xlsxis common — and that cost is accepted, because such a workbook carries its own author names. - An encrypted entry, any compression method other than stored or deflate, a
multi-disk archive, and an entry name that is absolute or contains
...
Known limitations, stated plainly.
- Comments and tracked changes remain in the document. Their attribution is removed; their text is not. For a document whose comments must not be published, mat2 is the better recommendation — it removes the parts outright. ADR-0012 requires saying so where it is true, and it is true here.
- Output is not byte-identical to input even for a clean document. A rewritten part is re-emitted stored where it arrived deflated (ADR-0028), and entry timestamps are normalised. Idempotence is byte-identical, and is tested. The Phase 1 image handlers can promise the stronger property for a clean file and this one cannot.
- A damaged OOXML package is reported as an unsupported ZIP container, not as a damaged
document. Detection has to read
[Content_Types].xmlto know what the file is; if that part cannot be read, there is nothing to distinguish the file from any other archive. The refusal is correct and fail-closed, but its wording is less useful than it could be. vbaProject.bin, OLE objects, fonts, and audio are not inspected. They are refused (containers) or copied with a note (fonts, media strypt has no handler for).- XML is scanned, not parsed. Entities are not resolved and nesting is not validated. An attribute is removed on the strength of its name, and a name cannot be spelled with an entity reference — but a producer doing something genuinely unusual with XML could in principle defeat the scanner, in which case the part is copied through unchanged rather than edited on a guess.
Differential result (2026-08-23). scripts/ooxml-differential.sh over all 13 fixtures,
against mat2 0.15.0 and ExifTool 13.55: no gaps — nothing survives strypt that does not also
survive mat2, and ExifTool finds no GPS, serial, artist, or owner tag in any output. One finding
in the other direction, recorded because it is interesting rather than because it flatters:
mat2 refuses presentation.pptx outright, because ppt/commentAuthors.xml is not on its
content-type whitelist, and strypt processes it. Two comparison exclusions (date_time,
create_system) are justified in the script's own comments; both are values both tools
normalise to a constant.
Fuzzing. ooxml and zip, clean; re-run when OpenDocument moved the container and scanner
layers under them.
Written 2026-08-24, from what the handler, the fuzz target, and the mat2/ExifTool differential actually showed — not from the specification.
The structural work is shared with §7.6 and the contents are not. OpenDocument is a ZIP package, so the container layer, the archive-wide decompression budget, the nested-container refusal, and the one-level descent into embedded pictures are the same code the Office handler uses. Everything above that layer is different, and assuming otherwise would have produced a handler that quietly missed most of this format's metadata. ADR-0031 records the four differences that changed the design; three of them are findings rather than design taste.
Finding 1 — an ODF-encrypted package does not look encrypted to ZIP, and that is a
silent-success hazard. ODF does not set ZIP's general-purpose encryption bit. It deflates an
entry, encrypts the result, and records the fact in META-INF/manifest.xml (Part 2 §3.4). The
refusal in the ZIP layer that correctly catches an encrypted .docx therefore passes an
encrypted .odt straight through — and then content.xml is ciphertext, no rule matches it,
nothing is found, and the package is reported clean having been examined by nobody. That is
§5.4 exactly, reached by a route Group 1 did not have. The handler refuses on the manifest
instead, and corpus/odf/malformed/encrypted.odt pins it.
Finding 2 — ODF puts authorship in element text, so a rule keyed on a name alone is wrong.
<w:ins w:author="A Name"> has no ODF equivalent; the same information is
<office:change-info><dc:creator>A Name</dc:creator></office:change-info>. And dc:creator is
also the document's own author in meta.xml, and also a comment's author inside
<office:annotation>. An implementation that removed dc:creator wherever it appeared would
edit markup it has no business touching; one that removed it nowhere would leave every
comment's author in the file. The scanner tracks which elements it is inside, which the Office
rules never needed to.
Two consequences of scanning rather than parsing are worth stating. A dc:creator containing
child elements — which the schema forbids and a hostile file may write anyway — has its whole
element removed rather than being left alone, because leaving it would mean a name surviving in
a document reported as cleaned. And a part whose markup the scanner cannot follow — mismatched
tags, or nesting past 256 levels — is copied through untouched with a Note saying so, rather
than edited on a guess about where the scan is.
Finding 3 — an embedded object is reachable without recursing, and this is the opposite
outcome from Office. A .docx containing a chart holds a whole .xlsx inside itself, which
ADR-0029 refuses (§7.6). ODF stores the same chart as ordinary entries in the same archive —
Object 1/content.xml, Object 1/meta.xml, Object 1/settings.xml — so the chart's own author
and printer metadata is removed in the same pass, with no descent at all, and the document is
cleaned rather than refused. Same feature in the two formats; opposite outcomes, entirely
because of how each stores it.
Where the metadata was, in the order a user would be surprised by it:
| Location | What it carries |
|---|---|
Pictures/* |
Whole JPEG/PNG/WebP files with GPS, camera serial numbers, and Exif thumbnails of the uncropped original |
meta.xml |
meta:initial-creator, dc:creator (in ODF the last person to save it), meta:creation-date, dc:date, meta:printed-by, meta:print-date |
meta:editing-cycles, meta:editing-duration |
The save count, and the total editing time as an ISO 8601 duration to the second — PT4H32M17S |
meta:generator |
The application, its version, and its operating system: LibreOffice/7.4.2$Linux_X86_64 |
meta:document-statistic |
Page, word, paragraph, and character counts — in attributes, not element text |
meta:user-defined |
Arbitrary named properties; the ODF counterpart of docProps/custom.xml, and the same place a matter number or a username lands |
meta:template |
An xlink:href frequently pointing at a file under the author's home directory |
settings.xml |
The printer's name and its base64 setup blob (driver, port, often a network path), the last cursor position, and a per-release set of configuration keys that fingerprints the producing build |
Thumbnails/thumbnail.png |
A rendered preview of the first page (Part 2 §3.8), which survives every redaction applied to the text |
Configurations2/, layout-cache |
The producer's saved user-interface configuration, and a binary cache of the text layout |
office:annotation, office:change-info |
Comment and revision authorship, as element text |
text:creator and its siblings |
Fields holding a cached copy of the author's name, printed in the document |
| ZIP entry headers and extra fields | A modification time per part, Unix UID/GID, NTFS times — as for every package format |
What is deliberately kept, and why.
- The words of comments and tracked changes, with their authors, initials and dates removed — the same call as §7.6, for the same reason. mat2 is the better recommendation for a document whose comments must not be published, and the difference is sharper here than for Office: mat2 removes ODF annotations and tracked changes outright, so a user who needs the comments gone and does not need the document to say what they reviewed is better served by it.
- A date or time field the document displays.
text:creation-datein a letter's header is a date the author chose to print. It stays, and aNotesays it is there. - Any part strypt does not recognise, copied through with a
Note::UnparsedRegionnaming it — includingObjectReplacements/, which holds a rendered preview of an embedded object in a metafile format strypt cannot read. mat2 drops those; strypt copies them, on the §7.6 principle that an unrecognised part may be load-bearing. This is a recorded difference in thoroughness, not an oversight: a replacement image is a rendering of document content, and it could in principle carry metadata of its own that strypt has not examined.
One place strypt edits what a reader sees, stated plainly because it is an exception. The
cached values of text:creator, text:initial-creator, text:author-name,
text:author-initials, text:printed-by, text:editing-cycles, and text:editing-duration
are emptied. These are fields the application filled in from meta.xml, so their content is a
second copy of what is being removed; leaving them would print the author's name in a document
strypt reported as cleaned. The elements remain, so an application refills them.
What is refused rather than half-processed. Each of these produces no output file at all: a
package with no META-INF/manifest.xml; a package whose manifest declares encryption; a package
whose mimetype entry and manifest root disagree about what the document is; a nested archive,
an embedded PDF, or an OLE object; and — refused at detection and named — an OpenDocument type
outside this group, which is a drawing, a formula, a chart, a database, or any -template
variant. A flat ODF file (.fodt, .fods, .fodp) is a single XML document rather than a
package and is refused as XML, which is correct but less informative than it could be.
Known limitations, stated plainly.
-
Comments and tracked changes remain in the document. Their attribution is removed; their text is not. For a document whose comments must not be published, mat2 is the better recommendation.
-
Output is not byte-identical to input even for a clean document. Rewritten parts are re-emitted stored (ADR-0028), entry timestamps are normalised, and a
mimetypeentry that arrived compressed or out of position is moved and re-stored. Idempotence is byte-identical and is tested over every fixture. -
Every stripped package imports into LibreOffice, and none prompts for repair. Run 2026-08-24 against LibreOffice 26.2.5.2 on macOS/arm64 by
scripts/odf-libreoffice-validation.sh: all 14 committed fixtures and 2 real LibreOffice-authored documents from the real-producer corpus were stripped, loaded, and re-exported to flat XML — which forces a full import of every part rather than a header sniff. No failures. The structural checks that previously stood alone still run beside it: an independent ZIP reader parses every output and verifies every CRC, the manifest is checked against the entries actually present, and themimetypeentry is checked against Part 2 §3.3.The repair-prompt check was done separately and by hand, because LibreOffice's recovery dialog is a GUI path that headless conversion cannot raise. Seven stripped files —
everything.odt,embedded-image.odt,comments.odt,tracked-changes.odt,spreadsheet.ods,presentation.odp, and the real-producerform.odt— were opened in the LibreOffice interface on 2026-08-24. None prompted for repair. The two claims are kept distinct on purpose: the automated one covers all 16 documents, the manual one covers these seven, and neither stands in for the other. A change to the handler re-runs the script automatically and re-owes the manual pass.Two things the run taught us, both about method rather than about the handler:
sofficeexits 0 even when the import fails outright. Verified againstcorpus/odf/malformed/truncated.odt, which prints "source file could not be loaded" and still returns 0. The existence of the output file is the only trustworthy signal; a check gated on the exit status would have reported every broken package as a success.- A body comparison can only see what LibreOffice round-trips.
embedded-image.odtcarries a picture thatcontent.xmlnever references, so the export drops it and the bodies match despite strypt having stripped the picture's GPS, body serial andArtistname. Picture stripping is covered by the differential and the integration tests, not by this script.
-
XML is scanned, not parsed. Entities are not resolved and nesting is not validated. A producer doing something genuinely unusual could defeat the scanner, in which case the part is copied through unchanged with a note rather than edited on a guess.
-
ObjectReplacements/, fonts, and binary parts are not inspected, only copied with a note. -
manifest.rdfis scanned as ordinary XML. ODF 1.2 RDF metadata is not interpreted as RDF, so a statement about the document expressed only in a way the element-name rules do not recognise would be copied through.
Differential result (2026-08-24). scripts/odf-differential.sh over all 14 fixtures,
against mat2 0.15.0 and ExifTool 13.55: no gaps — nothing survives strypt that does not also
survive mat2, and ExifTool finds no GPS, serial, artist, or owner tag in any output, including
inside Pictures/. The same two comparison exclusions as §7.6 apply (date_time,
create_system), and both are values both tools normalise to a constant.
One result in the other direction, recorded because it is interesting rather than because it
flatters: mat2 refuses embedded-object.ods — ERROR: element Object 1/settings.xml's format (application/xml) isn't supported. Its part patterns are anchored at the package root,
so an embedded chart's own settings.xml, one directory down, matches neither its keep list nor
its omit list. strypt processes that document and removes the object's metadata. A document with
an embedded chart is an ordinary thing to have, so this is a real difference — and it is one
data point about one release, not a general claim about either tool.
Fuzzing. odf, clean.
The structural difference from every other image format. A JPEG, PNG, or WebP keeps its
metadata in a delimited region — an APP1 segment, an eXIf chunk — that strypt drops whole.
A TIFF has no such region. Its metadata sits in the same directory as the tags needed to decode
the picture, its values live at absolute file offsets, and the image data itself is addressed by
StripOffsets or TileOffsets. There is nothing to excise.
So strypt does not edit a TIFF; it writes a new one (ADR-0033). Read that ADR before relying on anything below, because three properties of this handler follow from it and from nothing else.
| What | Where it lives | What strypt does |
|---|---|---|
| Camera and scanner identity | Make, Model, Software, HostComputer |
Never written to the output |
| Authorship and description | Artist, Copyright, ImageDescription, DocumentName, PageName |
Never written |
| Timestamps | DateTime, and the Exif date tags |
Never written |
| Location | The GPS IFD, reached through tag 0x8825 |
Never written; reported by tag |
| Device serial numbers | BodySerialNumber, CameraOwnerName in the Exif IFD |
Never written; reported by tag |
| Metadata packets | XMP (0x02BC), IPTC (0x83BB), ICC profile (0x8773) |
Never written |
| Vendor and private tags | Anything not on the structural allow-list | Never written — including tags strypt has never seen |
| Embedded thumbnails | A directory flagged reduced-resolution by NewSubfileType, and old-style JPEGInterchangeFormat |
The whole directory is dropped; reported as a thumbnail |
| The picture | Strips or tiles | Copied byte for byte |
| Decode tags | Dimensions, bit depth, compression, photometric interpretation, palette, geometry, YCbCr parameters | Copied across; geometry regenerated for the new layout |
The allow-list is the safety property, and its direction is the point. A tag reaches the
output only by being on a list of what the image cannot be decoded without. A deny-list would
carry an unknown tag through, and in this format the tags that leak hardest are exactly the ones
no table has heard of — a vendor maker note, a scanner's private field holding a serial number.
The corpus fixture unknown-vendor-tag.tiff exists to hold that property in place.
Three limitations, all real.
- The output is never byte-identical to the input, even for a TIFF carrying no metadata at all, because a rebuild reorders the file by construction. This is a stronger statement than the OOXML and OpenDocument caveat, which concerns only rewritten parts. Idempotence is byte-identical and is tested over every fixture.
- Metadata hidden inside the compressed image data is out of reach. strypt moves strips
without decoding them, which is what keeps the pixels bit-identical. mat2's default TIFF
path re-renders the image through GdkPixbuf, which does reach that, at the cost of
rewriting the image data — which is why mat2 itself offers
-Lfor users who need the pixels untouched. Where a user's threat model includes data concealed in the pixel stream, mat2's default is the better recommendation (ADR-0012). - A TIFF using a feature the writer cannot reproduce faithfully is refused, not
approximated. BigTIFF (magic 43, eight-byte offsets) is refused by name. So is a file whose
strip geometry is inconsistent, whose geometry is written in a field type TIFF does not permit
there, or whose directory carries no dimensions. Some of these are real files. Refusing is
CLAUDE.md§3 constraint 6, and the cost is accepted deliberately.
ICC colour profiles are removed. They routinely carry a device or vendor name in their
desc and cprt records, which is why MetadataKind::ColourProfile exists at all. The cost is
that a stripped image may render with slightly different colour on a colour-managed display.
That trade is the same one made for every other format in this tree.
Testing. 10 well-formed fixtures and 6 malformed ones in corpus/tiff, generated by
corpus/tools/make_tiff_fixtures.py; 17 integration tests in crates/strypt-core/tests/tiff.rs,
including a sweep asserting that no SYNTHETIC marker survives any fixture, a byte-for-byte
check that the picture crossed the rebuild, an every-prefix truncation sweep, and a
single-byte-flip sweep over every fixture; 14 unit tests in the handler and its tag table.
Fuzzing. tiff, clean; detect re-ran because TIFF changed its routing.
Measured against other tools on 2026-08-25. scripts/tiff-differential.sh compares strypt
against mat2 0.15.0 and ExifTool 13.55 over all 10 well-formed fixtures: zero tags
survive strypt and zero survive mat2, no synthetic marker reaches any output, and the
PRESERVED- payload crosses every rebuild.
Three things about that result are worth stating rather than leaving implied.
- The comparison is not a byte comparison, and for this format it especially cannot be. mat2's default TIFF path re-renders the pixels through GdkPixbuf; strypt copies the compressed data across. The two outputs cannot resemble each other. What is compared is what metadata survives in each.
-uis load-bearing in that script. ExifTool omits tags it does not recognise unless asked for them, which is exactly the class the allow-list exists to catch. Onunknown-vendor-tag.tiffthe default output names one of the two private tags; with-uit names both, reportingExif_0xc5d9explicitly. The script was verified able to fail: run against the unstripped fixtures the same filter reports 9, 6, 2, and 1 surviving tags respectively, naming the leaked values. An untested gate provides confidence without protection (CLAUDE.md§6).- ExifTool does not parse the synthetic IPTC and ICC blobs in
packets.tiff, so it contributes nothing on those two of the three packets there; it reads the XMP creator only. Those two are covered instead by the byte-levelSYNTHETICsweep in the same script and by the integration tests. This is a limit of the fixture's realism, not of the handler, and it is recorded so the differential's clean result is not read as broader than it is.
Structurally the easiest format in the project, and the reason is worth naming. A GIF is a
fixed header, a logical screen descriptor, an optional colour table, and then a flat sequence of
blocks ending in a single 0x3B byte (GIF89a §17–§27). The picture is in image blocks and
everything identifying is in extension blocks beside them, so removal is deletion from a list —
the PNG shape, not the TIFF one. Nothing is rebuilt and nothing is decoded.
| What | Where it lives | What strypt does |
|---|---|---|
| Comments | Comment extension, label 0xFE |
Removed |
| XMP | Application extension XMP DataXMP |
Removed; itemised by property |
| Photoshop and IPTC blocks | Application extensions MGK8BIM0000, MGKIPTC0000 |
Removed; the IPTC one is reported as naming a person |
| ICC profile | Application extension ICCRGBG1012 |
Removed |
| Vendor application blocks | Any other eleven-byte identifier | Removed — including identifiers strypt has never seen |
| Rendered text | Plain-text extension, label 0x01 |
Removed, together with the graphic control block in front of it |
| Blocks under undefined labels | Any other extension label | Removed |
| Data after the trailer | Past 0x3B |
Removed; reported as a thumbnail when it is itself a GIF |
| The animation's loop count | Application extensions NETSCAPE2.0, ANIMEXTS1.0 |
Kept, and declared as retained |
| Frame delay, disposal, transparency | Graphic control extensions | Kept |
| The picture | Image blocks, LZW data, colour tables, interlace flag | Copied byte for byte |
Two blocks are kept on purpose, and only one of them is a judgement call. Graphic control
extensions are plainly rendering: delay, disposal method, transparent colour index. The loop
count is the decision. It sits in an application extension, which is where the identifying blocks
also sit, so keeping it means keeping something from the category everything else in is removed
from. It is kept because it carries a loop count and nothing else: it names no person, device,
place, or time, and it is byte-identical between any two files that loop, so there is nothing in
it to distinguish anyone with. Removing it would turn a user's looping animation into a one-shot
— a change to what the file does, which docs/PRD.md §8.1 forbids. strypt declares it in the
report's retained list rather than staying silent about it.
Everything else in that category goes, and the rule runs as an allow-list. A deny-list of
known-bad identifiers would carry an unknown vendor block through precisely because nothing
recognised it, which is the failure ADR-0033's TIFF allow-list exists to prevent and is no less a
failure here. The fixture unknown-application.gif holds that property in place.
A plain-text extension takes its graphic control block with it. §23 makes a control block apply to the next graphic-rendering block; leaving one in front of an image it was never meant for would hand that image somebody else's delay and transparency. The pair is removed together, and the report names the plain-text block as what was found.
What is not reached. Metadata concealed inside the LZW-compressed image data is out of reach, for the same reason as TIFF: strypt copies the compressed bytes without decoding them, which is what keeps the pixels bit-identical. mat2's GIF path re-renders the image through GdkPixbuf and does reach that, at the cost of rewriting the picture. Where a user's threat model includes data hidden in the pixel stream, mat2 is the better recommendation (ADR-0012).
A clean GIF is returned byte-identical, which no other format in this tree can promise of a whole file — PNG promises it for its kept chunks, TIFF cannot promise it at all. Kept blocks are copied raw, so nothing is re-serialised.
Testing. 14 well-formed fixtures and 6 malformed ones in corpus/gif, generated by
corpus/tools/make_gif_fixtures.py — which carries its own LZW encoder so that every fixture is a
real, decodable GIF, because mat2's re-rendering path cannot open one that is not, and a
comparison it cannot run says nothing (the WebP mistake in §7.4). 22 integration tests in
crates/strypt-core/tests/gif.rs, including a sweep asserting that no SYNTHETIC marker survives
any fixture, an every-prefix truncation sweep, a single-byte-flip sweep, and a byte-for-byte
comparison of every image block before and after — made by a GIF walker written in the test
file rather than borrowed from the crate, so the check cannot pass by the parser agreeing with
itself. 20 unit tests in the handler.
Fuzzing. gif, clean. Its first run aborted on a harness fault, not a handler defect — the
origin of the per-format detect() guard (ROADMAP, Phase 2).
Measured against other tools on 2026-08-26. scripts/gif-differential.sh compares strypt
against mat2 0.15.0 and ExifTool 13.55 over all 14 well-formed fixtures: zero tags
survive strypt, no synthetic marker reaches any output, the frame count and image size are
unchanged, clean.gif comes back byte-identical, and the loop count survives where it was
present.
Four things about that result are worth stating rather than leaving implied.
- On
plain-text.gif, strypt removes more than mat2 does. ExifTool still reports[GIF] Text: SYNTHETIC-PLAINTEXT-0013in mat2's output and reports nothing in strypt's. This is recorded as a measurement, not as a claim about the two tools generally — one fixture, one block type, and mat2 remains the better recommendation for the pixel-stream case above. - The
[File]group is filtered tag by tag in that script, and that is load-bearing. ExifTool files a GIF's comment under[File] Comment, not under[GIF]— so the blanket^\[File\]exclusion the TIFF script uses would have hidden the single most common leak this format has. AnimationIterationsis excluded from the comparison deliberately, since strypt keeps the loop count on purpose. That exclusion is paired with a positive check in the same script asserting the loop count really does survive, so it is a declared decision rather than a quiet softening of the sweep.- ExifTool reports nothing at all for the unknown application block, the vendor block, and the
undefined-label extension, so it contributes nothing on three of the fourteen fixtures. Those
are covered instead by the byte-level
SYNTHETICsweep in the same script and by the integration tests. The filter was verified able to fail: run against the unstripped fixtures it reports surviving tags on 8 of the 14, including 3 onxmp.gif.
The format where the metadata is not in the box tree. A HEIF looks like a tree of nested boxes,
which invites the PNG treatment — walk the list, drop the ones you do not want. That is wrong here.
Exif and XMP are items: declared in iinf, bound to the picture by iref, and located by iloc
as absolute file offsets into mdat, where their bytes sit beside the coded image with no
delimiter between them. Removing one shifts every surviving item. strypt therefore rebuilds the
container and recomputes every offset against the buffer it is writing, so no offset from the
input reaches the output (ADR-0034 — required reading before touching this handler).
| What | Where it lives | What strypt does |
|---|---|---|
| Exif, with GPS, serial numbers and timestamps | An Exif item in mdat, bound by cdsc |
Removed; itemised by tag through the shared Exif reader |
| XMP | A mime item of type application/rdf+xml |
Removed; itemised by property |
| XMP written the Adobe way | A top-level uuid box |
Removed |
| ICC profile | A colr property of type prof or rICC |
Removed |
| Thumbnails | An item reached by a thmb reference |
Removed |
| Item names | infe item_name, hdlr name |
Written empty — structural fields, free-text values |
| Vendor items and properties | Any type outside the allow-lists | Removed — including types strypt has never seen |
xml , bxml, free, skip, data past the last box |
Boxes outside the allow-lists | Removed; reported |
| Numeric colour signalling | A colr property of type nclx |
Kept, and declared as retained |
| Rotation, mirroring, cropping, dimensions, depth, codec config | irot, imir, clap, ispe, pixi, av1C, hvcC … |
Kept |
| The picture | Item data in mdat |
Copied byte for byte |
What is refused rather than partly cleaned, each by name: a motion HEIF — which is what an
Apple Live Photo is — and an image-sequence brand, both because video is Group 4; iloc
construction method 2; an item whose data lives in another file; an ipro protection box; an
infe below version 2; and a file with no meta, no pitm, or a primary item pointing outside
itself. Refusing Live Photos refuses a common real iPhone file, and that cost is accepted
deliberately — the refusal names Live Photos so the user knows what happened.
What is not reached. Metadata concealed inside the compressed image data, for the same reason as TIFF §7.8 and GIF §7.9: strypt copies the coded bytes without decoding them, which is what keeps the pixels bit-identical. mat2 does not reach it either: in 0.15.0 (current, checked 2026-09-12) HEIC and AVIF go only through ExifTool, and its default mode refuses HEIC. Removing the ICC profile also trades colour fidelity, as it does for TIFF and JPEG.
Output is never byte-identical to input, even for a clean file — a rebuild reorders the file by construction. Idempotence is byte-identical and is tested.
Testing. 17 well-formed fixtures and 8 malformed ones in corpus/heif, generated by
corpus/tools/make_heif_fixtures.py, which embeds one recorded AV1 and one recorded HEVC
codestream and builds every container by hand — so each fixture is a real, decodable image that
libheif and ImageMagick both open, because a comparison a tool cannot run says nothing (the WebP
mistake in §7.4). 24 integration tests in crates/strypt-core/tests/heif.rs, including a
SYNTHETIC marker sweep, an every-prefix truncation sweep, a single-byte-flip sweep, and a
byte-for-byte check that the coded picture crossed the rebuild — made by a box walker written in
the test file, so the check cannot pass by the parser agreeing with itself. 13 unit tests in the
handler and 14 in container/bmff.rs.
Measured against other tools on 2026-08-27. scripts/heif-differential.sh compares strypt
against mat2 0.15.0 and ExifTool 13.55 over all 17 well-formed fixtures: zero tags
survive strypt on every one, no synthetic marker reaches any output, every stripped file still
decodes through libheif, and the decoded pixels are identical to the input's — 0 differing pixels
on all 17. The filter was verified able to fail, by the script itself: it runs against the
unstripped fixtures on every invocation and refuses to report a sweep if they do not light it up.
Five things about that result need stating rather than leaving implied.
- mat2's default mode declines HEIC, with "HEIC files can't be thoroughly cleaned. Use
lightweight mode instead." That is mat2 failing closed on its own terms, and it is not a defect.
The script therefore falls back to
mat2 -Lfor those files and says which mode ran, because lightweight mode is the weaker operation — it rewrites the container without re-rendering, which is what strypt does too, so for HEIC the two tools are being asked the same question. Comparing against a refusal would be comparing against nothing. - mat2 declines
trailing-data.heicin both modes, because ExifTool refuses to write a file with bytes past the last box. strypt removes those bytes and reports them. Recorded as a measurement on one fixture, not as a claim about the two tools generally. - ExifTool cannot see an ICC profile in either format — it raises "Bad length ICC_Profile" and names nothing — so the tag comparison is blind to it and ImageMagick is asked instead, in the same script, with the check written to fail if ImageMagick cannot see it in the input either.
- No third-party tool on this machine enumerates a HEIF thumbnail item. ExifTool does not
report it,
magick identifyshows one frame, and libheif'sheif-infoprintsthumbnail: 0x0. This is worth recording beyond strypt's own testing: a user cannot check for this leak with the usual tools, which makes a thumbnail-of-the-uncropped-original harder to notice in HEIF than in the formats of §7.2. The fixture therefore carries a marker in the thumbnail's own bytes so the byte-level sweep covers it, and the independent walker intests/heif.rschecks the item count. - ExifTool's results here are not reproducible unless its Perl hash seed is pinned. It walks a
file's atoms in hash order, which is randomised per process, and on some of these fixtures the
order decides whether it raises "Chunk offset in iloc atom is outside media data" and refuses to
write — the same bytes succeeding or failing run to run, measured 2026-08-27, and deterministic
again with
PERL_HASH_SEED=0. mat2 shells out to ExifTool and inherits it. The script pins the seed. This softens nothing: strypt's side of every check runs regardless, and a run where mat2 fails is scored as no comparison rather than as a pass. A real-encoder HEIC never triggered it in six of six runs, so it is a quirk of these minimal fixtures meeting that code path, not a property of the format.
Fuzzing. heif and bmff, clean. heif carries no "stripping never grows a file" assertion,
because this handler rebuilds.
The one format in this tree where strypt removes less than mat2, and says so. SVG is not a container of encoded pixels: the picture is text, and the metadata, the accessibility text and — if the author wanted — an executable program all sit in the same element tree. It is edited by deletion, so a clean drawing comes back byte-identical (ADR-0035 — required reading before touching this handler).
| What | Where it lives | What strypt does |
|---|---|---|
| RDF, Dublin Core, Creative Commons licensing, XMP | <metadata>, which SVG 1.1 §5.10 states is not rendered |
Removed whole; itemised by field, including fields written as attributes |
| The author's disk | sodipodi:docname, inkscape:export-filename, i:absref |
Removed |
| Window geometry, zoom, current layer | <sodipodi:namedview> |
Removed |
| A copy of the original Illustrator document | <i:pgf> |
Removed |
Any prefixed name outside xlink: and xml: |
Anywhere | Removed, including prefixes strypt has never seen, together with the xmlns: declaration that bound them |
| Generator strings and hand-written notes | XML comments, anywhere in the document | Removed |
An XMP packet's wrapper, <?xml-stylesheet?> |
Processing instructions | Removed. The XML declaration is kept — its encoding decides how the file is read |
| Producer fingerprints in CSS | /* … */ inside <style> |
Removed, quote-aware: /* inside a string is not a comment |
| A pasted photograph's Exif, GPS and thumbnail | A data: URI in href, xlink:href or src |
Descended into once, through the same handler the CLI uses on a loose file (ADR-0029), and re-encoded canonically |
<title> and <desc> |
The accessibility text | Kept, and declared as retained. mat2 removes them |
| References outside the document | A remote URL or a local path in href, or in url() |
Kept, and reported without their target ever being named |
| The drawing | Every element the picture is made of | Copied byte for byte, with its ids, ordering and attribute quoting |
What is refused rather than partly cleaned, each by name: a <script>, an on* event handler,
a <foreignObject>, or a javascript: reference — a document that can execute code is not one
strypt will half-clean; a doctype internal subset, which is an expansion vector and, when
external, a network reference; a data: URI decoding to a nested container, an embedded SVG
included; a non-UTF-8 document, because a scanner reading UTF-16 as UTF-8 would find no tags and
report a clean file; and a .svgz, which is a gzip stream — inflating it would put a
decompressor on the input path and write back bytes that are an artefact of strypt's compressor.
Two things strypt keeps could still identify their author. <title> and <desc> are free text
a person wrote, and an external reference's path can name a directory in someone's home folder.
Both are declared in the report rather than left silent, and neither is removed: removing the first
takes the drawing away from a screen-reader user, and removing the second turns a file that draws a
linked logo into one that draws nothing.
mat2 is the opposite tool here, and for a scripted or comment-bearing SVG it is the better recommendation (ADR-0012). It loads the document through Rsvg and re-renders it onto a blank Cairo surface, so it removes strictly more — the accessibility text and the script included — and accepts the four scripted fixtures strypt refuses. What it cannot do is give the file back: ids, grouping, animation, interactivity and the author's editable structure do not survive a re-render. Neither behaviour is a defect; they answer different questions.
Testing. 14 well-formed fixtures and 9 malformed ones in corpus/svg, generated by
corpus/tools/make_svg_fixtures.py. Every fixture is a real, renderable SVG, for §7.4's reason:
a document Rsvg cannot open makes the mat2 comparison say nothing. Every fixture also carries
id="PRESERVED-SHAPE", so "the payload crossed intact" is checked by looking for the author's own
bytes. 21 integration tests in crates/strypt-core/tests/svg.rs — a SYNTHETIC marker sweep, an
every-prefix truncation sweep, a single-byte-flip sweep, and an allow-list check made by an XML
walker written in the test file, so it cannot pass by the scanner agreeing with itself. 33 unit
tests across the handler, its rules, and the data: URI codec.
Measured against other tools on 2026-08-28. scripts/svg-differential.sh compares strypt
against mat2 0.15.0 and ExifTool 13.55 over all 14 well-formed fixtures: zero tags survive
strypt on every one, no synthetic marker reaches any output except the three spelled -KEPT-,
and the PRESERVED-SHAPE rectangle crosses byte for byte in every file — which mat2's output cannot
claim for any of them. The script was verified able to fail: run against a pass-through binary
it reported 19 gaps. Its Title/Desc exclusion is paired with a positive assertion that both
really do survive, so the one softening in the filter is a declared decision rather than a hole.
Fuzzing. svg, clean. It carries no "never grows" assertion — a data: URI is re-encoded
through another handler, and TIFF and HEIF may grow — but asserts ADR-0035 §1's stronger promise
instead: a document with nothing to remove comes back byte-identical, guarded by detect().
The same box grammar as HEIF, and the opposite conclusion. §7.10 rebuilds a HEIF because its
Exif and XMP are iloc-addressed items whose absolute file offsets all move when one is removed.
JPEG XL puts the same payloads in top-level boxes of their own and addresses nothing by file offset,
so it is edited by deletion and a clean file comes back byte-identical (ADR-0036 — required
reading before touching this handler). There are two spellings: an ISO-BMFF container, and a bare
FF 0A codestream with no box layer at all.
| What | Where it lives | What strypt does |
|---|---|---|
| Camera make and model, serial numbers, GPS, timestamps | Exif box, whose payload opens with a four-byte offset to the TIFF header (§5.3) |
Removed whole; itemised by tag through the same Exif scanner the other raster handlers use |
| XMP: author, editing application, document and instance ids | xml box |
Removed whole, itemised by field |
| C2PA provenance — capture device, signing identity, edit history | jumb box (JUMBF) |
Removed |
| Any of the above, Brotli-compressed | brob box, whose first four bytes name the type it wraps |
Removed without being decompressed; the wrapped type is reported. No Brotli decompressor is in this tree (ADR-0022's argument for PNG's zTXt) |
| The original JPEG's marker segments, verbatim | jbrd box |
Removed, and the cost is stated in the report — see below |
| Frame offsets into a file this handler edits | jxli box, an optional seek index |
Removed rather than trusted; libjxl does not need it to display an animation |
| Anything at all — padding is free to hold it | free, skip |
Removed |
| The picture, its level, its brands | JXL , ftyp, jxll, jxlc, jxlp |
Copied byte for byte from their original spans |
Removal reaches the output side, not the input side. The five boxes above are an allow-list: an unknown top-level box refuses the file, so a vendor box strypt has never seen cannot survive by going unrecognised. That is ADR-0033's direction, and the inverse of the deletion rule governing OOXML and ODF.
What jbrd costs, said plainly. It holds the source JPEG's headers so a JXL transcode can be
turned back into that exact file. It is therefore a copy of those headers — a producer fingerprint
strypt will not leave in place. Removing it leaves the picture decoding identically, but
bit-exact JPEG reconstruction stops working, and the report says so on every file that had one.
What is refused rather than partly cleaned: an unknown top-level box; any bytes after the last
box, which mean a truncated box or something appended and are not a place the format defines
(GIF's §7.9 trailing data is reported and dropped because §27 gives GIF an explicit end; this format
has none); a container with no jxlc or jxlp, because emitting one would be a success message
about a file that no longer holds a picture; a missing, short or corrupt signature box, tail
bytes included; and an ftyp not branded jxl .
The codestream is never entered, in either spelling. A bare codestream has no box layer, so
strypt reports it clean and returns it unchanged — that is a real answer about the box layer and a
declared silence about everything else. The codestream's ImageMetadata can carry an ICC
profile, whose description and manufacturer fields name a device or an application, and a
preview frame, which is a second picture. Both are entropy-coded inside the image data and out
of reach without a decoder — for ExifTool and mat2 as much as for strypt. Every JPEG XL report
carries this note, clean files included. mat2 refuses a bare codestream outright; strypt accepts
it as a no-op and says what it did not look at. Neither is wrong, and a user who wants that file
re-encoded rather than inspected wants a JPEG XL encoder, not either tool.
Testing. 13 well-formed fixtures and 9 malformed ones in corpus/jxl, generated by
corpus/tools/make_jxl_fixtures.py; no encoder is available in this environment, so the codestream
is a hand-written header stub that ExifTool reads as an 8×8 JXL. 17 integration tests in
crates/strypt-core/tests/jxl.rs, checking the allow-list with a box walker written in the test
file so it cannot pass by agreeing with the code under test, plus a SYNTHETIC marker sweep and
byte-identity on clean input. 15 unit tests in the handler. Every malformed fixture is refused
except exif-offset-past-end.jxl, which is accepted and removed whole: a block strypt cannot
read is still one it can delete, and deleting it is the safe direction.
Measured against other tools on 2026-08-29. scripts/jxl-differential.sh compares strypt with
mat2 0.15.0 and ExifTool 13.55 across all 13 well-formed fixtures: no gaps, no synthetic
marker in any output, and every file still identifying as JXL at its original dimensions. The
script was verified able to fail — against a pass-through binary it reported 28 gaps. mat2's
JXLParser is an ExiftoolParser running _lightweight_cleanup() (verified against
libmat2/images.py), so both tools edit the container rather than re-rendering and the comparison
is fair in both directions. The reverse direction is the interesting one: ExifTool removes
Exif, xml and brob, and leaves jumb, jbrd, jxli, free and skip. A C2PA manifest
naming the capture device and the signing identity survives mat2 and does not survive strypt.
Fuzzing. jxl, clean but punctuated under ADR-0044 (KNOWN_LIMITATIONS). It asserts
re-inspect-clean, idempotence, and — guarded by detect() — that stripping never grows a file.
The first audio format, and the one where the group's hazard is absent. ADR-0037 opened Group 4
on the finding that these containers carry indexes into a timed payload — MP4's stco addresses
mdat by absolute file offset. FLAC does not: a seek point is measured "from the first byte of the
first frame header" (RFC 9639 §8.5), so removing metadata moves nothing. It is therefore edited by
block surgery and a clean file comes back byte-identical (ADR-0038 — required reading before
touching this handler).
| What | Where it lives | What strypt does |
|---|---|---|
| Artist, album, date, comment, organisation, location | VORBIS_COMMENT, as NAME=value items |
Removed whole, itemised field by field |
| Ripping software, encoder settings, the vendor string | VORBIS_COMMENT vendor field and ENCODER* items |
Removed; ranked as a software fingerprint |
| Cover art, and whatever metadata is inside that image | PICTURE |
Removed whole. Nothing descends into it — a picture that is deleted need not be parsed (ADR-0029 does not extend here) |
| Disc catalogue number and per-track ISRC | CUESHEET |
Removed, and the cost is stated in the report — see below |
| Anything a vendor chose to put there | APPLICATION, reported by its four-byte registered id |
Removed |
| Anything at all — a reserved type is free to hold it | Types 7–126 | Removed unread |
| Anything left behind in the space a tagger reserved | PADDING |
Zeroed in place at its original length — see below |
| Stream parameters and seek points | STREAMINFO, SEEKTABLE |
Copied byte for byte |
| The audio | Frames | Copied byte for byte, never decoded |
Removal reaches the output side. The three kept types are an allow-list, so a block type strypt has never seen cannot survive by going unrecognised — ADR-0033's direction again.
Padding is kept and zeroed, not dropped. §8.2 defines padding as zero bits; real encoders leave
kilobytes of it so a later tagger can write in place, and real taggers leave whatever they were
holding in it. Zeroing at the original length scrubs the block without costing the user the room it
exists for. ExifTool still reports one Padding tag on stripped output for this reason, and so it
does for mat2's.
The audio MD5 is kept, and declared. STREAMINFO's last sixteen bytes are an MD5 of the
unencoded audio (§8.2). It is a fingerprint linking this file to any other copy of the same
recording, and it stays — because it is computed from the samples the file still carries, so anyone
holding the file can recompute it, while removing it breaks every verifier that checks it. It is
reported as retained rather than passed over in silence. An all-zero field means "unknown", and
there is then nothing to declare.
What CUESHEET costs, said plainly. It holds the media catalogue number of the disc and each
track's ISRC — a purchase and a pressing. Removing it means the file can no longer be split back
into tracks, and the report says so on every file that had one.
What is refused rather than partly cleaned: a bad stream marker; a first block that is not
STREAMINFO, a second one, or one of any length but 34; the forbidden block type 127; a block
length running past the end of the file; no frame sync where the block list ends, because
§9.1.1's 14-bit sync is the only confirmation the walk finished where the file claimed.
Tags glued to the ends are read and removed — an ID3v2 tag in front of the stream marker, an ID3v1, APE or Lyrics3 tag past the last frame. Neither is FLAC and a decoder skips both, so they survive every block this handler cleans. The prepended case was refused by name until the MP3 tranche put an ID3 reader in the tree, and the appended case used to survive silently under the note below (ADR-0040 lifts ADR-0038 decision 7). §7.15 covers what is in those tags.
The audio is never decoded. Anything in a frame's reserved bits is out of reach — for mat2 and ExifTool as much as for strypt. Every FLAC report carries this note, clean files included.
Testing. 12 well-formed fixtures and 7 malformed ones in corpus/flac, generated by
corpus/tools/make_flac_fixtures.py. Unlike the JPEG XL stubs, these are real decodable audio —
a 4096-sample constant subframe with correct CRC-8 and CRC-16 and a matching STREAMINFO MD5 — so
mat2 and ffmpeg can open them. 20 integration tests in crates/strypt-core/tests/flac.rs, checking
the allow-list with a block walker written in the test file so it cannot pass by agreeing with
the code under test, plus a SYNTHETIC marker sweep, byte-identity on clean input, and truncation
and byte-flip sweeps. 29 unit tests in the handler. Every malformed fixture is refused.
Measured against other tools on 2026-09-01. scripts/flac-differential.sh compares strypt with
mat2 0.15.0 and ExifTool 13.55 across all 10 well-formed fixtures: no gaps, no synthetic
marker in any output, and — decoded by ffmpeg 9.0.1 — every output the same audio, sample for
sample. The script was verified able to fail: against a pass-through binary it reported 18 gaps.
Both tools edit the block list rather than re-encoding, so the comparison is fair in both
directions, and the reverse direction is again the interesting one: mat2's FLACParser uses
mutagen, which knows VORBIS_COMMENT and PICTURE. An APPLICATION block, a CUESHEET carrying
a catalogue number and ISRCs, and a reserved block type all survive mat2 and do not survive
strypt.
Fuzzing. flac, clean, with the same assertions as jxl; re-run when formats/tags.rs and
formats/vorbis.rs became shared. Its peak RSS, 1,267 MB, is 62% of libFuzzer's 2 GB default, so
a target sharing this pipeline has less headroom than earlier numbers suggested.
A RIFF file of form type WAVE, so the walk is the one WebP has used since Phase 1 — moved into
container/riff.rs and now shared by both handlers (ADR-0039 — required reading before touching
either). Group 4's hazard is absent for a second tranche running: the cue chunk's dwChunkStart
and dwBlockStart are offsets into the data section of a wavl list, not into the file, so
removing chunks moves nothing. WAV is therefore edited by chunk surgery, the RIFF size is the
only recomputed field in the file, and a clean file comes back byte-identical.
| What | Where it lives | What strypt does |
|---|---|---|
| Artist, engineer, technician, commissioner, copyright holder, archival location, dates | LIST/INFO, eighteen tags |
Removed whole, itemised tag by tag |
| The desk, the operator, and every processing step applied | bext — Originator, OriginatorReference, UMID, CodingHistory (EBU Tech 3285) |
Removed; the UMID is a globally unique recording id |
| Field-recorder documents: scene, take, notes, device serials | iXML, aXML |
Removed |
| XMP, including anything an editor round-tripped into it | _PMX |
Removed, itemised by the shared XMP scanner |
| A whole ID3v2 tag | id3 , ID3 |
Dropped unread — deleting a chunk does not require parsing it, and no ID3 reader enters the tree until the MP3 tranche |
| Radio traffic metadata down to a URL | cart (AES46) |
Removed |
| Cue and region labels, notes, and text attached to them | LIST/adtl |
Removed |
| Sampler loop points and the instrument they were recorded for | smpl |
Removed, and the cost is stated in the report — the file can no longer be looped by a sampler at the points it recorded |
| Display text, character set, playlists, instrument settings | DISP, CSET, plst, inst |
Removed |
| Anything at all — a private chunk is free to hold it | Any code not named above, any unknown LIST form |
Removed unread |
| Anything left behind in the space a tagger reserved | JUNK, PAD , FLLR |
Zeroed in place at their original length — see below |
| Stream parameters, sample count, cue points | fmt , data, fact, cue |
Copied byte for byte |
| The audio | data |
Copied byte for byte, never decoded |
Removal reaches the output side. The four copied codes are an allow-list, so a chunk strypt has never seen cannot survive by going unrecognised — ADR-0033's direction, applied again.
Padding is kept and zeroed, not dropped, as FLAC's is (ADR-0038 decision 3): a compliant file is unchanged, a file hiding data in its padding is scrubbed and told about, and the space a later tagger writes in place is still there.
cue is a deliberate keep, and ExifTool calls it metadata. It names nobody and is playback
structure, and per the paragraph above nothing this handler removes can move its offsets. It
survives strypt and not mat2, which loses it as a side effect of rebuilding the file. That
difference is reported by name in the differential rather than filtered out of sight.
What is refused rather than partly cleaned: a file with no fmt or no data; a fmt under
sixteen bytes; a chunk running past the declared RIFF extent, or chunks that do not tile it exactly;
a four-character code that is not printable ASCII; RF64 and BW64 (EBU Tech 3306, ITU-R
BS.2088), which are a different container rather than a large WAV — their real sizes live in a
ds64 chunk and the RIFF fields hold a -1 placeholder, so walking them as RIFF would read the
wrong lengths; a WAV whose audio is a wavl wave list, because there the cue offsets index a
structure the handler would be editing; and any other RIFF form, AVI included, named as such.
The audio is never decoded. Anything hidden in the sample values, or appended past the declared RIFF extent of a file strypt accepts, is out of reach. Every WAV report carries this note, clean files included. This limit is shared with mat2 — see the measurement below.
Testing. 14 well-formed fixtures and 10 malformed ones in corpus/wav, generated by
corpus/tools/make_wav_fixtures.py. They are real playable audio — 16-bit mono PCM at 8 kHz —
so mat2, ExifTool and ffmpeg can all open them. 16 integration tests in
crates/strypt-core/tests/wav.rs, checking the allow-list with a chunk walker written in the test
file so it cannot pass by agreeing with the code under test, plus a SYNTHETIC marker sweep,
byte-identity on clean input, and truncation and byte-flip sweeps over the whole corpus. 22 unit
tests in the handler and 11 in container/riff.rs, one of which is that what the RIFF writer emits
the RIFF walker will read back. Every malformed fixture is refused, and the three refusals a user
would call a WAV are asserted to be refused by name.
Measured against other tools on 2026-09-01. scripts/wav-differential.sh compares strypt with
mat2 0.15.0 and ExifTool 13.55 across all 14 well-formed fixtures: no gaps, no ExifTool
tag surviving any output, no synthetic marker in any output, and — decoded by ffmpeg 9.0.1 —
every output the same audio, sample for sample. The script was verified able to fail: against a
pass-through stand-in it reported 43 gaps.
The interesting result is a prediction that did not hold. mat2's WAVParser is an
AbstractFFmpegParser, so it rebuilds the file rather than editing it, and re-encoding looked
like it should reach data hidden in the samples where chunk surgery cannot. Measured on the same
day, it does not: for 16-bit PCM the re-encode reproduces the data payload byte for byte on
every fixture. So the difference between the two tools on this format is in the container, not the
audio, and neither reaches the sample values. The differential compares that payload on every
run, which is where it will show up if the re-encode ever stops being lossless.
Fuzzing. wav and riff, clean; riff drives the shared walker directly and asserts the
writer's output reads back. webp re-ran because ADR-0039 moved its code, and a later change to
container/riff.rs owes the same.
The one format here that is not a container. There is no header describing the file and no index: an MP3 is a run of self-describing MPEG audio frames, and every piece of metadata it carries was glued to one end or the other by a tagger (ADR-0040 — required reading before touching this handler). Group 4's hazard is absent for a third tranche running, and for a stronger reason than FLAC's or WAV's: nothing in an MP3 points at anything else, so there is no offset or length that removal could invalidate. It is edited by deletion at both ends, nothing is ever written back, and a file with no tags comes back byte-identical.
| What | Where it lives | What strypt does |
|---|---|---|
| Artist, composer, conductor, publisher, copyright holder, and the tagging software | ID3v2 TPE1–TPE4, TCOM, TEXT, TOPE, TOLY, TPUB, TOWN, TCOP, TENC |
Removed with the whole tag, itemised frame by frame |
| Recording, encoding, release and tagging timestamps | TDRC, TDEN, TDOR, TDRL, TDTG, and v2.3's TYER/TDAT/TIME |
Removed |
| The encoder and the medium | TSSE, TMED, TFLT |
Removed |
| Cover art, and any file at all under a name the tagger chose | APIC, GEOB |
Removed unread — an embedded image carries whatever its own container holds |
| Recording and disc identifiers, and anything a vendor put in a private frame | UFID, MCDI, TSRC, PRIV |
Removed |
| Comments, lyrics, synchronised lyrics, user-defined text | COMM, USLT, SYLT, TXXX |
Removed |
| A place, where a tagger used the geotagging convention | TLOC |
Removed |
Title, artist, album, year and comment in the 128-byte tail tag, and its TAG+ extension |
ID3v1, ID3v1 extended | Removed |
| Free-form key/value items, cover art included | APEv1 and APEv2, header and footer | Removed, itemised by key |
| Lyrics and the fields around them | Lyrics3 v1 and v2 | Removed |
| Anything at all — a frame identifier nobody has a name for is free to hold it | Any ID3v2 frame not named above | Removed unread |
| Bytes between the tags and the first frame | Leading padding | Dropped if they are zeros, and reported; anything else refuses the file |
| The encoder and its settings | The Xing/Info/VBRI header frame and its LAME extension |
Kept and declared — see below |
| The audio | Every MPEG frame | Copied byte for byte, never decoded |
There is no allow-list here, because there is nothing to allow-list. The frames are the payload and everything that is not a frame goes. That makes the boundary the whole of the safety argument, which is why a tag length is refused rather than clamped and why the handler demands a valid frame header where the tags stop.
The VBR header frame is a deliberate keep, and it is a real fingerprint. Xing, Info and
VBRI are MPEG frames that decoders play as silence, and the LAME extension inside one names the
encoder and its settings. It sits inside the encoded stream, which Phase 2's audio group never
enters (ADR-0037), and removing it would break VBR seeking and gapless playback. mat2 leaves it
too. It is stated in every report that has one rather than passed over, because a keep nobody is
told about is a leak with a clean report.
What is refused rather than partly cleaned: a size field that is not the syncsafe integer §6.2 requires; a tag span running past the end of the file; an ID3v2 major version whose header this code has never read; an APE footer claiming a header that is not there; a tail tag whose size reaches below the head tags; a file that is nothing but tags, which would otherwise strip to an empty file reported as a success; arbitrary bytes between the tags and the first frame, which is exactly where something would be hidden from a tool that skipped ahead to the first sync; and MPEG audio that is not Layer III, named as such rather than as "unrecognised".
Lyrics3 v1 is the one place the direction reverses, deliberately: it carries no size field at all,
so a LYRICSEND that is really audio yields "no tag" rather than a refusal. There is no number to
have lied about.
The frames are never decoded. Anything hidden in a frame's ancillary data, in its reserved bits, or in the last partial frame is out of reach. Every MP3 report carries this note, clean files included. This limit is shared with mat2, which does not enter the stream either.
Testing. 23 well-formed fixtures and 11 malformed ones in corpus/mp3, generated by
corpus/tools/make_mp3_fixtures.py. They are real playable audio — MPEG-1 Layer III, 128 kbps,
44.1 kHz mono — so mat2, ExifTool and ffmpeg can all open them. 16 integration tests in
crates/strypt-core/tests/mp3.rs, checking the frames with a walker written in the test file so
it cannot pass by agreeing with the code under test, plus a SYNTHETIC marker sweep, byte-identity
on clean input, and truncation and byte-flip sweeps over the whole corpus. 14 unit tests in the
handler and 19 in formats/tags.rs. Every malformed fixture is refused, and the refusal a user
would call an MP3 — an .mp2 — is asserted to be refused by name.
Measured against other tools on 2026-09-02. scripts/mp3-differential.sh compares strypt with
mat2 0.15.0 and ExifTool 13.55 across all 23 well-formed fixtures: no gaps, no ExifTool
tag surviving any output beyond the declared VBR header, no tag surviving an independent walk of
either end, no synthetic marker in any output, and — decoded by ffmpeg 9.0.1 — every output the
same audio, with the frames additionally identical byte for byte. The script was verified able to
fail against an unstripped pass-through stand-in, on both its tag walk and its marker sweep.
Where the two tools differ, and it goes both ways. mat2's MP3Parser deletes the ID3 tag
through mutagen and neither tool re-encodes, so this is the closest comparison in the project so
far. Measured the same day: a Lyrics3 tag that is the only tag on a file survives mat2 and does
not survive strypt. In the other direction, strypt refuses files mat2 will still clean — an
.mp2, or a file with arbitrary bytes in front of the audio. Refusing is correct fail-closed
behaviour, and for those files mat2 is the better recommendation.
The FLAC handler changed with this tranche. An ID3-prefixed FLAC used to be refused by name (ADR-0038 decision 7) purely because there was no ID3 reader in the tree; it is now read and removed, and a trailing ID3v1, APE or Lyrics3 tag on a FLAC — which used to survive silently under that handler's "the frames are not decoded" note — is peeled too. §7.13 covers the rest of FLAC.
Fuzzing. mp3 and tags, clean; tags covers the ID3 path FLAC uses too.
The first format in group 4 that is rebuilt rather than edited. Every Ogg page carries a CRC
over its own bytes and a sequence number counting from the start of the stream, so emptying a
comment header invalidates the page holding it and renumbers every page after it. There is nothing
to edit in place (ADR-0041 — required reading before touching this handler). strypt reads the
stream into packets, replaces the header packets it owns, and writes every page again with its CRC
recomputed. Three mappings, one handler: Ogg Vorbis, Opus, and Ogg FLAC.
| What | Where it lives | What strypt does |
|---|---|---|
| Artist, performer, composer, conductor, copyright holder, the person who encoded it, a contact | VORBIS_COMMENT ARTIST, ALBUMARTIST, PERFORMER, COMPOSER, CONDUCTOR, COPYRIGHT, ORGANIZATION, ENCODED-BY, CONTACT |
Removed with the whole comment list, itemised field by field |
| Recording and tagging timestamps | DATE, YEAR |
Removed |
| A place, and a set of coordinates | LOCATION, GEO, GPS |
Removed |
| The encoder, the medium, and the library that wrote the file | ENCODER, ENCODING, SOURCEMEDIA, and the vendor string every comment header begins with |
Removed — the vendor string is cleared, which mat2 keeps (see below) |
| Recording and disc identifiers | ISRC, MUSICBRAINZ*, CDDB |
Removed |
| Comments and descriptions | COMMENT, DESCRIPTION |
Removed |
| Cover art | METADATA_BLOCK_PICTURE, and Ogg-FLAC's PICTURE block |
Removed unread — an embedded image carries whatever its own container holds |
| Anything at all — a comment key nobody has a name for is free to hold it | Any VORBIS_COMMENT field not named above |
Removed unread |
| Application and vendor blocks, cue sheets, and reserved block types | Ogg-FLAC APPLICATION, CUESHEET, anything not named below |
Removed unread |
| The stream serial number | Every page header | Rewritten to zero — see below |
| Page sequence numbers | Every page header | Renumbered from zero |
| The audio MD5 | Ogg-FLAC STREAMINFO |
Kept and declared — derived from the samples the file already carries, so its holder can recompute it (ADR-0038 decision 4) |
| The seek table | Ogg-FLAC SEEKTABLE |
Kept — playback structure, and RFC 9639 §8.5 measures its offsets from the first audio frame, so nothing removed can move them |
| Padding | Ogg-FLAC PADDING |
Kept at its length with every byte zeroed |
| Granule positions | Every page header | Carried verbatim, on the page the packet finished on |
| The audio | Every packet past the headers | Copied byte for byte, never decoded |
The serial number is itself an identifier, and this is the one place strypt gives up a
byte-identical clean file. RFC 3533 §6 wants a random serial per logical bitstream, and libogg's
own example seeds it from time(NULL) — which makes it a timestamp with a random-looking spelling.
It is rewritten to zero rather than to a fresh random value, because nothing this tool writes may
be non-deterministic. A clean Ogg therefore does not come back byte-for-byte identical, unlike FLAC,
WAV and MP3. What is promised instead, and tested: the packets cross byte for byte, granule
positions stay with their packets, and stripping twice gives the same bytes.
Granule positions are per-page, not per-packet. They timestamp the last packet finishing on that page, so a rebuild that repaginated freely would detach every timing from the audio it belongs to. Each packet is therefore tagged with whether it ended its input page, and the writer closes a page exactly there. Page boundaries can still move — a shorter comment header pulls what follows forward — but no timing does.
The comment header is emptied, not deleted. All three codecs require it: Vorbis I §4.2 counts
three header packets, Opus (RFC 7845) two, and the Ogg-FLAC mapping declares its header count in the
mapping packet. Ogg-FLAC keeps that count by replacing a removed block with a zero-length PADDING,
so neither the declared count nor the last-block flag is rewritten.
What is refused rather than partly cleaned: a page whose declared CRC does not match its bytes; an unknown page version; bytes before the first page or after the last; more than one logical bitstream — multiplexed or chained — which strypt will not partly clean; a page finishing no packet whose granule position is not −1; a stream ending mid-packet; a missing or malformed comment header; a stream with no packets past the headers; and an Ogg-FLAC block that does not fill its packet exactly. Three codecs are refused by name rather than as "unrecognised": Theora, Speex and Skeleton.
The packets are never decoded. Anything hidden inside an encoded audio packet, or in a Vorbis setup header's codebooks, is out of reach. Every Ogg report carries this note, clean files included. This limit is shared with mat2.
Testing. 10 well-formed fixtures and 15 malformed ones in corpus/ogg, generated by
corpus/tools/make_ogg_fixtures.py. They are real decodable audio — the identification, setup
and audio packets came once from ffmpeg 9.0.1, because a Vorbis setup header is a codebook table
nobody can hand-write; the pagination, the CRCs and every comment are the generator's own. 17
integration tests in crates/strypt-core/tests/ogg.rs, checking the output with a page walker
written in the test file — its own CRC included — so it cannot pass by agreeing with the code under
test, plus a SYNTHETIC marker sweep and truncation and byte-flip sweeps over the whole corpus. 17
unit tests in the handler, 14 in container/ogg.rs, and 4 in formats/vorbis.rs. Every malformed
fixture is refused, and the three codecs that do not land here are asserted to be refused by name.
The FLAC handler shares code with this one. The Vorbis comment reader moved out of formats/ flac.rs into formats/vorbis.rs and is now read by both, so a change there changes FLAC too — which
is why flac re-runs in this tranche's fuzzing. §7.13 covers the rest of FLAC.
Measured against other tools on 2026-09-03. scripts/ogg-differential.sh compares strypt with
mat2 0.15.0 and ExifTool 13.55 across all 10 well-formed fixtures: no gaps, no ExifTool
tag surviving any output beyond the declared Ogg-FLAC keeps, no serial number surviving an
independent page walk, no synthetic marker in any output, and — decoded by ffmpeg 9.0.1 — every
output the same audio. The script was verified able to fail against an unstripped pass-through
stand-in, on both its page walk and its marker sweep.
Where the two tools differ, and it goes both ways. mat2's OggParser goes through mutagen and
repaginates too, so neither tool re-encodes. Measured the same day: mat2 keeps the vendor string
— Xiph.Org libVorbis …, libopus 1.5.2 … — on every fixture that has one, and keeps the stream
serial number; strypt clears both. In the other direction, strypt refuses files mat2 will still
clean — a multiplexed or chained stream, and a Theora video in an Ogg. Refusing is correct
fail-closed behaviour, and for those files mat2 is the better recommendation.
Fuzzing. ogg and oggpage, certified 2026-09-12 under ADR-0044 once the harness repaired
page CRCs (§5.1). Before that, Ogg was the least-fuzzed handler in the tree.
ADR-0034's conclusion reverses here. HEIF met the same sentence — offsets index the file, so
removal moves things — and answered it by rebuilding, because HEIF's metadata sits inside mdat
interleaved with the picture. MP4's metadata is entirely outside mdat, so each media block moves
as one rigid block: strypt filters the box tree, copies ftyp and every mdat byte for byte, and
writes only moov fresh (ADR-0042 — required reading before touching this handler). Every chunk
offset is then remapped through a table of mdat extents, and an offset resolving inside none of
them refuses the file rather than being nudged by a delta nobody verified. One handler, two
formats: MP4 (.mp4, .m4v) and M4A (.m4a, .m4b).
| What | Where it lives | What strypt does |
|---|---|---|
| GPS coordinates | moov/udta/©xyz — the ISO-6709 string every phone writes into every video |
Removed with the whole udta |
| Title, artist, album, composer, comment, description, lyrics | moov/udta/meta/ilst — the iTunes atom list |
Removed, itemised atom by atom |
| Camera make and model | moov/udta/©mak, ©mod |
Removed |
| Recording and tagging dates | moov/udta/©day and every ilst date atom |
Removed |
| The encoding software | moov/udta/©too, ilst's ©too, and compressorname in a video sample entry — where ffmpeg writes Lavc libx264 |
Removed; compressorname is zeroed in place at §12.1.3's fixed offset |
| The handler name | moov/trak/mdia/hdlr — ffmpeg writes VideoHandler, Apple writes a vendor string |
Emptied |
| Cover art | ilst covr |
Removed unread |
| Vendor key/value pairs | ilst's ---- mean/name/data triples, Microsoft's Xtra |
Removed unread |
| XMP — creator, creator tool, and whatever else the packet holds | A top-level uuid box with XMP's fixed 16-byte extended type |
Removed; the packet is scanned first so the report names what went |
| Anything at all — a box nobody has a name for is free to hold it | Any box not on the per-level allow-list | Removed unread and reported |
| Creation and modification times | mvhd, tkhd, mdhd |
Zeroed in place — the boxes are mandatory, so they stay |
| The media language | mdhd |
Set to und, ISO 639-2/T's undetermined |
| The poster, preview, selection and current-time block | mvhd's 24 pre_defined bytes, which §8.2.2 says should already be zero |
Zeroed |
| Free space | free, skip, wide, and pnot/PICT previews |
Removed |
| The codec configuration | moov/trak/mdia/minf/stbl/stsd |
Kept and declared — the samples cannot be decoded without it |
| The sample tables | stts, ctts, stss, stsc, stsz, sgpd, sbgp, and the rest of stbl |
Kept — playback structure, naming no person, device, place or time |
| Chunk offsets | stco, co64 |
Rewritten through the extent table, at the width they arrived in |
| The media | Every mdat |
Copied byte for byte, header form included, never decoded |
A clean MP4 comes back byte-identical. Nothing is re-encoded and nothing is repaginated, so
unlike Ogg (§7.16) the file's own bytes are what returns. This is what edit-by-deletion is chosen
for, and it is tested: clean.mp4 and clean.m4a are fixed points.
What is refused rather than partly cleaned. Four families, each refused by name so the message says what the file is rather than what it lacks:
- Fragmented MP4 — a
moof,mfra,mvex,styp,sidxorssixbox, or adash,msdh,msix,cmfcorcmflbrand. Sample offsets live in track fragment runs this handler does not rewrite. - Encrypted media — a
pssh,senc,sinforschmbox, anencv/enca/encs/enct/drms/drmisample entry, or theM4Pbrand. The samples are ciphertext no rule here matches. - QuickTime
.mov— theqtbrand. A different vocabulary sharing the same box grammar. - 3GPP and 3GPP2 — the
3gp/3g2brand prefixes.
Also refused: a file with no moov, or with more than one; a track with no mvhd or no surviving
trak; a dref entry whose self-contained flag is clear (§8.7.2 — the samples are in another
file, so the mdat this handler relocates into is not where they are); a sample description that
does not tile exactly; a chunk offset table whose length disagrees with its count; and a moov
whose two write passes disagree on length.
Deliberate limits, each of them real:
- The samples are never decoded. x264 writes its version and its full option string into H.264
SEI user data, which lives inside
mdat; so do some in-band codec headers. That is out of reach, and every MP4 report carries this note, clean files included. This limit is shared with mat2, whose-codec copyremux does not re-encode either. - strypt does not descend into a sample entry to look for a
sinf. The fixed fields in front of an entry's child boxes differ per media type, and this handler does not parse them. Encryption is detected from the entry type and frompssh/sencelsewhere in the tree, which is how every CENC file in practice spells it — but a file that carried asinfinside an otherwise plain entry would not be caught by that rule.compressornameis the one field read inside an entry, and only for a video track, because §12.1.3 fixes its offset. - Timestamps are zeroed, not removed.
mvhd,tkhdandmdhdare mandatory, so a reader still sees the fields — reading0000:00:00 00:00:00. mat2 does the same. - Track structure survives. Track count, durations, timescales, resolution and codec are all still there, and together they are a weak fingerprint of the recording device and software. No metadata-removal tool removes them, because removing them removes the file.
Testing. 10 well-formed fixtures and 15 malformed ones in corpus/mp4, generated by
corpus/tools/make_mp4_fixtures.py. They are real decodable media — a 0.4 s H.264 video and a
0.2 s AAC recording came once from ffmpeg 9.0.1, because a codec configuration is not something to
hand-write; every box, offset and marker around them is the generator's own, and its clean.*
baselines are derived by the script rather than from strypt's output. 18 integration tests in
crates/strypt-core/tests/mp4.rs, checking the output with a box walker written in the test
file so it cannot pass by agreeing with the code under test, plus a SYNTHETIC marker sweep. The
load-bearing one resolves every chunk offset to "which mdat, how far in" on both sides and asserts
the pairs are equal — with a second test proving at least one fixture really does move its media, so
the first cannot pass vacuously.
container/bmff.rs is shared with HEIF. It gained a header-length field and a raw() accessor
for this handler, so a change there changes HEIF too — which is why heif and bmff re-run in
this tranche's fuzzing. §7.9 covers HEIF.
Measured against other tools on 2026-09-04. scripts/mp4-differential.sh compares strypt with
mat2 0.15.0 and ExifTool 13.55 across all 10 well-formed fixtures: no gaps, no ExifTool
tag surviving any output beyond the structural set, every chunk offset still resolving to the same
media byte under an independent box walk, no synthetic marker in any output, and — decoded by
ffmpeg 9.0.1 — every output the same recording. The script was verified able to fail: its
tag filter is checked against unstripped fixtures, its value-matched date rule is checked against a
fixture carrying real dates, and its marker sweep and offset walk are checked against a
pass-through stand-in.
Where the two tools differ. mat2 handles MP4 by remuxing through ffmpeg —
-map 0 -codec copy -map_metadata -1 -map_chapters -1 -disposition 0 with bitexact flags — so it
rewrites the container while strypt edits it. Measured the same day: mat2 keeps
HandlerDescription, HandlerVendorID and an empty free box on every fixture; strypt removes
all three. In the other direction, mat2's MP4Parser registers video/mp4 and does not claim
.m4a, so an M4A has no mat2 side at all here; and strypt refuses files mat2 will still clean
— fragmented MP4, and a QuickTime .mov. Refusing is correct fail-closed behaviour, and for those
files mat2 is the better recommendation.
Fuzzing. mp4, clean; bmff and heif re-ran because the shared box walk changed.
Update this document when:
- A format handler is added or substantially changed — mandatory.
- Fuzzing or differential testing reveals a new class of hidden data.
- A dependency handling untrusted input is added or swapped.
- A new front-end (GUI, file-manager integration, FFI) creates a new trust boundary.
- Any metadata-leak vulnerability is reported, whatever the outcome.
- At each phase transition, as a scheduled review even absent a specific trigger.