1use std::fmt;
10
11use serde_json::Value;
12
13mod engine;
14mod message;
15
16pub use message::{Anchor, ParamsCheck, Values, params_check};
17
18pub struct Validator {
20 engine: engine::Engine,
21}
22
23#[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 pub fn new(schema: &Value) -> Result<Self, SchemaError> {
45 engine::Engine::new(schema).map(|engine| Validator { engine })
46 }
47
48 #[must_use]
51 pub fn validate(&self, instance: &Value) -> Vec<Violation> {
52 self.engine.validate(instance)
53 }
54}
55
56#[derive(Debug, Clone, PartialEq)]
58#[non_exhaustive]
59pub struct Violation {
60 pub instance_path: Pointer,
63 pub schema_path: Pointer,
67 pub keyword: Keyword,
69}
70
71#[derive(Debug, Clone, PartialEq)]
73#[non_exhaustive]
74pub enum Keyword {
75 Type {
77 expected: Vec<String>,
79 },
80 Enum {
82 options: Vec<Value>,
84 },
85 Const {
87 expected: Value,
89 },
90 Pattern {
92 pattern: String,
94 },
95 Minimum {
97 limit: Value,
99 },
100 Maximum {
102 limit: Value,
104 },
105 MinItems {
107 limit: u64,
109 },
110 Required {
112 property: String,
114 },
115 AdditionalProperties {
117 unexpected: Vec<String>,
119 },
120 OneOf {
122 matched: Matched,
124 },
125 Not,
127 False,
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134#[non_exhaustive]
135pub enum Matched {
136 None,
138 Several,
140}
141
142impl Keyword {
143 #[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#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
166pub struct Pointer(Vec<String>);
167
168impl Pointer {
169 #[must_use]
171 pub fn root() -> Self {
172 Pointer(Vec::new())
173 }
174
175 #[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 #[must_use]
189 pub fn segments(&self) -> &[String] {
190 &self.0
191 }
192
193 #[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 #[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 #[must_use]
210 pub fn starts_with(&self, other: &Pointer) -> bool {
211 self.0.starts_with(&other.0)
212 }
213
214 #[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 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 #[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}