Skip to main content

greentic_deploy_spec/
integrity.rs

1//! Corruption-detection hash (A8 contract piece #6) and the canonical-JSON
2//! form the hash is computed over.
3//!
4//! A production `EnvironmentStore` must be able to detect on-disk/at-rest
5//! corruption of a persisted resource. The contract defines a content hash:
6//! SHA-256 over the resource's *canonical JSON* — object keys sorted
7//! lexicographically, no insignificant whitespace, arrays left in order. The
8//! strong [`StateEtag`](crate::remote::StateEtag) reuses the same digest.
9//!
10//! Canonicalization sorts keys explicitly (not relying on `serde_json`'s map
11//! ordering) so the digest is identical whether or not the `preserve_order`
12//! feature is unified into the build.
13
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16use sha2::{Digest, Sha256};
17use thiserror::Error;
18
19/// The only hash algorithm defined by the Phase A contract.
20pub 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    /// A stored [`StateIntegrity`] names an algorithm this build can't verify.
27    #[error("unsupported integrity algorithm `{0}` (expected `{INTEGRITY_ALGORITHM_SHA256}`)")]
28    UnsupportedAlgorithm(String),
29}
30
31/// Content hash of a persisted resource, used for corruption detection.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct StateIntegrity {
34    /// Hash algorithm identifier — `sha-256` in Phase A.
35    pub algorithm: String,
36    /// Lowercase hex digest over the canonical JSON of the resource.
37    pub digest: String,
38}
39
40impl StateIntegrity {
41    /// Compute the SHA-256 integrity hash over `value`'s canonical JSON.
42    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    /// Recompute the hash over `value` and report whether it matches `self`.
52    ///
53    /// Errors if `self.algorithm` is not one this build can recompute, so a
54    /// caller never silently treats an unknown algorithm as a match.
55    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
63/// Serialize `value` to canonical JSON: keys sorted lexicographically at every
64/// object level, compact (no insignificant whitespace), arrays in order.
65pub 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            // Emit keys in sorted order regardless of whether serde_json's
74            // `preserve_order` is active in the build.
75            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}