plecto_control/manifest/
content_hash.rs1use std::path::Path;
4
5use sha2::{Digest, Sha256};
6
7use super::Manifest;
8use crate::error::ControlError;
9
10impl Manifest {
11 pub fn content_hash(&self) -> Result<String, ControlError> {
25 self.content_hash_with_ca(None)
26 }
27
28 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 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 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 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 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 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 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}