Skip to main content

kaccy_bitcoin/
fuzz_testing.rs

1//! Comprehensive fuzzing and property-based testing utilities for Bitcoin transactions.
2//!
3//! This module provides tools for testing the robustness of transaction parsing,
4//! address validation, and script analysis against malformed or adversarial inputs.
5//!
6//! # Security Note
7//! These utilities are for testing only. All parsing functions in this crate
8//! are designed to fail gracefully on malformed input.
9
10use rand::{RngExt, SeedableRng};
11
12/// Configuration for transaction fuzzing sessions.
13#[derive(Debug, Clone)]
14pub struct FuzzConfig {
15    /// Maximum number of fuzz iterations to run per batch.
16    pub max_iterations: u32,
17    /// Optional deterministic seed for reproducible fuzzing.
18    pub seed: Option<u64>,
19    /// Maximum size in bytes for generated transactions.
20    pub max_tx_size_bytes: usize,
21    /// Maximum number of inputs to simulate.
22    pub max_input_count: u8,
23    /// Maximum number of outputs to simulate.
24    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/// Categories of malformed transaction inputs used for adversarial testing.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum MalformedTxCategory {
42    /// Too few bytes to form a valid transaction header.
43    TruncatedHeader,
44    /// Version field is set to an out-of-range value.
45    InvalidVersion,
46    /// Transaction has zero inputs (coinbase is an exception handled by the parser).
47    ZeroInputs,
48    /// Transaction has zero outputs.
49    ZeroOutputs,
50    /// Output value exceeds 21 million BTC.
51    OverflowValue,
52    /// Declared script length exceeds available bytes.
53    InvalidScriptLen,
54    /// Completely random byte sequence.
55    RandomNoise,
56    /// Structurally plausible but internally inconsistent transaction.
57    ValidishStructure,
58}
59
60/// Aggregated result from one batch of fuzz iterations.
61#[derive(Debug)]
62pub struct FuzzResult {
63    /// The malformed category that was tested.
64    pub category: MalformedTxCategory,
65    /// Total iterations executed.
66    pub iterations: u32,
67    /// Number of iterations that caused a panic (must be 0 for safety).
68    pub panics: u32,
69    /// Number of iterations that returned a parse error (expected for bad data).
70    pub errors: u32,
71    /// Number of iterations where parsing unexpectedly succeeded.
72    pub successes: u32,
73    /// Raw byte inputs that triggered a panic, captured for investigation.
74    pub panic_inputs: Vec<Vec<u8>>,
75}
76
77impl FuzzResult {
78    /// Returns `true` if no panics occurred — the primary safety invariant.
79    pub fn is_safe(&self) -> bool {
80        self.panics == 0
81    }
82}
83
84/// Generates adversarial Bitcoin transaction byte sequences for fuzz testing.
85pub struct TransactionFuzzer {
86    config: FuzzConfig,
87}
88
89impl TransactionFuzzer {
90    /// Create a new fuzzer with a custom configuration.
91    pub fn new(config: FuzzConfig) -> Self {
92        Self { config }
93    }
94
95    /// Create a fuzzer with default configuration.
96    pub fn with_default_config() -> Self {
97        Self {
98            config: FuzzConfig::default(),
99        }
100    }
101
102    /// Generate a raw transaction buffer that has been truncated at a random byte
103    /// boundary, simulating incomplete network messages or corrupt data.
104    pub fn generate_truncated(&self, rng: &mut impl RngExt) -> Vec<u8> {
105        let mut data = vec![0x01u8, 0x00, 0x00, 0x00]; // version = 1 (u32 LE)
106        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    /// Generate a buffer of completely random bytes of random length within
113    /// `[1, max_tx_size_bytes]`.
114    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    /// Generate a structurally plausible but internally inconsistent transaction.
120    ///
121    /// The returned buffer has a valid-looking header with one input whose
122    /// `script_sig` length varint claims more bytes than are available.  This
123    /// exercises off-by-one / overflow parsing paths without being pure noise.
124    pub fn generate_validish_malformed(&self, rng: &mut impl RngExt) -> Vec<u8> {
125        let mut data = vec![0x01u8, 0x00, 0x00, 0x00, 0x01]; // version=1, input_count=1
126        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        // 2-byte compact-int form claiming 65535 bytes of script
131        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()); // sequence
136        data
137    }
138
139    /// Generate a transaction whose single output value is set to `u64::MAX`,
140    /// which is far above the 21 million BTC supply cap (2_100_000_000_000_000
141    /// satoshis).
142    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()); // version
145        data.push(0x01); // input count
146        let txid: Vec<u8> = (0..32).map(|_| rng.random::<u8>()).collect();
147        data.extend(txid);
148        data.extend_from_slice(&0u32.to_le_bytes()); // vout
149        data.push(0x00); // script_sig length = 0 (empty)
150        data.extend_from_slice(&0xFFFF_FFFFu32.to_le_bytes()); // sequence
151        data.push(0x01); // output count
152        data.extend_from_slice(&u64::MAX.to_le_bytes()); // value = u64::MAX
153        data.push(0x01); // output script length = 1
154        data.push(0x6A); // OP_RETURN
155        data.extend_from_slice(&0u32.to_le_bytes()); // locktime
156        data
157    }
158
159    /// Run `config.max_iterations` fuzz iterations for the given `category`.
160    ///
161    /// For each iteration a byte sequence is generated according to `category`
162    /// and passed to `parse_fn`.  Panics are caught via [`std::panic::catch_unwind`]
163    /// so they contribute to `FuzzResult::panics` rather than aborting the test.
164    ///
165    /// `parse_fn` should return `true` if parsing succeeded and `false` if it
166    /// returned a structured error.
167    pub fn run_batch<F>(&self, category: MalformedTxCategory, parse_fn: F) -> FuzzResult
168    where
169        F: Fn(&[u8]) -> bool,
170    {
171        // Dispatch to the generic inner implementation to avoid Box<dyn Rng>
172        // which is not dyn-compatible due to Rng's generic methods.
173        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    /// Inner implementation of `run_batch`, generic over the RNG type.
198    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            // Use catch_unwind to ensure panics are captured, not propagated.
218            // AssertUnwindSafe is required because the closure borrows the slice.
219            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    /// Generate bytes for a specific malformed category.
243    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        // value starts at: version(4) + input_count(1) + txid(32) + vout(4) +
411        //                   script_len=0(1) + sequence(4) + output_count(1) = 47
412        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}