OCaml fuzz testing with Crowbar for protocol implementations...
fuzz_foo.ml tests lib/foo.ml. Keeps tests organized and discoverable.encode and decode, test decode(encode(x)) = x.For standalone packages, use one fuzz file per package:
ocaml-foo/
āāā lib/
āāā fuzz/
ā āāā dune
ā āāā fuzz_foo.ml
āāā dune-project
fuzz/dune:
(executable
(name fuzz_foo)
(modules fuzz_foo)
(libraries foo crowbar))
; Quick check with Crowbar (no AFL instrumentation)
(rule
(alias fuzz)
(deps fuzz_foo.exe)
(action
(run %{exe:fuzz_foo.exe})))
; AFL-instrumented build target (use with --profile=afl)
(rule
(alias fuzz-afl)
(deps
(source_tree input)
fuzz_foo.exe)
(action
(echo "AFL fuzzer built: %{exe:fuzz_foo.exe}\n")))
Seed corpus: Create fuzz/input/ with sample inputs:
mkdir -p fuzz/input
echo -n "" > fuzz/input/empty
# Add representative samples as seed inputs
fuzz/fuzz_foo.ml:
open Crowbar
let test_parse_crash_safety buf =
ignore (Foo.parse buf);
check true
let () =
add_test ~name:"foo: parse crash safety" [ bytes ] test_parse_crash_safety
For larger projects with many modules:
(executable
(name fuzz)
(libraries crowbar borealis)
(modules
fuzz
fuzz_common
fuzz_foo
fuzz_bar))
Main entry point (fuzz/fuzz.ml):
(* Force linking of modules that register tests via side effects *)
let () =
Fuzz_common.run ();
Fuzz_foo.run ();
Fuzz_bar.run ()
Each fuzz module ends with:
let run () = ()
This ensures the module is linked and its add_test calls execute.
When writing fuzz tests, follow these conventions:
add_test callsbytes directly instead of custom generatorstruncate helper to limit input size for protocol messages() directly - no need for check true in most casesCrypto_rng_unix.use_default () at the top if crypto is used(** Fuzz tests for Foo module. *)
open Crowbar
open Fuzz_common
(** Decode - must not crash on arbitrary input. *)
let test_decode buf =
let buf = truncate buf in
let _ = Foo.decode (to_bytes buf) in
()
(** Roundtrip - valid values must round-trip. *)
let test_roundtrip buf =
let buf = truncate buf in
match Foo.decode (to_bytes buf) with
| Error _ -> ()
| Ok v ->
let encoded = Foo.encode v in
match Foo.decode encoded with
| Error _ -> fail "re-decode failed"
| Ok v' -> if v <> v' then fail "roundtrip mismatch"
(** Pretty-print - must not crash. *)
let test_pp n =
let v = Foo.of_int (n mod 4) in
let _ = Format.asprintf "%a" Foo.pp v in
()
(* All add_test calls in run function - no side effects at module init *)
let run () =
add_test ~name:"foo: decode crash safety" [ bytes ] test_decode;
add_test ~name:"foo: roundtrip" [ bytes ] test_roundtrip;
add_test ~name:"foo: pp" [ uint8 ] test_pp
Main entry point (fuzz/fuzz.ml):
(* Initialize crypto RNG if needed by any module *)
let () = Crypto_rng_unix.use_default ()
(* Register all fuzz tests *)
let () =
Fuzz_common.run ();
Fuzz_foo.run ();
Fuzz_bar.run ()
open Crowbar
open Fuzz_common
(** Decode - must not crash on arbitrary input. *)
let test_decode buf =
let buf = truncate buf in
let _ = Foo.decode (to_bytes buf) in
()
(** Decode with exceptions - must not crash. *)
let test_decode_exn buf =
let buf = truncate buf in
(try ignore (Foo.decode_exn (to_bytes buf)) with _ -> ());
()
let run () =
add_test ~name:"foo: decode crash safety" [ bytes ] test_decode;
add_test ~name:"foo: decode_exn crash safety" [ bytes ] test_decode_exn
Key points:
bytes generator for arbitrary binary input (produces string type)ignore to discard results without warnings| exception _ -> () to catch any exceptionscheck true signals test passed(** Roundtrip - valid values must round-trip. *)
let test_roundtrip buf =
let buf = truncate buf in
match Foo.decode (to_bytes buf) with
| Error _ -> () (* Invalid input is fine *)
| Ok original ->
let encoded = Foo.encode original in
match Foo.decode encoded with
| Error _ -> fail "re-decode failed"
| Ok decoded ->
if original <> decoded then fail "roundtrip mismatch"
let run () =
add_test ~name:"foo: roundtrip" [ bytes ] test_roundtrip
Key points:
(** APID roundtrip - valid values must round-trip. *)
let test_apid_roundtrip n =
match Apid.of_int n with
| None -> if n >= 0 && n <= 2047 then fail "should accept valid value"
| Some apid ->
let n' = Apid.to_int apid in
if n <> n' then fail "roundtrip mismatch"
let run () =
add_test ~name:"apid: roundtrip" [ range 2048 ] test_apid_roundtrip
(** Max valid value. *)
let test_max_valid () =
match Apid.of_int 2047 with
| None -> fail "2047 should be valid"
| Some apid -> if Apid.to_int apid <> 2047 then fail "value mismatch"
(** Min valid value. *)
let test_min_valid () =
match Apid.of_int 0 with
| None -> fail "0 should be valid"
| Some apid -> if Apid.to_int apid <> 0 then fail "value mismatch"
let run () =
add_test ~name:"apid: max_valid" [ const () ] test_max_valid;
add_test ~name:"apid: min_valid" [ const () ] test_min_valid
Key points:
[ const () ] for tests with no random input[] as generator list (causes type error)(** Values above max must be rejected. *)
let test_invalid_above n =
let invalid = 2048 + n in
match Apid.of_int invalid with
| None -> ()
| Some _ -> fail "should reject values > 2047"
(** Negative values must be rejected. *)
let test_invalid_negative n =
let invalid = -(n + 1) in
match Apid.of_int invalid with
| None -> ()
| Some _ -> fail "should reject negative values"
let run () =
add_test ~name:"apid: invalid_above" [ range 1000 ] test_invalid_above;
add_test ~name:"apid: invalid_negative" [ range 1000 ] test_invalid_negative
(** Pretty-print - must not crash. *)
let test_pp buf =
let buf = truncate buf in
match Foo.decode (to_bytes buf) with
| Error _ -> ()
| Ok v -> let _ = Format.asprintf "%a" Foo.pp v in ()
let run () =
add_test ~name:"foo: pp" [ bytes ] test_pp
(** Test valid state transitions. *)
let test_activate_pending kid algo material_buf =
let material = to_bytes material_buf in
if Bytes.length material = 0 then ()
else
let key = Key.v ~kid ~algorithm:algo ~material in
match Key.activate key with
| Error _ -> () (* May fail if material invalid *)
| Ok active_key ->
if Key.state active_key <> Key.Active then fail "wrong state"
(** Test invalid state transitions return errors. *)
let test_activate_empty_fails kid algo =
let key = Key.empty ~kid ~algorithm:algo in
match Key.activate key with
| Ok _ -> fail "should fail on Empty key"
| Error (Key.Invalid_state_transition _) -> ()
| Error _ -> fail "wrong error type"
let run () =
add_test ~name:"key: activate Pending" [ uint8; uint8; bytes ]
test_activate_pending;
add_test ~name:"key: activate Empty fails" [ uint8; uint8 ]
test_activate_empty_fails
(** Nanoseconds roundtrip. *)
let test_ns_roundtrip n =
let d = Duration.of_ns n in
let n' = Duration.to_ns d in
if n <> n' then fail "ns roundtrip mismatch"
(** Microseconds to milliseconds conversion. *)
let test_us_to_ms n =
let us = Int64.of_int n in
let d = Duration.of_us us in
let ms = Duration.to_ms d in
let expected = Int64.div us 1000L in
if ms <> expected then fail "us to ms conversion failed"
let run () =
add_test ~name:"duration: ns_roundtrip" [ int64 ] test_ns_roundtrip;
add_test ~name:"duration: us_to_ms" [ range 1000000 ] test_us_to_ms
(** Test create/exists invariant. *)
let test_create_exists name_buf =
let name = Bytes.to_string (to_bytes name_buf) in
if String.length name = 0 then ()
else
let fs = Filestore.in_memory () in
match Filestore.create fs name with
| Error _ -> ()
| Ok () ->
if not (Filestore.exists fs name) then
fail "created file should exist"
let run () =
add_test ~name:"filestore: create_exists" [ bytes ] test_create_exists
(** Common utilities for fuzz tests. *)
open Crowbar
let to_bytes buf =
let len = String.length buf in
let b = Bytes.create len in
Bytes.blit_string buf 0 b 0 len;
b
let catch_invalid_arg f =
try f () with Invalid_argument _ -> check true
let run () = ()
| Generator | Type | Use for |
|---|---|---|
bytes |
string |
Arbitrary binary data |
uint8 |
int |
0-255 |
int8 |
int |
-128 to 127 |
int32 |
int32 |
Full int32 range |
int64 |
int64 |
Full int64 range |
range n |
int |
0 to n-1 |
bool |
bool |
true/false |
const v |
'a |
Fixed value (for no-input tests) |
list gen |
'a list |
Lists of generated values |
option gen |
'a option |
Some/None |
fuzz/
āāā fuzz.ml # Main entry, links all modules
āāā fuzz_common.ml # Shared utilities
āāā fuzz_tc_frame.ml # Tests for lib/frames/tc_frame.ml
āāā fuzz_tm_frame.ml # Tests for lib/frames/tm_frame.ml
āāā fuzz_apid.ml # Tests for lib/frames/apid.ml
āāā fuzz_keyid.ml # Tests for lib/sdls/keyid.ml
āāā ...
Naming convention: fuzz_<module>.ml tests lib/**/<module>.ml
dune exec fuzz/fuzz.exe
# Or use the alias:
dune build @fuzz
dune build fuzz/fuzz.exe
mkdir -p fuzz/input
echo -n "" > fuzz/input/empty
afl-fuzz -m none -i fuzz/input -o _fuzz -- \
_build/default/fuzz/fuzz.exe @@
Use crow to orchestrate long-running AFL campaigns across multiple targets:
# Initialize workspace (creates dune-workspace with afl profile if needed)
crow init
# Build all fuzz targets with AFL instrumentation
dune build --profile=afl @fuzz-afl
# List discovered fuzz targets
crow list
# Start a campaign with 8 CPUs for 24 hours
crow start --cpus=8 --duration=24h
# Monitor progress
crow status
# Stop the campaign
crow stop
crow automatically:
*/fuzz/dune files with crowbar dependenciesdune-workspace with AFL profile if missinggrep -h 'add_test ~name:"' fuzz/fuzz_*.ml | \
sed 's/.*~name:"\([^"]*\)".*/\1/' | sort | uniq -d
For each module with a public API (.mli file):
decode_*, parse_*, read_*, of_* functionsencode/decode, to_*/of_* pairspp_* functions don't crashequal and compare are consistentWhen adding fuzz tests to a codebase:
(* ERROR: This expression should not be a function *)
add_test ~name:"test" [] @@ fun () -> ...
const () for no-input testsadd_test ~name:"test" [ const () ] @@ fun () -> ...
(* BAD: Only tests happy path *)
add_test ~name:"foo: decode" [ bytes ] @@ fun buf ->
let Ok v = Foo.decode (to_bytes buf) in
check true
add_test ~name:"foo: decode" [ bytes ] @@ fun buf ->
(match Foo.decode (to_bytes buf) with
| Ok _ -> ()
| Error _ -> ());
check true
(* BAD: Fails on invalid input *)
add_test ~name:"foo: roundtrip" [ bytes ] @@ fun buf ->
match Foo.decode (to_bytes buf) with
| Error _ -> fail "decode failed" (* Wrong! Invalid input is expected *)
| Ok v -> ...
add_test ~name:"foo: roundtrip" [ bytes ] @@ fun buf ->
match Foo.decode (to_bytes buf) with
| Error _ -> check true (* Invalid input is fine *)
| Ok v -> ...
When adding fuzz tests, produce:
fuzz/fuzz_<module>.ml with comprehensive testsFuzz_<module>.run () calldune build succeeds