Skip to main content

plecto_control/manifest/
content_hash.rs

1//! The manifest's semantic content hash (ADR 000008 `config version`).
2
3use std::path::Path;
4
5use sha2::{Digest, Sha256};
6
7use super::Manifest;
8use crate::error::ControlError;
9
10impl Manifest {
11    /// The **semantic** content hash of this manifest — `sha256:<hex>` over a canonical
12    /// serialisation, not over the raw TOML. Two manifests that mean the same thing (differing
13    /// only in comments, whitespace, key order, or an explicit default written vs. omitted)
14    /// hash identically; any meaningful change flips the hash.
15    ///
16    /// This is the manifest's `config version`: the unit the reload gate compares for
17    /// idempotency (via [`content_hash_at`]), the value an operator audits, and the value a
18    /// future opt-in consensus layer (ADR 000008 openraft) would agree on. Canonical form is
19    /// `serde_json` over the derived `Serialize` — deterministic because the struct field order
20    /// is fixed and the manifest holds no maps (only ordered `Vec`s).
21    ///
22    /// Does **not** read referenced files. The load/reload path uses [`content_hash_at`] /
23    /// [`content_hash_with_ca`] so an in-place client-auth CA renewal flips the version.
24    pub fn content_hash(&self) -> Result<String, ControlError> {
25        self.content_hash_with_ca(None)
26    }
27
28    /// [`content_hash`] with referenced files resolved against `base_dir` (when `Some`): reads
29    /// `[listen.client_auth].ca_path` and mixes its digest in. Same path + different bytes must
30    /// flip the config version; otherwise SIGHUP reports `Unchanged` and the new trust roots
31    /// never load (fail-closed: an unreadable CA is an error, not a silently CA-less hash).
32    pub fn content_hash_at(&self, base_dir: Option<&Path>) -> Result<String, ControlError> {
33        let ca = match base_dir {
34            Some(base) => self.read_client_auth_ca(base)?,
35            None => None,
36        };
37        self.content_hash_with_ca(ca.as_deref())
38    }
39
40    /// Read `[listen.client_auth].ca_path`, or `None` when no client auth is configured. The
41    /// ONE read a build shares between the config version and the client verifier
42    /// ([`content_hash_with_ca`] + `tls::build_server_configs`), so the recorded version always
43    /// describes the trust roots the verifier was actually built from.
44    pub fn read_client_auth_ca(&self, base_dir: &Path) -> Result<Option<Vec<u8>>, ControlError> {
45        let Some(auth) = &self.listen.client_auth else {
46            return Ok(None);
47        };
48        std::fs::read(base_dir.join(&auth.ca_path))
49            .map(Some)
50            .map_err(|e| ControlError::ClientAuthCa {
51                path: auth.ca_path.clone(),
52                reason: format!("read failed: {e}"),
53            })
54    }
55
56    /// The semantic hash, with the client-auth CA bundle's digest mixed in when supplied
57    /// (callers obtain the bytes from [`read_client_auth_ca`]).
58    pub fn content_hash_with_ca(
59        &self,
60        client_auth_ca: Option<&[u8]>,
61    ) -> Result<String, ControlError> {
62        let mut hasher = Sha256::new();
63        hasher.update(serde_json::to_vec(self)?);
64        if let Some(bytes) = client_auth_ca {
65            hasher.update(b"\0listen.client_auth.ca\0");
66            hasher.update(Sha256::digest(bytes));
67        }
68        Ok(format!("sha256:{}", hex::encode(hasher.finalize())))
69    }
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75
76    #[test]
77    fn content_hash_is_semantic_not_textual() {
78        // Representation noise that does NOT change meaning must NOT change the hash:
79        // comments, whitespace, key order, and an explicit default (`isolation = "untrusted"`)
80        // written vs. omitted all canonicalise away.
81        let terse = Manifest::from_toml(
82            r#"
83[[filter]]
84id = "auth"
85source = "artifacts/auth"
86digest = "sha256:abc"
87
88[chain]
89filters = ["auth"]
90"#,
91        )
92        .unwrap();
93
94        let noisy = Manifest::from_toml(
95            r#"
96# a leading comment
97[chain]
98filters = ["auth"]   # chain first, with trailing comment
99
100[[filter]]
101digest   = "sha256:abc"
102source   = "artifacts/auth"
103id       = "auth"
104isolation = "untrusted"   # the default, written explicitly
105"#,
106        )
107        .unwrap();
108
109        assert_eq!(
110            terse.content_hash().unwrap(),
111            noisy.content_hash().unwrap(),
112            "semantically identical manifests must share a content hash"
113        );
114        assert!(terse.content_hash().unwrap().starts_with("sha256:"));
115    }
116
117    #[test]
118    fn client_auth_rides_the_content_hash_but_startup_fixed_listen_fields_do_not() {
119        // Regression (large-review finding): `build_active` consumes `listen.client_auth` on
120        // every reload, so a client_auth-only edit MUST flip the config version — otherwise
121        // SIGHUP reports `Unchanged` and the mTLS change is silently not applied. The
122        // startup-fixed `[listen]` fields (addr etc.) stay hash-exempt as before.
123        let base = Manifest::from_toml("").unwrap();
124        let with_addr = Manifest::from_toml("[listen]\naddr = \"0.0.0.0:8443\"\n").unwrap();
125        assert_eq!(
126            base.content_hash().unwrap(),
127            with_addr.content_hash().unwrap(),
128            "a bind-address edit is startup-fixed and must not flip the config version"
129        );
130
131        let with_client_auth =
132            Manifest::from_toml("[listen.client_auth]\nca_path = \"ca.pem\"\n").unwrap();
133        assert_ne!(
134            base.content_hash().unwrap(),
135            with_client_auth.content_hash().unwrap(),
136            "adding [listen.client_auth] must flip the config version"
137        );
138
139        let other_ca =
140            Manifest::from_toml("[listen.client_auth]\nca_path = \"other.pem\"\n").unwrap();
141        assert_ne!(
142            with_client_auth.content_hash().unwrap(),
143            other_ca.content_hash().unwrap(),
144            "changing the client-auth CA must flip the config version"
145        );
146    }
147
148    #[test]
149    fn client_auth_ca_file_bytes_ride_content_hash_at() {
150        // Same ca_path, different file bytes must flip the reload version (in-place CA rotation).
151        let dir = tempfile::tempdir().unwrap();
152        let ca_path = dir.path().join("ca.pem");
153        std::fs::write(
154            &ca_path,
155            b"-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n",
156        )
157        .unwrap();
158        let m = Manifest::from_toml("[listen.client_auth]\nca_path = \"ca.pem\"\n").unwrap();
159        let h1 = m.content_hash_at(Some(dir.path())).unwrap();
160        std::fs::write(
161            &ca_path,
162            b"-----BEGIN CERTIFICATE-----\nBBBB\n-----END CERTIFICATE-----\n",
163        )
164        .unwrap();
165        let h2 = m.content_hash_at(Some(dir.path())).unwrap();
166        assert_ne!(h1, h2, "in-place CA overwrite must flip content_hash_at");
167        assert_eq!(
168            m.content_hash().unwrap(),
169            m.content_hash().unwrap(),
170            "path-only content_hash stays stable (no file read)"
171        );
172    }
173
174    #[test]
175    fn content_hash_changes_on_meaningful_edit() {
176        let v1 = Manifest::from_toml(
177            r#"
178[[filter]]
179id = "auth"
180source = "artifacts/auth"
181digest = "sha256:abc"
182
183[chain]
184filters = ["auth"]
185"#,
186        )
187        .unwrap();
188        // Same filter, different chain (drops it) — a real config change.
189        let v2 = Manifest::from_toml(
190            r#"
191[[filter]]
192id = "auth"
193source = "artifacts/auth"
194digest = "sha256:abc"
195
196[chain]
197filters = []
198"#,
199        )
200        .unwrap();
201
202        assert_ne!(
203            v1.content_hash().unwrap(),
204            v2.content_hash().unwrap(),
205            "a chain change must flip the content hash"
206        );
207    }
208}