ledvar-core 0.1.0

Reference implementation of the Ledvar protocol: data model, canonical content-addressed hashing, and well-formedness.
Documentation
//! Well-formedness checking (SPEC §9). A property, exposed as one function.

use crate::error::Error;
use crate::model::{Node, SUPPORTED_PROTOCOL_MAJOR, SUPPORTED_PROTOCOL_MINOR, Snapshot};
use crate::version;
use std::collections::HashSet;

/// Validate that a snapshot is well-formed (SPEC §9): supported version, every node well-formed
/// (see [`validate_node`]), and all paths unique within the snapshot.
///
/// The rules the Rust types already guarantee are enforced *earlier*, at deserialization, and are
/// deliberately not re-checked here: string-ness (every field is `String`), valid Unicode (a Rust
/// `String` is always UTF-8, and `serde_json` rejects an unpaired surrogate at parse), `content`
/// being an object (a non-object fails to deserialize into a map), typed metadata (`timestamp: i64`,
/// `labels: BTreeMap<String, String>`, a `Ref` with both fields), and duplicate object keys (the
/// strict map deserializer in `model`). SPEC §9 allows refusing at parse *or* here. This function
/// covers exactly what the types cannot express.
pub fn validate(snapshot: &Snapshot) -> Result<(), Error> {
    let (major, minor, _patch) = version::parse(&snapshot.protocol_version)?;
    if major != SUPPORTED_PROTOCOL_MAJOR {
        return Err(Error::UnsupportedMajor(major));
    }
    // SPEC §10: while MAJOR is 0, a MINOR bump may move the canonical form, so the exact MINOR is
    // contract-significant — reject a differing one rather than silently accept another hash universe.
    if major == 0 && minor != SUPPORTED_PROTOCOL_MINOR {
        return Err(Error::UnsupportedMinor(minor));
    }

    // Uniqueness is over the `path` itself (identity) — compare paths directly rather than hashing
    // each node, which is cheaper and avoids computing hashes for a snapshot that may yet prove
    // ill-formed at a later node.
    let mut seen: HashSet<&Vec<String>> = HashSet::with_capacity(snapshot.tree.len());
    for (i, node) in snapshot.tree.iter().enumerate() {
        validate_node(node, i)?;
        if !seen.insert(&node.path) {
            return Err(Error::DuplicatePath(node.path.clone()));
        }
    }
    Ok(())
}

/// Well-formedness of a single node (SPEC §9), independent of any snapshot: a non-empty `path` with
/// no empty segment, no empty attribute name, and no empty value set. `index` is used only for error
/// reporting (pass `0` for a bare node). Called by [`validate`] per node, and by
/// [`Node::identity_key`] / [`Node::content_hash`] themselves, so an ill-formed node is never
/// hashed no matter how the caller reaches the hash (SPEC §9: an implementation MUST NOT hash an
/// ill-formed Snapshot).
pub fn validate_node(node: &Node, index: usize) -> Result<(), Error> {
    if node.path.is_empty() {
        return Err(Error::EmptyPath(index));
    }
    if node.path.iter().any(|seg| seg.is_empty()) {
        return Err(Error::EmptyPathSegment(index));
    }
    for (name, values) in &node.content {
        if name.is_empty() {
            return Err(Error::EmptyAttrName(index));
        }
        if values.is_empty() {
            return Err(Error::EmptyValueSet(index, name.clone()));
        }
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::model::Node;
    use std::collections::{BTreeMap, BTreeSet};

    fn snap(version: &str, nodes: Vec<Node>) -> Snapshot {
        Snapshot {
            protocol_version: version.to_string(),
            origin_id: "o".into(),
            provider_name: "p".into(),
            timestamp: 0,
            fingerprint: None,
            parent_origin_id: None,
            labels: BTreeMap::new(),
            tree: nodes,
        }
    }

    fn node(path: &[&str]) -> Node {
        Node {
            path: path.iter().map(|s| s.to_string()).collect(),
            content: BTreeMap::new(),
            labels: BTreeMap::new(),
            refs: Vec::new(),
        }
    }

    #[test]
    fn accepts_well_formed() {
        let s = snap("0.1.0", vec![node(&["a"]), node(&["b"])]);
        assert!(validate(&s).is_ok());
    }

    #[test]
    fn rejects_unsupported_major() {
        let s = snap("1.0.0", vec![node(&["a"])]);
        assert_eq!(validate(&s), Err(Error::UnsupportedMajor(1)));
    }

    #[test]
    fn rejects_bad_version() {
        let s = snap("nope", vec![node(&["a"])]);
        assert!(matches!(validate(&s), Err(Error::BadVersion(_))));
    }

    #[test]
    fn rejects_empty_path() {
        let s = snap("0.1.0", vec![node(&[])]);
        assert_eq!(validate(&s), Err(Error::EmptyPath(0)));
    }

    #[test]
    fn rejects_duplicate_path() {
        let mut n = node(&["a"]);
        n.content.insert("k".into(), BTreeSet::from(["v".to_string()]));
        // same path as a plain node => same identity => duplicate
        let s = snap("0.1.0", vec![node(&["a"]), n]);
        assert!(matches!(validate(&s), Err(Error::DuplicatePath(_))));
    }

    #[test]
    fn rejects_empty_path_segment() {
        let s = snap("0.1.0", vec![node(&["a", ""])]);
        assert_eq!(validate(&s), Err(Error::EmptyPathSegment(0)));
    }

    #[test]
    fn rejects_empty_attr_name() {
        let mut n = node(&["a"]);
        n.content.insert(String::new(), BTreeSet::from(["v".to_string()]));
        let s = snap("0.1.0", vec![n]);
        assert_eq!(validate(&s), Err(Error::EmptyAttrName(0)));
    }

    #[test]
    fn rejects_empty_value_set() {
        let mut n = node(&["a"]);
        n.content.insert("k".into(), BTreeSet::new());
        let s = snap("0.1.0", vec![n]);
        assert_eq!(validate(&s), Err(Error::EmptyValueSet(0, "k".to_string())));
    }

    #[test]
    fn rejects_differing_minor_while_major_zero() {
        // SPEC §10: during MAJOR 0 the exact MINOR is contract-significant.
        let s = snap("0.2.0", vec![node(&["a"])]);
        assert_eq!(validate(&s), Err(Error::UnsupportedMinor(2)));
    }

    #[test]
    fn accepts_empty_tree() {
        // An empty tree is an empty scope, not an error (SPEC §9).
        let s = snap("0.1.0", vec![]);
        assert!(validate(&s).is_ok());
    }
}