Skip to main content

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 {}