greentic_deploy_spec/
integrity.rs1use serde::{Deserialize, Serialize};
15use serde_json::Value;
16use sha2::{Digest, Sha256};
17use thiserror::Error;
18
19pub const INTEGRITY_ALGORITHM_SHA256: &str = "sha-256";
21
22#[derive(Debug, Error)]
23pub enum IntegrityError {
24 #[error("integrity serialize: {0}")]
25 Serde(#[from] serde_json::Error),
26 #[error("unsupported integrity algorithm `{0}` (expected `{INTEGRITY_ALGORITHM_SHA256}`)")]
28 UnsupportedAlgorithm(String),
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct StateIntegrity {
34 pub algorithm: String,
36 pub digest: String,
38}
39
40impl StateIntegrity {
41 pub fn sha256_of<T: Serialize>(value: &T) -> Result<Self, IntegrityError> {
43 let mut hasher = Sha256::new();
44 hasher.update(canonical_json(value)?.as_bytes());
45 Ok(Self {
46 algorithm: INTEGRITY_ALGORITHM_SHA256.to_string(),
47 digest: hex::encode(hasher.finalize()),
48 })
49 }
50
51 pub fn verify<T: Serialize>(&self, value: &T) -> Result<bool, IntegrityError> {
56 if self.algorithm != INTEGRITY_ALGORITHM_SHA256 {
57 return Err(IntegrityError::UnsupportedAlgorithm(self.algorithm.clone()));
58 }
59 Ok(self.digest == Self::sha256_of(value)?.digest)
60 }
61}
62
63pub fn canonical_json<T: Serialize>(value: &T) -> Result<String, IntegrityError> {
66 let canonical = canonicalize(&serde_json::to_value(value)?);
67 Ok(serde_json::to_string(&canonical)?)
68}
69
70fn canonicalize(value: &Value) -> Value {
71 match value {
72 Value::Object(map) => {
73 let mut entries: Vec<_> = map.iter().collect();
76 entries.sort_by_key(|(k, _)| *k);
77 Value::Object(
78 entries
79 .into_iter()
80 .map(|(k, v)| (k.clone(), canonicalize(v)))
81 .collect(),
82 )
83 }
84 Value::Array(items) => Value::Array(items.iter().map(canonicalize).collect()),
85 other => other.clone(),
86 }
87}
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92
93 #[test]
94 fn canonical_json_sorts_object_keys() {
95 let value = serde_json::json!({"b": 1, "a": {"d": 4, "c": 3}});
96 assert_eq!(
97 canonical_json(&value).unwrap(),
98 r#"{"a":{"c":3,"d":4},"b":1}"#
99 );
100 }
101
102 #[test]
103 fn canonical_json_preserves_array_order() {
104 let value = serde_json::json!([3, 1, 2]);
105 assert_eq!(canonical_json(&value).unwrap(), "[3,1,2]");
106 }
107
108 #[test]
109 fn hash_is_stable_for_equal_content() {
110 let a = serde_json::json!({"x": 1, "y": [1, 2]});
111 let b = serde_json::json!({"x": 1, "y": [1, 2]});
112 assert_eq!(
113 StateIntegrity::sha256_of(&a).unwrap(),
114 StateIntegrity::sha256_of(&b).unwrap()
115 );
116 }
117
118 #[test]
119 fn hash_independent_of_key_insertion_order() {
120 let a = serde_json::json!({"first": 1, "second": 2});
121 let b = serde_json::json!({"second": 2, "first": 1});
122 assert_eq!(
123 StateIntegrity::sha256_of(&a).unwrap().digest,
124 StateIntegrity::sha256_of(&b).unwrap().digest
125 );
126 }
127
128 #[test]
129 fn hash_changes_when_content_changes() {
130 let a = serde_json::json!({"x": 1});
131 let b = serde_json::json!({"x": 2});
132 assert_ne!(
133 StateIntegrity::sha256_of(&a).unwrap().digest,
134 StateIntegrity::sha256_of(&b).unwrap().digest
135 );
136 }
137
138 #[test]
139 fn verify_detects_tampering() {
140 let original = serde_json::json!({"generation": 4, "name": "local"});
141 let integrity = StateIntegrity::sha256_of(&original).unwrap();
142 assert!(integrity.verify(&original).unwrap());
143
144 let tampered = serde_json::json!({"generation": 5, "name": "local"});
145 assert!(!integrity.verify(&tampered).unwrap());
146 }
147
148 #[test]
149 fn verify_rejects_unknown_algorithm() {
150 let integrity = StateIntegrity {
151 algorithm: "blake3".to_string(),
152 digest: "00".to_string(),
153 };
154 let err = integrity
155 .verify(&serde_json::json!({}))
156 .expect_err("unknown algorithm must error");
157 assert!(matches!(err, IntegrityError::UnsupportedAlgorithm(a) if a == "blake3"));
158 }
159
160 #[test]
161 fn digest_is_lowercase_hex_sha256() {
162 let integrity = StateIntegrity::sha256_of(&serde_json::json!({})).unwrap();
163 assert_eq!(integrity.algorithm, INTEGRITY_ALGORITHM_SHA256);
164 assert_eq!(integrity.digest.len(), 64);
165 assert!(
166 integrity
167 .digest
168 .chars()
169 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
170 );
171 }
172}