feat: introduce global meta store and root store concept, and update CLI to manage active store

This commit is contained in:
2025-12-21 23:59:08 +01:00
parent f45c6ccfcf
commit 346ebccee7
15 changed files with 851 additions and 283 deletions
+1
View File
@@ -46,6 +46,7 @@ dirs = "5"
blake3 = "1" blake3 = "1"
hex = "0.4" hex = "0.4"
redb = "2" redb = "2"
uuid = { version = "1", features = ["v4"] }
# Testing # Testing
tokio-test = "0.4" tokio-test = "0.4"
+5 -5
View File
@@ -187,14 +187,14 @@ Note: KV stores multiple heads per key to support DAG conflict resolution. Reads
``` ```
Table Key Value Purpose Table Key Value Purpose
───────────────────────────────────────────────────────────────────────────── ─────────────────────────────────────────────────────────────────────────────
stores UUID (store_id) StoreInfo Known stores this node participates in stores [u8; 16] (UUID) u64 (created_at_ms) Known stores
meta String Vec<u8> Global metadata (node_id, etc.) meta "root_store" [u8; 16] (UUID) Root store ID (opened on startup)
``` ```
StoreInfo: `{ type: "manifest" | "data", name, created_at, ... }` - **Root Store**: The primary/manifest store for this node, auto-opened on CLI startup
- Manifest stores define mesh membership via KV entries (`/nodes/{pubkey}/...`) - **Stores Table**: Tracks all stores this node participates in
- Manifest stores define mesh membership via `/nodes/{pubkey}/...` entries
- Data stores hold application data - Data stores hold application data
- Node's list of manifest store IDs = meshes it belongs to
#### In-Memory Structures #### In-Memory Structures
+8 -9
View File
@@ -21,16 +21,15 @@
- Can replay log to reconstruct KV state - Can replay log to reconstruct KV state
- All operations survive restart - All operations survive restart
### Multi-KV Refactoring (before M2) ### Multi-KV Refactoring (before M2)
Current code assumes single store. Changes needed: - [x] DataDir → `stores/{uuid}/` subdirectories
- [ ] DataDir → support `stores/{uuid}/` subdirectories - [x] Store → per-store state.db
- [ ] SigChain → scoped to (store_id, author_id) - [x] Log paths → `stores/{uuid}/logs/{author}.log`
- [ ] Store → per-store state.db, not global - [x] Proto: Entry has store_id (UUID)
- [ ] Log paths → `stores/{uuid}/logs/{author}.log` - [x] CLI → `init`, `create-store`, `list-stores`, `use`
- [ ] Add global meta.db for stores table - [x] meta.db stores table (MetaStore)
- [ ] Proto: SignedEntry/messages need store_id (UUID) - [x] SigChain → validate entry.store_id
- [ ] CLI → `create-store`, `list-stores`, `use <store>`
--- ---
+39
View File
@@ -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<u8> | Key-value data |
| meta | String | Vec<u8> | 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`.
+211 -54
View File
@@ -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; use std::time::Instant;
/// Command handler function type /// Result of a command that may switch stores
pub type Handler = fn(&mut LatticeNode, &[String]); 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 struct Command {
pub name: &'static str, pub name: &'static str,
pub args: &'static str, pub args: &'static str,
@@ -16,9 +23,40 @@ pub struct Command {
pub handler: Handler, pub handler: Handler,
} }
/// Build the command registry
pub fn commands() -> Vec<Command> { pub fn commands() -> Vec<Command> {
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: "<uuid>",
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 { Command {
name: "put", name: "put",
args: "<key> <value>", args: "<key> <value>",
@@ -54,7 +92,7 @@ pub fn commands() -> Vec<Command> {
Command { Command {
name: "status", name: "status",
args: "", args: "",
description: "Show node statistics", description: "Show node/store info",
min_args: 0, min_args: 0,
max_args: 0, max_args: 0,
handler: cmd_status, handler: cmd_status,
@@ -70,83 +108,202 @@ pub fn commands() -> Vec<Command> {
] ]
} }
/// Print help from the command registry // --- Store management ---
fn cmd_help(_node: &mut LatticeNode, _args: &[String]) {
println!("\nLattice Commands:"); fn cmd_init(node: &LatticeNode, _store: Option<&StoreHandle>, _args: &[String]) -> CommandResult {
for cmd in commands() { match node.init() {
if cmd.args.is_empty() { Ok(store_id) => {
println!(" {:<18} {}", cmd.name, cmd.description); println!("Initialized with root store: {}", store_id);
} else { match node.open_store(store_id) {
println!(" {} {:<10} {}", cmd.name, cmd.args, cmd.description); 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]) { fn cmd_create_store(node: &LatticeNode, _store: Option<&StoreHandle>, _args: &[String]) -> CommandResult {
let status = node.status(); match node.create_store() {
println!("--- Node Status ---"); Ok(store_id) => {
println!("Node ID: {}", status.node_id); println!("Created store: {}", store_id);
println!("Data Dir: {}", status.data_dir); match node.open_store(store_id) {
println!("Log Sequence: {}", status.log_seq); Ok((handle, _)) => {
println!("Applied Entries: {}", status.applied_seq); println!("Switched to new store");
println!("-------------------"); CommandResult::SwitchTo(handle)
} }
Err(e) => {
fn cmd_put(node: &mut LatticeNode, args: &[String]) { eprintln!("Warning: {}", e);
let start = Instant::now(); CommandResult::Ok
match node.put(&args[0], args[1].as_bytes()) { }
Ok(seq) => println!("OK (seq: {}, time: {:.2?})", seq, start.elapsed()), }
Err(e) => eprintln!("Error: {}", e), }
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(); let start = Instant::now();
match node.get(&args[0]) { match node.open_store(store_id) {
Ok(Some(value)) => { Ok((handle, info)) => {
println!("{}", format_value(&value)); 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 <uuid>'");
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 <uuid>'");
return CommandResult::Ok;
};
let start = Instant::now();
match h.get(&args[0]) {
Ok(Some(v)) => {
println!("{}", format_value(&v));
println!("({:.2?})", start.elapsed()); println!("({:.2?})", start.elapsed());
} }
Ok(None) => println!("(nil)"), Ok(None) => println!("(nil)"),
Err(e) => eprintln!("Error: {}", e), 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 <uuid>'");
return CommandResult::Ok;
};
let start = Instant::now(); let start = Instant::now();
match node.delete(&args[0]) { match h.delete(&args[0]) {
Ok(seq) => println!("OK (seq: {}, time: {:.2?})", seq, start.elapsed()), Ok(seq) => println!("OK (seq: {}, {:.2?})", seq, start.elapsed()),
Err(e) => eprintln!("Error: {}", e), 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 <uuid>'");
return CommandResult::Ok;
};
let verbose = args.first().map(|a| a == "-v").unwrap_or(false); let verbose = args.first().map(|a| a == "-v").unwrap_or(false);
let start = Instant::now(); let start = Instant::now();
match node.list() { match h.list() {
Ok(entries) => { Ok(entries) => {
if entries.is_empty() { if entries.is_empty() {
println!("(empty)"); println!("(empty)");
return; } else {
} for (k, v) in &entries {
for (key, value) in &entries { if verbose {
if verbose { println!("{} = {} ({} bytes)", k, format_value(v), v.len());
println!("{} = {} ({} bytes)", key, format_value(value), value.len()); } else {
} else { println!("{} = {}", k, format_value(v));
println!("{} = {}", key, format_value(value)); }
} }
println!("({} keys, {:.2?})", entries.len(), start.elapsed());
} }
println!("({} keys, {:.2?})", entries.len(), start.elapsed());
} }
Err(e) => eprintln!("Error: {}", e), Err(e) => eprintln!("Error: {}", e),
} }
CommandResult::Ok
} }
fn format_value(value: &[u8]) -> String { fn format_value(v: &[u8]) -> String {
match std::str::from_utf8(value) { std::str::from_utf8(v).map(String::from).unwrap_or_else(|_| format!("0x{}", hex::encode(v)))
Ok(s) => s.to_string(),
Err(_) => format!("0x{}", hex::encode(value)),
}
} }
+54 -35
View File
@@ -3,7 +3,8 @@
mod node; mod node;
mod commands; mod commands;
use node::LatticeNodeBuilder; use commands::CommandResult;
use node::{LatticeNodeBuilder, StoreHandle};
use rustyline::error::ReadlineError; use rustyline::error::ReadlineError;
use rustyline::DefaultEditor; use rustyline::DefaultEditor;
@@ -11,35 +12,59 @@ fn main() {
println!("Lattice CLI v{}", env!("CARGO_PKG_VERSION")); println!("Lattice CLI v{}", env!("CARGO_PKG_VERSION"));
println!("Type 'help' for commands, 'quit' to exit.\n"); println!("Type 'help' for commands, 'quit' to exit.\n");
let mut node = match LatticeNodeBuilder::new().build() { let (node, info) = match LatticeNodeBuilder::new().build() {
Ok((n, info)) => { Ok(result) => result,
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
}
Err(e) => { Err(e) => {
eprintln!("Failed to initialize node: {}", e); eprintln!("Failed to initialize: {}", e);
eprintln!("Hint: If data is corrupted, remove the data directory and restart.");
return; 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<StoreHandle> = 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 mut rl = DefaultEditor::new().expect("Failed to create editor");
let cmds = commands::commands(); let cmds = commands::commands();
loop { loop {
match rl.readline("lattice> ") { let prompt = match &current_store {
Some(h) => format!("lattice:{}> ", &h.id().to_string()[..8]),
None => "lattice:no-store> ".to_string(),
};
match rl.readline(&prompt) {
Ok(line) => { Ok(line) => {
let line = line.trim(); let line = line.trim();
if line.is_empty() { if line.is_empty() { continue; }
continue;
}
let _ = rl.add_history_entry(line); let _ = rl.add_history_entry(line);
let args = match shlex::split(line) { let args = match shlex::split(line) {
@@ -50,40 +75,34 @@ fn main() {
} }
}; };
let cmd_name = match args.first() { let cmd_name = args.first().map(|s| s.as_str()).unwrap_or("");
Some(c) => c.as_str(),
None => continue,
};
// Handle quit specially
if cmd_name == "quit" || cmd_name == "exit" { if cmd_name == "quit" || cmd_name == "exit" {
println!("Goodbye!"); println!("Goodbye!");
break; break;
} }
// Look up command in registry
match cmds.iter().find(|c| c.name == cmd_name) { match cmds.iter().find(|c| c.name == cmd_name) {
Some(cmd) => { Some(cmd) => {
let cmd_args = &args[1..]; let cmd_args = &args[1..];
if cmd_args.len() < cmd.min_args || cmd_args.len() > cmd.max_args { 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);
println!("Usage: {} {}", cmd.name, cmd.args);
} else {
println!("Usage: {} {} (got {} args)", cmd.name, cmd.args, cmd_args.len());
}
} else { } 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) => { Err(ReadlineError::Interrupted | ReadlineError::Eof) => {
println!("Goodbye!"); println!("Goodbye!");
break; break;
} }
Err(err) => { Err(e) => {
eprintln!("Error: {:?}", err); eprintln!("Error: {:?}", e);
break; break;
} }
} }
+219 -138
View File
@@ -1,18 +1,18 @@
//! Lattice Node API //! Local Lattice node API with multi-store support
//!
//! A programmatic interface to a local Lattice node.
use lattice_core::{ use lattice_core::{
DataDir, EntryBuilder, Node, SigChain, Store, DataDir, EntryBuilder, MetaStore, Node, SigChain, Store, Uuid,
hlc::HLC, hlc::HLC,
log::LogError, log::LogError,
meta_store::MetaStoreError,
sigchain::SigChainError, sigchain::SigChainError,
store::StoreError, store::StoreError,
}; };
use std::path::Path; use std::path::Path;
use std::rc::Rc;
use std::cell::RefCell;
use thiserror::Error; use thiserror::Error;
/// Errors that can occur during node operations
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum NodeError { pub enum NodeError {
#[error("IO error: {0}")] #[error("IO error: {0}")]
@@ -21,6 +21,9 @@ pub enum NodeError {
#[error("Store error: {0}")] #[error("Store error: {0}")]
Store(#[from] StoreError), Store(#[from] StoreError),
#[error("MetaStore error: {0}")]
MetaStore(#[from] MetaStoreError),
#[error("SigChain error: {0}")] #[error("SigChain error: {0}")]
SigChain(#[from] SigChainError), SigChain(#[from] SigChainError),
@@ -29,43 +32,36 @@ pub enum NodeError {
#[error("Node error: {0}")] #[error("Node error: {0}")]
Node(#[from] lattice_core::node::NodeError), Node(#[from] lattice_core::node::NodeError),
#[error("Already initialized")]
AlreadyInitialized,
} }
/// Info returned when building a node
pub struct NodeInfo { pub struct NodeInfo {
pub node_id: String, pub node_id: String,
pub data_path: String, pub data_path: String,
pub is_new: bool, pub is_new: bool,
pub root_store: Option<Uuid>,
pub stores: Vec<Uuid>,
}
pub struct StoreInfo {
pub store_id: Uuid,
pub entries_replayed: u64, 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 { pub struct LatticeNodeBuilder {
data_dir: DataDir, pub data_dir: DataDir,
} }
impl LatticeNodeBuilder { impl LatticeNodeBuilder {
/// Create a builder with the default data directory
pub fn new() -> Self { pub fn new() -> Self {
Self { Self { data_dir: DataDir::default() }
data_dir: DataDir::default(),
}
} }
/// Build and initialize the node
pub fn build(self) -> Result<(LatticeNode, NodeInfo), NodeError> { pub fn build(self) -> Result<(LatticeNode, NodeInfo), NodeError> {
// Create directories
self.data_dir.ensure_dirs()?; self.data_dir.ensure_dirs()?;
// Load or create node identity
let key_path = self.data_dir.identity_key(); let key_path = self.data_dir.identity_key();
let is_new = !key_path.exists(); let is_new = !key_path.exists();
let node = if key_path.exists() { let node = if key_path.exists() {
@@ -76,113 +72,164 @@ impl LatticeNodeBuilder {
node node
}; };
let author_id_hex = hex::encode(node.public_key_bytes()); let meta = MetaStore::open(self.data_dir.meta_db())?;
let root_store = meta.root_store()?;
// Load or create sigchain let stores = meta.list_stores()?;
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 info = NodeInfo { let info = NodeInfo {
node_id: hex::encode(node.public_key_bytes()), node_id: hex::encode(node.public_key_bytes()),
data_path: self.data_dir.base().display().to_string(), data_path: self.data_dir.base().display().to_string(),
is_new, is_new,
entries_replayed, root_store,
stores,
}; };
Ok((LatticeNode { Ok((LatticeNode {
data_dir: self.data_dir, data_dir: self.data_dir,
node, node: Rc::new(node),
sigchain, meta,
store,
}, info)) }, info))
} }
} }
impl Default for LatticeNodeBuilder { impl Default for LatticeNodeBuilder {
fn default() -> Self { fn default() -> Self { Self::new() }
Self::new()
}
} }
/// A fully initialized Lattice node /// A local Lattice node (manages identity and store registry)
///
/// Use `LatticeNodeBuilder` to create an instance.
pub struct LatticeNode { pub struct LatticeNode {
data_dir: DataDir, data_dir: DataDir,
node: Node, node: Rc<Node>,
sigchain: SigChain, meta: MetaStore,
store: Store,
} }
impl LatticeNode { impl LatticeNode {
/// Get the node's public key as hex
pub fn node_id(&self) -> String { pub fn node_id(&self) -> String {
hex::encode(self.node.public_key_bytes()) hex::encode(self.node.public_key_bytes())
} }
/// Get the path to the data directory
pub fn data_path(&self) -> &Path { pub fn data_path(&self) -> &Path {
self.data_dir.base() self.data_dir.base()
} }
/// Get the current status of the node /// Get the root store ID
pub fn status(&self) -> NodeStatus { pub fn root_store(&self) -> Result<Option<Uuid>, NodeError> {
NodeStatus { Ok(self.meta.root_store()?)
node_id: self.node_id(), }
data_dir: self.data_dir.base().display().to_string(),
log_seq: self.sigchain.len(), /// Open the root store if set
applied_seq: self.store.last_seq().unwrap_or(0), pub fn open_root_store(&self) -> Result<Option<(StoreHandle, StoreInfo)>, NodeError> {
match self.meta.root_store()? {
Some(id) => Ok(Some(self.open_store(id)?)),
None => Ok(None),
} }
} }
/// Put a key-value pair /// Initialize the node with a root store (fails if already initialized)
pub fn put(&mut self, key: &str, value: &[u8]) -> Result<u64, NodeError> { pub fn init(&self) -> Result<Uuid, NodeError> {
let entry = EntryBuilder::new(self.sigchain.next_seq(), HLC::now()) if self.meta.root_store()?.is_some() {
.prev_hash(self.sigchain.last_hash().to_vec()) return Err(NodeError::AlreadyInitialized);
.put(key, value.to_vec()) }
.sign(&self.node); let store_id = self.create_store()?;
self.meta.set_root_store(store_id)?;
self.commit_entry(entry) Ok(store_id)
} }
/// Get a value by key pub fn list_stores(&self) -> Result<Vec<Uuid>, NodeError> {
Ok(self.meta.list_stores()?)
}
pub fn create_store(&self) -> Result<Uuid, NodeError> {
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<Node>,
sigchain: RefCell<SigChain>,
store: Store,
}
impl StoreHandle {
pub fn id(&self) -> Uuid { self.store_id }
pub fn get(&self, key: &str) -> Result<Option<Vec<u8>>, NodeError> { pub fn get(&self, key: &str) -> Result<Option<Vec<u8>>, NodeError> {
Ok(self.store.get(key)?) Ok(self.store.get(key)?)
} }
/// List all key-value pairs
pub fn list(&self) -> Result<Vec<(String, Vec<u8>)>, NodeError> { pub fn list(&self) -> Result<Vec<(String, Vec<u8>)>, NodeError> {
Ok(self.store.list_all()?) Ok(self.store.list_all()?)
} }
/// Delete a key pub fn log_seq(&self) -> u64 {
pub fn delete(&mut self, key: &str) -> Result<u64, NodeError> { self.sigchain.borrow().len()
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)
} }
/// Commit a signed entry: append to log via sigchain, then apply to store pub fn applied_seq(&self) -> Result<u64, NodeError> {
fn commit_entry(&mut self, entry: lattice_core::proto::SignedEntry) -> Result<u64, NodeError> { Ok(self.store.last_seq()?)
self.sigchain.append(&entry)?; }
self.store.apply_entry(&entry)?;
Ok(self.sigchain.len()) pub fn put(&self, key: &str, value: &[u8]) -> Result<u64, NodeError> {
self.commit_entry(|b| b.put(key, value.to_vec()))
}
pub fn delete(&self, key: &str) -> Result<u64, NodeError> {
self.commit_entry(|b| b.delete(key))
}
fn commit_entry<F>(&self, build: F) -> Result<u64, NodeError>
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 { fn temp_data_dir(name: &str) -> DataDir {
let path = temp_dir().join(format!("lattice_node_test_{}", name)); let path = temp_dir().join(format!("lattice_node_test_{}", name));
// Clean up from previous runs
let _ = std::fs::remove_dir_all(&path); let _ = std::fs::remove_dir_all(&path);
DataDir::new(path) DataDir::new(path)
} }
#[test] #[test]
fn test_put_survives_restart() { fn test_create_and_open_store() {
let data_dir = temp_data_dir("restart"); let data_dir = temp_data_dir("meta_store");
// First session: put a value let (node, info) = LatticeNodeBuilder { data_dir: data_dir.clone() }
{ .build()
let (mut node, _) = LatticeNodeBuilder { data_dir: data_dir.clone() } .expect("Failed to create node");
.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()));
}
// Second session: value should still be there assert!(info.stores.is_empty());
{
let (node, _) = LatticeNodeBuilder { data_dir: data_dir.clone() } let store_id = node.create_store().expect("Failed to create store");
.build()
.expect("Failed to create node on restart"); // Verify it's in the list
let stores = node.list_stores().expect("list failed");
assert_eq!(node.get("/test/key").unwrap(), Some(b"hello".to_vec())); assert!(stores.contains(&store_id));
assert_eq!(node.status().log_seq, 1);
} 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()); let _ = std::fs::remove_dir_all(data_dir.base());
} }
#[test] #[test]
fn test_log_replay_after_db_deletion() { fn test_store_isolation() {
let data_dir = temp_data_dir("replay"); let data_dir = temp_data_dir("meta_isolation");
// First session: put some values let (node, _) = LatticeNodeBuilder { data_dir: data_dir.clone() }
{ .build()
let (mut node, _) = LatticeNodeBuilder { data_dir: data_dir.clone() } .expect("Failed to create node");
.build()
.expect("Failed to create node"); let store_a = node.create_store().expect("create A");
let store_b = node.create_store().expect("create B");
node.put("/key1", b"value1").expect("put failed");
node.put("/key2", b"value2").expect("put failed"); let (handle_a, _) = node.open_store(store_a).expect("open A");
node.delete("/key1").expect("delete failed"); 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 _ = std::fs::remove_dir_all(data_dir.base());
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"); #[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 // First session: init
{ let root_id = {
let (node, info) = LatticeNodeBuilder { data_dir: data_dir.clone() } let (node, _) = LatticeNodeBuilder { data_dir: data_dir.clone() }
.build() .build()
.expect("Failed to rebuild node from log"); .expect("create node");
node.init().expect("init")
// Should have replayed 3 entries };
assert_eq!(info.entries_replayed, 3);
// key1 was deleted // Second session: root_store should be in info
assert_eq!(node.get("/key1").unwrap(), None); let (_, info) = LatticeNodeBuilder { data_dir: data_dir.clone() }
// key2 should still exist .build()
assert_eq!(node.get("/key2").unwrap(), Some(b"value2".to_vec())); .expect("reload node");
// log seq should be 3 (put, put, delete)
assert_eq!(node.status().log_seq, 3); assert_eq!(info.root_store, Some(root_id));
}
// Cleanup
let _ = std::fs::remove_dir_all(data_dir.base()); let _ = std::fs::remove_dir_all(data_dir.base());
} }
} }
+1
View File
@@ -15,6 +15,7 @@ dirs = { workspace = true }
blake3 = { workspace = true } blake3 = { workspace = true }
hex = { workspace = true } hex = { workspace = true }
redb = { workspace = true } redb = { workspace = true }
uuid = { workspace = true }
[build-dependencies] [build-dependencies]
prost-build = { workspace = true } prost-build = { workspace = true }
+55 -28
View File
@@ -2,19 +2,26 @@
//! //!
//! Provides platform-specific paths for Lattice data storage: //! Provides platform-specific paths for Lattice data storage:
//! - `identity.key` — Ed25519 private key //! - `identity.key` — Ed25519 private key
//! - `logs/` — Append-only log files per author //! - `meta.db` — Global metadata (stores table)
//! - `state.db` — KV snapshot and indexes //! - `stores/{uuid}/logs/{author}.log` — Per-store, per-author logs
//! - `stores/{uuid}/state.db` — Per-store KV state
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use uuid::Uuid;
const APP_NAME: &str = "lattice"; const APP_NAME: &str = "lattice";
/// Data directory configuration. /// Data directory configuration.
/// ///
/// Handles paths for: /// Multi-store layout:
/// - `identity.key` — node's private key /// ```text
/// - `logs/{author_id}.log` — per-author log files /// base/
/// - `state.db` — redb database /// identity.key
/// meta.db
/// stores/{uuid}/
/// logs/{author}.log
/// state.db
/// ```
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct DataDir { pub struct DataDir {
base: PathBuf, base: PathBuf,
@@ -27,10 +34,6 @@ impl DataDir {
} }
/// Create a DataDir using the platform-specific data directory. /// Create a DataDir using the platform-specific data directory.
///
/// - Linux: `~/.local/share/lattice/`
/// - macOS: `~/Library/Application Support/lattice/`
/// - Windows: `C:\Users\<user>\AppData\Roaming\lattice\`
pub fn default_location() -> Option<Self> { pub fn default_location() -> Option<Self> {
dirs::data_dir().map(|d| Self::new(d.join(APP_NAME))) dirs::data_dir().map(|d| Self::new(d.join(APP_NAME)))
} }
@@ -45,25 +48,47 @@ impl DataDir {
self.base.join("identity.key") self.base.join("identity.key")
} }
/// Get the path to the logs directory. /// Get the path to the global metadata database.
pub fn logs_dir(&self) -> PathBuf { pub fn meta_db(&self) -> PathBuf {
self.base.join("logs") self.base.join("meta.db")
} }
/// Get the path to a specific author's log file. /// Get the path to the stores directory.
pub fn log_file(&self, author_id_hex: &str) -> PathBuf { pub fn stores_dir(&self) -> PathBuf {
self.logs_dir().join(format!("{}.log", author_id_hex)) self.base.join("stores")
} }
/// Get the path to the state database. /// Get the path to a specific store's directory.
pub fn state_db(&self) -> PathBuf { pub fn store_dir(&self, store_id: Uuid) -> PathBuf {
self.base.join("state.db") 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<()> { pub fn ensure_dirs(&self) -> std::io::Result<()> {
std::fs::create_dir_all(&self.base)?; 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(()) Ok(())
} }
} }
@@ -83,29 +108,31 @@ mod tests {
let dd = DataDir::new("/custom/path"); let dd = DataDir::new("/custom/path");
assert_eq!(dd.base(), Path::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.identity_key(), PathBuf::from("/custom/path/identity.key"));
assert_eq!(dd.logs_dir(), PathBuf::from("/custom/path/logs")); assert_eq!(dd.meta_db(), PathBuf::from("/custom/path/meta.db"));
assert_eq!(dd.state_db(), PathBuf::from("/custom/path/state.db")); assert_eq!(dd.stores_dir(), PathBuf::from("/custom/path/stores"));
} }
#[test] #[test]
fn test_log_file_path() { fn test_store_paths() {
let dd = DataDir::new("/data"); let dd = DataDir::new("/data");
let path = dd.log_file("abc123"); let store_id = Uuid::parse_str("a1b2c3d4-e5f6-7890-abcd-ef1234567890").unwrap();
assert_eq!(path, PathBuf::from("/data/logs/abc123.log"));
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] #[test]
fn test_default_location_exists() { fn test_default_location_exists() {
// On most systems, default_location should return Some // On most systems, default_location should return Some
let location = DataDir::default_location(); let location = DataDir::default_location();
// Just verify it doesn't panic - actual path varies by platform
assert!(location.is_some() || true); assert!(location.is_some() || true);
} }
#[test] #[test]
fn test_default_impl() { fn test_default_impl() {
let dd = DataDir::default(); let dd = DataDir::default();
// Should either be platform default or ./data fallback
assert!(dd.base().to_str().is_some()); assert!(dd.base().to_str().is_some());
} }
} }
+3
View File
@@ -24,6 +24,7 @@ pub mod data_dir;
pub mod signed_entry; pub mod signed_entry;
pub mod log; pub mod log;
pub mod store; pub mod store;
pub mod meta_store;
// Constants // Constants
/// Maximum size of a serialized SignedEntry (16 MB) /// 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 signed_entry::{EntryBuilder, sign_entry, verify_signed_entry, hash_signed_entry};
pub use log::{append_entry, read_entries, LogReader}; pub use log::{append_entry, read_entries, LogReader};
pub use store::Store; pub use store::Store;
pub use meta_store::MetaStore;
pub use uuid::Uuid;
+154
View File
@@ -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<Path>) -> Result<Self, MetaStoreError> {
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<Vec<Uuid>, 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<Option<Uuid>, 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);
}
}
+1
View File
@@ -31,6 +31,7 @@ mod tests {
fn test_entry_with_ops() { fn test_entry_with_ops() {
let entry = Entry { let entry = Entry {
version: 1, version: 1,
store_id: vec![1u8; 16],
prev_hash: vec![0u8; 32], prev_hash: vec![0u8; 32],
seq: 5, seq: 5,
timestamp: Some(Hlc { timestamp: Some(Hlc {
+88 -14
View File
@@ -23,6 +23,9 @@ pub enum SigChainError {
#[error("Wrong author: expected {expected}, got {got}")] #[error("Wrong author: expected {expected}, got {got}")]
WrongAuthor { expected: String, got: String }, 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}")] #[error("Invalid sequence: expected {expected}, got {got}")]
InvalidSequence { expected: u64, got: u64 }, InvalidSequence { expected: u64, got: u64 },
@@ -34,11 +37,14 @@ pub enum SigChainError {
} }
/// An append-only log where each entry is cryptographically signed /// 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 { pub struct SigChain {
/// Path to the log file /// Path to the log file
log_path: PathBuf, log_path: PathBuf,
/// Store UUID (16 bytes)
store_id: [u8; 16],
/// Author's public key (32 bytes) /// Author's public key (32 bytes)
author_id: [u8; 32], author_id: [u8; 32],
@@ -50,10 +56,11 @@ pub struct SigChain {
} }
impl SigChain { impl SigChain {
/// Create a new empty sigchain for an author /// Create a new empty sigchain for a (store, author) pair
pub fn new(log_path: impl AsRef<Path>, author_id: [u8; 32]) -> Self { pub fn new(log_path: impl AsRef<Path>, store_id: [u8; 16], author_id: [u8; 32]) -> Self {
Self { Self {
log_path: log_path.as_ref().to_path_buf(), log_path: log_path.as_ref().to_path_buf(),
store_id,
author_id, author_id,
next_seq: 1, next_seq: 1,
last_hash: [0u8; 32], last_hash: [0u8; 32],
@@ -61,11 +68,11 @@ impl SigChain {
} }
/// Load a sigchain from an existing log file /// Load a sigchain from an existing log file
pub fn from_log(log_path: impl AsRef<Path>, author_id: [u8; 32]) -> Result<Self, SigChainError> { pub fn from_log(log_path: impl AsRef<Path>, store_id: [u8; 16], author_id: [u8; 32]) -> Result<Self, SigChainError> {
let log_path = log_path.as_ref().to_path_buf(); let log_path = log_path.as_ref().to_path_buf();
let entries = read_entries(&log_path)?; 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 { for signed_entry in entries {
// Verify signature // Verify signature
@@ -85,6 +92,18 @@ impl SigChain {
// Decode Entry // Decode Entry
let entry = Entry::decode(&signed_entry.entry_bytes[..])?; 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 // Validate sequence
if entry.seq != chain.next_seq { if entry.seq != chain.next_seq {
return Err(SigChainError::InvalidSequence { return Err(SigChainError::InvalidSequence {
@@ -156,6 +175,18 @@ impl SigChain {
// Decode entry // Decode entry
let entry = Entry::decode(&signed_entry.entry_bytes[..])?; 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 // Validate sequence
if entry.seq != self.next_seq { if entry.seq != self.next_seq {
return Err(SigChainError::InvalidSequence { return Err(SigChainError::InvalidSequence {
@@ -201,6 +232,7 @@ impl SigChain {
let hlc = HLC::now_with_clock(&SystemClock); let hlc = HLC::now_with_clock(&SystemClock);
let mut builder = EntryBuilder::new(self.next_seq, hlc) let mut builder = EntryBuilder::new(self.next_seq, hlc)
.store_id(self.store_id.to_vec())
.prev_hash(self.last_hash.to_vec()); .prev_hash(self.last_hash.to_vec());
// Add operations // Add operations
@@ -230,12 +262,14 @@ mod tests {
temp_dir().join(format!("lattice_sigchain_test_{}.log", name)) temp_dir().join(format!("lattice_sigchain_test_{}.log", name))
} }
const TEST_STORE: [u8; 16] = [1u8; 16];
#[test] #[test]
fn test_new_sigchain() { fn test_new_sigchain() {
let path = temp_log_path("new"); let path = temp_log_path("new");
let author = [1u8; 32]; 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.author_id(), &author);
assert_eq!(chain.next_seq(), 1); assert_eq!(chain.next_seq(), 1);
@@ -251,10 +285,11 @@ mod tests {
let node = Node::generate(); let node = Node::generate();
let author = node.public_key_bytes(); 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 clock = MockClock::new(1000);
let entry = EntryBuilder::new(1, HLC::now_with_clock(&clock)) let entry = EntryBuilder::new(1, HLC::now_with_clock(&clock))
.store_id(TEST_STORE.to_vec())
.prev_hash([0u8; 32].to_vec()) .prev_hash([0u8; 32].to_vec())
.put("/key", b"value".to_vec()) .put("/key", b"value".to_vec())
.sign(&node); .sign(&node);
@@ -275,11 +310,12 @@ mod tests {
let node = Node::generate(); let node = Node::generate();
let author = node.public_key_bytes(); 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 clock = MockClock::new(1000);
for i in 1..=3 { for i in 1..=3 {
let entry = EntryBuilder::new(i, HLC::now_with_clock(&clock)) let entry = EntryBuilder::new(i, HLC::now_with_clock(&clock))
.store_id(TEST_STORE.to_vec())
.prev_hash(chain.last_hash.to_vec()) .prev_hash(chain.last_hash.to_vec())
.put(format!("/key/{}", i), format!("value{}", i).into_bytes()) .put(format!("/key/{}", i), format!("value{}", i).into_bytes())
.sign(&node); .sign(&node);
@@ -303,9 +339,10 @@ mod tests {
// Write some entries // Write some entries
{ {
let mut chain = SigChain::new(&path, author); let mut chain = SigChain::new(&path, TEST_STORE, author);
for i in 1..=3 { for i in 1..=3 {
let entry = EntryBuilder::new(i, HLC::now_with_clock(&clock)) let entry = EntryBuilder::new(i, HLC::now_with_clock(&clock))
.store_id(TEST_STORE.to_vec())
.prev_hash(chain.last_hash.to_vec()) .prev_hash(chain.last_hash.to_vec())
.put("/key", b"val".to_vec()) .put("/key", b"val".to_vec())
.sign(&node); .sign(&node);
@@ -314,7 +351,7 @@ mod tests {
} }
// Reload from log // 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.len(), 3);
assert_eq!(chain.next_seq(), 4); assert_eq!(chain.next_seq(), 4);
@@ -329,11 +366,12 @@ mod tests {
let node = Node::generate(); let node = Node::generate();
let author = node.public_key_bytes(); 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 clock = MockClock::new(1000);
// Try to append with wrong seq (2 instead of 1) // Try to append with wrong seq (2 instead of 1)
let entry = EntryBuilder::new(2, HLC::now_with_clock(&clock)) let entry = EntryBuilder::new(2, HLC::now_with_clock(&clock))
.store_id(TEST_STORE.to_vec())
.prev_hash([0u8; 32].to_vec()) .prev_hash([0u8; 32].to_vec())
.put("/key", b"val".to_vec()) .put("/key", b"val".to_vec())
.sign(&node); .sign(&node);
@@ -352,11 +390,12 @@ mod tests {
let node = Node::generate(); let node = Node::generate();
let author = node.public_key_bytes(); 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 clock = MockClock::new(1000);
// First entry // First entry
let entry1 = EntryBuilder::new(1, HLC::now_with_clock(&clock)) let entry1 = EntryBuilder::new(1, HLC::now_with_clock(&clock))
.store_id(TEST_STORE.to_vec())
.prev_hash([0u8; 32].to_vec()) .prev_hash([0u8; 32].to_vec())
.put("/key", b"v1".to_vec()) .put("/key", b"v1".to_vec())
.sign(&node); .sign(&node);
@@ -364,6 +403,7 @@ mod tests {
// Second entry with wrong prev_hash // Second entry with wrong prev_hash
let entry2 = EntryBuilder::new(2, HLC::now_with_clock(&clock)) let entry2 = EntryBuilder::new(2, HLC::now_with_clock(&clock))
.store_id(TEST_STORE.to_vec())
.prev_hash([99u8; 32].to_vec()) // Wrong! .prev_hash([99u8; 32].to_vec()) // Wrong!
.put("/key", b"v2".to_vec()) .put("/key", b"v2".to_vec())
.sign(&node); .sign(&node);
@@ -382,11 +422,12 @@ mod tests {
let node = Node::generate(); let node = Node::generate();
let other_author = [99u8; 32]; // Different author 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); let clock = MockClock::new(1000);
// Entry signed by node but chain expects other_author // Entry signed by node but chain expects other_author
let entry = EntryBuilder::new(1, HLC::now_with_clock(&clock)) let entry = EntryBuilder::new(1, HLC::now_with_clock(&clock))
.store_id(TEST_STORE.to_vec())
.prev_hash([0u8; 32].to_vec()) .prev_hash([0u8; 32].to_vec())
.put("/key", b"val".to_vec()) .put("/key", b"val".to_vec())
.sign(&node); .sign(&node);
@@ -405,7 +446,7 @@ mod tests {
let node = Node::generate(); let node = Node::generate();
let author = node.public_key_bytes(); 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![ let ops = vec![
Operation { Operation {
@@ -427,4 +468,37 @@ mod tests {
std::fs::remove_file(&path).ok(); 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();
}
} }
+9
View File
@@ -32,6 +32,7 @@ pub enum EntryError {
/// Builder for creating Entry messages /// Builder for creating Entry messages
pub struct EntryBuilder { pub struct EntryBuilder {
version: u32, version: u32,
store_id: Vec<u8>,
prev_hash: Vec<u8>, prev_hash: Vec<u8>,
seq: u64, seq: u64,
timestamp: HLC, timestamp: HLC,
@@ -43,6 +44,7 @@ impl EntryBuilder {
pub fn new(seq: u64, timestamp: HLC) -> Self { pub fn new(seq: u64, timestamp: HLC) -> Self {
Self { Self {
version: 1, version: 1,
store_id: Vec::new(), // Empty = legacy single-store
prev_hash: vec![0u8; 32], // Genesis or will be set prev_hash: vec![0u8; 32], // Genesis or will be set
seq, seq,
timestamp, timestamp,
@@ -50,6 +52,12 @@ impl EntryBuilder {
} }
} }
/// Set the store ID (16-byte UUID)
pub fn store_id(mut self, id: impl Into<Vec<u8>>) -> Self {
self.store_id = id.into();
self
}
/// Set the previous entry hash (for chaining) /// Set the previous entry hash (for chaining)
pub fn prev_hash(mut self, hash: impl Into<Vec<u8>>) -> Self { pub fn prev_hash(mut self, hash: impl Into<Vec<u8>>) -> Self {
self.prev_hash = hash.into(); self.prev_hash = hash.into();
@@ -87,6 +95,7 @@ impl EntryBuilder {
pub fn build(self) -> Entry { pub fn build(self) -> Entry {
Entry { Entry {
version: self.version, version: self.version,
store_id: self.store_id,
prev_hash: self.prev_hash, prev_hash: self.prev_hash,
seq: self.seq, seq: self.seq,
timestamp: Some(Hlc { timestamp: Some(Hlc {
+3
View File
@@ -20,6 +20,9 @@ message Entry {
// Versioning allows us to change the format radically later if needed // Versioning allows us to change the format radically later if needed
uint32 version = 1; uint32 version = 1;
// Store this entry belongs to (16-byte UUID)
bytes store_id = 6;
// Ordering Metadata // Ordering Metadata
bytes prev_hash = 2; // Link to previous entry (32 bytes) bytes prev_hash = 2; // Link to previous entry (32 bytes)
uint64 seq = 3; // Monotonic sequence number uint64 seq = 3; // Monotonic sequence number