Initialize repository snapshot
This commit is contained in:
339
vaultmesh-guardian/src/lib.rs
Normal file
339
vaultmesh-guardian/src/lib.rs
Normal file
@@ -0,0 +1,339 @@
|
||||
//! VaultMesh Guardian Engine - Merkle root anchoring for the Civilization Ledger
|
||||
//!
|
||||
//! The Guardian engine computes Merkle roots over scroll JSONL files and
|
||||
//! emits anchor receipts that cryptographically bind all receipts at a point in time.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
#[cfg(feature = "metrics")]
|
||||
use std::sync::Arc;
|
||||
#[cfg(feature = "metrics")]
|
||||
use std::time::Instant;
|
||||
|
||||
use vaultmesh_core::{merkle_root, Scroll, VmHash};
|
||||
|
||||
#[cfg(feature = "metrics")]
|
||||
use vaultmesh_observability::ObservabilityEngine;
|
||||
|
||||
/// Schema version for guardian receipts
|
||||
pub const SCHEMA_VERSION: &str = "2.0.0";
|
||||
|
||||
/// Guardian anchor receipt body
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AnchorReceipt {
|
||||
pub schema_version: String,
|
||||
#[serde(rename = "type")]
|
||||
pub receipt_type: String,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub anchor_id: String,
|
||||
pub backend: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub anchor_by: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub anchor_epoch: Option<u64>,
|
||||
pub roots: HashMap<String, String>,
|
||||
pub scrolls: Vec<String>,
|
||||
pub anchor_hash: String,
|
||||
}
|
||||
|
||||
/// Result of computing a scroll's Merkle root
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScrollRoot {
|
||||
pub scroll: Scroll,
|
||||
pub root: VmHash,
|
||||
pub leaf_count: usize,
|
||||
}
|
||||
|
||||
/// Guardian engine for anchoring scroll Merkle roots
|
||||
pub struct GuardianEngine {
|
||||
/// Path to the VaultMesh root directory
|
||||
pub vaultmesh_root: PathBuf,
|
||||
/// DID of the guardian performing anchors
|
||||
pub guardian_did: String,
|
||||
/// Backend identifier (e.g., "local", "ethereum", "stellar")
|
||||
pub backend: String,
|
||||
/// Optional observability engine for metrics
|
||||
#[cfg(feature = "metrics")]
|
||||
pub observability: Option<Arc<ObservabilityEngine>>,
|
||||
}
|
||||
|
||||
impl GuardianEngine {
|
||||
/// Create a new Guardian engine
|
||||
pub fn new(vaultmesh_root: impl AsRef<Path>, guardian_did: &str) -> Self {
|
||||
Self {
|
||||
vaultmesh_root: vaultmesh_root.as_ref().to_path_buf(),
|
||||
guardian_did: guardian_did.to_string(),
|
||||
backend: "local".to_string(),
|
||||
#[cfg(feature = "metrics")]
|
||||
observability: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the backend for this engine
|
||||
pub fn with_backend(mut self, backend: &str) -> Self {
|
||||
self.backend = backend.to_string();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the observability engine for metrics
|
||||
#[cfg(feature = "metrics")]
|
||||
pub fn with_observability(mut self, obs: Arc<ObservabilityEngine>) -> Self {
|
||||
self.observability = Some(obs);
|
||||
self
|
||||
}
|
||||
|
||||
/// Get the path to a scroll's JSONL file
|
||||
fn scroll_path(&self, scroll: &Scroll) -> PathBuf {
|
||||
self.vaultmesh_root.join(scroll.jsonl_path())
|
||||
}
|
||||
|
||||
/// Compute the Merkle root for a single scroll
|
||||
pub fn compute_scroll_root(&self, scroll: &Scroll) -> std::io::Result<ScrollRoot> {
|
||||
let path = self.scroll_path(scroll);
|
||||
|
||||
if !path.exists() {
|
||||
return Ok(ScrollRoot {
|
||||
scroll: scroll.clone(),
|
||||
root: VmHash::blake3(b"empty"),
|
||||
leaf_count: 0,
|
||||
});
|
||||
}
|
||||
|
||||
let file = fs::File::open(&path)?;
|
||||
let reader = BufReader::new(file);
|
||||
|
||||
let mut hashes = Vec::new();
|
||||
for line in reader.lines() {
|
||||
let line = line?;
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
hashes.push(VmHash::blake3(line.as_bytes()));
|
||||
}
|
||||
|
||||
let leaf_count = hashes.len();
|
||||
let root = merkle_root(&hashes);
|
||||
|
||||
Ok(ScrollRoot {
|
||||
scroll: scroll.clone(),
|
||||
root,
|
||||
leaf_count,
|
||||
})
|
||||
}
|
||||
|
||||
/// Compute roots for all provided scrolls
|
||||
pub fn compute_all_roots(&self, scrolls: &[Scroll]) -> std::io::Result<Vec<ScrollRoot>> {
|
||||
scrolls.iter().map(|s| self.compute_scroll_root(s)).collect()
|
||||
}
|
||||
|
||||
/// Generate an anchor ID based on current timestamp
|
||||
fn generate_anchor_id() -> String {
|
||||
let now = Utc::now();
|
||||
format!("anchor-{}", now.format("%Y%m%d%H%M%S"))
|
||||
}
|
||||
|
||||
/// Anchor all provided scrolls and emit a guardian receipt
|
||||
pub fn anchor(&self, scrolls: &[Scroll]) -> std::io::Result<AnchorReceipt> {
|
||||
let roots = self.compute_all_roots(scrolls)?;
|
||||
|
||||
// Build roots map
|
||||
let mut roots_map = HashMap::new();
|
||||
for sr in &roots {
|
||||
let scroll_name = match sr.scroll {
|
||||
Scroll::Drills => "drills",
|
||||
Scroll::Compliance => "compliance",
|
||||
Scroll::Guardian => "guardian",
|
||||
Scroll::Treasury => "treasury",
|
||||
Scroll::Mesh => "mesh",
|
||||
Scroll::OffSec => "offsec",
|
||||
Scroll::Identity => "identity",
|
||||
Scroll::Observability => "observability",
|
||||
Scroll::Automation => "automation",
|
||||
Scroll::PsiField => "psi",
|
||||
};
|
||||
roots_map.insert(scroll_name.to_string(), sr.root.as_str().to_string());
|
||||
}
|
||||
|
||||
// Compute anchor hash over all roots
|
||||
let roots_json = serde_json::to_vec(&roots_map)?;
|
||||
let anchor_hash = VmHash::blake3(&roots_json);
|
||||
|
||||
let now = Utc::now();
|
||||
let anchor_epoch = now.timestamp() as u64;
|
||||
|
||||
let receipt = AnchorReceipt {
|
||||
schema_version: SCHEMA_VERSION.to_string(),
|
||||
receipt_type: "guardian_anchor".to_string(),
|
||||
timestamp: now,
|
||||
anchor_id: Self::generate_anchor_id(),
|
||||
backend: self.backend.clone(),
|
||||
anchor_by: Some(self.guardian_did.clone()),
|
||||
anchor_epoch: Some(anchor_epoch),
|
||||
roots: roots_map,
|
||||
scrolls: scrolls
|
||||
.iter()
|
||||
.map(|s| match s {
|
||||
Scroll::Drills => "drills",
|
||||
Scroll::Compliance => "compliance",
|
||||
Scroll::Guardian => "guardian",
|
||||
Scroll::Treasury => "treasury",
|
||||
Scroll::Mesh => "mesh",
|
||||
Scroll::OffSec => "offsec",
|
||||
Scroll::Identity => "identity",
|
||||
Scroll::Observability => "observability",
|
||||
Scroll::Automation => "automation",
|
||||
Scroll::PsiField => "psi",
|
||||
})
|
||||
.map(String::from)
|
||||
.collect(),
|
||||
anchor_hash: anchor_hash.as_str().to_string(),
|
||||
};
|
||||
|
||||
// Emit to guardian scroll
|
||||
self.emit_receipt(&receipt)?;
|
||||
|
||||
Ok(receipt)
|
||||
}
|
||||
|
||||
/// Write receipt to the guardian JSONL file
|
||||
fn emit_receipt(&self, receipt: &AnchorReceipt) -> std::io::Result<()> {
|
||||
#[cfg(feature = "metrics")]
|
||||
let start = Instant::now();
|
||||
|
||||
let path = self.scroll_path(&Scroll::Guardian);
|
||||
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)?;
|
||||
|
||||
let json = serde_json::to_string(receipt)?;
|
||||
writeln!(file, "{}", json)?;
|
||||
|
||||
// Update ROOT file
|
||||
self.update_root_file(&Scroll::Guardian)?;
|
||||
|
||||
// Record metrics if observability is enabled
|
||||
#[cfg(feature = "metrics")]
|
||||
if let Some(ref obs) = self.observability {
|
||||
let elapsed = start.elapsed().as_secs_f64();
|
||||
obs.observe_emitted("guardian", elapsed);
|
||||
// Set anchor age to 0 to indicate fresh anchor just occurred
|
||||
obs.set_anchor_age(0.0);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update the ROOT.*.txt file for a scroll
|
||||
fn update_root_file(&self, scroll: &Scroll) -> std::io::Result<()> {
|
||||
let root_result = self.compute_scroll_root(scroll)?;
|
||||
let root_path = self.vaultmesh_root.join(scroll.root_file());
|
||||
|
||||
fs::write(&root_path, root_result.root.as_str())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup_test_env() -> (TempDir, GuardianEngine) {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let receipts_dir = tmp.path().join("receipts/guardian");
|
||||
fs::create_dir_all(&receipts_dir).unwrap();
|
||||
|
||||
let engine = GuardianEngine::new(tmp.path(), "did:vm:guardian:test");
|
||||
(tmp, engine)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_scroll_root_empty() {
|
||||
let (_tmp, engine) = setup_test_env();
|
||||
let result = engine.compute_scroll_root(&Scroll::Drills).unwrap();
|
||||
|
||||
assert_eq!(result.leaf_count, 0);
|
||||
assert_eq!(result.root, VmHash::blake3(b"empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_scroll_root_single_line() {
|
||||
let (tmp, engine) = setup_test_env();
|
||||
|
||||
// Create a test JSONL file
|
||||
let path = tmp.path().join("receipts/drills/drill_runs.jsonl");
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(&path, "{\"test\": \"data\"}\n").unwrap();
|
||||
|
||||
let result = engine.compute_scroll_root(&Scroll::Drills).unwrap();
|
||||
|
||||
assert_eq!(result.leaf_count, 1);
|
||||
// Single leaf = its own hash
|
||||
let expected = VmHash::blake3(b"{\"test\": \"data\"}");
|
||||
assert_eq!(result.root, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_scroll_root_multiple_lines() {
|
||||
let (tmp, engine) = setup_test_env();
|
||||
|
||||
let path = tmp.path().join("receipts/drills/drill_runs.jsonl");
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(&path, "{\"line\": 1}\n{\"line\": 2}\n{\"line\": 3}\n").unwrap();
|
||||
|
||||
let result = engine.compute_scroll_root(&Scroll::Drills).unwrap();
|
||||
|
||||
assert_eq!(result.leaf_count, 3);
|
||||
// Root should be deterministic
|
||||
let result2 = engine.compute_scroll_root(&Scroll::Drills).unwrap();
|
||||
assert_eq!(result.root, result2.root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anchor_creates_receipt() {
|
||||
let (tmp, engine) = setup_test_env();
|
||||
|
||||
let receipt = engine.anchor(&[Scroll::Guardian]).unwrap();
|
||||
|
||||
assert_eq!(receipt.receipt_type, "guardian_anchor");
|
||||
assert_eq!(receipt.backend, "local");
|
||||
assert!(receipt.anchor_by.is_some());
|
||||
assert!(receipt.anchor_id.starts_with("anchor-"));
|
||||
|
||||
// Verify receipt was written to JSONL
|
||||
let path = tmp.path().join("receipts/guardian/anchor_events.jsonl");
|
||||
assert!(path.exists());
|
||||
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
assert!(content.contains("guardian_anchor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anchor_hash_deterministic() {
|
||||
let (tmp, engine) = setup_test_env();
|
||||
|
||||
// Create some test data
|
||||
let drills_path = tmp.path().join("receipts/drills/drill_runs.jsonl");
|
||||
fs::create_dir_all(drills_path.parent().unwrap()).unwrap();
|
||||
fs::write(&drills_path, "{\"drill\": 1}\n").unwrap();
|
||||
|
||||
// Compute roots twice
|
||||
let roots1 = engine.compute_all_roots(&[Scroll::Drills]).unwrap();
|
||||
let roots2 = engine.compute_all_roots(&[Scroll::Drills]).unwrap();
|
||||
|
||||
// Same input = same roots
|
||||
assert_eq!(roots1[0].root, roots2[0].root);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user