1use std::path::Path;
12use std::time::{SystemTime, UNIX_EPOCH};
13
14use crate::collab::Identity;
15use crate::VoidError;
16
17use super::io::{detect_repo_mode, load_manifest, load_repo_key, save_manifest, RepoMode};
18use super::keys::{ecies_wrap_key, ContributorId, RecipientPubKey, SigningPubKey};
19use super::types::Contributor;
20
21#[derive(Debug, Clone)]
27pub struct ContributorInfo {
28 pub signing_pubkey: SigningPubKey,
30 pub recipient_pubkey: RecipientPubKey,
32 pub name: Option<String>,
34 pub added_at: u64,
36 pub added_by: SigningPubKey,
38 pub is_owner: bool,
40}
41
42impl ContributorInfo {
43 pub fn from_contributor(contributor: &Contributor, is_owner: bool) -> Self {
45 Self {
46 signing_pubkey: contributor.identity.signing.clone(),
47 recipient_pubkey: contributor.identity.recipient.clone(),
48 name: contributor.name.clone(),
49 added_at: contributor.added_at,
50 added_by: contributor.added_by.clone(),
51 is_owner,
52 }
53 }
54}
55
56pub fn list_contributors(void_dir: &Path, _identity: &Identity) -> Result<Vec<ContributorInfo>, VoidError> {
72 let mode = detect_repo_mode(void_dir);
74 if mode == RepoMode::Uninitialized {
75 return Err(VoidError::NotInitialized);
76 }
77
78 let manifest = load_manifest(void_dir)?
80 .ok_or_else(|| VoidError::NotFound("Manifest not found".into()))?;
81
82 let contributors: Vec<ContributorInfo> = manifest
84 .contributors
85 .iter()
86 .map(|c| {
87 let is_owner = manifest.is_owner_or_identity(&c.identity.signing);
88 ContributorInfo::from_contributor(c, is_owner)
89 })
90 .collect();
91
92 Ok(contributors)
93}
94
95pub fn add_contributor(
119 void_dir: &Path,
120 identity: &Identity,
121 new_identity: &ContributorId,
122 name: Option<String>,
123) -> Result<(), VoidError> {
124 let mode = detect_repo_mode(void_dir);
126 if mode == RepoMode::Uninitialized {
127 return Err(VoidError::NotInitialized);
128 }
129
130 let repo_key = load_repo_key(void_dir, Some(identity))?;
132
133 let mut manifest = load_manifest(void_dir)?
135 .ok_or_else(|| VoidError::NotFound("Manifest not found".into()))?;
136
137 let caller_signing = identity.signing_pubkey();
139 if !manifest.is_owner_or_identity(&caller_signing) {
140 return Err(VoidError::Unauthorized(
141 "Only the repository owner can add contributors".into(),
142 ));
143 }
144
145 if manifest.find_contributor(&new_identity.signing).is_some() {
147 return Err(VoidError::Conflict(
148 "Identity is already a contributor".into(),
149 ));
150 }
151
152 let wrapped_key = ecies_wrap_key(&repo_key, &new_identity.recipient)?;
154
155 let added_at = SystemTime::now()
157 .duration_since(UNIX_EPOCH)
158 .unwrap_or_default()
159 .as_millis() as u64;
160
161 let mut signable = Vec::new();
163 signable.extend(added_at.to_le_bytes());
164 signable.extend(new_identity.signing.as_bytes());
165 signable.extend(new_identity.recipient.as_bytes());
166 signable.extend(caller_signing.as_bytes());
167
168 let signature = identity.sign(&signable);
170
171 let contributor = Contributor {
172 identity: new_identity.clone(),
173 name,
174 nostr_pubkey: None,
175 added_at,
176 added_by: caller_signing,
177 signature: signature.to_vec(),
178 };
179
180 manifest.contributors.push(contributor);
182
183 manifest
185 .read_keys
186 .wrapped
187 .insert(new_identity.signing.clone(), wrapped_key);
188
189 save_manifest(void_dir, &manifest)?;
191
192 Ok(())
193}
194
195#[derive(Debug, Clone)]
201pub struct RemoveResult {
202 pub removed_name: Option<String>,
204 pub removed_signing_pubkey: SigningPubKey,
206 pub key_rotation_recommended: bool,
211}
212
213pub fn remove_contributor(
238 void_dir: &Path,
239 identity: &Identity,
240 target: &str,
241) -> Result<RemoveResult, VoidError> {
242 let mode = detect_repo_mode(void_dir);
244 if mode == RepoMode::Uninitialized {
245 return Err(VoidError::NotInitialized);
246 }
247
248 let mut manifest = load_manifest(void_dir)?
250 .ok_or_else(|| VoidError::NotFound("Manifest not found".into()))?;
251
252 let caller_signing = identity.signing_pubkey();
254 if !manifest.is_owner_or_identity(&caller_signing) {
255 return Err(VoidError::Unauthorized(
256 "Only the repository owner can remove contributors".into(),
257 ));
258 }
259
260 let target_lower = target.to_lowercase();
262 let index = manifest
263 .contributors
264 .iter()
265 .position(|c| {
266 if let Some(ref name) = c.name {
268 if name.to_lowercase() == target_lower {
269 return true;
270 }
271 }
272 let pubkey_hex = c.identity.signing.to_hex();
274 if target.len() >= 8 && pubkey_hex.starts_with(&target_lower) {
275 return true;
276 }
277 false
278 })
279 .ok_or_else(|| {
280 VoidError::NotFound(format!(
281 "No contributor found matching '{}'. Use name or pubkey prefix (8+ chars).",
282 target
283 ))
284 })?;
285
286 let contributor = &manifest.contributors[index];
288
289 if manifest.is_owner(&contributor.identity.signing) {
291 return Err(VoidError::Unauthorized(
292 "Cannot remove the repository owner".into(),
293 ));
294 }
295
296 let removed_name = contributor.name.clone();
298 let removed_signing_pubkey = contributor.identity.signing.clone();
299
300 manifest.contributors.remove(index);
302
303 manifest.read_keys.wrapped.remove(&removed_signing_pubkey);
305
306 save_manifest(void_dir, &manifest)?;
308
309 Ok(RemoveResult {
310 removed_name,
311 removed_signing_pubkey,
312 key_rotation_recommended: true,
313 })
314}
315
316#[derive(Debug, Clone)]
322pub struct RenameResult {
323 pub old_name: Option<String>,
325 pub new_name: String,
327 pub signing_pubkey: SigningPubKey,
329}
330
331pub fn rename_contributor(
355 void_dir: &Path,
356 identity: &Identity,
357 target: &str,
358 new_name: String,
359) -> Result<RenameResult, VoidError> {
360 let mode = detect_repo_mode(void_dir);
362 if mode == RepoMode::Uninitialized {
363 return Err(VoidError::NotInitialized);
364 }
365
366 let mut manifest = load_manifest(void_dir)?
368 .ok_or_else(|| VoidError::NotFound("Manifest not found".into()))?;
369
370 let caller_signing = identity.signing_pubkey();
372 if !manifest.is_owner_or_identity(&caller_signing) {
373 return Err(VoidError::Unauthorized(
374 "Only the repository owner can rename contributors".into(),
375 ));
376 }
377
378 let target_lower = target.to_lowercase();
380 let index = manifest
381 .contributors
382 .iter()
383 .position(|c| {
384 if let Some(ref name) = c.name {
386 if name.to_lowercase() == target_lower {
387 return true;
388 }
389 }
390 let pubkey_hex = c.identity.signing.to_hex();
392 if target.len() >= 8 && pubkey_hex.starts_with(&target_lower) {
393 return true;
394 }
395 false
396 })
397 .ok_or_else(|| {
398 VoidError::NotFound(format!(
399 "No contributor found matching '{}'. Use name or pubkey prefix (8+ chars).",
400 target
401 ))
402 })?;
403
404 let old_name = manifest.contributors[index].name.clone();
406 let signing_pubkey = manifest.contributors[index].identity.signing.clone();
407
408 manifest.contributors[index].name = Some(new_name.clone());
410
411 save_manifest(void_dir, &manifest)?;
413
414 Ok(RenameResult {
415 old_name,
416 new_name,
417 signing_pubkey,
418 })
419}
420
421#[cfg(test)]
426mod tests {
427 use super::*;
428 use crate::collab::manifest::keys::{ecies_wrap_key as wrap_key, RepoKey};
429 use crate::collab::manifest::types::Manifest;
430 use std::fs;
431 use tempfile::tempdir;
432
433 fn setup_collab_repo() -> (tempfile::TempDir, std::path::PathBuf, Identity) {
434 let dir = tempdir().unwrap();
435 let void_dir = dir.path().join(".void");
436 fs::create_dir(&void_dir).unwrap();
437
438 let identity = Identity::generate();
440 let signing = identity.signing_pubkey();
441 let recipient = identity.recipient_pubkey();
442
443 let mut manifest = Manifest::new(signing.clone(), None);
445
446 let repo_key = RepoKey::from_bytes([0x42u8; 32]);
448 let wrapped = wrap_key(&repo_key, &recipient).unwrap();
449 manifest.read_keys.wrapped.insert(signing.clone(), wrapped);
450
451 let timestamp = SystemTime::now()
453 .duration_since(UNIX_EPOCH)
454 .map(|d| d.as_secs())
455 .unwrap_or(0);
456 let contributor = Contributor {
457 identity: ContributorId::new(signing.clone(), recipient),
458 name: None,
459 nostr_pubkey: None,
460 added_at: timestamp,
461 added_by: signing,
462 signature: vec![],
463 };
464 manifest.contributors.push(contributor);
465
466 save_manifest(&void_dir, &manifest).unwrap();
467
468 (dir, void_dir, identity)
469 }
470
471 #[test]
472 fn test_list_contributors_shows_owner() {
473 let (_dir, void_dir, identity) = setup_collab_repo();
474
475 let contributors = list_contributors(&void_dir, &identity).unwrap();
476
477 assert_eq!(contributors.len(), 1);
478 assert!(contributors[0].is_owner);
479 assert_eq!(
480 contributors[0].signing_pubkey,
481 identity.signing_pubkey()
482 );
483 }
484
485 #[test]
486 fn test_add_contributor_success() {
487 let (_dir, void_dir, owner_identity) = setup_collab_repo();
488
489 let new_identity = Identity::generate();
491 let new_id = ContributorId::new(
492 new_identity.signing_pubkey(),
493 new_identity.recipient_pubkey(),
494 );
495
496 add_contributor(&void_dir, &owner_identity, &new_id, Some("Alice".to_string())).unwrap();
498
499 let contributors = list_contributors(&void_dir, &owner_identity).unwrap();
501 assert_eq!(contributors.len(), 2);
502
503 let alice = contributors.iter().find(|c| c.name == Some("Alice".to_string()));
504 assert!(alice.is_some());
505 assert!(!alice.unwrap().is_owner);
506 }
507
508 #[test]
509 fn test_add_contributor_wraps_key() {
510 let (_dir, void_dir, owner_identity) = setup_collab_repo();
511
512 let new_identity = Identity::generate();
514 let new_id = ContributorId::new(
515 new_identity.signing_pubkey(),
516 new_identity.recipient_pubkey(),
517 );
518
519 add_contributor(&void_dir, &owner_identity, &new_id, None).unwrap();
520
521 let manifest = load_manifest(&void_dir).unwrap().unwrap();
523
524 let wrapped = manifest.read_keys.wrapped.get(&new_id.signing);
525 assert!(wrapped.is_some());
526 }
527
528 #[test]
529 fn test_add_duplicate_fails() {
530 let (_dir, void_dir, owner_identity) = setup_collab_repo();
531
532 let new_identity = Identity::generate();
533 let new_id = ContributorId::new(
534 new_identity.signing_pubkey(),
535 new_identity.recipient_pubkey(),
536 );
537
538 add_contributor(&void_dir, &owner_identity, &new_id, None).unwrap();
540
541 let result = add_contributor(&void_dir, &owner_identity, &new_id, None);
543 assert!(matches!(result, Err(VoidError::Conflict(_))));
544 }
545
546 #[test]
547 fn test_non_owner_cannot_add() {
548 let (_dir, void_dir, owner_identity) = setup_collab_repo();
549
550 let non_owner = Identity::generate();
552 let non_owner_id = ContributorId::new(
553 non_owner.signing_pubkey(),
554 non_owner.recipient_pubkey(),
555 );
556 add_contributor(&void_dir, &owner_identity, &non_owner_id, None).unwrap();
557
558 let another = Identity::generate();
560 let another_id = ContributorId::new(
561 another.signing_pubkey(),
562 another.recipient_pubkey(),
563 );
564
565 let result = add_contributor(&void_dir, &non_owner, &another_id, None);
566 assert!(matches!(result, Err(VoidError::Unauthorized(_))));
567 }
568
569 #[test]
570 fn test_remove_contributor_success() {
571 let (_dir, void_dir, owner_identity) = setup_collab_repo();
572
573 let contributor = Identity::generate();
575 let contributor_id = ContributorId::new(
576 contributor.signing_pubkey(),
577 contributor.recipient_pubkey(),
578 );
579 add_contributor(&void_dir, &owner_identity, &contributor_id, Some("Bob".to_string())).unwrap();
580
581 let result = remove_contributor(&void_dir, &owner_identity, "bob").unwrap();
583 assert_eq!(result.removed_name, Some("Bob".to_string()));
584 assert!(result.key_rotation_recommended);
585
586 let contributors = list_contributors(&void_dir, &owner_identity).unwrap();
588 assert_eq!(contributors.len(), 1);
589 assert!(contributors[0].is_owner);
590 }
591
592 #[test]
593 fn test_cannot_remove_owner() {
594 let (_dir, void_dir, owner_identity) = setup_collab_repo();
595
596 let owner_pubkey = owner_identity.signing_pubkey();
598 let prefix = &owner_pubkey.to_hex()[..8];
599
600 let result = remove_contributor(&void_dir, &owner_identity, prefix);
601 assert!(matches!(result, Err(VoidError::Unauthorized(_))));
602 }
603
604 #[test]
605 fn test_remove_by_name() {
606 let (_dir, void_dir, owner_identity) = setup_collab_repo();
607
608 let contributor = Identity::generate();
609 let contributor_id = ContributorId::new(
610 contributor.signing_pubkey(),
611 contributor.recipient_pubkey(),
612 );
613 add_contributor(&void_dir, &owner_identity, &contributor_id, Some("Carol".to_string())).unwrap();
614
615 let result = remove_contributor(&void_dir, &owner_identity, "CAROL").unwrap();
617 assert_eq!(result.removed_name, Some("Carol".to_string()));
618 }
619
620 #[test]
621 fn test_remove_by_pubkey_prefix() {
622 let (_dir, void_dir, owner_identity) = setup_collab_repo();
623
624 let contributor = Identity::generate();
625 let contributor_id = ContributorId::new(
626 contributor.signing_pubkey(),
627 contributor.recipient_pubkey(),
628 );
629 add_contributor(&void_dir, &owner_identity, &contributor_id, None).unwrap();
630
631 let pubkey_hex = contributor_id.signing.to_hex();
633 let prefix = &pubkey_hex[..12];
634
635 let result = remove_contributor(&void_dir, &owner_identity, prefix).unwrap();
636 assert_eq!(result.removed_signing_pubkey.to_hex(), pubkey_hex);
637 }
638
639}