1use serde::{Deserialize, Serialize};
2use std::collections::BTreeMap;
3
4use crate::clock::LamportClock;
5use crate::ontology::{Ontology, OntologyExtension};
6
7#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
9#[serde(untagged)]
10pub enum Value {
11 Null,
12 Bool(bool),
13 Int(i64),
14 Float(f64),
15 String(String),
16 List(Vec<Value>),
17 Map(BTreeMap<String, Value>),
18}
19
20#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
25#[serde(tag = "op")]
26pub enum GraphOp {
27 #[serde(rename = "define_ontology")]
30 DefineOntology { ontology: Ontology },
31 #[serde(rename = "add_node")]
32 AddNode {
33 node_id: String,
34 node_type: String,
35 #[serde(default)]
36 subtype: Option<String>,
37 label: String,
38 #[serde(default)]
39 properties: BTreeMap<String, Value>,
40 },
41 #[serde(rename = "add_edge")]
42 AddEdge {
43 edge_id: String,
44 edge_type: String,
45 source_id: String,
46 target_id: String,
47 #[serde(default)]
48 properties: BTreeMap<String, Value>,
49 },
50 #[serde(rename = "update_property")]
51 UpdateProperty {
52 entity_id: String,
53 key: String,
54 value: Value,
55 },
56 #[serde(rename = "remove_node")]
57 RemoveNode { node_id: String },
58 #[serde(rename = "remove_edge")]
59 RemoveEdge { edge_id: String },
60 #[serde(rename = "extend_ontology")]
62 ExtendOntology { extension: OntologyExtension },
63 #[serde(rename = "checkpoint")]
67 Checkpoint {
68 ops: Vec<GraphOp>,
70 #[serde(default)]
73 op_clocks: Vec<(u64, u32)>,
74 compacted_at_physical_ms: u64,
76 compacted_at_logical: u32,
78 },
79}
80
81pub type Hash = [u8; 32];
83
84#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
89pub struct Entry {
90 pub hash: Hash,
92 pub payload: GraphOp,
94 pub next: Vec<Hash>,
96 pub refs: Vec<Hash>,
98 pub clock: LamportClock,
100 pub author: String,
102 #[serde(default)]
104 pub signature: Option<Vec<u8>>,
105}
106
107#[derive(Serialize)]
110struct SignableContent<'a> {
111 payload: &'a GraphOp,
112 next: &'a Vec<Hash>,
113 refs: &'a Vec<Hash>,
114 clock: &'a LamportClock,
115 author: &'a str,
116}
117
118impl Entry {
119 pub fn new(
121 payload: GraphOp,
122 next: Vec<Hash>,
123 refs: Vec<Hash>,
124 clock: LamportClock,
125 author: impl Into<String>,
126 ) -> Self {
127 let author = author.into();
128 let hash = Self::compute_hash(&payload, &next, &refs, &clock, &author);
129 Self {
130 hash,
131 payload,
132 next,
133 refs,
134 clock,
135 author,
136 signature: None,
137 }
138 }
139
140 #[cfg(feature = "signing")]
142 pub fn new_signed(
143 payload: GraphOp,
144 next: Vec<Hash>,
145 refs: Vec<Hash>,
146 clock: LamportClock,
147 author: impl Into<String>,
148 signing_key: &ed25519_dalek::SigningKey,
149 ) -> Self {
150 use ed25519_dalek::Signer;
151 let author = author.into();
152 let hash = Self::compute_hash(&payload, &next, &refs, &clock, &author);
153 let sig = signing_key.sign(&hash);
154 Self {
155 hash,
156 payload,
157 next,
158 refs,
159 clock,
160 author,
161 signature: Some(sig.to_bytes().to_vec()),
162 }
163 }
164
165 #[cfg(feature = "signing")]
169 pub fn verify_signature(&self, public_key: &ed25519_dalek::VerifyingKey) -> bool {
170 use ed25519_dalek::Verifier;
171 match &self.signature {
172 Some(sig_bytes) => {
173 if sig_bytes.len() != 64 {
174 return false;
175 }
176 let mut sig_array = [0u8; 64];
177 sig_array.copy_from_slice(sig_bytes);
178 let sig = ed25519_dalek::Signature::from_bytes(&sig_array);
179 public_key.verify(&self.hash, &sig).is_ok()
180 }
181 None => true, }
183 }
184
185 pub fn is_signed(&self) -> bool {
187 self.signature.is_some()
188 }
189
190 fn compute_hash(
192 payload: &GraphOp,
193 next: &Vec<Hash>,
194 refs: &Vec<Hash>,
195 clock: &LamportClock,
196 author: &str,
197 ) -> Hash {
198 let signable = SignableContent {
199 payload,
200 next,
201 refs,
202 clock,
203 author,
204 };
205 let bytes = rmp_serde::to_vec(&signable).expect("serialization should not fail");
208 *blake3::hash(&bytes).as_bytes()
209 }
210
211 pub fn verify_hash(&self) -> bool {
213 let computed = Self::compute_hash(
214 &self.payload,
215 &self.next,
216 &self.refs,
217 &self.clock,
218 &self.author,
219 );
220 self.hash == computed
221 }
222
223 pub fn to_bytes(&self) -> Vec<u8> {
229 rmp_serde::to_vec(self).expect("entry serialization should not fail")
230 }
231
232 pub fn from_bytes(bytes: &[u8]) -> Result<Self, rmp_serde::decode::Error> {
234 rmp_serde::from_slice(bytes)
235 }
236
237 pub fn hash_hex(&self) -> String {
239 hex::encode(self.hash)
240 }
241}
242
243pub fn hash_hex(hash: &Hash) -> String {
245 hex::encode(hash)
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251 use crate::ontology::{EdgeTypeDef, NodeTypeDef, PropertyDef, ValueType};
252
253 fn sample_ontology() -> Ontology {
254 Ontology {
255 node_types: BTreeMap::from([
256 (
257 "entity".into(),
258 NodeTypeDef {
259 description: None,
260 properties: BTreeMap::from([
261 (
262 "ip".into(),
263 PropertyDef {
264 value_type: ValueType::String,
265 required: false,
266 description: None,
267 },
268 ),
269 (
270 "port".into(),
271 PropertyDef {
272 value_type: ValueType::Int,
273 required: false,
274 description: None,
275 },
276 ),
277 ]),
278 subtypes: None,
279 },
280 ),
281 (
282 "signal".into(),
283 NodeTypeDef {
284 description: None,
285 properties: BTreeMap::new(),
286 subtypes: None,
287 },
288 ),
289 ]),
290 edge_types: BTreeMap::from([(
291 "RUNS_ON".into(),
292 EdgeTypeDef {
293 description: None,
294 source_types: vec!["entity".into()],
295 target_types: vec!["entity".into()],
296 properties: BTreeMap::new(),
297 },
298 )]),
299 }
300 }
301
302 fn sample_op() -> GraphOp {
303 GraphOp::AddNode {
304 node_id: "server-1".into(),
305 node_type: "entity".into(),
306 label: "Production Server".into(),
307 properties: BTreeMap::from([
308 ("ip".into(), Value::String("10.0.0.1".into())),
309 ("port".into(), Value::Int(8080)),
310 ]),
311 subtype: None,
312 }
313 }
314
315 fn sample_clock() -> LamportClock {
316 LamportClock::with_values("inst-a", 1, 0)
317 }
318
319 #[test]
320 fn entry_hash_deterministic() {
321 let e1 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
322 let e2 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
323 assert_eq!(e1.hash, e2.hash);
324 }
325
326 #[test]
327 fn entry_hash_changes_on_mutation() {
328 let e1 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
329 let different_op = GraphOp::AddNode {
330 node_id: "server-2".into(),
331 node_type: "entity".into(),
332 label: "Other Server".into(),
333 properties: BTreeMap::new(),
334 subtype: None,
335 };
336 let e2 = Entry::new(different_op, vec![], vec![], sample_clock(), "inst-a");
337 assert_ne!(e1.hash, e2.hash);
338 }
339
340 #[test]
341 fn entry_hash_changes_with_different_author() {
342 let e1 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
343 let e2 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-b");
344 assert_ne!(e1.hash, e2.hash);
345 }
346
347 #[test]
348 fn entry_hash_changes_with_different_clock() {
349 let e1 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
350 let mut clock2 = sample_clock();
351 clock2.physical_ms = 99;
352 let e2 = Entry::new(sample_op(), vec![], vec![], clock2, "inst-a");
353 assert_ne!(e1.hash, e2.hash);
354 }
355
356 #[test]
357 fn entry_hash_changes_with_different_next() {
358 let e1 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
359 let e2 = Entry::new(
360 sample_op(),
361 vec![[0u8; 32]],
362 vec![],
363 sample_clock(),
364 "inst-a",
365 );
366 assert_ne!(e1.hash, e2.hash);
367 }
368
369 #[test]
370 fn entry_verify_hash_valid() {
371 let entry = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
372 assert!(entry.verify_hash());
373 }
374
375 #[test]
376 fn entry_verify_hash_reject_tampered() {
377 let mut entry = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
378 entry.author = "evil-node".into();
379 assert!(!entry.verify_hash());
380 }
381
382 #[test]
383 fn entry_roundtrip_msgpack() {
384 let entry = Entry::new(
385 sample_op(),
386 vec![[1u8; 32]],
387 vec![[2u8; 32]],
388 sample_clock(),
389 "inst-a",
390 );
391 let bytes = entry.to_bytes();
392 let decoded = Entry::from_bytes(&bytes).unwrap();
393 assert_eq!(entry, decoded);
394 }
395
396 #[test]
397 fn entry_next_links_causal() {
398 let e1 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
399 let e2 = Entry::new(
400 GraphOp::RemoveNode {
401 node_id: "server-1".into(),
402 },
403 vec![e1.hash],
404 vec![],
405 LamportClock::with_values("inst-a", 2, 0),
406 "inst-a",
407 );
408 assert_eq!(e2.next, vec![e1.hash]);
409 assert!(e2.verify_hash());
410 }
411
412 #[test]
413 fn graphop_all_variants_serialize() {
414 let ops = vec![
415 GraphOp::DefineOntology {
416 ontology: sample_ontology(),
417 },
418 sample_op(),
419 GraphOp::AddEdge {
420 edge_id: "e1".into(),
421 edge_type: "RUNS_ON".into(),
422 source_id: "svc-1".into(),
423 target_id: "server-1".into(),
424 properties: BTreeMap::new(),
425 },
426 GraphOp::UpdateProperty {
427 entity_id: "server-1".into(),
428 key: "cpu".into(),
429 value: Value::Float(85.5),
430 },
431 GraphOp::RemoveNode {
432 node_id: "server-1".into(),
433 },
434 GraphOp::RemoveEdge {
435 edge_id: "e1".into(),
436 },
437 GraphOp::ExtendOntology {
438 extension: crate::ontology::OntologyExtension {
439 node_types: BTreeMap::from([(
440 "metric".into(),
441 NodeTypeDef {
442 description: Some("A metric observation".into()),
443 properties: BTreeMap::new(),
444 subtypes: None,
445 },
446 )]),
447 edge_types: BTreeMap::new(),
448 node_type_updates: BTreeMap::new(),
449 },
450 },
451 GraphOp::Checkpoint {
452 ops: vec![
453 GraphOp::DefineOntology {
454 ontology: sample_ontology(),
455 },
456 GraphOp::AddNode {
457 node_id: "n1".into(),
458 node_type: "entity".into(),
459 subtype: None,
460 label: "Node 1".into(),
461 properties: BTreeMap::new(),
462 },
463 ],
464 op_clocks: vec![(1, 0), (2, 0)],
465 compacted_at_physical_ms: 1000,
466 compacted_at_logical: 5,
467 },
468 ];
469 for op in ops {
470 let entry = Entry::new(op, vec![], vec![], sample_clock(), "inst-a");
471 let bytes = entry.to_bytes();
472 let decoded = Entry::from_bytes(&bytes).unwrap();
473 assert_eq!(entry, decoded);
474 }
475 }
476
477 #[test]
478 fn genesis_entry_contains_ontology() {
479 let ont = sample_ontology();
480 let genesis = Entry::new(
481 GraphOp::DefineOntology {
482 ontology: ont.clone(),
483 },
484 vec![],
485 vec![],
486 LamportClock::new("inst-a"),
487 "inst-a",
488 );
489 match &genesis.payload {
490 GraphOp::DefineOntology { ontology } => assert_eq!(ontology, &ont),
491 _ => panic!("genesis should be DefineOntology"),
492 }
493 assert!(genesis.next.is_empty(), "genesis has no predecessors");
494 assert!(genesis.verify_hash());
495 }
496
497 #[test]
498 fn value_all_variants_roundtrip() {
499 let values = vec![
500 Value::Null,
501 Value::Bool(true),
502 Value::Int(42),
503 Value::Float(3.14),
504 Value::String("hello".into()),
505 Value::List(vec![Value::Int(1), Value::String("two".into())]),
506 Value::Map(BTreeMap::from([("key".into(), Value::Bool(false))])),
507 ];
508 for val in values {
509 let bytes = rmp_serde::to_vec(&val).unwrap();
510 let decoded: Value = rmp_serde::from_slice(&bytes).unwrap();
511 assert_eq!(val, decoded);
512 }
513 }
514
515 #[test]
516 fn hash_hex_format() {
517 let entry = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
518 let hex = entry.hash_hex();
519 assert_eq!(hex.len(), 64);
520 assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
521 }
522
523 #[test]
524 fn unsigned_entry_has_no_signature() {
525 let entry = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
526 assert!(!entry.is_signed());
527 assert!(entry.signature.is_none());
528 }
529
530 #[test]
531 fn unsigned_entry_roundtrip_preserves_none_signature() {
532 let entry = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
533 let bytes = entry.to_bytes();
534 let decoded = Entry::from_bytes(&bytes).unwrap();
535 assert_eq!(decoded.signature, None);
536 assert!(decoded.verify_hash());
537 }
538
539 #[cfg(feature = "signing")]
540 mod signing_tests {
541 use super::*;
542
543 fn test_keypair() -> ed25519_dalek::SigningKey {
544 use rand::rngs::OsRng;
545 ed25519_dalek::SigningKey::generate(&mut OsRng)
546 }
547
548 #[test]
549 fn signed_entry_roundtrip() {
550 let key = test_keypair();
551 let entry =
552 Entry::new_signed(sample_op(), vec![], vec![], sample_clock(), "inst-a", &key);
553
554 assert!(entry.is_signed());
555 assert!(entry.verify_hash());
556
557 let public = key.verifying_key();
558 assert!(entry.verify_signature(&public));
559 }
560
561 #[test]
562 fn signed_entry_serialization_roundtrip() {
563 let key = test_keypair();
564 let entry =
565 Entry::new_signed(sample_op(), vec![], vec![], sample_clock(), "inst-a", &key);
566
567 let bytes = entry.to_bytes();
568 let decoded = Entry::from_bytes(&bytes).unwrap();
569
570 assert!(decoded.is_signed());
571 assert!(decoded.verify_hash());
572 assert!(decoded.verify_signature(&key.verifying_key()));
573 }
574
575 #[test]
576 fn wrong_key_fails_verification() {
577 let key1 = test_keypair();
578 let key2 = test_keypair();
579
580 let entry =
581 Entry::new_signed(sample_op(), vec![], vec![], sample_clock(), "inst-a", &key1);
582
583 assert!(entry.verify_signature(&key1.verifying_key()));
585 assert!(!entry.verify_signature(&key2.verifying_key()));
587 }
588
589 #[test]
590 fn tampered_hash_fails_both_checks() {
591 let key = test_keypair();
592 let mut entry =
593 Entry::new_signed(sample_op(), vec![], vec![], sample_clock(), "inst-a", &key);
594
595 entry.hash[0] ^= 0xFF;
597
598 assert!(!entry.verify_hash());
599 assert!(!entry.verify_signature(&key.verifying_key()));
600 }
601
602 #[test]
603 fn unsigned_entry_passes_signature_check() {
604 let key = test_keypair();
606 let entry = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
607
608 assert!(!entry.is_signed());
609 assert!(entry.verify_signature(&key.verifying_key())); }
611 }
612}