greentic_deployer/environment/
trust_root.rs1use std::path::{Path, PathBuf};
20
21use greentic_distributor_client::signing::{TrustRoot, TrustedKey};
22pub use greentic_operator_trust::trust_root::{
23 TRUST_ROOT_SCHEMA_V1, TrustRootDocError, TrustRootDocument,
24};
25use greentic_operator_trust::trust_root::{apply_add, apply_remove, validate_trusted_key};
26use thiserror::Error;
27
28use super::atomic_write::{AtomicWriteError, atomic_write_json, copy_to_backup};
29
30const TRUST_ROOT_BACKUP_DIR: &str = "backups";
33
34pub const TRUST_ROOT_FILE: &str = "trust-root.json";
36
37#[derive(Debug, Error)]
38pub enum TrustRootError {
39 #[error("trust-root io on {path}: {source}")]
40 Io {
41 path: PathBuf,
42 #[source]
43 source: std::io::Error,
44 },
45 #[error("trust-root write {path}: {source}")]
46 Write {
47 path: PathBuf,
48 #[source]
49 source: AtomicWriteError,
50 },
51 #[error("trust-root parse {path}: {source}")]
52 Parse {
53 path: PathBuf,
54 #[source]
55 source: serde_json::Error,
56 },
57 #[error(transparent)]
62 Doc(#[from] TrustRootDocError),
63}
64
65pub fn trust_root_path(env_dir: &Path) -> PathBuf {
68 env_dir.join(TRUST_ROOT_FILE)
69}
70
71pub fn load(env_dir: &Path) -> Result<TrustRoot, TrustRootError> {
77 let path = trust_root_path(env_dir);
78 let bytes = match std::fs::read(&path) {
79 Ok(b) => b,
80 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
81 return Ok(TrustRoot::default());
82 }
83 Err(source) => return Err(TrustRootError::Io { path, source }),
84 };
85 let doc: TrustRootDocument =
86 serde_json::from_slice(&bytes).map_err(|source| TrustRootError::Parse {
87 path: path.clone(),
88 source,
89 })?;
90 Ok(doc.into_trust_root()?)
91}
92
93pub fn add_trusted_key(env_dir: &Path, key: TrustedKey) -> Result<TrustRoot, TrustRootError> {
107 let key = validate_trusted_key(key)?;
108 let mut current = load_keys(env_dir)?;
109 apply_add(&mut current, key);
110 save(env_dir, ¤t)?;
111 Ok(TrustRoot::new(current))
112}
113
114pub fn remove_trusted_key(env_dir: &Path, key_id: &str) -> Result<TrustRoot, TrustRootError> {
123 let mut current = load_keys(env_dir)?;
124 if apply_remove(&mut current, key_id) {
125 save(env_dir, ¤t)?;
126 }
127 Ok(TrustRoot::new(current))
128}
129
130fn load_keys(env_dir: &Path) -> Result<Vec<TrustedKey>, TrustRootError> {
131 let root = load(env_dir)?;
132 Ok(root.keys)
133}
134
135fn save(env_dir: &Path, keys: &[TrustedKey]) -> Result<(), TrustRootError> {
145 let path = trust_root_path(env_dir);
146 copy_to_backup(&path, &env_dir.join(TRUST_ROOT_BACKUP_DIR)).map_err(|source| {
147 TrustRootError::Write {
148 path: path.clone(),
149 source,
150 }
151 })?;
152 let doc = TrustRootDocument::v1(keys.to_vec());
153 atomic_write_json(&path, &doc).map_err(|source| TrustRootError::Write { path, source })
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159 use greentic_operator_trust::test_support::keypair;
160 use tempfile::tempdir;
161
162 #[test]
163 fn load_missing_file_returns_empty_trust_root() {
164 let dir = tempdir().unwrap();
165 let tr = load(dir.path()).unwrap();
166 assert!(tr.is_empty());
167 }
168
169 #[test]
170 fn add_then_load_roundtrips_a_key() {
171 let dir = tempdir().unwrap();
172 let (pem, key_id) = keypair(1);
173 let tr = add_trusted_key(
174 dir.path(),
175 TrustedKey {
176 key_id: key_id.clone(),
177 public_key_pem: pem.clone(),
178 },
179 )
180 .unwrap();
181 assert_eq!(tr.keys.len(), 1);
182 assert_eq!(tr.keys[0].key_id, key_id);
183
184 let reloaded = load(dir.path()).unwrap();
185 assert_eq!(reloaded.keys, tr.keys);
186 }
187
188 #[test]
189 fn add_with_uppercase_key_id_normalizes_to_canonical_lowercase() {
190 let dir = tempdir().unwrap();
191 let (pem, key_id) = keypair(2);
192 let uppercase = key_id.to_uppercase();
193 let tr = add_trusted_key(
194 dir.path(),
195 TrustedKey {
196 key_id: uppercase,
197 public_key_pem: pem,
198 },
199 )
200 .unwrap();
201 assert_eq!(tr.keys[0].key_id, key_id, "stored id must be canonical");
202 }
203
204 #[test]
205 fn add_with_mismatched_key_id_is_rejected() {
206 let dir = tempdir().unwrap();
207 let (pem_a, _id_a) = keypair(3);
208 let (_pem_b, id_b) = keypair(4);
209 let err = add_trusted_key(
210 dir.path(),
211 TrustedKey {
212 key_id: id_b,
213 public_key_pem: pem_a,
214 },
215 )
216 .expect_err("mismatched id must be rejected");
217 assert!(matches!(
218 err,
219 TrustRootError::Doc(TrustRootDocError::KeyIdMismatch { .. })
220 ));
221 assert!(!trust_root_path(dir.path()).exists());
223 }
224
225 #[test]
226 fn add_with_empty_key_id_is_rejected() {
227 let dir = tempdir().unwrap();
228 let (pem, _) = keypair(5);
229 let err = add_trusted_key(
230 dir.path(),
231 TrustedKey {
232 key_id: " ".into(),
233 public_key_pem: pem,
234 },
235 )
236 .expect_err("empty id must be rejected");
237 assert!(matches!(
238 err,
239 TrustRootError::Doc(TrustRootDocError::EmptyKeyId(_))
240 ));
241 }
242
243 #[test]
244 fn add_with_malformed_pem_is_rejected_pre_write() {
245 let dir = tempdir().unwrap();
246 let err = add_trusted_key(
247 dir.path(),
248 TrustedKey {
249 key_id: "abcdef".repeat(5).chars().take(32).collect(),
250 public_key_pem: "not-a-pem".into(),
251 },
252 )
253 .expect_err("bad pem must be rejected");
254 assert!(matches!(
255 err,
256 TrustRootError::Doc(TrustRootDocError::Key(_))
257 ));
258 assert!(!trust_root_path(dir.path()).exists());
259 }
260
261 #[test]
262 fn add_replaces_existing_key_with_same_key_id() {
263 let dir = tempdir().unwrap();
264 let (pem, id) = keypair(6);
265 add_trusted_key(
266 dir.path(),
267 TrustedKey {
268 key_id: id.clone(),
269 public_key_pem: pem.clone(),
270 },
271 )
272 .unwrap();
273 let tr = add_trusted_key(
274 dir.path(),
275 TrustedKey {
276 key_id: id.to_uppercase(),
277 public_key_pem: pem,
278 },
279 )
280 .unwrap();
281 assert_eq!(tr.keys.len(), 1, "duplicate key_id must dedup");
282 }
283
284 #[test]
285 fn add_two_distinct_keys_yields_two_entries() {
286 let dir = tempdir().unwrap();
287 let (pem_a, id_a) = keypair(7);
288 let (pem_b, id_b) = keypair(8);
289 add_trusted_key(
290 dir.path(),
291 TrustedKey {
292 key_id: id_a,
293 public_key_pem: pem_a,
294 },
295 )
296 .unwrap();
297 let tr = add_trusted_key(
298 dir.path(),
299 TrustedKey {
300 key_id: id_b,
301 public_key_pem: pem_b,
302 },
303 )
304 .unwrap();
305 assert_eq!(tr.keys.len(), 2);
306 }
307
308 #[test]
309 fn remove_drops_only_matching_key() {
310 let dir = tempdir().unwrap();
311 let (pem_a, id_a) = keypair(9);
312 let (pem_b, id_b) = keypair(10);
313 add_trusted_key(
314 dir.path(),
315 TrustedKey {
316 key_id: id_a.clone(),
317 public_key_pem: pem_a,
318 },
319 )
320 .unwrap();
321 add_trusted_key(
322 dir.path(),
323 TrustedKey {
324 key_id: id_b.clone(),
325 public_key_pem: pem_b,
326 },
327 )
328 .unwrap();
329 let tr = remove_trusted_key(dir.path(), &id_a).unwrap();
330 assert_eq!(tr.keys.len(), 1);
331 assert_eq!(tr.keys[0].key_id, id_b);
332 }
333
334 #[test]
335 fn remove_on_fresh_env_does_not_create_trust_root_file() {
336 let dir = tempdir().unwrap();
340 assert!(!trust_root_path(dir.path()).exists());
341 let tr = remove_trusted_key(dir.path(), "00ff00ff00ff00ff00ff00ff00ff00ff").unwrap();
342 assert!(tr.is_empty());
343 assert!(
344 !trust_root_path(dir.path()).exists(),
345 "no-op remove must not create an empty trust-root.json"
346 );
347 }
348
349 #[test]
350 fn remove_unknown_key_is_a_silent_noop() {
351 let dir = tempdir().unwrap();
352 let (pem, id) = keypair(11);
353 add_trusted_key(
354 dir.path(),
355 TrustedKey {
356 key_id: id.clone(),
357 public_key_pem: pem,
358 },
359 )
360 .unwrap();
361 let tr = remove_trusted_key(dir.path(), "00ff00ff00ff00ff00ff00ff00ff00ff").unwrap();
362 assert_eq!(tr.keys.len(), 1, "non-matching removal is a no-op");
363 assert_eq!(tr.keys[0].key_id, id);
364 }
365
366 #[test]
367 fn add_writes_prior_trust_root_to_backups_dir() {
368 let dir = tempdir().unwrap();
371 let (pem_a, id_a) = keypair(40);
372 let (pem_b, id_b) = keypair(41);
373 add_trusted_key(
374 dir.path(),
375 TrustedKey {
376 key_id: id_a.clone(),
377 public_key_pem: pem_a,
378 },
379 )
380 .unwrap();
381 add_trusted_key(
382 dir.path(),
383 TrustedKey {
384 key_id: id_b,
385 public_key_pem: pem_b,
386 },
387 )
388 .unwrap();
389 let backups: Vec<_> = std::fs::read_dir(dir.path().join("backups"))
391 .unwrap()
392 .filter_map(|e| e.ok())
393 .filter(|e| {
394 e.file_name()
395 .to_string_lossy()
396 .starts_with("trust-root.json.")
397 })
398 .collect();
399 assert!(
400 !backups.is_empty(),
401 "expected a trust-root backup file under backups/"
402 );
403 let backup_contents = std::fs::read_to_string(backups[0].path()).unwrap();
404 let parsed: TrustRootDocument = serde_json::from_str(&backup_contents).unwrap();
405 assert_eq!(parsed.keys.len(), 1);
406 assert!(parsed.keys[0].key_id.eq_ignore_ascii_case(&id_a));
407 }
408
409 #[test]
410 fn remove_writes_prior_trust_root_to_backups_dir() {
411 let dir = tempdir().unwrap();
412 let (pem, id) = keypair(42);
413 add_trusted_key(
414 dir.path(),
415 TrustedKey {
416 key_id: id.clone(),
417 public_key_pem: pem,
418 },
419 )
420 .unwrap();
421 remove_trusted_key(dir.path(), &id).unwrap();
422 let backups: Vec<_> = std::fs::read_dir(dir.path().join("backups"))
425 .unwrap()
426 .filter_map(|e| e.ok())
427 .filter(|e| {
428 e.file_name()
429 .to_string_lossy()
430 .starts_with("trust-root.json.")
431 })
432 .collect();
433 assert_eq!(backups.len(), 1, "remove must back up its predecessor");
434 let parsed: TrustRootDocument =
435 serde_json::from_str(&std::fs::read_to_string(backups[0].path()).unwrap()).unwrap();
436 assert_eq!(parsed.keys.len(), 1);
437 assert!(parsed.keys[0].key_id.eq_ignore_ascii_case(&id));
438 }
439
440 #[test]
441 fn unknown_schema_is_rejected_on_load() {
442 let dir = tempdir().unwrap();
443 std::fs::write(
444 trust_root_path(dir.path()),
445 br#"{"schema":"greentic.trust-root.v999","keys":[]}"#,
446 )
447 .unwrap();
448 let err = load(dir.path()).expect_err("bad schema must reject");
449 assert!(matches!(
450 err,
451 TrustRootError::Doc(TrustRootDocError::BadSchema { .. })
452 ));
453 }
454
455 #[test]
456 fn malformed_json_is_rejected_on_load() {
457 let dir = tempdir().unwrap();
458 std::fs::write(trust_root_path(dir.path()), b"{not json}").unwrap();
459 let err = load(dir.path()).expect_err("bad json must reject");
460 assert!(matches!(err, TrustRootError::Parse { .. }));
461 }
462}