Skip to main content

void_core/collab/manifest/
contributors.rs

1//! Contributor management operations.
2//!
3//! This module provides functions for managing repository contributors:
4//! - Listing contributors
5//! - Adding contributors (with ECIES key wrapping)
6//! - Removing contributors (with key rotation warnings)
7//!
8//! In Phase 3, only the repository owner can add/remove contributors.
9//! Delegation is planned for Phase 5.
10
11use 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// ============================================================================
22// ContributorInfo — Information about a contributor for display/API
23// ============================================================================
24
25/// Information about a contributor for display and API purposes.
26#[derive(Debug, Clone)]
27pub struct ContributorInfo {
28    /// Ed25519 public key for signature verification.
29    pub signing_pubkey: SigningPubKey,
30    /// X25519 public key for ECIES encryption.
31    pub recipient_pubkey: RecipientPubKey,
32    /// Human-readable name (if set).
33    pub name: Option<String>,
34    /// Unix timestamp when the contributor was added.
35    pub added_at: u64,
36    /// Signing key of the user who added this contributor.
37    pub added_by: SigningPubKey,
38    /// Whether this contributor is the repository owner.
39    pub is_owner: bool,
40}
41
42impl ContributorInfo {
43    /// Create from a Contributor and ownership status.
44    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
56// ============================================================================
57// list_contributors — List all contributors
58// ============================================================================
59
60/// List all contributors in a collaboration-mode repository.
61///
62/// # Arguments
63/// * `void_dir` - Path to the .void directory
64/// * `_identity` - The caller's identity (reserved for future authorization checks)
65///
66/// # Returns
67/// A list of `ContributorInfo` for each contributor.
68///
69/// # Errors
70/// - `NotInitialized` if not in a void repository
71pub fn list_contributors(void_dir: &Path, _identity: &Identity) -> Result<Vec<ContributorInfo>, VoidError> {
72    // Check repo mode
73    let mode = detect_repo_mode(void_dir);
74    if mode == RepoMode::Uninitialized {
75        return Err(VoidError::NotInitialized);
76    }
77
78    // Load the manifest
79    let manifest = load_manifest(void_dir)?
80        .ok_or_else(|| VoidError::NotFound("Manifest not found".into()))?;
81
82    // Convert contributors to ContributorInfo
83    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
95// ============================================================================
96// add_contributor — Add a new contributor
97// ============================================================================
98
99/// Add a new contributor to the repository.
100///
101/// This is owner-only in Phase 3. The function:
102/// 1. Verifies the caller is the repository owner
103/// 2. Checks the new identity is not already a contributor
104/// 3. Wraps the repo key using ECIES for the new contributor
105/// 4. Creates a signed Contributor entry
106/// 5. Updates and saves the manifest
107///
108/// # Arguments
109/// * `void_dir` - Path to the .void directory
110/// * `identity` - The caller's identity (must be owner)
111/// * `new_identity` - The new contributor's identity (signing + recipient pubkeys)
112/// * `name` - Optional human-readable name for the contributor
113///
114/// # Errors
115/// - `NotInitialized` if not in a void repository
116/// - `Unauthorized` if caller is not the owner
117/// - `Conflict` if identity is already a contributor
118pub fn add_contributor(
119    void_dir: &Path,
120    identity: &Identity,
121    new_identity: &ContributorId,
122    name: Option<String>,
123) -> Result<(), VoidError> {
124    // Check repo mode
125    let mode = detect_repo_mode(void_dir);
126    if mode == RepoMode::Uninitialized {
127        return Err(VoidError::NotInitialized);
128    }
129
130    // Load the repo key (needed for ECIES key wrapping)
131    let repo_key = load_repo_key(void_dir, Some(identity))?;
132
133    // Load the manifest
134    let mut manifest = load_manifest(void_dir)?
135        .ok_or_else(|| VoidError::NotFound("Manifest not found".into()))?;
136
137    // Verify caller is the owner (checks both repo owner key and identity key via delegation cert)
138    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    // Check if already a contributor
146    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    // Wrap the repo key for the new contributor using ECIES
153    let wrapped_key = ecies_wrap_key(&repo_key, &new_identity.recipient)?;
154
155    // Create the contributor entry
156    let added_at = SystemTime::now()
157        .duration_since(UNIX_EPOCH)
158        .unwrap_or_default()
159        .as_millis() as u64;
160
161    // Build signable bytes for the contributor addition
162    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    // Sign the contributor addition
169    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    // Add to manifest
181    manifest.contributors.push(contributor);
182
183    // Add wrapped key to read_keys
184    manifest
185        .read_keys
186        .wrapped
187        .insert(new_identity.signing.clone(), wrapped_key);
188
189    // Save the updated manifest
190    save_manifest(void_dir, &manifest)?;
191
192    Ok(())
193}
194
195// ============================================================================
196// RemoveResult — Result of removing a contributor
197// ============================================================================
198
199/// Result of removing a contributor.
200#[derive(Debug, Clone)]
201pub struct RemoveResult {
202    /// The removed contributor's name (if set).
203    pub removed_name: Option<String>,
204    /// The removed contributor's signing public key.
205    pub removed_signing_pubkey: SigningPubKey,
206    /// Whether key rotation is recommended after this removal.
207    ///
208    /// Key rotation is always recommended when removing contributors because
209    /// they may have cached the unwrapped repo key.
210    pub key_rotation_recommended: bool,
211}
212
213// ============================================================================
214// remove_contributor — Remove a contributor
215// ============================================================================
216
217/// Remove a contributor from the repository.
218///
219/// This is owner-only in Phase 3. The contributor can be matched by:
220/// - Exact name (case-insensitive)
221/// - Signing public key hex prefix (at least 8 characters)
222///
223/// The owner cannot be removed.
224///
225/// # Arguments
226/// * `void_dir` - Path to the .void directory
227/// * `identity` - The caller's identity (must be owner)
228/// * `target` - Name or hex pubkey prefix to match
229///
230/// # Returns
231/// A `RemoveResult` with details about the removed contributor.
232///
233/// # Errors
234/// - `NotInitialized` if not in a void repository
235/// - `Unauthorized` if caller is not the owner or trying to remove owner
236/// - `NotFound` if no matching contributor found
237pub fn remove_contributor(
238    void_dir: &Path,
239    identity: &Identity,
240    target: &str,
241) -> Result<RemoveResult, VoidError> {
242    // Check repo mode
243    let mode = detect_repo_mode(void_dir);
244    if mode == RepoMode::Uninitialized {
245        return Err(VoidError::NotInitialized);
246    }
247
248    // Load the manifest
249    let mut manifest = load_manifest(void_dir)?
250        .ok_or_else(|| VoidError::NotFound("Manifest not found".into()))?;
251
252    // Verify caller is the owner (checks both repo owner key and identity key via delegation cert)
253    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    // Find the contributor to remove
261    let target_lower = target.to_lowercase();
262    let index = manifest
263        .contributors
264        .iter()
265        .position(|c| {
266            // Match by name (case-insensitive)
267            if let Some(ref name) = c.name {
268                if name.to_lowercase() == target_lower {
269                    return true;
270                }
271            }
272            // Match by pubkey hex prefix (at least 8 chars for uniqueness)
273            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    // Get the contributor before removal
287    let contributor = &manifest.contributors[index];
288
289    // Cannot remove the owner
290    if manifest.is_owner(&contributor.identity.signing) {
291        return Err(VoidError::Unauthorized(
292            "Cannot remove the repository owner".into(),
293        ));
294    }
295
296    // Save info for result before removal
297    let removed_name = contributor.name.clone();
298    let removed_signing_pubkey = contributor.identity.signing.clone();
299
300    // Remove from contributors list
301    manifest.contributors.remove(index);
302
303    // Remove from read_keys.wrapped
304    manifest.read_keys.wrapped.remove(&removed_signing_pubkey);
305
306    // Save the updated manifest
307    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// ============================================================================
317// RenameResult — Result of renaming a contributor
318// ============================================================================
319
320/// Result of renaming a contributor.
321#[derive(Debug, Clone)]
322pub struct RenameResult {
323    /// The contributor's previous name (if set).
324    pub old_name: Option<String>,
325    /// The new name assigned.
326    pub new_name: String,
327    /// The contributor's signing public key.
328    pub signing_pubkey: SigningPubKey,
329}
330
331// ============================================================================
332// rename_contributor — Rename a contributor
333// ============================================================================
334
335/// Rename a contributor in the repository.
336///
337/// This is owner-only in Phase 3. The contributor can be matched by:
338/// - Exact name (case-insensitive)
339/// - Signing public key hex prefix (at least 8 characters)
340///
341/// # Arguments
342/// * `void_dir` - Path to the .void directory
343/// * `identity` - The caller's identity (must be owner)
344/// * `target` - Name or hex pubkey prefix to match
345/// * `new_name` - The new display name to assign
346///
347/// # Returns
348/// A `RenameResult` with old and new name details.
349///
350/// # Errors
351/// - `NotInitialized` if not in a void repository
352/// - `Unauthorized` if caller is not the owner
353/// - `NotFound` if no matching contributor found
354pub fn rename_contributor(
355    void_dir: &Path,
356    identity: &Identity,
357    target: &str,
358    new_name: String,
359) -> Result<RenameResult, VoidError> {
360    // Check repo mode
361    let mode = detect_repo_mode(void_dir);
362    if mode == RepoMode::Uninitialized {
363        return Err(VoidError::NotInitialized);
364    }
365
366    // Load the manifest
367    let mut manifest = load_manifest(void_dir)?
368        .ok_or_else(|| VoidError::NotFound("Manifest not found".into()))?;
369
370    // Verify caller is the owner (checks both repo owner key and identity key via delegation cert)
371    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    // Find the contributor to rename
379    let target_lower = target.to_lowercase();
380    let index = manifest
381        .contributors
382        .iter()
383        .position(|c| {
384            // Match by name (case-insensitive)
385            if let Some(ref name) = c.name {
386                if name.to_lowercase() == target_lower {
387                    return true;
388                }
389            }
390            // Match by pubkey hex prefix (at least 8 chars for uniqueness)
391            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    // Save old name for result
405    let old_name = manifest.contributors[index].name.clone();
406    let signing_pubkey = manifest.contributors[index].identity.signing.clone();
407
408    // Update the name
409    manifest.contributors[index].name = Some(new_name.clone());
410
411    // Save the updated manifest
412    save_manifest(void_dir, &manifest)?;
413
414    Ok(RenameResult {
415        old_name,
416        new_name,
417        signing_pubkey,
418    })
419}
420
421// ============================================================================
422// Tests
423// ============================================================================
424
425#[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        // Generate identity for owner
439        let identity = Identity::generate();
440        let signing = identity.signing_pubkey();
441        let recipient = identity.recipient_pubkey();
442
443        // Create manifest directly with owner as first contributor
444        let mut manifest = Manifest::new(signing.clone(), None);
445
446        // Wrap a repo key for the owner
447        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        // Add owner as contributor
452        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        // Generate a new identity for the contributor
490        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 the contributor
497        add_contributor(&void_dir, &owner_identity, &new_id, Some("Alice".to_string())).unwrap();
498
499        // Verify they're in the list
500        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        // Generate a new identity
513        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        // Load manifest and verify wrapped key exists
522        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        // First add succeeds
539        add_contributor(&void_dir, &owner_identity, &new_id, None).unwrap();
540
541        // Second add fails with conflict
542        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        // Add a non-owner contributor first
551        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        // Non-owner tries to add another contributor
559        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        // Add a contributor
574        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        // Remove by name
582        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        // Verify they're gone
587        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        // Get the owner's pubkey prefix
597        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        // Case-insensitive name match
616        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        // Match by pubkey prefix
632        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}