diff --git a/crates/rvf/rvf-adapters/claude-flow/Cargo.toml b/crates/rvf/rvf-adapters/claude-flow/Cargo.toml new file mode 100644 index 00000000..494c6fe6 --- /dev/null +++ b/crates/rvf/rvf-adapters/claude-flow/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "rvf-adapter-claude-flow" +version = "0.1.0" +edition = "2021" +description = "RVF adapter for claude-flow memory subsystem — stores memory entries as RVF files with WITNESS_SEG audit trails" +license = "MIT OR Apache-2.0" +repository = "https://github.com/ruvnet/ruvector" + +[features] +default = ["std"] +std = [] + +[dependencies] +rvf-types = { path = "../../rvf-types", features = ["std"] } +rvf-runtime = { path = "../../rvf-runtime", features = ["std"] } +rvf-crypto = { path = "../../rvf-crypto", features = ["std"] } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/rvf/rvf-adapters/claude-flow/src/config.rs b/crates/rvf/rvf-adapters/claude-flow/src/config.rs new file mode 100644 index 00000000..dad372d0 --- /dev/null +++ b/crates/rvf/rvf-adapters/claude-flow/src/config.rs @@ -0,0 +1,124 @@ +//! Configuration for the claude-flow memory adapter. + +use std::path::PathBuf; + +use rvf_runtime::options::DistanceMetric; + +/// Configuration for the RVF-backed claude-flow memory store. +#[derive(Clone, Debug)] +pub struct ClaudeFlowConfig { + /// Directory where RVF data files are stored. + pub data_dir: PathBuf, + /// Vector embedding dimension (must match the embeddings used by claude-flow). + pub dimension: u16, + /// Distance metric for similarity search. + pub metric: DistanceMetric, + /// Whether to record witness entries for audit trails. + pub enable_witness: bool, +} + +impl ClaudeFlowConfig { + /// Create a new configuration with required parameters. + pub fn new(data_dir: impl Into, dimension: u16) -> Self { + Self { + data_dir: data_dir.into(), + dimension, + metric: DistanceMetric::Cosine, + enable_witness: true, + } + } + + /// Set the distance metric. + pub fn with_metric(mut self, metric: DistanceMetric) -> Self { + self.metric = metric; + self + } + + /// Enable or disable witness audit trails. + pub fn with_witness(mut self, enable: bool) -> Self { + self.enable_witness = enable; + self + } + + /// Return the path to the main vector store RVF file. + pub fn store_path(&self) -> PathBuf { + self.data_dir.join("memory.rvf") + } + + /// Return the path to the witness chain file. + pub fn witness_path(&self) -> PathBuf { + self.data_dir.join("witness.bin") + } + + /// Ensure the data directory exists. + pub fn ensure_dirs(&self) -> std::io::Result<()> { + std::fs::create_dir_all(&self.data_dir) + } + + /// Validate the configuration. + pub fn validate(&self) -> Result<(), ConfigError> { + if self.dimension == 0 { + return Err(ConfigError::InvalidDimension); + } + Ok(()) + } +} + +/// Errors specific to adapter configuration. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ConfigError { + /// Dimension must be > 0. + InvalidDimension, +} + +impl std::fmt::Display for ConfigError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidDimension => write!(f, "vector dimension must be > 0"), + } + } +} + +impl std::error::Error for ConfigError {} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + #[test] + fn config_defaults() { + let cfg = ClaudeFlowConfig::new("/tmp/test", 384); + assert_eq!(cfg.dimension, 384); + assert_eq!(cfg.metric, DistanceMetric::Cosine); + assert!(cfg.enable_witness); + } + + #[test] + fn config_paths() { + let cfg = ClaudeFlowConfig::new("/data/memory", 128); + assert_eq!(cfg.store_path(), Path::new("/data/memory/memory.rvf")); + assert_eq!(cfg.witness_path(), Path::new("/data/memory/witness.bin")); + } + + #[test] + fn validate_zero_dimension() { + let cfg = ClaudeFlowConfig::new("/tmp", 0); + assert_eq!(cfg.validate(), Err(ConfigError::InvalidDimension)); + } + + #[test] + fn validate_ok() { + let cfg = ClaudeFlowConfig::new("/tmp", 64); + assert!(cfg.validate().is_ok()); + } + + #[test] + fn builder_methods() { + let cfg = ClaudeFlowConfig::new("/tmp", 256) + .with_metric(DistanceMetric::L2) + .with_witness(false); + assert_eq!(cfg.metric, DistanceMetric::L2); + assert!(!cfg.enable_witness); + } +} diff --git a/crates/rvf/rvf-adapters/claude-flow/src/lib.rs b/crates/rvf/rvf-adapters/claude-flow/src/lib.rs new file mode 100644 index 00000000..b286e537 --- /dev/null +++ b/crates/rvf/rvf-adapters/claude-flow/src/lib.rs @@ -0,0 +1,48 @@ +//! RVF adapter for the claude-flow memory subsystem. +//! +//! This crate bridges claude-flow's key/value/embedding memory model +//! with the RuVector Format (RVF) segment store. Memory entries are +//! persisted as RVF files with the RVText profile, and every mutation +//! is recorded in a WITNESS_SEG audit trail for tamper-evident logging. +//! +//! # Architecture +//! +//! - **`RvfMemoryStore`**: Main API wrapping `RvfStore` for +//! store/search/retrieve/delete operations on memory entries. +//! - **`WitnessChain`**: Persistent, append-only audit log using +//! `rvf_crypto::witness` chains (SHAKE-256 linked). +//! - **`ClaudeFlowConfig`**: Configuration for data directory, embedding +//! dimension, distance metric, and witness toggle. +//! +//! # Usage +//! +//! ```rust,no_run +//! use rvf_adapter_claude_flow::{ClaudeFlowConfig, RvfMemoryStore}; +//! +//! let config = ClaudeFlowConfig::new("/tmp/claude-flow-memory", 384); +//! let mut store = RvfMemoryStore::create(config).unwrap(); +//! +//! // Store a memory entry with its embedding +//! let embedding = vec![0.1f32; 384]; +//! store.store_memory("auth-pattern", "JWT with refresh tokens", +//! "patterns", &["auth".into()], &embedding).unwrap(); +//! +//! // Search by embedding similarity +//! let results = store.search_memory(&embedding, 5, Some("patterns"), None).unwrap(); +//! +//! // Retrieve by key +//! let id = store.retrieve_memory("auth-pattern", "patterns"); +//! +//! // Delete +//! store.delete_memory("auth-pattern", "patterns").unwrap(); +//! +//! store.close().unwrap(); +//! ``` + +pub mod config; +pub mod memory_store; +pub mod witness; + +pub use config::ClaudeFlowConfig; +pub use memory_store::{MemoryEntry, MemoryStoreError, RvfMemoryStore}; +pub use witness::{WitnessChain, WitnessError}; diff --git a/crates/rvf/rvf-adapters/claude-flow/src/memory_store.rs b/crates/rvf/rvf-adapters/claude-flow/src/memory_store.rs new file mode 100644 index 00000000..52d8abfa --- /dev/null +++ b/crates/rvf/rvf-adapters/claude-flow/src/memory_store.rs @@ -0,0 +1,445 @@ +//! `RvfMemoryStore` — wraps `RvfStore` for claude-flow memory operations. +//! +//! Maps claude-flow's key/value/namespace/tags/embedding model onto the +//! RVF segment model: +//! - Embeddings are stored as vectors via `ingest_batch` +//! - Keys and namespaces are encoded as metadata (META_SEG fields) +//! - Searches use `query` with optional namespace filtering +//! - Deletes use soft-delete with witness recording + +use std::collections::HashMap; + +use rvf_runtime::filter::{FilterExpr, FilterValue}; +use rvf_runtime::options::{MetadataEntry, MetadataValue, QueryOptions, RvfOptions}; +use rvf_runtime::{RvfStore, SearchResult}; +use rvf_types::RvfError; + +use crate::config::ClaudeFlowConfig; +use crate::witness::WitnessChain; + +/// Metadata field IDs for claude-flow memory entries. +const FIELD_KEY: u16 = 0; +const FIELD_NAMESPACE: u16 = 1; +const FIELD_TAGS: u16 = 2; + +/// A memory entry returned from retrieval or search. +#[derive(Clone, Debug)] +pub struct MemoryEntry { + /// The memory key. + pub key: String, + /// The namespace this entry belongs to. + pub namespace: String, + /// Tags associated with this entry. + pub tags: Vec, + /// The vector ID in the underlying store. + pub vector_id: u64, + /// Distance from query (only meaningful for search results). + pub distance: f32, +} + +/// The RVF-backed memory store for claude-flow. +pub struct RvfMemoryStore { + store: RvfStore, + witness: Option, + config: ClaudeFlowConfig, + /// Maps "namespace/key" -> vector_id for fast lookup. + key_index: HashMap, + /// Next vector ID to assign. + next_id: u64, +} + +impl RvfMemoryStore { + /// Create a new memory store, initializing the data directory and RVF file. + pub fn create(config: ClaudeFlowConfig) -> Result { + config.validate().map_err(MemoryStoreError::Config)?; + config.ensure_dirs().map_err(|e| MemoryStoreError::Io(e.to_string()))?; + + let rvf_options = RvfOptions { + dimension: config.dimension, + metric: config.metric, + ..Default::default() + }; + + let store = RvfStore::create(&config.store_path(), rvf_options) + .map_err(MemoryStoreError::Rvf)?; + + let witness = if config.enable_witness { + Some(WitnessChain::create(&config.witness_path()) + .map_err(MemoryStoreError::Witness)?) + } else { + None + }; + + Ok(Self { + store, + witness, + config, + key_index: HashMap::new(), + next_id: 1, + }) + } + + /// Open an existing memory store. + pub fn open(config: ClaudeFlowConfig) -> Result { + config.validate().map_err(MemoryStoreError::Config)?; + + let store = RvfStore::open(&config.store_path()) + .map_err(MemoryStoreError::Rvf)?; + + let witness = if config.enable_witness { + Some(WitnessChain::open_or_create(&config.witness_path()) + .map_err(MemoryStoreError::Witness)?) + } else { + None + }; + + // Rebuild the key_index from the store status. + // Since RvfStore doesn't expose metadata iteration, we start fresh. + // Existing vectors remain searchable by embedding; key lookup is + // rebuilt as entries are re-stored. + let status = store.status(); + let next_id = status.total_vectors + status.current_epoch as u64 + 1; + + Ok(Self { + store, + witness, + config, + key_index: HashMap::new(), + next_id, + }) + } + + /// Store a memory entry with its embedding vector. + /// + /// If an entry with the same key and namespace already exists, the old + /// one is soft-deleted and replaced. + pub fn store_memory( + &mut self, + key: &str, + _value: &str, + namespace: &str, + tags: &[String], + embedding: &[f32], + ) -> Result { + if embedding.len() != self.config.dimension as usize { + return Err(MemoryStoreError::DimensionMismatch { + expected: self.config.dimension as usize, + got: embedding.len(), + }); + } + + // If key already exists in this namespace, soft-delete the old entry. + let compound_key = format!("{namespace}/{key}"); + if let Some(&old_id) = self.key_index.get(&compound_key) { + self.store.delete(&[old_id]).map_err(MemoryStoreError::Rvf)?; + } + + let vector_id = self.next_id; + self.next_id += 1; + + // Encode tags as a comma-separated string for metadata storage. + let tags_str = tags.join(","); + + let metadata = vec![ + MetadataEntry { field_id: FIELD_KEY, value: MetadataValue::String(key.to_string()) }, + MetadataEntry { field_id: FIELD_NAMESPACE, value: MetadataValue::String(namespace.to_string()) }, + MetadataEntry { field_id: FIELD_TAGS, value: MetadataValue::String(tags_str) }, + ]; + + self.store + .ingest_batch(&[embedding], &[vector_id], Some(&metadata)) + .map_err(MemoryStoreError::Rvf)?; + + self.key_index.insert(compound_key, vector_id); + + if let Some(ref mut w) = self.witness { + let _ = w.record_store(key, namespace); + } + + Ok(vector_id) + } + + /// Search memory by embedding vector, optionally filtering by namespace. + pub fn search_memory( + &mut self, + query_embedding: &[f32], + k: usize, + namespace: Option<&str>, + _threshold: Option, + ) -> Result, MemoryStoreError> { + if query_embedding.len() != self.config.dimension as usize { + return Err(MemoryStoreError::DimensionMismatch { + expected: self.config.dimension as usize, + got: query_embedding.len(), + }); + } + + let filter = namespace.map(|ns| { + FilterExpr::Eq(FIELD_NAMESPACE, FilterValue::String(ns.to_string())) + }); + + let options = QueryOptions { + filter, + ..Default::default() + }; + + let results = self.store.query(query_embedding, k, &options) + .map_err(MemoryStoreError::Rvf)?; + + if let Some(ref mut w) = self.witness { + let ns = namespace.unwrap_or("*"); + let _ = w.record_search(ns, k); + } + + Ok(results) + } + + /// Retrieve a memory entry by key and namespace. + /// + /// Returns the vector ID if found (the entry can then be used with + /// the underlying store for further operations). + pub fn retrieve_memory( + &self, + key: &str, + namespace: &str, + ) -> Option { + let compound_key = format!("{namespace}/{key}"); + self.key_index.get(&compound_key).copied() + } + + /// Soft-delete a memory entry by key and namespace. + pub fn delete_memory( + &mut self, + key: &str, + namespace: &str, + ) -> Result { + let compound_key = format!("{namespace}/{key}"); + if let Some(vector_id) = self.key_index.remove(&compound_key) { + self.store.delete(&[vector_id]).map_err(MemoryStoreError::Rvf)?; + + if let Some(ref mut w) = self.witness { + let _ = w.record_delete(key, namespace); + } + + Ok(true) + } else { + Ok(false) + } + } + + /// Run compaction on the underlying store. + pub fn compact(&mut self) -> Result<(), MemoryStoreError> { + self.store.compact().map_err(MemoryStoreError::Rvf)?; + + if let Some(ref mut w) = self.witness { + let _ = w.record_compact(); + } + + Ok(()) + } + + /// Get the current store status. + pub fn status(&self) -> rvf_runtime::StoreStatus { + self.store.status() + } + + /// Return a reference to the witness chain (if enabled). + pub fn witness(&self) -> Option<&WitnessChain> { + self.witness.as_ref() + } + + /// Close the memory store, releasing locks. + pub fn close(self) -> Result<(), MemoryStoreError> { + self.store.close().map_err(MemoryStoreError::Rvf) + } +} + +/// Errors from memory store operations. +#[derive(Debug)] +pub enum MemoryStoreError { + /// Underlying RVF store error. + Rvf(RvfError), + /// Witness chain error. + Witness(crate::witness::WitnessError), + /// Configuration error. + Config(crate::config::ConfigError), + /// I/O error. + Io(String), + /// Embedding dimension mismatch. + DimensionMismatch { expected: usize, got: usize }, +} + +impl std::fmt::Display for MemoryStoreError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Rvf(e) => write!(f, "RVF store error: {e}"), + Self::Witness(e) => write!(f, "witness error: {e}"), + Self::Config(e) => write!(f, "config error: {e}"), + Self::Io(msg) => write!(f, "I/O error: {msg}"), + Self::DimensionMismatch { expected, got } => { + write!(f, "dimension mismatch: expected {expected}, got {got}") + } + } + } +} + +impl std::error::Error for MemoryStoreError {} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + use tempfile::TempDir; + + fn test_config(dir: &Path) -> ClaudeFlowConfig { + ClaudeFlowConfig::new(dir, 4) + } + + fn make_embedding(seed: f32) -> Vec { + vec![seed, seed * 0.5, seed * 0.25, seed * 0.125] + } + + #[test] + fn create_and_store() { + let dir = TempDir::new().unwrap(); + let config = test_config(dir.path()); + + let mut store = RvfMemoryStore::create(config).unwrap(); + let id = store.store_memory( + "key1", "value1", "default", &["tag1".into(), "tag2".into()], + &make_embedding(1.0), + ).unwrap(); + assert!(id > 0); + + let status = store.status(); + assert_eq!(status.total_vectors, 1); + + store.close().unwrap(); + } + + #[test] + fn store_and_search() { + let dir = TempDir::new().unwrap(); + let config = test_config(dir.path()); + + let mut store = RvfMemoryStore::create(config).unwrap(); + + store.store_memory("a", "val_a", "ns1", &[], &[1.0, 0.0, 0.0, 0.0]).unwrap(); + store.store_memory("b", "val_b", "ns1", &[], &[0.0, 1.0, 0.0, 0.0]).unwrap(); + store.store_memory("c", "val_c", "ns2", &[], &[0.0, 0.0, 1.0, 0.0]).unwrap(); + + // Search all namespaces + let results = store.search_memory(&[1.0, 0.0, 0.0, 0.0], 3, None, None).unwrap(); + assert_eq!(results.len(), 3); + + // Search filtered by namespace + let results = store.search_memory(&[1.0, 0.0, 0.0, 0.0], 3, Some("ns1"), None).unwrap(); + assert_eq!(results.len(), 2); + + store.close().unwrap(); + } + + #[test] + fn retrieve_by_key() { + let dir = TempDir::new().unwrap(); + let config = test_config(dir.path()); + + let mut store = RvfMemoryStore::create(config).unwrap(); + let id = store.store_memory("mykey", "myval", "ns", &[], &make_embedding(2.0)).unwrap(); + + assert_eq!(store.retrieve_memory("mykey", "ns"), Some(id)); + assert_eq!(store.retrieve_memory("missing", "ns"), None); + assert_eq!(store.retrieve_memory("mykey", "other_ns"), None); + + store.close().unwrap(); + } + + #[test] + fn delete_memory() { + let dir = TempDir::new().unwrap(); + let config = test_config(dir.path()); + + let mut store = RvfMemoryStore::create(config).unwrap(); + store.store_memory("k", "v", "ns", &[], &make_embedding(3.0)).unwrap(); + + assert!(store.delete_memory("k", "ns").unwrap()); + assert!(!store.delete_memory("k", "ns").unwrap()); // already deleted + assert_eq!(store.retrieve_memory("k", "ns"), None); + + store.close().unwrap(); + } + + #[test] + fn replace_existing_key() { + let dir = TempDir::new().unwrap(); + let config = test_config(dir.path()); + + let mut store = RvfMemoryStore::create(config).unwrap(); + let id1 = store.store_memory("k", "v1", "ns", &[], &make_embedding(1.0)).unwrap(); + let id2 = store.store_memory("k", "v2", "ns", &[], &make_embedding(2.0)).unwrap(); + + // New ID should be different (old was soft-deleted) + assert_ne!(id1, id2); + assert_eq!(store.retrieve_memory("k", "ns"), Some(id2)); + + // Only one live vector + let status = store.status(); + assert_eq!(status.total_vectors, 1); + + store.close().unwrap(); + } + + #[test] + fn dimension_mismatch() { + let dir = TempDir::new().unwrap(); + let config = test_config(dir.path()); + + let mut store = RvfMemoryStore::create(config).unwrap(); + let result = store.store_memory("k", "v", "ns", &[], &[1.0, 2.0]); // dim=2 vs config dim=4 + assert!(result.is_err()); + } + + #[test] + fn witness_audit_trail() { + let dir = TempDir::new().unwrap(); + let config = test_config(dir.path()); + + let mut store = RvfMemoryStore::create(config).unwrap(); + store.store_memory("a", "v", "ns", &[], &make_embedding(1.0)).unwrap(); + store.search_memory(&make_embedding(1.0), 1, None, None).unwrap(); + store.delete_memory("a", "ns").unwrap(); + + let witness = store.witness().unwrap(); + assert_eq!(witness.len(), 3); // store + search + delete + assert_eq!(witness.verify().unwrap(), 3); + + store.close().unwrap(); + } + + #[test] + fn compact_works() { + let dir = TempDir::new().unwrap(); + let config = test_config(dir.path()); + + let mut store = RvfMemoryStore::create(config).unwrap(); + store.store_memory("a", "v", "ns", &[], &make_embedding(1.0)).unwrap(); + store.store_memory("b", "v", "ns", &[], &make_embedding(2.0)).unwrap(); + store.delete_memory("a", "ns").unwrap(); + store.compact().unwrap(); + + let status = store.status(); + assert_eq!(status.total_vectors, 1); + + store.close().unwrap(); + } + + #[test] + fn no_witness_when_disabled() { + let dir = TempDir::new().unwrap(); + let config = ClaudeFlowConfig::new(dir.path(), 4).with_witness(false); + + let store = RvfMemoryStore::create(config).unwrap(); + assert!(store.witness().is_none()); + store.close().unwrap(); + } +} diff --git a/crates/rvf/rvf-adapters/claude-flow/src/witness.rs b/crates/rvf/rvf-adapters/claude-flow/src/witness.rs new file mode 100644 index 00000000..cccf8bc4 --- /dev/null +++ b/crates/rvf/rvf-adapters/claude-flow/src/witness.rs @@ -0,0 +1,292 @@ +//! Audit trail using WITNESS_SEG for claude-flow memory operations. +//! +//! Wraps `rvf_crypto::witness` to provide a persistent, append-only +//! witness chain that records every memory store/delete/search action. + +use std::fs::{File, OpenOptions}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; + +use rvf_crypto::witness::{WitnessEntry, create_witness_chain, verify_witness_chain}; +use rvf_crypto::shake256_256; + +/// Witness type constants for claude-flow actions. +pub const WITNESS_STORE: u8 = 0x01; +pub const WITNESS_DELETE: u8 = 0x02; +pub const WITNESS_SEARCH: u8 = 0x03; +pub const WITNESS_COMPACT: u8 = 0x04; + +/// Persistent witness chain that records memory operations. +pub struct WitnessChain { + path: PathBuf, + /// Cached chain bytes (in-memory mirror of the file). + chain_data: Vec, + /// Number of entries in the chain. + entry_count: usize, +} + +impl WitnessChain { + /// Create a new (empty) witness chain file at the given path. + pub fn create(path: &Path) -> Result { + File::create(path).map_err(|e| WitnessError::Io(e.to_string()))?; + Ok(Self { + path: path.to_path_buf(), + chain_data: Vec::new(), + entry_count: 0, + }) + } + + /// Open an existing witness chain file, verifying its integrity. + pub fn open(path: &Path) -> Result { + let mut file = File::open(path).map_err(|e| WitnessError::Io(e.to_string()))?; + let mut data = Vec::new(); + file.read_to_end(&mut data).map_err(|e| WitnessError::Io(e.to_string()))?; + + if data.is_empty() { + return Ok(Self { + path: path.to_path_buf(), + chain_data: Vec::new(), + entry_count: 0, + }); + } + + let entries = verify_witness_chain(&data) + .map_err(|_| WitnessError::ChainCorrupted)?; + + Ok(Self { + path: path.to_path_buf(), + chain_data: data, + entry_count: entries.len(), + }) + } + + /// Open an existing chain or create a new one. + pub fn open_or_create(path: &Path) -> Result { + if path.exists() { + Self::open(path) + } else { + Self::create(path) + } + } + + /// Record a memory store action. + pub fn record_store(&mut self, key: &str, namespace: &str) -> Result<(), WitnessError> { + let mut hasher_input = Vec::new(); + hasher_input.extend_from_slice(b"store:"); + hasher_input.extend_from_slice(namespace.as_bytes()); + hasher_input.push(b'/'); + hasher_input.extend_from_slice(key.as_bytes()); + self.append_entry(&hasher_input, WITNESS_STORE) + } + + /// Record a memory delete action. + pub fn record_delete(&mut self, key: &str, namespace: &str) -> Result<(), WitnessError> { + let mut hasher_input = Vec::new(); + hasher_input.extend_from_slice(b"delete:"); + hasher_input.extend_from_slice(namespace.as_bytes()); + hasher_input.push(b'/'); + hasher_input.extend_from_slice(key.as_bytes()); + self.append_entry(&hasher_input, WITNESS_DELETE) + } + + /// Record a search action. + pub fn record_search(&mut self, namespace: &str, k: usize) -> Result<(), WitnessError> { + let mut hasher_input = Vec::new(); + hasher_input.extend_from_slice(b"search:"); + hasher_input.extend_from_slice(namespace.as_bytes()); + hasher_input.push(b':'); + hasher_input.extend_from_slice(k.to_string().as_bytes()); + self.append_entry(&hasher_input, WITNESS_SEARCH) + } + + /// Record a compaction action. + pub fn record_compact(&mut self) -> Result<(), WitnessError> { + self.append_entry(b"compact", WITNESS_COMPACT) + } + + /// Verify the entire chain is intact. + pub fn verify(&self) -> Result { + if self.chain_data.is_empty() { + return Ok(0); + } + let entries = verify_witness_chain(&self.chain_data) + .map_err(|_| WitnessError::ChainCorrupted)?; + Ok(entries.len()) + } + + /// Return the number of entries in the chain. + pub fn len(&self) -> usize { + self.entry_count + } + + /// Return whether the chain is empty. + pub fn is_empty(&self) -> bool { + self.entry_count == 0 + } + + // ── Internal ────────────────────────────────────────────────────── + + fn append_entry(&mut self, action_data: &[u8], witness_type: u8) -> Result<(), WitnessError> { + let action_hash = shake256_256(action_data); + let timestamp_ns = now_ns(); + + let entry = WitnessEntry { + prev_hash: [0u8; 32], // create_witness_chain will set this + action_hash, + timestamp_ns, + witness_type, + }; + + // Rebuild the entire chain with the new entry appended. + // This is correct because create_witness_chain re-links prev_hash. + let mut all_entries = if self.chain_data.is_empty() { + Vec::new() + } else { + verify_witness_chain(&self.chain_data) + .map_err(|_| WitnessError::ChainCorrupted)? + }; + all_entries.push(entry); + + let new_chain = create_witness_chain(&all_entries); + + // Persist atomically: write to temp then rename. + let tmp_path = self.path.with_extension("bin.tmp"); + { + let mut f = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(&tmp_path) + .map_err(|e| WitnessError::Io(e.to_string()))?; + f.write_all(&new_chain).map_err(|e| WitnessError::Io(e.to_string()))?; + f.sync_all().map_err(|e| WitnessError::Io(e.to_string()))?; + } + std::fs::rename(&tmp_path, &self.path).map_err(|e| WitnessError::Io(e.to_string()))?; + + self.chain_data = new_chain; + self.entry_count = all_entries.len(); + Ok(()) + } +} + +/// Errors from witness chain operations. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum WitnessError { + /// I/O error (stringified for Clone/Eq compatibility). + Io(String), + /// Chain integrity verification failed. + ChainCorrupted, +} + +impl std::fmt::Display for WitnessError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Io(msg) => write!(f, "witness I/O error: {msg}"), + Self::ChainCorrupted => write!(f, "witness chain integrity check failed"), + } + } +} + +impl std::error::Error for WitnessError {} + +fn now_ns() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn create_and_open_empty() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("witness.bin"); + + let chain = WitnessChain::create(&path).unwrap(); + assert_eq!(chain.len(), 0); + assert!(chain.is_empty()); + + let reopened = WitnessChain::open(&path).unwrap(); + assert_eq!(reopened.len(), 0); + } + + #[test] + fn record_and_verify() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("witness.bin"); + + let mut chain = WitnessChain::create(&path).unwrap(); + chain.record_store("key1", "default").unwrap(); + chain.record_search("default", 5).unwrap(); + chain.record_delete("key1", "default").unwrap(); + assert_eq!(chain.len(), 3); + + let count = chain.verify().unwrap(); + assert_eq!(count, 3); + } + + #[test] + fn persistence_across_reopen() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("witness.bin"); + + { + let mut chain = WitnessChain::create(&path).unwrap(); + chain.record_store("a", "ns").unwrap(); + chain.record_store("b", "ns").unwrap(); + } + + let chain = WitnessChain::open(&path).unwrap(); + assert_eq!(chain.len(), 2); + assert_eq!(chain.verify().unwrap(), 2); + } + + #[test] + fn tampered_chain_detected() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("witness.bin"); + + { + let mut chain = WitnessChain::create(&path).unwrap(); + chain.record_store("x", "ns").unwrap(); + chain.record_store("y", "ns").unwrap(); + } + + // Tamper with the file + let mut data = std::fs::read(&path).unwrap(); + if data.len() > 40 { + data[40] ^= 0xFF; + } + std::fs::write(&path, &data).unwrap(); + + let result = WitnessChain::open(&path); + assert!(result.is_err() || result.unwrap().verify().is_err()); + } + + #[test] + fn open_or_create_new() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("witness.bin"); + + let chain = WitnessChain::open_or_create(&path).unwrap(); + assert!(chain.is_empty()); + } + + #[test] + fn open_or_create_existing() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("witness.bin"); + + { + let mut chain = WitnessChain::create(&path).unwrap(); + chain.record_compact().unwrap(); + } + + let chain = WitnessChain::open_or_create(&path).unwrap(); + assert_eq!(chain.len(), 1); + } +}