use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use crate::clock::LamportClock;
use crate::ontology::{Ontology, OntologyExtension};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Value {
Null,
Bool(bool),
Int(i64),
Float(f64),
String(String),
List(Vec<Value>),
Map(BTreeMap<String, Value>),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "op")]
pub enum GraphOp {
#[serde(rename = "define_ontology")]
DefineOntology { ontology: Ontology },
#[serde(rename = "add_node")]
AddNode {
node_id: String,
node_type: String,
#[serde(default)]
subtype: Option<String>,
label: String,
#[serde(default)]
properties: BTreeMap<String, Value>,
},
#[serde(rename = "add_edge")]
AddEdge {
edge_id: String,
edge_type: String,
source_id: String,
target_id: String,
#[serde(default)]
properties: BTreeMap<String, Value>,
},
#[serde(rename = "update_property")]
UpdateProperty {
entity_id: String,
key: String,
value: Value,
},
#[serde(rename = "remove_node")]
RemoveNode { node_id: String },
#[serde(rename = "remove_edge")]
RemoveEdge { edge_id: String },
#[serde(rename = "extend_ontology")]
ExtendOntology { extension: OntologyExtension },
#[serde(rename = "define_lens")]
DefineLens { transforms: Vec<u8> },
#[serde(rename = "checkpoint")]
Checkpoint {
ops: Vec<GraphOp>,
#[serde(default)]
op_clocks: Vec<(u64, u32)>,
compacted_at_physical_ms: u64,
compacted_at_logical: u32,
},
}
pub type Hash = [u8; 32];
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Entry {
pub hash: Hash,
pub payload: GraphOp,
pub next: Vec<Hash>,
#[serde(default)]
pub refs: Vec<Hash>,
pub clock: LamportClock,
pub author: String,
#[serde(default)]
pub signature: Option<Vec<u8>>,
#[serde(default)]
pub ontology_hash: Option<Hash>,
}
#[derive(Serialize)]
struct SignableContent<'a> {
payload: &'a GraphOp,
next: &'a Vec<Hash>,
refs: &'a Vec<Hash>,
clock: &'a LamportClock,
author: &'a str,
}
impl Entry {
pub fn new(
payload: GraphOp,
next: Vec<Hash>,
refs: Vec<Hash>,
clock: LamportClock,
author: impl Into<String>,
) -> Self {
let author = author.into();
let hash = Self::compute_hash(&payload, &next, &refs, &clock, &author);
Self {
hash,
payload,
next,
refs,
clock,
author,
signature: None,
ontology_hash: None,
}
}
#[cfg(feature = "signing")]
pub fn new_signed(
payload: GraphOp,
next: Vec<Hash>,
refs: Vec<Hash>,
clock: LamportClock,
author: impl Into<String>,
signing_key: &ed25519_dalek::SigningKey,
) -> Self {
use ed25519_dalek::Signer;
let author = author.into();
let hash = Self::compute_hash(&payload, &next, &refs, &clock, &author);
let sig = signing_key.sign(&hash);
Self {
hash,
payload,
next,
refs,
clock,
author,
signature: Some(sig.to_bytes().to_vec()),
ontology_hash: None,
}
}
#[cfg(feature = "signing")]
pub fn verify_signature(&self, public_key: &ed25519_dalek::VerifyingKey) -> bool {
use ed25519_dalek::Verifier;
match &self.signature {
Some(sig_bytes) => {
if sig_bytes.len() != 64 {
return false;
}
let mut sig_array = [0u8; 64];
sig_array.copy_from_slice(sig_bytes);
let sig = ed25519_dalek::Signature::from_bytes(&sig_array);
public_key.verify(&self.hash, &sig).is_ok()
}
None => true, }
}
pub fn is_signed(&self) -> bool {
self.signature.is_some()
}
fn compute_hash(
payload: &GraphOp,
next: &Vec<Hash>,
refs: &Vec<Hash>,
clock: &LamportClock,
author: &str,
) -> Hash {
let signable = SignableContent {
payload,
next,
refs,
clock,
author,
};
let bytes = rmp_serde::to_vec(&signable).expect("serialization should not fail");
*blake3::hash(&bytes).as_bytes()
}
pub fn verify_hash(&self) -> bool {
let computed = Self::compute_hash(
&self.payload,
&self.next,
&self.refs,
&self.clock,
&self.author,
);
self.hash == computed
}
pub fn to_bytes(&self) -> Vec<u8> {
rmp_serde::to_vec(self).expect("entry serialization should not fail")
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self, rmp_serde::decode::Error> {
rmp_serde::from_slice(bytes)
}
pub fn hash_hex(&self) -> String {
hex::encode(self.hash)
}
}
pub fn hash_hex(hash: &Hash) -> String {
hex::encode(hash)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ontology::{EdgeTypeDef, NodeTypeDef, PropertyDef, ValueType};
fn sample_ontology() -> Ontology {
Ontology {
node_types: BTreeMap::from([
(
"entity".into(),
NodeTypeDef {
description: None,
properties: BTreeMap::from([
(
"ip".into(),
PropertyDef {
value_type: ValueType::String,
required: false,
description: None,
constraints: None,
},
),
(
"port".into(),
PropertyDef {
value_type: ValueType::Int,
required: false,
description: None,
constraints: None,
},
),
]),
subtypes: None,
parent_type: None,
},
),
(
"signal".into(),
NodeTypeDef {
description: None,
properties: BTreeMap::new(),
subtypes: None,
parent_type: None,
},
),
]),
edge_types: BTreeMap::from([(
"RUNS_ON".into(),
EdgeTypeDef {
description: None,
source_types: vec!["entity".into()],
target_types: vec!["entity".into()],
properties: BTreeMap::new(),
},
)]),
}
}
fn sample_op() -> GraphOp {
GraphOp::AddNode {
node_id: "server-1".into(),
node_type: "entity".into(),
label: "Production Server".into(),
properties: BTreeMap::from([
("ip".into(), Value::String("10.0.0.1".into())),
("port".into(), Value::Int(8080)),
]),
subtype: None,
}
}
fn sample_clock() -> LamportClock {
LamportClock::with_values("inst-a", 1, 0)
}
#[test]
fn entry_hash_deterministic() {
let e1 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
let e2 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
assert_eq!(e1.hash, e2.hash);
}
#[test]
fn entry_hash_changes_on_mutation() {
let e1 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
let different_op = GraphOp::AddNode {
node_id: "server-2".into(),
node_type: "entity".into(),
label: "Other Server".into(),
properties: BTreeMap::new(),
subtype: None,
};
let e2 = Entry::new(different_op, vec![], vec![], sample_clock(), "inst-a");
assert_ne!(e1.hash, e2.hash);
}
#[test]
fn entry_hash_changes_with_different_author() {
let e1 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
let e2 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-b");
assert_ne!(e1.hash, e2.hash);
}
#[test]
fn entry_hash_changes_with_different_clock() {
let e1 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
let mut clock2 = sample_clock();
clock2.physical_ms = 99;
let e2 = Entry::new(sample_op(), vec![], vec![], clock2, "inst-a");
assert_ne!(e1.hash, e2.hash);
}
#[test]
fn entry_hash_changes_with_different_next() {
let e1 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
let e2 = Entry::new(
sample_op(),
vec![[0u8; 32]],
vec![],
sample_clock(),
"inst-a",
);
assert_ne!(e1.hash, e2.hash);
}
#[test]
fn entry_verify_hash_valid() {
let entry = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
assert!(entry.verify_hash());
}
#[test]
fn entry_verify_hash_reject_tampered() {
let mut entry = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
entry.author = "evil-node".into();
assert!(!entry.verify_hash());
}
#[test]
fn entry_roundtrip_msgpack() {
let entry = Entry::new(
sample_op(),
vec![[1u8; 32]],
vec![[2u8; 32]],
sample_clock(),
"inst-a",
);
let bytes = entry.to_bytes();
let decoded = Entry::from_bytes(&bytes).unwrap();
assert_eq!(entry, decoded);
}
#[test]
fn entry_next_links_causal() {
let e1 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
let e2 = Entry::new(
GraphOp::RemoveNode {
node_id: "server-1".into(),
},
vec![e1.hash],
vec![],
LamportClock::with_values("inst-a", 2, 0),
"inst-a",
);
assert_eq!(e2.next, vec![e1.hash]);
assert!(e2.verify_hash());
}
#[test]
fn graphop_all_variants_serialize() {
let ops = vec![
GraphOp::DefineOntology {
ontology: sample_ontology(),
},
sample_op(),
GraphOp::AddEdge {
edge_id: "e1".into(),
edge_type: "RUNS_ON".into(),
source_id: "svc-1".into(),
target_id: "server-1".into(),
properties: BTreeMap::new(),
},
GraphOp::UpdateProperty {
entity_id: "server-1".into(),
key: "cpu".into(),
value: Value::Float(85.5),
},
GraphOp::RemoveNode {
node_id: "server-1".into(),
},
GraphOp::RemoveEdge {
edge_id: "e1".into(),
},
GraphOp::ExtendOntology {
extension: crate::ontology::OntologyExtension {
node_types: BTreeMap::from([(
"metric".into(),
NodeTypeDef {
description: Some("A metric observation".into()),
properties: BTreeMap::new(),
subtypes: None,
parent_type: None,
},
)]),
edge_types: BTreeMap::new(),
node_type_updates: BTreeMap::new(),
},
},
GraphOp::Checkpoint {
ops: vec![
GraphOp::DefineOntology {
ontology: sample_ontology(),
},
GraphOp::AddNode {
node_id: "n1".into(),
node_type: "entity".into(),
subtype: None,
label: "Node 1".into(),
properties: BTreeMap::new(),
},
],
op_clocks: vec![(1, 0), (2, 0)],
compacted_at_physical_ms: 1000,
compacted_at_logical: 5,
},
];
for op in ops {
let entry = Entry::new(op, vec![], vec![], sample_clock(), "inst-a");
let bytes = entry.to_bytes();
let decoded = Entry::from_bytes(&bytes).unwrap();
assert_eq!(entry, decoded);
}
}
#[test]
fn genesis_entry_contains_ontology() {
let ont = sample_ontology();
let genesis = Entry::new(
GraphOp::DefineOntology {
ontology: ont.clone(),
},
vec![],
vec![],
LamportClock::new("inst-a"),
"inst-a",
);
match &genesis.payload {
GraphOp::DefineOntology { ontology } => assert_eq!(ontology, &ont),
_ => panic!("genesis should be DefineOntology"),
}
assert!(genesis.next.is_empty(), "genesis has no predecessors");
assert!(genesis.verify_hash());
}
#[test]
fn value_all_variants_roundtrip() {
let values = vec![
Value::Null,
Value::Bool(true),
Value::Int(42),
Value::Float(3.14),
Value::String("hello".into()),
Value::List(vec![Value::Int(1), Value::String("two".into())]),
Value::Map(BTreeMap::from([("key".into(), Value::Bool(false))])),
];
for val in values {
let bytes = rmp_serde::to_vec(&val).unwrap();
let decoded: Value = rmp_serde::from_slice(&bytes).unwrap();
assert_eq!(val, decoded);
}
}
#[test]
fn hash_hex_format() {
let entry = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
let hex = entry.hash_hex();
assert_eq!(hex.len(), 64);
assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn unsigned_entry_has_no_signature() {
let entry = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
assert!(!entry.is_signed());
assert!(entry.signature.is_none());
}
#[test]
fn unsigned_entry_roundtrip_preserves_none_signature() {
let entry = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
let bytes = entry.to_bytes();
let decoded = Entry::from_bytes(&bytes).unwrap();
assert_eq!(decoded.signature, None);
assert!(decoded.verify_hash());
}
#[cfg(feature = "signing")]
mod signing_tests {
use super::*;
fn test_keypair() -> ed25519_dalek::SigningKey {
use rand::rngs::OsRng;
ed25519_dalek::SigningKey::generate(&mut OsRng)
}
#[test]
fn signed_entry_roundtrip() {
let key = test_keypair();
let entry =
Entry::new_signed(sample_op(), vec![], vec![], sample_clock(), "inst-a", &key);
assert!(entry.is_signed());
assert!(entry.verify_hash());
let public = key.verifying_key();
assert!(entry.verify_signature(&public));
}
#[test]
fn signed_entry_serialization_roundtrip() {
let key = test_keypair();
let entry =
Entry::new_signed(sample_op(), vec![], vec![], sample_clock(), "inst-a", &key);
let bytes = entry.to_bytes();
let decoded = Entry::from_bytes(&bytes).unwrap();
assert!(decoded.is_signed());
assert!(decoded.verify_hash());
assert!(decoded.verify_signature(&key.verifying_key()));
}
#[test]
fn wrong_key_fails_verification() {
let key1 = test_keypair();
let key2 = test_keypair();
let entry =
Entry::new_signed(sample_op(), vec![], vec![], sample_clock(), "inst-a", &key1);
assert!(entry.verify_signature(&key1.verifying_key()));
assert!(!entry.verify_signature(&key2.verifying_key()));
}
#[test]
fn tampered_hash_fails_both_checks() {
let key = test_keypair();
let mut entry =
Entry::new_signed(sample_op(), vec![], vec![], sample_clock(), "inst-a", &key);
entry.hash[0] ^= 0xFF;
assert!(!entry.verify_hash());
assert!(!entry.verify_signature(&key.verifying_key()));
}
#[test]
fn unsigned_entry_passes_signature_check() {
let key = test_keypair();
let entry = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
assert!(!entry.is_signed());
assert!(entry.verify_signature(&key.verifying_key())); }
}
#[test]
fn value_int_json_roundtrip_preserves_type() {
let val = Value::Int(1);
let json = serde_json::to_string(&val).unwrap();
let back: Value = serde_json::from_str(&json).unwrap();
assert_eq!(
back,
Value::Int(1),
"Int(1) -> JSON -> back should stay Int, got {:?}",
back
);
}
#[test]
fn value_float_json_roundtrip_preserves_type() {
let val = Value::Float(1.0);
let json = serde_json::to_string(&val).unwrap();
let back: Value = serde_json::from_str(&json).unwrap();
assert_eq!(
back,
Value::Float(1.0),
"Float(1.0) -> JSON -> back should stay Float, got {:?}",
back
);
}
#[test]
fn value_float_json_includes_decimal() {
let json = serde_json::to_string(&Value::Float(1.0)).unwrap();
assert!(
json.contains('.'),
"Float(1.0) must serialize with decimal point, got: {}",
json
);
}
#[test]
fn graphop_with_mixed_values_json_roundtrip() {
let mut props = BTreeMap::new();
props.insert("count".into(), Value::Int(42));
props.insert("ratio".into(), Value::Float(1.0));
props.insert("name".into(), Value::String("test".into()));
let op = GraphOp::UpdateProperty {
entity_id: "e1".into(),
key: "data".into(),
value: Value::Map(props),
};
let json = serde_json::to_string(&op).unwrap();
let back: GraphOp = serde_json::from_str(&json).unwrap();
let entry1 = Entry::new(op, vec![], vec![], sample_clock(), "a");
let entry2 = Entry::new(back, vec![], vec![], sample_clock(), "a");
assert_eq!(
entry1.hash, entry2.hash,
"JSON round-trip changed the hash!"
);
}
#[test]
fn entry_ontology_hash_defaults_to_none() {
let entry = Entry::new(
GraphOp::AddNode {
node_id: "n1".into(),
node_type: "entity".into(),
subtype: None,
label: "n1".into(),
properties: BTreeMap::new(),
},
vec![],
vec![],
sample_clock(),
"author",
);
assert!(entry.ontology_hash.is_none());
}
#[test]
fn entry_ontology_hash_survives_roundtrip() {
let mut entry = Entry::new(
GraphOp::AddNode {
node_id: "n1".into(),
node_type: "entity".into(),
subtype: None,
label: "n1".into(),
properties: BTreeMap::new(),
},
vec![],
vec![],
sample_clock(),
"author",
);
entry.ontology_hash = Some([42u8; 32]);
let bytes = entry.to_bytes();
let restored = Entry::from_bytes(&bytes).unwrap();
assert_eq!(restored.ontology_hash, Some([42u8; 32]));
}
#[test]
fn entry_ontology_hash_not_in_content_hash() {
let mut a = Entry::new(
GraphOp::AddNode {
node_id: "n1".into(),
node_type: "entity".into(),
subtype: None,
label: "n1".into(),
properties: BTreeMap::new(),
},
vec![],
vec![],
sample_clock(),
"author",
);
let b = a.clone();
a.ontology_hash = Some([99u8; 32]);
assert_eq!(a.hash, b.hash);
}
#[test]
fn old_entry_without_ontology_hash_deserializes() {
let entry = Entry::new(
GraphOp::AddNode {
node_id: "n1".into(),
node_type: "entity".into(),
subtype: None,
label: "n1".into(),
properties: BTreeMap::new(),
},
vec![],
vec![],
sample_clock(),
"author",
);
let bytes = entry.to_bytes();
let restored = Entry::from_bytes(&bytes).unwrap();
assert!(restored.ontology_hash.is_none());
assert!(restored.verify_hash());
}
#[test]
fn define_lens_roundtrips() {
let op = GraphOp::DefineLens {
transforms: vec![1, 2, 3, 4],
};
let entry = Entry::new(op.clone(), vec![], vec![], sample_clock(), "author");
let bytes = entry.to_bytes();
let restored = Entry::from_bytes(&bytes).unwrap();
assert_eq!(restored.payload, op);
assert!(restored.verify_hash());
}
}