Skip to content

Add FFI session event log fuzz target - #2005

Merged
benalleng merged 2 commits into
payjoin:masterfrom
caarloshenriq:ffi-session-event-log
Oct 28, 2026
Merged

benalleng merged 2 commits into
payjoin:masterfrom
caarloshenriq:ffi-session-event-log

Conversation

@caarloshenriq

Copy link
Copy Markdown
Contributor

Adds ffi-session-event-log, the first fuzz target for payjoin-ffi. It feeds a host-provided event log through the FFI JSON persisters and replays it, for both receiver and sender.

Bindings persist session events as JSON in host-side storage and hand them back through JsonReceiverSessionPersister/JsonSenderSessionPersister on resume. Both the events and their order come from outside Rust, and nothing guarantees the host returns what it saved.

What the fuzzer controls

The input is a JSON-lines event log, one event per line, as a persister would return from load. The same log is replayed as a receiver session and as a sender session.

A first version that only ran the input through from_json stalled at 478 coverage counters within a minute: libFuzzer can't build a valid PSBT, key or OHTTP config inside JSON on its own, so almost every input died in serde. A custom mutator fixes that:

  • Seeds: complete receiver (12 events) and sender (3 events) sessions taken from the core serialization tests, with expiration moved to 2100. They are embedded with include_str!, so the target bootstraps from an empty corpus. Every prefix of both seeds replays successfully.
  • Leaf mutations: hex strings (scripts, txids, signatures) and byte arrays (keys, short id, OHTTP config) are mutated in decoded form and re-encoded, which gets mutations past the encoding check and into the bitcoin and payjoin decoders behind it. Numbers are mutated as u64.
  • Log mutations: events are dropped, repeated, reordered or spliced in from either seed, which drives replay through transitions a well-behaved persister never produces.
  • Raw share: one in four mutations goes to libFuzzer untouched, so malformed JSON and non-UTF-8 input stay covered.

Invariants

Beyond not panicking, the target asserts that every event accepted by ReceiverSessionEvent::from_json or SenderSessionEvent::from_json survives a to_json/from_json round trip unchanged, compared through the core SessionEvent. Otherwise a resumed session would replay a different history than the one saved.

What is covered

ReceiverSessionEvent and SenderSessionEvent from_json/to_json, replay_receiver_event_log, replay_sender_event_log, and on a successful replay the session history accessors: pj_uri, pj_param, fallback_tx and status.

Not covered

  • The async replay variants. They share the decoding path but go through a different adapter.
  • Calling methods on the replayed session state.
  • The other FFI entry points (OhttpKeys::decode, InputPair constructors, SenderBuilder::new). I plan to cover them in follow ups.

Tests

Three unit tests cover the empty input, non-UTF-8 input, and both seed logs. They run with:

cargo test --manifest-path fuzz/Cargo.toml --bin ffi-session-event-log

Nothing in CI runs the fuzz crate's tests today, so this is a manual check for now.

Fuzzing results

From an empty corpus on x86_64 Linux, 600 seconds, default max_len:

Metric Value
Executions 2,728,388
Coverage counters reached 8973
Feature edges 33185
Corpus size 4660 inputs (7.2 MB)
Peak RSS 619 MB
Crashes / OOM / Timeouts 0 / 0 / 0

This is a smoke run, not a campaign. Coverage was still growing when it ended. Throughput is about 4500 exec/s.

The full receiver seed is about 9.4 KB, above libFuzzer's default 4096 byte limit, so with default settings the mutator only uses session prefixes and the late receiver states are underexplored. Longer runs should pass -max_len=16384.

Addresses part of #1267.

Assigning @benalleng to review.

Disclosure: co-authored by Claude

Pull Request Checklist

Please confirm the following before requesting review:

@coveralls

coveralls commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

Coverage Report for CI Build 40384290794

Coverage remained the same at 87.471%

