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}