jcs_admit/error.rs
1//! The single error type every entry point returns.
2//!
3//! Every variant names a fault a caller can branch on, and that is half the
4//! reason this crate exists. The canonicalizer it delegates to reports what it
5//! does refuse as an untyped `serde_json::Error` -- measured 2026-09-19, a lone
6//! surrogate escape and nesting past its limit and a number out of range all
7//! arrive as one opaque type carrying a line and a column -- so a verifier
8//! reading it cannot treat a split-view document differently from a typo, and no
9//! refusal names the cap it crossed or the member it repeated.
10
11use core::fmt;
12
13/// Why a document was refused.
14///
15/// Every variant is a refusal, never a repair. A canonicalizer that repairs its
16/// input produces bytes two parties can disagree about, which is the whole
17/// failure canonicalization exists to remove.
18#[derive(Debug, Clone, PartialEq, Eq)]
19#[cfg_attr(feature = "serde", derive(serde::Serialize))]
20#[cfg_attr(feature = "serde", serde(tag = "variant", content = "fields"))]
21#[non_exhaustive]
22pub enum Error {
23 /// The bytes are not one syntactically valid JSON value.
24 Syntax {
25 /// Byte offset where the scan stopped.
26 offset: usize,
27 /// What the parser expected there.
28 reason: &'static str,
29 },
30 /// A complete JSON value was followed by something other than whitespace.
31 TrailingContent {
32 /// Byte offset of the first unexpected byte.
33 offset: usize,
34 },
35 /// An object repeated a member name.
36 ///
37 /// Collapsing a repeat to last-wins is a split-view attack: two parties
38 /// read the same bytes as two different documents, and a signature over
39 /// either reading verifies.
40 DuplicateMember {
41 /// The repeated member name.
42 name: String,
43 /// Byte offset of the repeated name's opening quote.
44 offset: usize,
45 },
46 /// A string literal's bytes do not denote a sequence of Unicode scalar
47 /// values: an unpaired surrogate escape, a surrogate escape pair that is
48 /// not high-then-low, a raw control character, invalid UTF-8 (which
49 /// includes an overlong form and a surrogate encoded directly in UTF-8),
50 /// or, under the I-JSON profile, a Unicode noncharacter.
51 ///
52 /// The check runs on the raw bytes before any decode, because a decoder
53 /// cannot report it afterwards: a decoder that substitutes U+FFFD leaves
54 /// three distinct wire strings canonicalizing to identical bytes.
55 StringNotScalar {
56 /// Byte offset inside the document.
57 offset: usize,
58 /// Which fault was found.
59 reason: &'static str,
60 },
61 /// An integer outside the range where an IEEE-754 double is exact,
62 /// refused under the I-JSON safe-integer profile.
63 UnsafeInteger {
64 /// The offending number token, as written.
65 token: String,
66 },
67 /// A number with a fractional part, refused under the integers-only
68 /// tightening of the I-JSON profile.
69 NonIntegerNumber {
70 /// The offending number token, as written.
71 token: String,
72 },
73 /// A number token whose value is not finite as an IEEE-754 double, so
74 /// RFC 8785 has no serialization for it.
75 NonFiniteNumber {
76 /// The offending number token, as written.
77 token: String,
78 },
79 /// Nesting exceeded the configured depth cap.
80 ///
81 /// The cap refuses rather than recursing. Without it a crafted document
82 /// overflows the stack, which is not catchable and happens before any
83 /// signature is checked.
84 TooDeep {
85 /// The cap that was exceeded.
86 limit: usize,
87 /// Byte offset of the container that would have crossed it.
88 offset: usize,
89 },
90 /// The delegate refused to serialize a document this crate admitted.
91 ///
92 /// Unreachable for an admitted document, and present rather than a panic:
93 /// `serde_json_canonicalizer` returns a `serde_json::Error` that is neither
94 /// `Clone` nor `Eq`, so it is carried as its rendered text. A caller that
95 /// sees this variant is looking at a disagreement between the admission
96 /// layer and the canonicalizer, which is a defect in one of them and not a
97 /// statement about the input.
98 Canonicalization {
99 /// What the delegate reported, rendered.
100 detail: String,
101 },
102 /// The input exceeded the configured size cap.
103 ///
104 /// A depth cap bounds the stack; a size cap bounds the heap. Both are
105 /// needed, because a depth limit alone leaves memory use proportional to
106 /// input length.
107 TooLarge {
108 /// Input length in bytes.
109 len: usize,
110 /// The cap that was exceeded.
111 limit: usize,
112 },
113}
114
115/// How much attacker-chosen text one refusal message may carry.
116///
117/// Long enough that no honest member name or number token is clipped, short
118/// enough that whoever sent a document cannot choose the size of the
119/// recipient's log line. Measured before this bound existed: a 2 MiB document
120/// repeating a 1 MiB member name rendered a 1,048,618-character message, and a
121/// verifier that logs why it refused a document is the ordinary case, so the
122/// amplification sits on the path that matters.
123const RENDER_BUDGET: usize = 96;
124
125/// Render `value` for a message, escaped and bounded.
126///
127/// Escaping first and clipping second is the order that matters. `Debug` for a
128/// string is what keeps a member name carrying a newline from adding a line to
129/// someone's log, and escaping can multiply the length -- ninety control
130/// characters are ninety bytes and four hundred and fifty rendered -- so a bound
131/// applied before it is not a bound on what gets written.
132fn clipped(value: &str) -> String {
133 let escaped = format!("{value:?}");
134 if escaped.len() <= RENDER_BUDGET {
135 return escaped;
136 }
137 let end = (0..=RENDER_BUDGET)
138 .rev()
139 .find(|i| escaped.is_char_boundary(*i))
140 .unwrap_or(0);
141 format!(
142 "{}... [{} bytes elided]",
143 &escaped[..end],
144 value.len().saturating_sub(end)
145 )
146}
147
148impl fmt::Display for Error {
149 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150 match self {
151 Self::Syntax { offset, reason } => {
152 write!(f, "invalid JSON at byte {offset}: {reason}")
153 }
154 Self::TrailingContent { offset } => {
155 write!(f, "trailing content after the JSON value at byte {offset}")
156 }
157 Self::DuplicateMember { name, offset } => {
158 write!(
159 f,
160 "duplicate object member {} at byte {offset}",
161 clipped(name)
162 )
163 }
164 Self::StringNotScalar { offset, reason } => write!(
165 f,
166 "JSON string is not a sequence of Unicode scalar values at byte {offset}: {reason}"
167 ),
168 Self::UnsafeInteger { token } => {
169 write!(
170 f,
171 "integer outside the I-JSON safe range: {}",
172 clipped(token)
173 )
174 }
175 Self::NonIntegerNumber { token } => {
176 write!(
177 f,
178 "non-integer number outside the integers-only profile: {}",
179 clipped(token)
180 )
181 }
182 Self::NonFiniteNumber { token } => {
183 write!(
184 f,
185 "number is not a finite IEEE-754 double: {}",
186 clipped(token)
187 )
188 }
189 Self::TooDeep { limit, offset } => {
190 write!(
191 f,
192 "JSON nesting exceeds the maximum depth {limit} at byte {offset}"
193 )
194 }
195 Self::TooLarge { len, limit } => {
196 write!(f, "JSON input of {len} bytes exceeds the maximum {limit}")
197 }
198 Self::Canonicalization { detail } => {
199 write!(
200 f,
201 "canonicalization of an admitted document failed: {detail}"
202 )
203 }
204 }
205 }
206}
207
208impl std::error::Error for Error {}