Skip to main content

void_core/metadata/
commit.rs

1//! Commit implementation.
2
3use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
4use void_crypto::{CommitCid, CommitSignature, MetadataCid, SigningPubKey};
5
6use crate::cid::ToVoidCid;
7use crate::{Result, VoidCid, VoidError};
8
9use super::Commit;
10
11impl Commit {
12    /// Creates a new unsigned commit with a single parent.
13    pub fn new(
14        parent: Option<CommitCid>,
15        metadata_bundle: MetadataCid,
16        timestamp: u64,
17        message: String,
18    ) -> Self {
19        Self {
20            parents: parent.into_iter().collect(),
21            metadata_bundle,
22            timestamp,
23            message,
24            author: None,
25            signature: None,
26            manifest_cid: None,
27            stats: None,
28            repo_manifest_cid: None,
29        }
30    }
31
32    /// Creates a new unsigned merge commit with multiple parents.
33    pub fn new_merge(
34        parents: Vec<CommitCid>,
35        metadata_bundle: MetadataCid,
36        timestamp: u64,
37        message: String,
38    ) -> Self {
39        Self {
40            parents,
41            metadata_bundle,
42            timestamp,
43            message,
44            author: None,
45            signature: None,
46            manifest_cid: None,
47            stats: None,
48            repo_manifest_cid: None,
49        }
50    }
51
52    /// Returns true if this is the initial commit (no parents).
53    pub fn is_initial(&self) -> bool {
54        self.parents.is_empty()
55    }
56
57    /// Returns true if this is a merge commit (2+ parents).
58    pub fn is_merge(&self) -> bool {
59        self.parents.len() > 1
60    }
61
62    /// Returns the first parent CID, if any.
63    pub fn first_parent(&self) -> Option<&CommitCid> {
64        self.parents.first()
65    }
66
67    /// Returns the single parent for non-merge commits.
68    pub fn parent(&self) -> Option<&CommitCid> {
69        if self.parents.len() == 1 {
70            self.parents.first()
71        } else {
72            None
73        }
74    }
75
76    /// All non-parent object CIDs referenced by this commit.
77    ///
78    /// Returns parsed `VoidCid` for each referenced object (metadata bundle,
79    /// tree manifest, repo manifest). This is the canonical list of objects
80    /// a commit depends on — use it for reachability tracking to avoid
81    /// missing objects when new CID fields are added to Commit.
82    ///
83    /// Parent commit CIDs are excluded since they serve a different purpose
84    /// (graph traversal vs. object reachability).
85    ///
86    /// CIDs that fail to parse are silently skipped.
87    pub fn referenced_object_cids(&self) -> Vec<VoidCid> {
88        let mut cids = Vec::with_capacity(3);
89        if let Ok(c) = self.metadata_bundle.to_void_cid() {
90            cids.push(c);
91        }
92        if let Some(ref m) = self.manifest_cid {
93            if let Ok(c) = m.to_void_cid() {
94                cids.push(c);
95            }
96        }
97        if let Some(ref r) = self.repo_manifest_cid {
98            if let Ok(c) = r.to_void_cid() {
99                cids.push(c);
100            }
101        }
102        cids
103    }
104
105    /// Returns true if this commit is signed.
106    pub fn is_signed(&self) -> bool {
107        self.author.is_some() && self.signature.is_some()
108    }
109
110    /// Returns the bytes to sign (excludes signature field).
111    ///
112    /// Format: parents || metadata_bundle || timestamp || message || author || manifest_cid || stats || repo_manifest_cid
113    pub fn signable_bytes(&self) -> Vec<u8> {
114        let mut buf = Vec::new();
115
116        // Parents (length-prefixed)
117        buf.extend((self.parents.len() as u32).to_le_bytes());
118        for parent in &self.parents {
119            buf.extend((parent.as_bytes().len() as u32).to_le_bytes());
120            buf.extend(parent.as_bytes());
121        }
122
123        // Metadata bundle (length-prefixed)
124        buf.extend((self.metadata_bundle.as_bytes().len() as u32).to_le_bytes());
125        buf.extend(self.metadata_bundle.as_bytes());
126
127        // Timestamp
128        buf.extend(self.timestamp.to_le_bytes());
129
130        // Message (length-prefixed)
131        buf.extend((self.message.len() as u32).to_le_bytes());
132        buf.extend(self.message.as_bytes());
133
134        // Author (if present)
135        if let Some(author) = &self.author {
136            buf.push(1); // Present marker
137            buf.extend(author.as_bytes());
138        } else {
139            buf.push(0); // Absent marker
140        }
141
142        // manifest_cid (if present)
143        if let Some(manifest_cid) = &self.manifest_cid {
144            buf.push(1); // Present marker
145            buf.extend((manifest_cid.as_bytes().len() as u32).to_le_bytes());
146            buf.extend(manifest_cid.as_bytes());
147        } else {
148            buf.push(0); // Absent marker
149        }
150
151        // stats (if present)
152        if let Some(stats) = &self.stats {
153            buf.push(1); // Present marker
154            buf.extend(stats.total_files.to_le_bytes());
155            buf.extend(stats.total_bytes.to_le_bytes());
156            buf.extend(&stats.paths_hash);
157        } else {
158            buf.push(0); // Absent marker
159        }
160
161        // repo_manifest_cid (if present)
162        if let Some(repo_manifest_cid) = &self.repo_manifest_cid {
163            buf.push(1); // Present marker
164            buf.extend((repo_manifest_cid.as_bytes().len() as u32).to_le_bytes());
165            buf.extend(repo_manifest_cid.as_bytes());
166        } else {
167            buf.push(0); // Absent marker
168        }
169
170        buf
171    }
172
173    /// Sign the commit with an Ed25519 signing key.
174    ///
175    /// Sets the author field to the public key and computes the signature.
176    pub fn sign(&mut self, signing_key: &SigningKey) {
177        self.author = Some(SigningPubKey::from_bytes(signing_key.verifying_key().to_bytes()));
178        let signable = self.signable_bytes();
179        let sig: Signature = signing_key.sign(&signable);
180        self.signature = Some(CommitSignature::from_bytes(sig.to_bytes()));
181    }
182
183    /// Verify the commit signature.
184    ///
185    /// Returns:
186    /// - `Ok(true)` if the signature is valid
187    /// - `Ok(false)` if the commit is unsigned (both author and signature are None)
188    /// - `Err(_)` if the signature is invalid or malformed
189    pub fn verify(&self) -> Result<bool> {
190        match (&self.author, &self.signature) {
191            (Some(pubkey_bytes), Some(sig_bytes)) => {
192                let verifying_key = VerifyingKey::from_bytes(pubkey_bytes.as_bytes()).map_err(|e| {
193                    VoidError::InvalidSignature(format!("invalid public key: {}", e))
194                })?;
195
196                let signature = Signature::from_bytes(sig_bytes.as_bytes());
197                let signable = self.signable_bytes();
198
199                verifying_key
200                    .verify_strict(&signable, &signature)
201                    .map_err(|e| {
202                        VoidError::InvalidSignature(format!("verification failed: {}", e))
203                    })?;
204
205                Ok(true)
206            }
207            (None, None) => Ok(false), // Unsigned commits are valid (but not signed)
208            _ => Err(VoidError::InvalidSignature(
209                "commit has author without signature or vice versa".to_string(),
210            )),
211        }
212    }
213}