use std::path::{Path, PathBuf};
use greentic_distributor_client::signing::{TrustRoot, TrustedKey};
pub use greentic_operator_trust::trust_root::{
TRUST_ROOT_SCHEMA_V1, TrustRootDocError, TrustRootDocument,
};
use greentic_operator_trust::trust_root::{apply_add, apply_remove, validate_trusted_key};
use thiserror::Error;
use super::atomic_write::{AtomicWriteError, atomic_write_json, copy_to_backup};
const TRUST_ROOT_BACKUP_DIR: &str = "backups";
pub const TRUST_ROOT_FILE: &str = "trust-root.json";
#[derive(Debug, Error)]
pub enum TrustRootError {
#[error("trust-root io on {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("trust-root write {path}: {source}")]
Write {
path: PathBuf,
#[source]
source: AtomicWriteError,
},
#[error("trust-root parse {path}: {source}")]
Parse {
path: PathBuf,
#[source]
source: serde_json::Error,
},
#[error(transparent)]
Doc(#[from] TrustRootDocError),
}
pub fn trust_root_path(env_dir: &Path) -> PathBuf {
env_dir.join(TRUST_ROOT_FILE)
}
pub fn load(env_dir: &Path) -> Result<TrustRoot, TrustRootError> {
let path = trust_root_path(env_dir);
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(TrustRoot::default());
}
Err(source) => return Err(TrustRootError::Io { path, source }),
};
let doc: TrustRootDocument =
serde_json::from_slice(&bytes).map_err(|source| TrustRootError::Parse {
path: path.clone(),
source,
})?;
Ok(doc.into_trust_root()?)
}
pub fn add_trusted_key(env_dir: &Path, key: TrustedKey) -> Result<TrustRoot, TrustRootError> {
let key = validate_trusted_key(key)?;
let mut current = load_keys(env_dir)?;
apply_add(&mut current, key);
save(env_dir, ¤t)?;
Ok(TrustRoot::new(current))
}
pub fn remove_trusted_key(env_dir: &Path, key_id: &str) -> Result<TrustRoot, TrustRootError> {
let mut current = load_keys(env_dir)?;
if apply_remove(&mut current, key_id) {
save(env_dir, ¤t)?;
}
Ok(TrustRoot::new(current))
}
fn load_keys(env_dir: &Path) -> Result<Vec<TrustedKey>, TrustRootError> {
let root = load(env_dir)?;
Ok(root.keys)
}
fn save(env_dir: &Path, keys: &[TrustedKey]) -> Result<(), TrustRootError> {
let path = trust_root_path(env_dir);
copy_to_backup(&path, &env_dir.join(TRUST_ROOT_BACKUP_DIR)).map_err(|source| {
TrustRootError::Write {
path: path.clone(),
source,
}
})?;
let doc = TrustRootDocument::v1(keys.to_vec());
atomic_write_json(&path, &doc).map_err(|source| TrustRootError::Write { path, source })
}
#[cfg(test)]
mod tests {
use super::*;
use greentic_operator_trust::test_support::keypair;
use tempfile::tempdir;
#[test]
fn load_missing_file_returns_empty_trust_root() {
let dir = tempdir().unwrap();
let tr = load(dir.path()).unwrap();
assert!(tr.is_empty());
}
#[test]
fn add_then_load_roundtrips_a_key() {
let dir = tempdir().unwrap();
let (pem, key_id) = keypair(1);
let tr = add_trusted_key(
dir.path(),
TrustedKey {
key_id: key_id.clone(),
public_key_pem: pem.clone(),
},
)
.unwrap();
assert_eq!(tr.keys.len(), 1);
assert_eq!(tr.keys[0].key_id, key_id);
let reloaded = load(dir.path()).unwrap();
assert_eq!(reloaded.keys, tr.keys);
}
#[test]
fn add_with_uppercase_key_id_normalizes_to_canonical_lowercase() {
let dir = tempdir().unwrap();
let (pem, key_id) = keypair(2);
let uppercase = key_id.to_uppercase();
let tr = add_trusted_key(
dir.path(),
TrustedKey {
key_id: uppercase,
public_key_pem: pem,
},
)
.unwrap();
assert_eq!(tr.keys[0].key_id, key_id, "stored id must be canonical");
}
#[test]
fn add_with_mismatched_key_id_is_rejected() {
let dir = tempdir().unwrap();
let (pem_a, _id_a) = keypair(3);
let (_pem_b, id_b) = keypair(4);
let err = add_trusted_key(
dir.path(),
TrustedKey {
key_id: id_b,
public_key_pem: pem_a,
},
)
.expect_err("mismatched id must be rejected");
assert!(matches!(
err,
TrustRootError::Doc(TrustRootDocError::KeyIdMismatch { .. })
));
assert!(!trust_root_path(dir.path()).exists());
}
#[test]
fn add_with_empty_key_id_is_rejected() {
let dir = tempdir().unwrap();
let (pem, _) = keypair(5);
let err = add_trusted_key(
dir.path(),
TrustedKey {
key_id: " ".into(),
public_key_pem: pem,
},
)
.expect_err("empty id must be rejected");
assert!(matches!(
err,
TrustRootError::Doc(TrustRootDocError::EmptyKeyId(_))
));
}
#[test]
fn add_with_malformed_pem_is_rejected_pre_write() {
let dir = tempdir().unwrap();
let err = add_trusted_key(
dir.path(),
TrustedKey {
key_id: "abcdef".repeat(5).chars().take(32).collect(),
public_key_pem: "not-a-pem".into(),
},
)
.expect_err("bad pem must be rejected");
assert!(matches!(
err,
TrustRootError::Doc(TrustRootDocError::Key(_))
));
assert!(!trust_root_path(dir.path()).exists());
}
#[test]
fn add_replaces_existing_key_with_same_key_id() {
let dir = tempdir().unwrap();
let (pem, id) = keypair(6);
add_trusted_key(
dir.path(),
TrustedKey {
key_id: id.clone(),
public_key_pem: pem.clone(),
},
)
.unwrap();
let tr = add_trusted_key(
dir.path(),
TrustedKey {
key_id: id.to_uppercase(),
public_key_pem: pem,
},
)
.unwrap();
assert_eq!(tr.keys.len(), 1, "duplicate key_id must dedup");
}
#[test]
fn add_two_distinct_keys_yields_two_entries() {
let dir = tempdir().unwrap();
let (pem_a, id_a) = keypair(7);
let (pem_b, id_b) = keypair(8);
add_trusted_key(
dir.path(),
TrustedKey {
key_id: id_a,
public_key_pem: pem_a,
},
)
.unwrap();
let tr = add_trusted_key(
dir.path(),
TrustedKey {
key_id: id_b,
public_key_pem: pem_b,
},
)
.unwrap();
assert_eq!(tr.keys.len(), 2);
}
#[test]
fn remove_drops_only_matching_key() {
let dir = tempdir().unwrap();
let (pem_a, id_a) = keypair(9);
let (pem_b, id_b) = keypair(10);
add_trusted_key(
dir.path(),
TrustedKey {
key_id: id_a.clone(),
public_key_pem: pem_a,
},
)
.unwrap();
add_trusted_key(
dir.path(),
TrustedKey {
key_id: id_b.clone(),
public_key_pem: pem_b,
},
)
.unwrap();
let tr = remove_trusted_key(dir.path(), &id_a).unwrap();
assert_eq!(tr.keys.len(), 1);
assert_eq!(tr.keys[0].key_id, id_b);
}
#[test]
fn remove_on_fresh_env_does_not_create_trust_root_file() {
let dir = tempdir().unwrap();
assert!(!trust_root_path(dir.path()).exists());
let tr = remove_trusted_key(dir.path(), "00ff00ff00ff00ff00ff00ff00ff00ff").unwrap();
assert!(tr.is_empty());
assert!(
!trust_root_path(dir.path()).exists(),
"no-op remove must not create an empty trust-root.json"
);
}
#[test]
fn remove_unknown_key_is_a_silent_noop() {
let dir = tempdir().unwrap();
let (pem, id) = keypair(11);
add_trusted_key(
dir.path(),
TrustedKey {
key_id: id.clone(),
public_key_pem: pem,
},
)
.unwrap();
let tr = remove_trusted_key(dir.path(), "00ff00ff00ff00ff00ff00ff00ff00ff").unwrap();
assert_eq!(tr.keys.len(), 1, "non-matching removal is a no-op");
assert_eq!(tr.keys[0].key_id, id);
}
#[test]
fn add_writes_prior_trust_root_to_backups_dir() {
let dir = tempdir().unwrap();
let (pem_a, id_a) = keypair(40);
let (pem_b, id_b) = keypair(41);
add_trusted_key(
dir.path(),
TrustedKey {
key_id: id_a.clone(),
public_key_pem: pem_a,
},
)
.unwrap();
add_trusted_key(
dir.path(),
TrustedKey {
key_id: id_b,
public_key_pem: pem_b,
},
)
.unwrap();
let backups: Vec<_> = std::fs::read_dir(dir.path().join("backups"))
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_string_lossy()
.starts_with("trust-root.json.")
})
.collect();
assert!(
!backups.is_empty(),
"expected a trust-root backup file under backups/"
);
let backup_contents = std::fs::read_to_string(backups[0].path()).unwrap();
let parsed: TrustRootDocument = serde_json::from_str(&backup_contents).unwrap();
assert_eq!(parsed.keys.len(), 1);
assert!(parsed.keys[0].key_id.eq_ignore_ascii_case(&id_a));
}
#[test]
fn remove_writes_prior_trust_root_to_backups_dir() {
let dir = tempdir().unwrap();
let (pem, id) = keypair(42);
add_trusted_key(
dir.path(),
TrustedKey {
key_id: id.clone(),
public_key_pem: pem,
},
)
.unwrap();
remove_trusted_key(dir.path(), &id).unwrap();
let backups: Vec<_> = std::fs::read_dir(dir.path().join("backups"))
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_string_lossy()
.starts_with("trust-root.json.")
})
.collect();
assert_eq!(backups.len(), 1, "remove must back up its predecessor");
let parsed: TrustRootDocument =
serde_json::from_str(&std::fs::read_to_string(backups[0].path()).unwrap()).unwrap();
assert_eq!(parsed.keys.len(), 1);
assert!(parsed.keys[0].key_id.eq_ignore_ascii_case(&id));
}
#[test]
fn unknown_schema_is_rejected_on_load() {
let dir = tempdir().unwrap();
std::fs::write(
trust_root_path(dir.path()),
br#"{"schema":"greentic.trust-root.v999","keys":[]}"#,
)
.unwrap();
let err = load(dir.path()).expect_err("bad schema must reject");
assert!(matches!(
err,
TrustRootError::Doc(TrustRootDocError::BadSchema { .. })
));
}
#[test]
fn malformed_json_is_rejected_on_load() {
let dir = tempdir().unwrap();
std::fs::write(trust_root_path(dir.path()), b"{not json}").unwrap();
let err = load(dir.path()).expect_err("bad json must reject");
assert!(matches!(err, TrustRootError::Parse { .. }));
}
}