use crate::canon;
use crate::error::Error;
use crate::wellformed;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
pub const SUPPORTED_PROTOCOL_MAJOR: u64 = 0;
pub const SUPPORTED_PROTOCOL_MINOR: u64 = 1;
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))
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Snapshot {
pub protocol_version: String,
pub origin_id: String,
pub provider_name: String,
pub timestamp: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fingerprint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_origin_id: Option<String>,
#[serde(
default,
skip_serializing_if = "BTreeMap::is_empty",
deserialize_with = "de_map_no_dup_keys"
)]
pub labels: BTreeMap<String, String>,
pub tree: Vec<Node>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Node {
pub path: Vec<String>,
#[serde(
default,
skip_serializing_if = "BTreeMap::is_empty",
deserialize_with = "de_map_no_dup_keys"
)]
pub content: BTreeMap<String, BTreeSet<String>>,
#[serde(
default,
skip_serializing_if = "BTreeMap::is_empty",
deserialize_with = "de_map_no_dup_keys"
)]
pub labels: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub refs: Vec<Ref>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Ref {
pub relation: String,
pub target: String,
}
impl Node {
pub fn identity_key(&self) -> Result<String, Error> {
wellformed::validate_node(self, 0)?;
Ok(canon::hash_path(&self.path))
}
pub fn content_hash(&self) -> Result<String, Error> {
wellformed::validate_node(self, 0)?;
Ok(canon::hash_content(&self.content))
}
pub fn canonical_path(&self) -> String {
canon::canonical_path(&self.path)
}
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() {
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() {
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() {
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");
}
}