Skip to main content

ledvar_core/
wellformed.rs

1//! Well-formedness checking (SPEC §9). A property, exposed as one function.
2
3use crate::error::Error;
4use crate::model::{Node, SUPPORTED_PROTOCOL_MAJOR, SUPPORTED_PROTOCOL_MINOR, Snapshot};
5use crate::version;
6use std::collections::HashSet;
7
8/// Validate that a snapshot is well-formed (SPEC §9): supported version, every node well-formed
9/// (see [`validate_node`]), and all paths unique within the snapshot.
10///
11/// The rules the Rust types already guarantee are enforced *earlier*, at deserialization, and are
12/// deliberately not re-checked here: string-ness (every field is `String`), valid Unicode (a Rust
13/// `String` is always UTF-8, and `serde_json` rejects an unpaired surrogate at parse), `content`
14/// being an object (a non-object fails to deserialize into a map), typed metadata (`timestamp: i64`,
15/// `labels: BTreeMap<String, String>`, a `Ref` with both fields), and duplicate object keys (the
16/// strict map deserializer in `model`). SPEC §9 allows refusing at parse *or* here. This function
17/// covers exactly what the types cannot express.
18pub fn validate(snapshot: &Snapshot) -> Result<(), Error> {
19    let (major, minor, _patch) = version::parse(&snapshot.protocol_version)?;
20    if major != SUPPORTED_PROTOCOL_MAJOR {
21        return Err(Error::UnsupportedMajor(major));
22    }
23    // SPEC §10: while MAJOR is 0, a MINOR bump may move the canonical form, so the exact MINOR is
24    // contract-significant — reject a differing one rather than silently accept another hash universe.
25    if major == 0 && minor != SUPPORTED_PROTOCOL_MINOR {
26        return Err(Error::UnsupportedMinor(minor));
27    }
28
29    // Uniqueness is over the `path` itself (identity) — compare paths directly rather than hashing
30    // each node, which is cheaper and avoids computing hashes for a snapshot that may yet prove
31    // ill-formed at a later node.
32    let mut seen: HashSet<&Vec<String>> = HashSet::with_capacity(snapshot.tree.len());
33    for (i, node) in snapshot.tree.iter().enumerate() {
34        validate_node(node, i)?;
35        if !seen.insert(&node.path) {
36            return Err(Error::DuplicatePath(node.path.clone()));
37        }
38    }
39    Ok(())
40}
41
42/// Well-formedness of a single node (SPEC §9), independent of any snapshot: a non-empty `path` with
43/// no empty segment, no empty attribute name, and no empty value set. `index` is used only for error
44/// reporting (pass `0` for a bare node). Called by [`validate`] per node, and by
45/// [`Node::identity_key`] / [`Node::content_hash`] themselves, so an ill-formed node is never
46/// hashed no matter how the caller reaches the hash (SPEC §9: an implementation MUST NOT hash an
47/// ill-formed Snapshot).
48pub fn validate_node(node: &Node, index: usize) -> Result<(), Error> {
49    if node.path.is_empty() {
50        return Err(Error::EmptyPath(index));
51    }
52    if node.path.iter().any(|seg| seg.is_empty()) {
53        return Err(Error::EmptyPathSegment(index));
54    }
55    for (name, values) in &node.content {
56        if name.is_empty() {
57            return Err(Error::EmptyAttrName(index));
58        }
59        if values.is_empty() {
60            return Err(Error::EmptyValueSet(index, name.clone()));
61        }
62    }
63    Ok(())
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69    use crate::model::Node;
70    use std::collections::{BTreeMap, BTreeSet};
71
72    fn snap(version: &str, nodes: Vec<Node>) -> Snapshot {
73        Snapshot {
74            protocol_version: version.to_string(),
75            origin_id: "o".into(),
76            provider_name: "p".into(),
77            timestamp: 0,
78            fingerprint: None,
79            parent_origin_id: None,
80            labels: BTreeMap::new(),
81            tree: nodes,
82        }
83    }
84
85    fn node(path: &[&str]) -> Node {
86        Node {
87            path: path.iter().map(|s| s.to_string()).collect(),
88            content: BTreeMap::new(),
89            labels: BTreeMap::new(),
90            refs: Vec::new(),
91        }
92    }
93
94    #[test]
95    fn accepts_well_formed() {
96        let s = snap("0.1.0", vec![node(&["a"]), node(&["b"])]);
97        assert!(validate(&s).is_ok());
98    }
99
100    #[test]
101    fn rejects_unsupported_major() {
102        let s = snap("1.0.0", vec![node(&["a"])]);
103        assert_eq!(validate(&s), Err(Error::UnsupportedMajor(1)));
104    }
105
106    #[test]
107    fn rejects_bad_version() {
108        let s = snap("nope", vec![node(&["a"])]);
109        assert!(matches!(validate(&s), Err(Error::BadVersion(_))));
110    }
111
112    #[test]
113    fn rejects_empty_path() {
114        let s = snap("0.1.0", vec![node(&[])]);
115        assert_eq!(validate(&s), Err(Error::EmptyPath(0)));
116    }
117
118    #[test]
119    fn rejects_duplicate_path() {
120        let mut n = node(&["a"]);
121        n.content.insert("k".into(), BTreeSet::from(["v".to_string()]));
122        // same path as a plain node => same identity => duplicate
123        let s = snap("0.1.0", vec![node(&["a"]), n]);
124        assert!(matches!(validate(&s), Err(Error::DuplicatePath(_))));
125    }
126
127    #[test]
128    fn rejects_empty_path_segment() {
129        let s = snap("0.1.0", vec![node(&["a", ""])]);
130        assert_eq!(validate(&s), Err(Error::EmptyPathSegment(0)));
131    }
132
133    #[test]
134    fn rejects_empty_attr_name() {
135        let mut n = node(&["a"]);
136        n.content.insert(String::new(), BTreeSet::from(["v".to_string()]));
137        let s = snap("0.1.0", vec![n]);
138        assert_eq!(validate(&s), Err(Error::EmptyAttrName(0)));
139    }
140
141    #[test]
142    fn rejects_empty_value_set() {
143        let mut n = node(&["a"]);
144        n.content.insert("k".into(), BTreeSet::new());
145        let s = snap("0.1.0", vec![n]);
146        assert_eq!(validate(&s), Err(Error::EmptyValueSet(0, "k".to_string())));
147    }
148
149    #[test]
150    fn rejects_differing_minor_while_major_zero() {
151        // SPEC §10: during MAJOR 0 the exact MINOR is contract-significant.
152        let s = snap("0.2.0", vec![node(&["a"])]);
153        assert_eq!(validate(&s), Err(Error::UnsupportedMinor(2)));
154    }
155
156    #[test]
157    fn accepts_empty_tree() {
158        // An empty tree is an empty scope, not an error (SPEC §9).
159        let s = snap("0.1.0", vec![]);
160        assert!(validate(&s).is_ok());
161    }
162}