1use crate::canon;
9use crate::error::Error;
10use crate::wellformed;
11use serde::{Deserialize, Serialize};
12use std::collections::{BTreeMap, BTreeSet};
13use std::fmt;
14
15pub const SUPPORTED_PROTOCOL_MAJOR: u64 = 0;
18
19pub const SUPPORTED_PROTOCOL_MINOR: u64 = 1;
23
24fn 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct Snapshot {
59 pub protocol_version: String,
61 pub origin_id: String,
63 pub provider_name: String,
65 pub timestamp: i64,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub fingerprint: Option<String>,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub parent_origin_id: Option<String>,
73 #[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 pub tree: Vec<Node>,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87pub struct Node {
88 pub path: Vec<String>,
90 #[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 #[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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
106 pub refs: Vec<Ref>,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111pub struct Ref {
112 pub relation: String,
114 pub target: String,
116}
117
118impl Node {
119 pub fn identity_key(&self) -> Result<String, Error> {
124 wellformed::validate_node(self, 0)?;
125 Ok(canon::hash_path(&self.path))
126 }
127
128 pub fn content_hash(&self) -> Result<String, Error> {
133 wellformed::validate_node(self, 0)?;
134 Ok(canon::hash_content(&self.content))
135 }
136
137 pub fn canonical_path(&self) -> String {
140 canon::canonical_path(&self.path)
141 }
142
143 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 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 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 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}