feat: introduce global meta store and root store concept, and update CLI to manage active store
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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\<user>\AppData\Roaming\lattice\`
|
||||
pub fn default_location() -> Option<Self> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Path>, author_id: [u8; 32]) -> Self {
|
||||
/// Create a new empty sigchain for a (store, author) pair
|
||||
pub fn new(log_path: impl AsRef<Path>, 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<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 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ pub enum EntryError {
|
||||
/// Builder for creating Entry messages
|
||||
pub struct EntryBuilder {
|
||||
version: u32,
|
||||
store_id: Vec<u8>,
|
||||
prev_hash: Vec<u8>,
|
||||
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<Vec<u8>>) -> Self {
|
||||
self.store_id = id.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the previous entry hash (for chaining)
|
||||
pub fn prev_hash(mut self, hash: impl Into<Vec<u8>>) -> 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 {
|
||||
|
||||
Reference in New Issue
Block a user