roc-fuzz checks pure Roc code through correctness assertions, declared examples, allocation measurements, logical trace rules and coverage-guided exploration. One target executable checks exact accepted observations and preserves original findings for inspection, replay, retention and minimization.
Browse the generated API reference · Download the standalone PDF manual · View roc-fuzz on GitHub.
1. Why fuzz a Roc application?§
Suppose a decoder passes every example you wrote, but one unusual combination of lengths and Unicode text still makes it fail. Adding more example tests helps only when you can imagine the troublesome input in advance.
Fuzzing repeatedly runs part of your application with varied inputs. It watches which inputs reach new behavior and uses those discoveries to guide the search. You describe what valid inputs look like and what must always be true. roc-fuzz does the repetitive search and saves any input that exposes a failure.
1.1. What a fuzz target is§
A fuzz target is a small Roc application that connects roc-fuzz to one behavior in your code. It contains three essential ideas:
a generator that creates the Roc values to try;
an operation that calls the application code you want to exercise; and
a property that decides whether the result is acceptable.

The target is necessary because roc-fuzz cannot infer what correctness means for your application. It can explore inputs and notice crashes, hangs, leaks, and new behavior in the compiled program, but only you can state application rules such as:
encoding and then decoding returns the original value;
pushing an item and then popping returns that item and the original stack;
normalizing an already normalized value changes nothing;
two independent implementations produce the same result; or
arbitrary input may return an ordinary error but must never crash.
A target is development and test code. It is separate from the application you ship, and it normally imports the module it exercises. Start with one operation and one property per target so failures remain easy to understand.
1.2. How fuzzing complements tests§
Unit tests and fuzzing answer different questions and work best together.
| Approach | What it does well | What it leaves to you |
|---|---|---|
| Unit tests | Check named examples and preserve known regressions quickly and deterministically. | You must choose every example. |
| A hand-written random loop | Tries more values than a short example list. | You must build generation, search strategy, repeatability, failure storage, and diagnostic tools. |
| roc-fuzz | Uses execution feedback to explore, retains useful inputs, and saves failures that can be shown, replayed, and minimized. | You must define meaningful generators and properties; a weak property can miss real bugs. |
After roc-fuzz finds a bug, fix the code and retain its original bytes with
TARGET retain. Add the printed Fuzz.saved declaration alongside typed source
examples. Those regressions run independently of coverage retention, while
exploration continues searching for new cases. Unit tests can share the same
application invariants.
1.3. What “typed” and “coverage-guided” mean§
Typed means the test receives ordinary Roc values rather than making every
target decode an arbitrary byte array itself. A generator can produce a U64,
a Str, or a record such as
{ delimiter : Str, value : Str }. You choose the useful ranges and shapes.
roc-fuzz does not automatically know your application’s domain rules.
Coverage-guided describes how the search chooses where to spend effort. roc-fuzz observes which parts of the compiled program each input reaches. When an input reaches behavior not seen before, it retains that input and creates further variations from it. This feedback makes the search more purposeful than independent random samples.

