3c864c6c
Merge branch '003-key-trust-allowlist': Key trust allowlist for sync verification
a73x 2026-03-21 07:49
diff --git a/src/cli.rs b/src/cli.rs index 506ce77..d38fde8 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -40,6 +40,10 @@ pub enum Commands { #[arg(long)] force: bool, }, /// Manage trusted keys #[command(subcommand)] Key(KeyCmd), } #[derive(Subcommand)] @@ -127,6 +131,28 @@ pub enum IssueCmd { } #[derive(Subcommand)] pub enum KeyCmd { /// Add a trusted public key Add { /// Base64-encoded Ed25519 public key pubkey: Option<String>, /// Read public key from your own signing key #[arg(long = "self")] self_key: bool, /// Human-readable label for the key #[arg(long)] label: Option<String>, }, /// List trusted public keys List, /// Remove a trusted public key Remove { /// Base64-encoded public key to remove pubkey: String, }, } #[derive(Subcommand)] pub enum PatchCmd { /// Create a new patch for review Create { diff --git a/src/error.rs b/src/error.rs index 5931733..2295ef2 100644 --- a/src/error.rs +++ b/src/error.rs @@ -22,4 +22,7 @@ pub enum Error { #[error("no signing key found — run 'collab init-key' to generate one")] KeyNotFound, #[error("untrusted key: {0}")] UntrustedKey(String), } diff --git a/src/lib.rs b/src/lib.rs index 5a806e4..5f3d67a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,10 +8,11 @@ pub mod patch; pub mod state; pub mod signing; pub mod sync; pub mod trust; pub mod tui; use base64::Engine; use cli::{Commands, IssueCmd, PatchCmd}; use cli::{Commands, IssueCmd, KeyCmd, PatchCmd}; use event::ReviewVerdict; use git2::Repository; use state::{IssueStatus, PatchStatus}; @@ -268,5 +269,75 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { println!("Public key: {}", pubkey_b64); Ok(()) } Commands::Key(cmd) => match cmd { KeyCmd::Add { pubkey, self_key, label, } => { if self_key && pubkey.is_some() { return Err(error::Error::Cmd( "cannot specify both --self and a public key argument".to_string(), )); } let key = if self_key { let config_dir = signing::signing_key_dir()?; let vk = signing::load_verifying_key(&config_dir)?; base64::engine::general_purpose::STANDARD.encode(vk.to_bytes()) } else { match pubkey { Some(k) => k, None => { return Err(error::Error::Cmd( "public key argument required (or use --self)".to_string(), )); } } }; trust::validate_pubkey(&key)?; let added = trust::save_trusted_key(repo, &key, label.as_deref())?; if added { let label_display = label .as_ref() .map(|l| format!(" ({})", l)) .unwrap_or_default(); println!("Trusted key added: {}{}", key, label_display); } else { println!("Key {} is already trusted.", key); } Ok(()) } KeyCmd::List => { let policy = trust::load_trust_policy(repo)?; match policy { trust::TrustPolicy::Unconfigured => { println!("No trusted keys configured."); } trust::TrustPolicy::Configured(keys) => { if keys.is_empty() { println!("No trusted keys configured."); } else { for k in &keys { match &k.label { Some(l) => println!("{} {}", k.pubkey, l), None => println!("{}", k.pubkey), } } } } } Ok(()) } KeyCmd::Remove { pubkey } => { let removed = trust::remove_trusted_key(repo, &pubkey)?; let label_display = removed .label .as_ref() .map(|l| format!(" ({})", l)) .unwrap_or_default(); println!("Removed trusted key: {}{}", removed.pubkey, label_display); Ok(()) } }, } } diff --git a/src/signing.rs b/src/signing.rs index 9f41a64..bcc95bd 100644 --- a/src/signing.rs +++ b/src/signing.rs @@ -47,6 +47,8 @@ pub enum VerifyStatus { Invalid, /// No signature or pubkey field in event.json. Missing, /// Signature valid but key not in trusted set. Untrusted, } /// Detailed verification result for a single commit. @@ -266,6 +268,16 @@ pub fn verify_ref( error: Some("missing signature".to_string()), }); } VerifyStatus::Untrusted => { // verify_signed_event never returns Untrusted, // but handle it for exhaustiveness results.push(SignatureVerificationResult { commit_id: oid, status: VerifyStatus::Untrusted, pubkey: Some(signed.pubkey), error: Some("untrusted key".to_string()), }); } } } } else { diff --git a/src/sync.rs b/src/sync.rs index 69192e5..8cd7db1 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -6,6 +6,7 @@ use crate::dag; use crate::error::Error; use crate::identity::get_author; use crate::signing; use crate::trust; /// Add collab refspecs to all remotes. pub fn init(repo: &Repository) -> Result<(), Error> { @@ -125,10 +126,22 @@ fn reconcile_refs( .collect() }; // Load trust policy once for all refs of this kind let trust_policy = trust::load_trust_policy(repo)?; let mut warned_unconfigured = false; for (remote_ref, id) in &sync_refs { // Verify all commits on the remote ref before reconciling match signing::verify_ref(repo, remote_ref) { Ok(results) => { // Apply trust checking let results = trust::check_trust(&results, &trust_policy); if matches!(trust_policy, trust::TrustPolicy::Unconfigured) && !warned_unconfigured { eprintln!("warning: no trusted keys configured — all valid signatures accepted. Run 'collab key add --self' to start."); warned_unconfigured = true; } let failures: Vec<_> = results .iter() .filter(|r| r.status != signing::VerifyStatus::Valid) diff --git a/src/trust.rs b/src/trust.rs new file mode 100644 index 0000000..c84ac8d --- /dev/null +++ b/src/trust.rs @@ -0,0 +1,505 @@ use std::collections::HashSet; use std::fs; use std::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") } /// 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(()) } /// Load the trust policy from the trusted keys file. /// /// Returns `TrustPolicy::Unconfigured` if the file does not exist. /// Returns `TrustPolicy::Configured(keys)` if the file exists (even if empty). /// Malformed lines are skipped with a warning to stderr. /// Duplicate keys are deduplicated (last wins for label). pub fn load_trust_policy(repo: &Repository) -> Result<TrustPolicy, Error> { let path = trusted_keys_path(repo); if !path.exists() { return Ok(TrustPolicy::Unconfigured); } let content = fs::read_to_string(&path)?; 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(TrustPolicy::Configured(keys)) } /// 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 the 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); // Ensure directory exists if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } // Check for duplicates if path.exists() { let policy = load_trust_policy(repo)?; if let TrustPolicy::Configured(ref keys) = policy { 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) } /// Remove a trusted key from the file. Returns the removed entry. pub fn remove_trusted_key(repo: &Repository, pubkey: &str) -> Result<TrustedKey, Error> { let path = trusted_keys_path(repo); let policy = load_trust_policy(repo)?; match policy { TrustPolicy::Unconfigured => { return Err(Error::Cmd(format!( "key {} is not in the trusted keys list", pubkey ))); } TrustPolicy::Configured(keys) => { let removed = keys.iter().find(|k| k.pubkey == pubkey).cloned(); match removed { None => { return 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) } } } } } /// 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)"), } } } diff --git a/tests/trust_test.rs b/tests/trust_test.rs new file mode 100644 index 0000000..f8bfb07 --- /dev/null +++ b/tests/trust_test.rs @@ -0,0 +1,203 @@ mod common; use common::TestRepo; // =========================================================================== // T006: Integration tests for `collab key add` // =========================================================================== #[test] fn test_key_add_valid_key() { let repo = TestRepo::new("Alice", "alice@example.com"); // Add own key via --self let out = repo.run_ok(&["key", "add", "--self"]); assert!( out.contains("Trusted key added:"), "should confirm key added, got: {}", out ); } #[test] fn test_key_add_with_label() { let repo = TestRepo::new("Alice", "alice@example.com"); let out = repo.run_ok(&["key", "add", "--self", "--label", "Alice (main laptop)"]); assert!(out.contains("Trusted key added:")); assert!(out.contains("Alice (main laptop)")); // Verify it shows up in list let out = repo.run_ok(&["key", "list"]); assert!(out.contains("Alice (main laptop)")); } #[test] fn test_key_add_duplicate_prints_message() { let repo = TestRepo::new("Alice", "alice@example.com"); repo.run_ok(&["key", "add", "--self"]); let out = repo.run_ok(&["key", "add", "--self"]); assert!( out.contains("already trusted"), "should say already trusted, got: {}", out ); } #[test] fn test_key_add_invalid_key_returns_error() { let repo = TestRepo::new("Alice", "alice@example.com"); let err = repo.run_err(&["key", "add", "not-a-valid-key!!!"]); assert!( err.contains("invalid") || err.contains("base64"), "should mention invalid key, got: {}", err ); } #[test] fn test_key_add_self_and_pubkey_errors() { let repo = TestRepo::new("Alice", "alice@example.com"); let err = repo.run_err(&["key", "add", "--self", "somepubkey"]); assert!( err.contains("cannot specify both"), "should error on --self with pubkey, got: {}", err ); } #[test] fn test_key_add_explicit_pubkey() { let repo = TestRepo::new("Alice", "alice@example.com"); // Get our own public key to use as a valid key let out = repo.run_ok(&["key", "add", "--self", "--label", "Me"]); assert!(out.contains("Trusted key added:")); // List should show the key let out = repo.run_ok(&["key", "list"]); assert!(out.contains("Me")); } // =========================================================================== // T016: Integration tests for `collab key list` // =========================================================================== #[test] fn test_key_list_no_keys() { let repo = TestRepo::new("Alice", "alice@example.com"); let out = repo.run_ok(&["key", "list"]); assert!( out.contains("No trusted keys configured"), "should say no keys, got: {}", out ); } #[test] fn test_key_list_shows_keys_and_labels() { let repo = TestRepo::new("Alice", "alice@example.com"); repo.run_ok(&["key", "add", "--self", "--label", "Alice"]); let out = repo.run_ok(&["key", "list"]); assert!(out.contains("Alice"), "should show label, got: {}", out); // Should contain a base64 key string (44 chars for ed25519) assert!( out.lines().any(|l| l.len() >= 44), "should contain a key string, got: {}", out ); } // =========================================================================== // T017: Integration tests for `collab key remove` // =========================================================================== #[test] fn test_key_remove_existing() { let repo = TestRepo::new("Alice", "alice@example.com"); // Add a key, extract the pubkey from the list repo.run_ok(&["key", "add", "--self", "--label", "Alice"]); let list = repo.run_ok(&["key", "list"]); let pubkey = list.lines().next().unwrap().split_whitespace().next().unwrap(); let out = repo.run_ok(&["key", "remove", pubkey]); assert!( out.contains("Removed trusted key:"), "should confirm removal, got: {}", out ); assert!(out.contains("Alice"), "should show label of removed key"); // List should now be empty let out = repo.run_ok(&["key", "list"]); assert!(out.contains("No trusted keys configured")); } #[test] fn test_key_remove_nonexistent_errors() { let repo = TestRepo::new("Alice", "alice@example.com"); // Add a key first so the file exists repo.run_ok(&["key", "add", "--self"]); let err = repo.run_err(&["key", "remove", "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY="]); assert!( err.contains("not in the trusted keys list"), "should say not found, got: {}", err ); } // =========================================================================== // T021: Edge case tests // =========================================================================== #[test] fn test_key_add_then_remove_then_readd() { let repo = TestRepo::new("Alice", "alice@example.com"); repo.run_ok(&["key", "add", "--self", "--label", "Original"]); let list = repo.run_ok(&["key", "list"]); let pubkey = list.lines().next().unwrap().split_whitespace().next().unwrap().to_string(); // Remove repo.run_ok(&["key", "remove", &pubkey]); // Re-add with a different label let out = repo.run_ok(&["key", "add", &pubkey, "--label", "Re-added"]); assert!(out.contains("Trusted key added:")); let list = repo.run_ok(&["key", "list"]); assert!(list.contains("Re-added")); } #[test] fn test_key_list_with_corrupted_file() { let repo = TestRepo::new("Alice", "alice@example.com"); // Add a valid key first repo.run_ok(&["key", "add", "--self", "--label", "Good key"]); // Manually corrupt the file by adding a bad line let git_dir = repo.dir.path().join(".git/collab/trusted-keys"); let content = std::fs::read_to_string(&git_dir).unwrap(); std::fs::write( &git_dir, format!("{}garbage_not_a_valid_key BadEntry\n", content), ) .unwrap(); // List should still work, showing valid keys and skipping the bad one let out = repo.run_ok(&["key", "list"]); assert!(out.contains("Good key"), "valid key should still appear"); // The bad line should be skipped (warning goes to stderr) }