use std::fmt;
use serde_json::Value;
mod engine;
mod message;
pub use message::{Anchor, ParamsCheck, Values, params_check};
pub struct Validator {
engine: engine::Engine,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SchemaError(String);
impl fmt::Display for SchemaError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "invalid schema: {}", self.0)
}
}
impl std::error::Error for SchemaError {}
impl Validator {
pub fn new(schema: &Value) -> Result<Self, SchemaError> {
engine::Engine::new(schema).map(|engine| Validator { engine })
}
#[must_use]
pub fn validate(&self, instance: &Value) -> Vec<Violation> {
self.engine.validate(instance)
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct Violation {
pub instance_path: Pointer,
pub schema_path: Pointer,
pub keyword: Keyword,
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum Keyword {
Type {
expected: Vec<String>,
},
Enum {
options: Vec<Value>,
},
Const {
expected: Value,
},
Pattern {
pattern: String,
},
Minimum {
limit: Value,
},
Maximum {
limit: Value,
},
MinItems {
limit: u64,
},
Required {
property: String,
},
AdditionalProperties {
unexpected: Vec<String>,
},
OneOf {
matched: Matched,
},
Not,
False,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Matched {
None,
Several,
}
impl Keyword {
#[must_use]
pub fn name(&self) -> &str {
match self {
Keyword::Type { .. } => "type",
Keyword::Enum { .. } => "enum",
Keyword::Const { .. } => "const",
Keyword::Pattern { .. } => "pattern",
Keyword::Minimum { .. } => "minimum",
Keyword::Maximum { .. } => "maximum",
Keyword::MinItems { .. } => "minItems",
Keyword::Required { .. } => "required",
Keyword::AdditionalProperties { .. } => "additionalProperties",
Keyword::OneOf { .. } => "oneOf",
Keyword::Not => "not",
Keyword::False => "false",
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Pointer(Vec<String>);
impl Pointer {
#[must_use]
pub fn root() -> Self {
Pointer(Vec::new())
}
#[must_use]
pub fn parse(pointer: &str) -> Self {
Pointer(
pointer
.split('/')
.skip(1)
.map(|segment| segment.replace("~1", "/").replace("~0", "~"))
.collect(),
)
}
#[must_use]
pub fn segments(&self) -> &[String] {
&self.0
}
#[must_use]
pub fn join(&self, segment: &str) -> Self {
let mut segments = self.0.clone();
segments.push(segment.to_string());
Pointer(segments)
}
#[must_use]
pub fn parent(&self) -> Option<Self> {
let (_, parent) = self.0.split_last()?;
Some(Pointer(parent.to_vec()))
}
#[must_use]
pub fn starts_with(&self, other: &Pointer) -> bool {
self.0.starts_with(&other.0)
}
#[must_use]
pub fn lookup<'a>(&self, document: &'a Value) -> Option<&'a Value> {
self.0
.iter()
.try_fold(document, |value, segment| match value {
Value::Object(object) => object.get(segment),
Value::Array(items) => segment.parse::<usize>().ok().and_then(|i| items.get(i)),
_ => None,
})
}
}
impl fmt::Display for Pointer {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for segment in &self.0 {
write!(f, "/{}", segment.replace('~', "~0").replace('/', "~1"))?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ActionType;
use crate::params::with_schema;
use serde_json::json;
fn violations(action: ActionType, with: &Value) -> Vec<Violation> {
Validator::new(&with_schema(action).to_value())
.unwrap()
.validate(with)
}
fn keywords(action: ActionType, with: &Value) -> Vec<(String, Keyword)> {
violations(action, with)
.into_iter()
.map(|v| (v.instance_path.to_string(), v.keyword))
.collect()
}
#[test]
fn every_action_schema_compiles() {
for &action in ActionType::ALL {
Validator::new(&with_schema(action).to_value())
.unwrap_or_else(|e| panic!("{action}: {e}"));
}
}
#[test]
fn a_valid_block_has_no_violations() {
assert!(
violations(ActionType::GenerateKey, &json!({"algorithm": "ECDSA-P256"})).is_empty()
);
assert!(violations(ActionType::Confirm, &json!({})).is_empty());
assert!(violations(ActionType::ExportPublic, &json!({})).is_empty());
}
#[test]
fn each_keyword_is_reported_as_itself() {
let found = keywords(ActionType::GenerateKey, &json!({"algorithm": "RSA-9999"}));
assert!(matches!(found.as_slice(), [(path, Keyword::Enum { .. })] if path == "/algorithm"));
let found = keywords(
ActionType::IssueCertificate,
&json!({"validity_days": "ten"}),
);
assert_eq!(
found,
[(
"/validity_days".to_string(),
Keyword::Type {
expected: vec!["integer".to_string()]
}
)]
);
let found = keywords(ActionType::CheckValue, &json!({"actual": "x"}));
assert_eq!(
found,
[(
String::new(),
Keyword::Required {
property: "expected".to_string()
}
)]
);
let found = keywords(ActionType::Confirm, &json!({"statement": "x"}));
assert_eq!(
found,
[(
String::new(),
Keyword::AdditionalProperties {
unexpected: vec!["statement".to_string()]
}
)]
);
let found = keywords(
ActionType::WrapKey,
&json!({"expect_recipient": "deadbeef"}),
);
assert!(
matches!(found.as_slice(), [(path, Keyword::Pattern { .. })] if path == "/expect_recipient")
);
let found = keywords(
ActionType::SplitSecret,
&json!({"threshold": 2, "shares": 101}),
);
let maximum = (
"/shares".to_string(),
Keyword::Maximum { limit: json!(100) },
);
assert_eq!(found, [maximum.clone(), maximum]);
let found = keywords(
ActionType::EnterSecret,
&json!({"message": "m", "format": "wibble"}),
);
assert_eq!(
found,
[(
"/format".to_string(),
Keyword::OneOf {
matched: Matched::None
}
)]
);
let found = keywords(
ActionType::EnterSecret,
&json!({"message": "m", "length": 4, "min_length": 2}),
);
assert_eq!(found, [(String::new(), Keyword::Not)]);
}
#[test]
fn the_schema_path_is_where_the_keyword_is_written() {
let [violation] = violations(ActionType::GenerateKey, &json!({"algorithm": "RSA-9999"}))
.try_into()
.unwrap();
assert_eq!(
violation.schema_path.to_string(),
"/$defs/KeyAlgorithm/enum"
);
}
#[test]
fn a_pointer_round_trips_and_escapes() {
for text in ["", "/with", "/with/san/0", "/a~1b/c~0d"] {
assert_eq!(Pointer::parse(text).to_string(), text);
}
assert_eq!(Pointer::parse("/a~1b").segments(), ["a/b"]);
let document = json!({"with": {"san": ["DNS:x"]}});
assert_eq!(
Pointer::parse("/with/san/0").lookup(&document),
Some(&json!("DNS:x"))
);
assert!(Pointer::parse("/with/san/0").starts_with(&Pointer::parse("/with")));
assert!(!Pointer::parse("/without").starts_with(&Pointer::parse("/with")));
}
}