// spec, раздел "Хранение" + "Fast Sync". // Persistence layer для state + proposal archive. // Minimal filesystem-backed store (без RocksDB/sled — pure std::fs + fixed-size records). use mt_consensus::{ProposalHeader, PROPOSAL_HEADER_SIZE}; use mt_crypto::{Signature, PUBLIC_KEY_SIZE, SIGNATURE_SIZE}; use mt_state::{ AccountRecord, AccountTable, CandidatePool, CandidateRecord, NodeRecord, NodeTable, ACCOUNT_RECORD_SIZE, CANDIDATE_RECORD_SIZE, NODE_RECORD_SIZE, }; use std::fs; use std::io; use std::path::{Path, PathBuf}; // ============ Phase A: FsStore — filesystem-backed KV ============ #[derive(Debug)] pub struct FsStore { root: PathBuf, } #[derive(Debug)] pub enum StoreError { Io(io::Error), CorruptedLength(String), ParseFailed(String), NotFound(String), } impl From for StoreError { fn from(e: io::Error) -> Self { StoreError::Io(e) } } impl FsStore { pub fn open>(path: P) -> Result { let root = path.as_ref().to_path_buf(); fs::create_dir_all(&root)?; let proposals_dir = root.join("proposals"); fs::create_dir_all(&proposals_dir)?; // M5-LOW-8 closure: cleanup orphaned `.tmp` файлов от прошлого // crashed write_atomic (process killed между fs::write tmp и // fs::rename → tmp file остаётся на диске). Multiple crashes без // cleanup → накопление tmp в root + proposals/. // Tmp файлы не influence load_* (load ищет по точному имени без // .tmp suffix), но это storage waste. Cleanup на open() — ленивая // recovery без impact на normal write path. Self::cleanup_orphan_tmp(&root); Self::cleanup_orphan_tmp(&proposals_dir); Ok(Self { root }) } fn cleanup_orphan_tmp(dir: &Path) { // Best-effort: pri ошибке read_dir / fs::remove_file просто skip // (не failure для open — tmp cleanup advisory, не load-bearing). if let Ok(entries) = fs::read_dir(dir) { for entry in entries.flatten() { let path = entry.path(); if path.is_file() && path .extension() .and_then(|e| e.to_str()) .map(|e| e == "tmp") .unwrap_or(false) { let _ = fs::remove_file(&path); } } } } pub fn root(&self) -> &Path { &self.root } fn path(&self, name: &str) -> PathBuf { self.root.join(name) } fn proposal_path(&self, window: u64) -> PathBuf { self.root .join("proposals") .join(format!("{:020}.bin", window)) } // R5 atomic rename pattern: writes go в `.tmp` ДО fs::rename // на final path. POSIX `rename(2)` atomic per single filesystem — // observers видят либо old либо new content, не partial. Защита от // power-loss / SIGKILL mid-write либо disk-full corruption. // // Для full crash-safety узел дополнительно использует fsync (в M6 // operator layer); rename atomicity достаточна для filesystem-level // consistency без user-space syncing. fn write_atomic(&self, name: &str, data: &[u8]) -> Result<(), StoreError> { let final_path = self.path(name); let tmp_path = self.path(&format!("{name}.tmp")); fs::write(&tmp_path, data)?; fs::rename(&tmp_path, &final_path)?; Ok(()) } fn write_atomic_to(&self, final_path: &Path, data: &[u8]) -> Result<(), StoreError> { let parent = final_path .parent() .ok_or_else(|| StoreError::ParseFailed("path has no parent".into()))?; let file_name = final_path .file_name() .ok_or_else(|| StoreError::ParseFailed("path has no file name".into()))?; let tmp_path = parent.join(format!("{}.tmp", file_name.to_string_lossy())); fs::write(&tmp_path, data)?; fs::rename(&tmp_path, final_path)?; Ok(()) } } // ============ Phase B: Table persistence ============ // AccountRecord fixed-size decode (inverse CanonicalEncode). // Layout (ACCOUNT_RECORD_SIZE = 2059 B под ML-DSA-65) — см. spec раздел // "Account — содержимое блока": // account_id 32 + balance 16 + suite_id 2 + is_node_operator 1 + frontier_hash 32 // + op_height 4 + account_chain_length 4 + account_chain_length_snapshot 4 // + current_pubkey 1952 + creation_window 4 + last_op_window 4 // + last_activation_window 4 fn read_32_at(bytes: &[u8], off: usize) -> [u8; 32] { let mut h = [0u8; 32]; h.copy_from_slice(&bytes[off..off + 32]); h } fn read_u16_at(bytes: &[u8], off: usize) -> u16 { let mut b = [0u8; 2]; b.copy_from_slice(&bytes[off..off + 2]); u16::from_le_bytes(b) } fn read_u32_at(bytes: &[u8], off: usize) -> u32 { let mut b = [0u8; 4]; b.copy_from_slice(&bytes[off..off + 4]); u32::from_le_bytes(b) } fn read_u64_at(bytes: &[u8], off: usize) -> u64 { let mut b = [0u8; 8]; b.copy_from_slice(&bytes[off..off + 8]); u64::from_le_bytes(b) } fn read_u128_at(bytes: &[u8], off: usize) -> u128 { let mut b = [0u8; 16]; b.copy_from_slice(&bytes[off..off + 16]); u128::from_le_bytes(b) } fn read_pubkey_at(bytes: &[u8], off: usize) -> [u8; PUBLIC_KEY_SIZE] { let mut pk = [0u8; PUBLIC_KEY_SIZE]; pk.copy_from_slice(&bytes[off..off + PUBLIC_KEY_SIZE]); pk } fn decode_account_record(bytes: &[u8]) -> Result { if bytes.len() != ACCOUNT_RECORD_SIZE { return Err(StoreError::CorruptedLength(format!( "AccountRecord expect {ACCOUNT_RECORD_SIZE}, got {}", bytes.len() ))); } let account_id = read_32_at(bytes, 0); let balance = read_u128_at(bytes, 32); let suite_id = read_u16_at(bytes, 48); let is_node_operator = bytes[50] != 0; let frontier_hash = read_32_at(bytes, 51); let op_height = read_u32_at(bytes, 83); let account_chain_length = read_u32_at(bytes, 87); let account_chain_length_snapshot = read_u32_at(bytes, 91); let current_pubkey = read_pubkey_at(bytes, 95); let creation_window = read_u32_at(bytes, 95 + PUBLIC_KEY_SIZE); let last_op_window = read_u32_at(bytes, 99 + PUBLIC_KEY_SIZE); let last_activation_window = read_u32_at(bytes, 103 + PUBLIC_KEY_SIZE); Ok(AccountRecord { account_id, balance, suite_id, is_node_operator, frontier_hash, op_height, account_chain_length, account_chain_length_snapshot, current_pubkey, creation_window, last_op_window, last_activation_window, }) } // NodeRecord fixed-size decode (NODE_RECORD_SIZE = 2098 B под ML-DSA-65): // node_id 32 + node_pubkey 1952 + suite_id 2 + operator_account_id 32 // + start_window 8 + chain_length 8 + chain_length_snapshot 8 // + chain_length_checkpoints [u64;6] = 48 + last_confirmation_window 8 fn decode_node_record(bytes: &[u8]) -> Result { if bytes.len() != NODE_RECORD_SIZE { return Err(StoreError::CorruptedLength(format!( "NodeRecord expect {NODE_RECORD_SIZE}, got {}", bytes.len() ))); } // Offsets: 32 + 1952 + 2 + 32 + 8 + 8 + 8 + 48 (6×u64) + 8 = 2098 let node_id = read_32_at(bytes, 0); let node_pubkey = read_pubkey_at(bytes, 32); let suite_id = read_u16_at(bytes, 32 + PUBLIC_KEY_SIZE); let operator_account_id = read_32_at(bytes, 34 + PUBLIC_KEY_SIZE); let base = 66 + PUBLIC_KEY_SIZE; let start_window = read_u64_at(bytes, base); let chain_length = read_u64_at(bytes, base + 8); let chain_length_snapshot = read_u64_at(bytes, base + 16); let mut chain_length_checkpoints = [0u64; 6]; for (i, cp) in chain_length_checkpoints.iter_mut().enumerate() { *cp = read_u64_at(bytes, base + 24 + i * 8); } let last_confirmation_window = read_u64_at(bytes, base + 24 + 48); Ok(NodeRecord { node_id, node_pubkey, suite_id, operator_account_id, start_window, chain_length, chain_length_snapshot, chain_length_checkpoints, last_confirmation_window, }) } // CandidateRecord fixed-size decode (CANDIDATE_RECORD_SIZE = 2082 B под ML-DSA-65): // node_id 32 + node_pubkey 1952 + suite_id 2 + operator_account_id 32 // + proof_endpoint 32 + w_start 8 + vdf_chain_length 8 // + registration_window 8 + expires 8 fn decode_candidate_record(bytes: &[u8]) -> Result { if bytes.len() != CANDIDATE_RECORD_SIZE { return Err(StoreError::CorruptedLength(format!( "CandidateRecord expect {CANDIDATE_RECORD_SIZE}, got {}", bytes.len() ))); } // Offsets: 32 + 1952 + 2 + 32 + 32 + 8 + 8 + 8 + 8 = 2082 let node_id = read_32_at(bytes, 0); let node_pubkey = read_pubkey_at(bytes, 32); let suite_id = read_u16_at(bytes, 32 + PUBLIC_KEY_SIZE); let operator_account_id = read_32_at(bytes, 34 + PUBLIC_KEY_SIZE); let proof_endpoint = read_32_at(bytes, 66 + PUBLIC_KEY_SIZE); let base = 98 + PUBLIC_KEY_SIZE; let w_start = read_u64_at(bytes, base); let vdf_chain_length = read_u64_at(bytes, base + 8); let registration_window = read_u64_at(bytes, base + 16); let expires = read_u64_at(bytes, base + 24); Ok(CandidateRecord { node_id, node_pubkey, suite_id, operator_account_id, proof_endpoint, w_start, vdf_chain_length, registration_window, expires, }) } impl FsStore { pub fn save_account_table(&self, table: &AccountTable) -> Result<(), StoreError> { use mt_codec::CanonicalEncode; let mut buf = Vec::with_capacity(table.len() * ACCOUNT_RECORD_SIZE); for rec in table.iter() { rec.encode(&mut buf); } self.write_atomic("accounts.bin", &buf)?; Ok(()) } pub fn load_account_table(&self) -> Result { let path = self.path("accounts.bin"); if !path.exists() { return Ok(AccountTable::new()); } let bytes = fs::read(&path)?; if bytes.len() % ACCOUNT_RECORD_SIZE != 0 { return Err(StoreError::CorruptedLength(format!( "accounts.bin length {} не кратна {ACCOUNT_RECORD_SIZE}", bytes.len() ))); } let mut table = AccountTable::new(); for chunk in bytes.chunks_exact(ACCOUNT_RECORD_SIZE) { let rec = decode_account_record(chunk)?; table.insert(rec); } Ok(table) } pub fn save_node_table(&self, table: &NodeTable) -> Result<(), StoreError> { use mt_codec::CanonicalEncode; let mut buf = Vec::with_capacity(table.len() * NODE_RECORD_SIZE); for rec in table.iter() { rec.encode(&mut buf); } self.write_atomic("nodes.bin", &buf)?; Ok(()) } pub fn load_node_table(&self) -> Result { let path = self.path("nodes.bin"); if !path.exists() { return Ok(NodeTable::new()); } let bytes = fs::read(&path)?; if bytes.len() % NODE_RECORD_SIZE != 0 { return Err(StoreError::CorruptedLength(format!( "nodes.bin length {} не кратна {NODE_RECORD_SIZE}", bytes.len() ))); } let mut table = NodeTable::new(); for chunk in bytes.chunks_exact(NODE_RECORD_SIZE) { let rec = decode_node_record(chunk)?; table.insert(rec); } Ok(table) } pub fn save_candidate_pool(&self, pool: &CandidatePool) -> Result<(), StoreError> { use mt_codec::CanonicalEncode; let mut buf = Vec::with_capacity(pool.len() * CANDIDATE_RECORD_SIZE); for rec in pool.iter() { rec.encode(&mut buf); } self.write_atomic("candidates.bin", &buf)?; Ok(()) } pub fn load_candidate_pool(&self) -> Result { let path = self.path("candidates.bin"); if !path.exists() { return Ok(CandidatePool::new()); } let bytes = fs::read(&path)?; if bytes.len() % CANDIDATE_RECORD_SIZE != 0 { return Err(StoreError::CorruptedLength(format!( "candidates.bin length {} не кратна {CANDIDATE_RECORD_SIZE}", bytes.len() ))); } let mut pool = CandidatePool::new(); for chunk in bytes.chunks_exact(CANDIDATE_RECORD_SIZE) { let rec = decode_candidate_record(chunk)?; pool.insert(rec); } Ok(pool) } } // ============ Phase C: Proposal archive ============ fn decode_proposal_header(bytes: &[u8]) -> Result { if bytes.len() != PROPOSAL_HEADER_SIZE { return Err(StoreError::CorruptedLength(format!( "ProposalHeader expect {PROPOSAL_HEADER_SIZE}, got {}", bytes.len() ))); } // Offsets per spec v31.0.0 (winner_class byte удалён; header 3722 B // под ML-DSA-65; signed-scope без signature = 413 B; structural offsets // 0..413 без изменений): // 0..32 prev_proposal_hash, 32..40 window_index (u64), // 40..44 protocol_version (u32), 44..76 control_root, 76..108 node_root, // 108..140 candidate_root, 140..172 account_root, 172..204 state_root, // 204..236 timechain_value, 236..268 included_bundles_root, // 268..300 included_reveals_root, // 300..332 winner_endpoint, 332..364 winner_id, 364..396 proposer_node_id, // 396..412 target (u128), 412 fallback_depth (u8), // 413..3722 signature (3309B ML-DSA-65) let prev_proposal_hash = read_32_at(bytes, 0); let window_index = read_u64_at(bytes, 32); let protocol_version = read_u32_at(bytes, 40); let control_root = read_32_at(bytes, 44); let node_root = read_32_at(bytes, 76); let candidate_root = read_32_at(bytes, 108); let account_root = read_32_at(bytes, 140); let state_root = read_32_at(bytes, 172); let timechain_value = read_32_at(bytes, 204); let included_bundles_root = read_32_at(bytes, 236); let included_reveals_root = read_32_at(bytes, 268); let winner_endpoint = read_32_at(bytes, 300); let winner_id = read_32_at(bytes, 332); let proposer_node_id = read_32_at(bytes, 364); let target = read_u128_at(bytes, 396); let fallback_depth = bytes[412]; let mut sig_bytes = [0u8; SIGNATURE_SIZE]; sig_bytes.copy_from_slice(&bytes[413..413 + SIGNATURE_SIZE]); let signature = Signature::from_array(sig_bytes); Ok(ProposalHeader { prev_proposal_hash, window_index, protocol_version, control_root, node_root, candidate_root, account_root, state_root, timechain_value, included_bundles_root, included_reveals_root, winner_endpoint, winner_id, proposer_node_id, target, fallback_depth, signature, }) } impl FsStore { pub fn archive_proposal(&self, header: &ProposalHeader) -> Result<(), StoreError> { use mt_codec::CanonicalEncode; let mut buf = Vec::with_capacity(PROPOSAL_HEADER_SIZE); header.encode(&mut buf); self.write_atomic_to(&self.proposal_path(header.window_index), &buf)?; Ok(()) } pub fn get_proposal_by_window( &self, window: u64, ) -> Result, StoreError> { let path = self.proposal_path(window); if !path.exists() { return Ok(None); } let bytes = fs::read(&path)?; Ok(Some(decode_proposal_header(&bytes)?)) } } // ============ Phase D: Crash recovery (meta last_cemented_window) ============ impl FsStore { pub fn save_meta_last_cemented(&self, window: u64) -> Result<(), StoreError> { self.write_atomic("meta_last_cemented.bin", &window.to_le_bytes())?; Ok(()) } pub fn load_meta_last_cemented(&self) -> Result, StoreError> { let path = self.path("meta_last_cemented.bin"); if !path.exists() { return Ok(None); } let bytes = fs::read(&path)?; if bytes.len() != 8 { return Err(StoreError::CorruptedLength(format!( "meta_last_cemented.bin expect 8, got {}", bytes.len() ))); } let mut b = [0u8; 8]; b.copy_from_slice(&bytes); Ok(Some(u64::from_le_bytes(b))) } // Restart consistency: meta — последний целостный commit. При reopen // проверяем что proposal (meta.last_cemented_window) существует в archive. // Иначе: crash between commit и meta write → fallback на предыдущий. pub fn verify_consistency(&self) -> Result { let last = self.load_meta_last_cemented()?.unwrap_or(0); // Проверка: proposal_{last} существует (если > 0) if last > 0 && self.get_proposal_by_window(last)?.is_none() { return Err(StoreError::NotFound(format!( "meta_last_cemented = {last}, но proposals/{:020}.bin отсутствует", last ))); } Ok(last) } } // ============ Phase E: Pruning ============ impl FsStore { // Удалить proposals с window_index < threshold. // Возвращает Vec удалённых window indices. pub fn prune_proposals_before(&self, threshold: u64) -> Result, StoreError> { let proposals_dir = self.root.join("proposals"); let mut removed = Vec::new(); if !proposals_dir.exists() { return Ok(removed); } for entry in fs::read_dir(&proposals_dir)? { let entry = entry?; let file_name = entry.file_name(); let name = file_name.to_string_lossy(); if !name.ends_with(".bin") { continue; } let stem = &name[..name.len() - 4]; let window: u64 = match stem.parse() { Ok(w) => w, Err(_) => continue, }; if window < threshold { fs::remove_file(entry.path())?; removed.push(window); } } removed.sort(); Ok(removed) } } #[cfg(test)] mod tests { use super::*; use mt_codec::CanonicalEncode; use mt_crypto::SECRET_KEY_SIZE; use mt_state::derive_account_id; fn tmp_dir(suffix: &str) -> PathBuf { let base = std::env::temp_dir().join(format!("mt-store-test-{suffix}-{}", rand_suffix())); let _ = fs::remove_dir_all(&base); base } fn rand_suffix() -> u64 { // Псевдо-рандом через nano system time (не consensus-critical) use std::time::{SystemTime, UNIX_EPOCH}; SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.subsec_nanos() as u64 ^ (d.as_secs() << 32)) .unwrap_or(0) } fn make_account(seed: u8) -> AccountRecord { let pubkey = [seed; PUBLIC_KEY_SIZE]; AccountRecord { account_id: derive_account_id(1, &pubkey), balance: 1000 + seed as u128, suite_id: 1, is_node_operator: seed % 2 == 0, frontier_hash: [seed; 32], op_height: seed as u32, account_chain_length: seed as u32, account_chain_length_snapshot: (seed / 2) as u32, current_pubkey: pubkey, creation_window: seed as u32, last_op_window: seed as u32, last_activation_window: 0, } } fn make_node(seed: u8) -> NodeRecord { NodeRecord { node_id: [seed; 32], node_pubkey: [seed; PUBLIC_KEY_SIZE], suite_id: 1, operator_account_id: [seed; 32], start_window: seed as u64, chain_length: (seed as u64 + 1) * 100, chain_length_snapshot: (seed as u64 + 1) * 50, chain_length_checkpoints: [(seed as u64) * 10; 6], last_confirmation_window: seed as u64, } } fn make_candidate(seed: u8) -> CandidateRecord { CandidateRecord { node_id: [seed; 32], node_pubkey: [seed; PUBLIC_KEY_SIZE], suite_id: 1, operator_account_id: [seed; 32], proof_endpoint: [seed; 32], w_start: seed as u64, vdf_chain_length: seed as u64 * 1000, registration_window: seed as u64, expires: seed as u64 + 10_000, } } fn make_header(window: u64) -> ProposalHeader { ProposalHeader { prev_proposal_hash: [(window % 256) as u8; 32], window_index: window, protocol_version: 1, control_root: [0x02; 32], node_root: [0x03; 32], candidate_root: [0x04; 32], account_root: [0x05; 32], state_root: [0x06; 32], timechain_value: [0x07; 32], included_bundles_root: [0x08; 32], included_reveals_root: [0x09; 32], winner_endpoint: [0x0A; 32], winner_id: [0x0B; 32], proposer_node_id: [0xAA; 32], target: u128::from(window), fallback_depth: 1, signature: Signature::from_array([(window % 256) as u8; SIGNATURE_SIZE]), } } // Phase A #[test] fn open_creates_directories() { let p = tmp_dir("open"); let _store = FsStore::open(&p).unwrap(); assert!(p.exists()); assert!(p.join("proposals").exists()); fs::remove_dir_all(&p).ok(); } #[test] fn reopen_existing_is_idempotent() { let p = tmp_dir("reopen"); let s1 = FsStore::open(&p).unwrap(); let s2 = FsStore::open(&p).unwrap(); assert_eq!(s1.root(), s2.root()); fs::remove_dir_all(&p).ok(); } // M5-LOW-8 closure: anti-regression — orphan .tmp files (от crashed // write_atomic) cleanup при FsStore::open. Без cleanup multiple crashes // → накопление tmp в root + proposals/. #[test] fn open_cleans_orphan_tmp_files() { let p = tmp_dir("orphan_tmp"); // Pre-create directories + simulated orphan .tmp от crashed write fs::create_dir_all(&p).unwrap(); fs::create_dir_all(p.join("proposals")).unwrap(); let orphan_root = p.join("accounts.bin.tmp"); let orphan_proposal = p.join("proposals").join("00000000000000000042.bin.tmp"); let normal_file = p.join("accounts.bin"); fs::write(&orphan_root, b"crashed-write").unwrap(); fs::write(&orphan_proposal, b"crashed-write").unwrap(); fs::write(&normal_file, b"valid").unwrap(); assert!(orphan_root.exists()); assert!(orphan_proposal.exists()); // open() должен cleanup .tmp но НЕ трогать non-tmp let _store = FsStore::open(&p).unwrap(); assert!(!orphan_root.exists(), "orphan root .tmp not cleaned"); assert!( !orphan_proposal.exists(), "orphan proposal .tmp not cleaned" ); assert!(normal_file.exists(), "non-tmp file mistakenly removed"); fs::remove_dir_all(&p).ok(); } // Phase B — AccountTable #[test] fn account_table_save_load_roundtrip() { let p = tmp_dir("acc"); let store = FsStore::open(&p).unwrap(); let mut t = AccountTable::new(); for i in 1u8..=5 { t.insert(make_account(i)); } let root_before = t.root(); store.save_account_table(&t).unwrap(); let loaded = store.load_account_table().unwrap(); assert_eq!(loaded.len(), 5); assert_eq!(loaded.root(), root_before); fs::remove_dir_all(&p).ok(); } #[test] fn account_table_load_missing_returns_empty() { let p = tmp_dir("acc-empty"); let store = FsStore::open(&p).unwrap(); let t = store.load_account_table().unwrap(); assert_eq!(t.len(), 0); fs::remove_dir_all(&p).ok(); } #[test] fn account_table_decode_is_inverse_of_encode() { let a = make_account(42); let mut buf = Vec::new(); a.encode(&mut buf); let decoded = decode_account_record(&buf).unwrap(); assert_eq!(decoded, a); } #[test] fn account_table_decode_wrong_size_fails() { let result = decode_account_record(&[0u8; 100]); assert!(matches!(result, Err(StoreError::CorruptedLength(_)))); } // Phase B — NodeTable #[test] fn node_table_save_load_roundtrip() { let p = tmp_dir("nodes"); let store = FsStore::open(&p).unwrap(); let mut t = NodeTable::new(); for i in 1u8..=3 { t.insert(make_node(i)); } let root_before = t.root(); store.save_node_table(&t).unwrap(); let loaded = store.load_node_table().unwrap(); assert_eq!(loaded.len(), 3); assert_eq!(loaded.root(), root_before); fs::remove_dir_all(&p).ok(); } #[test] fn node_table_decode_inverse() { let n = make_node(77); let mut buf = Vec::new(); n.encode(&mut buf); let decoded = decode_node_record(&buf).unwrap(); assert_eq!(decoded, n); } // Phase B — CandidatePool #[test] fn candidate_pool_save_load_roundtrip() { let p = tmp_dir("cands"); let store = FsStore::open(&p).unwrap(); let mut pool = CandidatePool::new(); for i in 1u8..=4 { pool.insert(make_candidate(i)); } let root_before = pool.root(); store.save_candidate_pool(&pool).unwrap(); let loaded = store.load_candidate_pool().unwrap(); assert_eq!(loaded.len(), 4); assert_eq!(loaded.root(), root_before); fs::remove_dir_all(&p).ok(); } #[test] fn candidate_decode_inverse() { let c = make_candidate(99); let mut buf = Vec::new(); c.encode(&mut buf); let decoded = decode_candidate_record(&buf).unwrap(); assert_eq!(decoded, c); } // Phase C — Proposal archive #[test] fn archive_and_fetch_proposal() { let p = tmp_dir("prop"); let store = FsStore::open(&p).unwrap(); let h = make_header(100); store.archive_proposal(&h).unwrap(); let loaded = store.get_proposal_by_window(100).unwrap().unwrap(); assert_eq!(loaded, h); fs::remove_dir_all(&p).ok(); } #[test] fn fetch_missing_proposal_returns_none() { let p = tmp_dir("prop-none"); let store = FsStore::open(&p).unwrap(); assert!(store.get_proposal_by_window(12345).unwrap().is_none()); fs::remove_dir_all(&p).ok(); } #[test] fn archive_100_proposals_random_access() { let p = tmp_dir("prop-many"); let store = FsStore::open(&p).unwrap(); for w in 1..=100u64 { store.archive_proposal(&make_header(w)).unwrap(); } // Random access for w in [1u64, 50, 99, 100] { let h = store.get_proposal_by_window(w).unwrap().unwrap(); assert_eq!(h.window_index, w); } fs::remove_dir_all(&p).ok(); } #[test] fn proposal_header_decode_inverse() { let h = make_header(42); let mut buf = Vec::new(); h.encode(&mut buf); let decoded = decode_proposal_header(&buf).unwrap(); assert_eq!(decoded, h); } // Phase D — Crash recovery #[test] fn meta_last_cemented_save_load() { let p = tmp_dir("meta"); let store = FsStore::open(&p).unwrap(); store.save_meta_last_cemented(12345).unwrap(); let loaded = store.load_meta_last_cemented().unwrap(); assert_eq!(loaded, Some(12345)); fs::remove_dir_all(&p).ok(); } #[test] fn meta_missing_returns_none() { let p = tmp_dir("meta-none"); let store = FsStore::open(&p).unwrap(); assert_eq!(store.load_meta_last_cemented().unwrap(), None); fs::remove_dir_all(&p).ok(); } #[test] fn verify_consistency_fresh_store_returns_zero() { let p = tmp_dir("consist-fresh"); let store = FsStore::open(&p).unwrap(); assert_eq!(store.verify_consistency().unwrap(), 0); fs::remove_dir_all(&p).ok(); } #[test] fn verify_consistency_detects_missing_proposal() { // meta указывает на window 100, но proposal не archive'd → inconsistency let p = tmp_dir("consist-bad"); let store = FsStore::open(&p).unwrap(); store.save_meta_last_cemented(100).unwrap(); assert!(matches!( store.verify_consistency(), Err(StoreError::NotFound(_)) )); fs::remove_dir_all(&p).ok(); } #[test] fn verify_consistency_success_with_proposal() { let p = tmp_dir("consist-ok"); let store = FsStore::open(&p).unwrap(); store.archive_proposal(&make_header(50)).unwrap(); store.save_meta_last_cemented(50).unwrap(); assert_eq!(store.verify_consistency().unwrap(), 50); fs::remove_dir_all(&p).ok(); } // Phase E — Pruning #[test] fn prune_removes_old_proposals() { let p = tmp_dir("prune"); let store = FsStore::open(&p).unwrap(); for w in 1..=20u64 { store.archive_proposal(&make_header(w)).unwrap(); } let removed = store.prune_proposals_before(10).unwrap(); // removed: windows 1..=9 assert_eq!(removed.len(), 9); // Current state proposals 10..20 должны оставаться for w in 10..=20u64 { assert!(store.get_proposal_by_window(w).unwrap().is_some()); } // Pruned — absent for w in 1..=9u64 { assert!(store.get_proposal_by_window(w).unwrap().is_none()); } fs::remove_dir_all(&p).ok(); } #[test] fn prune_does_not_touch_tables() { let p = tmp_dir("prune-tables"); let store = FsStore::open(&p).unwrap(); // Save tables let mut at = AccountTable::new(); at.insert(make_account(1)); store.save_account_table(&at).unwrap(); // Archive and prune proposals store.archive_proposal(&make_header(5)).unwrap(); store.prune_proposals_before(100).unwrap(); // AccountTable intact let loaded = store.load_account_table().unwrap(); assert_eq!(loaded.len(), 1); fs::remove_dir_all(&p).ok(); } #[test] fn prune_empty_proposals_returns_empty() { let p = tmp_dir("prune-empty"); let store = FsStore::open(&p).unwrap(); let removed = store.prune_proposals_before(100).unwrap(); assert!(removed.is_empty()); fs::remove_dir_all(&p).ok(); } // Integration: full restart cycle #[test] fn full_restart_cycle_state_preserved() { let p = tmp_dir("restart"); // === Session 1 === let store = FsStore::open(&p).unwrap(); let mut at = AccountTable::new(); for i in 1u8..=3 { at.insert(make_account(i)); } let mut nt = NodeTable::new(); for i in 1u8..=2 { nt.insert(make_node(i)); } let mut pool = CandidatePool::new(); pool.insert(make_candidate(10)); let at_root_before = at.root(); let nt_root_before = nt.root(); let pool_root_before = pool.root(); store.save_account_table(&at).unwrap(); store.save_node_table(&nt).unwrap(); store.save_candidate_pool(&pool).unwrap(); store.archive_proposal(&make_header(50)).unwrap(); store.save_meta_last_cemented(50).unwrap(); drop(store); // close // === Session 2 (restart) === let store2 = FsStore::open(&p).unwrap(); let at2 = store2.load_account_table().unwrap(); let nt2 = store2.load_node_table().unwrap(); let pool2 = store2.load_candidate_pool().unwrap(); let last = store2.verify_consistency().unwrap(); assert_eq!(at2.root(), at_root_before); assert_eq!(nt2.root(), nt_root_before); assert_eq!(pool2.root(), pool_root_before); assert_eq!(last, 50); // Proposals can still be fetched let prop = store2.get_proposal_by_window(50).unwrap().unwrap(); assert_eq!(prop.window_index, 50); fs::remove_dir_all(&p).ok(); } #[test] fn sig_size_sanity() { // ML-DSA-65 (FIPS 204 level 3) sizes assert_eq!(SECRET_KEY_SIZE, 4032); assert_eq!(SIGNATURE_SIZE, 3309); } }