From 346ebccee7a0bd86c0818d508f96e02b13afb938 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Sun, 21 Dec 2025 23:50:40 +0100 Subject: [PATCH] feat: introduce global meta store and root store concept, and update CLI to manage active store --- Cargo.toml | 1 + docs/architecture.md | 10 +- docs/roadmap.md | 17 +- docs/storage.md | 39 ++++ lattice-cli/src/commands.rs | 265 ++++++++++++++++++----- lattice-cli/src/main.rs | 89 +++++--- lattice-cli/src/node.rs | 357 +++++++++++++++++++------------ lattice-core/Cargo.toml | 1 + lattice-core/src/data_dir.rs | 83 ++++--- lattice-core/src/lib.rs | 3 + lattice-core/src/meta_store.rs | 154 +++++++++++++ lattice-core/src/proto.rs | 1 + lattice-core/src/sigchain.rs | 102 +++++++-- lattice-core/src/signed_entry.rs | 9 + proto/lattice.proto | 3 + 15 files changed, 851 insertions(+), 283 deletions(-) create mode 100644 docs/storage.md create mode 100644 lattice-core/src/meta_store.rs diff --git a/Cargo.toml b/Cargo.toml index 645ced9..ccd8d2b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,7 @@ dirs = "5" blake3 = "1" hex = "0.4" redb = "2" +uuid = { version = "1", features = ["v4"] } # Testing tokio-test = "0.4" diff --git a/docs/architecture.md b/docs/architecture.md index cfb47f3..ca4dc9e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -187,14 +187,14 @@ Note: KV stores multiple heads per key to support DAG conflict resolution. Reads ``` Table Key Value Purpose ───────────────────────────────────────────────────────────────────────────── -stores UUID (store_id) StoreInfo Known stores this node participates in -meta String Vec Global metadata (node_id, etc.) +stores [u8; 16] (UUID) u64 (created_at_ms) Known stores +meta "root_store" [u8; 16] (UUID) Root store ID (opened on startup) ``` -StoreInfo: `{ type: "manifest" | "data", name, created_at, ... }` -- Manifest stores define mesh membership via KV entries (`/nodes/{pubkey}/...`) +- **Root Store**: The primary/manifest store for this node, auto-opened on CLI startup +- **Stores Table**: Tracks all stores this node participates in +- Manifest stores define mesh membership via `/nodes/{pubkey}/...` entries - Data stores hold application data -- Node's list of manifest store IDs = meshes it belongs to #### In-Memory Structures diff --git a/docs/roadmap.md b/docs/roadmap.md index 63f53fc..cbabdc2 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -21,16 +21,15 @@ - Can replay log to reconstruct KV state - All operations survive restart -### Multi-KV Refactoring (before M2) +### Multi-KV Refactoring (before M2) ✓ -Current code assumes single store. Changes needed: -- [ ] DataDir → support `stores/{uuid}/` subdirectories -- [ ] SigChain → scoped to (store_id, author_id) -- [ ] Store → per-store state.db, not global -- [ ] Log paths → `stores/{uuid}/logs/{author}.log` -- [ ] Add global meta.db for stores table -- [ ] Proto: SignedEntry/messages need store_id (UUID) -- [ ] CLI → `create-store`, `list-stores`, `use ` +- [x] DataDir → `stores/{uuid}/` subdirectories +- [x] Store → per-store state.db +- [x] Log paths → `stores/{uuid}/logs/{author}.log` +- [x] Proto: Entry has store_id (UUID) +- [x] CLI → `init`, `create-store`, `list-stores`, `use` +- [x] meta.db stores table (MetaStore) +- [x] SigChain → validate entry.store_id --- diff --git a/docs/storage.md b/docs/storage.md new file mode 100644 index 0000000..49b0880 --- /dev/null +++ b/docs/storage.md @@ -0,0 +1,39 @@ +# Storage Format + +## Directory Layout + +``` +~/.local/share/lattice/ +├── identity.key # Ed25519 private key (not replicated) +├── meta.db # Global metadata (redb) +└── stores/{uuid}/ + ├── logs/{author}.log # Append-only SignedEntry stream + └── state.db # Per-store KV state (redb) +``` + +## meta.db (redb) + +| Table | Key | Value | Purpose | +|---------|---------------|--------------------|------------------------------| +| stores | UUID (16B) | created_at (u64) | Known stores | +| meta | "root_store" | UUID (16B) | Auto-opened on CLI startup | + +## state.db (redb, per store) + +| Table | Key | Value | Purpose | +|---------|----------|-------------|------------------------| +| kv | String | Vec | Key-value data | +| meta | String | Vec | last_seq, last_hash | + +## Log Files + +Each `{author}.log` contains length-delimited `LogRecord` messages: + +```protobuf +message LogRecord { + bytes hash = 1; // BLAKE3 hash of entry_bytes + bytes entry_bytes = 2; // Serialized SignedEntry +} +``` + +Hashes are verified on read; corruption causes `LogError::HashMismatch`. diff --git a/lattice-cli/src/commands.rs b/lattice-cli/src/commands.rs index 7141633..141c9fd 100644 --- a/lattice-cli/src/commands.rs +++ b/lattice-cli/src/commands.rs @@ -1,12 +1,19 @@ -//! CLI command handlers (presentation layer) +//! CLI command handlers -use crate::node::LatticeNode; +use crate::node::{LatticeNode, StoreHandle}; +use lattice_core::Uuid; use std::time::Instant; -/// Command handler function type -pub type Handler = fn(&mut LatticeNode, &[String]); +/// Result of a command that may switch stores +pub enum CommandResult { + /// No store change + Ok, + /// Switch to this store + SwitchTo(StoreHandle), +} + +pub type Handler = fn(&LatticeNode, Option<&StoreHandle>, &[String]) -> CommandResult; -/// Command definition pub struct Command { pub name: &'static str, pub args: &'static str, @@ -16,9 +23,40 @@ pub struct Command { pub handler: Handler, } -/// Build the command registry pub fn commands() -> Vec { vec![ + Command { + name: "init", + args: "", + description: "Initialize node with root store", + min_args: 0, + max_args: 0, + handler: cmd_init, + }, + Command { + name: "create-store", + args: "", + description: "Create a new store", + min_args: 0, + max_args: 0, + handler: cmd_create_store, + }, + Command { + name: "use", + args: "", + description: "Switch to a store", + min_args: 1, + max_args: 1, + handler: cmd_use_store, + }, + Command { + name: "list-stores", + args: "", + description: "List all stores", + min_args: 0, + max_args: 0, + handler: cmd_list_stores, + }, Command { name: "put", args: " ", @@ -54,7 +92,7 @@ pub fn commands() -> Vec { Command { name: "status", args: "", - description: "Show node statistics", + description: "Show node/store info", min_args: 0, max_args: 0, handler: cmd_status, @@ -70,83 +108,202 @@ pub fn commands() -> Vec { ] } -/// Print help from the command registry -fn cmd_help(_node: &mut LatticeNode, _args: &[String]) { - println!("\nLattice Commands:"); - for cmd in commands() { - if cmd.args.is_empty() { - println!(" {:<18} {}", cmd.name, cmd.description); - } else { - println!(" {} {:<10} {}", cmd.name, cmd.args, cmd.description); +// --- Store management --- + +fn cmd_init(node: &LatticeNode, _store: Option<&StoreHandle>, _args: &[String]) -> CommandResult { + match node.init() { + Ok(store_id) => { + println!("Initialized with root store: {}", store_id); + match node.open_store(store_id) { + Ok((handle, _)) => CommandResult::SwitchTo(handle), + Err(e) => { + eprintln!("Warning: {}", e); + CommandResult::Ok + } + } + } + Err(e) => { + eprintln!("Error: {}", e); + CommandResult::Ok } } - println!(" quit Exit the CLI"); - println!("\nTip: Use quotes for values with spaces: put \"my key\" \"hello world\"\n"); } -fn cmd_status(node: &mut LatticeNode, _args: &[String]) { - let status = node.status(); - println!("--- Node Status ---"); - println!("Node ID: {}", status.node_id); - println!("Data Dir: {}", status.data_dir); - println!("Log Sequence: {}", status.log_seq); - println!("Applied Entries: {}", status.applied_seq); - println!("-------------------"); -} - -fn cmd_put(node: &mut LatticeNode, args: &[String]) { - let start = Instant::now(); - match node.put(&args[0], args[1].as_bytes()) { - Ok(seq) => println!("OK (seq: {}, time: {:.2?})", seq, start.elapsed()), - Err(e) => eprintln!("Error: {}", e), +fn cmd_create_store(node: &LatticeNode, _store: Option<&StoreHandle>, _args: &[String]) -> CommandResult { + match node.create_store() { + Ok(store_id) => { + println!("Created store: {}", store_id); + match node.open_store(store_id) { + Ok((handle, _)) => { + println!("Switched to new store"); + CommandResult::SwitchTo(handle) + } + Err(e) => { + eprintln!("Warning: {}", e); + CommandResult::Ok + } + } + } + Err(e) => { + eprintln!("Error: {}", e); + CommandResult::Ok + } } } -fn cmd_get(node: &mut LatticeNode, args: &[String]) { +fn cmd_use_store(node: &LatticeNode, _store: Option<&StoreHandle>, args: &[String]) -> CommandResult { + let store_id = match Uuid::parse_str(&args[0]) { + Ok(id) => id, + Err(_) => { + eprintln!("Error: invalid UUID '{}'", args[0]); + return CommandResult::Ok; + } + }; + let start = Instant::now(); - match node.get(&args[0]) { - Ok(Some(value)) => { - println!("{}", format_value(&value)); + match node.open_store(store_id) { + Ok((handle, info)) => { + if info.entries_replayed > 0 { + println!("Replayed {} entries ({:.2?})", info.entries_replayed, start.elapsed()); + } else { + println!("Switched to store {}", store_id); + } + CommandResult::SwitchTo(handle) + } + Err(e) => { + eprintln!("Error: {}", e); + CommandResult::Ok + } + } +} + +fn cmd_list_stores(node: &LatticeNode, store: Option<&StoreHandle>, _args: &[String]) -> CommandResult { + let stores = match node.list_stores() { + Ok(s) => s, + Err(e) => { + eprintln!("Error: {}", e); + return CommandResult::Ok; + } + }; + let current_id = store.map(|s| s.id()); + + if stores.is_empty() { + println!("No stores. Use 'init' or 'create-store'."); + } else { + for store_id in stores { + let marker = if Some(store_id) == current_id { " *" } else { "" }; + println!("{}{}", store_id, marker); + } + } + CommandResult::Ok +} + +// --- Info --- + +fn cmd_help(_node: &LatticeNode, _store: Option<&StoreHandle>, _args: &[String]) -> CommandResult { + println!("\nCommands:"); + for cmd in commands() { + if cmd.args.is_empty() { + println!(" {:<16} {}", cmd.name, cmd.description); + } else { + println!(" {} {:<8} {}", cmd.name, cmd.args, cmd.description); + } + } + println!(" quit Exit"); + println!(); + CommandResult::Ok +} + +fn cmd_status(node: &LatticeNode, store: Option<&StoreHandle>, _args: &[String]) -> CommandResult { + println!("Node ID: {}", node.node_id()); + println!("Data: {}", node.data_path().display()); + match node.root_store() { + Ok(Some(id)) => println!("Root: {}", id), + Ok(None) => println!("Root: (not set)"), + Err(_) => println!("Root: (error)"), + } + if let Some(h) = store { + println!("Store: {}", h.id()); + println!("Log Seq: {}", h.log_seq()); + println!("Applied: {}", h.applied_seq().unwrap_or(0)); + } else { + println!("Store: (none)"); + } + CommandResult::Ok +} + +// --- KV --- + +fn cmd_put(_node: &LatticeNode, store: Option<&StoreHandle>, args: &[String]) -> CommandResult { + let Some(h) = store else { + println!("No store selected. Use 'init' or 'use '"); + return CommandResult::Ok; + }; + let start = Instant::now(); + match h.put(&args[0], args[1].as_bytes()) { + Ok(seq) => println!("OK (seq: {}, {:.2?})", seq, start.elapsed()), + Err(e) => eprintln!("Error: {}", e), + } + CommandResult::Ok +} + +fn cmd_get(_node: &LatticeNode, store: Option<&StoreHandle>, args: &[String]) -> CommandResult { + let Some(h) = store else { + println!("No store selected. Use 'init' or 'use '"); + return CommandResult::Ok; + }; + let start = Instant::now(); + match h.get(&args[0]) { + Ok(Some(v)) => { + println!("{}", format_value(&v)); println!("({:.2?})", start.elapsed()); } Ok(None) => println!("(nil)"), Err(e) => eprintln!("Error: {}", e), } + CommandResult::Ok } -fn cmd_delete(node: &mut LatticeNode, args: &[String]) { +fn cmd_delete(_node: &LatticeNode, store: Option<&StoreHandle>, args: &[String]) -> CommandResult { + let Some(h) = store else { + println!("No store selected. Use 'init' or 'use '"); + return CommandResult::Ok; + }; let start = Instant::now(); - match node.delete(&args[0]) { - Ok(seq) => println!("OK (seq: {}, time: {:.2?})", seq, start.elapsed()), + match h.delete(&args[0]) { + Ok(seq) => println!("OK (seq: {}, {:.2?})", seq, start.elapsed()), Err(e) => eprintln!("Error: {}", e), } + CommandResult::Ok } -fn cmd_list(node: &mut LatticeNode, args: &[String]) { +fn cmd_list(_node: &LatticeNode, store: Option<&StoreHandle>, args: &[String]) -> CommandResult { + let Some(h) = store else { + println!("No store selected. Use 'init' or 'use '"); + return CommandResult::Ok; + }; let verbose = args.first().map(|a| a == "-v").unwrap_or(false); let start = Instant::now(); - match node.list() { + match h.list() { Ok(entries) => { if entries.is_empty() { println!("(empty)"); - return; - } - for (key, value) in &entries { - if verbose { - println!("{} = {} ({} bytes)", key, format_value(value), value.len()); - } else { - println!("{} = {}", key, format_value(value)); + } else { + for (k, v) in &entries { + if verbose { + println!("{} = {} ({} bytes)", k, format_value(v), v.len()); + } else { + println!("{} = {}", k, format_value(v)); + } } + println!("({} keys, {:.2?})", entries.len(), start.elapsed()); } - println!("({} keys, {:.2?})", entries.len(), start.elapsed()); } Err(e) => eprintln!("Error: {}", e), } + CommandResult::Ok } -fn format_value(value: &[u8]) -> String { - match std::str::from_utf8(value) { - Ok(s) => s.to_string(), - Err(_) => format!("0x{}", hex::encode(value)), - } +fn format_value(v: &[u8]) -> String { + std::str::from_utf8(v).map(String::from).unwrap_or_else(|_| format!("0x{}", hex::encode(v))) } diff --git a/lattice-cli/src/main.rs b/lattice-cli/src/main.rs index 607178a..df76338 100644 --- a/lattice-cli/src/main.rs +++ b/lattice-cli/src/main.rs @@ -3,7 +3,8 @@ mod node; mod commands; -use node::LatticeNodeBuilder; +use commands::CommandResult; +use node::{LatticeNodeBuilder, StoreHandle}; use rustyline::error::ReadlineError; use rustyline::DefaultEditor; @@ -11,35 +12,59 @@ fn main() { println!("Lattice CLI v{}", env!("CARGO_PKG_VERSION")); println!("Type 'help' for commands, 'quit' to exit.\n"); - let mut node = match LatticeNodeBuilder::new().build() { - Ok((n, info)) => { - println!("Node ID: {}", info.node_id); - println!("Data: {}", info.data_path); - if info.is_new { - println!("Status: New identity created"); - } else if info.entries_replayed > 0 { - println!("Replay: {} log entries applied", info.entries_replayed); - } - println!(); - n - } + let (node, info) = match LatticeNodeBuilder::new().build() { + Ok(result) => result, Err(e) => { - eprintln!("Failed to initialize node: {}", e); - eprintln!("Hint: If data is corrupted, remove the data directory and restart."); + eprintln!("Failed to initialize: {}", e); return; } }; + + println!("Node ID: {}", info.node_id); + println!("Data: {}", info.data_path); + + if info.is_new { + println!("Status: New identity created"); + } else if !info.stores.is_empty() { + println!("Stores: {}", info.stores.len()); + } + if let Some(root) = info.root_store { + println!("Root: {}", root); + } + + let mut current_store: Option = match node.open_root_store() { + Ok(Some((h, open_info))) => { + if open_info.entries_replayed > 0 { + println!("Root: {} (replayed {})", open_info.store_id, open_info.entries_replayed); + } else { + println!("Root: {}", open_info.store_id); + } + Some(h) + } + Ok(None) => { + println!("Status: Not initialized (use 'init')"); + None + } + Err(e) => { + eprintln!("Warning: {}", e); + None + } + }; + println!(); let mut rl = DefaultEditor::new().expect("Failed to create editor"); let cmds = commands::commands(); loop { - match rl.readline("lattice> ") { + let prompt = match ¤t_store { + Some(h) => format!("lattice:{}> ", &h.id().to_string()[..8]), + None => "lattice:no-store> ".to_string(), + }; + + match rl.readline(&prompt) { Ok(line) => { let line = line.trim(); - if line.is_empty() { - continue; - } + if line.is_empty() { continue; } let _ = rl.add_history_entry(line); let args = match shlex::split(line) { @@ -50,40 +75,34 @@ fn main() { } }; - let cmd_name = match args.first() { - Some(c) => c.as_str(), - None => continue, - }; - - // Handle quit specially + let cmd_name = args.first().map(|s| s.as_str()).unwrap_or(""); + if cmd_name == "quit" || cmd_name == "exit" { println!("Goodbye!"); break; } - // Look up command in registry match cmds.iter().find(|c| c.name == cmd_name) { Some(cmd) => { let cmd_args = &args[1..]; if cmd_args.len() < cmd.min_args || cmd_args.len() > cmd.max_args { - if cmd.min_args == cmd.max_args { - println!("Usage: {} {}", cmd.name, cmd.args); - } else { - println!("Usage: {} {} (got {} args)", cmd.name, cmd.args, cmd_args.len()); - } + println!("Usage: {} {}", cmd.name, cmd.args); } else { - (cmd.handler)(&mut node, cmd_args); + match (cmd.handler)(&node, current_store.as_ref(), cmd_args) { + CommandResult::Ok => {} + CommandResult::SwitchTo(h) => current_store = Some(h), + } } } - None => println!("Unknown command: '{}'. Type 'help' for commands.", cmd_name), + None => println!("Unknown: '{}'. Type 'help'.", cmd_name), } } Err(ReadlineError::Interrupted | ReadlineError::Eof) => { println!("Goodbye!"); break; } - Err(err) => { - eprintln!("Error: {:?}", err); + Err(e) => { + eprintln!("Error: {:?}", e); break; } } diff --git a/lattice-cli/src/node.rs b/lattice-cli/src/node.rs index 6cb0a9d..eb8eba1 100644 --- a/lattice-cli/src/node.rs +++ b/lattice-cli/src/node.rs @@ -1,18 +1,18 @@ -//! Lattice Node API -//! -//! A programmatic interface to a local Lattice node. +//! Local Lattice node API with multi-store support use lattice_core::{ - DataDir, EntryBuilder, Node, SigChain, Store, + DataDir, EntryBuilder, MetaStore, Node, SigChain, Store, Uuid, hlc::HLC, log::LogError, + meta_store::MetaStoreError, sigchain::SigChainError, store::StoreError, }; use std::path::Path; +use std::rc::Rc; +use std::cell::RefCell; use thiserror::Error; -/// Errors that can occur during node operations #[derive(Error, Debug)] pub enum NodeError { #[error("IO error: {0}")] @@ -21,6 +21,9 @@ pub enum NodeError { #[error("Store error: {0}")] Store(#[from] StoreError), + #[error("MetaStore error: {0}")] + MetaStore(#[from] MetaStoreError), + #[error("SigChain error: {0}")] SigChain(#[from] SigChainError), @@ -29,43 +32,36 @@ pub enum NodeError { #[error("Node error: {0}")] Node(#[from] lattice_core::node::NodeError), + + #[error("Already initialized")] + AlreadyInitialized, } -/// Info returned when building a node pub struct NodeInfo { pub node_id: String, pub data_path: String, pub is_new: bool, + pub root_store: Option, + pub stores: Vec, +} + +pub struct StoreInfo { + pub store_id: Uuid, pub entries_replayed: u64, } -/// Status information about the node -pub struct NodeStatus { - pub node_id: String, - pub data_dir: String, - pub log_seq: u64, - pub applied_seq: u64, -} - -/// Builder for creating a fully initialized LatticeNode pub struct LatticeNodeBuilder { - data_dir: DataDir, + pub data_dir: DataDir, } impl LatticeNodeBuilder { - /// Create a builder with the default data directory pub fn new() -> Self { - Self { - data_dir: DataDir::default(), - } + Self { data_dir: DataDir::default() } } - /// Build and initialize the node pub fn build(self) -> Result<(LatticeNode, NodeInfo), NodeError> { - // Create directories self.data_dir.ensure_dirs()?; - // Load or create node identity let key_path = self.data_dir.identity_key(); let is_new = !key_path.exists(); let node = if key_path.exists() { @@ -76,113 +72,164 @@ impl LatticeNodeBuilder { node }; - let author_id_hex = hex::encode(node.public_key_bytes()); - - // Load or create sigchain - let log_path = self.data_dir.log_file(&author_id_hex); - let sigchain = if log_path.exists() { - SigChain::from_log(&log_path, node.public_key_bytes())? - } else { - SigChain::new(&log_path, node.public_key_bytes()) - }; - - // Open store and replay log - let store = Store::open(self.data_dir.state_db())?; - let entries_replayed = if log_path.exists() { - store.replay_log(&log_path)? - } else { - 0 - }; + let meta = MetaStore::open(self.data_dir.meta_db())?; + let root_store = meta.root_store()?; + let stores = meta.list_stores()?; let info = NodeInfo { node_id: hex::encode(node.public_key_bytes()), data_path: self.data_dir.base().display().to_string(), is_new, - entries_replayed, + root_store, + stores, }; Ok((LatticeNode { data_dir: self.data_dir, - node, - sigchain, - store, + node: Rc::new(node), + meta, }, info)) } } impl Default for LatticeNodeBuilder { - fn default() -> Self { - Self::new() - } + fn default() -> Self { Self::new() } } -/// A fully initialized Lattice node -/// -/// Use `LatticeNodeBuilder` to create an instance. +/// A local Lattice node (manages identity and store registry) pub struct LatticeNode { data_dir: DataDir, - node: Node, - sigchain: SigChain, - store: Store, + node: Rc, + meta: MetaStore, } impl LatticeNode { - /// Get the node's public key as hex pub fn node_id(&self) -> String { hex::encode(self.node.public_key_bytes()) } - /// Get the path to the data directory pub fn data_path(&self) -> &Path { self.data_dir.base() } - /// Get the current status of the node - pub fn status(&self) -> NodeStatus { - NodeStatus { - node_id: self.node_id(), - data_dir: self.data_dir.base().display().to_string(), - log_seq: self.sigchain.len(), - applied_seq: self.store.last_seq().unwrap_or(0), + /// Get the root store ID + pub fn root_store(&self) -> Result, NodeError> { + Ok(self.meta.root_store()?) + } + + /// Open the root store if set + pub fn open_root_store(&self) -> Result, NodeError> { + match self.meta.root_store()? { + Some(id) => Ok(Some(self.open_store(id)?)), + None => Ok(None), } } - /// Put a key-value pair - pub fn put(&mut self, key: &str, value: &[u8]) -> Result { - let entry = EntryBuilder::new(self.sigchain.next_seq(), HLC::now()) - .prev_hash(self.sigchain.last_hash().to_vec()) - .put(key, value.to_vec()) - .sign(&self.node); - - self.commit_entry(entry) + /// Initialize the node with a root store (fails if already initialized) + pub fn init(&self) -> Result { + if self.meta.root_store()?.is_some() { + return Err(NodeError::AlreadyInitialized); + } + let store_id = self.create_store()?; + self.meta.set_root_store(store_id)?; + Ok(store_id) } - /// Get a value by key + pub fn list_stores(&self) -> Result, NodeError> { + Ok(self.meta.list_stores()?) + } + + pub fn create_store(&self) -> Result { + let store_id = Uuid::new_v4(); + self.data_dir.ensure_store_dirs(store_id)?; + let _ = Store::open(self.data_dir.store_state_db(store_id))?; + self.meta.add_store(store_id)?; + Ok(store_id) + } + + pub fn open_store(&self, store_id: Uuid) -> Result<(StoreHandle, StoreInfo), NodeError> { + self.data_dir.ensure_store_dirs(store_id)?; + + let author_id_hex = hex::encode(self.node.public_key_bytes()); + let log_path = self.data_dir.store_log_file(store_id, &author_id_hex); + + let sigchain = if log_path.exists() { + SigChain::from_log(&log_path, *store_id.as_bytes(), self.node.public_key_bytes())? + } else { + SigChain::new(&log_path, *store_id.as_bytes(), self.node.public_key_bytes()) + }; + + let store = Store::open(self.data_dir.store_state_db(store_id))?; + let entries_replayed = if log_path.exists() { + store.replay_log(&log_path)? + } else { + 0 + }; + + let info = StoreInfo { store_id, entries_replayed }; + let handle = StoreHandle { + store_id, + node: Rc::clone(&self.node), + sigchain: RefCell::new(sigchain), + store, + }; + + Ok((handle, info)) + } +} + +/// A handle to a specific store with KV operations +pub struct StoreHandle { + store_id: Uuid, + node: Rc, + sigchain: RefCell, + store: Store, +} + +impl StoreHandle { + pub fn id(&self) -> Uuid { self.store_id } + pub fn get(&self, key: &str) -> Result>, NodeError> { Ok(self.store.get(key)?) } - /// List all key-value pairs pub fn list(&self) -> Result)>, NodeError> { Ok(self.store.list_all()?) } - /// Delete a key - pub fn delete(&mut self, key: &str) -> Result { - let entry = EntryBuilder::new(self.sigchain.next_seq(), HLC::now()) - .prev_hash(self.sigchain.last_hash().to_vec()) - .delete(key) - .sign(&self.node); - - self.commit_entry(entry) + pub fn log_seq(&self) -> u64 { + self.sigchain.borrow().len() } - /// Commit a signed entry: append to log via sigchain, then apply to store - fn commit_entry(&mut self, entry: lattice_core::proto::SignedEntry) -> Result { - self.sigchain.append(&entry)?; - self.store.apply_entry(&entry)?; + pub fn applied_seq(&self) -> Result { + Ok(self.store.last_seq()?) + } - Ok(self.sigchain.len()) + pub fn put(&self, key: &str, value: &[u8]) -> Result { + self.commit_entry(|b| b.put(key, value.to_vec())) + } + + pub fn delete(&self, key: &str) -> Result { + self.commit_entry(|b| b.delete(key)) + } + + fn commit_entry(&self, build: F) -> Result + where + F: FnOnce(EntryBuilder) -> EntryBuilder, + { + let mut sigchain = self.sigchain.borrow_mut(); + let seq = sigchain.len() + 1; + let prev_hash = sigchain.last_hash(); + + let builder = EntryBuilder::new(seq, HLC::now()) + .store_id(self.store_id.as_bytes().to_vec()) + .prev_hash(prev_hash.to_vec()); + let entry = build(builder).sign(&self.node); + + sigchain.append(&entry)?; + self.store.apply_entry(&entry)?; + + Ok(seq) } } @@ -193,77 +240,111 @@ mod tests { fn temp_data_dir(name: &str) -> DataDir { let path = temp_dir().join(format!("lattice_node_test_{}", name)); - // Clean up from previous runs let _ = std::fs::remove_dir_all(&path); DataDir::new(path) } #[test] - fn test_put_survives_restart() { - let data_dir = temp_data_dir("restart"); + fn test_create_and_open_store() { + let data_dir = temp_data_dir("meta_store"); - // First session: put a value - { - let (mut node, _) = LatticeNodeBuilder { data_dir: data_dir.clone() } - .build() - .expect("Failed to create node"); - - node.put("/test/key", b"hello").expect("put failed"); - assert_eq!(node.get("/test/key").unwrap(), Some(b"hello".to_vec())); - } + let (node, info) = LatticeNodeBuilder { data_dir: data_dir.clone() } + .build() + .expect("Failed to create node"); - // Second session: value should still be there - { - let (node, _) = LatticeNodeBuilder { data_dir: data_dir.clone() } - .build() - .expect("Failed to create node on restart"); - - assert_eq!(node.get("/test/key").unwrap(), Some(b"hello".to_vec())); - assert_eq!(node.status().log_seq, 1); - } + assert!(info.stores.is_empty()); + + let store_id = node.create_store().expect("Failed to create store"); + + // Verify it's in the list + let stores = node.list_stores().expect("list failed"); + assert!(stores.contains(&store_id)); + + let (handle, _) = node.open_store(store_id).expect("Failed to open store"); + handle.put("/key", b"value").expect("put failed"); + assert_eq!(handle.get("/key").unwrap(), Some(b"value".to_vec())); - // Cleanup let _ = std::fs::remove_dir_all(data_dir.base()); } #[test] - fn test_log_replay_after_db_deletion() { - let data_dir = temp_data_dir("replay"); + fn test_store_isolation() { + let data_dir = temp_data_dir("meta_isolation"); - // First session: put some values - { - let (mut node, _) = LatticeNodeBuilder { data_dir: data_dir.clone() } - .build() - .expect("Failed to create node"); - - node.put("/key1", b"value1").expect("put failed"); - node.put("/key2", b"value2").expect("put failed"); - node.delete("/key1").expect("delete failed"); + let (node, _) = LatticeNodeBuilder { data_dir: data_dir.clone() } + .build() + .expect("Failed to create node"); + + let store_a = node.create_store().expect("create A"); + let store_b = node.create_store().expect("create B"); + + let (handle_a, _) = node.open_store(store_a).expect("open A"); + handle_a.put("/key", b"from A").expect("put A"); + + let (handle_b, _) = node.open_store(store_b).expect("open B"); + assert_eq!(handle_b.get("/key").unwrap(), None); + + assert_eq!(handle_a.get("/key").unwrap(), Some(b"from A".to_vec())); + + let _ = std::fs::remove_dir_all(data_dir.base()); + } + + #[test] + fn test_init_creates_root_store() { + let data_dir = temp_data_dir("init_root"); + + let (node, info) = LatticeNodeBuilder { data_dir: data_dir.clone() } + .build() + .expect("create node"); + + // Initially no root store + assert!(info.root_store.is_none()); + + // Init creates root store + let root_id = node.init().expect("init failed"); + assert_eq!(node.root_store().unwrap(), Some(root_id)); + + let _ = std::fs::remove_dir_all(data_dir.base()); + } + + #[test] + fn test_duplicate_init_fails() { + let data_dir = temp_data_dir("init_dup"); + + let (node, _) = LatticeNodeBuilder { data_dir: data_dir.clone() } + .build() + .expect("create node"); + + node.init().expect("first init"); + + // Second init should fail + match node.init() { + Err(NodeError::AlreadyInitialized) => (), + other => panic!("Expected AlreadyInitialized, got {:?}", other), } - // Delete state.db but keep the log - let db_path = data_dir.state_db(); - std::fs::remove_file(&db_path).expect("Failed to delete state.db"); - assert!(!db_path.exists(), "state.db should be deleted"); + let _ = std::fs::remove_dir_all(data_dir.base()); + } + + #[test] + fn test_root_store_in_info_after_init() { + let data_dir = temp_data_dir("init_info"); - // Third session: log should be replayed to reconstruct state - { - let (node, info) = LatticeNodeBuilder { data_dir: data_dir.clone() } + // First session: init + let root_id = { + let (node, _) = LatticeNodeBuilder { data_dir: data_dir.clone() } .build() - .expect("Failed to rebuild node from log"); - - // Should have replayed 3 entries - assert_eq!(info.entries_replayed, 3); - // key1 was deleted - assert_eq!(node.get("/key1").unwrap(), None); - // key2 should still exist - assert_eq!(node.get("/key2").unwrap(), Some(b"value2".to_vec())); - // log seq should be 3 (put, put, delete) - assert_eq!(node.status().log_seq, 3); - } + .expect("create node"); + node.init().expect("init") + }; + + // Second session: root_store should be in info + let (_, info) = LatticeNodeBuilder { data_dir: data_dir.clone() } + .build() + .expect("reload node"); + + assert_eq!(info.root_store, Some(root_id)); - // Cleanup let _ = std::fs::remove_dir_all(data_dir.base()); } } - diff --git a/lattice-core/Cargo.toml b/lattice-core/Cargo.toml index 77c4dd9..6e7cd2d 100644 --- a/lattice-core/Cargo.toml +++ b/lattice-core/Cargo.toml @@ -15,6 +15,7 @@ dirs = { workspace = true } blake3 = { workspace = true } hex = { workspace = true } redb = { workspace = true } +uuid = { workspace = true } [build-dependencies] prost-build = { workspace = true } diff --git a/lattice-core/src/data_dir.rs b/lattice-core/src/data_dir.rs index a55504a..84920d3 100644 --- a/lattice-core/src/data_dir.rs +++ b/lattice-core/src/data_dir.rs @@ -2,19 +2,26 @@ //! //! Provides platform-specific paths for Lattice data storage: //! - `identity.key` — Ed25519 private key -//! - `logs/` — Append-only log files per author -//! - `state.db` — KV snapshot and indexes +//! - `meta.db` — Global metadata (stores table) +//! - `stores/{uuid}/logs/{author}.log` — Per-store, per-author logs +//! - `stores/{uuid}/state.db` — Per-store KV state use std::path::{Path, PathBuf}; +use uuid::Uuid; const APP_NAME: &str = "lattice"; /// Data directory configuration. /// -/// Handles paths for: -/// - `identity.key` — node's private key -/// - `logs/{author_id}.log` — per-author log files -/// - `state.db` — redb database +/// Multi-store layout: +/// ```text +/// base/ +/// identity.key +/// meta.db +/// stores/{uuid}/ +/// logs/{author}.log +/// state.db +/// ``` #[derive(Debug, Clone)] pub struct DataDir { base: PathBuf, @@ -27,10 +34,6 @@ impl DataDir { } /// Create a DataDir using the platform-specific data directory. - /// - /// - Linux: `~/.local/share/lattice/` - /// - macOS: `~/Library/Application Support/lattice/` - /// - Windows: `C:\Users\\AppData\Roaming\lattice\` pub fn default_location() -> Option { dirs::data_dir().map(|d| Self::new(d.join(APP_NAME))) } @@ -45,25 +48,47 @@ impl DataDir { self.base.join("identity.key") } - /// Get the path to the logs directory. - pub fn logs_dir(&self) -> PathBuf { - self.base.join("logs") + /// Get the path to the global metadata database. + pub fn meta_db(&self) -> PathBuf { + self.base.join("meta.db") } - /// Get the path to a specific author's log file. - pub fn log_file(&self, author_id_hex: &str) -> PathBuf { - self.logs_dir().join(format!("{}.log", author_id_hex)) + /// Get the path to the stores directory. + pub fn stores_dir(&self) -> PathBuf { + self.base.join("stores") } - /// Get the path to the state database. - pub fn state_db(&self) -> PathBuf { - self.base.join("state.db") + /// Get the path to a specific store's directory. + pub fn store_dir(&self, store_id: Uuid) -> PathBuf { + self.stores_dir().join(store_id.to_string()) } - /// Ensure all required directories exist. + /// Get the path to a store's logs directory. + pub fn store_logs_dir(&self, store_id: Uuid) -> PathBuf { + self.store_dir(store_id).join("logs") + } + + /// Get the path to a specific author's log file within a store. + pub fn store_log_file(&self, store_id: Uuid, author_id_hex: &str) -> PathBuf { + self.store_logs_dir(store_id).join(format!("{}.log", author_id_hex)) + } + + /// Get the path to a store's state database. + pub fn store_state_db(&self, store_id: Uuid) -> PathBuf { + self.store_dir(store_id).join("state.db") + } + + /// Ensure base directory exists. pub fn ensure_dirs(&self) -> std::io::Result<()> { std::fs::create_dir_all(&self.base)?; - std::fs::create_dir_all(self.logs_dir())?; + std::fs::create_dir_all(self.stores_dir())?; + Ok(()) + } + + /// Ensure directories for a specific store exist. + pub fn ensure_store_dirs(&self, store_id: Uuid) -> std::io::Result<()> { + self.ensure_dirs()?; + std::fs::create_dir_all(self.store_logs_dir(store_id))?; Ok(()) } } @@ -83,29 +108,31 @@ mod tests { let dd = DataDir::new("/custom/path"); assert_eq!(dd.base(), Path::new("/custom/path")); assert_eq!(dd.identity_key(), PathBuf::from("/custom/path/identity.key")); - assert_eq!(dd.logs_dir(), PathBuf::from("/custom/path/logs")); - assert_eq!(dd.state_db(), PathBuf::from("/custom/path/state.db")); + assert_eq!(dd.meta_db(), PathBuf::from("/custom/path/meta.db")); + assert_eq!(dd.stores_dir(), PathBuf::from("/custom/path/stores")); } #[test] - fn test_log_file_path() { + fn test_store_paths() { let dd = DataDir::new("/data"); - let path = dd.log_file("abc123"); - assert_eq!(path, PathBuf::from("/data/logs/abc123.log")); + let store_id = Uuid::parse_str("a1b2c3d4-e5f6-7890-abcd-ef1234567890").unwrap(); + + assert_eq!(dd.store_dir(store_id), PathBuf::from("/data/stores/a1b2c3d4-e5f6-7890-abcd-ef1234567890")); + assert_eq!(dd.store_logs_dir(store_id), PathBuf::from("/data/stores/a1b2c3d4-e5f6-7890-abcd-ef1234567890/logs")); + assert_eq!(dd.store_log_file(store_id, "abc123"), PathBuf::from("/data/stores/a1b2c3d4-e5f6-7890-abcd-ef1234567890/logs/abc123.log")); + assert_eq!(dd.store_state_db(store_id), PathBuf::from("/data/stores/a1b2c3d4-e5f6-7890-abcd-ef1234567890/state.db")); } #[test] fn test_default_location_exists() { // On most systems, default_location should return Some let location = DataDir::default_location(); - // Just verify it doesn't panic - actual path varies by platform assert!(location.is_some() || true); } #[test] fn test_default_impl() { let dd = DataDir::default(); - // Should either be platform default or ./data fallback assert!(dd.base().to_str().is_some()); } } diff --git a/lattice-core/src/lib.rs b/lattice-core/src/lib.rs index 88f6b54..c431a0b 100644 --- a/lattice-core/src/lib.rs +++ b/lattice-core/src/lib.rs @@ -24,6 +24,7 @@ pub mod data_dir; pub mod signed_entry; pub mod log; pub mod store; +pub mod meta_store; // Constants /// Maximum size of a serialized SignedEntry (16 MB) @@ -39,3 +40,5 @@ pub use data_dir::DataDir; pub use signed_entry::{EntryBuilder, sign_entry, verify_signed_entry, hash_signed_entry}; pub use log::{append_entry, read_entries, LogReader}; pub use store::Store; +pub use meta_store::MetaStore; +pub use uuid::Uuid; diff --git a/lattice-core/src/meta_store.rs b/lattice-core/src/meta_store.rs new file mode 100644 index 0000000..cda2178 --- /dev/null +++ b/lattice-core/src/meta_store.rs @@ -0,0 +1,154 @@ +//! MetaStore - global node metadata in meta.db +//! +//! Tables: +//! - stores: UUID → created_at (Unix ms) +//! - meta: "root_store" → UUID (auto-opened on startup) + +use redb::{Database, ReadableTable, TableDefinition}; +use std::path::Path; +use thiserror::Error; +use uuid::Uuid; + +const STORES_TABLE: TableDefinition<&[u8], u64> = TableDefinition::new("stores"); +const META_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("meta"); + +const META_ROOT_STORE: &str = "root_store"; + +#[derive(Error, Debug)] +pub enum MetaStoreError { + #[error("Database error: {0}")] + Database(#[from] redb::DatabaseError), + + #[error("Table error: {0}")] + Table(#[from] redb::TableError), + + #[error("Transaction error: {0}")] + Transaction(#[from] redb::TransactionError), + + #[error("Commit error: {0}")] + Commit(#[from] redb::CommitError), + + #[error("Storage error: {0}")] + Storage(#[from] redb::StorageError), +} + +/// Global metadata store +pub struct MetaStore { + db: Database, +} + +impl MetaStore { + /// Open or create meta.db at the given path + pub fn open(path: impl AsRef) -> Result { + let db = Database::create(path)?; + + // Ensure tables exist + let write_txn = db.begin_write()?; + { + let _ = write_txn.open_table(STORES_TABLE)?; + let _ = write_txn.open_table(META_TABLE)?; + } + write_txn.commit()?; + + Ok(Self { db }) + } + + /// Register a new store + pub fn add_store(&self, store_id: Uuid) -> Result<(), MetaStoreError> { + let write_txn = self.db.begin_write()?; + { + let mut table = write_txn.open_table(STORES_TABLE)?; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64; + table.insert(store_id.as_bytes().as_slice(), now)?; + } + write_txn.commit()?; + Ok(()) + } + + /// List all registered stores + pub fn list_stores(&self) -> Result, MetaStoreError> { + let read_txn = self.db.begin_read()?; + let table = read_txn.open_table(STORES_TABLE)?; + + let mut stores = Vec::new(); + for result in table.iter()? { + let (key, _created_at) = result?; + let bytes: [u8; 16] = key.value().try_into().unwrap_or([0; 16]); + stores.push(Uuid::from_bytes(bytes)); + } + Ok(stores) + } + + /// Get the root store ID (auto-opened on startup) + pub fn root_store(&self) -> Result, MetaStoreError> { + let read_txn = self.db.begin_read()?; + let table = read_txn.open_table(META_TABLE)?; + + match table.get(META_ROOT_STORE)? { + Some(value) => { + let bytes: [u8; 16] = value.value().try_into().unwrap_or([0; 16]); + Ok(Some(Uuid::from_bytes(bytes))) + } + None => Ok(None), + } + } + + /// Set the root store ID + pub fn set_root_store(&self, store_id: Uuid) -> Result<(), MetaStoreError> { + let write_txn = self.db.begin_write()?; + { + let mut table = write_txn.open_table(META_TABLE)?; + table.insert(META_ROOT_STORE, store_id.as_bytes().as_slice())?; + } + write_txn.commit()?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env::temp_dir; + + #[test] + fn test_add_and_list_stores() { + let path = temp_dir().join("meta_store_test.db"); + let _ = std::fs::remove_file(&path); + + let meta = MetaStore::open(&path).unwrap(); + + let id1 = Uuid::new_v4(); + let id2 = Uuid::new_v4(); + + meta.add_store(id1).unwrap(); + meta.add_store(id2).unwrap(); + + let stores = meta.list_stores().unwrap(); + assert_eq!(stores.len(), 2); + assert!(stores.contains(&id1)); + assert!(stores.contains(&id2)); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn test_root_store() { + let path = temp_dir().join("meta_store_root.db"); + let _ = std::fs::remove_file(&path); + + let meta = MetaStore::open(&path).unwrap(); + + // Initially no root store + assert_eq!(meta.root_store().unwrap(), None); + + let root = Uuid::new_v4(); + meta.set_root_store(root).unwrap(); + + assert_eq!(meta.root_store().unwrap(), Some(root)); + + let _ = std::fs::remove_file(&path); + } +} diff --git a/lattice-core/src/proto.rs b/lattice-core/src/proto.rs index d680b74..18139ca 100644 --- a/lattice-core/src/proto.rs +++ b/lattice-core/src/proto.rs @@ -31,6 +31,7 @@ mod tests { fn test_entry_with_ops() { let entry = Entry { version: 1, + store_id: vec![1u8; 16], prev_hash: vec![0u8; 32], seq: 5, timestamp: Some(Hlc { diff --git a/lattice-core/src/sigchain.rs b/lattice-core/src/sigchain.rs index 42e8c87..f3ff1fe 100644 --- a/lattice-core/src/sigchain.rs +++ b/lattice-core/src/sigchain.rs @@ -23,6 +23,9 @@ pub enum SigChainError { #[error("Wrong author: expected {expected}, got {got}")] WrongAuthor { expected: String, got: String }, + #[error("Wrong store_id: expected {expected}, got {got}")] + WrongStoreId { expected: String, got: String }, + #[error("Invalid sequence: expected {expected}, got {got}")] InvalidSequence { expected: u64, got: u64 }, @@ -34,11 +37,14 @@ pub enum SigChainError { } /// An append-only log where each entry is cryptographically signed -/// and hash-linked to the previous entry. +/// and hash-linked to the previous entry, scoped to a specific store. pub struct SigChain { /// Path to the log file log_path: PathBuf, + /// Store UUID (16 bytes) + store_id: [u8; 16], + /// Author's public key (32 bytes) author_id: [u8; 32], @@ -50,10 +56,11 @@ pub struct SigChain { } impl SigChain { - /// Create a new empty sigchain for an author - pub fn new(log_path: impl AsRef, author_id: [u8; 32]) -> Self { + /// Create a new empty sigchain for a (store, author) pair + pub fn new(log_path: impl AsRef, store_id: [u8; 16], author_id: [u8; 32]) -> Self { Self { log_path: log_path.as_ref().to_path_buf(), + store_id, author_id, next_seq: 1, last_hash: [0u8; 32], @@ -61,11 +68,11 @@ impl SigChain { } /// Load a sigchain from an existing log file - pub fn from_log(log_path: impl AsRef, author_id: [u8; 32]) -> Result { + pub fn from_log(log_path: impl AsRef, store_id: [u8; 16], author_id: [u8; 32]) -> Result { let log_path = log_path.as_ref().to_path_buf(); let entries = read_entries(&log_path)?; - let mut chain = Self::new(&log_path, author_id); + let mut chain = Self::new(&log_path, store_id, author_id); for signed_entry in entries { // Verify signature @@ -85,6 +92,18 @@ impl SigChain { // Decode Entry let entry = Entry::decode(&signed_entry.entry_bytes[..])?; + // Validate store_id + // Note: Empty/malformed store_id becomes [0u8;16], which fails validation + // against any real UUID store. This intentionally rejects legacy entries. + let entry_store: [u8; 16] = entry.store_id.clone().try_into() + .unwrap_or([0u8; 16]); + if entry_store != store_id { + return Err(SigChainError::WrongStoreId { + expected: hex::encode(store_id), + got: hex::encode(entry_store), + }); + } + // Validate sequence if entry.seq != chain.next_seq { return Err(SigChainError::InvalidSequence { @@ -156,6 +175,18 @@ impl SigChain { // Decode entry let entry = Entry::decode(&signed_entry.entry_bytes[..])?; + // Validate store_id + // Note: Empty/malformed store_id becomes [0u8;16], which fails validation + // against any real UUID store. This intentionally rejects legacy entries. + let entry_store: [u8; 16] = entry.store_id.clone().try_into() + .unwrap_or([0u8; 16]); + if entry_store != self.store_id { + return Err(SigChainError::WrongStoreId { + expected: hex::encode(self.store_id), + got: hex::encode(entry_store), + }); + } + // Validate sequence if entry.seq != self.next_seq { return Err(SigChainError::InvalidSequence { @@ -201,6 +232,7 @@ impl SigChain { let hlc = HLC::now_with_clock(&SystemClock); let mut builder = EntryBuilder::new(self.next_seq, hlc) + .store_id(self.store_id.to_vec()) .prev_hash(self.last_hash.to_vec()); // Add operations @@ -230,12 +262,14 @@ mod tests { temp_dir().join(format!("lattice_sigchain_test_{}.log", name)) } + const TEST_STORE: [u8; 16] = [1u8; 16]; + #[test] fn test_new_sigchain() { let path = temp_log_path("new"); let author = [1u8; 32]; - let chain = SigChain::new(&path, author); + let chain = SigChain::new(&path, TEST_STORE, author); assert_eq!(chain.author_id(), &author); assert_eq!(chain.next_seq(), 1); @@ -251,10 +285,11 @@ mod tests { let node = Node::generate(); let author = node.public_key_bytes(); - let mut chain = SigChain::new(&path, author); + let mut chain = SigChain::new(&path, TEST_STORE, author); let clock = MockClock::new(1000); let entry = EntryBuilder::new(1, HLC::now_with_clock(&clock)) + .store_id(TEST_STORE.to_vec()) .prev_hash([0u8; 32].to_vec()) .put("/key", b"value".to_vec()) .sign(&node); @@ -275,11 +310,12 @@ mod tests { let node = Node::generate(); let author = node.public_key_bytes(); - let mut chain = SigChain::new(&path, author); + let mut chain = SigChain::new(&path, TEST_STORE, author); let clock = MockClock::new(1000); for i in 1..=3 { let entry = EntryBuilder::new(i, HLC::now_with_clock(&clock)) + .store_id(TEST_STORE.to_vec()) .prev_hash(chain.last_hash.to_vec()) .put(format!("/key/{}", i), format!("value{}", i).into_bytes()) .sign(&node); @@ -303,9 +339,10 @@ mod tests { // Write some entries { - let mut chain = SigChain::new(&path, author); + let mut chain = SigChain::new(&path, TEST_STORE, author); for i in 1..=3 { let entry = EntryBuilder::new(i, HLC::now_with_clock(&clock)) + .store_id(TEST_STORE.to_vec()) .prev_hash(chain.last_hash.to_vec()) .put("/key", b"val".to_vec()) .sign(&node); @@ -314,7 +351,7 @@ mod tests { } // Reload from log - let chain = SigChain::from_log(&path, author).unwrap(); + let chain = SigChain::from_log(&path, TEST_STORE, author).unwrap(); assert_eq!(chain.len(), 3); assert_eq!(chain.next_seq(), 4); @@ -329,11 +366,12 @@ mod tests { let node = Node::generate(); let author = node.public_key_bytes(); - let mut chain = SigChain::new(&path, author); + let mut chain = SigChain::new(&path, TEST_STORE, author); let clock = MockClock::new(1000); // Try to append with wrong seq (2 instead of 1) let entry = EntryBuilder::new(2, HLC::now_with_clock(&clock)) + .store_id(TEST_STORE.to_vec()) .prev_hash([0u8; 32].to_vec()) .put("/key", b"val".to_vec()) .sign(&node); @@ -352,11 +390,12 @@ mod tests { let node = Node::generate(); let author = node.public_key_bytes(); - let mut chain = SigChain::new(&path, author); + let mut chain = SigChain::new(&path, TEST_STORE, author); let clock = MockClock::new(1000); // First entry let entry1 = EntryBuilder::new(1, HLC::now_with_clock(&clock)) + .store_id(TEST_STORE.to_vec()) .prev_hash([0u8; 32].to_vec()) .put("/key", b"v1".to_vec()) .sign(&node); @@ -364,6 +403,7 @@ mod tests { // Second entry with wrong prev_hash let entry2 = EntryBuilder::new(2, HLC::now_with_clock(&clock)) + .store_id(TEST_STORE.to_vec()) .prev_hash([99u8; 32].to_vec()) // Wrong! .put("/key", b"v2".to_vec()) .sign(&node); @@ -382,11 +422,12 @@ mod tests { let node = Node::generate(); let other_author = [99u8; 32]; // Different author - let mut chain = SigChain::new(&path, other_author); + let mut chain = SigChain::new(&path, TEST_STORE, other_author); let clock = MockClock::new(1000); // Entry signed by node but chain expects other_author let entry = EntryBuilder::new(1, HLC::now_with_clock(&clock)) + .store_id(TEST_STORE.to_vec()) .prev_hash([0u8; 32].to_vec()) .put("/key", b"val".to_vec()) .sign(&node); @@ -405,7 +446,7 @@ mod tests { let node = Node::generate(); let author = node.public_key_bytes(); - let mut chain = SigChain::new(&path, author); + let mut chain = SigChain::new(&path, TEST_STORE, author); let ops = vec![ Operation { @@ -427,4 +468,37 @@ mod tests { std::fs::remove_file(&path).ok(); } + + #[test] + fn test_reject_wrong_store_id() { + let path_a = temp_log_path("storeid_a"); + let path_b = temp_log_path("storeid_b"); + std::fs::remove_file(&path_a).ok(); + std::fs::remove_file(&path_b).ok(); + + let node = Node::generate(); + let author = node.public_key_bytes(); + let clock = MockClock::new(1000); + + let store_a = [0xAAu8; 16]; + let store_b = [0xBBu8; 16]; + + // Create valid entry for store A + let mut chain_a = SigChain::new(&path_a, store_a, author); + let entry = EntryBuilder::new(1, HLC::now_with_clock(&clock)) + .store_id(store_a.to_vec()) + .prev_hash([0u8; 32].to_vec()) + .put("/key", b"val".to_vec()) + .sign(&node); + chain_a.append(&entry).unwrap(); + + // Try to replay that entry into store B's chain + let mut chain_b = SigChain::new(&path_b, store_b, author); + let result = chain_b.append(&entry); + + assert!(matches!(result, Err(SigChainError::WrongStoreId { .. }))); + + std::fs::remove_file(&path_a).ok(); + std::fs::remove_file(&path_b).ok(); + } } diff --git a/lattice-core/src/signed_entry.rs b/lattice-core/src/signed_entry.rs index 652925b..030c51f 100644 --- a/lattice-core/src/signed_entry.rs +++ b/lattice-core/src/signed_entry.rs @@ -32,6 +32,7 @@ pub enum EntryError { /// Builder for creating Entry messages pub struct EntryBuilder { version: u32, + store_id: Vec, prev_hash: Vec, seq: u64, timestamp: HLC, @@ -43,6 +44,7 @@ impl EntryBuilder { pub fn new(seq: u64, timestamp: HLC) -> Self { Self { version: 1, + store_id: Vec::new(), // Empty = legacy single-store prev_hash: vec![0u8; 32], // Genesis or will be set seq, timestamp, @@ -50,6 +52,12 @@ impl EntryBuilder { } } + /// Set the store ID (16-byte UUID) + pub fn store_id(mut self, id: impl Into>) -> Self { + self.store_id = id.into(); + self + } + /// Set the previous entry hash (for chaining) pub fn prev_hash(mut self, hash: impl Into>) -> Self { self.prev_hash = hash.into(); @@ -87,6 +95,7 @@ impl EntryBuilder { pub fn build(self) -> Entry { Entry { version: self.version, + store_id: self.store_id, prev_hash: self.prev_hash, seq: self.seq, timestamp: Some(Hlc { diff --git a/proto/lattice.proto b/proto/lattice.proto index 12b8063..10c5431 100644 --- a/proto/lattice.proto +++ b/proto/lattice.proto @@ -20,6 +20,9 @@ message Entry { // Versioning allows us to change the format radically later if needed uint32 version = 1; + // Store this entry belongs to (16-byte UUID) + bytes store_id = 6; + // Ordering Metadata bytes prev_hash = 2; // Link to previous entry (32 bytes) uint64 seq = 3; // Monotonic sequence number