feat: Add full deterministic support by default - #24
Conversation
Sort map and struct keys deterministically per RFC 8949 §4.2.1 Map, struct, and struct-variant entries are now buffered and written in bytewise-lexicographic order of their encoded key bytes by default, as C2PA requires. Arrays keep their existing zero-copy fast path since element order is never reordered. Adds Encoder::set_deterministic(false) and to_vec_unordered/ to_writer_unordered to opt out and restore the original unsorted, unbuffered behavior for callers who need it. Deterministic mode can be disabled and non-deterministic mode still supported.
Reject duplicate map/struct keys in deterministic mode, since RFC 8949 §4.2.1 forbids them (only enforced when deterministic; unordered mode is unaffected). Fix Value::Tag, which previously serialized its inner value and silently dropped the tag. Replace Tagged<T>'s hardcoded whitelist of ~30 tag numbers with a general mechanism (thread-local tag slot + a single sentinel marker name) that works for any u64 tag, matching the approach serde_cbor uses. Value::Tag now emits real CBOR tag bytes on encode, and a new opt-in Decoder::with_capture_tags / Value:: from_tagged_slice reconstructs Value::Tag (including nested tags) on decode, without changing the default from_slice behavior of transparently skipping tags for plain types.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #24 +/- ##
==========================================
+ Coverage 66.58% 68.34% +1.76%
==========================================
Files 6 6
Lines 1721 1763 +42
==========================================
+ Hits 1146 1205 +59
+ Misses 575 558 -17 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Merging this PR will degrade performance by 42.9%
|
| Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|
| ❌ | encode_hashmap_100_entries |
21.1 µs | 94.1 µs | -77.56% |
| ❌ | encode_struct |
7.6 µs | 15.5 µs | -51.17% |
| ❌ | encode_nested_3_levels |
139 µs | 246.6 µs | -43.63% |
| ❌ | encode_options_all_some |
8.1 µs | 12.1 µs | -32.7% |
| ❌ | encode_options_all_none |
3.3 µs | 4.5 µs | -26.33% |
| ❌ | roundtrip_struct |
19.7 µs | 26.6 µs | -25.69% |
| ❌ | encode_with_flatten |
13.7 µs | 15.8 µs | -13.03% |
Tip
Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.
Comparing gpeacock/deterministic_mode (7c6b2fa) with main (3fd0162)
tmathern
left a comment
There was a problem hiding this comment.
Does the deterministic mode has other effects on encoding?
I am thinking maybe of everything float-related, since those can have rounding errors that may be affected by determinism rules precised in the spec (if anything)?
| /// Buffers the fields of a struct variant so they can be written in | ||
| /// deterministic (sorted-by-key) order once all fields are known, matching | ||
| /// the same requirement enforced for plain maps and structs. | ||
| pub struct SerializeStructVariantBuf<'a, W: Write> { |
There was a problem hiding this comment.
Doesn't take the set_deterministic value into account?
| // The RFC also disallows duplicate keys, so that's only | ||
| // checked in this same deterministic mode. | ||
| if encoder.deterministic { | ||
| buffer.sort_by(|a, b| a.0.cmp(&b.0)); |
There was a problem hiding this comment.
Similar to what is in the function fn end(self) -> Result<()> . To make sure they do the same thing, I would refactor and call them in both locations.
| if encoder.deterministic { | ||
| buffer.sort_by(|a, b| a.0.cmp(&b.0)); | ||
| if buffer.windows(2).any(|w| w[0].0 == w[1].0) { | ||
| return Err(Error::Message( |
There was a problem hiding this comment.
A specific Error for this here might be more helpful for error matching?
| // visit_newtype_struct so it can reconstruct the tag | ||
| // (used by Value::from_tagged_slice). Only safe for a | ||
| // visitor that implements visit_newtype_struct. | ||
| crate::tags::set_tag(Some(tag)); |
There was a problem hiding this comment.
Can't we import it on top? I prefer all imports to be improted rather than crate::something in the middle of the code. So they are also more easily visible/reusable when the time comes.
| use crate::{Decoder, Encoder, Result, constants::*}; | ||
|
|
||
| thread_local! { | ||
| static CURRENT_TAG: Cell<Option<u64>> = const { Cell::new(None) }; |
There was a problem hiding this comment.
Ok to have things in thread_local here? No side-effects to be weary of when used in c2pa-rs? E.g. do we need extra hygiene around cleaning values up?
Code Review — Deterministic Encoding + CBOR Tag SupportOverviewTwo mostly-independent changes, both sound in their core design:
The sorting logic is correct: Reading is unaffected: the decode path is unchanged by default (the new Findings, roughly by severity: 1. Compatibility — default output bytes change (by design, but breaking)
2. Compatibility — duplicate keys now hard-error in the default pathDeterministic mode rejects duplicate keys (most realistically from a 3. Security — nested tags have no recursion-depth guard (pre-existing, but PR adds a second path)The This exists on 4. Latent bug —
|
Sort map and struct keys deterministically per RFC 8949 §4.2.1
Map, struct, and struct-variant entries are now buffered and written
in bytewise-lexicographic order of their encoded key bytes by default,
as C2PA requires. Arrays keep their existing zero-copy fast path since
element order is never reordered.
Adds Encoder::set_deterministic(false) and to_vec_unordered/
to_writer_unordered to opt out and restore the original unsorted,
unbuffered behavior for callers who need it.
Fix duplicate map keys and Value::Tag encoding for RFC 8949 compliance
Reject duplicate map/struct keys in deterministic mode, since RFC 8949
§4.2.1 forbids them (only enforced when deterministic; unordered mode
is unaffected).
Fix Value::Tag, which previously serialized its inner value and
silently dropped the tag. Replace Tagged's hardcoded whitelist of
~30 tag numbers with a general mechanism (thread-local tag slot + a
single sentinel marker name) that works for any u64 tag, matching the
approach serde_cbor uses. Value::Tag now emits real CBOR tag bytes on
encode, and a new opt-in Decoder::with_capture_tags / Value::
from_tagged_slice reconstructs Value::Tag (including nested tags) on
decode, without changing the default from_slice behavior of
transparently skipping tags for plain types.