a73x

eb29a934

Add a trusted-key allowlist for signature verification

a73x   2026-03-21 07:49

Commit message
Add a trusted-key allowlist for signature verification

src/cli.rs
Old New
@@ -40,6 +40,10 @@ pub enum Commands {
40 #[arg(long)] 40 #[arg(long)]
41 force: bool, 41 force: bool,
42 }, 42 },
43
44 /// Manage trusted keys
45 #[command(subcommand)]
46 Key(KeyCmd),
43 } 47 }
44 48
45 #[derive(Subcommand)] 49 #[derive(Subcommand)]
@@ -127,6 +131,28 @@ pub enum IssueCmd {
127 } 131 }
128 132
129 #[derive(Subcommand)] 133 #[derive(Subcommand)]
134 pub enum KeyCmd {
135 /// Add a trusted public key
136 Add {
137 /// Base64-encoded Ed25519 public key
138 pubkey: Option<String>,
139 /// Read public key from your own signing key
140 #[arg(long = "self")]
141 self_key: bool,
142 /// Human-readable label for the key
143 #[arg(long)]
144 label: Option<String>,
145 },
146 /// List trusted public keys
147 List,
148 /// Remove a trusted public key
149 Remove {
150 /// Base64-encoded public key to remove
151 pubkey: String,
152 },
153 }
154
155 #[derive(Subcommand)]
130 pub enum PatchCmd { 156 pub enum PatchCmd {
131 /// Create a new patch for review 157 /// Create a new patch for review
132 Create { 158 Create {
src/error.rs
Old New
@@ -22,4 +22,7 @@ pub enum Error {
22 22
23 #[error("no signing key found — run 'collab init-key' to generate one")] 23 #[error("no signing key found — run 'collab init-key' to generate one")]
24 KeyNotFound, 24 KeyNotFound,
25
26 #[error("untrusted key: {0}")]
27 UntrustedKey(String),
25 } 28 }
src/lib.rs
Old New
@@ -8,10 +8,11 @@ pub mod patch;
8 pub mod state; 8 pub mod state;
9 pub mod signing; 9 pub mod signing;
10 pub mod sync; 10 pub mod sync;
11 pub mod trust;
11 pub mod tui; 12 pub mod tui;
12 13
13 use base64::Engine; 14 use base64::Engine;
14 use cli::{Commands, IssueCmd, PatchCmd}; 15 use cli::{Commands, IssueCmd, KeyCmd, PatchCmd};
15 use event::ReviewVerdict; 16 use event::ReviewVerdict;
16 use git2::Repository; 17 use git2::Repository;
17 use state::{IssueStatus, PatchStatus}; 18 use state::{IssueStatus, PatchStatus};
@@ -268,5 +269,75 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
268 println!("Public key: {}", pubkey_b64); 269 println!("Public key: {}", pubkey_b64);
269 Ok(()) 270 Ok(())
270 } 271 }
272 Commands::Key(cmd) => match cmd {
273 KeyCmd::Add {
274 pubkey,
275 self_key,
276 label,
277 } => {
278 if self_key && pubkey.is_some() {
279 return Err(error::Error::Cmd(
280 "cannot specify both --self and a public key argument".to_string(),
281 ));
282 }
283 let key = if self_key {
284 let config_dir = signing::signing_key_dir()?;
285 let vk = signing::load_verifying_key(&config_dir)?;
286 base64::engine::general_purpose::STANDARD.encode(vk.to_bytes())
287 } else {
288 match pubkey {
289 Some(k) => k,
290 None => {
291 return Err(error::Error::Cmd(
292 "public key argument required (or use --self)".to_string(),
293 ));
294 }
295 }
296 };
297 trust::validate_pubkey(&key)?;
298 let added = trust::save_trusted_key(repo, &key, label.as_deref())?;
299 if added {
300 let label_display = label
301 .as_ref()
302 .map(|l| format!(" ({})", l))
303 .unwrap_or_default();
304 println!("Trusted key added: {}{}", key, label_display);
305 } else {
306 println!("Key {} is already trusted.", key);
307 }
308 Ok(())
309 }
310 KeyCmd::List => {
311 let policy = trust::load_trust_policy(repo)?;
312 match policy {
313 trust::TrustPolicy::Unconfigured => {
314 println!("No trusted keys configured.");
315 }
316 trust::TrustPolicy::Configured(keys) => {
317 if keys.is_empty() {
318 println!("No trusted keys configured.");
319 } else {
320 for k in &keys {
321 match &k.label {
322 Some(l) => println!("{} {}", k.pubkey, l),
323 None => println!("{}", k.pubkey),
324 }
325 }
326 }
327 }
328 }
329 Ok(())
330 }
331 KeyCmd::Remove { pubkey } => {
332 let removed = trust::remove_trusted_key(repo, &pubkey)?;
333 let label_display = removed
334 .label
335 .as_ref()
336 .map(|l| format!(" ({})", l))
337 .unwrap_or_default();
338 println!("Removed trusted key: {}{}", removed.pubkey, label_display);
339 Ok(())
340 }
341 },
271 } 342 }
272 } 343 }
src/signing.rs
Old New
@@ -47,6 +47,8 @@ pub enum VerifyStatus {
47 Invalid, 47 Invalid,
48 /// No signature or pubkey field in event.json. 48 /// No signature or pubkey field in event.json.
49 Missing, 49 Missing,
50 /// Signature valid but key not in trusted set.
51 Untrusted,
50 } 52 }
51 53
52 /// Detailed verification result for a single commit. 54 /// Detailed verification result for a single commit.
@@ -266,6 +268,16 @@ pub fn verify_ref(
266 error: Some("missing signature".to_string()), 268 error: Some("missing signature".to_string()),
267 }); 269 });
268 } 270 }
271 VerifyStatus::Untrusted => {
272 // verify_signed_event never returns Untrusted,
273 // but handle it for exhaustiveness
274 results.push(SignatureVerificationResult {
275 commit_id: oid,
276 status: VerifyStatus::Untrusted,
277 pubkey: Some(signed.pubkey),
278 error: Some("untrusted key".to_string()),
279 });
280 }
269 } 281 }
270 } 282 }
271 } else { 283 } else {
src/sync.rs
Old New
@@ -6,6 +6,7 @@ use crate::dag;
6 use crate::error::Error; 6 use crate::error::Error;
7 use crate::identity::get_author; 7 use crate::identity::get_author;
8 use crate::signing; 8 use crate::signing;
9 use crate::trust;
9 10
10 /// Add collab refspecs to all remotes. 11 /// Add collab refspecs to all remotes.
11 pub fn init(repo: &Repository) -> Result<(), Error> { 12 pub fn init(repo: &Repository) -> Result<(), Error> {
@@ -125,10 +126,22 @@ fn reconcile_refs(
125 .collect() 126 .collect()
126 }; 127 };
127 128
129 // Load trust policy once for all refs of this kind
130 let trust_policy = trust::load_trust_policy(repo)?;
131 let mut warned_unconfigured = false;
132
128 for (remote_ref, id) in &sync_refs { 133 for (remote_ref, id) in &sync_refs {
129 // Verify all commits on the remote ref before reconciling 134 // Verify all commits on the remote ref before reconciling
130 match signing::verify_ref(repo, remote_ref) { 135 match signing::verify_ref(repo, remote_ref) {
131 Ok(results) => { 136 Ok(results) => {
137 // Apply trust checking
138 let results = trust::check_trust(&results, &trust_policy);
139
140 if matches!(trust_policy, trust::TrustPolicy::Unconfigured) && !warned_unconfigured {
141 eprintln!("warning: no trusted keys configured — all valid signatures accepted. Run 'collab key add --self' to start.");
142 warned_unconfigured = true;
143 }
144
132 let failures: Vec<_> = results 145 let failures: Vec<_> = results
133 .iter() 146 .iter()
134 .filter(|r| r.status != signing::VerifyStatus::Valid) 147 .filter(|r| r.status != signing::VerifyStatus::Valid)
src/trust.rs
Old New
@@ -0,0 +1,505 @@
1 use std::collections::HashSet;
2 use std::fs;
3 use std::path::PathBuf;
4
5 use base64::engine::general_purpose::STANDARD;
6 use base64::Engine;
7 use ed25519_dalek::VerifyingKey;
8 use git2::Repository;
9
10 use crate::error::Error;
11 use crate::signing::{SignatureVerificationResult, VerifyStatus};
12
13 /// A single trusted key entry.
14 #[derive(Debug, Clone)]
15 pub struct TrustedKey {
16 /// Base64-encoded Ed25519 public key.
17 pub pubkey: String,
18 /// Optional human-readable label.
19 pub label: Option<String>,
20 }
21
22 /// Loaded trust policy.
23 #[derive(Debug)]
24 pub enum TrustPolicy {
25 /// No trusted keys file exists. Fall back to accepting any valid signature.
26 Unconfigured,
27 /// Trusted keys file exists (possibly empty). Enforce allowlist.
28 Configured(Vec<TrustedKey>),
29 }
30
31 /// Return the path to the trusted keys file for the given repo.
32 pub fn trusted_keys_path(repo: &Repository) -> PathBuf {
33 repo.path().join("collab/trusted-keys")
34 }
35
36 /// Validate that a string is a valid base64-encoded 32-byte Ed25519 public key.
37 pub fn validate_pubkey(pubkey: &str) -> Result<(), Error> {
38 let bytes = STANDARD
39 .decode(pubkey.trim())
40 .map_err(|e| Error::Verification(format!("invalid public key: base64 decode failed: {}", e)))?;
41 if bytes.len() != 32 {
42 return Err(Error::Verification(format!(
43 "invalid public key: expected 32 bytes, got {}",
44 bytes.len()
45 )));
46 }
47 let key_bytes: [u8; 32] = bytes.try_into().unwrap();
48 VerifyingKey::from_bytes(&key_bytes)
49 .map_err(|e| Error::Verification(format!("invalid public key: not a valid Ed25519 point: {}", e)))?;
50 Ok(())
51 }
52
53 /// Load the trust policy from the trusted keys file.
54 ///
55 /// Returns `TrustPolicy::Unconfigured` if the file does not exist.
56 /// Returns `TrustPolicy::Configured(keys)` if the file exists (even if empty).
57 /// Malformed lines are skipped with a warning to stderr.
58 /// Duplicate keys are deduplicated (last wins for label).
59 pub fn load_trust_policy(repo: &Repository) -> Result<TrustPolicy, Error> {
60 let path = trusted_keys_path(repo);
61 if !path.exists() {
62 return Ok(TrustPolicy::Unconfigured);
63 }
64 let content = fs::read_to_string(&path)?;
65 let mut seen = HashSet::new();
66 let mut keys = Vec::new();
67
68 for line in content.lines() {
69 let trimmed = line.trim();
70 if trimmed.is_empty() || trimmed.starts_with('#') {
71 continue;
72 }
73 let (pubkey, label) = match trimmed.split_once(' ') {
74 Some((k, l)) => (k.to_string(), Some(l.to_string())),
75 None => (trimmed.to_string(), None),
76 };
77 // Validate the key; skip malformed entries
78 if let Err(e) = validate_pubkey(&pubkey) {
79 eprintln!("warning: skipping malformed trusted key: {}: {}", pubkey, e);
80 continue;
81 }
82 // Deduplicate: remove earlier entry if present
83 if seen.contains(&pubkey) {
84 keys.retain(|k: &TrustedKey| k.pubkey != pubkey);
85 }
86 seen.insert(pubkey.clone());
87 keys.push(TrustedKey { pubkey, label });
88 }
89
90 Ok(TrustPolicy::Configured(keys))
91 }
92
93 /// Check whether a pubkey is in the trusted set.
94 pub fn is_key_trusted(keys: &[TrustedKey], pubkey: &str) -> bool {
95 keys.iter().any(|k| k.pubkey == pubkey)
96 }
97
98 /// Save (append) a trusted key to the trusted keys file.
99 ///
100 /// Creates the `.git/collab/` directory and file if needed.
101 /// Returns `true` if the key was added, `false` if it was already trusted.
102 pub fn save_trusted_key(repo: &Repository, pubkey: &str, label: Option<&str>) -> Result<bool, Error> {
103 let path = trusted_keys_path(repo);
104 // Ensure directory exists
105 if let Some(parent) = path.parent() {
106 fs::create_dir_all(parent)?;
107 }
108 // Check for duplicates
109 if path.exists() {
110 let policy = load_trust_policy(repo)?;
111 if let TrustPolicy::Configured(ref keys) = policy {
112 if is_key_trusted(keys, pubkey) {
113 return Ok(false);
114 }
115 }
116 }
117 // Append key
118 let line = match label {
119 Some(l) => format!("{} {}\n", pubkey, l),
120 None => format!("{}\n", pubkey),
121 };
122 use std::io::Write;
123 let mut file = fs::OpenOptions::new()
124 .create(true)
125 .append(true)
126 .open(&path)?;
127 file.write_all(line.as_bytes())?;
128 Ok(true)
129 }
130
131 /// Remove a trusted key from the file. Returns the removed entry.
132 pub fn remove_trusted_key(repo: &Repository, pubkey: &str) -> Result<TrustedKey, Error> {
133 let path = trusted_keys_path(repo);
134 let policy = load_trust_policy(repo)?;
135 match policy {
136 TrustPolicy::Unconfigured => {
137 return Err(Error::Cmd(format!(
138 "key {} is not in the trusted keys list",
139 pubkey
140 )));
141 }
142 TrustPolicy::Configured(keys) => {
143 let removed = keys.iter().find(|k| k.pubkey == pubkey).cloned();
144 match removed {
145 None => {
146 return Err(Error::Cmd(format!(
147 "key {} is not in the trusted keys list",
148 pubkey
149 )));
150 }
151 Some(removed_key) => {
152 // Rewrite file without the removed key
153 let content = fs::read_to_string(&path)?;
154 let mut new_lines = Vec::new();
155 for line in content.lines() {
156 let trimmed = line.trim();
157 if trimmed.is_empty() || trimmed.starts_with('#') {
158 new_lines.push(line.to_string());
159 continue;
160 }
161 let key_part = trimmed.split_once(' ')
162 .map(|(k, _)| k)
163 .unwrap_or(trimmed);
164 if key_part != pubkey {
165 new_lines.push(line.to_string());
166 }
167 }
168 let new_content = if new_lines.is_empty() {
169 String::new()
170 } else {
171 new_lines.join("\n") + "\n"
172 };
173 fs::write(&path, new_content)?;
174 Ok(removed_key)
175 }
176 }
177 }
178 }
179 }
180
181 /// Check trust for a set of signature verification results.
182 ///
183 /// If `TrustPolicy::Unconfigured`, returns the results unchanged.
184 /// If `TrustPolicy::Configured`, changes `Valid` results to `Untrusted` when the
185 /// pubkey is not in the trusted set. All other statuses pass through unchanged.
186 pub fn check_trust(
187 results: &[SignatureVerificationResult],
188 policy: &TrustPolicy,
189 ) -> Vec<SignatureVerificationResult> {
190 match policy {
191 TrustPolicy::Unconfigured => results.to_vec(),
192 TrustPolicy::Configured(keys) => {
193 let trusted_set: HashSet<&str> = keys.iter().map(|k| k.pubkey.as_str()).collect();
194 results
195 .iter()
196 .map(|r| {
197 if r.status == VerifyStatus::Valid {
198 if let Some(ref pk) = r.pubkey {
199 if !trusted_set.contains(pk.as_str()) {
200 return SignatureVerificationResult {
201 commit_id: r.commit_id,
202 status: VerifyStatus::Untrusted,
203 pubkey: r.pubkey.clone(),
204 error: Some(format!("untrusted key: {}", pk)),
205 };
206 }
207 }
208 }
209 r.clone()
210 })
211 .collect()
212 }
213 }
214 }
215
216 #[cfg(test)]
217 mod tests {
218 use super::*;
219 use tempfile::TempDir;
220 use git2::Oid;
221
222 fn test_repo() -> (TempDir, Repository) {
223 let dir = TempDir::new().unwrap();
224 let repo = Repository::init(dir.path()).unwrap();
225 (dir, repo)
226 }
227
228 /// Generate a valid Ed25519 public key in base64 for testing.
229 fn valid_test_pubkey() -> String {
230 use ed25519_dalek::SigningKey;
231 use rand_core::OsRng;
232 let sk = SigningKey::generate(&mut OsRng);
233 STANDARD.encode(sk.verifying_key().to_bytes())
234 }
235
236 // =====================================================
237 // T004: Unit tests for validate_pubkey()
238 // =====================================================
239
240 #[test]
241 fn validate_pubkey_accepts_valid_key() {
242 let pk = valid_test_pubkey();
243 assert!(validate_pubkey(&pk).is_ok());
244 }
245
246 #[test]
247 fn validate_pubkey_rejects_invalid_base64() {
248 let result = validate_pubkey("not-valid-base64!!!");
249 assert!(result.is_err());
250 let err = result.unwrap_err().to_string();
251 assert!(err.contains("base64"), "error should mention base64: {}", err);
252 }
253
254 #[test]
255 fn validate_pubkey_rejects_wrong_byte_length() {
256 // 16 bytes instead of 32
257 let short = STANDARD.encode(vec![0u8; 16]);
258 let result = validate_pubkey(&short);
259 assert!(result.is_err());
260 let err = result.unwrap_err().to_string();
261 assert!(err.contains("32 bytes"), "error should mention 32 bytes: {}", err);
262 }
263
264 #[test]
265 fn validate_pubkey_rejects_too_long() {
266 let long = STANDARD.encode(vec![0u8; 64]);
267 let result = validate_pubkey(&long);
268 assert!(result.is_err());
269 }
270
271 #[test]
272 fn validate_pubkey_accepts_whitespace_trimmed() {
273 let pk = valid_test_pubkey();
274 let padded = format!(" {} ", pk);
275 assert!(validate_pubkey(&padded).is_ok());
276 }
277
278 // =====================================================
279 // T005: Unit tests for load_trust_policy() and save_trusted_key()
280 // =====================================================
281
282 #[test]
283 fn load_trust_policy_returns_unconfigured_when_no_file() {
284 let (_dir, repo) = test_repo();
285 let policy = load_trust_policy(&repo).unwrap();
286 assert!(matches!(policy, TrustPolicy::Unconfigured));
287 }
288
289 #[test]
290 fn load_trust_policy_returns_configured_empty_for_empty_file() {
291 let (_dir, repo) = test_repo();
292 let path = trusted_keys_path(&repo);
293 fs::create_dir_all(path.parent().unwrap()).unwrap();
294 fs::write(&path, "").unwrap();
295 let policy = load_trust_policy(&repo).unwrap();
296 match policy {
297 TrustPolicy::Configured(keys) => assert!(keys.is_empty()),
298 _ => panic!("expected Configured"),
299 }
300 }
301
302 #[test]
303 fn load_trust_policy_parses_keys_and_labels() {
304 let (_dir, repo) = test_repo();
305 let pk1 = valid_test_pubkey();
306 let pk2 = valid_test_pubkey();
307 let path = trusted_keys_path(&repo);
308 fs::create_dir_all(path.parent().unwrap()).unwrap();
309 fs::write(&path, format!("# Comment\n{} Alice\n{}\n\n", pk1, pk2)).unwrap();
310 let policy = load_trust_policy(&repo).unwrap();
311 match policy {
312 TrustPolicy::Configured(keys) => {
313 assert_eq!(keys.len(), 2);
314 assert_eq!(keys[0].pubkey, pk1);
315 assert_eq!(keys[0].label.as_deref(), Some("Alice"));
316 assert_eq!(keys[1].pubkey, pk2);
317 assert!(keys[1].label.is_none());
318 }
319 _ => panic!("expected Configured"),
320 }
321 }
322
323 #[test]
324 fn load_trust_policy_skips_malformed_lines() {
325 let (_dir, repo) = test_repo();
326 let pk = valid_test_pubkey();
327 let path = trusted_keys_path(&repo);
328 fs::create_dir_all(path.parent().unwrap()).unwrap();
329 fs::write(&path, format!("garbage_not_base64\n{} Good key\n", pk)).unwrap();
330 let policy = load_trust_policy(&repo).unwrap();
331 match policy {
332 TrustPolicy::Configured(keys) => {
333 assert_eq!(keys.len(), 1);
334 assert_eq!(keys[0].pubkey, pk);
335 }
336 _ => panic!("expected Configured"),
337 }
338 }
339
340 #[test]
341 fn load_trust_policy_deduplicates_keys() {
342 let (_dir, repo) = test_repo();
343 let pk = valid_test_pubkey();
344 let path = trusted_keys_path(&repo);
345 fs::create_dir_all(path.parent().unwrap()).unwrap();
346 fs::write(&path, format!("{} First\n{} Second\n", pk, pk)).unwrap();
347 let policy = load_trust_policy(&repo).unwrap();
348 match policy {
349 TrustPolicy::Configured(keys) => {
350 assert_eq!(keys.len(), 1);
351 // Last wins for label
352 assert_eq!(keys[0].label.as_deref(), Some("Second"));
353 }
354 _ => panic!("expected Configured"),
355 }
356 }
357
358 #[test]
359 fn save_trusted_key_creates_file_and_appends() {
360 let (_dir, repo) = test_repo();
361 let pk1 = valid_test_pubkey();
362 let pk2 = valid_test_pubkey();
363 save_trusted_key(&repo, &pk1, Some("Alice")).unwrap();
364 save_trusted_key(&repo, &pk2, None).unwrap();
365 let policy = load_trust_policy(&repo).unwrap();
366 match policy {
367 TrustPolicy::Configured(keys) => {
368 assert_eq!(keys.len(), 2);
369 assert_eq!(keys[0].pubkey, pk1);
370 assert_eq!(keys[0].label.as_deref(), Some("Alice"));
371 assert_eq!(keys[1].pubkey, pk2);
372 assert!(keys[1].label.is_none());
373 }
374 _ => panic!("expected Configured"),
375 }
376 }
377
378 #[test]
379 fn save_trusted_key_prevents_duplicates() {
380 let (_dir, repo) = test_repo();
381 let pk = valid_test_pubkey();
382 let added = save_trusted_key(&repo, &pk, Some("Alice")).unwrap();
383 assert!(added, "first add should return true");
384 let added = save_trusted_key(&repo, &pk, Some("Alice again")).unwrap();
385 assert!(!added, "duplicate add should return false");
386 let policy = load_trust_policy(&repo).unwrap();
387 match policy {
388 TrustPolicy::Configured(keys) => {
389 assert_eq!(keys.len(), 1, "should not have duplicates");
390 }
391 _ => panic!("expected Configured"),
392 }
393 }
394
395 // =====================================================
396 // T012: Unit tests for check_trust()
397 // =====================================================
398
399 fn make_result(status: VerifyStatus, pubkey: Option<&str>) -> SignatureVerificationResult {
400 SignatureVerificationResult {
401 commit_id: Oid::zero(),
402 status,
403 pubkey: pubkey.map(|s| s.to_string()),
404 error: None,
405 }
406 }
407
408 #[test]
409 fn check_trust_unconfigured_passes_all_through() {
410 let pk = valid_test_pubkey();
411 let results = vec![make_result(VerifyStatus::Valid, Some(&pk))];
412 let checked = check_trust(&results, &TrustPolicy::Unconfigured);
413 assert_eq!(checked.len(), 1);
414 assert_eq!(checked[0].status, VerifyStatus::Valid);
415 }
416
417 #[test]
418 fn check_trust_configured_with_trusted_key_returns_valid() {
419 let pk = valid_test_pubkey();
420 let keys = vec![TrustedKey { pubkey: pk.clone(), label: None }];
421 let results = vec![make_result(VerifyStatus::Valid, Some(&pk))];
422 let checked = check_trust(&results, &TrustPolicy::Configured(keys));
423 assert_eq!(checked[0].status, VerifyStatus::Valid);
424 }
425
426 #[test]
427 fn check_trust_configured_with_untrusted_key_returns_untrusted() {
428 let pk_trusted = valid_test_pubkey();
429 let pk_untrusted = valid_test_pubkey();
430 let keys = vec![TrustedKey { pubkey: pk_trusted, label: None }];
431 let results = vec![make_result(VerifyStatus::Valid, Some(&pk_untrusted))];
432 let checked = check_trust(&results, &TrustPolicy::Configured(keys));
433 assert_eq!(checked[0].status, VerifyStatus::Untrusted);
434 assert!(checked[0].error.as_ref().unwrap().contains(&pk_untrusted));
435 }
436
437 #[test]
438 fn check_trust_passes_through_missing_and_invalid() {
439 let keys = vec![];
440 let results = vec![
441 make_result(VerifyStatus::Missing, None),
442 make_result(VerifyStatus::Invalid, Some("whatever")),
443 ];
444 let checked = check_trust(&results, &TrustPolicy::Configured(keys));
445 assert_eq!(checked[0].status, VerifyStatus::Missing);
446 assert_eq!(checked[1].status, VerifyStatus::Invalid);
447 }
448
449 #[test]
450 fn check_trust_empty_configured_rejects_all_valid() {
451 let pk = valid_test_pubkey();
452 let results = vec![make_result(VerifyStatus::Valid, Some(&pk))];
453 let checked = check_trust(&results, &TrustPolicy::Configured(vec![]));
454 assert_eq!(checked[0].status, VerifyStatus::Untrusted);
455 }
456
457 // =====================================================
458 // T009: Unit tests for remove_trusted_key()
459 // =====================================================
460
461 #[test]
462 fn remove_trusted_key_removes_and_returns_entry() {
463 let (_dir, repo) = test_repo();
464 let pk1 = valid_test_pubkey();
465 let pk2 = valid_test_pubkey();
466 save_trusted_key(&repo, &pk1, Some("Alice")).unwrap();
467 save_trusted_key(&repo, &pk2, Some("Bob")).unwrap();
468 let removed = remove_trusted_key(&repo, &pk1).unwrap();
469 assert_eq!(removed.pubkey, pk1);
470 assert_eq!(removed.label.as_deref(), Some("Alice"));
471 let policy = load_trust_policy(&repo).unwrap();
472 match policy {
473 TrustPolicy::Configured(keys) => {
474 assert_eq!(keys.len(), 1);
475 assert_eq!(keys[0].pubkey, pk2);
476 }
477 _ => panic!("expected Configured"),
478 }
479 }
480
481 #[test]
482 fn remove_trusted_key_errors_when_not_found() {
483 let (_dir, repo) = test_repo();
484 let pk = valid_test_pubkey();
485 save_trusted_key(&repo, &pk, None).unwrap();
486 let other_pk = valid_test_pubkey();
487 let result = remove_trusted_key(&repo, &other_pk);
488 assert!(result.is_err());
489 assert!(result.unwrap_err().to_string().contains("not in the trusted keys list"));
490 }
491
492 #[test]
493 fn remove_last_key_leaves_empty_configured_file() {
494 let (_dir, repo) = test_repo();
495 let pk = valid_test_pubkey();
496 save_trusted_key(&repo, &pk, None).unwrap();
497 remove_trusted_key(&repo, &pk).unwrap();
498 // File should still exist (Configured) but empty
499 let policy = load_trust_policy(&repo).unwrap();
500 match policy {
501 TrustPolicy::Configured(keys) => assert!(keys.is_empty()),
502 _ => panic!("expected Configured (empty)"),
503 }
504 }
505 }
tests/trust_test.rs
Old New
@@ -0,0 +1,203 @@
1 mod common;
2
3 use common::TestRepo;
4
5 // ===========================================================================
6 // T006: Integration tests for `collab key add`
7 // ===========================================================================
8
9 #[test]
10 fn test_key_add_valid_key() {
11 let repo = TestRepo::new("Alice", "alice@example.com");
12
13 // Add own key via --self
14 let out = repo.run_ok(&["key", "add", "--self"]);
15 assert!(
16 out.contains("Trusted key added:"),
17 "should confirm key added, got: {}",
18 out
19 );
20 }
21
22 #[test]
23 fn test_key_add_with_label() {
24 let repo = TestRepo::new("Alice", "alice@example.com");
25
26 let out = repo.run_ok(&["key", "add", "--self", "--label", "Alice (main laptop)"]);
27 assert!(out.contains("Trusted key added:"));
28 assert!(out.contains("Alice (main laptop)"));
29
30 // Verify it shows up in list
31 let out = repo.run_ok(&["key", "list"]);
32 assert!(out.contains("Alice (main laptop)"));
33 }
34
35 #[test]
36 fn test_key_add_duplicate_prints_message() {
37 let repo = TestRepo::new("Alice", "alice@example.com");
38
39 repo.run_ok(&["key", "add", "--self"]);
40 let out = repo.run_ok(&["key", "add", "--self"]);
41 assert!(
42 out.contains("already trusted"),
43 "should say already trusted, got: {}",
44 out
45 );
46 }
47
48 #[test]
49 fn test_key_add_invalid_key_returns_error() {
50 let repo = TestRepo::new("Alice", "alice@example.com");
51
52 let err = repo.run_err(&["key", "add", "not-a-valid-key!!!"]);
53 assert!(
54 err.contains("invalid") || err.contains("base64"),
55 "should mention invalid key, got: {}",
56 err
57 );
58 }
59
60 #[test]
61 fn test_key_add_self_and_pubkey_errors() {
62 let repo = TestRepo::new("Alice", "alice@example.com");
63
64 let err = repo.run_err(&["key", "add", "--self", "somepubkey"]);
65 assert!(
66 err.contains("cannot specify both"),
67 "should error on --self with pubkey, got: {}",
68 err
69 );
70 }
71
72 #[test]
73 fn test_key_add_explicit_pubkey() {
74 let repo = TestRepo::new("Alice", "alice@example.com");
75
76 // Get our own public key to use as a valid key
77 let out = repo.run_ok(&["key", "add", "--self", "--label", "Me"]);
78 assert!(out.contains("Trusted key added:"));
79
80 // List should show the key
81 let out = repo.run_ok(&["key", "list"]);
82 assert!(out.contains("Me"));
83 }
84
85 // ===========================================================================
86 // T016: Integration tests for `collab key list`
87 // ===========================================================================
88
89 #[test]
90 fn test_key_list_no_keys() {
91 let repo = TestRepo::new("Alice", "alice@example.com");
92
93 let out = repo.run_ok(&["key", "list"]);
94 assert!(
95 out.contains("No trusted keys configured"),
96 "should say no keys, got: {}",
97 out
98 );
99 }
100
101 #[test]
102 fn test_key_list_shows_keys_and_labels() {
103 let repo = TestRepo::new("Alice", "alice@example.com");
104
105 repo.run_ok(&["key", "add", "--self", "--label", "Alice"]);
106
107 let out = repo.run_ok(&["key", "list"]);
108 assert!(out.contains("Alice"), "should show label, got: {}", out);
109 // Should contain a base64 key string (44 chars for ed25519)
110 assert!(
111 out.lines().any(|l| l.len() >= 44),
112 "should contain a key string, got: {}",
113 out
114 );
115 }
116
117 // ===========================================================================
118 // T017: Integration tests for `collab key remove`
119 // ===========================================================================
120
121 #[test]
122 fn test_key_remove_existing() {
123 let repo = TestRepo::new("Alice", "alice@example.com");
124
125 // Add a key, extract the pubkey from the list
126 repo.run_ok(&["key", "add", "--self", "--label", "Alice"]);
127
128 let list = repo.run_ok(&["key", "list"]);
129 let pubkey = list.lines().next().unwrap().split_whitespace().next().unwrap();
130
131 let out = repo.run_ok(&["key", "remove", pubkey]);
132 assert!(
133 out.contains("Removed trusted key:"),
134 "should confirm removal, got: {}",
135 out
136 );
137 assert!(out.contains("Alice"), "should show label of removed key");
138
139 // List should now be empty
140 let out = repo.run_ok(&["key", "list"]);
141 assert!(out.contains("No trusted keys configured"));
142 }
143
144 #[test]
145 fn test_key_remove_nonexistent_errors() {
146 let repo = TestRepo::new("Alice", "alice@example.com");
147
148 // Add a key first so the file exists
149 repo.run_ok(&["key", "add", "--self"]);
150
151 let err = repo.run_err(&["key", "remove", "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY="]);
152 assert!(
153 err.contains("not in the trusted keys list"),
154 "should say not found, got: {}",
155 err
156 );
157 }
158
159 // ===========================================================================
160 // T021: Edge case tests
161 // ===========================================================================
162
163 #[test]
164 fn test_key_add_then_remove_then_readd() {
165 let repo = TestRepo::new("Alice", "alice@example.com");
166
167 repo.run_ok(&["key", "add", "--self", "--label", "Original"]);
168
169 let list = repo.run_ok(&["key", "list"]);
170 let pubkey = list.lines().next().unwrap().split_whitespace().next().unwrap().to_string();
171
172 // Remove
173 repo.run_ok(&["key", "remove", &pubkey]);
174
175 // Re-add with a different label
176 let out = repo.run_ok(&["key", "add", &pubkey, "--label", "Re-added"]);
177 assert!(out.contains("Trusted key added:"));
178
179 let list = repo.run_ok(&["key", "list"]);
180 assert!(list.contains("Re-added"));
181 }
182
183 #[test]
184 fn test_key_list_with_corrupted_file() {
185 let repo = TestRepo::new("Alice", "alice@example.com");
186
187 // Add a valid key first
188 repo.run_ok(&["key", "add", "--self", "--label", "Good key"]);
189
190 // Manually corrupt the file by adding a bad line
191 let git_dir = repo.dir.path().join(".git/collab/trusted-keys");
192 let content = std::fs::read_to_string(&git_dir).unwrap();
193 std::fs::write(
194 &git_dir,
195 format!("{}garbage_not_a_valid_key BadEntry\n", content),
196 )
197 .unwrap();
198
199 // List should still work, showing valid keys and skipping the bad one
200 let out = repo.run_ok(&["key", "list"]);
201 assert!(out.contains("Good key"), "valid key should still appear");
202 // The bad line should be skipped (warning goes to stderr)
203 }