Added
-
zencodec-testkitcrate (new workspace member, unpublished) — a
conformance harness codec crates run against their ownEncoderConfig/
DecoderConfig. Shipscheck_metadata_no_leak(a retention policy must never
leak what it discards — decodes the output and re-parses the embedded EXIF to
prove GPS/thumbnail/rights the policy dropped are actually gone),
check_cross_path_pixel_equivalence(one-shot vspush_rowsvs streaming vs
push-sink produce identical pixels),check_animation_cross_path_equivalence
(the three animation decode paths — borrowed, owned, push-sink — yield identical
frames, where canvas-aliasing/frame-ordering bugs hide),
check_orientation_roundtrip(an orientation survives a keeping policy exactly
once — no loss, no double-application), andcheck_capability_honesty—
comprehensive, bidirectional: every declared capability (push_rows,
encode_from, animation, streaming,lossless,cheap_probe, the
icc/exif/xmp/cicpchannels,native_alpha) must work, and every
undeclared optional path must decline withUnsupportedOperation; all
violations are reported together.check_allruns every check with default
inputs. Includes two codecs validated against the harness in-crate: a faithful
reference(round-trips pixels and metadata, declares/honors every
capability) and aminimalone (one-shot only, declares every optional
capability false) exercising the false-direction branches, plus a hand-built
GPS/thumbnail EXIF fixture. The repo is now a Cargo workspace (zencodecroot +
zencodec-testkitmember);zencodec's published package is unaffected. (23d4046) -
Cross-codec color-emission policy —
resolve_color_emit(&SourceColor, &EncodeCapabilities, ColorEmitPolicy) -> ColorEmitPlan,
a pureno_stddecision of which color carriers (ICC vs CICP) to write for a
target, with no CMS and no codec dependencies. Thecolormodule is private;
the types are re-exported at the crate root (zencodec::ColorEmitPolicy, …).ColorEmitPolicy { Compatibility, Balanced (default), Compact, Verbatim, Custom(ColorEmitFields) };
ColorEmitPlan { cicp: Option<Cicp>, icc: IccDisposition };
IccDisposition { KeepSource, SynthesizeFrom(Cicp), Drop }. Handles the
grayscale/CMYK terminal states and never emits a redundantSynthesizeFrom(sRGB).
(Names carry the emit direction so they can't be confused with the decode-side
SourceColor.)ColorEmitFields::newmakesColorEmitPolicy::Customconstructible downstream.EncodeCapabilitiesgainscicp_is_valid_carrier(standardized carrier —
JXL/AVIF/HEICnclx, PNGcICP) andcicp_safe_sole_carrier(safe CICP-only
— JXL, AVIF, HEIC, whosenclx/CICP is spec-mandated and reader-authoritative)
(+with_*);IccRetentiongainsDropIfCicpRepresentable,
DropIfCicpSafeSoleCarrier.Balancedkeeps a synthesized ICC companion when
the CICP carrier is not sole-safe (PNGcICP). The plan lowers through
zenpixels_convert'sfinalize_for_output_with; aSynthesizeFrom
materializes via the transfer-awaresynthesize_icc_for_cicp— a bundled
constprofile, or (withcms-moxcms) generated — never a mis-tagged TRC,
never a silent drop.EncodePolicycarries the color-carrier emission policy:color: Option<ColorEmitPolicy>(+with_color,resolve_color), so encode and
transcode select the ICC-vs-CICP carrier through it. Its docs reframe the
legacyembed_*flags as a coarse best-effort codec gate, and point at
EncodeJob::with_metadata_policyfor reliable field-level retention.
MetadataPolicyis nowCopy.helpers::set_exif_orientationrewrites a blob's EXIF orientation tag inline
(offset-preserving) so a baked-upright pixel buffer and its embedded tag can't
disagree (the double-rotation hazard). Applied by the pipeline, not by the
color resolver.exif::ByteOrderis module-scoped (a TIFF/EXIF header detail), not re-exported
at the crate root.- Design + rejected alternatives:
docs/color-emit-model.md. (23d4046, bbe4c7e, 3fb841e)
-
EXIF string-field editing —
Exif::set_copyright/set_artistset (insert
or replace) the IFD0 rights tags, materialized through the existing canonical
Exif::to_bytes(offsets recomputed, byte-exact fixpoint preserved). The new
exif::TextEncoding(re-exported at the crate root) lets the caller pick the
TIFF field type explicitly:Ascii(Exif 2.x, type 2 — carries UTF-8 bytes
de-facto, most compatible) orUtf8(Exif 3.0 / CIPA DC-008-2023, type 129 —
spec-conformant Unicode, thinly read). Explicit over auto-upgrade because
auto-promoting non-ASCII to type 129 would silently produce strings most
readers can't parse.Entryvalue bytes are nowCowso parsed entries stay
zero-copy while edited ones are owned; thecopyright()/artist()/
*_bytes()accessors now borrow&self. EXIF tag/type numbers in the parser
are named constants (no bare hex), and theExifPolicytimestamps category is
datetimes(plural — it covers DateTime / Original / Digitized / OffsetTime* /
SubSecTime*). (f4b9f1b) -
Explicit metadata-retention policy at embed time (compile-time enforced) —
retention is a transient choice made when handing metadata to the encoder, not
state stored onMetadata. New blessed entry points:
EncodeJob::with_metadata_policy(meta, MetadataPolicy)and
DynEncodeJob::set_metadata_policy(meta, MetadataPolicy)filter the record via
Metadata::filteredbefore it reaches the codec, so a codec only ever embeds
what the policy kept. The pre-existingEncodeJob::with_metadata/
DynEncodeJob::set_metadataare now#[deprecated]: they propagate metadata
without a retention choice, so the compiler warns at every such call site —
a compile-time nudge towardwith_metadata_policy, not a semver break
(existing code still compiles, and codecs still implementwith_metadataas
the primitive the wrapper routes through; deprecation warns callers, not
implementors).MetadataPolicyhas noDefault— callers name a policy
explicitly (Webrecommended, privacy-safe). No field was added toMetadata
(size_ofstays 104 on 64-bit) and its bytes stay untouched until embed, so
bring-your-own-EXIF-library round-trips still see the originals. (73c5799) -
EXIF privacy hardening for partial-strip policies —
MakerNote(0x927C) is
dropped whenevergpsorcamerais stripped (it can embed GPS/serials and
can't be selectively scrubbed);SubIFDs(0x014A, an unmodeled sub-IFD pointer)
is dropped on a rewrite rather than left dangling; IFD1 (thumbnail-directory)
entries are filtered by the same per-category rules as IFD0 (a keep-thumbnail
policy previously kept their Make/Model/DateTime); andexif::retainnow fails
safe for a >4 GiB blob under a stripping policy (drop, not pass-through). The
Web/ColorAndRotationpresets were already safe — these close gaps for
hand-rolledCustompolicies. (d8a2fae) -
From-scratch EXIF construction —
Exif::new(TextEncoding)(+Default,
which usesAscii) starts an empty little-endian tree, completing the
parse/new→ edit →to_bytesflow so you can build a blob with no source:
Exif::new(TextEncoding::Ascii)→set_copyright(…)→to_bytes()(raw TIFF;
the codec adds the APP1Exif\0\0framing). TheTextEncodingis required — the
Exif 2.x ASCII (type 2) vs Exif 3.0 UTF-8 (type 129) choice is a blob property
used byset_copyright/set_artist(type 129 is read by almost nothing, so it
can't be a silent default). (b7acd9f, 73c5799) -
Metadata::with_copyright(&str)/with_artist(&str)— one-liner rights
stamping that builds an EXIF blob if there is none and merges into a parseable
existing one (keeping other tags), replacing an unparseable one. Written ASCII
(Exif 2.x, most compatible); for UTF-8/Exif 3.0 or other tags, build via
exif::Exif+with_exif. (1051288) -
Field-level metadata retention —
Metadata::filtered(&MetadataPolicy),
the shared filter for re-encode / recompress pipelines: keep what a
downstream image needs, strip the rest, without callers hand-parsing EXIF.MetadataPolicy:PreserveExact(keep all, incl. a redundant sRGB ICC),
Preserve(keep all but drop a redundant sRGB ICC),Web(recommended —
ICC non-sRGB + EXIF orientation/rights + CICP/HDR; drop the rest of EXIF
and all XMP),ColorAndRotation(only what places pixels: ICC non-sRGB +
CICP/HDR + orientation), andCustom(MetadataFields).MetadataFields(#[non_exhaustive],with_*builders):icc: IccRetention(#[non_exhaustive];Drop/KeepNonSrgb/Keep—
three-way sRGB handling),exif: ExifPolicy, andxmp/cicp/hdr: Retention.exif::Retention(#[non_exhaustive];Keep/Discard, query via
keeps/discards) — explicit per-field intent, nobool-direction
ambiguity.- Every disposition type (
MetadataPolicy,IccRetention,Retention) and
every record (Metadata,MetadataFields,ExifPolicy) is
#[non_exhaustive]with builder construction, so new policies, ICC modes,
EXIF categories, retention fields, andMetadatafields land additively —
the surface never needs a semver-major break (see the module's Forward
compatibility docs).
-
Structured EXIF (
zencodec::exif) —Exif<'a>parses a TIFF/EXIF blob
into a borrowing IFD tree (zero-copy; thumbnails/values are never copied),
Exif::filtered(&ExifPolicy)prunes by category, andExif::to_bytes
re-serializes a valid TIFF with recomputed offsets.ExifPolicy
(#[non_exhaustive],with_*builders) has seven categories:orientation,
rights,thumbnail,gps,datetimes,camera,other— so e.g.
"drop only the thumbnail" or "strip GPS" is one field.exif::retainis the
Cowentry point: borrows the source unchanged when nothing is dropped
(soMetadata::filteredis a cheapArcclone), allocates only on a real
rewrite. Bounds-checked, no panics on untrusted input; preserves byte order
andExif\0\0framing. (helpers::parse_exif_orientationnow delegates
here.)- Hardened (adversarial review + 80M+ fuzz executions across four targets):
the serializer deduplicates aliased out-of-line values so a malformed
IFD pointing many entries at one blob can't amplify the rewrite ~1000×
(DoS); Copyright/Artist accessors read both ASCII (type 2) and UTF-8
(type 129, Exif 3.0) per CIPA DC-008 (a UTF-8-typed field was previously
dropped as unknown), expose raw bytes (copyright_bytes/artist_bytes)
alongside the lossy-UTF-8 text view, and a pruning rewrite preserves field
bytes and TIFF type verbatim (never transcoded — neither corrupted nor
"corrected"); EXIF categories were corrected per the spec's tag tables —
the Exif-IFD creator/owner name tags (CameraOwnerName 0xA430, Photographer
0xA437, ImageEditor 0xA438) are attribution (rights, kept by a copyright
policy — they were previously stripped as "other"), and firmware / editing-
software / unique-ID tags are device identity (camera); the thumbnail
length tag is read as SHORT or LONG (real cameras use SHORT — was silently
dropping valid thumbnails);
structural sub-IFD pointers too short to hold an offset are preserved
(peek-before-remove) instead of dropping the sub-IFD; andretainpasses a4 GiB blob through untouched rather than risk
u32offset truncation. - Robust error model:
Exif::parsereturnsNoneon structural failure but
gracefully skips an individual unreadable / unknown-type / out-of-bounds
entry (and salvages a truncated entry table) — one bad or future-typed
entry no longer discards the whole IFD;retainfails safe (drops EXIF
it can't parse under a stripping policy rather than leaking it through); and
to_bytesis canonical (a byte-exact fixpoint), so filtering is
idempotent (a fuzz-found non-idempotence, now a regression seed). - Test infrastructure: differential tests against
kamadak-exif
(tests/exif_differential.rs), four libFuzzer targets (fuzz/— parse,
roundtrip, filter, andMetadata::filtered), a stable regression harness
with a committed crash seed (tests/fuzz_regression.rs), and a zero-copy
benchmark over 1 KiB–1 MiB thumbnails (benches/exif_filter.rs).
- Hardened (adversarial review + 80M+ fuzz executions across four targets):
-
ThreadingPolicy::resolve_thread_count()— cross-codec shared helper that
translates a [ThreadingPolicy] to the integer thread count that
native-threaded encoder libraries (rav1e/ravif, dav1d/rav1d, libwebp, etc.)
accept. Returns1forSequential,0(auto) forParalleland every
other variant. Replaces hand-writtenpolicy_to_threadshelpers in
individual codec crates (Cluster B Class 1 dedup). -
ResourceLimits::for_untrusted_input()(withsafe_default()alias) — a
safer starting point thanResourceLimits::default()for services
accepting bytes from the network or end users. Caps: 100 MP per frame,
200 MP across an animation, 16384×16384 max dims, 1 GiB memory, 256 MiB
input, 65536 frames, 1 hour duration.ResourceLimits::default()
continues to mean "no limits" for backwards compatibility (bc2790d).
Changed
metadata::parse_exif_orientationnow delegates to the canonical
helpers::parse_exif_orientation. The previous local implementation was
a looser duplicate that read the orientation value asu16regardless
of TIFF type, missingTIFF_LONG(type 4) values for big-endian inputs
and lacking the IFD entry-count cap and tag-sort early-exit DoS
protections present in the helper (141238f).DynDecodeJobandDynEncodeJobshim setters nowdebug_assert!when
called after the inner job has been consumed by aninto_*method,
catching the (structurally unreachable) misuse path loudly in tests and
dev builds. Release behaviour is unchanged (silent no-op). Trait
signatures are unchanged (a5b782e).
Documentation
- Module-level docs in
policy.rsnow recommendDecodePolicy::strict()
as the starting point for untrusted input, paired with
ResourceLimits::for_untrusted_input(468073d).