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 constraints: None,
268 },
269 ),
270 (
271 "port".into(),
272 PropertyDef {
273 value_type: ValueType::Int,
274 required: false,
275 description: None,
276 constraints: None,
277 },
278 ),
279 ]),
280 subtypes: None,
281 },
282 ),
283 (
284 "signal".into(),
285 NodeTypeDef {
286 description: None,
287 properties: BTreeMap::new(),
288 subtypes: None,
289 },
290 ),
291 ]),
292 edge_types: BTreeMap::from([(
293 "RUNS_ON".into(),
294 EdgeTypeDef {
295 description: None,
296 source_types: vec!["entity".into()],
297 target_types: vec!["entity".into()],
298 properties: BTreeMap::new(),
299 },
300 )]),
301 }
302 }
303
304 fn sample_op() -> GraphOp {
305 GraphOp::AddNode {
306 node_id: "server-1".into(),
307 node_type: "entity".into(),
308 label: "Production Server".into(),
309 properties: BTreeMap::from([
310 ("ip".into(), Value::String("10.0.0.1".into())),
311 ("port".into(), Value::Int(8080)),
312 ]),
313 subtype: None,
314 }
315 }
316
317 fn sample_clock() -> LamportClock {
318 LamportClock::with_values("inst-a", 1, 0)
319 }
320
321 #[test]
322 fn entry_hash_deterministic() {
323 let e1 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
324 let e2 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
325 assert_eq!(e1.hash, e2.hash);
326 }
327
328 #[test]
329 fn entry_hash_changes_on_mutation() {
330 let e1 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
331 let different_op = GraphOp::AddNode {
332 node_id: "server-2".into(),
333 node_type: "entity".into(),
334 label: "Other Server".into(),
335 properties: BTreeMap::new(),
336 subtype: None,
337 };
338 let e2 = Entry::new(different_op, vec![], vec![], sample_clock(), "inst-a");
339 assert_ne!(e1.hash, e2.hash);
340 }
341
342 #[test]
343 fn entry_hash_changes_with_different_author() {
344 let e1 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
345 let e2 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-b");
346 assert_ne!(e1.hash, e2.hash);
347 }
348
349 #[test]
350 fn entry_hash_changes_with_different_clock() {
351 let e1 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
352 let mut clock2 = sample_clock();
353 clock2.physical_ms = 99;
354 let e2 = Entry::new(sample_op(), vec![], vec![], clock2, "inst-a");
355 assert_ne!(e1.hash, e2.hash);
356 }
357
358 #[test]
359 fn entry_hash_changes_with_different_next() {
360 let e1 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
361 let e2 = Entry::new(
362 sample_op(),
363 vec![[0u8; 32]],
364 vec![],
365 sample_clock(),
366 "inst-a",
367 );
368 assert_ne!(e1.hash, e2.hash);
369 }
370
371 #[test]
372 fn entry_verify_hash_valid() {
373 let entry = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
374 assert!(entry.verify_hash());
375 }
376
377 #[test]
378 fn entry_verify_hash_reject_tampered() {
379 let mut entry = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
380 entry.author = "evil-node".into();
381 assert!(!entry.verify_hash());
382 }
383
384 #[test]
385 fn entry_roundtrip_msgpack() {
386 let entry = Entry::new(
387 sample_op(),
388 vec![[1u8; 32]],
389 vec![[2u8; 32]],
390 sample_clock(),
391 "inst-a",
392 );
393 let bytes = entry.to_bytes();
394 let decoded = Entry::from_bytes(&bytes).unwrap();
395 assert_eq!(entry, decoded);
396 }
397
398 #[test]
399 fn entry_next_links_causal() {
400 let e1 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
401 let e2 = Entry::new(
402 GraphOp::RemoveNode {
403 node_id: "server-1".into(),
404 },
405 vec![e1.hash],
406 vec![],
407 LamportClock::with_values("inst-a", 2, 0),
408 "inst-a",
409 );
410 assert_eq!(e2.next, vec![e1.hash]);
411 assert!(e2.verify_hash());
412 }
413
414 #[test]
415 fn graphop_all_variants_serialize() {
416 let ops = vec![
417 GraphOp::DefineOntology {
418 ontology: sample_ontology(),
419 },
420 sample_op(),
421 GraphOp::AddEdge {
422 edge_id: "e1".into(),
423 edge_type: "RUNS_ON".into(),
424 source_id: "svc-1".into(),
425 target_id: "server-1".into(),
426 properties: BTreeMap::new(),
427 },
428 GraphOp::UpdateProperty {
429 entity_id: "server-1".into(),
430 key: "cpu".into(),
431 value: Value::Float(85.5),
432 },
433 GraphOp::RemoveNode {
434 node_id: "server-1".into(),
435 },
436 GraphOp::RemoveEdge {
437 edge_id: "e1".into(),
438 },
439 GraphOp::ExtendOntology {
440 extension: crate::ontology::OntologyExtension {
441 node_types: BTreeMap::from([(
442 "metric".into(),
443 NodeTypeDef {
444 description: Some("A metric observation".into()),
445 properties: BTreeMap::new(),
446 subtypes: None,
447 },
448 )]),
449 edge_types: BTreeMap::new(),
450 node_type_updates: BTreeMap::new(),
451 },
452 },
453 GraphOp::Checkpoint {
454 ops: vec![
455 GraphOp::DefineOntology {
456 ontology: sample_ontology(),
457 },
458 GraphOp::AddNode {
459 node_id: "n1".into(),
460 node_type: "entity".into(),
461 subtype: None,
462 label: "Node 1".into(),
463 properties: BTreeMap::new(),
464 },
465 ],
466 op_clocks: vec![(1, 0), (2, 0)],
467 compacted_at_physical_ms: 1000,
468 compacted_at_logical: 5,
469 },
470 ];
471 for op in ops {
472 let entry = Entry::new(op, vec![], vec![], sample_clock(), "inst-a");
473 let bytes = entry.to_bytes();
474 let decoded = Entry::from_bytes(&bytes).unwrap();
475 assert_eq!(entry, decoded);
476 }
477 }
478
479 #[test]
480 fn genesis_entry_contains_ontology() {
481 let ont = sample_ontology();
482 let genesis = Entry::new(
483 GraphOp::DefineOntology {
484 ontology: ont.clone(),
485 },
486 vec![],
487 vec![],
488 LamportClock::new("inst-a"),
489 "inst-a",
490 );
491 match &genesis.payload {
492 GraphOp::DefineOntology { ontology } => assert_eq!(ontology, &ont),
493 _ => panic!("genesis should be DefineOntology"),
494 }
495 assert!(genesis.next.is_empty(), "genesis has no predecessors");
496 assert!(genesis.verify_hash());
497 }
498
499 #[test]
500 fn value_all_variants_roundtrip() {
501 let values = vec![
502 Value::Null,
503 Value::Bool(true),
504 Value::Int(42),
505 Value::Float(3.14),
506 Value::String("hello".into()),
507 Value::List(vec![Value::Int(1), Value::String("two".into())]),
508 Value::Map(BTreeMap::from([("key".into(), Value::Bool(false))])),
509 ];
510 for val in values {
511 let bytes = rmp_serde::to_vec(&val).unwrap();
512 let decoded: Value = rmp_serde::from_slice(&bytes).unwrap();
513 assert_eq!(val, decoded);
514 }
515 }
516
517 #[test]
518 fn hash_hex_format() {
519 let entry = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
520 let hex = entry.hash_hex();
521 assert_eq!(hex.len(), 64);
522 assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
523 }
524
525 #[test]
526 fn unsigned_entry_has_no_signature() {
527 let entry = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
528 assert!(!entry.is_signed());
529 assert!(entry.signature.is_none());
530 }
531
532 #[test]
533 fn unsigned_entry_roundtrip_preserves_none_signature() {
534 let entry = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
535 let bytes = entry.to_bytes();
536 let decoded = Entry::from_bytes(&bytes).unwrap();
537 assert_eq!(decoded.signature, None);
538 assert!(decoded.verify_hash());
539 }
540
541 #[cfg(feature = "signing")]
542 mod signing_tests {
543 use super::*;
544
545 fn test_keypair() -> ed25519_dalek::SigningKey {
546 use rand::rngs::OsRng;
547 ed25519_dalek::SigningKey::generate(&mut OsRng)
548 }
549
550 #[test]
551 fn signed_entry_roundtrip() {
552 let key = test_keypair();
553 let entry =
554 Entry::new_signed(sample_op(), vec![], vec![], sample_clock(), "inst-a", &key);
555
556 assert!(entry.is_signed());
557 assert!(entry.verify_hash());
558
559 let public = key.verifying_key();
560 assert!(entry.verify_signature(&public));
561 }
562
563 #[test]
564 fn signed_entry_serialization_roundtrip() {
565 let key = test_keypair();
566 let entry =
567 Entry::new_signed(sample_op(), vec![], vec![], sample_clock(), "inst-a", &key);
568
569 let bytes = entry.to_bytes();
570 let decoded = Entry::from_bytes(&bytes).unwrap();
571
572 assert!(decoded.is_signed());
573 assert!(decoded.verify_hash());
574 assert!(decoded.verify_signature(&key.verifying_key()));
575 }
576
577 #[test]
578 fn wrong_key_fails_verification() {
579 let key1 = test_keypair();
580 let key2 = test_keypair();
581
582 let entry =
583 Entry::new_signed(sample_op(), vec![], vec![], sample_clock(), "inst-a", &key1);
584
585 assert!(entry.verify_signature(&key1.verifying_key()));
587 assert!(!entry.verify_signature(&key2.verifying_key()));
589 }
590
591 #[test]
592 fn tampered_hash_fails_both_checks() {
593 let key = test_keypair();
594 let mut entry =
595 Entry::new_signed(sample_op(), vec![], vec![], sample_clock(), "inst-a", &key);
596
597 entry.hash[0] ^= 0xFF;
599
600 assert!(!entry.verify_hash());
601 assert!(!entry.verify_signature(&key.verifying_key()));
602 }
603
604 #[test]
605 fn unsigned_entry_passes_signature_check() {
606 let key = test_keypair();
608 let entry = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
609
610 assert!(!entry.is_signed());
611 assert!(entry.verify_signature(&key.verifying_key())); }
613 }
614}