Skip to main content

void_core/collab/manifest/
io.rs

1//! Manifest I/O operations.
2//!
3//! Handles loading and saving the contributor manifest, and ECIES
4//! key wrapping/unwrapping for collaboration mode.
5//!
6//! The manifest is stored as **plaintext JSON** (`contributors.json`).
7//! This is safe because per-contributor repo keys are individually
8//! ECIES-wrapped — the wrapped blobs can only be unwrapped by each
9//! contributor's X25519 private key.
10
11use std::fs;
12use std::path::Path;
13
14use crate::collab::Identity;
15use crate::{Result, VoidError};
16
17use super::keys::{ecies_unwrap_key, RepoKey};
18use super::types::Manifest;
19
20/// Filename for the contributor manifest.
21pub const MANIFEST_JSON_FILE: &str = "contributors.json";
22
23/// Repository mode based on manifest presence.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum RepoMode {
26    /// No .void directory or no manifest found.
27    Uninitialized,
28    /// Multi-contributor repository (has manifest).
29    Collaboration,
30}
31
32/// Check if a contributor manifest exists in the given void directory.
33///
34/// # Arguments
35/// * `void_dir` - Path to the .void directory.
36pub fn manifest_exists(void_dir: impl AsRef<Path>) -> bool {
37    void_dir.as_ref().join(MANIFEST_JSON_FILE).exists()
38}
39
40/// Detect the repository mode.
41///
42/// # Arguments
43/// * `void_dir` - Path to the .void directory.
44pub fn detect_repo_mode(void_dir: impl AsRef<Path>) -> RepoMode {
45    let void_dir = void_dir.as_ref();
46    if !void_dir.exists() {
47        return RepoMode::Uninitialized;
48    }
49
50    if manifest_exists(void_dir) {
51        RepoMode::Collaboration
52    } else {
53        RepoMode::Uninitialized
54    }
55}
56
57/// Load the contributor manifest from disk.
58///
59/// Returns `Ok(None)` if no manifest exists.
60///
61/// # Arguments
62/// * `void_dir` - Path to the .void directory.
63///
64/// # Errors
65/// Returns error if a manifest exists but cannot be read or parsed.
66pub fn load_manifest(void_dir: impl AsRef<Path>) -> Result<Option<Manifest>> {
67    let json_path = void_dir.as_ref().join(MANIFEST_JSON_FILE);
68    if json_path.exists() {
69        let content = fs::read(&json_path).map_err(|e| {
70            VoidError::Io(std::io::Error::new(
71                e.kind(),
72                format!("failed to read manifest: {}", e),
73            ))
74        })?;
75
76        let manifest: Manifest = serde_json::from_slice(&content).map_err(|e| {
77            VoidError::Serialization(format!("failed to parse manifest: {}", e))
78        })?;
79
80        return Ok(Some(manifest));
81    }
82
83    Ok(None)
84}
85
86/// Save the contributor manifest to disk as plaintext JSON.
87///
88/// Uses atomic write (write to temp file, then rename) to prevent corruption.
89/// The manifest is stored as `contributors.json` — wrapped keys within the
90/// manifest provide per-contributor ECIES security.
91///
92/// # Arguments
93/// * `void_dir` - Path to the .void directory.
94/// * `manifest` - The manifest to save.
95///
96/// # Errors
97/// Returns error if serialization or writing fails.
98pub fn save_manifest(void_dir: impl AsRef<Path>, manifest: &Manifest) -> Result<()> {
99    let void_dir = void_dir.as_ref();
100    let path = void_dir.join(MANIFEST_JSON_FILE);
101    let temp_path = void_dir.join(format!("{}.tmp", MANIFEST_JSON_FILE));
102
103    // Serialize to JSON
104    let content = serde_json::to_vec_pretty(manifest).map_err(|e| {
105        VoidError::Serialization(format!("failed to serialize manifest: {}", e))
106    })?;
107
108    // Atomic write: write to temp file, then rename
109    fs::write(&temp_path, &content).map_err(|e| {
110        VoidError::Io(std::io::Error::new(
111            e.kind(),
112            format!("failed to write manifest temp file: {}", e),
113        ))
114    })?;
115
116    fs::rename(&temp_path, &path).map_err(|e| {
117        // Clean up temp file on rename failure
118        let _ = fs::remove_file(&temp_path);
119        VoidError::Io(std::io::Error::new(
120            e.kind(),
121            format!("failed to rename manifest file: {}", e),
122        ))
123    })?;
124
125    Ok(())
126}
127
128/// Delete the contributor manifest from disk.
129///
130/// # Arguments
131/// * `void_dir` - Path to the .void directory.
132///
133/// # Errors
134/// Returns error if deletion fails (but not if file doesn't exist).
135pub fn delete_manifest(void_dir: impl AsRef<Path>) -> Result<()> {
136    let path = void_dir.as_ref().join(MANIFEST_JSON_FILE);
137    if path.exists() {
138        fs::remove_file(&path)?;
139    }
140
141    Ok(())
142}
143
144// ============================================================================
145// Unified Key Loading
146// ============================================================================
147
148/// Load the repository key in collaboration mode.
149///
150/// Unwraps the key from the manifest using the identity.
151///
152/// # Arguments
153/// * `void_dir` - Path to the .void directory
154/// * `identity` - Optional identity for collaboration mode (required if in collab mode)
155///
156/// # Errors
157/// - `NotInitialized` if the repository is not initialized
158/// - `InvalidKey` if identity is required but not provided
159/// - Various errors if key loading/unwrapping fails
160pub fn load_repo_key(void_dir: impl AsRef<Path>, identity: Option<&Identity>) -> Result<RepoKey> {
161    let void_dir = void_dir.as_ref();
162    match detect_repo_mode(void_dir) {
163        RepoMode::Uninitialized => Err(VoidError::NotInitialized),
164        RepoMode::Collaboration => {
165            let identity = identity.ok_or_else(|| {
166                VoidError::InvalidKey("identity required for collaboration mode".into())
167            })?;
168            load_collaboration_key(void_dir, identity)
169        }
170    }
171}
172
173/// Load the repository key in collaboration mode.
174///
175/// Reads the plaintext manifest, finds the ECIES-wrapped key for the given
176/// identity, and unwraps it using the identity's X25519 private key.
177fn load_collaboration_key(void_dir: impl AsRef<Path>, identity: &Identity) -> Result<RepoKey> {
178    let manifest = load_manifest(void_dir)?
179        .ok_or_else(|| VoidError::NotFound("Manifest not found".into()))?;
180
181    // Look up our wrapped key by signing pubkey
182    let signing_pubkey = identity.signing_pubkey();
183
184    let wrapped_key = manifest
185        .read_keys
186        .wrapped
187        .get(&signing_pubkey)
188        .ok_or_else(|| {
189            VoidError::InvalidKey(
190                "no wrapped key found for this identity in the manifest".into(),
191            )
192        })?;
193
194    // Unwrap using our X25519 private key
195    Ok(ecies_unwrap_key(wrapped_key, identity)?)
196}
197
198// ============================================================================
199// Tests
200// ============================================================================
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use crate::collab::manifest::keys::SigningPubKey;
206    use tempfile::tempdir;
207
208    fn test_manifest() -> Manifest {
209        Manifest::new(SigningPubKey::from_bytes([0xaa; 32]), None)
210    }
211
212    #[test]
213    fn detect_repo_mode_uninitialized() {
214        let mode = detect_repo_mode(Path::new("/nonexistent/path"));
215        assert_eq!(mode, RepoMode::Uninitialized);
216    }
217
218    #[test]
219    fn detect_repo_mode_no_manifest() {
220        let dir = tempdir().unwrap();
221        let void_dir = dir.path().join(".void");
222        fs::create_dir(&void_dir).unwrap();
223
224        let mode = detect_repo_mode(&void_dir);
225        assert_eq!(mode, RepoMode::Uninitialized);
226    }
227
228    #[test]
229    fn detect_repo_mode_collaboration_json() {
230        let dir = tempdir().unwrap();
231        let void_dir = dir.path().join(".void");
232        fs::create_dir(&void_dir).unwrap();
233
234        let manifest_path = void_dir.join(MANIFEST_JSON_FILE);
235        fs::write(manifest_path, b"{}").unwrap();
236
237        let mode = detect_repo_mode(&void_dir);
238        assert_eq!(mode, RepoMode::Collaboration);
239    }
240
241    #[test]
242    fn manifest_exists_false_when_missing() {
243        let dir = tempdir().unwrap();
244        let void_dir = dir.path().join(".void");
245        fs::create_dir(&void_dir).unwrap();
246
247        assert!(!manifest_exists(&void_dir));
248    }
249
250    #[test]
251    fn manifest_exists_true_when_json() {
252        let dir = tempdir().unwrap();
253        let void_dir = dir.path().join(".void");
254        fs::create_dir(&void_dir).unwrap();
255
256        let manifest_path = void_dir.join(MANIFEST_JSON_FILE);
257        fs::write(manifest_path, b"{}").unwrap();
258
259        assert!(manifest_exists(&void_dir));
260    }
261
262    #[test]
263    fn load_manifest_returns_none_when_missing() {
264        let dir = tempdir().unwrap();
265        let void_dir = dir.path().join(".void");
266        fs::create_dir(&void_dir).unwrap();
267
268        let result = load_manifest(&void_dir).unwrap();
269        assert!(result.is_none());
270    }
271
272    #[test]
273    fn save_and_load_manifest_roundtrip() {
274        let dir = tempdir().unwrap();
275        let void_dir = dir.path().join(".void");
276        fs::create_dir(&void_dir).unwrap();
277
278        let manifest = test_manifest();
279
280        // Save (plaintext JSON)
281        save_manifest(&void_dir, &manifest).unwrap();
282        assert!(manifest_exists(&void_dir));
283
284        // Load
285        let loaded = load_manifest(&void_dir).unwrap().unwrap();
286        assert_eq!(loaded.version, manifest.version);
287        assert!(loaded.is_owner(&SigningPubKey::from_bytes([0xaa; 32])));
288    }
289
290    #[test]
291    fn load_manifest_invalid_json_fails() {
292        let dir = tempdir().unwrap();
293        let void_dir = dir.path().join(".void");
294        fs::create_dir(&void_dir).unwrap();
295
296        // Write invalid JSON to the manifest file
297        fs::write(void_dir.join(MANIFEST_JSON_FILE), b"not valid json").unwrap();
298
299        let result = load_manifest(&void_dir);
300        assert!(result.is_err());
301    }
302
303    #[test]
304    fn delete_manifest_removes_file() {
305        let dir = tempdir().unwrap();
306        let void_dir = dir.path().join(".void");
307        fs::create_dir(&void_dir).unwrap();
308
309        let manifest = test_manifest();
310
311        save_manifest(&void_dir, &manifest).unwrap();
312        assert!(manifest_exists(&void_dir));
313
314        delete_manifest(&void_dir).unwrap();
315        assert!(!manifest_exists(&void_dir));
316    }
317
318    #[test]
319    fn delete_manifest_succeeds_when_missing() {
320        let dir = tempdir().unwrap();
321        let void_dir = dir.path().join(".void");
322        fs::create_dir(&void_dir).unwrap();
323
324        // Should not fail if manifest doesn't exist
325        delete_manifest(&void_dir).unwrap();
326    }
327
328    #[test]
329    fn atomic_write_no_temp_file_left() {
330        let dir = tempdir().unwrap();
331        let void_dir = dir.path().join(".void");
332        fs::create_dir(&void_dir).unwrap();
333
334        let manifest = test_manifest();
335
336        save_manifest(&void_dir, &manifest).unwrap();
337
338        // Temp file should not exist
339        let temp_path = void_dir.join(format!("{}.tmp", MANIFEST_JSON_FILE));
340        assert!(!temp_path.exists());
341    }
342
343    // ========================================================================
344    // Unified Key Loading Tests
345    // ========================================================================
346
347    #[test]
348    fn load_repo_key_uninitialized() {
349        let result = load_repo_key(Path::new("/nonexistent"), None);
350        assert!(result.is_err());
351    }
352
353    #[test]
354    fn load_repo_key_collab_mode_needs_identity() {
355        let dir = tempdir().unwrap();
356        let void_dir = dir.path().join(".void");
357        fs::create_dir(&void_dir).unwrap();
358
359        // Create manifest to trigger collaboration mode
360        let manifest = test_manifest();
361        save_manifest(&void_dir, &manifest).unwrap();
362
363        // Without identity, should fail
364        let result = load_repo_key(&void_dir, None);
365        assert!(result.is_err());
366    }
367}