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 #[serde(default)]
99 pub refs: Vec<Hash>,
100 pub clock: LamportClock,
102 pub author: String,
104 #[serde(default)]
106 pub signature: Option<Vec<u8>>,
107}
108
109#[derive(Serialize)]
112struct SignableContent<'a> {
113 payload: &'a GraphOp,
114 next: &'a Vec<Hash>,
115 refs: &'a Vec<Hash>,
116 clock: &'a LamportClock,
117 author: &'a str,
118}
119
120impl Entry {
121 pub fn new(
123 payload: GraphOp,
124 next: Vec<Hash>,
125 refs: Vec<Hash>,
126 clock: LamportClock,
127 author: impl Into<String>,
128 ) -> Self {
129 let author = author.into();
130 let hash = Self::compute_hash(&payload, &next, &refs, &clock, &author);
131 Self {
132 hash,
133 payload,
134 next,
135 refs,
136 clock,
137 author,
138 signature: None,
139 }
140 }
141
142 #[cfg(feature = "signing")]
144 pub fn new_signed(
145 payload: GraphOp,
146 next: Vec<Hash>,
147 refs: Vec<Hash>,
148 clock: LamportClock,
149 author: impl Into<String>,
150 signing_key: &ed25519_dalek::SigningKey,
151 ) -> Self {
152 use ed25519_dalek::Signer;
153 let author = author.into();
154 let hash = Self::compute_hash(&payload, &next, &refs, &clock, &author);
155 let sig = signing_key.sign(&hash);
156 Self {
157 hash,
158 payload,
159 next,
160 refs,
161 clock,
162 author,
163 signature: Some(sig.to_bytes().to_vec()),
164 }
165 }
166
167 #[cfg(feature = "signing")]
171 pub fn verify_signature(&self, public_key: &ed25519_dalek::VerifyingKey) -> bool {
172 use ed25519_dalek::Verifier;
173 match &self.signature {
174 Some(sig_bytes) => {
175 if sig_bytes.len() != 64 {
176 return false;
177 }
178 let mut sig_array = [0u8; 64];
179 sig_array.copy_from_slice(sig_bytes);
180 let sig = ed25519_dalek::Signature::from_bytes(&sig_array);
181 public_key.verify(&self.hash, &sig).is_ok()
182 }
183 None => true, }
185 }
186
187 pub fn is_signed(&self) -> bool {
189 self.signature.is_some()
190 }
191
192 fn compute_hash(
194 payload: &GraphOp,
195 next: &Vec<Hash>,
196 refs: &Vec<Hash>,
197 clock: &LamportClock,
198 author: &str,
199 ) -> Hash {
200 let signable = SignableContent {
201 payload,
202 next,
203 refs,
204 clock,
205 author,
206 };
207 let bytes = rmp_serde::to_vec(&signable).expect("serialization should not fail");
210 *blake3::hash(&bytes).as_bytes()
211 }
212
213 pub fn verify_hash(&self) -> bool {
215 let computed = Self::compute_hash(
216 &self.payload,
217 &self.next,
218 &self.refs,
219 &self.clock,
220 &self.author,
221 );
222 self.hash == computed
223 }
224
225 pub fn to_bytes(&self) -> Vec<u8> {
231 rmp_serde::to_vec(self).expect("entry serialization should not fail")
232 }
233
234 pub fn from_bytes(bytes: &[u8]) -> Result<Self, rmp_serde::decode::Error> {
236 rmp_serde::from_slice(bytes)
237 }
238
239 pub fn hash_hex(&self) -> String {
241 hex::encode(self.hash)
242 }
243}
244
245pub fn hash_hex(hash: &Hash) -> String {
247 hex::encode(hash)
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253 use crate::ontology::{EdgeTypeDef, NodeTypeDef, PropertyDef, ValueType};
254
255 fn sample_ontology() -> Ontology {
256 Ontology {
257 node_types: BTreeMap::from([
258 (
259 "entity".into(),
260 NodeTypeDef {
261 description: None,
262 properties: BTreeMap::from([
263 (
264 "ip".into(),
265 PropertyDef {
266 value_type: ValueType::String,
267 required: false,
268 description: None,
269 constraints: None,
270 },
271 ),
272 (
273 "port".into(),
274 PropertyDef {
275 value_type: ValueType::Int,
276 required: false,
277 description: None,
278 constraints: None,
279 },
280 ),
281 ]),
282 subtypes: None,
283 parent_type: None,
284 },
285 ),
286 (
287 "signal".into(),
288 NodeTypeDef {
289 description: None,
290 properties: BTreeMap::new(),
291 subtypes: None,
292 parent_type: None,
293 },
294 ),
295 ]),
296 edge_types: BTreeMap::from([(
297 "RUNS_ON".into(),
298 EdgeTypeDef {
299 description: None,
300 source_types: vec!["entity".into()],
301 target_types: vec!["entity".into()],
302 properties: BTreeMap::new(),
303 },
304 )]),
305 }
306 }
307
308 fn sample_op() -> GraphOp {
309 GraphOp::AddNode {
310 node_id: "server-1".into(),
311 node_type: "entity".into(),
312 label: "Production Server".into(),
313 properties: BTreeMap::from([
314 ("ip".into(), Value::String("10.0.0.1".into())),
315 ("port".into(), Value::Int(8080)),
316 ]),
317 subtype: None,
318 }
319 }
320
321 fn sample_clock() -> LamportClock {
322 LamportClock::with_values("inst-a", 1, 0)
323 }
324
325 #[test]
326 fn entry_hash_deterministic() {
327 let e1 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
328 let e2 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
329 assert_eq!(e1.hash, e2.hash);
330 }
331
332 #[test]
333 fn entry_hash_changes_on_mutation() {
334 let e1 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
335 let different_op = GraphOp::AddNode {
336 node_id: "server-2".into(),
337 node_type: "entity".into(),
338 label: "Other Server".into(),
339 properties: BTreeMap::new(),
340 subtype: None,
341 };
342 let e2 = Entry::new(different_op, vec![], vec![], sample_clock(), "inst-a");
343 assert_ne!(e1.hash, e2.hash);
344 }
345
346 #[test]
347 fn entry_hash_changes_with_different_author() {
348 let e1 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
349 let e2 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-b");
350 assert_ne!(e1.hash, e2.hash);
351 }
352
353 #[test]
354 fn entry_hash_changes_with_different_clock() {
355 let e1 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
356 let mut clock2 = sample_clock();
357 clock2.physical_ms = 99;
358 let e2 = Entry::new(sample_op(), vec![], vec![], clock2, "inst-a");
359 assert_ne!(e1.hash, e2.hash);
360 }
361
362 #[test]
363 fn entry_hash_changes_with_different_next() {
364 let e1 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
365 let e2 = Entry::new(
366 sample_op(),
367 vec![[0u8; 32]],
368 vec![],
369 sample_clock(),
370 "inst-a",
371 );
372 assert_ne!(e1.hash, e2.hash);
373 }
374
375 #[test]
376 fn entry_verify_hash_valid() {
377 let entry = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
378 assert!(entry.verify_hash());
379 }
380
381 #[test]
382 fn entry_verify_hash_reject_tampered() {
383 let mut entry = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
384 entry.author = "evil-node".into();
385 assert!(!entry.verify_hash());
386 }
387
388 #[test]
389 fn entry_roundtrip_msgpack() {
390 let entry = Entry::new(
391 sample_op(),
392 vec![[1u8; 32]],
393 vec![[2u8; 32]],
394 sample_clock(),
395 "inst-a",
396 );
397 let bytes = entry.to_bytes();
398 let decoded = Entry::from_bytes(&bytes).unwrap();
399 assert_eq!(entry, decoded);
400 }
401
402 #[test]
403 fn entry_next_links_causal() {
404 let e1 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
405 let e2 = Entry::new(
406 GraphOp::RemoveNode {
407 node_id: "server-1".into(),
408 },
409 vec![e1.hash],
410 vec![],
411 LamportClock::with_values("inst-a", 2, 0),
412 "inst-a",
413 );
414 assert_eq!(e2.next, vec![e1.hash]);
415 assert!(e2.verify_hash());
416 }
417
418 #[test]
419 fn graphop_all_variants_serialize() {
420 let ops = vec![
421 GraphOp::DefineOntology {
422 ontology: sample_ontology(),
423 },
424 sample_op(),
425 GraphOp::AddEdge {
426 edge_id: "e1".into(),
427 edge_type: "RUNS_ON".into(),
428 source_id: "svc-1".into(),
429 target_id: "server-1".into(),
430 properties: BTreeMap::new(),
431 },
432 GraphOp::UpdateProperty {
433 entity_id: "server-1".into(),
434 key: "cpu".into(),
435 value: Value::Float(85.5),
436 },
437 GraphOp::RemoveNode {
438 node_id: "server-1".into(),
439 },
440 GraphOp::RemoveEdge {
441 edge_id: "e1".into(),
442 },
443 GraphOp::ExtendOntology {
444 extension: crate::ontology::OntologyExtension {
445 node_types: BTreeMap::from([(
446 "metric".into(),
447 NodeTypeDef {
448 description: Some("A metric observation".into()),
449 properties: BTreeMap::new(),
450 subtypes: None,
451 parent_type: None,
452 },
453 )]),
454 edge_types: BTreeMap::new(),
455 node_type_updates: BTreeMap::new(),
456 },
457 },
458 GraphOp::Checkpoint {
459 ops: vec![
460 GraphOp::DefineOntology {
461 ontology: sample_ontology(),
462 },
463 GraphOp::AddNode {
464 node_id: "n1".into(),
465 node_type: "entity".into(),
466 subtype: None,
467 label: "Node 1".into(),
468 properties: BTreeMap::new(),
469 },
470 ],
471 op_clocks: vec![(1, 0), (2, 0)],
472 compacted_at_physical_ms: 1000,
473 compacted_at_logical: 5,
474 },
475 ];
476 for op in ops {
477 let entry = Entry::new(op, vec![], vec![], sample_clock(), "inst-a");
478 let bytes = entry.to_bytes();
479 let decoded = Entry::from_bytes(&bytes).unwrap();
480 assert_eq!(entry, decoded);
481 }
482 }
483
484 #[test]
485 fn genesis_entry_contains_ontology() {
486 let ont = sample_ontology();
487 let genesis = Entry::new(
488 GraphOp::DefineOntology {
489 ontology: ont.clone(),
490 },
491 vec![],
492 vec![],
493 LamportClock::new("inst-a"),
494 "inst-a",
495 );
496 match &genesis.payload {
497 GraphOp::DefineOntology { ontology } => assert_eq!(ontology, &ont),
498 _ => panic!("genesis should be DefineOntology"),
499 }
500 assert!(genesis.next.is_empty(), "genesis has no predecessors");
501 assert!(genesis.verify_hash());
502 }
503
504 #[test]
505 fn value_all_variants_roundtrip() {
506 let values = vec![
507 Value::Null,
508 Value::Bool(true),
509 Value::Int(42),
510 Value::Float(3.14),
511 Value::String("hello".into()),
512 Value::List(vec![Value::Int(1), Value::String("two".into())]),
513 Value::Map(BTreeMap::from([("key".into(), Value::Bool(false))])),
514 ];
515 for val in values {
516 let bytes = rmp_serde::to_vec(&val).unwrap();
517 let decoded: Value = rmp_serde::from_slice(&bytes).unwrap();
518 assert_eq!(val, decoded);
519 }
520 }
521
522 #[test]
523 fn hash_hex_format() {
524 let entry = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
525 let hex = entry.hash_hex();
526 assert_eq!(hex.len(), 64);
527 assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
528 }
529
530 #[test]
531 fn unsigned_entry_has_no_signature() {
532 let entry = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
533 assert!(!entry.is_signed());
534 assert!(entry.signature.is_none());
535 }
536
537 #[test]
538 fn unsigned_entry_roundtrip_preserves_none_signature() {
539 let entry = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
540 let bytes = entry.to_bytes();
541 let decoded = Entry::from_bytes(&bytes).unwrap();
542 assert_eq!(decoded.signature, None);
543 assert!(decoded.verify_hash());
544 }
545
546 #[cfg(feature = "signing")]
547 mod signing_tests {
548 use super::*;
549
550 fn test_keypair() -> ed25519_dalek::SigningKey {
551 use rand::rngs::OsRng;
552 ed25519_dalek::SigningKey::generate(&mut OsRng)
553 }
554
555 #[test]
556 fn signed_entry_roundtrip() {
557 let key = test_keypair();
558 let entry =
559 Entry::new_signed(sample_op(), vec![], vec![], sample_clock(), "inst-a", &key);
560
561 assert!(entry.is_signed());
562 assert!(entry.verify_hash());
563
564 let public = key.verifying_key();
565 assert!(entry.verify_signature(&public));
566 }
567
568 #[test]
569 fn signed_entry_serialization_roundtrip() {
570 let key = test_keypair();
571 let entry =
572 Entry::new_signed(sample_op(), vec![], vec![], sample_clock(), "inst-a", &key);
573
574 let bytes = entry.to_bytes();
575 let decoded = Entry::from_bytes(&bytes).unwrap();
576
577 assert!(decoded.is_signed());
578 assert!(decoded.verify_hash());
579 assert!(decoded.verify_signature(&key.verifying_key()));
580 }
581
582 #[test]
583 fn wrong_key_fails_verification() {
584 let key1 = test_keypair();
585 let key2 = test_keypair();
586
587 let entry =
588 Entry::new_signed(sample_op(), vec![], vec![], sample_clock(), "inst-a", &key1);
589
590 assert!(entry.verify_signature(&key1.verifying_key()));
592 assert!(!entry.verify_signature(&key2.verifying_key()));
594 }
595
596 #[test]
597 fn tampered_hash_fails_both_checks() {
598 let key = test_keypair();
599 let mut entry =
600 Entry::new_signed(sample_op(), vec![], vec![], sample_clock(), "inst-a", &key);
601
602 entry.hash[0] ^= 0xFF;
604
605 assert!(!entry.verify_hash());
606 assert!(!entry.verify_signature(&key.verifying_key()));
607 }
608
609 #[test]
610 fn unsigned_entry_passes_signature_check() {
611 let key = test_keypair();
613 let entry = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
614
615 assert!(!entry.is_signed());
616 assert!(entry.verify_signature(&key.verifying_key())); }
618 }
619
620 #[test]
623 fn value_int_json_roundtrip_preserves_type() {
624 let val = Value::Int(1);
625 let json = serde_json::to_string(&val).unwrap();
626 let back: Value = serde_json::from_str(&json).unwrap();
627 assert_eq!(
628 back,
629 Value::Int(1),
630 "Int(1) -> JSON -> back should stay Int, got {:?}",
631 back
632 );
633 }
634
635 #[test]
636 fn value_float_json_roundtrip_preserves_type() {
637 let val = Value::Float(1.0);
638 let json = serde_json::to_string(&val).unwrap();
639 let back: Value = serde_json::from_str(&json).unwrap();
640 assert_eq!(
641 back,
642 Value::Float(1.0),
643 "Float(1.0) -> JSON -> back should stay Float, got {:?}",
644 back
645 );
646 }
647
648 #[test]
649 fn value_float_json_includes_decimal() {
650 let json = serde_json::to_string(&Value::Float(1.0)).unwrap();
653 assert!(
654 json.contains('.'),
655 "Float(1.0) must serialize with decimal point, got: {}",
656 json
657 );
658 }
659
660 #[test]
661 fn graphop_with_mixed_values_json_roundtrip() {
662 let mut props = BTreeMap::new();
663 props.insert("count".into(), Value::Int(42));
664 props.insert("ratio".into(), Value::Float(1.0));
665 props.insert("name".into(), Value::String("test".into()));
666
667 let op = GraphOp::UpdateProperty {
668 entity_id: "e1".into(),
669 key: "data".into(),
670 value: Value::Map(props),
671 };
672
673 let json = serde_json::to_string(&op).unwrap();
674 let back: GraphOp = serde_json::from_str(&json).unwrap();
675
676 let entry1 = Entry::new(op, vec![], vec![], sample_clock(), "a");
678 let entry2 = Entry::new(back, vec![], vec![], sample_clock(), "a");
679 assert_eq!(
680 entry1.hash, entry2.hash,
681 "JSON round-trip changed the hash!"
682 );
683 }
684}