Details

  • Coverage remained the same as the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 17854
Covered Lines: 15617
Line Coverage: 87.47%
Coverage Strength: 319.45 hits per line

💛 - Coveralls

@benalleng
benalleng self-requested a review October 23, 2026 11:21
@benalleng benalleng self-assigned this Oct 23, 2026

@benalleng benalleng left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to see more seeds since they are spliced into this fuzzer so anything e don't list in the seed is pretty much unreacheable. Some examples;

{"GotReplyableError":{"error_code":<n>,"message":"m","extra":{}}}
{"Closed":"Aborted"}
{"Closed":"FallbackBroadcasted"}
{"Closed":{"Success":"0000000000000000000000000000000000000000000000000000000000000000"}} {"Closed":{"Unrecognized":"0000000000000000000000000000000000000000000000000000000000000000"}}
{"Cancelled":[]}
{"Closed":"Aborted"}

perhaps these could be fed as an events.jsonl as a group of events since these are single lines to be fed as a bank of seeds to be spliced in.

_ => {}
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should add a type-change mutator.

fn typeflip_leaf(value: &mut Value) {
    *value = match value {
        Value::Null => Value::Bool(false),
        Value::Bool(b) => Value::from(u8::from(*b)),
        Value::Number(n) => Value::String(n.to_string()),
        Value::String(s) => match s.parse::<u64>() {
            Ok(n) => Value::from(n),
            Err(_) => s
                .parse::<f64>()
                .ok()
                .and_then(serde_json::Number::from_f64)
                .map(Value::Number)
                .unwrap_or(Value::Null),
        },
        _ => Value::Null,
    };
}

Also we can add a removal mutation to remove entries entirely.

fn remove_nth_entry(value: &mut Value, n: &mut usize) -> bool {
    if as_bytes(value).is_some() {
        return false;
    }
    match value {
        Value::Object(map) => {
            if *n < map.len() {
                let key = map.keys().nth(*n).expect("index below len").clone();
                map.remove(&key);
                return true;
            }
            *n -= map.len();
            map.values_mut().any(|v| remove_nth_entry(v, n))
        }
        Value::Array(items) => {
            if *n < items.len() {
                items.remove(*n);
                return true;
            }
            *n -= items.len();
            items.iter_mut().any(|v| remove_nth_entry(v, n))
        }
        _ => false,
    }
}

Bindings persist session events as JSON in host-side storage and
replay them through replay_receiver_event_log and
replay_sender_event_log on resume, so both the events and their
order originate outside Rust.

Feed the input as a JSON-lines event log through the FFI JSON
persister traits, assert that every accepted event survives a
to_json/from_json round trip unchanged, and query the session
history of any log that replays.

libFuzzer cannot produce a valid PSBT, key or OHTTP config inside
JSON on its own, so a custom mutator starts from complete receiver
and sender seed sessions and mutates them event by event: hex and
byte-array leaves are mutated in decoded form to get past the
encoding check, and events are dropped, repeated, reordered or
spliced in from the seeds to drive replay through transitions a
well-behaved persister never produces.
@benalleng

benalleng commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

looks like you need a rebase NVM I think this is a stale check from a previous PR that messed up

@caarloshenriq

Copy link
Copy Markdown
Contributor Author

looks like you need a rebase NVM I think this is a stale check from a previous PR that messed up

I think too, I rebased and keep failing

@benalleng benalleng left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The unused dep warnings were a red herring the real problem is the nix flake not being able to properly parse .jsonl files.

You need to add this to the flake.nix

// /flake.nix lines 120
|| (builtins.match ".*fuzz_targets/.*\\.jsonl$" path != null)

@benalleng benalleng left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TACK 190a31a

Ran for ~2 CPU hours at 3.5M cycles will followup with a corpus PR in qa-assets

@benalleng
benalleng merged commit 2b6ff22 into payjoin:master Oct 28, 2026
34 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants