a73x

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"));
    }
}