Skip to main content

rite_model/validate/
mod.rs

1//! Check a JSON value against a generated schema.
2//!
3//! The engine is behind this module: nothing outside it sees how a schema is
4//! held, only [`Violation`]s. Generated schemas keep to
5//! [`params::KEYWORDS`](crate::params::KEYWORDS), the engine checks those
6//! keywords alone, and [`Keyword`] has a variant for each that can reject a
7//! value.
8
9use std::fmt;
10
11use serde_json::Value;
12
13mod engine;
14mod message;
15
16pub use message::{Anchor, ParamsCheck, Values, params_check};
17
18/// A compiled schema.
19pub struct Validator {
20    engine: engine::Engine,
21}
22
23/// A schema the engine could not compile, which is a mistake in the
24/// generator rather than in a ceremony.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct SchemaError(String);
27
28impl fmt::Display for SchemaError {
29    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30        write!(f, "invalid schema: {}", self.0)
31    }
32}
33
34impl std::error::Error for SchemaError {}
35
36impl Validator {
37    /// Compile a draft 2020-12 schema that keeps to
38    /// [`params::KEYWORDS`](crate::params::KEYWORDS).
39    ///
40    /// # Errors
41    ///
42    /// Returns where the schema is not one, or uses a keyword outside that
43    /// set.
44    pub fn new(schema: &Value) -> Result<Self, SchemaError> {
45        engine::Engine::new(schema).map(|engine| Validator { engine })
46    }
47
48    /// Every way `instance` fails the schema, in the order the engine finds
49    /// them. Empty when it passes.
50    #[must_use]
51    pub fn validate(&self, instance: &Value) -> Vec<Violation> {
52        self.engine.validate(instance)
53    }
54}
55
56/// One way a value fails a schema.
57#[derive(Debug, Clone, PartialEq)]
58#[non_exhaustive]
59pub struct Violation {
60    /// Where in the value: the value that failed, or for `required` and
61    /// `additionalProperties` the object holding the keys.
62    pub instance_path: Pointer,
63    /// The keyword that rejected it, as a location in the schema document.
64    /// A keyword inside a definition is located there, not where it was
65    /// referenced from.
66    pub schema_path: Pointer,
67    /// The keyword and what it asked for.
68    pub keyword: Keyword,
69}
70
71/// A keyword that rejected a value, and what it asked for.
72#[derive(Debug, Clone, PartialEq)]
73#[non_exhaustive]
74pub enum Keyword {
75    /// `type`: the value is none of these types.
76    Type {
77        /// The JSON types accepted, as JSON Schema names them.
78        expected: Vec<String>,
79    },
80    /// `enum`: the value is none of these.
81    Enum {
82        /// The values accepted.
83        options: Vec<Value>,
84    },
85    /// `const`: the value is not this one.
86    Const {
87        /// The value accepted.
88        expected: Value,
89    },
90    /// `pattern`: the string does not match.
91    Pattern {
92        /// The regular expression.
93        pattern: String,
94    },
95    /// `minimum`: the number is smaller.
96    Minimum {
97        /// The smallest accepted.
98        limit: Value,
99    },
100    /// `maximum`: the number is larger.
101    Maximum {
102        /// The largest accepted.
103        limit: Value,
104    },
105    /// `minItems`: the list is shorter.
106    MinItems {
107        /// The fewest items accepted.
108        limit: u64,
109    },
110    /// `required`: the object lacks this key.
111    Required {
112        /// The missing key.
113        property: String,
114    },
115    /// `additionalProperties`: the object has keys the schema does not name.
116    AdditionalProperties {
117        /// The keys, in the order the object holds them.
118        unexpected: Vec<String>,
119    },
120    /// `oneOf`: the value matched none of the alternatives, or several.
121    OneOf {
122        /// Which of the two.
123        matched: Matched,
124    },
125    /// `not`: the value matched what it must not.
126    Not,
127    /// A `false` schema: no value is accepted here.
128    False,
129}
130
131/// How many of a `oneOf`'s alternatives a value matched, when it was not
132/// exactly one.
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134#[non_exhaustive]
135pub enum Matched {
136    /// None of them.
137    None,
138    /// More than one.
139    Several,
140}
141
142impl Keyword {
143    /// The JSON Schema name of the keyword, a stable code for machine
144    /// output.
145    #[must_use]
146    pub fn name(&self) -> &str {
147        match self {
148            Keyword::Type { .. } => "type",
149            Keyword::Enum { .. } => "enum",
150            Keyword::Const { .. } => "const",
151            Keyword::Pattern { .. } => "pattern",
152            Keyword::Minimum { .. } => "minimum",
153            Keyword::Maximum { .. } => "maximum",
154            Keyword::MinItems { .. } => "minItems",
155            Keyword::Required { .. } => "required",
156            Keyword::AdditionalProperties { .. } => "additionalProperties",
157            Keyword::OneOf { .. } => "oneOf",
158            Keyword::Not => "not",
159            Keyword::False => "false",
160        }
161    }
162}
163
164/// A JSON pointer (RFC 6901), held as its segments.
165#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
166pub struct Pointer(Vec<String>);
167
168impl Pointer {
169    /// The pointer to the whole document.
170    #[must_use]
171    pub fn root() -> Self {
172        Pointer(Vec::new())
173    }
174
175    /// Read a pointer such as `/with/san/0`. The empty string is the root.
176    #[must_use]
177    pub fn parse(pointer: &str) -> Self {
178        Pointer(
179            pointer
180                .split('/')
181                .skip(1)
182                .map(|segment| segment.replace("~1", "/").replace("~0", "~"))
183                .collect(),
184        )
185    }
186
187    /// The keys and indexes from the root, in order.
188    #[must_use]
189    pub fn segments(&self) -> &[String] {
190        &self.0
191    }
192
193    /// This pointer with one more segment.
194    #[must_use]
195    pub fn join(&self, segment: &str) -> Self {
196        let mut segments = self.0.clone();
197        segments.push(segment.to_string());
198        Pointer(segments)
199    }
200
201    /// The pointer one segment up, or `None` at the root.
202    #[must_use]
203    pub fn parent(&self) -> Option<Self> {
204        let (_, parent) = self.0.split_last()?;
205        Some(Pointer(parent.to_vec()))
206    }
207
208    /// Whether `self` is `other` or inside it.
209    #[must_use]
210    pub fn starts_with(&self, other: &Pointer) -> bool {
211        self.0.starts_with(&other.0)
212    }
213
214    /// The value this pointer names in `document`, if there is one.
215    #[must_use]
216    pub fn lookup<'a>(&self, document: &'a Value) -> Option<&'a Value> {
217        self.0
218            .iter()
219            .try_fold(document, |value, segment| match value {
220                Value::Object(object) => object.get(segment),
221                Value::Array(items) => segment.parse::<usize>().ok().and_then(|i| items.get(i)),
222                _ => None,
223            })
224    }
225}
226
227impl fmt::Display for Pointer {
228    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
229        for segment in &self.0 {
230            write!(f, "/{}", segment.replace('~', "~0").replace('/', "~1"))?;
231        }
232        Ok(())
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use crate::ActionType;
240    use crate::params::with_schema;
241    use serde_json::json;
242
243    fn violations(action: ActionType, with: &Value) -> Vec<Violation> {
244        Validator::new(&with_schema(action).to_value())
245            .unwrap()
246            .validate(with)
247    }
248
249    fn keywords(action: ActionType, with: &Value) -> Vec<(String, Keyword)> {
250        violations(action, with)
251            .into_iter()
252            .map(|v| (v.instance_path.to_string(), v.keyword))
253            .collect()
254    }
255
256    #[test]
257    fn every_action_schema_compiles() {
258        for &action in ActionType::ALL {
259            Validator::new(&with_schema(action).to_value())
260                .unwrap_or_else(|e| panic!("{action}: {e}"));
261        }
262    }
263
264    #[test]
265    fn a_valid_block_has_no_violations() {
266        assert!(
267            violations(ActionType::GenerateKey, &json!({"algorithm": "ECDSA-P256"})).is_empty()
268        );
269        assert!(violations(ActionType::Confirm, &json!({})).is_empty());
270        assert!(violations(ActionType::ExportPublic, &json!({})).is_empty());
271    }
272
273    #[test]
274    fn each_keyword_is_reported_as_itself() {
275        let found = keywords(ActionType::GenerateKey, &json!({"algorithm": "RSA-9999"}));
276        assert!(matches!(found.as_slice(), [(path, Keyword::Enum { .. })] if path == "/algorithm"));
277
278        let found = keywords(
279            ActionType::IssueCertificate,
280            &json!({"validity_days": "ten"}),
281        );
282        assert_eq!(
283            found,
284            [(
285                "/validity_days".to_string(),
286                Keyword::Type {
287                    expected: vec!["integer".to_string()]
288                }
289            )]
290        );
291
292        let found = keywords(ActionType::CheckValue, &json!({"actual": "x"}));
293        assert_eq!(
294            found,
295            [(
296                String::new(),
297                Keyword::Required {
298                    property: "expected".to_string()
299                }
300            )]
301        );
302
303        let found = keywords(ActionType::Confirm, &json!({"statement": "x"}));
304        assert_eq!(
305            found,
306            [(
307                String::new(),
308                Keyword::AdditionalProperties {
309                    unexpected: vec!["statement".to_string()]
310                }
311            )]
312        );
313
314        let found = keywords(
315            ActionType::WrapKey,
316            &json!({"expect_recipient": "deadbeef"}),
317        );
318        assert!(
319            matches!(found.as_slice(), [(path, Keyword::Pattern { .. })] if path == "/expect_recipient")
320        );
321
322        // The cap over every scheme and the default scheme's limit are both
323        // 100, and the engine reports each rule.
324        let found = keywords(
325            ActionType::SplitSecret,
326            &json!({"threshold": 2, "shares": 101}),
327        );
328        let maximum = (
329            "/shares".to_string(),
330            Keyword::Maximum { limit: json!(100) },
331        );
332        assert_eq!(found, [maximum.clone(), maximum]);
333
334        let found = keywords(
335            ActionType::EnterSecret,
336            &json!({"message": "m", "format": "wibble"}),
337        );
338        assert_eq!(
339            found,
340            [(
341                "/format".to_string(),
342                Keyword::OneOf {
343                    matched: Matched::None
344                }
345            )]
346        );
347
348        let found = keywords(
349            ActionType::EnterSecret,
350            &json!({"message": "m", "length": 4, "min_length": 2}),
351        );
352        assert_eq!(found, [(String::new(), Keyword::Not)]);
353    }
354
355    /// A keyword inside a definition is located in the definition, which is
356    /// where its annotations are.
357    #[test]
358    fn the_schema_path_is_where_the_keyword_is_written() {
359        let [violation] = violations(ActionType::GenerateKey, &json!({"algorithm": "RSA-9999"}))
360            .try_into()
361            .unwrap();
362        assert_eq!(
363            violation.schema_path.to_string(),
364            "/$defs/KeyAlgorithm/enum"
365        );
366    }
367
368    #[test]
369    fn a_pointer_round_trips_and_escapes() {
370        for text in ["", "/with", "/with/san/0", "/a~1b/c~0d"] {
371            assert_eq!(Pointer::parse(text).to_string(), text);
372        }
373        assert_eq!(Pointer::parse("/a~1b").segments(), ["a/b"]);
374        let document = json!({"with": {"san": ["DNS:x"]}});
375        assert_eq!(
376            Pointer::parse("/with/san/0").lookup(&document),
377            Some(&json!("DNS:x"))
378        );
379        assert!(Pointer::parse("/with/san/0").starts_with(&Pointer::parse("/with")));
380        assert!(!Pointer::parse("/without").starts_with(&Pointer::parse("/with")));
381    }
382}