src/trust.rs
Ref: Size: 28.4 KiB
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use ed25519_dalek::VerifyingKey;
use git2::Repository;
use crate::error::Error;
use crate::signing::{SignatureVerificationResult, VerifyStatus};
/// A single trusted key entry.
#[derive(Debug, Clone)]
pub struct TrustedKey {
/// Base64-encoded Ed25519 public key.
pub pubkey: String,
/// Optional human-readable label.
pub label: Option<String>,
}
/// Loaded trust policy.
#[derive(Debug)]
pub enum TrustPolicy {
/// No trusted keys file exists. Fall back to accepting any valid signature.
Unconfigured,
/// Trusted keys file exists (possibly empty). Enforce allowlist.
Configured(Vec<TrustedKey>),
}
/// Return the path to the trusted keys file for the given repo.
pub fn trusted_keys_path(repo: &Repository) -> PathBuf {
repo.path().join("collab/trusted-keys")
}
/// Return the path to the global trusted keys file (~/.config/git-collab/trusted-keys).
pub fn global_trusted_keys_path() -> Result<PathBuf, Error> {
let config_dir = crate::signing::signing_key_dir()?;
Ok(config_dir.join("trusted-keys"))
}
/// Validate that a string is a valid base64-encoded 32-byte Ed25519 public key.
pub fn validate_pubkey(pubkey: &str) -> Result<(), Error> {
let bytes = STANDARD
.decode(pubkey.trim())
.map_err(|e| Error::Verification(format!("invalid public key: base64 decode failed: {}", e)))?;
if bytes.len() != 32 {
return Err(Error::Verification(format!(
"invalid public key: expected 32 bytes, got {}",
bytes.len()
)));
}
let key_bytes: [u8; 32] = bytes.try_into().unwrap();
VerifyingKey::from_bytes(&key_bytes)
.map_err(|e| Error::Verification(format!("invalid public key: not a valid Ed25519 point: {}", e)))?;
Ok(())
}
/// Parse trusted keys from a file. Returns an empty Vec if the file does not exist.
/// Returns `None` if the file does not exist (to distinguish from an empty file).
/// Malformed lines are skipped with a warning to stderr.
/// Duplicate keys are deduplicated (last wins for label).
pub fn parse_trusted_keys_file(path: &Path) -> Result<Vec<TrustedKey>, Error> {
if !path.exists() {
return Ok(Vec::new());
}
parse_trusted_keys_content(&fs::read_to_string(path)?)
}
/// Parse trusted keys from a string content.
fn parse_trusted_keys_content(content: &str) -> Result<Vec<TrustedKey>, Error> {
let mut seen = HashSet::new();
let mut keys = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let (pubkey, label) = match trimmed.split_once(' ') {
Some((k, l)) => (k.to_string(), Some(l.to_string())),
None => (trimmed.to_string(), None),
};
// Validate the key; skip malformed entries
if let Err(e) = validate_pubkey(&pubkey) {
eprintln!("warning: skipping malformed trusted key: {}: {}", pubkey, e);
continue;
}
// Deduplicate: remove earlier entry if present
if seen.contains(&pubkey) {
keys.retain(|k: &TrustedKey| k.pubkey != pubkey);
}
seen.insert(pubkey.clone());
keys.push(TrustedKey { pubkey, label });
}
Ok(keys)
}
/// Merge two key lists. Keys from `later` override labels from `earlier` on duplicates.
fn merge_keys(earlier: Vec<TrustedKey>, later: Vec<TrustedKey>) -> Vec<TrustedKey> {
let mut seen = HashSet::new();
let mut merged = Vec::new();
// Add all from earlier
for k in earlier {
seen.insert(k.pubkey.clone());
merged.push(k);
}
// Add/override from later
for k in later {
if seen.contains(&k.pubkey) {
// Replace earlier entry with later (last wins for label)
for existing in merged.iter_mut() {
if existing.pubkey == k.pubkey {
existing.label = k.label.clone();
break;
}
}
} else {
seen.insert(k.pubkey.clone());
merged.push(k);
}
}
merged
}
/// Load the trust policy from both global and repo-local trusted keys files.
///
/// Returns `TrustPolicy::Unconfigured` if neither file exists.
/// Returns `TrustPolicy::Configured(keys)` if either file exists (even if empty).
/// Global keys are loaded first, then repo-local keys are merged in.
/// Repo-local keys take precedence for labels on duplicate keys.
pub fn load_trust_policy(repo: &Repository) -> Result<TrustPolicy, Error> {
let global_path = global_trusted_keys_path().ok();
load_trust_policy_with_global(repo, global_path.as_deref())
}
/// Load trust policy with an explicit global path (for testing).
pub fn load_trust_policy_with_global(repo: &Repository, global_path: Option<&Path>) -> Result<TrustPolicy, Error> {
let repo_path = trusted_keys_path(repo);
let repo_exists = repo_path.exists();
let global_exists = global_path.is_some_and(|p| p.exists());
if !repo_exists && !global_exists {
return Ok(TrustPolicy::Unconfigured);
}
let global_keys = if let Some(gp) = global_path {
parse_trusted_keys_file(gp)?
} else {
Vec::new()
};
let repo_keys = if repo_exists {
parse_trusted_keys_file(&repo_path)?
} else {
Vec::new()
};
let merged = merge_keys(global_keys, repo_keys);
Ok(TrustPolicy::Configured(merged))
}
/// Check whether a pubkey is in the trusted set.
pub fn is_key_trusted(keys: &[TrustedKey], pubkey: &str) -> bool {
keys.iter().any(|k| k.pubkey == pubkey)
}
/// Save (append) a trusted key to an arbitrary trusted keys file.
///
/// Creates the parent directory and file if needed.
/// Returns `true` if the key was added, `false` if it was already present.
pub fn save_trusted_key_to_file(path: &Path, pubkey: &str, label: Option<&str>) -> Result<bool, Error> {
// Ensure directory exists
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
// Check for duplicates
if path.exists() {
let keys = parse_trusted_keys_file(path)?;
if is_key_trusted(&keys, pubkey) {
return Ok(false);
}
}
// Append key
let line = match label {
Some(l) => format!("{} {}\n", pubkey, l),
None => format!("{}\n", pubkey),
};
use std::io::Write;
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
file.write_all(line.as_bytes())?;
Ok(true)
}
/// Save (append) a trusted key to the repo-local trusted keys file.
///
/// Creates the `.git/collab/` directory and file if needed.
/// Returns `true` if the key was added, `false` if it was already trusted.
pub fn save_trusted_key(repo: &Repository, pubkey: &str, label: Option<&str>) -> Result<bool, Error> {
let path = trusted_keys_path(repo);
save_trusted_key_to_file(&path, pubkey, label)
}
/// Save (append) a trusted key to the global trusted keys file.
///
/// Creates `~/.config/git-collab/` directory and file if needed.
/// Returns `true` if the key was added, `false` if it was already trusted.
pub fn save_trusted_key_global(pubkey: &str, label: Option<&str>) -> Result<bool, Error> {
let path = global_trusted_keys_path()?;
save_trusted_key_to_file(&path, pubkey, label)
}
/// Remove a trusted key from an arbitrary trusted keys file. Returns the removed entry.
pub fn remove_trusted_key_from_file(path: &Path, pubkey: &str) -> Result<TrustedKey, Error> {
if !path.exists() {
return Err(Error::Cmd(format!(
"key {} is not in the trusted keys list",
pubkey
)));
}
let keys = parse_trusted_keys_file(path)?;
let removed = keys.iter().find(|k| k.pubkey == pubkey).cloned();
match removed {
None => Err(Error::Cmd(format!(
"key {} is not in the trusted keys list",
pubkey
))),
Some(removed_key) => {
// Rewrite file without the removed key
let content = fs::read_to_string(path)?;
let mut new_lines = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
new_lines.push(line.to_string());
continue;
}
let key_part = trimmed.split_once(' ')
.map(|(k, _)| k)
.unwrap_or(trimmed);
if key_part != pubkey {
new_lines.push(line.to_string());
}
}
let new_content = if new_lines.is_empty() {
String::new()
} else {
new_lines.join("\n") + "\n"
};
fs::write(path, new_content)?;
Ok(removed_key)
}
}
}
/// Remove a trusted key from the repo-local file. Returns the removed entry.
pub fn remove_trusted_key(repo: &Repository, pubkey: &str) -> Result<TrustedKey, Error> {
let path = trusted_keys_path(repo);
remove_trusted_key_from_file(&path, pubkey)
}
/// Remove a trusted key from the global file. Returns the removed entry.
pub fn remove_trusted_key_global(pubkey: &str) -> Result<TrustedKey, Error> {
let path = global_trusted_keys_path()?;
remove_trusted_key_from_file(&path, pubkey)
}
/// Check trust for a set of signature verification results.
///
/// If `TrustPolicy::Unconfigured`, returns the results unchanged.
/// If `TrustPolicy::Configured`, changes `Valid` results to `Untrusted` when the
/// pubkey is not in the trusted set. All other statuses pass through unchanged.
pub fn check_trust(
results: &[SignatureVerificationResult],
policy: &TrustPolicy,
) -> Vec<SignatureVerificationResult> {
match policy {
TrustPolicy::Unconfigured => results.to_vec(),
TrustPolicy::Configured(keys) => {
let trusted_set: HashSet<&str> = keys.iter().map(|k| k.pubkey.as_str()).collect();
results
.iter()
.map(|r| {
if r.status == VerifyStatus::Valid {
if let Some(ref pk) = r.pubkey {
if !trusted_set.contains(pk.as_str()) {
return SignatureVerificationResult {
commit_id: r.commit_id,
status: VerifyStatus::Untrusted,
pubkey: r.pubkey.clone(),
error: Some(format!("untrusted key: {}", pk)),
};
}
}
}
r.clone()
})
.collect()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use git2::Oid;
fn test_repo() -> (TempDir, Repository) {
let dir = TempDir::new().unwrap();
let repo = Repository::init(dir.path()).unwrap();
(dir, repo)
}
/// Generate a valid Ed25519 public key in base64 for testing.
fn valid_test_pubkey() -> String {
use ed25519_dalek::SigningKey;
use rand_core::OsRng;
let sk = SigningKey::generate(&mut OsRng);
STANDARD.encode(sk.verifying_key().to_bytes())
}
// =====================================================
// T004: Unit tests for validate_pubkey()
// =====================================================
#[test]
fn validate_pubkey_accepts_valid_key() {
let pk = valid_test_pubkey();
assert!(validate_pubkey(&pk).is_ok());
}
#[test]
fn validate_pubkey_rejects_invalid_base64() {
let result = validate_pubkey("not-valid-base64!!!");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("base64"), "error should mention base64: {}", err);
}
#[test]
fn validate_pubkey_rejects_wrong_byte_length() {
// 16 bytes instead of 32
let short = STANDARD.encode(vec![0u8; 16]);
let result = validate_pubkey(&short);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("32 bytes"), "error should mention 32 bytes: {}", err);
}
#[test]
fn validate_pubkey_rejects_too_long() {
let long = STANDARD.encode(vec![0u8; 64]);
let result = validate_pubkey(&long);
assert!(result.is_err());
}
#[test]
fn validate_pubkey_accepts_whitespace_trimmed() {
let pk = valid_test_pubkey();
let padded = format!(" {} ", pk);
assert!(validate_pubkey(&padded).is_ok());
}
// =====================================================
// T005: Unit tests for load_trust_policy() and save_trusted_key()
// =====================================================
#[test]
fn load_trust_policy_returns_unconfigured_when_no_file() {
let (_dir, repo) = test_repo();
let policy = load_trust_policy(&repo).unwrap();
assert!(matches!(policy, TrustPolicy::Unconfigured));
}
#[test]
fn load_trust_policy_returns_configured_empty_for_empty_file() {
let (_dir, repo) = test_repo();
let path = trusted_keys_path(&repo);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, "").unwrap();
let policy = load_trust_policy(&repo).unwrap();
match policy {
TrustPolicy::Configured(keys) => assert!(keys.is_empty()),
_ => panic!("expected Configured"),
}
}
#[test]
fn load_trust_policy_parses_keys_and_labels() {
let (_dir, repo) = test_repo();
let pk1 = valid_test_pubkey();
let pk2 = valid_test_pubkey();
let path = trusted_keys_path(&repo);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, format!("# Comment\n{} Alice\n{}\n\n", pk1, pk2)).unwrap();
let policy = load_trust_policy(&repo).unwrap();
match policy {
TrustPolicy::Configured(keys) => {
assert_eq!(keys.len(), 2);
assert_eq!(keys[0].pubkey, pk1);
assert_eq!(keys[0].label.as_deref(), Some("Alice"));
assert_eq!(keys[1].pubkey, pk2);
assert!(keys[1].label.is_none());
}
_ => panic!("expected Configured"),
}
}
#[test]
fn load_trust_policy_skips_malformed_lines() {
let (_dir, repo) = test_repo();
let pk = valid_test_pubkey();
let path = trusted_keys_path(&repo);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, format!("garbage_not_base64\n{} Good key\n", pk)).unwrap();
let policy = load_trust_policy(&repo).unwrap();
match policy {
TrustPolicy::Configured(keys) => {
assert_eq!(keys.len(), 1);
assert_eq!(keys[0].pubkey, pk);
}
_ => panic!("expected Configured"),
}
}
#[test]
fn load_trust_policy_deduplicates_keys() {
let (_dir, repo) = test_repo();
let pk = valid_test_pubkey();
let path = trusted_keys_path(&repo);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, format!("{} First\n{} Second\n", pk, pk)).unwrap();
let policy = load_trust_policy(&repo).unwrap();
match policy {
TrustPolicy::Configured(keys) => {
assert_eq!(keys.len(), 1);
// Last wins for label
assert_eq!(keys[0].label.as_deref(), Some("Second"));
}
_ => panic!("expected Configured"),
}
}
#[test]
fn save_trusted_key_creates_file_and_appends() {
let (_dir, repo) = test_repo();
let pk1 = valid_test_pubkey();
let pk2 = valid_test_pubkey();
save_trusted_key(&repo, &pk1, Some("Alice")).unwrap();
save_trusted_key(&repo, &pk2, None).unwrap();
let policy = load_trust_policy(&repo).unwrap();
match policy {
TrustPolicy::Configured(keys) => {
assert_eq!(keys.len(), 2);
assert_eq!(keys[0].pubkey, pk1);
assert_eq!(keys[0].label.as_deref(), Some("Alice"));
assert_eq!(keys[1].pubkey, pk2);
assert!(keys[1].label.is_none());
}
_ => panic!("expected Configured"),
}
}
#[test]
fn save_trusted_key_prevents_duplicates() {
let (_dir, repo) = test_repo();
let pk = valid_test_pubkey();
let added = save_trusted_key(&repo, &pk, Some("Alice")).unwrap();
assert!(added, "first add should return true");
let added = save_trusted_key(&repo, &pk, Some("Alice again")).unwrap();
assert!(!added, "duplicate add should return false");
let policy = load_trust_policy(&repo).unwrap();
match policy {
TrustPolicy::Configured(keys) => {
assert_eq!(keys.len(), 1, "should not have duplicates");
}
_ => panic!("expected Configured"),
}
}
// =====================================================
// T012: Unit tests for check_trust()
// =====================================================
fn make_result(status: VerifyStatus, pubkey: Option<&str>) -> SignatureVerificationResult {
SignatureVerificationResult {
commit_id: Oid::zero(),
status,
pubkey: pubkey.map(|s| s.to_string()),
error: None,
}
}
#[test]
fn check_trust_unconfigured_passes_all_through() {
let pk = valid_test_pubkey();
let results = vec![make_result(VerifyStatus::Valid, Some(&pk))];
let checked = check_trust(&results, &TrustPolicy::Unconfigured);
assert_eq!(checked.len(), 1);
assert_eq!(checked[0].status, VerifyStatus::Valid);
}
#[test]
fn check_trust_configured_with_trusted_key_returns_valid() {
let pk = valid_test_pubkey();
let keys = vec![TrustedKey { pubkey: pk.clone(), label: None }];
let results = vec![make_result(VerifyStatus::Valid, Some(&pk))];
let checked = check_trust(&results, &TrustPolicy::Configured(keys));
assert_eq!(checked[0].status, VerifyStatus::Valid);
}
#[test]
fn check_trust_configured_with_untrusted_key_returns_untrusted() {
let pk_trusted = valid_test_pubkey();
let pk_untrusted = valid_test_pubkey();
let keys = vec![TrustedKey { pubkey: pk_trusted, label: None }];
let results = vec![make_result(VerifyStatus::Valid, Some(&pk_untrusted))];
let checked = check_trust(&results, &TrustPolicy::Configured(keys));
assert_eq!(checked[0].status, VerifyStatus::Untrusted);
assert!(checked[0].error.as_ref().unwrap().contains(&pk_untrusted));
}
#[test]
fn check_trust_passes_through_missing_and_invalid() {
let keys = vec![];
let results = vec![
make_result(VerifyStatus::Missing, None),
make_result(VerifyStatus::Invalid, Some("whatever")),
];
let checked = check_trust(&results, &TrustPolicy::Configured(keys));
assert_eq!(checked[0].status, VerifyStatus::Missing);
assert_eq!(checked[1].status, VerifyStatus::Invalid);
}
#[test]
fn check_trust_empty_configured_rejects_all_valid() {
let pk = valid_test_pubkey();
let results = vec![make_result(VerifyStatus::Valid, Some(&pk))];
let checked = check_trust(&results, &TrustPolicy::Configured(vec![]));
assert_eq!(checked[0].status, VerifyStatus::Untrusted);
}
// =====================================================
// T009: Unit tests for remove_trusted_key()
// =====================================================
#[test]
fn remove_trusted_key_removes_and_returns_entry() {
let (_dir, repo) = test_repo();
let pk1 = valid_test_pubkey();
let pk2 = valid_test_pubkey();
save_trusted_key(&repo, &pk1, Some("Alice")).unwrap();
save_trusted_key(&repo, &pk2, Some("Bob")).unwrap();
let removed = remove_trusted_key(&repo, &pk1).unwrap();
assert_eq!(removed.pubkey, pk1);
assert_eq!(removed.label.as_deref(), Some("Alice"));
let policy = load_trust_policy(&repo).unwrap();
match policy {
TrustPolicy::Configured(keys) => {
assert_eq!(keys.len(), 1);
assert_eq!(keys[0].pubkey, pk2);
}
_ => panic!("expected Configured"),
}
}
#[test]
fn remove_trusted_key_errors_when_not_found() {
let (_dir, repo) = test_repo();
let pk = valid_test_pubkey();
save_trusted_key(&repo, &pk, None).unwrap();
let other_pk = valid_test_pubkey();
let result = remove_trusted_key(&repo, &other_pk);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not in the trusted keys list"));
}
#[test]
fn remove_last_key_leaves_empty_configured_file() {
let (_dir, repo) = test_repo();
let pk = valid_test_pubkey();
save_trusted_key(&repo, &pk, None).unwrap();
remove_trusted_key(&repo, &pk).unwrap();
// File should still exist (Configured) but empty
let policy = load_trust_policy(&repo).unwrap();
match policy {
TrustPolicy::Configured(keys) => assert!(keys.is_empty()),
_ => panic!("expected Configured (empty)"),
}
}
// =====================================================
// Global trust store tests
// =====================================================
#[test]
fn global_trusted_keys_path_returns_config_path() {
let path = global_trusted_keys_path().unwrap();
assert!(path.to_string_lossy().contains("git-collab"));
assert!(path.to_string_lossy().ends_with("trusted-keys"));
}
#[test]
fn load_trust_policy_merges_global_and_repo_keys() {
let (_dir, repo) = test_repo();
let global_dir = TempDir::new().unwrap();
let global_path = global_dir.path().join("trusted-keys");
let pk_global = valid_test_pubkey();
let pk_repo = valid_test_pubkey();
// Write a global key
fs::write(&global_path, format!("{} GlobalAlice\n", pk_global)).unwrap();
// Write a repo key
save_trusted_key(&repo, &pk_repo, Some("RepoAlice")).unwrap();
// Load with custom global path
let policy = load_trust_policy_with_global(&repo, Some(&global_path)).unwrap();
match policy {
TrustPolicy::Configured(keys) => {
assert_eq!(keys.len(), 2, "should have both global and repo keys");
let pubkeys: Vec<&str> = keys.iter().map(|k| k.pubkey.as_str()).collect();
assert!(pubkeys.contains(&pk_global.as_str()), "should contain global key");
assert!(pubkeys.contains(&pk_repo.as_str()), "should contain repo key");
}
_ => panic!("expected Configured"),
}
}
#[test]
fn load_trust_policy_repo_key_overrides_global_label() {
let (_dir, repo) = test_repo();
let global_dir = TempDir::new().unwrap();
let global_path = global_dir.path().join("trusted-keys");
let pk = valid_test_pubkey();
// Same key in both global and repo, with different labels
fs::write(&global_path, format!("{} GlobalLabel\n", pk)).unwrap();
save_trusted_key(&repo, &pk, Some("RepoLabel")).unwrap();
let policy = load_trust_policy_with_global(&repo, Some(&global_path)).unwrap();
match policy {
TrustPolicy::Configured(keys) => {
assert_eq!(keys.len(), 1, "duplicates should be merged");
// Repo-local takes precedence (loaded second, last wins)
assert_eq!(keys[0].label.as_deref(), Some("RepoLabel"));
}
_ => panic!("expected Configured"),
}
}
#[test]
fn load_trust_policy_global_only_no_repo_file() {
let (_dir, repo) = test_repo();
let global_dir = TempDir::new().unwrap();
let global_path = global_dir.path().join("trusted-keys");
let pk = valid_test_pubkey();
fs::write(&global_path, format!("{} GlobalOnly\n", pk)).unwrap();
let policy = load_trust_policy_with_global(&repo, Some(&global_path)).unwrap();
match policy {
TrustPolicy::Configured(keys) => {
assert_eq!(keys.len(), 1);
assert_eq!(keys[0].pubkey, pk);
assert_eq!(keys[0].label.as_deref(), Some("GlobalOnly"));
}
_ => panic!("expected Configured"),
}
}
#[test]
fn load_trust_policy_no_global_file_falls_back_to_repo_only() {
let (_dir, repo) = test_repo();
let global_dir = TempDir::new().unwrap();
let global_path = global_dir.path().join("nonexistent-trusted-keys");
let pk = valid_test_pubkey();
save_trusted_key(&repo, &pk, Some("RepoOnly")).unwrap();
let policy = load_trust_policy_with_global(&repo, Some(&global_path)).unwrap();
match policy {
TrustPolicy::Configured(keys) => {
assert_eq!(keys.len(), 1);
assert_eq!(keys[0].pubkey, pk);
}
_ => panic!("expected Configured"),
}
}
#[test]
fn load_trust_policy_neither_global_nor_repo_returns_unconfigured() {
let (_dir, repo) = test_repo();
let global_dir = TempDir::new().unwrap();
let global_path = global_dir.path().join("nonexistent-trusted-keys");
let policy = load_trust_policy_with_global(&repo, Some(&global_path)).unwrap();
assert!(matches!(policy, TrustPolicy::Unconfigured));
}
#[test]
fn save_trusted_key_global_creates_file_and_appends() {
let global_dir = TempDir::new().unwrap();
let global_path = global_dir.path().join("trusted-keys");
let pk1 = valid_test_pubkey();
let pk2 = valid_test_pubkey();
let added = save_trusted_key_to_file(&global_path, &pk1, Some("Alice")).unwrap();
assert!(added, "first add should return true");
let added = save_trusted_key_to_file(&global_path, &pk2, None).unwrap();
assert!(added, "second add should return true");
let keys = parse_trusted_keys_file(&global_path).unwrap();
assert_eq!(keys.len(), 2);
assert_eq!(keys[0].pubkey, pk1);
assert_eq!(keys[0].label.as_deref(), Some("Alice"));
assert_eq!(keys[1].pubkey, pk2);
assert!(keys[1].label.is_none());
}
#[test]
fn save_trusted_key_global_prevents_duplicates() {
let global_dir = TempDir::new().unwrap();
let global_path = global_dir.path().join("trusted-keys");
let pk = valid_test_pubkey();
let added = save_trusted_key_to_file(&global_path, &pk, Some("Alice")).unwrap();
assert!(added);
let added = save_trusted_key_to_file(&global_path, &pk, Some("Alice again")).unwrap();
assert!(!added, "duplicate add should return false");
let keys = parse_trusted_keys_file(&global_path).unwrap();
assert_eq!(keys.len(), 1);
}
#[test]
fn remove_trusted_key_from_file_works() {
let global_dir = TempDir::new().unwrap();
let global_path = global_dir.path().join("trusted-keys");
let pk1 = valid_test_pubkey();
let pk2 = valid_test_pubkey();
save_trusted_key_to_file(&global_path, &pk1, Some("Alice")).unwrap();
save_trusted_key_to_file(&global_path, &pk2, Some("Bob")).unwrap();
let removed = remove_trusted_key_from_file(&global_path, &pk1).unwrap();
assert_eq!(removed.pubkey, pk1);
assert_eq!(removed.label.as_deref(), Some("Alice"));
let keys = parse_trusted_keys_file(&global_path).unwrap();
assert_eq!(keys.len(), 1);
assert_eq!(keys[0].pubkey, pk2);
}
#[test]
fn remove_trusted_key_from_file_errors_when_not_found() {
let global_dir = TempDir::new().unwrap();
let global_path = global_dir.path().join("trusted-keys");
let pk = valid_test_pubkey();
save_trusted_key_to_file(&global_path, &pk, None).unwrap();
let other_pk = valid_test_pubkey();
let result = remove_trusted_key_from_file(&global_path, &other_pk);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not in the trusted keys list"));
}
}