Skip to main content

void_core/pipeline/seal/
commit.rs

1//! Commit logic for seal pipeline.
2
3
4use crate::collab::manifest::{detect_repo_mode, load_manifest, RepoMode, SigningPubKey};
5use crate::crypto::{
6    decrypt, encrypt, AAD_COMMIT, AAD_MANIFEST, AAD_REPO_MANIFEST,
7};
8use crate::index::{read_index, IndexEntry};
9use crate::metadata::Commit;
10
11use crate::staged;
12use crate::store::{ObjectStoreExt, StagedStore};
13use crate::cid::ToVoidCid;
14use crate::{cid, refs, Result, VoidContext, VoidError};
15use ed25519_dalek::SigningKey;
16
17use super::types::{CommitOptions, CommitResult, SealResult};
18use super::workspace::{build_commit_stats, build_tree_manifest, seal_index, seal_workspace};
19
20/// Load file entries from a commit via its TreeManifest (for comparing staged changes).
21fn load_commit_entries(
22    ctx: &VoidContext,
23    commit_cid: &void_crypto::CommitCid,
24) -> Result<Vec<IndexEntry>> {
25    let store = ctx.open_store()?;
26    let commit_cid = cid::from_bytes(commit_cid.as_bytes())?;
27    let (commit, reader) = ctx.load_commit(&store, &commit_cid)?;
28
29    let manifest = ctx.load_manifest(&store, &commit, &reader)?
30        .ok_or_else(|| VoidError::IntegrityError {
31            expected: "manifest_cid present on commit".into(),
32            actual: "None".into(),
33        })?;
34
35    manifest.iter()
36        .map(|me| {
37            let me = me?;
38            Ok(IndexEntry {
39                path: me.path.clone(),
40                content_hash: me.content_hash,
41                mtime_secs: 0,
42                mtime_nanos: 0,
43                size: me.length,
44                materialized: true,
45            })
46        })
47        .collect()
48}
49
50/// Check if there are any staged changes by comparing index to HEAD.
51/// Returns true if there are adds, modifications, or deletions staged.
52fn has_staged_changes(index: &crate::index::WorkspaceIndex, head_entries: &[IndexEntry]) -> bool {
53    // Different count means files were added or deleted
54    if index.entries.len() != head_entries.len() {
55        return true;
56    }
57    // Same count - check if any paths or content differ
58    index
59        .entries
60        .iter()
61        .zip(head_entries.iter())
62        .any(|(a, b)| a.path != b.path || a.content_hash != b.content_hash)
63}
64
65/// Commits a workspace snapshot (seal + commit object + HEAD update)
66pub fn commit_workspace(opts: CommitOptions) -> Result<CommitResult> {
67    // In collaboration mode, enforce mandatory signing
68    let mode = detect_repo_mode(opts.seal.ctx.paths.void_dir.as_std_path());
69    if mode == RepoMode::Collaboration {
70        // Require signing
71        if opts.seal.ctx.crypto.signing_key.is_none() {
72            return Err(VoidError::Unauthorized(
73                "Commits must be signed in collaboration mode. Configure your identity with 'void identity init'.".into()
74            ));
75        }
76
77        // Verify signer is a contributor
78        let signing_key = opts.seal.ctx.crypto.signing_key.as_ref()
79            .expect("signing_key presence checked by guard above");
80        let signer = get_pubkey_from_signing_key(signing_key)?;
81
82        // Load manifest and check if signer is a contributor
83        if let Some(manifest) = load_manifest(opts.seal.ctx.paths.void_dir.as_std_path())? {
84            if manifest.find_contributor(&signer).is_none() {
85                return Err(VoidError::Unauthorized(
86                    "Signer is not a contributor to this repository".into()
87                ));
88            }
89        }
90    }
91
92    // Read index (the staging area)
93    let index = match read_index(opts.seal.ctx.paths.workspace_dir.as_std_path(), opts.seal.ctx.crypto.vault.index_key()?) {
94        Ok(idx) => Some(idx),
95        Err(VoidError::NotFound(_)) => None,
96        Err(err) => return Err(err),
97    };
98
99    // Load HEAD entries (empty vec if first commit or foreign parent)
100    let head_entries = match &opts.parent_cid {
101        Some(parent_cid) if !opts.foreign_parent => {
102            load_commit_entries(&opts.seal.ctx, parent_cid)?
103        }
104        _ => Vec::new(),
105    };
106
107    // Check for staged changes: compare index to HEAD
108    // Staged changes = (files in index but not HEAD) + (modified files) + (files in HEAD but not index)
109    if let Some(ref idx) = index {
110        if !has_staged_changes(idx, &head_entries) {
111            return Err(VoidError::NothingToCommit(
112                "no changes staged for commit (use 'void add' to stage files)".to_string(),
113            ));
114        }
115    }
116    // Note: if no index, we proceed to seal_workspace which collects all workspace files.
117    // For first commits this allows committing without explicit staging.
118
119    // Generate per-commit derived key for envelope encryption (via vault)
120    let (content_key, key_nonce) = opts.seal.ctx.crypto.vault.derive_commit_key()?;
121
122    // Set content_key for metadata encryption (shards always use root key
123    // in workspace.rs because they are reused across commits).
124    let mut seal_opts = opts.seal.clone();
125    seal_opts.content_key = Some(content_key);
126
127    // Extract parent's content_key for re-wrapping reused shard keys
128    // (skip for foreign parents — their commit is encrypted with a different repo's key)
129    if let Some(ref parent_cid) = opts.parent_cid {
130        if !opts.foreign_parent {
131            let parent_store = opts.seal.ctx.open_store()?;
132            let parent_cid_obj = cid::from_bytes(parent_cid.as_bytes())?;
133            let parent_encrypted: void_crypto::EncryptedCommit = parent_store.get_blob(&parent_cid_obj)?;
134            let (_, parent_reader) = opts.seal.ctx.open_commit(&parent_encrypted)?;
135            seal_opts.parent_content_key = Some(parent_reader.content_key().clone());
136        }
137    }
138
139    // Now do the actual seal operation
140    let t0 = std::time::Instant::now();
141    let seal_result = match index {
142        Some(idx) => seal_index(&seal_opts, &idx.entries)?,
143        None => seal_workspace(seal_opts.clone())?,
144    };
145    let t_seal = t0.elapsed();
146
147    // Check if we actually sealed any files (catches empty workspace case)
148    if seal_result.stats.files_sealed == 0 && head_entries.is_empty() {
149        return Err(VoidError::NothingToCommit(
150            "nothing to commit: working tree is empty".to_string(),
151        ));
152    }
153
154    // Data loss guard: block commits that delete a large percentage of files
155    // This catches silent failures where files became unreadable and weren't staged
156    let new_file_count = seal_result.stats.files_sealed;
157    let parent_file_count = head_entries.len();
158    const MIN_FILES_FOR_GUARD: usize = 10;
159    const MAX_DELETION_RATIO: f64 = 0.7; // Block if current < 70% of parent (>30% loss)
160
161    if parent_file_count >= MIN_FILES_FOR_GUARD && !opts.allow_data_loss {
162        let ratio = new_file_count as f64 / parent_file_count as f64;
163        if ratio < MAX_DELETION_RATIO {
164            return Err(VoidError::DataLossGuard {
165                parent_files: parent_file_count,
166                new_files: new_file_count,
167                deleted_pct: ((1.0 - ratio) * 100.0) as u8,
168            });
169        }
170    }
171
172    let SealResult {
173        metadata_cid,
174        shard_cids,
175        stats,
176        index_entries,
177        ..
178    } = seal_result.clone();
179
180    let t_guard = t0.elapsed();
181
182    // Create StagedStore for atomic publishing of manifest and commit
183    let staged_store = StagedStore::new(opts.seal.ctx.paths.void_dir.as_std_path())?;
184
185    // Build TreeManifest from per-file shard assignments computed during sealing
186    let manifest = build_tree_manifest(&seal_result)?;
187    let manifest_stats = build_commit_stats(&manifest);
188
189    // Save stats for returning in CommitResult
190    let result_total_files = manifest_stats.total_files;
191    let result_total_bytes = manifest_stats.total_bytes;
192
193    // Encrypt the pre-serialized manifest bytes
194    let manifest_encrypted = encrypt(content_key.as_bytes(), manifest.as_bytes(), AAD_MANIFEST)?;
195
196    // Stage the manifest - on error, discard all staged objects
197    let manifest_void_cid = staged_store.stage_or_discard(&manifest_encrypted)?;
198    let manifest_cid = void_crypto::ManifestCid::from_bytes(cid::to_bytes(&manifest_void_cid));
199
200    let timestamp = std::time::SystemTime::now()
201        .duration_since(std::time::UNIX_EPOCH)
202        .unwrap_or_default()
203        .as_millis() as u64;
204
205    let mut commit = Commit::new(
206        opts.parent_cid.clone(),
207        metadata_cid.clone(),
208        timestamp,
209        opts.message,
210    );
211
212    // Set manifest_cid and stats
213    commit.manifest_cid = Some(manifest_cid.clone());
214    commit.stats = Some(manifest_stats);
215
216    // Embed repo manifest if in collaboration mode
217    if mode == RepoMode::Collaboration {
218        if let Ok(Some(collab_manifest)) = load_manifest(opts.seal.ctx.paths.void_dir.as_std_path()) {
219            let manifest_json = serde_json::to_vec(&collab_manifest)
220                .map_err(|e| VoidError::Serialization(format!("repo manifest: {}", e)))?;
221            let rm_encrypted = encrypt(content_key.as_bytes(), &manifest_json, AAD_REPO_MANIFEST)?;
222            let rm_cid = staged_store.stage_or_discard(&rm_encrypted)?;
223            commit.repo_manifest_cid = Some(void_crypto::RepoManifestCid::from_bytes(cid::to_bytes(&rm_cid)));
224        }
225    }
226
227    // Sign commit if signing key is provided
228    if let Some(ref signing_key) = opts.seal.ctx.crypto.signing_key {
229        commit.sign(signing_key);
230    }
231
232    let commit_bytes = crate::support::cbor_to_vec(&commit)?;
233    // Use envelope encryption for commit (wraps the derived key nonce via vault)
234    let commit_encrypted =
235        opts.seal.ctx.crypto.vault.seal_commit_with_nonce(&commit_bytes, &key_nonce)?;
236
237    // Stage the commit for atomic publishing
238    let commit_cid = staged_store.stage_blob_or_discard(&commit_encrypted)?;
239
240    // Verify staged objects can be read back AND decrypted before publishing
241    // This catches corruption or AAD mismatches early
242
243    // Verify manifest can be decrypted (uses derived content key)
244    let manifest_void_cid = manifest_cid.to_void_cid()?;
245    match staged_store.get(&manifest_void_cid) {
246        Ok(encrypted) => {
247            if let Err(e) = decrypt(content_key.as_bytes(), &encrypted, AAD_MANIFEST) {
248                let _ = staged_store.discard_all();
249                return Err(VoidError::Io(std::io::Error::new(
250                    std::io::ErrorKind::Other,
251                    format!("staged manifest verification failed: {}", e),
252                )));
253            }
254        }
255        Err(e) => {
256            let _ = staged_store.discard_all();
257            return Err(VoidError::Io(std::io::Error::new(
258                std::io::ErrorKind::Other,
259                format!("staged manifest read failed: {}", e),
260            )));
261        }
262    }
263
264    // Verify repo manifest can be decrypted (if present)
265    if let Some(ref rm_cid_typed) = commit.repo_manifest_cid {
266        let rm_cid = rm_cid_typed.to_void_cid()?;
267        match staged_store.get(&rm_cid) {
268            Ok(encrypted) => {
269                if let Err(e) = decrypt(content_key.as_bytes(), &encrypted, AAD_REPO_MANIFEST) {
270                    let _ = staged_store.discard_all();
271                    return Err(VoidError::Io(std::io::Error::new(
272                        std::io::ErrorKind::Other,
273                        format!("staged repo manifest verification failed: {}", e),
274                    )));
275                }
276            }
277            Err(e) => {
278                let _ = staged_store.discard_all();
279                return Err(VoidError::Io(std::io::Error::new(
280                    std::io::ErrorKind::Other,
281                    format!("staged repo manifest read failed: {}", e),
282                )));
283            }
284        }
285    }
286
287    // Verify commit can be decrypted (uses envelope format with root key)
288    match staged_store.get(&commit_cid) {
289        Ok(encrypted) => {
290            if let Err(e) = opts.seal.ctx.decrypt(&encrypted, AAD_COMMIT) {
291                let _ = staged_store.discard_all();
292                return Err(VoidError::Io(std::io::Error::new(
293                    std::io::ErrorKind::Other,
294                    format!("staged commit verification failed: {}", e),
295                )));
296            }
297        }
298        Err(e) => {
299            let _ = staged_store.discard_all();
300            return Err(VoidError::Io(std::io::Error::new(
301                std::io::ErrorKind::Other,
302                format!("staged commit read failed: {}", e),
303            )));
304        }
305    }
306
307    let t_verify = t0.elapsed();
308
309    // Publish all staged objects atomically
310    if let Err(e) = staged_store.publish_all() {
311        let _ = staged_store.discard_all();
312        return Err(e);
313    }
314
315    let commit_cid_typed = void_crypto::CommitCid::from_bytes(cid::to_bytes(&commit_cid));
316
317    let t_publish = t0.elapsed();
318
319    crate::index::write_index(
320        opts.seal.ctx.paths.workspace_dir.as_std_path(),
321        opts.seal.ctx.crypto.vault.index_key()?,
322        Some(commit_cid_typed.clone()),
323        index_entries,
324    )?;
325    let t_index = t0.elapsed();
326
327    // Clear all staged blobs — content is now sealed into shards in the object store.
328    // Staged blobs are only needed between `void add` and `void commit`.
329    let _ = staged::clear_all_staged(opts.seal.ctx.paths.workspace_dir.as_std_path());
330    let t_prune = t0.elapsed();
331
332    eprintln!("[commit timing] seal={:?} guard={:?} verify={:?} publish={:?} index={:?} prune={:?}",
333        t_seal, t_guard - t_seal, t_verify - t_guard, t_publish - t_verify,
334        t_index - t_publish, t_prune - t_index);
335
336    match refs::read_head(&opts.seal.ctx.paths.workspace_dir)? {
337        Some(refs::HeadRef::Symbolic(branch)) => {
338            refs::write_branch(&opts.seal.ctx.paths.void_dir, &branch, &commit_cid_typed)?;
339        }
340        Some(refs::HeadRef::Detached(_)) => {
341            refs::write_head(
342                &opts.seal.ctx.paths.workspace_dir,
343                &refs::HeadRef::Detached(commit_cid_typed.clone()),
344            )?;
345        }
346        None => {
347            let default_branch = "trunk".to_string();
348            refs::write_head(&opts.seal.ctx.paths.workspace_dir, &refs::HeadRef::Symbolic(default_branch.clone()))?;
349            refs::write_branch(&opts.seal.ctx.paths.void_dir, &default_branch, &commit_cid_typed)?;
350        }
351    }
352
353    Ok(CommitResult {
354        commit_cid: commit_cid_typed,
355        metadata_cid,
356        shard_cids,
357        stats,
358        manifest_cid: Some(manifest_cid),
359        total_files: Some(result_total_files),
360        total_bytes: Some(result_total_bytes),
361        repo_manifest_cid: commit.repo_manifest_cid.clone(),
362    })
363}
364
365// ============================================================================
366// Collaboration Mode Helpers
367// ============================================================================
368
369/// Extract public key from an Ed25519 signing key.
370fn get_pubkey_from_signing_key(signing_key: &SigningKey) -> Result<SigningPubKey> {
371    Ok(SigningPubKey::from_bytes(signing_key.verifying_key().to_bytes()))
372}
373