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