Skip to main content

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}