// Automated determinism invariants для mt-store. // M5 audit prep — persistence layer: AccountTable / NodeTable / CandidatePool / // ProposalHeader save-load roundtrip + crash recovery + pruning. // Любая non-equivalence save/load = state divergence = consensus fork. // Invariants ловят byte-exact roundtrip regression при refactor encoding/decoding. use mt_crypto::PUBLIC_KEY_SIZE; use mt_state::{ AccountRecord, AccountTable, CandidatePool, CandidateRecord, NodeRecord, NodeTable, }; use mt_store::{FsStore, StoreError}; use std::path::PathBuf; // ---------- Helpers ---------- fn unique_tmp_dir(seed: &str) -> PathBuf { let pid = std::process::id(); let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.subsec_nanos() as u64) .unwrap_or(0); let path = std::env::temp_dir().join(format!("mt-store-det-{seed}-{pid}-{nanos}")); let _ = std::fs::remove_dir_all(&path); path } fn sample_account(id_byte: u8) -> AccountRecord { AccountRecord { account_id: [id_byte; 32], balance: 1_000_000_000_000u128, suite_id: 1, is_node_operator: id_byte % 2 == 0, frontier_hash: [id_byte.wrapping_add(1); 32], op_height: id_byte as u32, account_chain_length: id_byte as u32, account_chain_length_snapshot: (id_byte / 2) as u32, current_pubkey: [id_byte; PUBLIC_KEY_SIZE], creation_window: id_byte as u32, last_op_window: (id_byte as u32).saturating_add(10), last_activation_window: 0, } } fn sample_node(id_byte: u8) -> NodeRecord { NodeRecord { node_id: [id_byte; 32], node_pubkey: [id_byte; PUBLIC_KEY_SIZE], suite_id: 1, operator_account_id: [id_byte.wrapping_add(1); 32], start_window: id_byte as u64 * 10, chain_length: id_byte as u64 * 100, chain_length_snapshot: id_byte as u64 * 50, chain_length_checkpoints: [id_byte as u64; 6], last_confirmation_window: id_byte as u64 * 200, } } fn sample_candidate(id_byte: u8) -> CandidateRecord { CandidateRecord { node_id: [id_byte; 32], node_pubkey: [id_byte; PUBLIC_KEY_SIZE], suite_id: 1, operator_account_id: [id_byte.wrapping_add(1); 32], proof_endpoint: [id_byte.wrapping_add(2); 32], w_start: id_byte as u64 * 10, vdf_chain_length: 20_160, registration_window: id_byte as u64 * 10, expires: id_byte as u64 * 10 + 60_480, } } // ---------- AccountTable save/load roundtrip ---------- #[test] fn account_table_save_load_roundtrip() { let dir = unique_tmp_dir("acct-rt"); let store = FsStore::open(&dir).expect("open store"); let mut table = AccountTable::new(); table.insert(sample_account(0x01)); table.insert(sample_account(0x02)); table.insert(sample_account(0x03)); let root_before = table.root(); store.save_account_table(&table).expect("save"); let loaded = store.load_account_table().expect("load"); assert_eq!(table.len(), loaded.len()); assert_eq!( root_before, loaded.root(), "root byte-equal after roundtrip" ); let _ = std::fs::remove_dir_all(&dir); } #[test] fn account_table_load_empty_when_no_file() { let dir = unique_tmp_dir("acct-empty"); let store = FsStore::open(&dir).expect("open store"); let loaded = store.load_account_table().expect("load empty"); assert_eq!(loaded.len(), 0); let _ = std::fs::remove_dir_all(&dir); } #[test] fn account_table_corrupted_length_detected() { let dir = unique_tmp_dir("acct-corrupt"); let store = FsStore::open(&dir).expect("open store"); // Записываем не-кратное ACCOUNT_RECORD_SIZE количество байт std::fs::write(dir.join("accounts.bin"), b"corrupt-not-multiple").expect("write"); let result = store.load_account_table(); assert!(matches!(result, Err(StoreError::CorruptedLength(_)))); let _ = std::fs::remove_dir_all(&dir); } // ---------- NodeTable save/load roundtrip ---------- #[test] fn node_table_save_load_roundtrip() { let dir = unique_tmp_dir("node-rt"); let store = FsStore::open(&dir).expect("open store"); let mut table = NodeTable::new(); table.insert(sample_node(0x10)); table.insert(sample_node(0x20)); let root_before = table.root(); store.save_node_table(&table).expect("save"); let loaded = store.load_node_table().expect("load"); assert_eq!(table.len(), loaded.len()); assert_eq!(root_before, loaded.root()); let _ = std::fs::remove_dir_all(&dir); } // ---------- CandidatePool save/load roundtrip ---------- #[test] fn candidate_pool_save_load_roundtrip() { let dir = unique_tmp_dir("cand-rt"); let store = FsStore::open(&dir).expect("open store"); let mut pool = CandidatePool::new(); pool.insert(sample_candidate(0x40)); pool.insert(sample_candidate(0x50)); let root_before = pool.root(); store.save_candidate_pool(&pool).expect("save"); let loaded = store.load_candidate_pool().expect("load"); assert_eq!(pool.len(), loaded.len()); assert_eq!(root_before, loaded.root()); let _ = std::fs::remove_dir_all(&dir); } // ---------- Crash recovery (meta_last_cemented) ---------- #[test] fn meta_last_cemented_save_load_roundtrip() { let dir = unique_tmp_dir("meta-rt"); let store = FsStore::open(&dir).expect("open store"); store.save_meta_last_cemented(42).expect("save meta"); let loaded = store.load_meta_last_cemented().expect("load meta"); assert_eq!(loaded, Some(42)); let _ = std::fs::remove_dir_all(&dir); } #[test] fn meta_last_cemented_returns_none_when_absent() { let dir = unique_tmp_dir("meta-empty"); let store = FsStore::open(&dir).expect("open store"); assert_eq!(store.load_meta_last_cemented().expect("load"), None); let _ = std::fs::remove_dir_all(&dir); } #[test] fn verify_consistency_zero_when_no_meta() { let dir = unique_tmp_dir("consist-zero"); let store = FsStore::open(&dir).expect("open store"); // Без meta файла → last = 0, no proposal проверка assert_eq!(store.verify_consistency().expect("verify"), 0); let _ = std::fs::remove_dir_all(&dir); } #[test] fn verify_consistency_detects_missing_proposal() { let dir = unique_tmp_dir("consist-missing"); let store = FsStore::open(&dir).expect("open store"); // meta = 100, но proposal в archive отсутствует → finding store.save_meta_last_cemented(100).expect("save meta"); let result = store.verify_consistency(); assert!(matches!(result, Err(StoreError::NotFound(_)))); let _ = std::fs::remove_dir_all(&dir); } // ---------- Pruning ---------- #[test] fn prune_proposals_returns_empty_when_dir_absent() { let dir = unique_tmp_dir("prune-empty"); let store = FsStore::open(&dir).expect("open store"); // Удаляем proposals dir чтобы test edge case (хотя open() создаёт) let _ = std::fs::remove_dir_all(dir.join("proposals")); let removed = store.prune_proposals_before(100).expect("prune"); assert_eq!(removed, Vec::::new()); let _ = std::fs::remove_dir_all(&dir); } // ---------- Determinism: byte-equal save для identical input ---------- #[test] fn save_account_table_byte_equal_for_identical_input() { let dir1 = unique_tmp_dir("det-acct-1"); let dir2 = unique_tmp_dir("det-acct-2"); let store1 = FsStore::open(&dir1).expect("open 1"); let store2 = FsStore::open(&dir2).expect("open 2"); let mut table = AccountTable::new(); table.insert(sample_account(0x01)); table.insert(sample_account(0x02)); table.insert(sample_account(0x03)); store1.save_account_table(&table).expect("save 1"); store2.save_account_table(&table).expect("save 2"); let bytes1 = std::fs::read(dir1.join("accounts.bin")).expect("read 1"); let bytes2 = std::fs::read(dir2.join("accounts.bin")).expect("read 2"); assert_eq!( bytes1, bytes2, "byte-exact identical input → identical file" ); let _ = std::fs::remove_dir_all(&dir1); let _ = std::fs::remove_dir_all(&dir2); } #[test] fn save_account_table_byte_equal_invariant_under_insertion_order() { // BTreeMap-backed AccountTable должен давать identical encoded bytes // независимо от insertion order. let dir1 = unique_tmp_dir("ord-acct-1"); let dir2 = unique_tmp_dir("ord-acct-2"); let store1 = FsStore::open(&dir1).expect("open 1"); let store2 = FsStore::open(&dir2).expect("open 2"); let mut t1 = AccountTable::new(); t1.insert(sample_account(0x01)); t1.insert(sample_account(0x02)); t1.insert(sample_account(0x03)); let mut t2 = AccountTable::new(); t2.insert(sample_account(0x03)); t2.insert(sample_account(0x01)); t2.insert(sample_account(0x02)); store1.save_account_table(&t1).expect("save 1"); store2.save_account_table(&t2).expect("save 2"); let bytes1 = std::fs::read(dir1.join("accounts.bin")).expect("read 1"); let bytes2 = std::fs::read(dir2.join("accounts.bin")).expect("read 2"); assert_eq!( bytes1, bytes2, "BTreeMap canonical sort guarantees order-independent encode" ); let _ = std::fs::remove_dir_all(&dir1); let _ = std::fs::remove_dir_all(&dir2); } // ---------- Static API invariants ---------- #[test] fn store_root_returns_open_path() { let dir = unique_tmp_dir("root-path"); let store = FsStore::open(&dir).expect("open"); assert_eq!(store.root(), dir.as_path()); let _ = std::fs::remove_dir_all(&dir); } #[test] fn store_creates_proposals_subdirectory() { let dir = unique_tmp_dir("propdir"); let _store = FsStore::open(&dir).expect("open"); assert!(dir.join("proposals").is_dir()); let _ = std::fs::remove_dir_all(&dir); } // ---------- R5 atomic rename pattern verification ---------- #[test] fn save_does_not_leave_tmp_on_success() { // R5 invariant: после успешного save_X нет файла `.tmp` в root. // (rename атомарен, tmp removed.) let dir = unique_tmp_dir("atomic-cleanup"); let store = FsStore::open(&dir).expect("open"); let mut table = AccountTable::new(); table.insert(sample_account(0x01)); store.save_account_table(&table).expect("save"); assert!(dir.join("accounts.bin").exists(), "final file present"); assert!( !dir.join("accounts.bin.tmp").exists(), "tmp file removed after rename" ); let _ = std::fs::remove_dir_all(&dir); } #[test] fn save_atomic_overwrite_preserves_old_until_rename() { // R5 invariant: existing файл не truncated в-мiddle write. Если write fails // mid-flight (вне нашего теста — нужен fault injection для full coverage), // old content остаётся valid. // Этот тест verifies happy path: повторный save заменяет content полностью. let dir = unique_tmp_dir("atomic-overwrite"); let store = FsStore::open(&dir).expect("open"); let mut t1 = AccountTable::new(); t1.insert(sample_account(0x01)); store.save_account_table(&t1).expect("save 1"); let bytes_v1 = std::fs::read(dir.join("accounts.bin")).expect("read 1"); let mut t2 = AccountTable::new(); t2.insert(sample_account(0x01)); t2.insert(sample_account(0x02)); store.save_account_table(&t2).expect("save 2"); let bytes_v2 = std::fs::read(dir.join("accounts.bin")).expect("read 2"); assert_ne!(bytes_v1, bytes_v2, "v2 overwrites v1"); assert_eq!(bytes_v2.len(), 2 * 2059, "v2 contains 2 records"); let _ = std::fs::remove_dir_all(&dir); } // ---------- Full state cycle: open → populate → save → close → reopen → load ---------- #[test] fn full_state_cycle_state_root_byte_equal() { let dir = unique_tmp_dir("cycle"); { let store = FsStore::open(&dir).expect("open"); let mut acct = AccountTable::new(); acct.insert(sample_account(0x01)); acct.insert(sample_account(0x02)); let mut nodes = NodeTable::new(); nodes.insert(sample_node(0x10)); let mut cands = CandidatePool::new(); cands.insert(sample_candidate(0x40)); store.save_account_table(&acct).expect("save acct"); store.save_node_table(&nodes).expect("save nodes"); store.save_candidate_pool(&cands).expect("save cand"); } // close { // reopen let store2 = FsStore::open(&dir).expect("reopen"); let acct = store2.load_account_table().expect("load acct"); let nodes = store2.load_node_table().expect("load nodes"); let cands = store2.load_candidate_pool().expect("load cand"); assert_eq!(acct.len(), 2); assert_eq!(nodes.len(), 1); assert_eq!(cands.len(), 1); } let _ = std::fs::remove_dir_all(&dir); }