269 lines
9.9 KiB
Rust
269 lines
9.9 KiB
Rust
|
|
// Pseudo-fuzz harness для mt-store wire decoders.
|
|||
|
|
// Pass 11 / Pass 22 (CRITIC.md): wire decoders должны иметь fuzz target —
|
|||
|
|
// `decode_*` принимают `&[u8]` от filesystem (potential attacker if disk
|
|||
|
|
// compromised, либо disk corruption) и должны never panic, only return
|
|||
|
|
// Result<T, StoreError>.
|
|||
|
|
//
|
|||
|
|
// Полный cargo-fuzz harness требует nightly toolchain (libfuzzer-sys) что
|
|||
|
|
// конфликтует с `rust-toolchain.toml minimum 1.70 stable` workspace policy
|
|||
|
|
// (Код/CLAUDE.md "Rust stable, минимум 1.70"). Используем deterministic
|
|||
|
|
// PRNG-based pseudo-fuzz: generates 1000+ pseudorandom byte arrays
|
|||
|
|
// различных длин, проверяет что decode_* возвращает либо Ok либо
|
|||
|
|
// StoreError::CorruptedLength — НИКОГДА panic / index out of bounds /
|
|||
|
|
// silent corruption.
|
|||
|
|
//
|
|||
|
|
// Запускается как обычный `cargo test`, входит в workspace тестовую
|
|||
|
|
// suite. При появлении nightly toolchain — заменить на libfuzzer-sys
|
|||
|
|
// harness в crates/mt-store/fuzz/fuzz_targets/ для intelligent coverage-
|
|||
|
|
// guided fuzzing.
|
|||
|
|
|
|||
|
|
use mt_state::{ACCOUNT_RECORD_SIZE, CANDIDATE_RECORD_SIZE, NODE_RECORD_SIZE};
|
|||
|
|
use mt_store::{FsStore, StoreError};
|
|||
|
|
|
|||
|
|
// Deterministic PRNG (xorshift64) — reproducible, не привносит
|
|||
|
|
// non-determinism в test runs (важно для CI repeatability).
|
|||
|
|
struct Xorshift64(u64);
|
|||
|
|
|
|||
|
|
impl Xorshift64 {
|
|||
|
|
fn new(seed: u64) -> Self {
|
|||
|
|
Self(if seed == 0 {
|
|||
|
|
0xDEAD_BEEF_CAFE_BABE
|
|||
|
|
} else {
|
|||
|
|
seed
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
fn next_u64(&mut self) -> u64 {
|
|||
|
|
let mut x = self.0;
|
|||
|
|
x ^= x << 13;
|
|||
|
|
x ^= x >> 7;
|
|||
|
|
x ^= x << 17;
|
|||
|
|
self.0 = x;
|
|||
|
|
x
|
|||
|
|
}
|
|||
|
|
fn next_byte(&mut self) -> u8 {
|
|||
|
|
(self.next_u64() & 0xFF) as u8
|
|||
|
|
}
|
|||
|
|
fn fill(&mut self, buf: &mut [u8]) {
|
|||
|
|
for b in buf.iter_mut() {
|
|||
|
|
*b = self.next_byte();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn unique_tmp_dir(seed: &str) -> std::path::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-fuzz-{seed}-{pid}-{nanos}"));
|
|||
|
|
let _ = std::fs::remove_dir_all(&path);
|
|||
|
|
path
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Test invariant: decode_X(arbitrary bytes) → never panic, always Result.
|
|||
|
|
// Corruption / wrong length → StoreError::CorruptedLength либо ParseFailed.
|
|||
|
|
// Valid length + arbitrary content → Ok (decode succeeds на любом byte
|
|||
|
|
// pattern of expected length, потому что decode не валидирует semantic
|
|||
|
|
// invariants — это caller responsibility validate_*).
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn fuzz_account_record_decode_no_panic() {
|
|||
|
|
let dir = unique_tmp_dir("fuzz-acct");
|
|||
|
|
let store = FsStore::open(&dir).expect("open");
|
|||
|
|
let mut rng = Xorshift64::new(0xACC7);
|
|||
|
|
|
|||
|
|
for iter in 0..2000 {
|
|||
|
|
// Random length: mix valid (ACCOUNT_RECORD_SIZE) и invalid (random)
|
|||
|
|
let length = if iter % 5 == 0 {
|
|||
|
|
ACCOUNT_RECORD_SIZE
|
|||
|
|
} else if iter % 3 == 0 {
|
|||
|
|
(rng.next_u64() % 5000) as usize
|
|||
|
|
} else {
|
|||
|
|
// Boundaries: 0, 1, near-correct, double, off-by-one
|
|||
|
|
match iter % 7 {
|
|||
|
|
0 => 0,
|
|||
|
|
1 => 1,
|
|||
|
|
2 => ACCOUNT_RECORD_SIZE - 1,
|
|||
|
|
3 => ACCOUNT_RECORD_SIZE + 1,
|
|||
|
|
4 => ACCOUNT_RECORD_SIZE * 2,
|
|||
|
|
5 => ACCOUNT_RECORD_SIZE / 2,
|
|||
|
|
_ => (rng.next_u64() % (3 * ACCOUNT_RECORD_SIZE as u64)) as usize,
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
let mut bytes = vec![0u8; length];
|
|||
|
|
rng.fill(&mut bytes);
|
|||
|
|
std::fs::write(dir.join("accounts.bin"), &bytes).expect("write");
|
|||
|
|
|
|||
|
|
// Single-record load attempts
|
|||
|
|
let result = std::panic::catch_unwind(|| store.load_account_table());
|
|||
|
|
assert!(
|
|||
|
|
result.is_ok(),
|
|||
|
|
"decode_account_record paniked at iter={iter} length={length}"
|
|||
|
|
);
|
|||
|
|
// Также verify что error либо Ok возвращены — не silent unwrap
|
|||
|
|
match result.unwrap() {
|
|||
|
|
Ok(_) => {
|
|||
|
|
// Длина кратна ACCOUNT_RECORD_SIZE → decoded successfully
|
|||
|
|
assert_eq!(length % ACCOUNT_RECORD_SIZE, 0);
|
|||
|
|
},
|
|||
|
|
Err(StoreError::CorruptedLength(_)) => {
|
|||
|
|
assert_ne!(length % ACCOUNT_RECORD_SIZE, 0);
|
|||
|
|
},
|
|||
|
|
Err(other) => panic!("unexpected error variant for length={length}: {:?}", other),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn fuzz_node_record_decode_no_panic() {
|
|||
|
|
let dir = unique_tmp_dir("fuzz-node");
|
|||
|
|
let store = FsStore::open(&dir).expect("open");
|
|||
|
|
let mut rng = Xorshift64::new(0x0DE0);
|
|||
|
|
|
|||
|
|
for iter in 0..2000 {
|
|||
|
|
let length = match iter % 8 {
|
|||
|
|
0 => 0,
|
|||
|
|
1 => 1,
|
|||
|
|
2 => NODE_RECORD_SIZE,
|
|||
|
|
3 => NODE_RECORD_SIZE - 1,
|
|||
|
|
4 => NODE_RECORD_SIZE + 1,
|
|||
|
|
5 => NODE_RECORD_SIZE * 2,
|
|||
|
|
6 => (rng.next_u64() % 10000) as usize,
|
|||
|
|
_ => (rng.next_u64() % (3 * NODE_RECORD_SIZE as u64)) as usize,
|
|||
|
|
};
|
|||
|
|
let mut bytes = vec![0u8; length];
|
|||
|
|
rng.fill(&mut bytes);
|
|||
|
|
std::fs::write(dir.join("nodes.bin"), &bytes).expect("write");
|
|||
|
|
|
|||
|
|
let result = std::panic::catch_unwind(|| store.load_node_table());
|
|||
|
|
assert!(
|
|||
|
|
result.is_ok(),
|
|||
|
|
"decode_node_record paniked at iter={iter} length={length}"
|
|||
|
|
);
|
|||
|
|
match result.unwrap() {
|
|||
|
|
Ok(_) => assert_eq!(length % NODE_RECORD_SIZE, 0),
|
|||
|
|
Err(StoreError::CorruptedLength(_)) => {
|
|||
|
|
assert_ne!(length % NODE_RECORD_SIZE, 0)
|
|||
|
|
},
|
|||
|
|
Err(other) => panic!("unexpected error: {other:?}"),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn fuzz_candidate_record_decode_no_panic() {
|
|||
|
|
let dir = unique_tmp_dir("fuzz-cand");
|
|||
|
|
let store = FsStore::open(&dir).expect("open");
|
|||
|
|
let mut rng = Xorshift64::new(0xCAFE);
|
|||
|
|
|
|||
|
|
for iter in 0..2000 {
|
|||
|
|
let length = match iter % 8 {
|
|||
|
|
0 => 0,
|
|||
|
|
1 => CANDIDATE_RECORD_SIZE,
|
|||
|
|
2 => CANDIDATE_RECORD_SIZE - 1,
|
|||
|
|
3 => CANDIDATE_RECORD_SIZE + 1,
|
|||
|
|
4 => CANDIDATE_RECORD_SIZE * 2,
|
|||
|
|
5 => CANDIDATE_RECORD_SIZE * 3,
|
|||
|
|
6 => (rng.next_u64() % 10000) as usize,
|
|||
|
|
_ => (rng.next_u64() % (3 * CANDIDATE_RECORD_SIZE as u64)) as usize,
|
|||
|
|
};
|
|||
|
|
let mut bytes = vec![0u8; length];
|
|||
|
|
rng.fill(&mut bytes);
|
|||
|
|
std::fs::write(dir.join("candidates.bin"), &bytes).expect("write");
|
|||
|
|
|
|||
|
|
let result = std::panic::catch_unwind(|| store.load_candidate_pool());
|
|||
|
|
assert!(
|
|||
|
|
result.is_ok(),
|
|||
|
|
"decode_candidate_record paniked at iter={iter} length={length}"
|
|||
|
|
);
|
|||
|
|
match result.unwrap() {
|
|||
|
|
Ok(_) => assert_eq!(length % CANDIDATE_RECORD_SIZE, 0),
|
|||
|
|
Err(StoreError::CorruptedLength(_)) => {
|
|||
|
|
assert_ne!(length % CANDIDATE_RECORD_SIZE, 0)
|
|||
|
|
},
|
|||
|
|
Err(other) => panic!("unexpected error: {other:?}"),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn fuzz_proposal_header_decode_no_panic() {
|
|||
|
|
use mt_consensus::PROPOSAL_HEADER_SIZE;
|
|||
|
|
let dir = unique_tmp_dir("fuzz-prop");
|
|||
|
|
let store = FsStore::open(&dir).expect("open");
|
|||
|
|
let mut rng = Xorshift64::new(0xDEAD);
|
|||
|
|
|
|||
|
|
let proposals_dir = dir.join("proposals");
|
|||
|
|
let _ = std::fs::create_dir_all(&proposals_dir);
|
|||
|
|
|
|||
|
|
for iter in 0..1000 {
|
|||
|
|
let length = match iter % 8 {
|
|||
|
|
0 => 0,
|
|||
|
|
1 => PROPOSAL_HEADER_SIZE,
|
|||
|
|
2 => PROPOSAL_HEADER_SIZE - 1,
|
|||
|
|
3 => PROPOSAL_HEADER_SIZE + 1,
|
|||
|
|
4 => PROPOSAL_HEADER_SIZE / 2,
|
|||
|
|
5 => PROPOSAL_HEADER_SIZE * 2,
|
|||
|
|
6 => (rng.next_u64() % 10000) as usize,
|
|||
|
|
_ => (rng.next_u64() % (3 * PROPOSAL_HEADER_SIZE as u64)) as usize,
|
|||
|
|
};
|
|||
|
|
let mut bytes = vec![0u8; length];
|
|||
|
|
rng.fill(&mut bytes);
|
|||
|
|
let target = proposals_dir.join(format!("{:020}.bin", 42));
|
|||
|
|
std::fs::write(&target, &bytes).expect("write proposal");
|
|||
|
|
|
|||
|
|
let result = std::panic::catch_unwind(|| store.get_proposal_by_window(42));
|
|||
|
|
assert!(
|
|||
|
|
result.is_ok(),
|
|||
|
|
"decode_proposal_header paniked at iter={iter} length={length}"
|
|||
|
|
);
|
|||
|
|
match result.unwrap() {
|
|||
|
|
Ok(Some(_)) => assert_eq!(length, PROPOSAL_HEADER_SIZE),
|
|||
|
|
Ok(None) => panic!("expected file present"),
|
|||
|
|
Err(StoreError::CorruptedLength(_)) => {
|
|||
|
|
assert_ne!(length, PROPOSAL_HEADER_SIZE)
|
|||
|
|
},
|
|||
|
|
Err(other) => panic!("unexpected error: {other:?}"),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn fuzz_meta_last_cemented_decode_no_panic() {
|
|||
|
|
let dir = unique_tmp_dir("fuzz-meta");
|
|||
|
|
let store = FsStore::open(&dir).expect("open");
|
|||
|
|
let mut rng = Xorshift64::new(0xBEEF);
|
|||
|
|
|
|||
|
|
for iter in 0..500 {
|
|||
|
|
let length = match iter % 6 {
|
|||
|
|
0 => 0,
|
|||
|
|
1 => 8, // valid
|
|||
|
|
2 => 7, // off-by-one
|
|||
|
|
3 => 9,
|
|||
|
|
4 => 16,
|
|||
|
|
_ => (rng.next_u64() % 100) as usize,
|
|||
|
|
};
|
|||
|
|
let mut bytes = vec![0u8; length];
|
|||
|
|
rng.fill(&mut bytes);
|
|||
|
|
std::fs::write(dir.join("meta_last_cemented.bin"), &bytes).expect("write");
|
|||
|
|
|
|||
|
|
let result = std::panic::catch_unwind(|| store.load_meta_last_cemented());
|
|||
|
|
assert!(
|
|||
|
|
result.is_ok(),
|
|||
|
|
"load_meta_last_cemented paniked at iter={iter} length={length}"
|
|||
|
|
);
|
|||
|
|
match result.unwrap() {
|
|||
|
|
Ok(Some(_)) => assert_eq!(length, 8),
|
|||
|
|
Ok(None) => panic!("expected file present"),
|
|||
|
|
Err(StoreError::CorruptedLength(_)) => assert_ne!(length, 8),
|
|||
|
|
Err(other) => panic!("unexpected error: {other:?}"),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|||
|
|
}
|