vcon/read.rs
1//! Reading and checking a vCon: which form it is in, whether it is
2//! gzipped, its uuid, and whether it needs extensions the caller does not
3//! support. [`inspect`] does no I/O and returns no bytes.
4//!
5//! [`Inspection`], [`Limits`] and [`Rejection`] are `#[non_exhaustive]`, so
6//! a later release can add a field or a refusal without breaking a caller.
7//! A caller reads an [`Inspection`] but does not build one:
8//!
9//! ```compile_fail,E0639
10//! let found = vcon::read::Inspection {
11//! form: vcon::Form::Unsigned,
12//! gzip: false,
13//! uuid: "01a07da8-c2bb-83e5-b9a2-279e0d16bc46".parse().unwrap(),
14//! };
15//! ```
16//!
17//! makes [`Limits`] with [`Limits::new`]:
18//!
19//! ```compile_fail,E0639
20//! let limits = vcon::read::Limits { inflated: 1 << 20 };
21//! ```
22//!
23//! and, matching a [`Rejection`], has an arm for refusals it does not name:
24//!
25//! ```compile_fail,E0004
26//! fn named(r: &vcon::read::Rejection) -> &'static str {
27//! use vcon::read::Rejection::*;
28//! match r {
29//! TooLarge => "too-large", TooDeep => "too-deep", BadGzip => "bad-gzip",
30//! NotJson => "not-json", DuplicateKey => "duplicate", AmbiguousForm => "ambiguous",
31//! NoForm => "no-form", MissingUuid => "missing-uuid", BadUuid => "bad-uuid",
32//! MissingCreatedAt => "missing-created-at", BadPayload => "bad-payload",
33//! UuidMismatch => "uuid-mismatch", BadCritical => "bad-critical",
34//! UnsupportedCritical(_) => "unsupported",
35//! }
36//! }
37//! ```
38
39use std::collections::BTreeSet;
40
41use uuid::Uuid;
42
43use crate::{Form, critical, form, gzip, identity, json, signed};
44
45/// The most signed (JWS) layers a body may have, counting from the body to
46/// the unsigned vCon: one for a vCon signed once. A layer is added each time
47/// a signed vCon is signed again, as the vCon core draft describes for
48/// archives kept longer than their signing certificates; 64 allows one a
49/// year for 63 years. More is refused as [`Rejection::TooDeep`] before the
50/// next layer is read.
51pub const MAX_SIGNED_LAYERS: usize = 64;
52
53/// The most arrays and objects open at once in any one JSON text read: the
54/// body, a signed payload, the vCon itself counting as one. More is refused
55/// as [`Rejection::TooDeep`]. It is below the JSON parser's own bound of 127,
56/// so too deep is told apart from not JSON.
57pub const MAX_JSON_DEPTH: usize = 64;
58
59/// Bounds on the work one inspection may do.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61#[non_exhaustive]
62pub struct Limits {
63 /// The most bytes all gzip inflation in one body may produce, counting
64 /// the body itself and every gzipped payload layer inside it.
65 pub inflated: u64,
66}
67
68impl Limits {
69 /// Limits that let gzip inflation in one body produce at most
70 /// `inflated` bytes.
71 #[must_use]
72 pub const fn new(inflated: u64) -> Limits {
73 Limits { inflated }
74 }
75}
76
77/// What a received vCon is.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79#[non_exhaustive]
80pub struct Inspection {
81 /// Its form.
82 pub form: Form,
83 /// Whether the body as received is gzipped.
84 pub gzip: bool,
85 /// Its uuid: from the vCon itself, from the innermost signed payload, or
86 /// from the unprotected header of an encrypted vCon.
87 pub uuid: Uuid,
88}
89
90/// Reads `body` without changing it. `content_type` is the media type it
91/// arrived with, if any, and `supported` the extension names this server
92/// accepts in `critical`. An encrypted vCon's `critical` cannot be read and
93/// is not checked.
94///
95/// # Errors
96///
97/// A [`Rejection`] naming the first reason found that `body` is not a vCon
98/// this caller can store: too large or too deep, bad gzip, not one JSON
99/// object, a repeated member name, no form or two, a missing or bad uuid, no
100/// created_at, an unreadable signed payload, a uuid that differs between
101/// layers, or a `critical` that is not an array of strings or names
102/// extensions `supported` does not list.
103pub fn inspect(
104 body: &[u8],
105 content_type: Option<&str>,
106 limits: &Limits,
107 supported: &BTreeSet<String>,
108) -> Result<Inspection, Rejection> {
109 let (inflated, gzip) = gzip::inflate(body, content_type, limits.inflated)?;
110 let mut budget = limits.inflated;
111 if gzip {
112 budget -= inflated.len() as u64;
113 }
114 let object = json::parse_object(&inflated)?;
115 let form = form::form_of(&object)?;
116 let uuid = match form {
117 Form::Unsigned => {
118 let uuid = identity::unsigned_identity(&object)?;
119 critical::check_critical(&object, supported)?;
120 uuid
121 }
122 Form::Signed => signed::signed_identity(&object, &mut budget, supported)?,
123 Form::Encrypted => identity::encrypted_identity(&object)?,
124 };
125 Ok(Inspection { form, gzip, uuid })
126}
127
128/// The members of a vCon that an export may copy into clear columns, read
129/// from an unsigned vCon or from the vCon inside a signed one. A member
130/// that is missing, or not of the type the vCon core draft gives it, is
131/// `None`.
132#[derive(Debug, Clone, PartialEq, Eq, Default)]
133#[non_exhaustive]
134pub struct Fields {
135 /// `created_at`, as written.
136 pub created_at: Option<String>,
137 /// `updated_at`, as written.
138 pub updated_at: Option<String>,
139 /// `subject`.
140 pub subject: Option<String>,
141 /// Each entry of `parties`, in order; an entry that is not an object
142 /// has no field.
143 pub parties: Option<Vec<Party>>,
144 /// How many entries `dialog` has.
145 pub dialogs: Option<usize>,
146}
147
148/// The members of one party that [`Fields`] copies.
149#[derive(Debug, Clone, PartialEq, Eq, Default)]
150#[non_exhaustive]
151pub struct Party {
152 /// `tel`, a telephone number.
153 pub tel: Option<String>,
154 /// `mailto`, an email address.
155 pub mailto: Option<String>,
156 /// `name`.
157 pub name: Option<String>,
158}
159
160/// Reads the [`Fields`] of `body`, a vCon [`inspect`] has accepted, within
161/// the same `limits`. An encrypted vCon gives `None`: nothing in it can be
162/// read without its key.
163///
164/// # Errors
165///
166/// The [`Rejection`] `inspect` would give for a body that is too large or
167/// too deep, is bad gzip, or is not a vCon of a known form.
168pub fn fields(body: &[u8], limits: &Limits) -> Result<Option<Fields>, Rejection> {
169 let (inflated, gzip) = gzip::inflate(body, None, limits.inflated)?;
170 let mut budget = limits.inflated;
171 if gzip {
172 budget -= inflated.len() as u64;
173 }
174 let object = json::parse_object(&inflated)?;
175 Ok(match form::form_of(&object)? {
176 Form::Unsigned => Some(crate::fields::fields_of(&object)),
177 Form::Signed => Some(crate::fields::fields_of(
178 &signed::innermost(&object, &mut budget)?.0,
179 )),
180 Form::Encrypted => None,
181 })
182}
183
184/// Why a received body cannot be stored as a vCon.
185#[derive(Debug, Clone, PartialEq, Eq)]
186#[non_exhaustive]
187pub enum Rejection {
188 /// Inflating a gzip body passed the configured limit.
189 TooLarge,
190 /// The body nests deeper than [`MAX_SIGNED_LAYERS`] signed layers, or a
191 /// JSON text in it deeper than [`MAX_JSON_DEPTH`].
192 TooDeep,
193 /// The body is marked as gzip but is not a valid gzip stream.
194 BadGzip,
195 /// The body is not exactly one JSON object.
196 NotJson,
197 /// An object in the body repeats a member name.
198 DuplicateKey,
199 /// The body has the members of both the signed and the encrypted form.
200 AmbiguousForm,
201 /// The body has the members of no form.
202 NoForm,
203 /// The vCon has no uuid.
204 MissingUuid,
205 /// The uuid is not a string in RFC 9562 text form.
206 BadUuid,
207 /// The vCon has no created_at.
208 MissingCreatedAt,
209 /// A signed vCon's payload or signatures cannot be read as a JWS
210 /// carrying a vCon.
211 BadPayload,
212 /// A `uuid` in a JWS header differs from the uuid of the signed vCon.
213 UuidMismatch,
214 /// `critical` or `must_support` is not an array of strings.
215 BadCritical,
216 /// `critical` or `must_support` names extensions this server does not
217 /// support; they are listed in order of first appearance.
218 UnsupportedCritical(Vec<String>),
219}
220
221#[cfg(test)]
222mod tests {
223 use std::io::Write;
224
225 use base64::Engine;
226 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
227 use flate2::Compression;
228 use flate2::write::GzEncoder;
229 use serde_json::{Value, json};
230
231 use super::*;
232
233 // Here, not in tests/inspect.rs: Rejection is #[non_exhaustive], so
234 // only a match inside the crate can leave out the catch-all arm.
235 const ID: &str = "01a07da8-c2bb-83e5-b9a2-279e0d16bc46";
236 const LIMITS: Limits = Limits::new(1 << 20);
237
238 fn gz(bytes: &[u8]) -> Vec<u8> {
239 let mut e = GzEncoder::new(Vec::new(), Compression::default());
240 e.write_all(bytes).expect("compress");
241 e.finish().expect("finish")
242 }
243
244 fn b64(bytes: &[u8]) -> String {
245 URL_SAFE_NO_PAD.encode(bytes)
246 }
247
248 fn unsigned() -> Value {
249 json!({"uuid": ID, "created_at": "2026-10-23T00:00:00Z", "parties": []})
250 }
251
252 fn run(body: &[u8]) -> Result<(Form, bool, Uuid), Rejection> {
253 inspect(body, None, &LIMITS, &BTreeSet::new()).map(|i| (i.form, i.gzip, i.uuid))
254 }
255
256 /// A body `inspect` refuses with `rejection`. The match has no catch-all
257 /// arm, so a new Rejection fails to compile here until it has a body.
258 fn refused_as(rejection: &Rejection) -> Vec<u8> {
259 match rejection {
260 Rejection::TooLarge => gz(&vec![b' '; (LIMITS.inflated + 1) as usize]),
261 Rejection::TooDeep => format!(
262 "{}{}",
263 "[".repeat(MAX_JSON_DEPTH + 1),
264 "]".repeat(MAX_JSON_DEPTH + 1)
265 )
266 .into_bytes(),
267 Rejection::BadGzip => vec![0x1f, 0x8b, 0],
268 Rejection::NotJson => b"[]".to_vec(),
269 Rejection::DuplicateKey => br#"{"uuid": "a", "uuid": "b"}"#.to_vec(),
270 Rejection::AmbiguousForm => {
271 br#"{"payload": "", "signatures": [], "ciphertext": "", "recipients": []}"#.to_vec()
272 }
273 Rejection::NoForm => b"{}".to_vec(),
274 Rejection::MissingUuid => br#"{"parties": []}"#.to_vec(),
275 Rejection::BadUuid => br#"{"uuid": "x"}"#.to_vec(),
276 Rejection::MissingCreatedAt => format!(r#"{{"uuid": "{ID}"}}"#).into_bytes(),
277 Rejection::BadPayload => br#"{"payload": "%", "signatures": []}"#.to_vec(),
278 Rejection::UuidMismatch => json!({
279 "payload": b64(unsigned().to_string().as_bytes()),
280 "signatures": [{"header": {"uuid": "01a07da8-c2bb-83e5-b9a2-279e0d16bc47"}}]
281 })
282 .to_string()
283 .into_bytes(),
284 Rejection::BadCritical => {
285 format!(r#"{{"uuid": "{ID}", "created_at": "x", "critical": 1}}"#).into_bytes()
286 }
287 Rejection::UnsupportedCritical(names) => {
288 json!({"uuid": ID, "created_at": "x", "critical": names})
289 .to_string()
290 .into_bytes()
291 }
292 }
293 }
294
295 #[test]
296 fn every_rejection_is_reachable() {
297 // Every variant, each once; refused_as names each of them too.
298 let every = [
299 Rejection::TooLarge,
300 Rejection::TooDeep,
301 Rejection::BadGzip,
302 Rejection::NotJson,
303 Rejection::DuplicateKey,
304 Rejection::AmbiguousForm,
305 Rejection::NoForm,
306 Rejection::MissingUuid,
307 Rejection::BadUuid,
308 Rejection::MissingCreatedAt,
309 Rejection::BadPayload,
310 Rejection::UuidMismatch,
311 Rejection::BadCritical,
312 Rejection::UnsupportedCritical(vec!["x".to_owned()]),
313 ];
314 for rejection in every {
315 assert_eq!(
316 run(&refused_as(&rejection)),
317 Err(rejection.clone()),
318 "refused_as gives a body that only this rejection fits"
319 );
320 }
321 }
322}