Skip to main content

neleus_db/
commit.rs

1use std::time::{SystemTime, UNIX_EPOCH};
2
3use anyhow::Result;
4use serde::{Deserialize, Serialize};
5
6use crate::canonical::to_cbor;
7use crate::hash::{Hash, hash_typed};
8use crate::object_store::ObjectStore;
9use crate::state::StateRoot;
10
11const COMMIT_TAG: &[u8] = b"commit:";
12const COMMIT_PAYLOAD_TAG: &[u8] = b"commit_payload:";
13const COMMIT_SCHEMA_VERSION: u32 = 1;
14
15pub type CommitHash = Hash;
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct CommitSignature {
19    pub scheme: String,
20    pub key_id: Option<String>,
21    pub signature: Vec<u8>,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct Commit {
26    #[serde(default = "default_commit_schema_version")]
27    pub schema_version: u32,
28    pub parents: Vec<CommitHash>,
29    pub timestamp: u64,
30    pub author: String,
31    pub message: String,
32    pub state_root: StateRoot,
33    pub manifests: Vec<Hash>,
34    #[serde(default)]
35    pub signature: Option<CommitSignature>,
36}
37
38pub trait CommitVerifier {
39    fn verify(&self, commit_hash: CommitHash, commit: &Commit) -> Result<()>;
40}
41
42pub trait CommitSigner {
43    fn sign(&self, payload_hash: Hash, commit: &Commit) -> Result<CommitSignature>;
44}
45
46#[derive(Clone, Debug)]
47pub struct CommitStore {
48    objects: ObjectStore,
49}
50
51impl CommitStore {
52    pub fn new(objects: ObjectStore) -> Self {
53        Self { objects }
54    }
55
56    pub fn create_commit(
57        &self,
58        parents: Vec<CommitHash>,
59        state_root: StateRoot,
60        manifests: Vec<Hash>,
61        author: String,
62        message: String,
63    ) -> Result<CommitHash> {
64        let commit = Commit {
65            schema_version: COMMIT_SCHEMA_VERSION,
66            parents,
67            timestamp: now_unix(),
68            author,
69            message,
70            state_root,
71            manifests,
72            signature: None,
73        };
74        self.objects.put_serialized(COMMIT_TAG, &commit)
75    }
76
77    pub fn create_signed_commit<S: CommitSigner>(
78        &self,
79        signer: &S,
80        parents: Vec<CommitHash>,
81        state_root: StateRoot,
82        manifests: Vec<Hash>,
83        author: String,
84        message: String,
85    ) -> Result<CommitHash> {
86        let unsigned = Commit {
87            schema_version: COMMIT_SCHEMA_VERSION,
88            parents,
89            timestamp: now_unix(),
90            author,
91            message,
92            state_root,
93            manifests,
94            signature: None,
95        };
96        let payload_hash = hash_typed(COMMIT_PAYLOAD_TAG, &to_cbor(&unsigned)?);
97        let signature = signer.sign(payload_hash, &unsigned)?;
98
99        let signed = Commit {
100            signature: Some(signature),
101            ..unsigned
102        };
103        self.objects.put_serialized(COMMIT_TAG, &signed)
104    }
105
106    pub fn get_commit(&self, hash: CommitHash) -> Result<Commit> {
107        let mut commit: Commit = self.objects.get_deserialized_typed(COMMIT_TAG, hash)?;
108        migrate_commit_in_place(&mut commit);
109        Ok(commit)
110    }
111
112    pub fn verify_commit_with<V: CommitVerifier>(
113        &self,
114        hash: CommitHash,
115        verifier: &V,
116    ) -> Result<()> {
117        let commit = self.get_commit(hash)?;
118        verifier.verify(hash, &commit)
119    }
120}
121
122pub fn create_commit(
123    store: &CommitStore,
124    parents: Vec<CommitHash>,
125    state_root: StateRoot,
126    manifests: Vec<Hash>,
127    author: String,
128    message: String,
129) -> Result<CommitHash> {
130    store.create_commit(parents, state_root, manifests, author, message)
131}
132
133pub fn get_commit(store: &CommitStore, hash: CommitHash) -> Result<Commit> {
134    store.get_commit(hash)
135}
136
137fn default_commit_schema_version() -> u32 {
138    COMMIT_SCHEMA_VERSION
139}
140
141fn migrate_commit_in_place(commit: &mut Commit) {
142    if commit.schema_version == 0 {
143        commit.schema_version = COMMIT_SCHEMA_VERSION;
144    }
145}
146
147fn now_unix() -> u64 {
148    SystemTime::now()
149        .duration_since(UNIX_EPOCH)
150        .expect("clock drift before epoch")
151        .as_secs()
152}
153
154#[cfg(test)]
155mod tests {
156    use tempfile::TempDir;
157
158    use super::*;
159    use crate::blob_store::BlobStore;
160    use crate::object_store::ObjectStore;
161    use crate::state::StateStore;
162    use crate::wal::Wal;
163
164    fn stores(tmp: &TempDir) -> (CommitStore, StateStore, BlobStore) {
165        let objects = ObjectStore::new(tmp.path().join("objects"));
166        objects.ensure_dir().unwrap();
167        let commit_store = CommitStore::new(objects.clone());
168
169        let blobs = BlobStore::new(tmp.path().join("blobs"));
170        blobs.ensure_dir().unwrap();
171
172        let state = StateStore::new(objects, blobs.clone(), Wal::new(tmp.path().join("wal")));
173        (commit_store, state, blobs)
174    }
175
176    #[test]
177    fn commit_create_get_roundtrip() {
178        let tmp = TempDir::new().unwrap();
179        let (cs, state, _) = stores(&tmp);
180        let root = state.empty_root().unwrap();
181        let h = cs
182            .create_commit(vec![], root, vec![], "agent".into(), "msg".into())
183            .unwrap();
184        let c = cs.get_commit(h).unwrap();
185        assert_eq!(c.author, "agent");
186        assert_eq!(c.message, "msg");
187        assert_eq!(c.schema_version, COMMIT_SCHEMA_VERSION);
188    }
189
190    #[test]
191    fn commit_hash_changes_with_message() {
192        let tmp = TempDir::new().unwrap();
193        let (cs, state, _) = stores(&tmp);
194        let root = state.empty_root().unwrap();
195        let a = cs
196            .create_commit(vec![], root, vec![], "a".into(), "m1".into())
197            .unwrap();
198        let b = cs
199            .create_commit(vec![], root, vec![], "a".into(), "m2".into())
200            .unwrap();
201        assert_ne!(a, b);
202    }
203
204    #[test]
205    fn commit_parent_reference_preserved() {
206        let tmp = TempDir::new().unwrap();
207        let (cs, state, _) = stores(&tmp);
208        let root = state.empty_root().unwrap();
209        let p = cs
210            .create_commit(vec![], root, vec![], "a".into(), "p".into())
211            .unwrap();
212        let c = cs
213            .create_commit(vec![p], root, vec![], "a".into(), "c".into())
214            .unwrap();
215        let out = cs.get_commit(c).unwrap();
216        assert_eq!(out.parents, vec![p]);
217    }
218
219    #[test]
220    fn commit_can_reference_manifests() {
221        let tmp = TempDir::new().unwrap();
222        let (cs, state, blobs) = stores(&tmp);
223        let root = state.empty_root().unwrap();
224        let manifest_hash = blobs.put(b"manifest ref").unwrap();
225        let c = cs
226            .create_commit(
227                vec![],
228                root,
229                vec![manifest_hash],
230                "agent".into(),
231                "with manifest".into(),
232            )
233            .unwrap();
234        let out = cs.get_commit(c).unwrap();
235        assert_eq!(out.manifests, vec![manifest_hash]);
236    }
237
238    #[test]
239    fn commit_timestamp_nonzero() {
240        let tmp = TempDir::new().unwrap();
241        let (cs, state, _) = stores(&tmp);
242        let root = state.empty_root().unwrap();
243        let h = cs
244            .create_commit(vec![], root, vec![], "agent".into(), "msg".into())
245            .unwrap();
246        let c = cs.get_commit(h).unwrap();
247        assert!(c.timestamp > 0);
248    }
249
250    #[test]
251    fn commit_free_functions_work() {
252        let tmp = TempDir::new().unwrap();
253        let (cs, state, _) = stores(&tmp);
254        let root = state.empty_root().unwrap();
255
256        let h = super::create_commit(&cs, vec![], root, vec![], "a".into(), "m".into()).unwrap();
257        let c = super::get_commit(&cs, h).unwrap();
258        assert_eq!(c.message, "m");
259    }
260
261    struct DummySigner;
262
263    impl CommitSigner for DummySigner {
264        fn sign(&self, payload_hash: Hash, _commit: &Commit) -> Result<CommitSignature> {
265            Ok(CommitSignature {
266                scheme: "dummy".into(),
267                key_id: Some("k1".into()),
268                signature: payload_hash.as_bytes().to_vec(),
269            })
270        }
271    }
272
273    struct DummyVerifier;
274
275    impl CommitVerifier for DummyVerifier {
276        fn verify(&self, _hash: CommitHash, commit: &Commit) -> Result<()> {
277            if commit.signature.is_some() {
278                Ok(())
279            } else {
280                Err(anyhow::anyhow!("missing signature"))
281            }
282        }
283    }
284
285    #[test]
286    fn signed_commit_hook_works() {
287        let tmp = TempDir::new().unwrap();
288        let (cs, state, _) = stores(&tmp);
289        let root = state.empty_root().unwrap();
290        let h = cs
291            .create_signed_commit(
292                &DummySigner,
293                vec![],
294                root,
295                vec![],
296                "agent".into(),
297                "msg".into(),
298            )
299            .unwrap();
300        let c = cs.get_commit(h).unwrap();
301        assert!(c.signature.is_some());
302        cs.verify_commit_with(h, &DummyVerifier).unwrap();
303    }
304}