The retained useful inputs form a corpus. A later run can reuse that corpus to resume from behavior already discovered. Coverage here is search feedback, not a percentage and not proof that the program is correct.
roc-fuzz embeds a mature native fuzzing engine in the executable built by Roc. Normal users interact with roc-fuzz commands and Roc values; knowledge of that engine is needed only for the expert escape hatch described later.
1.4. What roc-fuzz provides§
For a Roc application author, the platform supplies:
typed generators for common Roc values and tools for combining them;
coverage feedback that directs the search toward new behavior;
a bounded local runner with sensible defaults;
saved inputs for crashes, failed properties, hangs, memory limits, and leaks;
commands to render a saved input as a Roc value, reproduce it, and minimize it;
a persistent corpus that makes later runs build on earlier discoveries; and
a supervised CI mode that records configuration, logs, hashes, and failures.
A target builds into one self-contained executable:
roc build --fuzz fuzz/stack/main.roc --output=stack-fuzz
./stack-fuzz run
The normal workflow needs no separate native project or fuzzing tool installation.
1.5. Choose a good first target§
Good first targets are fast, deterministic operations over in-memory values with a rule that is easy to state.
| Application behavior | Possible property | Why it fits |
|---|---|---|
| Parser or decoder | Every input returns a value or an ordinary error without crashing or hanging. | Large input space and clear acceptable outcomes. |
| Encoder and decoder | Decoding an encoded value returns the original value. | Strong round-trip property. |
| Collection or state transition | An operation preserves membership, length, ordering, or an independent model. | Many combinations can exercise edge cases. |
| Canonicalization or normalization | Applying the operation twice gives the same result as once. | Simple idempotence property. |
| Two implementations of one rule | Both produce the same result. | Each implementation acts as an oracle for the other. |
A target is a poor fit when one input performs slow network, filesystem, UI, or database work; depends on nondeterministic external state; can legitimately run without a practical time or memory bound; or deliberately terminates the process for ordinary invalid input. Prefer unit, integration, end-to-end, or benchmark tests for those jobs.
Fuzzing consumes CPU time and stores a corpus and failure artifacts. More time can explore more behavior, but a clean run means only that no failure was found during that search. It never proves the absence of bugs.
roc-fuzz currently supports Linux x86-64 with musl and Apple Silicon macOS 11 or newer. Continue with Fuzz your first function to build a target against a small application module and work through a failure.
2. Fuzz your first function§
This tutorial adds a fuzz target beside a small Roc module, runs it, inspects a failure, fixes the bug, and preserves the lesson as a regression test. You do not need prior fuzzing experience or knowledge of the embedded engine.
The example uses a stack because its rule is easy to state: after pushing an item, popping must return that item and the original stack. The demo keeps the application module beside its target only to make the tutorial self-contained; a real target normally imports an existing module from the application.
2.1. Create the application module§
Create fuzz/stack/Stack.roc:
Stack := [].{
push : List(a), a -> List(a)
push = |items, item| List.append(items, item)
pop : List(a) -> Try({ value : a, rest : List(a) }, [Empty])
pop = |items|
match items {
[first, .. as rest] => Ok({ value: first, rest })
[] => Err(Empty)
}
}
This version contains a plausible implementation mistake: push appends the
new item to the end, while pop removes an item from the front. A test using
only an empty stack would pass, so the defect is easy to overlook.
The fuzz target is a separate development application that imports this module; the shipped application does not need to depend on roc-fuzz.
2.2. Write the target§
Create fuzz/stack/main.roc:
app [target] { fuzz: platform "PLATFORM_URL" }
import fuzz.Fuzz
import Stack
test : List(U8) -> Fuzz.Outcome
test = |initial| {
item = 0
pushed = Stack.push(initial, item)
match Stack.pop(pushed) {
Ok({ value, rest }) if value == item and rest == initial => Fuzz.keep
Ok(_) => crash "pop did not undo push"
Err(Empty) => crash "the stack was empty after push"
}
}
target = Fuzz.target_with({
examples: [],
name: "stack-push-pop",
generator: Fuzz.list(Fuzz.u8, 8),
test,
show: |initial| Str.inspect(initial),
})
Replace PLATFORM_URL with the platform URL in the notes of the
latest release.
The target connects the three ideas from the previous chapter:
| Part | Role in this target |
|---|---|
| Generator | Fuzz.list(Fuzz.u8, 8) supplies lists containing up to eight U8 values. |
| Application operation | The test calls Stack.push and Stack.pop. |
| Property | After pushing 0, the popped value must be 0 and the remaining stack must equal the generated initial value. |
Fuzz.keep means the generated case completed normally and satisfied the
property. crash means roc-fuzz must save the case as a failure. show tells
roc-fuzz how to render the generated value for a person.
The application header selects an immutable roc-fuzz release and its compatible Roc compiler. For another project, select a release from GitHub Releases and use the compiler documented with that release. Roc obtains the platform through its normal package mechanism; there is no separate roc-fuzz installer.
2.3. Build and find the bug§
Build a self-contained fuzzing executable:
roc build --fuzz fuzz/stack/main.roc --output=stack-fuzz
Start with the bounded default run:
./stack-fuzz run
roc-fuzz generates initial stacks and repeatedly runs the property. An empty stack passes even with the bug, while a non-empty stack can expose the mismatch between the two ends. The exact status lines and saved hash vary, but the end of the output includes commands shaped like these:
Next steps:
./stack-fuzz show .roc-fuzz/crash-<hash>
./stack-fuzz replay .roc-fuzz/crash-<hash>
./stack-fuzz minimize .roc-fuzz/crash-<hash> .roc-fuzz/minimized-crash
The saved file contains the reproducible input used by the generator. It is an
implementation format for roc-fuzz, so inspect it with show instead of opening
it as text:
./stack-fuzz show .roc-fuzz/crash-<hash>
For this target, show prints the generated initial stack. Reproduce the failure
in a fresh process:
./stack-fuzz replay .roc-fuzz/crash-<hash>
Then ask roc-fuzz for a smaller reproducing input:
./stack-fuzz minimize \
.roc-fuzz/crash-<hash> .roc-fuzz/minimized-crash
./stack-fuzz show .roc-fuzz/minimized-crash
./stack-fuzz replay .roc-fuzz/minimized-crash
Minimization is most useful for structured or long inputs, but using the same workflow here establishes the habit.
2.4. Fix and preserve the failure§
The two stack operations disagree about which end is the top. Fix push in
Stack.roc:
push : List(a), a -> List(a)
push = |items, item| List.prepend(items, item)
Rebuild the executable and replay the minimized input. It should now complete without a failed property:
roc build --fuzz fuzz/stack/main.roc --output=stack-fuzz
./stack-fuzz replay .roc-fuzz/minimized-crash
./stack-fuzz run
Retain the exact witness and add its printed Fuzz.saved declaration to the
target’s examples list:
./stack-fuzz retain .roc-fuzz/minimized-crash --name previous-crash --output=fuzz/regressions
./stack-fuzz check
You can also append a normal unit test using the minimized non-empty stack to
Stack.roc:
expect {
initial : List(U8)
initial = [1]
match Stack.pop(Stack.push(initial, 0)) {
Ok({ value, rest }) => value == 0 and rest == initial
Err(Empty) => Bool.False
}
}
Run it with the rest of the module’s tests:
roc test fuzz/stack/Stack.roc
Use the actual value printed by show if minimization found a different
one-element list. The regression test protects this known case on every test
run; the fuzz target continues exploring other values and future implementation
changes.
A bounded run that finds no failure is still a successful run. It means the property held for the cases explored during that run, not that the operation is proved correct.
2.5. Understand a normal run§
By default, run checks every declared example once in a fresh process, then
explores for 60 seconds. It keeps useful inputs under .roc-fuzz/corpus and
saves failures in the run’s report directory under .roc-fuzz/reports/. The corpus is a set
of inputs that reached behavior worth revisiting; later runs reuse it and
continue searching from those discoveries.
During a clean run, NEW means the engine retained an input that reached new
behavior and DONE means the configured bound was reached. The
campaign status glossary explains the remaining
fields and tuning options.
2.6. Design the next target§
Choose one fast application operation and one rule that should hold for every input. Common property shapes include:
Round trip: decoding an encoded value returns the original value.
Inverse: an operation followed by its inverse returns the starting value.
Idempotence: normalizing twice gives the same result as normalizing once.
Invariant: an operation preserves ordering, length, membership, or another rule of the data structure.
Differential: two independent implementations produce the same result.
Robustness: arbitrary input may return success or an ordinary error, but must not crash or hang.
Generate useful values directly. If a parser accepts bytes of any shape, arbitrary
bytes may be appropriate. If an operation requires a bounded port number or a
non-empty list, express that rule in the generator so most search effort reaches
the behavior you care about. Use Fuzz.reject only when a generated case lies
outside the property; do not use crash to reject an ordinary invalid input.
Keep unrelated operations and properties in separate targets. A practical first set might include one target per parser, encoder/decoder pair, state transition, or important business invariant. There is no need to fuzz every function.
The campaign guide explains corpora, tuning, and unstable
failures. The advanced guide covers generator dispatch,
allocation assertions, and the low-level engine interface. Run TARGET --help
for the command summary and browse the generated API
reference for generator, combinator, and target signatures.
2.7. Repository and release examples§
The repository’s examples/ applications use relative paths to the local platform;
prepare its native inputs before building them. Published releases provide a
roc-fuzz-examples-VERSION.zip download containing complete runnable applications
with that release’s content-addressed platform URL. Neither the platform nor
an archived example pins a compiler: a daily compatibility check runs the latest
release’s archived examples, unchanged, on the newest Roc nightly.
3. Design effective targets§
The first tutorial generated one U8. Application behavior usually accepts
records, lists, strings, or domain types. Build a generator that produces values
close to the operation’s useful input domain, then keep the property focused on
one behavior.
3.1. Start from the operation§
Choose a function that is fast, deterministic, and easy to call in memory. Write the property in one sentence before choosing a generator. If the sentence needs several unrelated “and” clauses, make separate targets.
Examples include:
“Decoding an encoded message returns the original message.”
“Sorting preserves every item and produces nondecreasing order.”
“A parser returns
OkorErrfor every string without crashing.”“Applying this state transition agrees with the simple reference model.”
A generator explores only the cases the property can observe. Calling an operation without checking its result finds crashes and resource failures, but cannot find an ordinary wrong answer.
3.2. Generate useful values§
roc-fuzz provides generators for numeric values, strings, bytes, and collections. Use a bounded generator when the full type includes values that are irrelevant or too expensive for this target:
port = Fuzz.u8_in(1, 255)
name = Fuzz.str
payload = Fuzz.list(Fuzz.u8, 1024)
Fuzz.list(item_generator, maximum_length) generates a list whose elements come
from item_generator. The maximum is a test-design bound, so choose a size that
reaches meaningful behavior without making each case slow.
Use Fuzz.map when the domain value can be constructed from one generated value,
and Fuzz.map2 when it needs two:
non_empty_bytes = Fuzz.map(
Fuzz.list(Fuzz.u8, 255),
|rest| List.prepend(rest, 1),
)
range = Fuzz.map2(
Fuzz.u8,
Fuzz.u8,
|a, b| if a <= b { start: a, end: b } else { start: b, end: a },
)
Constructing valid values directly gives the search more useful executions. Generation controls bias; property preconditions or validated input types define validity. Encoded examples and their mutations bypass the generator and must pass the same validity checks.
3.3. Build record inputs§
Roc’s record-builder syntax can combine a generator for each field:
Input := { delimiter : Str, value : Str }.{
parser_for : _
encoder_for : _
}
input_generator : Fuzz.Generator(Input)
input_generator = {
delimiter: Fuzz.str,
value: Fuzz.str,
}.Fuzz
Use the resulting generator with Fuzz.target_with:
target = Fuzz.target_with({
examples: [],
name: "split-round-trip",
generator: input_generator,
test,
show: |input| Str.inspect(input),
})
This explicit form is a good default for a generator used by one target. When a
named input type owns a reusable generation policy, define its generator_for
method and use Fuzz.target:
Input := { delimiter : Str, value : Str }.{
parser_for : _
encoder_for : _
generator_for = |_| {
{
delimiter: Fuzz.str,
value: Fuzz.str,
}.Fuzz
}
}
target = Fuzz.target({
examples: [],
name: "split-round-trip",
test,
show: |input| Str.inspect(input),
})
Both forms produce the same kind of typed target. The
advanced explanation describes
how Roc resolves generator_for; using the pattern does not require knowing its
implementation.
3.4. Declare examples and regressions§
examples: [
Fuzz.example({ name: "empty", revision: "1", make: || Input.{ delimiter: ",", value: "" } }),
Fuzz.saved({ name: "previous-crash", revision: "1", bytes: regression_bytes }),
]
A typed example is encoded into real corpus seed bytes; it does not need an inverse generator. Saved declarations keep original artifacts. Every example runs independently of corpus retention, exactly once in a fresh process during checking or acceptance. Increment revisions when an example’s meaning changes. The baseline also checks the actual input digest, even if the revision is unchanged.
3.5. Handle invalid input deliberately§
An ordinary invalid input is not automatically a fuzzing failure. A parser may
return Err for malformed text and still satisfy a robustness property. Return
Fuzz.keep when the observed result is allowed.
Prefer a generator that creates valid domain values. Return Fuzz.reject when a
precondition is awkward to encode and the generated case is outside the property
you are testing. Do not use crash or expect to reject such a case; those mean
roc-fuzz found a failure worth saving.
Watch the rejection rate as tooling adds support for it. If most inputs are rejected, redesign the generator so the campaign spends more time in the code that matters.
3.6. Keep saved inputs meaningful§
A saved file is decoded by the target’s generator. Changing that generator can make an old file produce a different typed value. Keep generators stable when a corpus or failure matters, and retain the matching executable when exact historical reproduction is important.
The generated API reference lists all generators and combinators. Continue with Run and maintain fuzzing campaigns when the target produces useful values and passes a short smoke run.
3.7. Arctangent builtin properties§
The tests/targets/builtins/arctangentDec.roc, arctangentF32.roc, and
arctangentF64.roc targets check atan and atan2 with composed numeric
generators. They also assert that the numeric checks allocate no heap memory;
input generation and diagnostic rendering are outside that measurement.
The float targets check NaN propagation, signed zeros, infinities, range,
quadrants, and odd symmetry. F32 results are compared with F64 results rounded
to F32, allowing two ULP. F64 angles are checked against normalized input
coordinates using sine and cosine, with absolute tolerance 3e-15. Quadrant
checks allow two ULP at the rounded half-pi boundary, while zero and infinity
cases require exact result bits.
The Dec generator composes two U64 values into a signed 128-bit attosecond
payload and varies its magnitude with a shift. It checks axes, range, symmetry,
and agreement between atan(y) and atan2({ x: 1, y }), allowing 128 attoseconds.
Comparison with the separate F64 implementation allows 4e-15 for conversion
and floating-point error. Dec.lowest is tested but excluded from negation
checks because its negation overflows.
These checks supplement the compiler’s high-precision unit tests; shared
algorithms and finite tolerances mean they do not prove correct rounding.
To run just these inventory entries, with a roc on PATH that includes the
atan2 builtins:
blueprint run test-targets -- --operation all --max-total-time 60 \
--target arctangentDec --target arctangentF32 --target arctangentF64
Use the normal runner’s show, replay, and minimize commands to investigate
saved failures, as described in the campaign chapter.
4. Run and maintain fuzzing campaigns§
After the first target works, use these goal-oriented guides to choose bounds, interpret status output, retain useful inputs, investigate unstable failures, and prepare longer searches. The advanced guide covers the embedded engine and target implementation details.
4.1. Tune a run§
Give every new target a short, bounded smoke run before a long campaign:
TARGET run \
--time=60 \
--max-input-size=4096 \
--timeout=5 \
--memory-limit=2048 \
--print-final-stats
The most useful limits are:
| Option | Meaning | Starting point |
|---|---|---|
--time=N | Stop after N seconds; 0 is unbounded. | Defaults to 60. |
--runs=N | Stop after N generated inputs. | Useful for repeatable CI checks. |
--max-input-size=N | Limit raw entropy bytes supplied to the generator. | Start small and raise only when needed. |
--timeout=N | Treat one input taking longer than N seconds as a failure. | A few seconds for a normally fast target. |
--memory-limit=N | Stop when the process exceeds this many MB. | Set from the expected working set. |
--dictionary=FILE | Add important byte tokens to mutations. | Useful for text or binary formats. |
--seed=N | Select the engine’s random seed. | Useful for repeating a campaign with the same runner build and configuration. |
--print-final-stats | Print final execution and resource counters. | Useful in CI and smoke tests. |
--max-input-size limits the generator’s raw input, not necessarily
every typed list or string it produces. Put bounds in the generator as
well when a typed value could become expensive.
Do not confuse the campaign seed with a saved input. Different engine
builds, coverage layouts, or configurations may generate different
mutation sequences from --seed. Reproduce a failure with its saved
artifact and replay, not with the campaign seed alone.
The runner reports compact status fields:
NEW: an input reached behavior not represented by the corpus and was saved.REDUCE: a smaller input preserved previously found behavior.cov: covered edges or blocks.corp: number and total byte size of in-memory corpus entries.exec/s: target executions per second.rss: current process memory use.DONE: the run reached its time or iteration limit.
Coverage growth shows that the fuzzer is exploring. A plateau is a
reason to inspect the target, generator, seeds, and dictionary, not
proof that the code has no bugs. Very low exec/s, steadily growing
rss, or repeated timeouts usually calls for a narrower target or
tighter bounds.
4.2. Maintain a corpus§
A corpus is a collection of small raw inputs that collectively reach useful code. It is not a directory of every attempted input. Use a stable corpus directory across campaigns:
TARGET run quality/corpus/my-target --time=3600 --max-input-size=4096
Seed a target with a few small examples that reach meaningfully
different behavior, then let the fuzzer extend them. Because roc-fuzz
decodes raw entropy into typed values, a seed file’s bytes may not
resemble the value printed by show. Check manually created seeds with
the target executable.
Reduce a large corpus into a new, initially empty directory while preserving coverage:
TARGET reduce-corpus quality/corpus/my-target quality/corpus/my-target-small
Keep valuable seeds and fixed crash inputs with the target in version
control. Use a deliberate directory such as fuzz/corpus/<target>/,
document why each seed matters, and replay it in tests. Generated
.roc-fuzz/ directories are ignored at every depth; keep evolving
campaign corpora in local storage or CI caches, and preserve findings as
CI artifacts until reduced to regressions. Run them after code changes
so the target does not silently stop compiling or exercising the
intended behavior.
Dictionaries are most effective when recognizable bytes flow fairly directly into a parser. They may help less when a structural generator heavily transforms the raw input.
Changing a generator can make old corpus and crash files decode to
different typed values. The generator is part of the target’s input
format. Keep it stable when practical and use show to check saved
inputs after a change.
4.3. Diagnose an unstable failure§
Always replay a saved failure in a fresh process. If it does not reproduce, investigate:
a generator or target change that altered the typed value or property;
a different or truncated raw input file;
an executable built from different target code; or
a compiler or runtime defect.
Treat the failure as actionable once its conditions are understood and, ideally, it reproduces from a clean process.
4.4. Know what counts as a failure§
Coverage feedback tells the engine which inputs explore new behavior; it does not tell the runner whether a result is correct. The property in the target is the correctness oracle.
roc-fuzz saves explicit Roc crash and failed expect calls, per-input
timeouts, and process memory-limit failures. A wrong answer that does
not break the target’s property is invisible to the fuzzer, which is why
choosing a strong property matters more than simply calling the function
under test.
4.5. Prepare a long campaign§
Before committing substantial CPU time, check that:
the target tests one named behavior with a meaningful property;
invalid input returns normally unless invalid input crashing is the bug;
the generator creates useful values without excessive rejection;
input sizes and allocations are bounded;
every generated input terminates or is caught by the timeout;
a one-minute run gains coverage without immediate hangs or memory growth;
a deliberate temporary
crashis saved, shown, replayed, and minimized as expected; andthe corpus and fixed failures have a clear place in the repository and CI.
4.6. Command reference§
| Command | Behavior |
|---|---|
TARGET or TARGET run [CORPUS] [OPTIONS] | Check examples and baseline, then explore, with one report. |
TARGET check [OPTIONS] | Run each declared example once in a fresh process and compare the baseline. |
TARGET accept [OPTIONS] | Explicitly accept the complete valid allocation observations. |
TARGET explore [CORPUS] [OPTIONS] | Run coverage-guided exploration independently. |
TARGET examples | List names, revisions and input digests. |
TARGET show INPUT | Display the decoded representative. |
TARGET replay INPUT [--detailed] | Reproduce one input under the supervisor. |
TARGET measure INPUT | Print one input’s named allocation observations as JSON. |
TARGET retain INPUT --name NAME --output DIR | Keep original bytes and print a source declaration. |
TARGET minimize INPUT OUTPUT [OPTIONS] | Minimize a reproducing finding within the campaign budget. |
TARGET reduce-corpus INPUT OUTPUT | Retain coverage-relevant inputs in OUTPUT. |
TARGET raw ... | Advanced libFuzzer controls. |
Friendly execution options include:
| Option | Meaning |
|---|---|
--time=N | Exploration budget in seconds; default 60, 0 unbounded. |
--runs=N | Exploration execution bound. |
--timeout=N | Invocation deadline in seconds; default 10. |
--max-input-size=N | Bound corpus input bytes, from 1 byte through 1 MiB. |
--memory-limit=N | Exploration process RSS limit in MiB. |
--dictionary=FILE | Mutation tokens. |
--seed=N | Exploration random seed; retain input bytes for reproduction. |
--report-dir=DIR | Absent or empty destination for the complete report. |
--baseline=PATH | Override <declared-target-name>_baseline.json. |
--profile=NAME | Select the named baseline profile. |
--detailed | Capture a detailed logical trace for replay. |
--no-detect-leaks | Disable the broader invocation leak check. |
Provenance options are --source-revision, --roc-version,
--platform-release and --platform-sha256, each supplied as --name=value.
See Add fuzzing to CI for evidence and baseline workflows.
Minimization and corpus reduction accept --time, --runs, --timeout and
--report-dir. They default to a 60-second campaign budget and require a positive
--time; unbounded exploration remains available with run --time=0 or
explore --time=0. A successful minimization writes the best witness, including
the unchanged original if no reduction was found. Failed minimization preserves
an existing output. Corpus reduction requires an absent or empty output directory
that does not overlap the input directory.
5. Add fuzzing to CI§
Every target executable contains its supervisor, baseline reader and evidence writer. A consumer needs Roc and the compiled target. The Blueprint consumer template supplies pinned compiler downloads, ordinary tasks and a workflow with no package provider. It lives outside the release example URL rewriting path.
roc build --fuzz fuzz/main.roc --output=fuzz-target
./fuzz-target run .roc-fuzz/corpus \
--report-dir=.roc-fuzz/evidence \
--time=60 --timeout=10 --max-input-size=4096 \
--source-revision="$SOURCE_REVISION" \
--roc-version="$ROC_VERSION" \
--platform-release="$ROC_FUZZ_RELEASE" \
--platform-sha256="$ROC_FUZZ_SHA256"
run checks the complete declared example inventory and exact accepted baseline
before exploring. A failed example or baseline change skips exploration and
records the reason in the same report. Each declared example executes once in a
fresh child process. Exploration reuses one instrumented child for efficiency.
The parent writes reports even when a child crashes or exceeds its deadline.
Example preparation also runs in a supervised child: a crashing or nonterminating
maker cannot terminate the supervisor or accept a partial inventory.
Defaults are 60 seconds of exploration, a 10-second per-input deadline and leak
checking. check runs examples and baseline checks without exploration;
explore runs the search independently. A missing accepted entry reports
unbaselined and allows normal execution. Exploration with zero accepted
executions is inconclusive and exits unsuccessfully.
5.1. Review and accept allocation observations§
./fuzz-target check
./fuzz-target accept --profile=default
./fuzz-target check
The default file uses the target’s declared name, such as
my_target_baseline.json, in the current working directory. Use
--baseline=PATH to override it. Separate entries hold each supported host target
and named profile. Commit the accepted file after reviewing it; CI runs check
or run and never accepts changes automatically.
Matching is exact: example names, revisions, input digests, region inventory, metric version and all statistics. A reduction also requires review. Acceptance runs each declared example once and updates the relevant entry atomically only when the complete inventory passes. Rejections, assertions, crashes, timeouts, incomplete observations and write failures cannot accept a partial inventory.
5.2. Read the evidence§
An explicit report directory must be absent or empty. Keep it separate from
inputs and the persistent corpus. Without an override, each invocation chooses
a fresh directory under .roc-fuzz/reports/.
The directory contains:
report.json, followingroc-fuzz-quality/1;summary.mdand the combined childrun.log;original example inputs, worker observations and original trace snapshots;
exploration failure artifacts and
original-input.binfor the last attempted input, with capture completeness and its SHA-256 in the report; andbounded original-run logical traces, including unfinished regions on failure.
The report records the metric version, effective execution controls (including leak checking), preparation status, structured finding kinds and each worker’s exact span within the combined log. Inputs are capped at 1 MiB in the friendly commands; the prepared-inventory JSON transport is capped at 32 MiB.
Use the report’s outcome, baseline status, metric validity, trace-rule result
and capture completeness independently. Missing diagnostic events do not make
allocation statistics invalid; online trace rules continue past capture limits.
A killed writer can leave an incomplete capture, which is explicitly identified.
When a worker returns no result, metric_valid, trace_rules_passed and
capture_complete are null (unknown). Fatal outcomes remain separate from
partial evidence in the original snapshot.
A detailed replay is separate evidence rather than a reconstruction of the
original run:
./fuzz-target replay finding.bin --detailed --report-dir=.roc-fuzz/replay
./fuzz-target measure finding.bin --report-dir=.roc-fuzz/measurement
./fuzz-target retain finding.bin --name previous-crash --output=fuzz/regressions
measure prints a JSON array of named allocation observations. retain keeps
the original bytes and prints a Fuzz.saved source declaration plus the decoded
representative. Keep the binary witness when storage shape matters.
Exit status 0 means success, 1 means a finding, baseline change or inconclusive
exploration, and 2 means an infrastructure/configuration failure. Native child
termination remains separate in the report.
5.3. Keep project policy in the project§
Enumerate your targets in ordinary Blueprint tasks or a CI matrix. Choose valid input domains in properties or validated types, and use generators for bias. Keep resource bounds appropriate for the operation. Acceptance remains a separate explicit task. The repository’s maintainer toolchain can use Nix while consumer tasks use only fetched compilers and self-contained target executables.
5.3.1. GitHub Actions pattern§
The template under templates/quality/ includes a complete pinned-action workflow
for Linux and Apple Silicon macOS. Its verification task builds, checks and
explores with run --report-dir=.... Upload evidence even on failure:
- name: Verify quality
run: blueprint workflow verify
- name: Preserve evidence
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9
with:
name: quality-${{ runner.os }}
path: .roc-fuzz/evidence
if-no-files-found: error
5.4. Coverage limits§
Coverage feedback guides discovery; it is not a proof of correctness or a claim that every source branch ran. A retained corpus and a declared regression inventory serve different purposes: coverage reduction can remove redundant seeds while source-declared examples still run independently on every check. Attempted, accepted and rejected execution counts show whether the property actually exercised useful inputs. A campaign that rejects every input provides no passing evidence.
6. Release bundle provenance§
roc-fuzz does not store native archives or objects in Git. The independent
link-inputs.yml producer builds x64-musl and Apple
Silicon inputs on native GitHub-hosted runners, packages them without
libhost.a, and tests the exact archives with fresh hosts and final bundles. It
attests a canonical manifest and each archive but cannot publish. The trusted
default-branch publisher verifies that evidence for the exact same-repository
pull-request ref and SHA before creating an immutable release.
Platform builds restore the reviewed native-libraries.lock.json manifest and
archives, verify sizes and SHA-256 digests on downloads and cache hits, validate
their locked release, source and inventory, and build fresh hosts.
The reviewed content hash is the routine trust anchor, so a valid cache hit uses
zero network and does not contact GitHub’s attestation service. A corrupt cache
entry is discarded and only the exact locked asset may be downloaded again. The
producer attestations are admission evidence checked once by the trusted
publisher. See linker-input setup.
After those consumer tests pass, GitHub’s attestation service creates
signed SLSA build provenance and an SBOM attestation for the exact
.tar.zst bundle. GitHub binds the attestation to the release workflow
and source revision with its OIDC identity, signs it through Sigstore,
and records it in the repository attestations API. No long-lived signing
key is stored in the repository.
Each release also publishes <bundle>.intoto.jsonl for offline
attestation verification, and three files beside the bundle that describe
what it holds:
<bundle>.spdx.json, an SPDX software bill of materials;<bundle>.cdx.json, the same graph in CycloneDX;<bundle>.sbom-manifest.json, the evidence manifest that names the bundle and each SBOM by size and SHA-256.
roc-blueprint’s SBOM tool writes them through roc-automation’s
actions/sbom-sidecars, from the reviewed sbom/composition.json. The tool
checks that file against Blueprint.roc and Blueprint.lock: every input of
the environment is either a component or excluded with a reason. It lists
every member of the archive by digest and binds the documents to the bundle’s
final bytes. The components are the platform itself and the libFuzzer, Zig
runtime, musl and LLVM runtime code in the native inputs; Zig is a build
dependency. What the documents do not establish is stated as incomplete
coverage, not left out.
The build pins the Zig release and the SHA-256 of the libfuzzer-sys source
archive that scripts/native-deps.json names. Both targets are compiled against the headers Zig bundles; the
build reads no installed Apple SDK. The libraries are byte-reproducible, so
anyone with that Zig release can rebuild them from the recorded source revision
and compare digests. Native-library archives and the
final platform bundle are separate published and attested subjects, with
independent versions.
6.1. Verify a release§
Download a bundle from the GitHub release, then run:
gh attestation verify roc-fuzz-<version>.tar.zst \
--repo lukewilliamboswell/roc-fuzz
Verification checks the bundle digest, Sigstore signature, certificate and repository identity, and transparency information. This project does not claim a particular SLSA level; the attestation establishes the workflow and revision that produced the released bundle.
For offline or archived verification, download the matching
<bundle>.intoto.jsonl asset and pass it to GitHub CLI:
gh attestation verify roc-fuzz-<version>.tar.zst \
--bundle roc-fuzz-<version>.tar.zst.intoto.jsonl \
--repo lukewilliamboswell/roc-fuzz
The SBOM documents are attested with the bundle. Each names the bundle’s SHA-256, which must equal the SHA-256 of the downloaded file.
7. Understand roc-fuzz internals and advanced features§
This chapter explains the runtime model, reusable generator dispatch, allocation assertions, and the low-level libFuzzer escape hatch. Application authors do not need this material for the normal target workflow.
7.1. Understand the in-process model§
The embedded libFuzzer calls a target thousands of times in the same process. It does not start a clean executable for every generated input. This is what makes it fast. Roc’s purity means target calls cannot leak application state or background work into later calls, and determinism makes saved inputs reliable with the same target and generator.
A reliable target still needs all of the following properties:
Invalid or unusual inputs return ordinary values such as
Err, rather than triggering an intentional assertion or process exit.One input normally finishes in less than 10 milliseconds.
Inputs and allocations have sensible upper bounds.
Every input terminates.
The runner is a poor fit when one test takes a significant fraction of a second, can grow without a practical memory bound, or is expected to terminate the process for invalid input. If a crash, timeout, or memory blow-up would be a bug, it remains a useful fuzzing failure.
A saved entropy input produces the same typed value and result when the target and generator are unchanged. Encoded examples use the binary parser and the same property instead. Keep the executable alongside important failure artifacts when exact historical reproduction matters.
7.2. Understand generator dispatch§
generator_for uses Roc’s ordinary static dispatch. It is a method
associated with the target’s input type, not a runtime interface,
registry, or callback table. Package APIs can require their own methods
with where clauses, so the method does not need to be a
compiler-defined name.
The pattern deliberately mirrors Json.parser_for:
Json.parserequires its result type to have a compatibleparser_formethod and asks that type for a JSON parser.Fuzz.targetrequires its input type to have a compatiblegenerator_formethod and asks that type for a fuzz generator.The checker resolves both to a specific method implementation at compile time. The compiled call has no dynamic-dispatch overhead.
Conceptually, Fuzz.target has this requirement:
target : { name : Str, examples : List(Example(a)), test : a -> Outcome, show : a -> Str } -> Target
where [a.generator_for : FuzzEncoding -> Generator(a)]
FuzzEncoding currently contains the Default policy. Passing a policy
value follows the same shape as JsonEncoding and leaves room for
additional generation policies without changing the method pattern.
Use Fuzz.target when generation is part of a reusable named input
type. Use Fuzz.target_with with an explicit Generator(a) for a local
structural input or when choosing between generators for the same type
in different targets.
7.3. Measure allocations and validate traces§
Every property callback has an automatic $property region. Decoding and
entropy generation finish before that region starts. Named regions use one
native engine:
Fuzz.measure! : Str, (() => a) => { value : a, stats : Fuzz.Stats }
Fuzz.start_measure! : Str => Fuzz.Measurement
Fuzz.Measurement.finish! : Fuzz.Measurement => Fuzz.Stats
measured = Fuzz.measure!("decode", || decode(input))
check_result(measured.value)
if measured.stats.reallocs != 0 { crash "decode unexpectedly reallocated" }
window = Fuzz.start_measure!("manual")
value = build(input)
stats = window.finish!()
use(value, stats)
Finish manual windows exactly once, in nesting order. Invalid handles, abandoned windows, instrumentation limits and arithmetic overflow fail explicitly. The returned value is still alive at the measurement boundary. Nested windows are inclusive: a parent contains its children’s allocator operations.
The platform transfers measured results through an opaque ownership container
before finishing the region. This keeps every returned field alive even when
the caller reads only stats or discards the result. The private container is
measurement bookkeeping and is excluded from allocator statistics and explicit
counters in every enclosing region. Ordinary application Box.box allocations
remain counted. The invocation-wide leak check includes both kinds of storage.
7.3.1. Understand the statistics§
allocs,reallocs,deallocsSeparate Roc allocator hook counts. A realloc counts once as a realloc; its internal allocation and release are not counted again.
allocs + reallocsgives the number of allocation requests.requested_bytesSum of each allocation’s requested length and each realloc’s full new length. This includes Roc’s own allocation headers and excludes the host’s bookkeeping header. It measures traffic, rather than RSS or copied bytes.
entry_live_allocs,entry_live_bytesRoc allocations already live at entry, including input storage. Allocations made before a window contribute its entry and peak memory, rather than its allocation count.
exit_live_allocs,exit_live_bytesMemory alive when the window finishes. This can include a returned value and is not itself a leak.
peak_live_bytesActual maximum live requested bytes during the window, including entry-live memory and simultaneous old/new blocks during realloc.
The broader invocation leak check runs after the complete target adapter returns.
It includes input preparation and teardown, which the property region excludes.
--no-detect-leaks disables that check. Metrics remain separate from trace rules
and from diagnostic capture completeness.
Ordinary Roc assertions can inspect any statistic. The allocation assertion
helpers expect_allocs_at_most! and expect_allocs_at_least! take zero-argument
callbacks (|| operation(input)) and count allocations plus reallocations.
Low-level process counters remain available for tightly focused assertions;
assert on differences, never their absolute process-wide totals.
7.3.2. Declare logical trace rules§
Fuzz.trace_rules!([
Before("decode", "checked"),
Count("checked", 1),
Forbidden("fallback"),
])
measured = Fuzz.measure!("decode", || decode(input))
check_result(measured.value)
Fuzz.mark!("checked")
Register rules before the region’s first event. Events are direct marks and
completed immediate-child regions. Before(a, b) requires both events and every
a before every b; Count(event, n) requires exactly n; Forbidden(event)
requires none. A grandchild completion is an event of its immediate parent.
Rules are evaluated online even after diagnostic capture fills. Detailed replay exports ordered events, explicit gaps and unfinished regions. Parent identifiers and occurrence numbers distinguish repeated names. Neither traces nor assertions contain timestamps. Capture truncation does not turn an invalid trace into a passing one and does not invalidate completed allocation statistics.
7.3.3. Accept an exact allocation baseline§
Declare examples with names, revisions and make: || value, alongside saved
artifacts. TARGET check runs each once in a fresh process. TARGET accept runs
the same complete inventory once and atomically accepts valid observations.
No warm-up or statistical repetition is needed for these deterministic metrics.
The default file is <declared-target-name>_baseline.json in the working
directory: a target named my_target uses my_target_baseline.json. Override it
with --baseline=PATH. Entries are separate by host target and --profile=NAME.
An absent entry reports unbaselined and permits normal execution.
Every example name, revision, exact input SHA-256, region inventory, metric version and statistic must match. Reductions and increases both need explicit acceptance. Failed or rejected examples, incomplete observations and failed baseline writes preserve the existing accepted file. Check and acceptance never repeat examples to produce a more convenient result.
7.3.4. Binary inputs and witness identity§
Structural values use the platform-owned binary format through Roc’s existing
encoder_for and parser_for derivation. Nominal inputs opt in with both methods.
Fuzz.encode(value) creates a versioned encoded-value input; Fuzz.entropy(bytes)
frames generator entropy. Source examples are encoded into actual mutable seeds.
Host preparation copies bytes and releases temporary Roc values before running
an input. Encoding does not invert arbitrary Fuzz.map functions.
The frame is RFZ, version byte 1, form byte (0 entropy, 1 value), then
payload. Bare historical bytes without the reserved RFZ prefix remain entropy.
Artifacts whose interpretation changes across platform versions need their
original executable; cross-version corpus migration is outside this API.
Unsupported versions, malformed
values and exceeded decoding budgets reject. The parser allows at most 1 MiB,
4096 value nodes and 128 total containers, validating counts before allocation.
Generation chooses bias. Preconditions or validated types define validity for
both forms; a range enforced only by a generator cannot protect encoded values.
Logical encoding is not a storage snapshot. Preserve original entropy when
aliasing, capacity or slices are part of a witness. retain copies original
bytes, then prints a saved declaration and a decoded representative.
Float encoding follows Roc’s floating-point value semantics. Do not rely on
NaN payloads surviving compiler operations; use integer bit inputs for
payload-sensitive properties. With the October 27 compiler, dictionary keys
that are structural records or tuples can crash compiler specialization during
codec derivation. --specialize=no avoids that compiler limitation; ordinary
scalar and tag keys work with the default backend.
7.4. Use the libFuzzer interface§
TARGET raw [LIBFUZZER_ARG...] passes arguments directly to the
embedded libFuzzer CLI. Run this for its complete option list:
TARGET raw -help=1
Prefer run, show, replay, minimize, and reduce-corpus for the
normal workflow. Low-level flags such as -max_total_time and
-max_len belong behind raw; run accepts the stable friendly
options listed above. raw is an escape hatch for libFuzzer features
the friendly command layer does not yet expose directly.
7.5. Further reading§
LLVM libFuzzer documentation covers the in-process execution model, options, corpus behavior, output, and FAQ.
8. Contributing to roc-fuzz§
The platform has four layers:
platform/Fuzz.rocdefines typed generators, record-builder composition, and statically dispatched target construction.platform/Target.rocerases the app’s input type behind closures.platform/main.rocexposes the byte-oriented native boundary and declares the x64-musl and Apple Silicon macOS targets.src/main.zigadapts generated Roc ABI calls to libFuzzer and translates the friendly runner commands.
src/roc_platform_abi.zig is generated from Roc’s ZigGlue.roc.
8.1. Toolchain§
roc-fuzz follows the roc-automation manual. Two Roc compilers are involved, and they are not interchangeable:
roc-stableruns the scripts underscripts/and nothing else. It is one nightly release, named inBlueprint.rocand repeated in therocfield of every script header. It changes only through a reviewed pull request.rocchecks, tests, builds and bundles the platform and every application that depends on it. It is the latest published nightly and is not pinned anywhere: the platform, the examples and the test targets carry norocfield. Users install the newest compiler, so that is the one the platform has to work with.
Blueprint.roc declares the tooling environment and Blueprint.lock pins it:
roc-stable, the Roc bundles the scripts depend on, and the programs they
start. roc-blueprint
realises that environment with Nix, the same way on a contributor’s machine and
on every CI runner. To work on roc-fuzz you need:
Nix;
the
blueprintcommand, from the roc-blueprint release that the platform URL inBlueprint.rocnames:nix profile install github:lukewilliamboswell/roc-blueprint/VERSION, or the release’s prebuilt binary checked against its published SHA-256;a current Roc nightly installed as
roc.
The environment supplies the Zig release that Blueprint.roc names, from
zig-overlay, to build the host and its
linker inputs and to format the host bindings. scripts/native-deps.json names
the same release, and the supply-chain check holds the two together.
blueprint tasks # list the declared tasks
blueprint run check # everything a pull request must pass that needs no host
blueprint shell # enter the environment and run scripts/*.roc directly
Run blueprint from an ordinary shell. Inside the environment roc-stable is
the pinned compiler and your own roc remains visible. Arguments to a
task follow --, for example blueprint run build-platform -- --target x64musl.
Linker inputs have an independent, content-addressed release cycle;
libhost.a is deliberately outside that cycle and is built with each platform
update. This separation means a host or Roc source change does not rebuild large,
stable runtime inputs. The builder uses zig ar.
Repository examples use relative paths to the working-tree platform. Each
release provides roc-fuzz-examples-VERSION.zip with complete applications,
supporting modules, the cases that describe them, and that release’s exact
platform URL. Nothing else in an archived example differs from the repository.
The Nightly compatibility workflow runs the latest release’s archive, exactly
as published, on the latest nightly every day.
8.1.1. Maintain the tooling§
Nothing updates the tooling on a schedule. Each of these is a pull request a maintainer opens:
- Update the lock
Run
blueprint update, orblueprint update INPUTto move one input, thenblueprint run check.- Change the tooling compiler
Change the tag in
Command("roc-stable", ...)inBlueprint.rocand therocfield of every script to the same tag. The check script fails until they match. Runblueprint update rocif the locked overlay predates that nightly.- Upgrade Blueprint
Change the platform URL in the
Blueprint.rocheader, runblueprint checkand then a fullblueprint update, and review the new lock. No workflow names the version:actions/setup-blueprintreads it fromBlueprint.rocand verifies the binary against the checksums roc-automation records, so a release it does not record yet needs that action repinned first.- Update a script dependency
Change the release URL in each script header and under
RocPackagesinBlueprint.roc, adding any new dependency of that dependency (roc-stable deps scripts/check_all.roclists them), then runblueprint update.- Keep the SBOM composition current
sbom/composition.jsongives every inputBlueprint.rocdeclares (each source, tool, fetched program, script package and task) a disposition. A new task or tool needs an entry there too, or the release dry run’s SBOM step refuses the composition. A change to what the native inputs contain, such as a new library or a new Zig release, changes its components.- Regenerate the host bindings
See Host bindings.
8.2. Repository scripting convention§
Repository-owned automation is written in Roc. Put executable entry points in
scripts/*.roc and reusable logic in scripts/src/*.roc. Use the existing shared
modules for commands, paths, hashing, source edits, archives, and release metadata.
Do not add Python scripts, shell launchers, or build-before-run wrappers to work
around compiler behavior. Keep workflow YAML limited to job orchestration and
small environment/output plumbing; substantive automation belongs in Roc.
Every entry point starts with the #!/usr/bin/env roc-stable shebang, is
committed as executable, and is declared as a Task in Blueprint.roc. CI
calls tasks, never script paths, so a workflow and a contributor run the same
command. A script starts another script through Script.roc_stable! and runs
package work through Script.roc!, which is the roc on PATH. Never use
roc-stable to check, test or bundle the platform. A successful script must
return zero, and a failed script must remain a failure.
A script references every dependency by immutable release URL, and the same URL
is listed under RocPackages in Blueprint.roc. scripts/check_supply_chain.roc
holds each header to Blueprint.roc: the shebang, the roc field, and every
bundle URL. It also fails when a Roc file outside scripts/ carries a roc
field, and when a workflow references an action by anything but a full commit
SHA.
Roc scripts may invoke standard tools such as Git, curl, tar, zip, and unzip via
argument lists, without shell evaluation. Roc owns selection, validation, and
control flow. Declare a program a script starts under Tools in Blueprint.roc.
Pinned external tools and shared actions remain external dependencies;
for example, the shared documentation builder currently uses Python internally.
That does not justify adding repository-owned Python orchestration.
Pure regression checks belong in shared-module expects imported by
scripts/tooling_tests.roc. Filesystem and process checks belong in
scripts/tooling_integration_tests.roc; both run from scripts/check_all.roc.
8.3. Generate platform inputs§
Generate the current host’s ignored inputs with:
blueprint run build-platform
Pass -- --target x64musl or -- --target arm64mac explicitly in automation.
The default restores the archives selected by native-libraries.lock.json,
rehashes their manifest and target archive even on a cache hit, validates the
complete archive inventory, builds the current host, and writes local
SHA256SUMS. The archive digest is the reviewed authorization for routine use;
the cache key only locates likely bytes. This is why cache hits are still hashed
and why a cache miss downloads the exact locked release rather than rebuilding.
It avoids repeat network traffic without allowing a poisoned or truncated cache
entry to become trusted. Routine restoration does not query GitHub’s attestation
service. Never commit generated archives, objects, or manifests.
The Linker inputs producer, link-inputs.yml, runs on a pull request that
touches the recipe and on dispatch. One Linux job cross-compiles both targets,
and a job on each target runs every case against the candidate. It packages both targets without
libhost.a, tests each candidate through a fresh host and final bundle, emits a
canonical aggregate manifest, and attests that manifest and both archives. It has
no publication authority. Release new inputs for changes to their sources,
runtimes/toolchains, flags, targets, security fixes, or the macOS adapter compiled
into libfuzzer.a. The macOS coverage shim stays with libhost.a.
Publish link inputs runs trusted controller code pinned to a reviewed full
roc-automation commit. It dispatches the producer at the exact same-repository
PR head, verifies the workflow/ref/SHA, hashes and attestations, publishes an
immutable release named from the aggregate manifest hash, and adds only the lock
in a lease-guarded GitHub-signed commit. Candidate code cannot publish, while the
privileged job never executes candidate code. The PR-built release remains the
permanent dependency after merge; rebuilding it would break the evidence-to-byte
identity that the review admitted.
GitHub immutable releases must be enabled before the first publication. The controller checks the resulting release state and refuses to adopt a lock when the hosting policy does not enforce immutability; a content-derived name alone cannot prevent an administrator from replacing assets.
Before adopting a release, review its producer changes, exact source revision, input fingerprint, manifest and archive digests, sizes, target coverage, native link tests, and workflow attestations. Merge with a merge commit so the attested producer SHA and signed lock commit remain reachable. Ordinary pull requests then know only the reviewed lock, cache, verifier, and target files; they never dispatch or reproduce linker inputs.
The libraries are byte-reproducible: the same sources, Zig release and flags give the same bytes on any machine, in any directory, at any time. Nothing is compiled with debug information, which would record the absolute paths of the Zig installation, its cache and the working directory, and archive members are named by file name alone where Zig names them by cache path. The macOS target is compiled against the headers Zig bundles, so it builds on Linux as well as on macOS and reads no installed Apple SDK. Keep it that way: a change that makes two builds of the same commit differ is a defect in the recipe.
A routine pull request never rebuilds the libraries. It hashes a short list of
files and compares the result with the input_fingerprint the lock records;
LinkInputs.fingerprint_inputs is the list:
scripts/native-deps.json: the Zig release, the libFuzzer source, and the recipe revision;the macOS libFuzzer adapter and the stand-in header it is compiled with; and
the licence files packed beside the libraries.
No script, workflow or lock file is in it, so editing tooling never demands a
release. The recipe itself lives in scripts, and that is what the recipe
revision is for: raise recipe_revision when a change to the build alters a
library byte, such as a flag, a target or the list of libraries.
Reproducibility keeps that honest, and three checks guard it:
The
Linker-input recipejob ofci.ymlis part ofCI required. It asks whether the pull request touches the recipe, which is the list inLinkInputs.recipe_paths: the build script, the target definitions, the recipe file, the macOS sources, andBlueprint.rocandBlueprint.lock, which supply Zig. A routine pull request stops there. One that touches the recipe rebuilds both targets and, while the lock claims to be current, requires the result to be byte-identical to the locked release. A change that alters a library without raising the revision fails, naming the files, and cannot merge. So does a change that makes the build irreproducible.The
Linker inputsproducer makes the same comparison when it builds a candidate, and runs every case against it on both targets.The supply-chain check fails when a path in
LinkInputs.recipe_pathsis missing from the producer’spathsfilter, so a file added to the recipe cannot be left out of the trigger.
Run the comparison locally with:
blueprint run build-platform -- --target x64musl --target arm64mac --libraries source --libraries-only
blueprint run link-inputs -- --operation check-source
When the fingerprint changes, or that comparison fails, use this sequence:
Stage the change in a same-repository PR, raising
recipe_revisionif the build recipe changed. ItsPlatform bundlejob fails with a stale-lock error until the new lock is adopted.To inspect a nonpublishing candidate, dispatch
Linker inputson the PR branch withrelease_candidate=trueandexpected_shaset to its exact current head. For example, rungh workflow run link-inputs.yml --ref BRANCH -f release_candidate=true -f expected_sha=SHA, replacingBRANCHandSHA. Review both target tests, the manifest, archive hashes, and attestations. This run does not create a GitHub release or edit the PR.From
trunk, dispatchPublish link inputswithpull-requestset to that PR number:gh workflow run publish-link-inputs.yml --ref trunk -f pull-request=NUMBER. To recover an interrupted publication, also passproducer-runwith the ID of the successful producer run for the current head. The trusted publisher reruns the producer at the exact PR head, publishes the immutable linker-input release, and appends a GitHub-signednative-libraries.lock.json-only commit to the PR. Do not dispatch it from the PR branch or rebuild the inputs after merging.Review that lock commit and revalidate the updated PR. After its required checks pass, merge the PR with a merge commit. Publish a new Roc platform bundle later through the separate
Releaseworkflow, using the adopted lock.
The optional preview in step 2 does not replace the producer run performed by the trusted publisher in step 3. The publisher is the release operation; stop before that dispatch when only candidate validation is authorized.
The fully static build excludes FuzzerInterceptors.cpp. Its wrappers
locate libc functions through dlsym, which is not usable in the static
musl executable. The standard libFuzzer scheduler, mutators, corpus
engine, crash handling, minimizer, and merge engine remain unchanged.
Roc’s compare and switch instrumentation still supplies value feedback.
The macOS target includes FuzzerInterceptors.cpp: its dlsym lookups
work with macOS’s dynamic libSystem. Its deployment target is macOS
11.0, and its only allowed dynamic dependencies are system libraries.
src/macos_sancov.c supplies the public TLS slot required by Roc’s
macOS stack-depth coverage instrumentation.
src/macos_fuzzer_ext_functions.cpp binds the standalone runner hooks
directly because upstream libFuzzer’s Darwin dlsym lookup requires
-export_dynamic, which Roc does not pass. Both files are
repository-owned platform glue covered by the project license; the
native build, show/replay, fuzz, failure-artifact, and packaged-bundle
smoke tests exercise them on Apple Silicon.
8.4. Host bindings§
The host is compiled against src/roc_platform_abi.zig, which roc glue
generates from platform/main.roc. The file is committed. The host build reads
it and never generates it, so the host builds the same way on every runner and
a change to the interface is reviewed as a diff.
The compiler generates the bindings, so a new nightly can change them when
nothing in this repository changed. The Host bindings job of ci.yml
regenerates them with the nightly the run resolved and fails when the committed
file differs. Run the same check locally with:
blueprint run check-glue
The glue specification comes from the source archive attached to that nightly’s
release, verified against the SHA-256 that GitHub records for the asset. For a
compiler built from source, name its checkout with -- --roc-source DIR.
When the check fails and the change did not touch platform/, the compiler
changed the interface. Upgrading is a manual task, because a changed layout or
signature usually needs a matching host change:
Run
blueprint run check-glue -- --updatewith the latest nightly asroc.Review the diff and change the host where a layout or signature changed.
Build the host for every target and run the targets.
Open one pull request with the bindings and the host change.
8.5. Validation§
Every bug fix and significant new behavior should include an automated test at the narrowest useful layer. Changes to public behavior also update the relevant examples, interface documentation, or report schema. A pull request should explain the behavior changed and the validation performed.
The scripts use whichever roc comes first on PATH. To validate with a
compiler under development, put it there:
PATH=/path/to/roc/zig-out/bin:$PATH blueprint run check
blueprint run test-targets -- --operation check --verbose
blueprint run test-targets -- --operation build --verbose
blueprint run test-targets -- --operation seed
blueprint run test-targets -- --operation fuzz --max-total-time 2
blueprint run check needs no native inputs: it formats, tests the scripts,
runs the pin and supply-chain checks, the native allocation/trace engine and
supervisor lifecycle suite, type-checks the platform and runs its
expect tests from platform/main.roc, and type-checks every case in
tests/targets.json. After native inputs are prepared, test-targets
provides the focused operations shown above.
test-targets runs each stage of the operation (check, test, build, seed,
measure, quality, fuzz) over every selected case and prints one line per step as it
finishes, with its position, PASS, FAIL or SKIP, and its time in
milliseconds. A failed step does not stop the run: the case’s later stages are
skipped and every other case carries on. The run ends with each failure and the
end of its output, each skip with its note, the counts, the total time and the
slowest steps, and exits non-zero if anything failed. A command’s output is
shown only when its step fails; --verbose shows all of it as it runs. Colour
is used only on a terminal and never when NO_COLOR is set. --target NAME
and --path-prefix PREFIX narrow the selection.
Checked-in examples and test targets use the local platform. The nightly
compatibility workflow uses the latest release’s own archived examples and
immutable bundle. Validation
checks the exact example inventory, Roc formatting and types. After
generating the current host inputs, building creates every
self-contained executable and verifies a static x86-64 ELF on Linux or
an arm64 Mach-O with system-only dynamic dependencies on macOS. Seed
validation renders and replays each deterministic input. The fuzz
operation runs short campaigns and verifies that an intentional Roc
failure is saved byte-for-byte with follow-up commands.
Build the HTML manual, PDF, and generated API reference with:
blueprint run docs
blueprint run site
docs runs roc-docs, the renderer actions/build-docs runs, at the
roc-automation tools release that Blueprint.roc names and Blueprint.lock
pins by digest. It writes the working tree’s manual, PDF and API reference
under .docs-out/. site assembles what GitHub Pages serves: the latest
release’s documentation, restored from the archive that release published, with
the working tree’s under unreleased/.
The renderer does not run Mermaid. Each diagram’s PNG is prepared once, checked
in under docs/images/ with a provenance note saying how it was made, and
registered with its source’s digest in docs/prepared-diagrams.json. Editing a
diagram’s source fails the build until its image is prepared again. The final API audit fails if an exposed module or public entry
has no rendered documentation. The documentation and release workflows run the
same build and audit.
Every case runs the check, test, build, seed, and fuzz
stages by default. To switch off a case’s seed replay or fuzz run, set its
skip_seed or skip_fuzz and add an entry to the skips list in
tests/targets.json with the case name, the operation, a concrete reason and a
full issue URL. The supply-chain check rejects a skip without exactly one such
note, and a note left behind once the case runs again. The driver names every
skip and its reason at the start of each run. No case is skipped today.
A file under trophy-case/repros/ that can run is a case in
tests/targets.json. The one kept there is a deliberate type error that the
compiler once panicked on; blueprint run check requires roc check to report
it.
Four workflows each answer one question:
ci.ymlDoes this change work with today’s compiler? One job runs
blueprint run checkand audits the API reference. One job regenerates the host bindings and compares them with the committed file. One builder cross-compiles the host for both targets around the locked inputs and assembles a single bundle, and a job on each target downloads that bundle and runs every case against it.CI requiredaggregates them.release.ymlDo these exact bytes work? On a pull request it is a dry run under the version
0.0.1-dryrun: it builds the bundle, the examples archive, the SBOM, the documentation archive and the release notes, and tests the archive against the bundle on both targets.Release requiredaggregates it.nightly.ymlDoes the latest release still work with today’s compiler? It runs daily; see Release bundle.
fuzz.ymlHas today’s compiler broken a builtin? Once a day, and only on
trunk, it fuzzes every builtin target intests/targets.jsonon the latest nightly for a few seconds each, about ten minutes in all. It runsblueprint run test-targets -- --operation fuzz --campaign-dir DIR --path-prefix tests/targets/builtins/. The corpus underDIR/corpus/carries over between runs through the Actions cache. Each target’s failing inputs are kept underDIR/failures/and uploaded as thefuzz-failuresartifact. Every target runs before the job fails. Reproduce a failure with the target’sreplaycommand and the kept input, then report it upstream if it is the compiler’s.
A new nightly can fail an untouched pull request. Compare with the latest
Nightly compatibility run and with trunk before treating a failure as a
regression in the change.
Linux CI also rebuilds the checksum-pinned libFuzzer sources with
AddressSanitizer and runs a short native smoke campaign. This detects heap,
stack, use-after-free, and leak errors in the embedded C++ fuzzing engine on
every pull request, every trunk update, and before release publication. Run
the same check locally with
blueprint run native-sanitizers; it requires Clang,
curl, and tar. The sanitizer executable is CI-only and does not change the
static release artifacts.
The Roc driver runs on Linux and Apple Silicon without platform skips. Scripts execute directly with the fixed tooling compiler; compilation errors, warnings, and application failures retain their normal Roc exit status.
Use --suite-root PATH to run an unpacked examples archive containing examples/ and
tests/targets.json. Each invocation has fresh temporary executables and, unless
--campaign-dir names a directory to keep them in, fresh corpora.
Fuzz smoke tests pass corpus directories to libFuzzer; passing an individual seed
file would only replay it. Repository formatting is checked separately, not against
an older release’s frozen source.
Nightly compatibility selects the highest stable semantic-version platform release, ignoring linker-input releases and release candidates. Both targets consume the same selection and verify asset hashes. Releases through 0.4.1 use examples from the exact tag commit, rebound to that release’s own bundle (the old manual URL follow-up could occur after tagging). Newer releases must include the examples archive. The compatibility artifact records the release, source commit, compiler and asset identities used; a missing archive or failed download cannot become a passing test. Archives up to 0.4.3 pin every example to the compiler of their day, and the driver treats Roc’s version-mismatch warning as a failure, so those archives pass only with the compiler they name. Later archives carry no pin.
Every property callback has an automatic allocation observation. Source examples
run independently of the coverage corpus and can be accepted into an exact
baseline. Explicit named regions use Fuzz.measure! or manual measurement
windows; ordinary Roc assertions inspect their statistics. Existing builtin
regressions also use allocation assertion helpers around specific operations.
The quality stage exercises the real executable’s unbaselined, acceptance and
exact-match workflow for targets with declared examples. The native suite tests
crashes, deadlines, rejected inputs, baseline drift in both directions, original
shared evidence and atomic write failures.
Assert on a difference between two counter reads, never on a raw value: the counter is process-wide and libFuzzer reuses one process across millions of inputs. Only assert zero for a value that is uniquely owned and pre-sized, because copy-on-write allocation is correct when a value is aliased.
Separately, the runner checks after every input that the target freed
everything it allocated, and fails the input otherwise. Pass
--no-detect-leaks to a run to turn that off.
The byte-oriented builtin targets live under tests/targets/builtins/
and use explicit byte generators and Fuzz.keep outcomes. Their original
byte-valued probe results keep aliases alive and are not rejection statuses.
They are regression tests for the compiler and its
builtins: tests/targets.json lists them beside the examples, pull-request CI
runs them, and a release’s examples archive leaves them out. The applications
under examples/ are an end-user gallery and should
prefer Fuzz.target, typed generators, and the .Fuzz record builder.
A multi-file example must live in its own directory with main.roc as
its app root; the test driver enforces this so editor tooling can
discover the project naturally.
8.6. Release bundle§
Create a Roc platform bundle after generating both target input sets with:
blueprint run bundle -- --output-dir dist
build_bundle.roc verifies every generated input checksum, then includes the
archives and license notices in
roc bundle. Test the resulting bundle from an external
target with:
blueprint run test-bundle -- dist/<bundle>.tar.zst
8.6.1. What a release contains§
One immutable GitHub release per version holds:
| Asset | Purpose |
|---|---|
The bundle, under the filename roc bundle emits | What users depend on. The filename is its content hash. |
roc-fuzz-examples-VERSION.zip | The examples pinned to this release’s bundle, with the cases that describe them. |
roc-fuzz-docs-VERSION.zip | The manual and API reference for this version. The documentation site restores the latest release’s copy. |
roc-fuzz-VERSION.pdf | The manual. |
<bundle>.spdx.json, <bundle>.cdx.json | The bundle’s SBOM, in SPDX and CycloneDX, written from sbom/composition.json. |
<bundle>.sbom-manifest.json | The evidence manifest binding the bundle and its SBOM documents by digest. |
*.intoto.jsonl | The exported build-provenance attestations for the bundle and the examples archive. |
8.6.2. Write the release notes§
Before dispatching a release, commit a non-empty docs/releases/X.Y.Z.adoc, or
docs/releases/X.Y.Z-rcN.adoc for a release candidate, and add an include
for it at the top of docs/releases.adoc. The first heading
must be = roc-fuzz VERSION. Release notes are reviewed as documentation and
should explain the release to users: lead with the theme, show notable features
and compatibility changes, name the supported hosts, link to
verification material, and give upgrade or feedback guidance.
Use {platform-url} where the copyable application header needs the
content-addressed bundle URL, and {manual-url} for the release’s PDF. Do not
put a roc field in that header. The build job fills both in after it has made
the bundle, translates the note’s headings and source blocks to GitHub Markdown
with scripts/render_release_notes.roc, and appends links to the bundle’s SBOM
documents and, under Compilers, the output of roc version and
roc-stable version. That record is the release’s statement of what it was
built and tested with. Notes that never use {platform-url}, or that leave a
placeholder unresolved, fail the build.
The committed file keeps its placeholders and is never rewritten after the
release. A pull request renders the highest version under docs/releases/, so
a mistake in the notes being written fails on the pull request that makes it.
While that version is still the latest release, there is nothing to render.
8.6.3. Publish a release§
From the repository’s Actions tab, run Release on trunk with an X.Y.Z
version. The workflow:
rejects a version whose tag or release exists, requires
trunk, and requires the notes file;runs
blueprint run checkand the sanitizer check on that commit;cross-compiles the host for both targets, bundles once, and checks the requested version against the public API with
roc bump --expect, so a breaking change cannot go out as a patch;builds the examples archive with the real release URL, the SBOM, the documentation archive, the manual and the rendered notes;
on each target, serves the bundle on a loopback address and runs the archived examples against it, with an empty package cache;
attests the tested bundle, its SBOM documents and the examples archive; and
publishes from a job that checks out nothing and runs no repository script. roc-automation’s
actions/publish-releasestages a draft release at the tested commit, uploads every asset, downloads each one back and compares it with the tested file, and only then publishes.
After a final release the workflow rebuilds and deploys the documentation site, which leads with the latest release.
The publish job runs in the release environment. Use roc bump before
choosing a version:
roc bump --old <previous release bundle URL> platform/main.roc
A release candidate is the same dispatch with an X.Y.Z-rcN version. It goes
through every step above, holds the same assets, and is published as a
prerelease that is never the latest release. Nightly compatibility and the
documentation site ignore it. Fix what a candidate turns up on trunk and
publish -rc2; a candidate is immutable like any other release.
8.6.4. When a new compiler breaks the latest release§
Nightly compatibility runs every day. It selects the latest Roc nightly and
the latest stable platform release, and runs that release’s examples archive,
unchanged, on both targets with trunk’s scripts. Its report job keeps one
issue, titled "The latest release fails on the latest Roc nightly": opened by
the first failure, commented on by each later one, and closed by the next pass.
A run in which setup failed before any example ran fails without touching the
issue.
A release is immutable, so the fix is always a patch release that works with
the new compiler. Reproduce a past combination by dispatching the workflow with
a nightly-tag and a release-version.
8.6.5. Recover an interrupted publication§
Inspect exactly what exists before retrying. An unfinished draft can be completed from the same run’s artifacts: upload what is missing, verify the complete inventory, then publish. A published release that is incomplete cannot be repaired; publish a new version. Changed bytes are always a new version. Never replace an existing tag or asset, and never rebuild a release from a moving branch.
8.7. Design constraints§
Static dispatch happens while Fuzz.target is specialized for the app’s
input type. Target then erases that type, preventing the native ABI
from depending on every app record or tag-union layout.
The platform supports x64-musl and Apple Silicon macOS. Keep native calls behind the generated bindings, so that a compiler ABI change is caught by the bindings check and by compilation.
8.8. Shared examples archive§
Starting with 0.4.5, the examples ZIP has one root named
roc-fuzz-examples-VERSION, containing README.md, spec.json, and examples/.
The specification derives from the published examples in tests/targets.json.
Each app requests build_args: ["--fuzz"] and records replay and one-second
seeded smoke cases. Cases keep exact stdout and exit expectations while
stderr_match: "ignore" allows libFuzzer statistics to vary; raw stderr remains
available for diagnosis. Seeds and local harness metadata live under
examples/.roc-fuzz/. Historical flat archives remain readable.
The release test jobs run the exact ZIP and bundle through the reviewed shared
release-package test-archive utility before publication. Its loopback bundle
replacement preserves the archived sources and verifies the candidate bytes.
The daily Nightly compatibility workflow uses the shared single-job caller
on Linux x86-64 and Apple Silicon. Empty selectors test the latest final release
against the latest nightly and report through the shared compatibility gate.
Diagnostic selector overrides test historical pairs and do not update the issue
about the latest pair. The separate builtin fuzz campaign retains its own
schedule and corpus; its result does not determine release compatibility.
9. Project information§
9.1. Support, feedback, and releases§
Use GitHub issues for reproducible bugs and enhancement requests. Include the roc-fuzz release, Roc compiler version, operating system and architecture, target source or a minimal reproduction, and the relevant command output. Proposed changes use pull requests and follow the contributor guide.
Suspected vulnerabilities follow the private security reporting process and must not be disclosed in a public issue. Published bundles, PDF manuals, documentation archives, checksums, provenance, and human-readable release notes are available from GitHub Releases.
9.2. Origins and acknowledgments§
The foundational and exploratory work for roc-fuzz was created by Brendan Hansknecht. Brendan established the original Roc fuzzing integration, arbitrary input machinery, quality-target suite, and the early trophy case demonstrating the bugs this approach could find.
The platform in this repository extends Brendan’s experiment into a modern Roc
workflow with typed generators, reusable target construction, and a
self-contained executable built directly with roc build --fuzz. This work
would not exist without the foundation he developed.
10. Release notes§
Each release has its notes in docs/releases/, newest first. They are the
source of the text on that release’s GitHub page.
A release’s bundle URL holds the content hash of the bundle, which does not
exist until the release builds it. The notes therefore write {platform-url}
where an application header names the platform, and the release workflow fills
in the real URL when it publishes. Copy the URL from the
release page.
10.1. roc-fuzz 0.5.0§
roc-fuzz now combines pure Roc correctness, named source examples, exact allocation baselines, logical traces and coverage-guided exploration in one self-contained target executable.
Use the platform dependency attached to this release:
app [target] { fuzz: platform "{platform-url}" }
10.1.1. Examples and reproducible inputs§
Every target configuration declares examples, using [] when it has none.
Fuzz.example({ name, revision, make: || value }) creates a typed seed;
Fuzz.saved({ name, revision, bytes }) preserves an original witness. Each
example runs once in a fresh process, independently of coverage retention.
Typed values become actual mutable corpus seeds through the platform’s binary
format, derived using Roc’s existing encoder/parser protocols. Nominal input
types opt in with encoder_for : _ and parser_for : _.
Bare entropy witnesses keep their generator interpretation. Logical encoding is not a snapshot of allocation capacity, sharing or slices. Keep original bytes for storage-sensitive findings. Preconditions or validated types must validate both decoded and generated values; generator bounds express bias.
10.1.2. Allocation and logical trace evidence§
Fuzz.measure!("region", || operation(input)) returns { value, stats } and
records a named observation. start_measure! and Measurement.finish! use the
same scope engine. Statistics distinguish allocations, reallocations,
deallocations, requested bytes, entry/exit live memory and actual peak live
bytes, including old/new overlap during reallocations. The property itself is
measured after generation/decoding and before adapter teardown.
Trace rules support Before, exact Count and Forbidden over direct marks
and completed immediate-child regions. Online validation continues after
bounded capture truncates. Detailed replay identifies repeated paths, gaps and
unfinished regions. Timing measurements and timing assertions are excluded.
The earlier measurement/report API is replaced by named measurements. All
zero-argument callbacks use || and signatures such as () => a. Byte target
adapters now forward nonzero return values as rejection.
10.1.3. One supervised workflow§
TARGET or TARGET run checks examples and baselines, then explores and writes
one report. Defaults remain 60 seconds of exploration, with a 10-second
invocation deadline and leak checks. Failed examples skip exploration.
check, explore, accept, examples, show, replay, measure, retain,
minimize, reduce-corpus and the advanced raw boundary are embedded commands.
CI uses run --report-dir=... instead of the earlier ci command.
Baselines default to <declared-target-name>_baseline.json in the working
directory, with --baseline=PATH and --profile=NAME overrides. Every inventory,
revision, actual input digest, metric version and statistic must match. Both
increases and reductions require explicit acceptance. A missing entry is
unbaselined. Acceptance is separate from checking and preserves previous data
when the inventory fails or a write cannot complete.
Reports distinguish outcomes from allocation/trace evidence and capture completeness. Exploration counts attempted, accepted and rejected inputs; zero accepted inputs is unsuccessful and inconclusive. Original bytes and bounded trace breadcrumbs remain in supervisor-owned shared storage even when a child crashes. Diagnostic replay remains separate evidence.
10.1.4. Upgrade and verification§
The provider-free Blueprint template uses pinned Roc downloads and ordinary tasks and workflows. Consumers need no Nix, Zig, Python, Git executable or standalone coordinator. The maintainer environment still uses Nix. Acceptance is an explicit maintainer task, never part of verification.
Supported hosts are Linux x86-64 with musl and Apple Silicon macOS 11 or newer.
This release is validated with Roc nightly-2026-10-27-de79a0f. Float encoding
follows Roc value semantics; use integer bits for NaN-payload properties. The
current compiler’s default specialization can fail on binary dictionaries with
structural record/tuple keys; --specialize=no avoids that compiler limitation.
See the manual, the generated API reference, and the release’s checksums, SBOM and provenance. Existing artifacts are preserved; there is no automatic corpus migration or cross-version baseline comparison. Build a target, review its declared inventory, explicitly accept its allocation observations, and commit the resulting baseline for each host/profile.
10.2. roc-fuzz 0.4.5§
Published examples now use the shared roc-automation suite format. Each archive
has one named root, an explicit specification, and seeds for replay and a
one-second smoke run of every released example. The shared runner builds them
with roc build --fuzz, checks stdout and exit codes, and retains libFuzzer’s
variable stderr as diagnostics. Existing flat examples archives remain readable
by repository tooling. The platform API is unchanged.
Use this platform dependency in an application header:
app [target] { fuzz: platform "{platform-url}" }
The discarded-function-result lifetime repro now expects success on the current nightly. It retains its allocation-balance assertion: compiler fix roc#12652 releases the unused result before the next effect. The same repro exits 77 on the October 23 compiler and 0 on the October 27 compiler against the same platform bundle.
10.3. roc-fuzz 0.4.4§
This release makes the published platform work with current Roc nightlies again, and lets a package hold allocation ratchets on roc-fuzz without a test platform of its own. Release 0.4.3 fails the daily compatibility check on current nightlies, and a release cannot change, so 0.4.4 replaces it. Every change to the API is an addition; existing targets build and run unchanged.
10.3.1. Allocation ratchets§
Fuzz.measure_alloc_counts! counts a region’s allocations, reallocations and
deallocations separately, with the bytes it requested, where
Fuzz.measure_allocs! counts allocations and reallocations together.
Fuzz.realloc_count!, Fuzz.dealloc_count! and Fuzz.requested_bytes!
expose the counters underneath.
Fuzz.report_allocs! measures a region and reports it under a label. The new
TARGET measure [INPUT] command runs the target once, outside libFuzzer, and
prints one versioned JSON line per report:
{"format":"roc-fuzz-allocs/1","label":"grow a unique list","allocs":1,"reallocs":2,"deallocs":1,"requested_bytes":31}
A harness compares each line with the ceilings in a reviewed data file, with
no deliberate crash and no input files. A crash, failed expect or leak under
measure exits non-zero, and every other command discards reports, so the
same target can still be fuzzed. A plain roc build gives the same counts as
roc build --fuzz. The manual’s Hold allocation counts with a ratchet
section covers the line format, what each count means, and what makes counts
change.
10.3.2. Out of memory is a saved failure§
A Roc allocation the host cannot serve now ends the run the way a crash does.
The runner prints [roc crashed] out of memory and saves the input that asked
for the allocation, so it can be replayed and minimized like any other failure.
10.3.3. Other changes§
The host’s bindings are regenerated from the current compiler’s platform ABI.
The examples archive no longer pins a compiler. Each archived example differs from the repository’s only in its platform URL, so it builds with whichever compiler you run, without a version-mismatch warning. The compiler the release was built with is recorded under Compilers below and in the archive’s
release.json. The archive holds the examples a new user reads; the regression targets that exercise Roc’s builtins stay in the repository undertests/targets/.The host and the native linker inputs are byte-reproducible: the same sources, Zig release and flags give identical libraries on any machine. The macOS libraries are built against the headers Zig bundles, so the build reads no installed Apple SDK. Nothing is compiled with debug information, so the bundle is much smaller.
The bundle comes with SPDX and CycloneDX documents and an evidence manifest that binds them to the bundle’s exact bytes. They are linked below.
The online manual at https://lukewilliamboswell.github.io/roc-fuzz/ leads with the latest release; the default branch’s copy is under
unreleased/.
10.3.4. Use this release§
roc-fuzz 0.4.4 supports Linux x86-64 with musl and Apple Silicon macOS 11 or newer. Both hosts test the packaged bundle and the archived examples before publication. Copy this application header into a fuzz target:
app [target] { fuzz: platform "{platform-url}" }
The header names no compiler. Use a current Roc nightly.
Download roc-fuzz-examples-0.4.4.zip from the release assets for the complete
example suite, its supporting modules, and the cases that describe them, bound
to this exact bundle. Read or download the versioned PDF manual at
https://github.com/lukewilliamboswell/roc-fuzz/releases/download/0.4.4/roc-fuzz-0.4.4.pdf.
10.3.5. Upgrade and verification§
Replace the platform URL in each target’s header and remove the roc field if
the header has one. No other change to a target is needed. A package that reads
allocation counts from a deliberate crash can move to report_allocs! and
measure.
Saved corpus and crash files encode generator input rather than a stable public format. Keep the matching executable when exact historical reproduction matters.
The bundle, its SBOM documents and its build-provenance attestations are published together. Verify a downloaded bundle with:
gh attestation verify <bundle>.tar.zst --repo lukewilliamboswell/roc-fuzz
For bugs or feedback, open an issue at https://github.com/lukewilliamboswell/roc-fuzz/issues with the compiler version, target, and reproducing input. The full history since 0.4.3 is at https://github.com/lukewilliamboswell/roc-fuzz/compare/0.4.3...0.4.4.
10.4. roc-fuzz 0.4.3§
This patch removes redundant type exposures from the platform imports so the
platform compiles with Roc nightly-2026-10-04-130536d. The public API and native
linker inputs are unchanged from 0.4.2. Release validation and the frozen example
suite retain the accepted compiler nightly-2026-09-29-7f11a82.
The compiler upgrade and basic-cli 0.24.0 tooling migration are validated separately against this compatibility release.
10.5. roc-fuzz 0.4.2§
This release expands builtin fuzz coverage and makes published examples
reproducible independently of the repository’s development branch. The platform
API is unchanged from 0.4.1. Project validation and repository tooling now use Roc
nightly-2026-09-29-7f11a82.
10.5.1. Expanded builtin coverage§
The registered suite now contains 75 targets, including new checks for:
atan2for decimal and floating-point numbers;approximate equality for
F32,F64, andDec;decimal rounding and rounding to a step;
custom iterators and streams, including laziness, size hints, and leaks; and
numeric prefix parsing, round trips, ownership, and JSON number decoding.
These targets run through type checks, unit tests, builds, seed replay, and short fuzz campaigns. Wide UTF-16/UTF-32 decoding targets remain deferred because their APIs are not available in the selected compiler.
10.5.2. Use this release§
app [target] { fuzz: platform "{platform-url}", roc: "nightly-2026-09-29-7f11a82" }
Download roc-fuzz-examples-0.4.2.zip from the release assets for the complete
example suite, supporting modules, and test inventory bound to this exact
platform bundle. Repository examples instead use the local working-tree
platform. The platform no longer embeds a compiler pin; the examples record the
compiler used for publication, and workspace metadata selects the compiler for
development and CI.
Linux x86-64 with musl and Apple Silicon macOS remain supported. Both hosts test the packaged platform and frozen examples before publication. Reviewed native linker inputs retain their independent content-addressed release and lock.
10.5.3. Upgrade and verification§
Update your application’s platform URL to the one above and use the selected compiler. No platform API migration is required from 0.4.1. Compatibility with other compiler versions is not implied by this release.
The release includes the versioned manual at https://github.com/lukewilliamboswell/roc-fuzz/releases/download/0.4.2/roc-fuzz-0.4.2.pdf, a zipped documentation site, an SPDX SBOM, and provenance attestations for the platform and examples. Use these assets to inspect the published inputs and verify the downloaded artifacts. Report regressions through the repository’s issue tracker with the compiler version, target, and reproducing input.
10.6. roc-fuzz 0.4.1§
This patch pins the platform and project validation to Roc
nightly-2026-09-26-d6267b4. The September 26 compiler reports a warning for
an older compiler pin even inside a downloaded platform dependency, so 0.4.0
cannot be used by warning-free builds with that compiler. This release aligns
the platform pin with those downstream builds without changing the Roc API.
10.6.1. Use this release§
app [target] { fuzz: platform "{platform-url}", roc: "nightly-2026-09-26-d6267b4" }
Linux x86-64 with musl and Apple Silicon macOS remain supported. The native inputs are regenerated and attested under the updated workspace fingerprint; packaged-platform tests run on both supported hosts before publication. Repository tooling retains its separate September 18 compiler pin.
The versioned manual is available at https://github.com/lukewilliamboswell/roc-fuzz/releases/download/0.4.1/roc-fuzz-0.4.1.pdf. See the 0.4.0 notes for the allocation properties, CI evidence command, and migration from 0.3.0.
10.7. roc-fuzz 0.4.0§
roc-fuzz 0.4.0 makes fuzzing a better fit for day-to-day Roc development. It
adds allocation-aware properties and a purpose-built CI evidence command,
while keeping the usual workflow small: describe typed inputs, state the
property, and build a self-contained libFuzzer executable with roc build --fuzz.
This is also the first release with a complete manual and a hardened, reproducible release pipeline. The platform bundle, documentation, checksums, SBOM, and signed provenance are published together so that a fuzz target can be pinned and audited later.
10.7.1. Use this release§
roc-fuzz 0.4.0 supports Linux x86-64 with musl and Apple Silicon macOS 11 or
newer. It was built using nightly-2026-09-22-e494788 of the Roc compiler but is
expected to work on later compatible versions.
Copy this application header into a fuzz target:
app [target] { fuzz: platform "{platform-url}", roc: "nightly-2026-09-22-e494788" }
Read or download the versioned PDF manual at https://github.com/lukewilliamboswell/roc-fuzz/releases/download/0.4.0/roc-fuzz-0.4.0.pdf. The online manual at https://lukewilliamboswell.github.io/roc-fuzz/ follows the default branch and may describe changes made after this release.
10.7.2. Allocation properties§
Fuzz targets can now turn memory-management and copy-on-write expectations into
properties. Fuzz.measure_allocs! measures work, expect_allocs_at_most! and
expect_allocs_at_least! pin allocation behaviour, and expect_no_leaks!
checks that a region leaves no live allocations behind. Effectful
target_with! and from_bytes! constructors let those checks run inside a
target without making ordinary pure targets more complicated.
These checks have already paid for themselves: the expanded Dict and List campaigns found aliasing, uniqueness, and reference-counting regressions, with small reproducers recorded in the repository’s trophy case.
10.7.3. CI evidence, not just a pass or fail§
Every generated executable now has a ci command. It supervises a bounded fuzz
run and writes a machine-readable JSON report, a job-summary Markdown file,
logs, corpus hashes, and reproducing failures into one evidence directory. The
report records the downstream revision, Roc version, platform release, and
platform digest, making it much easier to answer what was tested after a CI job
has finished.
The runner also adds explicit artifact directories and optional leak-detection control. A new model-based Set suite and broader Dict and List sorting targets exercise the platform itself across Linux and macOS.
10.7.4. Documentation and release integrity§
The new manual covers the path from a first target through campaign tuning, corpus maintenance, failure minimization, CI integration, internals, and release verification. API documentation is generated from the platform source and ships beside both the PDF and an offline HTML archive.
Release bundles are tested on every supported operating system. Each release also includes an SPDX SBOM and downloadable GitHub attestations for offline provenance verification. Native libraries are checksum-locked and released independently, while the Linux libFuzzer build is exercised under AddressSanitizer before publication.
10.7.5. Upgrading from 0.3.0§
The platform’s low-level Target.run callback is now effectful and is named
run!. Applications built through Fuzz.target, Fuzz.target_with, or
Fuzz.from_bytes do not need to change for this API adjustment. Custom code
which constructs or calls Target directly must rename that field or method.
This release requires nightly-2026-09-22-e494788 or later, where tag unions in
output positions are implicitly open. Arbitrary.bytes now declares its error as
[NotEnoughData(U64)] without ..; code that uses ? to merge that error into
a wider union keeps working on the required compiler, but will not type-check on
older nightlies.
Saved corpus and crash files encode generator input rather than a stable public format. Keep the matching executable when exact historical reproduction matters, and start a fresh corpus when changing a target’s generator changes how bytes are decoded.
For bugs or feedback, please open an issue at https://github.com/lukewilliamboswell/roc-fuzz/issues. The full commit history since 0.3.0 is available in the comparison with 0.3.0.