test(fuzz): make the harness check what spec §7 says it checks

The fuzz oracle and its proptest twin checked much less than §7 claims
(S8). The harness now:

- records every frame the node attempts to send, before the transport
  can refuse it, so the "every frame fits mtu()" check can fail;
- reads the MTU from its own u16 (input bytes 1-2), mapped into
  MIN_MTU..=MAX_FRAME, instead of reusing the first step tag;
- reconciles drained drop counts per address: VersionMismatch/Malformed
  drops equal the frames that fail decode (the overflow slot makes up
  any difference), IdentityChanged drops never exceed frames whose
  `from` changed, and nothing is counted beyond the frames received;
- checks no addressed observation is stored, or forwarded in an Obs
  frame, past its lifetime;
- checks signatures round-trip from the input (an observation stored from
  a whole Obs frame keeps exactly its signature, and a stored signature
  never changes), replacing the input-independent sig_round_trip call;
- checks every acked id is stored (F1) and bounds the broadcast ids
  offered per received summary by SEND_QUEUE_CAP (F9);
- has a real adversarial Mode (input byte 0): well-formed Obs, Frag,
  Summary and Ack frames from 3 x MAX_PEERS addresses, with signed and
  max-size observations (u16 payload lengths), chosen observers
  (including the node's own id), ages, lifetimes and addressing,
  structured fragment streams that conflict, repeat or arrive out of
  order, replayed summaries, `from` values that change within a contact,
  and Acks from the peer's real id naming ids the node really sent;
- adds the §10 peer-table recovery probe, which the fuzz target runs
  after every adversarial input.

fuzz_twin.rs now runs the fuzz entry point on random bytes, generated
adversarial steps, and two rewritten properties that were trivially
true: summary_repeat_does_not_resend seeds broadcasts and checks a
replay re-sends nothing while the next on-time summary re-sends all;
peer_table_recovers_after_flood floods the table, checks it is full,
and probes recovery after PEER_IDLE_MS. The recovery tests are
#[ignore]d until the S1 idle sweep lands, and the fuzz target fails on
the first adversarial input until then. A 180 s nightly run with the
probe disabled found nothing else.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 08:44:09 -07:00
co-authored by Claude Opus 5.5
parent 4206e3f628
commit 0fc6333c17
3 changed files with 1108 additions and 595 deletions
+9 -12
View File
@@ -1,19 +1,16 @@
//! cargo-fuzz entry point for the `tines-core` node harness.
//!
//! Decodes arbitrary bytes into [`fuzz::Step`]s and drives the shared
//! [`fuzz::Harness`] — the same oracle the proptest twin in
//! `tines-core/tests/fuzz_twin.rs` runs, so any bug caught here is caught there
//! too (and vice versa).
//! [`fuzz::run_input`] reads the mode and MTU from the first three bytes and
//! drives the shared [`fuzz::Harness`] — the same oracle the proptest twin in
//! `tines-core/tests/fuzz_twin.rs` runs, so a property checked here is
//! checked there too. Adversarial-mode inputs also get the §10 peer-table
//! recovery probe.
#![no_main]
libfuzzer_sys::fuzz_target!(|data: &[u8]| {
let steps = fuzz::decode_steps(data);
// Randomize the MTU per corpus entry so different runs probe different wire
// bounds (the §7 "every sent frame ≤ mtu" check then catches clamps that
// fail at a specific MTU). First byte seeds it; an empty input falls back
// to 128.
let mtu = 128usize + (data.first().copied().unwrap_or(0) as usize) % 512;
if !steps.is_empty() {
fuzz::Harness::new(mtu).run(&steps);
if let Some(mut h) = fuzz::run_input(data) {
if h.mode() == fuzz::Mode::Adversarial {
h.check_peer_table_recovers();
}
}
});
+850 -406
View File
File diff suppressed because it is too large Load Diff
+249 -177
View File
@@ -1,227 +1,299 @@
//! Proptest twin of the `fuzz/` harness (Task 12).
//! Proptest twin of the `fuzz/` harness (spec §7 "Stable twin").
//!
//! The cargo-fuzz crate at `fuzz/` drives this very same module through a
//! scripted [`Node`]/[`MemStore`] transport, decoding arbitrary fuzzed bytes
//! into ordered steps and asserting no-panic / MTU-bounded output / drop-count
//! reconciliation (§7) plus the §10 adversarial-peer properties. The twin
//! reuses that *exact* harness via a `#[path]` include so any bug fixed in the
//! oracle is caught here too — "same harness", two entry points, not two copies.
//! The cargo-fuzz crate at `fuzz/` drives a scripted `Node` + `MemStore`
//! through decoded steps and checks the §7 properties after each one. The
//! twin compiles that *exact* harness in through a `#[path]` include, so it is
//! one oracle with two entry points, not two copies:
//!
//! This is the bounded-case-count proptest half of Task 12: it runs under the
//! normal suite (`cargo test -p tines-core`) and needs no nightly toolchain.
//! When `tines-core` is built with the optional `conformance` feature, the
//! harness's own signature round-trip also runs (see its source); otherwise the
//! twin covers store lifecycle through the public `observe()` path instead.
//! * `fuzz_inputs` feeds random bytes through `fuzz::run_input`, the fuzz
//! target's own entry point, in both modes;
//! * `structured_*` feed generated steps straight to the harness, so every
//! case reaches well-formed frames;
//! * the rest pin specific §10 properties as scenarios.
//!
//! The harness drives `MemStore`, which needs `alloc`; the twin is skipped in
//! `--no-default-features` builds (like `pair.rs`).
#![cfg(feature = "alloc")]
//! It runs on the pinned stable toolchain in the normal gate. It needs the
//! `conformance` feature (the harness builds frames through core's hidden
//! `conformance` helpers) and `alloc` (`MemStore`); Cargo.toml's
//! `required-features` skips it otherwise.
#![allow(clippy::needless_range_loop)]
#[path = "../../fuzz/src/lib.rs"]
mod fuzz;
use fuzz::{AckSpec, FragOrder, FragSpec, Harness, Mode, ObsSpec, SpecSummary, Step, Who};
use proptest::prelude::*;
use tines_core::conformance::MAX_PEERS;
use tines_core::conformance::{MAX_PEERS, MIN_MTU, PEER_IDLE_MS, SUMMARY_INTERVAL_MS};
use tines_core::consts::{MAX_FRAME, MAX_PAYLOAD, MAX_TOPIC, SUMMARY_BYTES};
use tines_core::Store;
// A single bounded case count keeps the twin fast under `cargo test` while
// still exercising a meaningful surface of the harness.
const CASES: u32 = 32;
const CASES: u32 = 64;
fn node_id() -> impl Strategy<Value = [u8; 16]> {
any::<[u8; 16]>()
fn mtu() -> impl Strategy<Value = usize> {
MIN_MTU..=MAX_FRAME
}
fn topic() -> impl Strategy<Value = Vec<u8>> {
proptest::collection::vec(any::<u8>(), 0..=MAX_TOPIC)
fn who() -> impl Strategy<Value = Who> {
prop_oneof![
6 => Just(Who::Peer),
1 => Just(Who::Me),
1 => any::<[u8; 16]>().prop_map(Who::Id),
]
}
fn payload() -> impl Strategy<Value = Vec<u8>> {
proptest::collection::vec(any::<u8>(), 0..MAX_PAYLOAD)
/// Addresses past `MAX_PEERS`, so the peer table overflows.
fn addr() -> impl Strategy<Value = u64> {
0..(3 * MAX_PEERS as u64)
}
// A well-formed broadcast observation spec. `dest` stays broadcast (None): the
// addressed path shares storage/exchange logic and is exercised by the raw
// Frame step below, so broadcasting keeps the twin's invariants unambiguous.
fn obs_spec() -> impl Strategy<Value = fuzz::ObsSpec> {
(
any::<u64>(), // addr
node_id(), // from
any::<u64>(), // seq
any::<u32>(), // age_ms
topic(),
payload(),
fn sig() -> impl Strategy<Value = Option<fuzz::SigSpec>> {
proptest::option::of(
(
prop_oneof![Just(1u8), any::<u8>()],
prop_oneof![Just(1u8), any::<u8>()],
prop_oneof![Just(64usize), 0usize..=64],
)
.prop_map(|(version, algo, len)| fuzz::SigSpec {
version,
algo,
bytes: vec![0x5A; len],
}),
)
.prop_map(|(addr, from, seq, age_ms, topic, payload)| fuzz::ObsSpec {
addr,
from,
seq,
age_ms: age_ms as u64,
topic,
payload,
dest: None,
})
}
// A well-formed summary spec with a chosen bits pattern and salt.
fn summary_spec() -> impl Strategy<Value = fuzz::SpecSummary> {
fn obs_spec() -> impl Strategy<Value = ObsSpec> {
(
any::<u64>(),
node_id(),
any::<u32>(),
proptest::collection::vec(any::<u8>(), 0..SUMMARY_BYTES),
(addr(), who(), who(), 0u64..64, any::<u32>()),
(
proptest::option::of(any::<u64>()),
prop_oneof![Just(MAX_TOPIC), 0..=MAX_TOPIC],
prop_oneof![Just(MAX_PAYLOAD), 0..=MAX_PAYLOAD],
proptest::option::of((who(), any::<u32>())),
sig(),
),
)
.prop_map(|(addr, from, salt, bits)| fuzz::SpecSummary {
addr,
from,
salt,
bits,
})
.prop_map(
|((addr, from, observer, seq, age), (observed_at, tl, pl, dest, sig))| ObsSpec {
addr,
from,
observer,
seq,
age_ms: u64::from(age),
observed_at,
topic: vec![b't'; tl],
payload: vec![b'p'; pl],
dest: dest.map(|(to, life)| (to, u64::from(life))),
sig,
},
)
}
// An Ack spec for 0..=4 ids (sent or unsent).
fn ack_spec() -> impl Strategy<Value = fuzz::AckSpec> {
(any::<u64>(), proptest::collection::vec(node_id(), 0..=8)).prop_map(|(addr, ids)| {
fuzz::AckSpec {
addr,
from: [0u8; 16],
ids,
}
fn structured_step() -> impl Strategy<Value = Step> {
prop_oneof![
obs_spec().prop_map(Step::Obs),
(
obs_spec(),
1usize..=MAX_FRAME,
prop_oneof![
Just(FragOrder::InOrder),
Just(FragOrder::Reverse),
proptest::collection::vec(any::<u8>(), 0..8).prop_map(FragOrder::Explicit),
],
proptest::option::of(any::<[u8; 16]>()),
proptest::option::of(any::<u8>()),
)
.prop_map(|(obs, chunk, order, id, count)| Step::Frags(FragSpec {
obs,
chunk,
order,
id,
count,
})),
(
addr(),
who(),
any::<u32>(),
0..=SUMMARY_BYTES,
any::<u8>(),
1u8..=3
)
.prop_map(|(addr, from, salt, len, fill, repeat)| Step::Summarize(
SpecSummary {
addr,
from,
salt,
bits: vec![fill; len],
repeat,
}
)),
(
addr(),
who(),
proptest::collection::vec(any::<u16>(), 0..8),
proptest::collection::vec(any::<[u8; 16]>(), 0..2),
)
.prop_map(|(addr, from, sent, raw)| Step::Ack(AckSpec {
addr,
from,
sent,
raw,
})),
addr().prop_map(Step::Contact),
addr().prop_map(Step::Lost),
prop_oneof![-5_000i64..=40_000, any::<i64>()].prop_map(Step::Advance),
any::<u16>().prop_map(|size| Step::Ourselves { size }),
(who(), any::<u32>(), any::<u16>()).prop_map(|(to, life, size)| Step::OurselvesTo {
to,
lifetime_ms: u64::from(life),
size,
}),
(addr(), proptest::collection::vec(any::<u8>(), 0..64))
.prop_map(|(a, bytes)| Step::Frame(a, bytes)),
]
}
/// A broadcast observation from the peer at `addr`, distinct per `seq`.
fn peer_obs(addr: u64, seq: u64) -> ObsSpec {
ObsSpec {
addr,
from: Who::Peer,
observer: Who::Peer,
seq,
age_ms: 0,
observed_at: None,
topic: b"t".to_vec(),
payload: b"p".to_vec(),
dest: None,
sig: None,
}
}
/// A summary from `addr` saying it holds nothing.
fn empty_summary(addr: u64) -> Step {
Step::Summarize(SpecSummary {
addr,
from: Who::Peer,
salt: 7,
bits: vec![0; SUMMARY_BYTES],
repeat: 1,
})
}
// Any valid Step of any kind — drives the Basic mode with real, well-formed
// inputs rather than raw random bytes.
fn any_step() -> impl Strategy<Value = fuzz::Step> {
prop_oneof![
// Addressed / broadcast observations (well-formed Obs frames).
obs_spec().prop_map(fuzz::Step::Obs),
// Summaries with chosen bits patterns and salts.
summary_spec().prop_map(fuzz::Step::Summarize),
// Acks for sent/unsent ids.
ack_spec().prop_map(fuzz::Step::Ack),
// Raw Frame: a peer (or our own) byte stream of up to MAX_FRAME bytes,
// including deliberately malformed content to exercise the drop path.
(
any::<u64>(),
proptest::collection::vec(any::<u8>(), 0..MAX_FRAME),
)
.prop_map(|(addr, bytes)| fuzz::Step::Frame(addr, bytes)),
// Contact / lost + clock advance (may go backwards).
any::<u64>().prop_map(fuzz::Step::Contact),
any::<u64>().prop_map(fuzz::Step::Lost),
any::<i64>().prop_map(fuzz::Step::Advance),
// Ourselves / ours-to (self observation).
(0u32..=0).prop_map(|_| fuzz::Step::Ourselves),
]
/// Poll the harness `n` times at the current tick.
fn settle(h: &mut Harness, n: usize) {
for _ in 0..n {
h.pump();
}
}
proptest! {
#![proptest_config(ProptestConfig { cases: CASES, ..Default::default() })]
/// §7 core safety: an arbitrary sequence of well-formed steps run through a
/// randomised-MTU harness must never panic, must stay within the MTU on
/// every emitted frame, and must not overflow its drop counter. `run`
/// asserts all three; reaching it clean is the property. A single mixed
/// sequence may also exceed MAX_PEERS distinct addresses (addr is a full
/// u64), so this alone exercises the flood path without panic.
/// The fuzz target's own entry point on random bytes: both modes, a
/// random MTU, every §7 check after every step.
#[test]
fn basic_run_no_panic_or_overflow(
steps in proptest::collection::vec(any_step(), 0..=128),
mtu in 32usize..=4096usize,
fn fuzz_inputs(data in proptest::collection::vec(any::<u8>(), 3..2048)) {
let _ = fuzz::run_input(&data);
}
/// Generated well-formed adversarial steps from more than `MAX_PEERS`
/// addresses, every §7 check after every step.
#[test]
fn structured_adversarial(
steps in proptest::collection::vec(structured_step(), 1..=64),
mtu in mtu(),
) {
if !steps.is_empty() {
let mut h = fuzz::Harness::new(mtu);
h.run(&steps);
}
Harness::new(Mode::Adversarial, mtu).run(&steps);
}
/// §10 idempotency: repeating an identical summary must not emit MORE
/// frames than the first time it was seen.
/// §10 / F9: a summary replayed within the interval re-sends nothing,
/// while the next on-time summary re-offers everything the peer still
/// lacks (lossy convergence is preserved).
#[test]
fn summary_repeat_does_not_resend(spec in summary_spec(), mtu in 32usize..=4096) {
let mut h = fuzz::Harness::new(mtu);
h.step(&fuzz::Step::Summarize(spec.clone()));
h.drain();
let first = h.sent_frames().len();
h.step(&fuzz::Step::Summarize(spec));
h.drain();
let second = h.sent_frames().len();
prop_assert!(second <= first, "repeating a summary must not re-send ({} vs {} frames)", second, first);
}
/// §10 flood recovery: feeding MORE than MAX_PEERS distinct-identity
/// summaries through the transport must never panic and output stays
/// bounded per received summary.
#[test]
fn flood_past_max_peers_does_not_panic(mtu in 32usize..=4096) {
let n = MAX_PEERS as u64 + 16; // deliberately beyond the cap
let mut steps = Vec::new();
for i in 1..=n {
steps.push(fuzz::Step::Summarize(fuzz::SpecSummary {
addr: i,
from: [i as u8; 16],
salt: (i as u32),
bits: (0..SUMMARY_BYTES).map(|_| i as u8).collect(),
}));
fn summary_repeat_does_not_resend(n in 1usize..=12, mtu in 600usize..=MAX_FRAME) {
let mut h = Harness::new(Mode::Adversarial, mtu);
for _ in 0..n {
h.run_step(&Step::Ourselves { size: 16 });
}
let mut h = fuzz::Harness::new(mtu);
h.run(&steps);
}
prop_assert_eq!(h.store().len(), n);
/// §10 peer-table recovery after a beyond-MAX_PEERS flood: once the table
/// has been pushed past its cap, a fresh contact + our own observation must
/// still be stored (the node keeps working rather than wedging).
#[test]
fn peer_table_recovers_after_flood(mtu in 32usize..=4096) {
let mut h = fuzz::Harness::new(mtu);
for i in 1..=(MAX_PEERS as u64 + 16) {
let spec = fuzz::SpecSummary {
addr: i,
from: [i as u8; 16],
salt: (i as u32),
bits: (0..SUMMARY_BYTES).map(|_| i as u8).collect(),
};
h.step(&fuzz::Step::Summarize(spec));
h.drain();
}
// A fresh contact + our own observation after the flood.
h.step(&fuzz::Step::Contact(1));
h.step(&fuzz::Step::Ourselves);
h.drain();
prop_assert!(!h.store().is_empty(), "node must still store an observation after a beyond-MAX_PEERS flood");
// The peer says it holds nothing: every broadcast is offered once.
h.run_step(&empty_summary(1));
settle(&mut h, n + 4);
prop_assert_eq!(h.offered_since_summary(1), n);
// The same summary again, 1 ms later: ignored, nothing re-sent.
h.run_step(&Step::Advance(1));
h.run_step(&empty_summary(1));
settle(&mut h, n + 4);
prop_assert_eq!(h.offered_since_summary(1), 0, "a replayed summary re-sent ids");
// A full interval later the peer still lacks everything, so it gets
// everything again.
h.run_step(&Step::Advance(SUMMARY_INTERVAL_MS as i64));
h.run_step(&empty_summary(1));
settle(&mut h, n + 4);
prop_assert_eq!(h.offered_since_summary(1), n);
}
/// F1: acknowledging an unknown id must not evict a known, stored
/// observation. Seed a peer broadcast obs (guaranteed stored), then Ack an
/// id we did not observe and assert the seed is still present.
/// observation.
#[test]
fn ack_unknown_does_not_evict_known(obs in obs_spec(), mtu in 32usize..=4096) {
let mut h = fuzz::Harness::new(mtu);
// Seed a known broadcast observation from a peer.
h.step(&fuzz::Step::Obs(obs));
h.drain();
let known: Vec<[u8; 16]> = {
let mut out = Vec::new();
h.store().for_each_broadcast_id(&mut |id: &[u8; 16]| out.push(*id));
out
};
prop_assert!(!known.is_empty(), "expected a stored broadcast to ack against");
// An Ack referencing an id we never observed must not remove the seed.
let unknown = [0xFFu8; 16];
h.step(&fuzz::Step::Ack(fuzz::AckSpec { addr: 1, from: [0u8; 16], ids: vec![unknown] }));
h.drain();
fn ack_unknown_does_not_evict_known(obs in obs_spec(), mtu in mtu()) {
let mut h = Harness::new(Mode::Adversarial, mtu);
let obs = ObsSpec { dest: None, sig: None, observer: Who::Peer, from: Who::Peer, ..obs };
let addr = obs.addr;
h.run_step(&Step::Obs(obs));
let mut known = Vec::new();
h.store().for_each_id(&mut |id| known.push(*id));
prop_assert_eq!(known.len(), 1, "the peer's broadcast must be stored");
h.run_step(&Step::Ack(AckSpec { addr, from: Who::Peer, sent: vec![], raw: vec![[0xFF; 16]] }));
for id in &known {
prop_assert!(h.store().contains(id), "an Ack of an unknown id must not evict a known stored observation");
prop_assert!(h.store().contains(id) && !h.store().is_released(id));
}
}
}
/// §10 / F8 peer-table recovery: `MAX_PEERS + 16` addresses each send one
/// frame (filling the table), then everyone goes quiet. After
/// `PEER_IDLE_MS`, a never-seen address must get a slot.
#[test]
#[ignore = "needs S1 (idle sweep): silent peers are never evicted yet"]
fn peer_table_recovers_after_flood() {
let mut h = Harness::new(Mode::Adversarial, MAX_FRAME);
for addr in 0..(MAX_PEERS as u64 + 16) {
h.run_step(&Step::Obs(peer_obs(addr, addr)));
}
// Sanity: the flood really filled the table, so the probe tests eviction.
let before = h.store().len();
h.run_step(&Step::Obs(peer_obs(1_000, 1_000)));
assert_eq!(h.store().len(), before, "the table should be full");
h.check_peer_table_recovers();
}
/// Same flood, but contact-only peers: `Contact` events with no frames must
/// age out too.
#[test]
#[ignore = "needs S1 (idle sweep): contact-only peers are never evicted yet"]
fn peer_table_recovers_after_contact_flood() {
let mut h = Harness::new(Mode::Adversarial, MAX_FRAME);
for addr in 0..(MAX_PEERS as u64) {
h.run_step(&Step::Contact(addr));
}
h.check_peer_table_recovers();
}
/// Before the idle window ends the table stays full: the flood isn't
/// trivially recovered by any later frame.
#[test]
fn full_peer_table_refuses_a_newcomer_before_idle() {
let mut h = Harness::new(Mode::Adversarial, MAX_FRAME);
for addr in 0..(MAX_PEERS as u64) {
h.run_step(&Step::Obs(peer_obs(addr, addr)));
}
assert_eq!(h.store().len(), MAX_PEERS);
h.run_step(&Step::Advance(PEER_IDLE_MS as i64 / 2));
h.run_step(&Step::Obs(peer_obs(500, 500)));
assert_eq!(h.store().len(), MAX_PEERS);
}