ledvar-core 0.1.0

Reference implementation of the Ledvar protocol: data model, canonical content-addressed hashing, and well-formedness.
Documentation
//! The wire data model — the types that ARE the protocol (SPEC §4).
//!
//! Field names and types are load-bearing: they are serialized as-is over every
//! transport and into every store. `content` uses `BTreeMap`/`BTreeSet` so that
//! keys and value-sets are kept sorted and de-duplicated — which is exactly the
//! ordering the canonical form needs, paid once on construction.

use crate::canon;
use crate::error::Error;
use crate::wellformed;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;

/// Protocol MAJOR this implementation understands. Snapshots with a different
/// MAJOR are rejected by [`crate::validate`].
pub const SUPPORTED_PROTOCOL_MAJOR: u64 = 0;

/// Protocol MINOR this implementation targets. While MAJOR is 0 the exact MINOR is
/// contract-significant — a MINOR bump within 0.x may move the canonical form (SPEC §10) — so
/// [`crate::validate`] rejects a snapshot whose MINOR differs while MAJOR is 0.
pub const SUPPORTED_PROTOCOL_MINOR: u64 = 1;

/// Deserialize a JSON object into a `BTreeMap`, **rejecting duplicate keys** (SPEC §9: no object may
/// contain a duplicate key). The default `serde` map deserialization silently keeps the last of a
/// repeated key; this surfaces it as a parse error instead, so `{"a":[…],"a":[…]}` (or a repeated
/// label) is refused rather than quietly collapsed. Applied to `content` and every `labels`.
fn de_map_no_dup_keys<'de, D, V>(d: D) -> Result<BTreeMap<String, V>, D::Error>
where
    D: serde::Deserializer<'de>,
    V: Deserialize<'de>,
{
    use serde::de::{Error as _, MapAccess, Visitor};
    use std::marker::PhantomData;

    struct MapVisitor<V>(PhantomData<V>);
    impl<'de, V: Deserialize<'de>> Visitor<'de> for MapVisitor<V> {
        type Value = BTreeMap<String, V>;
        fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
            f.write_str("a map with unique keys")
        }
        fn visit_map<A: MapAccess<'de>>(self, mut access: A) -> Result<Self::Value, A::Error> {
            let mut map = BTreeMap::new();
            while let Some((k, v)) = access.next_entry::<String, V>()? {
                if map.contains_key(&k) {
                    return Err(A::Error::custom(format!("duplicate object key: {k:?}")));
                }
                map.insert(k, v);
            }
            Ok(map)
        }
    }
    d.deserialize_map(MapVisitor(PhantomData))
}

/// One observation of some state at a point in time (SPEC §4.1).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Snapshot {
    /// `MAJOR.MINOR.PATCH`; only MAJOR is contract-significant.
    pub protocol_version: String,
    /// What was observed (a host, an account, a cluster…).
    pub origin_id: String,
    /// The source/collector that produced it.
    pub provider_name: String,
    /// Observation time, seconds since the Unix epoch (UTC).
    pub timestamp: i64,
    /// Optional identity/integrity token for the source.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub fingerprint: Option<String>,
    /// Optional lineage/grouping pointer to another origin.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub parent_origin_id: Option<String>,
    /// Snapshot-level annotation. Never hashed.
    #[serde(
        default,
        skip_serializing_if = "BTreeMap::is_empty",
        deserialize_with = "de_map_no_dup_keys"
    )]
    pub labels: BTreeMap<String, String>,
    /// The observed nodes.
    pub tree: Vec<Node>,
}

/// A node of the tree: **identity** (`path`) + **content** (attributes). The
/// collector fills it; this crate computes the hashes (SPEC §4.2, §5).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Node {
    /// The node's identity: its ordered location in the tree. Unique within a snapshot.
    pub path: Vec<String>,
    /// Attributes: each name maps to a set of string values (a scalar is a one-element set).
    #[serde(
        default,
        skip_serializing_if = "BTreeMap::is_empty",
        deserialize_with = "de_map_no_dup_keys"
    )]
    pub content: BTreeMap<String, BTreeSet<String>>,
    /// Free-form annotation for grouping. Never hashed, never diffed.
    #[serde(
        default,
        skip_serializing_if = "BTreeMap::is_empty",
        deserialize_with = "de_map_no_dup_keys"
    )]
    pub labels: BTreeMap<String, String>,
    /// Directed annotation edges to other nodes. Never hashed, never diffed.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub refs: Vec<Ref>,
}

/// A directed annotation edge from one node to another (SPEC §4.3).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Ref {
    /// Arbitrary edge label (e.g. `configures`, `depends_on`).
    pub relation: String,
    /// Path-based selector of the target node.
    pub target: String,
}

impl Node {
    /// `identity_key` = SHA-256 of the canonical `path` ("which node this is").
    ///
    /// Validates the node first — an implementation MUST NOT hash ill-formed input
    /// (SPEC §9), so an ill-formed node yields an error, never a hash.
    pub fn identity_key(&self) -> Result<String, Error> {
        wellformed::validate_node(self, 0)?;
        Ok(canon::hash_path(&self.path))
    }

