Skip to main content

ledvar_core/
model.rs

1//! The wire data model — the types that ARE the protocol (SPEC §4).
2//!
3//! Field names and types are load-bearing: they are serialized as-is over every
4//! transport and into every store. `content` uses `BTreeMap`/`BTreeSet` so that
5//! keys and value-sets are kept sorted and de-duplicated — which is exactly the
6//! ordering the canonical form needs, paid once on construction.
7
8use crate::canon;
9use crate::error::Error;
10use crate::wellformed;
11use serde::{Deserialize, Serialize};
12use std::collections::{BTreeMap, BTreeSet};
13use std::fmt;
14
15/// Protocol MAJOR this implementation understands. Snapshots with a different
16/// MAJOR are rejected by [`crate::validate`].
17pub const SUPPORTED_PROTOCOL_MAJOR: u64 = 0;
18
19/// Protocol MINOR this implementation targets. While MAJOR is 0 the exact MINOR is
20/// contract-significant — a MINOR bump within 0.x may move the canonical form (SPEC §10) — so
21/// [`crate::validate`] rejects a snapshot whose MINOR differs while MAJOR is 0.
22pub const SUPPORTED_PROTOCOL_MINOR: u64 = 1;
23
24/// Deserialize a JSON object into a `BTreeMap`, **rejecting duplicate keys** (SPEC §9: no object may
25/// contain a duplicate key). The default `serde` map deserialization silently keeps the last of a
26/// repeated key; this surfaces it as a parse error instead, so `{"a":[…],"a":[…]}` (or a repeated
27/// label) is refused rather than quietly collapsed. Applied to `content` and every `labels`.
28fn de_map_no_dup_keys<'de, D, V>(d: D) -> Result<BTreeMap<String, V>, D::Error>
29where
30    D: serde::Deserializer<'de>,
31    V: Deserialize<'de>,
32{
33    use serde::de::{Error as _, MapAccess, Visitor};
34    use std::marker::PhantomData;
35
36    struct MapVisitor<V>(PhantomData<V>);
37    impl<'de, V: Deserialize<'de>> Visitor<'de> for MapVisitor<V> {
38        type Value = BTreeMap<String, V>;
39        fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
40            f.write_str("a map with unique keys")
41        }
42        fn visit_map<A: MapAccess<'de>>(self, mut access: A) -> Result<Self::Value, A::Error> {
43            let mut map = BTreeMap::new();
44            while let Some((k, v)) = access.next_entry::<String, V>()? {
45                if map.contains_key(&k) {
46                    return Err(A::Error::custom(format!("duplicate object key: {k:?}")));
47                }
48                map.insert(k, v);
49            }
50            Ok(map)
51        }
52    }
53    d.deserialize_map(MapVisitor(PhantomData))
54}
55
56/// One observation of some state at a point in time (SPEC §4.1).
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct Snapshot {
59    /// `MAJOR.MINOR.PATCH`; only MAJOR is contract-significant.
60    pub protocol_version: String,
61    /// What was observed (a host, an account, a cluster…).
62    pub origin_id: String,
63    /// The source/collector that produced it.
64    pub provider_name: String,
65    /// Observation time, seconds since the Unix epoch (UTC).
66    pub timestamp: i64,
67    /// Optional identity/integrity token for the source.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub fingerprint: Option<String>,
70    /// Optional lineage/grouping pointer to another origin.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub parent_origin_id: Option<String>,
73    /// Snapshot-level annotation. Never hashed.
74    #[serde(
75        default,
76        skip_serializing_if = "BTreeMap::is_empty",
77        deserialize_with = "de_map_no_dup_keys"
78    )]
79    pub labels: BTreeMap<String, String>,
80    /// The observed nodes.
81    pub tree: Vec<Node>,
82}
83
84/// A node of the tree: **identity** (`path`) + **content** (attributes). The
85/// collector fills it; this crate computes the hashes (SPEC §4.2, §5).
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87pub struct Node {
88    /// The node's identity: its ordered location in the tree. Unique within a snapshot.
89    pub path: Vec<String>,
90    /// Attributes: each name maps to a set of string values (a scalar is a one-element set).
91    #[serde(
92        default,
93        skip_serializing_if = "BTreeMap::is_empty",
94        deserialize_with = "de_map_no_dup_keys"
95    )]
96    pub content: BTreeMap<String, BTreeSet<String>>,
97    /// Free-form annotation for grouping. Never hashed, never diffed.
98    #[serde(
99        default,
100        skip_serializing_if = "BTreeMap::is_empty",
101        deserialize_with = "de_map_no_dup_keys"
102    )]
103    pub labels: BTreeMap<String, String>,
104    /// Directed annotation edges to other nodes. Never hashed, never diffed.
105    #[serde(default, skip_serializing_if = "Vec::is_empty")]
106    pub refs: Vec<Ref>,
107}
108
109/// A directed annotation edge from one node to another (SPEC §4.3).
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111pub struct Ref {
112    /// Arbitrary edge label (e.g. `configures`, `depends_on`).
113    pub relation: String,
114    /// Path-based selector of the target node.
115    pub target: String,
116}
117
118impl Node {
119    /// `identity_key` = SHA-256 of the canonical `path` ("which node this is").
120    ///
121    /// Validates the node first — an implementation MUST NOT hash ill-formed input
122    /// (SPEC §9), so an ill-formed node yields an error, never a hash.
123    pub fn identity_key(&self) -> Result<String, Error> {
124        wellformed::validate_node(self, 0)?;
125        Ok(canon::hash_path(&self.path))
126    }
127
128    /// `content_hash` = SHA-256 of the canonical `content` ("what the node is").
129    ///
130    /// Validates the node first — an implementation MUST NOT hash ill-formed input
131    /// (SPEC §9), so an ill-formed node yields an error, never a hash.
132    pub fn content_hash(&self) -> Result<String, Error> {
133        wellformed::validate_node(self, 0)?;
134        Ok(canon::hash_content(&self.content))
135    }
136
137    /// The exact canonical bytes hashed for the identity (for inspection/debugging).
138    /// Not validated — these are bytes, not a hash; use [`Node::identity_key`] to hash.
139    pub fn canonical_path(&self) -> String {
140        canon::canonical_path(&self.path)
141    }
142
143    /// The exact canonical bytes hashed for the content (for inspection/debugging).
144    /// Not validated — these are bytes, not a hash; use [`Node::content_hash`] to hash.
145    pub fn canonical_content(&self) -> String {
146        canon::canonical_content(&self.content)
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    fn node(path: &[&str], content: &[(&str, &[&str])]) -> Node {
155        Node {
156            path: path.iter().map(|s| s.to_string()).collect(),
157            content: content
158                .iter()
159                .map(|(k, vs)| (k.to_string(), vs.iter().map(|s| s.to_string()).collect()))
160                .collect(),
161            labels: BTreeMap::new(),
162            refs: Vec::new(),
163        }
164    }
165
166    #[test]
167    fn value_order_and_duplicates_do_not_affect_content_hash() {
168        let a = node(&["x"], &[("actions", &["read", "write"])]);
169        let b = node(&["x"], &[("actions", &["write", "read", "read"])]);
170        assert_eq!(a.content_hash().unwrap(), b.content_hash().unwrap());
171        assert_eq!(a.identity_key().unwrap(), b.identity_key().unwrap());
172    }
173
174    #[test]
175    fn labels_and_refs_never_affect_hashes() {
176        let plain = node(&["vms", "i-1"], &[("cpu", &["4"])]);
177        let mut annotated = plain.clone();
178        annotated.labels.insert("env".into(), "prod".into());
179        annotated.refs.push(Ref {
180            relation: "depends_on".into(),
181            target: "vms/i-2".into(),
182        });
183        assert_eq!(plain.identity_key().unwrap(), annotated.identity_key().unwrap());
184        assert_eq!(plain.content_hash().unwrap(), annotated.content_hash().unwrap());
185    }
186
187    #[test]
188    fn changing_content_keeps_identity_but_changes_content_hash() {
189        let before = node(&["vms", "i-1"], &[("cpu", &["4"])]);
190        let after = node(&["vms", "i-1"], &[("cpu", &["8"])]);
191        assert_eq!(before.identity_key().unwrap(), after.identity_key().unwrap());
192        assert_ne!(before.content_hash().unwrap(), after.content_hash().unwrap());
193    }
194
195    #[test]
196    fn worked_example_matches_spec() {
197        // Mirrors SPEC §6.3 (a retail product — domain-neutral; the duplicate `sale` shows dedup).
198        let n = node(
199            &["catalog", "sku:AX-42"],
200            &[
201                ("tags", &["sale", "featured", "sale"]),
202                ("price_brl", &["149.90"]),
203            ],
204        );
205        assert_eq!(n.canonical_path(), r#"["catalog","sku:AX-42"]"#);
206        assert_eq!(
207            n.canonical_content(),
208            r#"{"price_brl":["149.90"],"tags":["featured","sale"]}"#
209        );
210        assert_eq!(
211            n.identity_key().unwrap(),
212            "40b6af8764108d36606126606c42d3e396a9e0778d7ad6a38e6bdc5804f6ad0c"
213        );
214        assert_eq!(
215            n.content_hash().unwrap(),
216            "63c1529881beb90df3a9865bea9cafe9bf1b4701932e0aa22ce980b7a387c5a2"
217        );
218    }
219
220    #[test]
221    fn hashing_an_ill_formed_node_is_refused() {
222        // SPEC §9: an implementation MUST NOT hash ill-formed input. The hash methods
223        // guard themselves, so skipping `validate` cannot produce a hash of e.g. an
224        // empty value set or an empty path segment.
225        let empty_set = node(&["x"], &[("a", &[])]);
226        assert!(empty_set.content_hash().is_err());
227        assert!(empty_set.identity_key().is_err());
228
229        let empty_segment = node(&["x", ""], &[]);
230        assert!(empty_segment.identity_key().is_err());
231        assert!(empty_segment.content_hash().is_err());
232
233        let empty_path = node(&[], &[]);
234        assert!(empty_path.identity_key().is_err());
235    }
236
237    #[test]
238    fn minimal_node_round_trips_via_json() {
239        let parsed: Node = serde_json::from_str(r#"{"path":["a"]}"#).unwrap();
240        assert!(parsed.content.is_empty() && parsed.labels.is_empty() && parsed.refs.is_empty());
241        let back = serde_json::to_string(&parsed).unwrap();
242        assert_eq!(back, r#"{"path":["a"]}"#);
243    }
244
245    #[test]
246    fn rejects_duplicate_attribute_key() {
247        // SPEC §9: no object may contain a duplicate key. serde's default map silently keeps the
248        // last of a repeated key; our strict deserializer refuses it at parse instead.
249        let r: Result<Node, _> = serde_json::from_str(r#"{"path":["x"],"content":{"a":["v1"],"a":["v2"]}}"#);
250        assert!(
251            r.is_err(),
252            "duplicate attribute key must be rejected, not collapsed"
253        );
254    }
255
256    #[test]
257    fn rejects_duplicate_label_key() {
258        let r: Result<Node, _> = serde_json::from_str(r#"{"path":["x"],"labels":{"k":"1","k":"2"}}"#);
259        assert!(r.is_err(), "duplicate label key must be rejected");
260    }
261}