1use rand::{RngExt, SeedableRng};
11
12#[derive(Debug, Clone)]
14pub struct FuzzConfig {
15 pub max_iterations: u32,
17 pub seed: Option<u64>,
19 pub max_tx_size_bytes: usize,
21 pub max_input_count: u8,
23 pub max_output_count: u8,
25}
26
27impl Default for FuzzConfig {
28 fn default() -> Self {
29 Self {
30 max_iterations: 1000,
31 seed: None,
32 max_tx_size_bytes: 100_000,
33 max_input_count: 50,
34 max_output_count: 50,
35 }
36 }
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum MalformedTxCategory {
42 TruncatedHeader,
44 InvalidVersion,
46 ZeroInputs,
48 ZeroOutputs,
50 OverflowValue,
52 InvalidScriptLen,
54 RandomNoise,
56 ValidishStructure,
58}
59
60#[derive(Debug)]
62pub struct FuzzResult {
63 pub category: MalformedTxCategory,
65 pub iterations: u32,
67 pub panics: u32,
69 pub errors: u32,
71 pub successes: u32,
73 pub panic_inputs: Vec<Vec<u8>>,
75}
76
77impl FuzzResult {
78 pub fn is_safe(&self) -> bool {
80 self.panics == 0
81 }
82}
83
84pub struct TransactionFuzzer {
86 config: FuzzConfig,
87}
88
89impl TransactionFuzzer {
90 pub fn new(config: FuzzConfig) -> Self {
92 Self { config }
93 }
94
95 pub fn with_default_config() -> Self {
97 Self {
98 config: FuzzConfig::default(),
99 }
100 }
101
102 pub fn generate_truncated(&self, rng: &mut impl RngExt) -> Vec<u8> {
105 let mut data = vec![0x01u8, 0x00, 0x00, 0x00]; let extra: usize = rng.random_range(0..11);
107 let suffix: Vec<u8> = (0..extra).map(|_| rng.random::<u8>()).collect();
108 data.extend(suffix);
109 data
110 }
111
112 pub fn generate_random_noise(&self, rng: &mut impl RngExt) -> Vec<u8> {
115 let len: usize = rng.random_range(1..=self.config.max_tx_size_bytes);
116 (0..len).map(|_| rng.random::<u8>()).collect()
117 }
118
119 pub fn generate_validish_malformed(&self, rng: &mut impl RngExt) -> Vec<u8> {
125 let mut data = vec![0x01u8, 0x00, 0x00, 0x00, 0x01]; let txid: Vec<u8> = (0..32).map(|_| rng.random::<u8>()).collect();
127 data.extend(txid);
128 let vout: u32 = rng.random_range(0..10);
129 data.extend_from_slice(&vout.to_le_bytes());
130 data.extend_from_slice(&[0xFD, 0xFF, 0xFF]);
132 let actual: usize = (rng.random::<u8>() % 9) as usize;
133 let script: Vec<u8> = (0..actual).map(|_| rng.random::<u8>()).collect();
134 data.extend(script);
135 data.extend_from_slice(&0xFFFF_FFFFu32.to_le_bytes()); data
137 }
138
139 pub fn generate_overflow_value(&self, rng: &mut impl RngExt) -> Vec<u8> {
143 let mut data: Vec<u8> = Vec::with_capacity(61);
144 data.extend_from_slice(&1u32.to_le_bytes()); data.push(0x01); let txid: Vec<u8> = (0..32).map(|_| rng.random::<u8>()).collect();
147 data.extend(txid);
148 data.extend_from_slice(&0u32.to_le_bytes()); data.push(0x00); data.extend_from_slice(&0xFFFF_FFFFu32.to_le_bytes()); data.push(0x01); data.extend_from_slice(&u64::MAX.to_le_bytes()); data.push(0x01); data.push(0x6A); data.extend_from_slice(&0u32.to_le_bytes()); data
157 }
158
159 pub fn run_batch<F>(&self, category: MalformedTxCategory, parse_fn: F) -> FuzzResult
168 where
169 F: Fn(&[u8]) -> bool,
170 {
171 match self.config.seed {
174 Some(seed) => {
175 let mut rng = rand::rngs::SmallRng::seed_from_u64(seed);
176 Self::run_batch_inner(
177 category,
178 parse_fn,
179 &mut rng,
180 self.config.max_iterations,
181 self.config.max_tx_size_bytes,
182 )
183 }
184 None => {
185 let mut rng = rand::rng();
186 Self::run_batch_inner(
187 category,
188 parse_fn,
189 &mut rng,
190 self.config.max_iterations,
191 self.config.max_tx_size_bytes,
192 )
193 }
194 }
195 }
196
197 fn run_batch_inner<F, R>(
199 category: MalformedTxCategory,
200 parse_fn: F,
201 rng: &mut R,
202 max_iterations: u32,
203 max_size: usize,
204 ) -> FuzzResult
205 where
206 F: Fn(&[u8]) -> bool,
207 R: RngExt,
208 {
209 let mut panics: u32 = 0;
210 let mut errors: u32 = 0;
211 let mut successes: u32 = 0;
212 let mut panic_inputs: Vec<Vec<u8>> = Vec::new();
213
214 for _ in 0..max_iterations {
215 let bytes = Self::generate_bytes_for_category(category, rng, max_size);
216
217 let result =
220 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| parse_fn(&bytes)));
221
222 match result {
223 Err(_panic_payload) => {
224 panics += 1;
225 panic_inputs.push(bytes);
226 }
227 Ok(true) => successes += 1,
228 Ok(false) => errors += 1,
229 }
230 }
231
232 FuzzResult {
233 category,
234 iterations: max_iterations,
235 panics,
236 errors,
237 successes,
238 panic_inputs,
239 }
240 }
241
242 fn generate_bytes_for_category<R: RngExt>(
244 category: MalformedTxCategory,
245 rng: &mut R,
246 max_size: usize,
247 ) -> Vec<u8> {
248 match category {
249 MalformedTxCategory::TruncatedHeader => {
250 let mut data = vec![0x01u8, 0x00, 0x00, 0x00];
251 let extra: usize = rng.random_range(0..11);
252 let suffix: Vec<u8> = (0..extra).map(|_| rng.random::<u8>()).collect();
253 data.extend(suffix);
254 data
255 }
256 MalformedTxCategory::RandomNoise => {
257 let len: usize = rng.random_range(1..=max_size);
258 (0..len).map(|_| rng.random::<u8>()).collect()
259 }
260 MalformedTxCategory::ValidishStructure => {
261 let mut data = vec![0x01u8, 0x00, 0x00, 0x00, 0x01];
262 let txid: Vec<u8> = (0..32).map(|_| rng.random::<u8>()).collect();
263 data.extend(txid);
264 let vout: u32 = rng.random_range(0..10);
265 data.extend_from_slice(&vout.to_le_bytes());
266 data.extend_from_slice(&[0xFD, 0xFF, 0xFF]);
267 let actual: usize = (rng.random::<u8>() % 9) as usize;
268 let script: Vec<u8> = (0..actual).map(|_| rng.random::<u8>()).collect();
269 data.extend(script);
270 data.extend_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
271 data
272 }
273 MalformedTxCategory::OverflowValue => {
274 let mut data: Vec<u8> = Vec::with_capacity(61);
275 data.extend_from_slice(&1u32.to_le_bytes());
276 data.push(0x01);
277 let txid: Vec<u8> = (0..32).map(|_| rng.random::<u8>()).collect();
278 data.extend(txid);
279 data.extend_from_slice(&0u32.to_le_bytes());
280 data.push(0x00);
281 data.extend_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
282 data.push(0x01);
283 data.extend_from_slice(&u64::MAX.to_le_bytes());
284 data.push(0x01);
285 data.push(0x6A);
286 data.extend_from_slice(&0u32.to_le_bytes());
287 data
288 }
289 MalformedTxCategory::InvalidVersion
290 | MalformedTxCategory::ZeroInputs
291 | MalformedTxCategory::ZeroOutputs
292 | MalformedTxCategory::InvalidScriptLen => {
293 let len: usize = rng.random_range(1..=max_size);
294 (0..len).map(|_| rng.random::<u8>()).collect()
295 }
296 }
297 }
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303
304 #[test]
305 fn test_fuzz_config_default() {
306 let cfg = FuzzConfig::default();
307 assert_eq!(cfg.max_iterations, 1000);
308 assert_eq!(cfg.max_tx_size_bytes, 100_000);
309 assert_eq!(cfg.max_input_count, 50);
310 assert_eq!(cfg.max_output_count, 50);
311 assert!(cfg.seed.is_none());
312 }
313
314 #[test]
315 fn test_generate_random_noise_length() {
316 let fuzzer = TransactionFuzzer::with_default_config();
317 let mut rng = rand::rng();
318 for _ in 0..20 {
319 let noise = fuzzer.generate_random_noise(&mut rng);
320 assert!(!noise.is_empty(), "Random noise must be non-empty");
321 assert!(
322 noise.len() <= fuzzer.config.max_tx_size_bytes,
323 "Noise length {} exceeds max {}",
324 noise.len(),
325 fuzzer.config.max_tx_size_bytes
326 );
327 }
328 }
329
330 #[test]
331 fn test_generate_truncated_is_short() {
332 let cfg = FuzzConfig {
333 seed: Some(42),
334 ..Default::default()
335 };
336 let fuzzer = TransactionFuzzer::new(cfg);
337 let mut rng = rand::rngs::SmallRng::seed_from_u64(42);
338 for _ in 0..20 {
339 let truncated = fuzzer.generate_truncated(&mut rng);
340 assert!(
341 truncated.len() < 300,
342 "Truncated tx unexpectedly large: {} bytes",
343 truncated.len()
344 );
345 }
346 }
347
348 #[test]
349 fn test_generate_validish_non_empty() {
350 let fuzzer = TransactionFuzzer::with_default_config();
351 let mut rng = rand::rng();
352 let data = fuzzer.generate_validish_malformed(&mut rng);
353 assert!(!data.is_empty(), "Validish malformed tx must not be empty");
354 assert!(
355 data.len() >= 5,
356 "Validish malformed tx is suspiciously short: {} bytes",
357 data.len()
358 );
359 }
360
361 #[test]
362 fn test_fuzz_result_is_safe_with_no_panics() {
363 let result = FuzzResult {
364 category: MalformedTxCategory::RandomNoise,
365 iterations: 100,
366 panics: 0,
367 errors: 90,
368 successes: 10,
369 panic_inputs: Vec::new(),
370 };
371 assert!(result.is_safe());
372
373 let unsafe_result = FuzzResult {
374 category: MalformedTxCategory::RandomNoise,
375 iterations: 100,
376 panics: 1,
377 errors: 89,
378 successes: 10,
379 panic_inputs: vec![vec![0xFF]],
380 };
381 assert!(!unsafe_result.is_safe());
382 }
383
384 #[test]
385 fn test_run_batch_no_panics() {
386 use bitcoin::consensus::Decodable;
387
388 let cfg = FuzzConfig {
389 max_iterations: 50,
390 seed: Some(12345),
391 ..Default::default()
392 };
393 let fuzzer = TransactionFuzzer::new(cfg);
394
395 let result = fuzzer.run_batch(MalformedTxCategory::RandomNoise, |bytes| {
396 let mut slice = bytes;
397 bitcoin::Transaction::consensus_decode(&mut slice).is_ok()
398 });
399
400 assert!(result.is_safe(), "Parsing should never panic");
401 assert_eq!(result.iterations, 50);
402 }
403
404 #[test]
405 fn test_generate_overflow_value_has_max_value_bytes() {
406 let fuzzer = TransactionFuzzer::with_default_config();
407 let mut rng = rand::rngs::SmallRng::seed_from_u64(99);
408 let data = fuzzer.generate_overflow_value(&mut rng);
409
410 let value_offset = 4 + 1 + 32 + 4 + 1 + 4 + 1;
413 assert!(
414 data.len() > value_offset + 7,
415 "Overflow tx too short: {} bytes",
416 data.len()
417 );
418 let value_bytes = &data[value_offset..value_offset + 8];
419 assert_eq!(
420 value_bytes,
421 &u64::MAX.to_le_bytes(),
422 "Overflow value bytes must be u64::MAX"
423 );
424 }
425
426 #[test]
427 fn test_fuzzer_with_custom_config() {
428 use bitcoin::consensus::Decodable;
429
430 let cfg = FuzzConfig {
431 max_iterations: 20,
432 seed: Some(777),
433 max_tx_size_bytes: 512,
434 max_input_count: 5,
435 max_output_count: 5,
436 };
437 let fuzzer = TransactionFuzzer::new(cfg);
438
439 let categories = [
440 MalformedTxCategory::TruncatedHeader,
441 MalformedTxCategory::ValidishStructure,
442 MalformedTxCategory::OverflowValue,
443 ];
444
445 for category in &categories {
446 let result = fuzzer.run_batch(*category, |bytes| {
447 let mut slice = bytes;
448 bitcoin::Transaction::consensus_decode(&mut slice).is_ok()
449 });
450 assert!(
451 result.is_safe(),
452 "Category {:?} triggered a panic",
453 category
454 );
455 assert_eq!(result.iterations, 20);
456 }
457 }
458}