    /// `content_hash` = SHA-256 of the canonical `content` ("what the node is").
    ///
    /// Validates the node first — an implementation MUST NOT hash ill-formed input
    /// (SPEC §9), so an ill-formed node yields an error, never a hash.
    pub fn content_hash(&self) -> Result<String, Error> {
        wellformed::validate_node(self, 0)?;
        Ok(canon::hash_content(&self.content))
    }

    /// The exact canonical bytes hashed for the identity (for inspection/debugging).
    /// Not validated — these are bytes, not a hash; use [`Node::identity_key`] to hash.
    pub fn canonical_path(&self) -> String {
        canon::canonical_path(&self.path)
    }

    /// The exact canonical bytes hashed for the content (for inspection/debugging).
    /// Not validated — these are bytes, not a hash; use [`Node::content_hash`] to hash.
    pub fn canonical_content(&self) -> String {
        canon::canonical_content(&self.content)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

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

    #[test]
    fn value_order_and_duplicates_do_not_affect_content_hash() {
        let a = node(&["x"], &[("actions", &["read", "write"])]);
        let b = node(&["x"], &[("actions", &["write", "read", "read"])]);
        assert_eq!(a.content_hash().unwrap(), b.content_hash().unwrap());
        assert_eq!(a.identity_key().unwrap(), b.identity_key().unwrap());
    }

    #[test]
    fn labels_and_refs_never_affect_hashes() {
        let plain = node(&["vms", "i-1"], &[("cpu", &["4"])]);
        let mut annotated = plain.clone();
        annotated.labels.insert("env".into(), "prod".into());
        annotated.refs.push(Ref {
            relation: "depends_on".into(),
            target: "vms/i-2".into(),
        });
        assert_eq!(plain.identity_key().unwrap(), annotated.identity_key().unwrap());
        assert_eq!(plain.content_hash().unwrap(), annotated.content_hash().unwrap());
    }

    #[test]
    fn changing_content_keeps_identity_but_changes_content_hash() {
        let before = node(&["vms", "i-1"], &[("cpu", &["4"])]);
        let after = node(&["vms", "i-1"], &[("cpu", &["8"])]);
        assert_eq!(before.identity_key().unwrap(), after.identity_key().unwrap());
        assert_ne!(before.content_hash().unwrap(), after.content_hash().unwrap());
    }

    #[test]
    fn worked_example_matches_spec() {
        // Mirrors SPEC §6.3 (a retail product — domain-neutral; the duplicate `sale` shows dedup).
        let n = node(
            &["catalog", "sku:AX-42"],
            &[
                ("tags", &["sale", "featured", "sale"]),
                ("price_brl", &["149.90"]),
            ],
        );
        assert_eq!(n.canonical_path(), r#"["catalog","sku:AX-42"]"#);
        assert_eq!(
            n.canonical_content(),
            r#"{"price_brl":["149.90"],"tags":["featured","sale"]}"#
        );
        assert_eq!(
            n.identity_key().unwrap(),
            "40b6af8764108d36606126606c42d3e396a9e0778d7ad6a38e6bdc5804f6ad0c"
        );
        assert_eq!(
            n.content_hash().unwrap(),
            "63c1529881beb90df3a9865bea9cafe9bf1b4701932e0aa22ce980b7a387c5a2"
        );
    }

    #[test]
    fn hashing_an_ill_formed_node_is_refused() {
        // SPEC §9: an implementation MUST NOT hash ill-formed input. The hash methods
        // guard themselves, so skipping `validate` cannot produce a hash of e.g. an
        // empty value set or an empty path segment.
        let empty_set = node(&["x"], &[("a", &[])]);
        assert!(empty_set.content_hash().is_err());
        assert!(empty_set.identity_key().is_err());

        let empty_segment = node(&["x", ""], &[]);
        assert!(empty_segment.identity_key().is_err());
        assert!(empty_segment.content_hash().is_err());

        let empty_path = node(&[], &[]);
        assert!(empty_path.identity_key().is_err());
    }

    #[test]
    fn minimal_node_round_trips_via_json() {
        let parsed: Node = serde_json::from_str(r#"{"path":["a"]}"#).unwrap();
        assert!(parsed.content.is_empty() && parsed.labels.is_empty() && parsed.refs.is_empty());
        let back = serde_json::to_string(&parsed).unwrap();
        assert_eq!(back, r#"{"path":["a"]}"#);
    }

    #[test]
    fn rejects_duplicate_attribute_key() {
        // SPEC §9: no object may contain a duplicate key. serde's default map silently keeps the
        // last of a repeated key; our strict deserializer refuses it at parse instead.
        let r: Result<Node, _> = serde_json::from_str(r#"{"path":["x"],"content":{"a":["v1"],"a":["v2"]}}"#);
        assert!(
            r.is_err(),
            "duplicate attribute key must be rejected, not collapsed"
        );
    }

    #[test]
    fn rejects_duplicate_label_key() {
        let r: Result<Node, _> = serde_json::from_str(r#"{"path":["x"],"labels":{"k":"1","k":"2"}}"#);
        assert!(r.is_err(), "duplicate label key must be rejected");
    }
}