jcs_admit/lib.rs
1//! Admission control for JSON that is about to be signed or verified.
2//!
3//! One document has one canonical form, and a signature means nothing unless
4//! every party agrees which bytes those are. RFC 8785 says how to write them.
5//! This crate decides which documents are allowed to have them, refuses the rest
6//! on the raw bytes, and then hands the writing to
7//! [`serde_json_canonicalizer`].
8//!
9//! # Why a separate layer
10//!
11//! The two canonicalizers on crates.io are serde `Serializer`s. That is the
12//! right shape for their job and it fixes where they can stand: a value has
13//! already been parsed before either of them is reached, and the faults that
14//! matter to a verifier are the ones that vanish during that parse. A repeated
15//! object member is gone -- the map kept one of the two and nothing downstream
16//! can tell it happened. There is no layer below them to put the check in, so the
17//! check goes above them, on the bytes, which is this crate.
18//!
19//! Measured against `serde_json_canonicalizer` 0.3.2 on 2026-09-19 (the run is
20//! pinned in `tests/incumbent_differential.rs`, which fails if any of it stops
21//! being true):
22//!
23//! | input | the delegate | this crate |
24//! |---|---|---|
25//! | `{"a":1,"a":2}` | `{"a":2}` via `pipe`, `{"a":1}` straight into its serializer | [`Error::DuplicateMember`] |
26//! | 129 nested arrays | accepted by `to_vec`; 10,000 aborts the process | [`Error::TooDeep`] |
27//! | `9007199254740993` | `9007199254740992`, a different integer | [`Error::UnsafeInteger`] under [`admit_ijson`]; [`admit`] rewrites it the same way |
28//! | 12 MB of array | accepted | [`Error::TooLarge`] |
29//!
30//! The third row's right-hand cell names [`admit_ijson`] and the qualification is
31//! load-bearing: the RFC 8785 default *admits* that token and canonicalizes it to
32//! `9007199254740992`, the same different integer the delegate produces, because
33//! section 3.2.2.3 defers to ECMAScript and ECMAScript has one numeric type. The
34//! The first row is the one to read twice. One document, one crate, two canonical
35//! forms depending on which of its entry points you used, and both of them
36//! signable.
37//!
38//! What the delegate gets right, it gets right for us: it formats numbers through
39//! `ryu_js`, which is the ECMAScript `Number::toString` variant RFC 8785 section
40//! 3.2.2.3 requires, and it reads the RFC's own awkward reference number
41//! `333333333.33333329` correctly. This crate stands on that rather than carrying
42//! a third copy of it.
43//!
44//! # What it refuses
45//!
46//! 1. **A repeated object member** ([`Error::DuplicateMember`]). Collapsing a
47//! repeat to last-wins is a split view: two parties read the same bytes as two
48//! different documents and a signature over either reading verifies.
49//! Comparison is on the *decoded* name, so `"a"` and `"a"` are one member.
50//! 2. **Nesting past a cap** ([`DEFAULT_MAX_DEPTH`], 128 -- the value RFC 8785's
51//! in-toto profile made normative). Without a cap a crafted document
52//! overflows the stack, which no caller can catch and which happens before any
53//! signature is checked.
54//! 3. **A string whose bytes are not Unicode scalar values**
55//! ([`Error::StringNotScalar`]): an unpaired surrogate escape, a surrogate
56//! written directly in UTF-8, an overlong form, a raw control character.
57//! Checked before any decode, because a decoder that substitutes U+FFFD
58//! leaves `"\ud800"`, `"\udc00"` and a literal U+FFFD canonicalizing to
59//! identical bytes.
60//! 4. **A number with no finite double** ([`Error::NonFiniteNumber`]), and under
61//! the opt-in RFC 7493 profile ([`admit_ijson`]) an integer at or above 2^53
62//! ([`Error::UnsafeInteger`]) or a Unicode noncharacter.
63//! 5. **An input past a size cap** ([`Error::TooLarge`]). A depth cap bounds the
64//! stack; a size cap bounds the heap.
65//!
66//! # Examples
67//!
68//! ```
69//! use jcs_admit::{admit, Error};
70//!
71//! assert_eq!(admit(br#"{ "b": 1, "a": [1.0, 1e30] }"#)?, br#"{"a":[1,1e+30],"b":1}"#);
72//!
73//! // The refusal the layer exists for, and it names the member.
74//! assert!(matches!(
75//! admit(br#"{"a":1,"a":2}"#),
76//! Err(Error::DuplicateMember { .. })
77//! ));
78//! # Ok::<(), Error>(())
79//! ```
80
81#![doc(html_root_url = "https://docs.rs/jcs-admit/0.1.1")]
82
83mod delegate;
84mod error;
85mod number;
86mod parse;
87
88pub use error::Error;
89
90/// The default depth cap: at most 128 nested arrays and objects.
91///
92/// Not arbitrary. 128 is the nesting limit `serde_json` enforces by default, it
93/// is the `MAX_DEPTH` the in-toto attestation specification carries as normative
94/// text, it is far below the stack-overflow point so the counter trips with an
95/// ordinary error first, and it is orders of magnitude above any real signed
96/// payload. Three values exist in the wild -- 128, 256, and none at all -- and
97/// the third is a crash.
98pub const DEFAULT_MAX_DEPTH: usize = 128;
99
100/// The deepest nesting this crate honours, whatever a caller configures.
101///
102/// A depth cap is only a refusal while it is below the stack. The admission walk
103/// recurses, one frame per open container, so a cap above the stack is not a cap:
104/// measured on this machine before the ceiling existed, an unbounded cap on
105/// 19,414 nested arrays aborted the process with "fatal runtime error: stack
106/// overflow" -- not an `Err`, not catchable, reached through safe API in a crate
107/// that forbids `unsafe` entirely. [`Options::max_depth`] saturates here: four
108/// times the default, sized against the *unoptimized* build because that is the
109/// profile a caller's own tests run under.
110pub const MAX_SUPPORTED_DEPTH: usize = 512;
111
112/// The default size cap on untrusted input, 20 MiB.
113pub const DEFAULT_MAX_BYTES: usize = 20 << 20;
114
115/// What to refuse beyond the RFC 8785 baseline.
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct Options {
118 max_depth: usize,
119 max_bytes: Option<usize>,
120 safe_integers: bool,
121 integers_only: bool,
122 reject_noncharacters: bool,
123}
124
125impl Default for Options {
126 fn default() -> Self {
127 Self::rfc8785()
128 }
129}
130
131impl Options {
132 /// RFC 8785 as written, with the depth and size caps applied.
133 #[must_use]
134 pub const fn rfc8785() -> Self {
135 Self {
136 max_depth: DEFAULT_MAX_DEPTH,
137 max_bytes: Some(DEFAULT_MAX_BYTES),
138 safe_integers: false,
139 integers_only: false,
140 reject_noncharacters: false,
141 }
142 }
143
144 /// RFC 8785 plus the RFC 7493 I-JSON profile: an integer must lie inside the
145 /// range where an IEEE-754 double is exact, and a Unicode noncharacter is
146 /// refused in a string literal.
147 ///
148 /// Opt-in rather than default, and the reason is load-bearing: the RFC 8785
149 /// reference suite carries `1e30` and `1e-27`, so a crate whose default
150 /// refused them would fail the specification it implements.
151 #[must_use]
152 pub const fn ijson() -> Self {
153 Self {
154 safe_integers: true,
155 reject_noncharacters: true,
156 ..Self::rfc8785()
157 }
158 }
159
160 /// Set the maximum number of nested arrays and objects.
161 ///
162 /// Lowered freely; raised only as far as [`MAX_SUPPORTED_DEPTH`], where a
163 /// larger request saturates. The clamp is the difference between a
164 /// configuration and a crash.
165 #[must_use]
166 pub const fn max_depth(mut self, limit: usize) -> Self {
167 self.max_depth = if limit > MAX_SUPPORTED_DEPTH {
168 MAX_SUPPORTED_DEPTH
169 } else {
170 limit
171 };
172 self
173 }
174
175 /// Set the maximum input size in bytes, or `None` for no cap.
176 #[must_use]
177 pub const fn max_bytes(mut self, limit: Option<usize>) -> Self {
178 self.max_bytes = limit;
179 self
180 }
181
182 /// Check only the configured byte cap before allocating an input buffer.
183 ///
184 /// This does not inspect, parse or admit any bytes. Passing the check means
185 /// only that `len` is within the configured cap, or that no cap is set.
186 /// Admission still requires [`admit_with`], which uses this same check first.
187 ///
188 /// ```
189 /// use jcs_admit::{admit_with, Error, Options};
190 /// let opts = Options::rfc8785().max_bytes(Some(16));
191 /// assert_eq!(opts.check_input_length(16), Ok(()));
192 /// assert_eq!(opts.check_input_length(17),
193 /// Err(Error::TooLarge { len: 17, limit: 16 }));
194 /// // A length within the cap does not establish valid JSON.
195 /// assert!(opts.check_input_length(1).is_ok());
196 /// assert!(admit_with(b"{", &opts).is_err());
197 /// ```
198 pub fn check_input_length(&self, len: usize) -> Result<(), Error> {
199 if let Some(limit) = self.max_bytes {
200 if len > limit {
201 return Err(Error::TooLarge { len, limit });
202 }
203 }
204 Ok(())
205 }
206
207 /// Refuse an integer outside the range where an IEEE-754 double is exact.
208 #[must_use]
209 pub const fn safe_integers(mut self, refuse: bool) -> Self {
210 self.safe_integers = refuse;
211 self
212 }
213
214 /// Refuse any number with a fractional part.
215 ///
216 /// Tighter than RFC 7493, which permits one. Two implementations that never
217 /// format a float can never disagree about one, so pinning integers only
218 /// removes a class of cross-language divergence rather than testing for it.
219 #[must_use]
220 pub const fn integers_only(mut self, refuse: bool) -> Self {
221 self.integers_only = refuse;
222 self
223 }
224
225 /// Refuse a Unicode noncharacter in a string literal (RFC 7493 section 2.1).
226 #[must_use]
227 pub const fn reject_noncharacters(mut self, refuse: bool) -> Self {
228 self.reject_noncharacters = refuse;
229 self
230 }
231
232 pub(crate) const fn depth_limit(&self) -> usize {
233 self.max_depth
234 }
235
236 pub(crate) const fn wants_safe_integers(&self) -> bool {
237 self.safe_integers
238 }
239
240 pub(crate) const fn wants_integers_only(&self) -> bool {
241 self.integers_only
242 }
243
244 pub(crate) const fn wants_noncharacter_check(&self) -> bool {
245 self.reject_noncharacters
246 }
247}
248
249/// Admit one JSON document and return its RFC 8785 canonical form.
250///
251/// Takes `&[u8]` rather than `&str` on purpose: a document arrives off a socket
252/// or off disk as bytes, and the faults this crate refuses include ones no `&str`
253/// can hold. A surrogate written directly in UTF-8 and an overlong form are
254/// unrepresentable in a Rust string, so an entry point taking `&str` cannot be
255/// handed them at all and its caller has to validate the bytes first -- which is
256/// the check, moved back onto the caller. Measured 2026-09-19: the delegate's
257/// only byte-facing entry point, `pipe`, takes `&str`.
258///
259/// # The number it hands back may not be the number it was given
260///
261/// RFC 8785 section 3.2.2.3 defers number formatting to ECMAScript, and
262/// ECMAScript has one numeric type. So `admit(b"9007199254740993")` returns
263/// `9007199254740992` and `admit(b"18446744073709551617")` returns
264/// `18446744073709552000`: admitted, canonical, and a different number than the
265/// wire carried. That is the specification working as written rather than a
266/// defect in it -- the RFC's own reference vectors carry `1e30` and
267/// `333333333.33333329`, so a default that refused inexact tokens would fail the
268/// specification this crate implements.
269///
270/// A *producer* canonicalizing before it signs is the caller who cannot live with
271/// that, because it signs the rewritten number. [`admit_ijson`] is the answer:
272/// the RFC 7493 safe-integer profile refuses the token instead of rewriting it.
273/// A verifier that hashes the bytes it received rather than bytes it
274/// re-serialized is unaffected, which is why the profile is opt-in.
275///
276/// # Errors
277///
278/// Returns [`Error`] when the input is not one valid JSON value, repeats an
279/// object member, carries a string that is not a sequence of Unicode scalar
280/// values, nests past [`DEFAULT_MAX_DEPTH`], exceeds [`DEFAULT_MAX_BYTES`], or
281/// carries a number with no finite double.
282pub fn admit(input: &[u8]) -> Result<Vec<u8>, Error> {
283 admit_with(input, &Options::rfc8785())
284}
285
286/// Admit one JSON document under the RFC 7493 I-JSON profile.
287///
288/// # Errors
289///
290/// As [`admit`], and additionally for an integer at or above 2^53 or a Unicode
291/// noncharacter in a string literal.
292pub fn admit_ijson(input: &[u8]) -> Result<Vec<u8>, Error> {
293 admit_with(input, &Options::ijson())
294}
295
296/// Admit one JSON document under explicit options.
297///
298/// # Errors
299///
300/// As [`admit`], under the supplied [`Options`].
301pub fn admit_with(input: &[u8], opts: &Options) -> Result<Vec<u8>, Error> {
302 opts.check_input_length(input.len())?;
303 let node = parse::Parser::new(input, opts).document()?;
304 serde_json_canonicalizer::to_vec(&node).map_err(|e| Error::Canonicalization {
305 detail: e.to_string(),
306 })
307}
308
309/// Whether `input` is admissible AND already its own canonical form.
310///
311/// The question a verifier has. It must hash the bytes it received, not
312/// bytes it re-serialized, so what it needs to know is whether those two are the
313/// same -- and a document that parses but was not stored canonically is exactly
314/// the disagreement a second implementation exists to catch. Neither canonicalizer
315/// on crates.io exposes this; a caller has to canonicalize and compare, and get
316/// the comparison right.
317#[must_use]
318pub fn is_canonical(input: &[u8]) -> bool {
319 is_canonical_with(input, &Options::rfc8785())
320}
321
322/// Whether `input` is admissible and already canonical under `opts`.
323///
324/// [`is_canonical`] answers for RFC 8785 as written, which is the wrong question
325/// for a caller who verifies under a profile: a document carrying a Unicode
326/// noncharacter is canonical by RFC 8785 and refused by [`admit_ijson`], so a
327/// verifier that admits bytes with the plain gate and then verifies them under
328/// the profile has admitted a document its own profile will not accept. Ask with
329/// the options the verify path uses.
330#[must_use]
331pub fn is_canonical_with(input: &[u8], opts: &Options) -> bool {
332 admit_with(input, opts).is_ok_and(|canonical| canonical == input)
333}