Skip to content

Commit 565b78b

Browse files
committed
create marshal module
1 parent 20258fb commit 565b78b

5 files changed

Lines changed: 401 additions & 280 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
//! Libfuzzer-facing scenario: a byte tape (consumed by `FuzzRng`) plus a
2+
//! length-bounded list of events the driver replays against marshal.
3+
4+
use arbitrary::Arbitrary;
5+
6+
const MIN_EVENTS: usize = 1;
7+
const MAX_EVENTS: usize = 128;
8+
9+
#[derive(Debug, Clone, Copy, Arbitrary)]
10+
pub enum MarshalEvent {
11+
/// Notify marshal that a block was locally proposed.
12+
Propose { block_idx: u8 },
13+
/// Notify marshal that a block was verified.
14+
Verify { block_idx: u8 },
15+
/// Notify marshal that a block was certified.
16+
Certify { block_idx: u8 },
17+
/// Report a finalization for a block.
18+
ReportFinalization { block_idx: u8 },
19+
/// Report a notarization for a block.
20+
ReportNotarization { block_idx: u8 },
21+
/// Publish a block through the variant's local buffer (buffered
22+
/// broadcast engine for Standard, shards engine for Coding) without
23+
/// going through marshal's mailbox.
24+
PublishViaVariant { block_idx: u8 },
25+
/// Release one pending application ack, recording the popped height
26+
/// as a delivery observation.
27+
AckNext,
28+
/// Abort the marshal actor and re-initialize from the same on-disk
29+
/// state. Pending acks at the moment of restart are NOT signaled,
30+
/// so marshal's persistent state retains them as un-processed and
31+
/// the new instance must redeliver them (at-least-once).
32+
Restart,
33+
/// Yield without dispatching a marshal-facing event.
34+
Idle,
35+
}
36+
37+
#[derive(Debug, Clone)]
38+
pub struct MarshalFuzzInput {
39+
pub raw_bytes: Vec<u8>,
40+
pub events: Vec<MarshalEvent>,
41+
}
42+
43+
impl Arbitrary<'_> for MarshalFuzzInput {
44+
fn arbitrary(u: &mut arbitrary::Unstructured<'_>) -> arbitrary::Result<Self> {
45+
let event_count = u.int_in_range(MIN_EVENTS..=MAX_EVENTS)?;
46+
let mut events = Vec::with_capacity(event_count);
47+
for _ in 0..event_count {
48+
events.push(MarshalEvent::arbitrary(u)?);
49+
}
50+
let remaining = u.len().min(crate::MAX_RAW_BYTES);
51+
let raw_bytes = if remaining == 0 {
52+
vec![0]
53+
} else {
54+
u.bytes(remaining)?.to_vec()
55+
};
56+
Ok(Self { raw_bytes, events })
57+
}
58+
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
//! Marshal fuzz invariants.
2+
//!
3+
//! Each function asserts one property of the marshal-under-test against
4+
//! the driver's shadow state. The orchestrator [`check_all`] runs them
5+
//! in order; runner.rs only calls `check_all`.
6+
//!
7+
//! Conventions match the rest of the consensus fuzz crate: panics on
8+
//! violation, with a message that includes the relevant shadow state so
9+
//! libFuzzer's crash log is self-explanatory.
10+
11+
use commonware_consensus::{marshal::mocks::harness::TestHarness, types::Height};
12+
use commonware_cryptography::Digestible;
13+
use std::collections::{BTreeMap, HashSet};
14+
15+
/// Run every marshal invariant. Called from the driver at end of run.
16+
pub fn check_all<H: TestHarness>(
17+
ready_prefix: u64,
18+
delivery_log: &[Height],
19+
segment_bounds: &[usize],
20+
segment_starts: &[u64],
21+
expected_redeliveries: &[Vec<Height>],
22+
application_blocks: &BTreeMap<Height, H::ApplicationBlock>,
23+
canonical: &[H::TestBlock],
24+
) {
25+
check_ready_prefix_delivered(ready_prefix, delivery_log);
26+
check_segment_ordering(segment_bounds, segment_starts, delivery_log);
27+
check_redelivery_after_restart(expected_redeliveries, segment_bounds, delivery_log);
28+
check_digest_fidelity::<H>(application_blocks, canonical);
29+
}
30+
31+
/// Invariant: ready-prefix delivery.
32+
///
33+
/// Every height in `1..=ready_prefix` must appear at least once in
34+
/// `delivery_log`. The driver advances `ready_prefix` only when an
35+
/// above-floor `ReportFinalization` (or restart-triggered repair)
36+
/// observes a complete chain back to height 1, which is precisely when
37+
/// marshal is obliged to deliver the prefix.
38+
pub fn check_ready_prefix_delivered(ready_prefix: u64, delivery_log: &[Height]) {
39+
let delivered_set: HashSet<u64> = delivery_log.iter().map(|h| h.get()).collect();
40+
for h in 1..=ready_prefix {
41+
assert!(
42+
delivered_set.contains(&h),
43+
"marshal violated at-least-once delivery: ready height {h} never reached \
44+
the application (ready_prefix={ready_prefix}, delivered={delivered_set:?})",
45+
);
46+
}
47+
}
48+
49+
/// Invariant: per-segment in-order delivery.
50+
///
51+
/// Within each actor instance (segment between restarts) marshal must
52+
/// deliver heights starting at `restored processed_height + 1` and
53+
/// advance strictly by one. The driver pre-populates `segment_starts`
54+
/// from each `setup.height` and `segment_bounds` from the delivery_log
55+
/// positions at restart boundaries.
56+
pub fn check_segment_ordering(
57+
segment_bounds: &[usize],
58+
segment_starts: &[u64],
59+
delivery_log: &[Height],
60+
) {
61+
assert_eq!(
62+
segment_bounds.len(),
63+
segment_starts.len() + 1,
64+
"segment bookkeeping inconsistency",
65+
);
66+
for (segment_idx, window) in segment_bounds.windows(2).enumerate() {
67+
let (start_idx, end_idx) = (window[0], window[1]);
68+
if start_idx == end_idx {
69+
continue;
70+
}
71+
let segment = &delivery_log[start_idx..end_idx];
72+
let expected_start = segment_starts[segment_idx];
73+
assert_eq!(
74+
segment[0].get(),
75+
expected_start,
76+
"segment #{segment_idx} must start at restored processed height + 1 \
77+
({expected_start}), got {} (segment={:?})",
78+
segment[0].get(),
79+
segment,
80+
);
81+
for (offset, h) in segment.iter().enumerate() {
82+
let expected = expected_start + offset as u64;
83+
assert_eq!(
84+
h.get(),
85+
expected,
86+
"marshal violated in-order delivery within segment #{segment_idx}: \
87+
expected height {expected}, observed {} (segment={:?})",
88+
h.get(),
89+
segment,
90+
);
91+
}
92+
}
93+
}
94+
95+
/// Invariant: at-least-once across restart.
96+
///
97+
/// Each height that was pending ack at the moment of restart `i` must
98+
/// reappear somewhere in `delivery_log[segment_bounds[i+1]..]`. Their
99+
/// ack handles were never signaled, so marshal's persistent state
100+
/// retains them as un-processed and the new instance is obliged to
101+
/// redeliver.
102+
pub fn check_redelivery_after_restart(
103+
expected_redeliveries: &[Vec<Height>],
104+
segment_bounds: &[usize],
105+
delivery_log: &[Height],
106+
) {
107+
for (restart_idx, expected) in expected_redeliveries.iter().enumerate() {
108+
if expected.is_empty() {
109+
continue;
110+
}
111+
let post_restart_start = segment_bounds[restart_idx + 1];
112+
let post_restart: HashSet<u64> = delivery_log[post_restart_start..]
113+
.iter()
114+
.map(|h| h.get())
115+
.collect();
116+
for h in expected {
117+
assert!(
118+
post_restart.contains(&h.get()),
119+
"marshal violated at-least-once across restart: height {} was \
120+
pending at restart #{} but was never redelivered \
121+
(post-restart deliveries={post_restart:?})",
122+
h.get(),
123+
restart_idx + 1,
124+
);
125+
}
126+
}
127+
}
128+
129+
/// Invariant: digest fidelity.
130+
///
131+
/// Every block surfaced in `application.blocks()` must match the
132+
/// canonical chain digest at its height. Re-emits after restart
133+
/// overwrite the prior `BTreeMap` entry, so the latest delivery at each
134+
/// height is the one we compare against canonical.
135+
pub fn check_digest_fidelity<H: TestHarness>(
136+
application_blocks: &BTreeMap<Height, H::ApplicationBlock>,
137+
canonical: &[H::TestBlock],
138+
) {
139+
for (height, block) in application_blocks.iter() {
140+
let canonical_block = &canonical[(height.get() - 1) as usize];
141+
assert_eq!(
142+
block.digest(),
143+
H::digest(canonical_block),
144+
"marshal delivered a block whose digest does not match the canonical \
145+
chain at height {}",
146+
height.get(),
147+
);
148+
}
149+
}

consensus/fuzz/src/marshal/mod.rs

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
//! Fuzz driver for the marshal actor.
2+
//!
3+
//! Drives a single marshal actor under test by synthesizing every input
4+
//! marshal would normally receive from the consensus engine and from peers
5+
//! (blocks, notarizations, finalizations) and feeding them through the
6+
//! mailbox directly. Generic over `H: TestHarness` so the standard and
7+
//! coding variants share the same driver and corpora-per-binary discipline.
8+
//!
9+
//! # Invariants checked
10+
//!
11+
//! - **In-order delivery, no gaps within a marshal instance.** Within each
12+
//! actor lifetime (segment between restarts), the first delivery is
13+
//! `setup.height + 1` and subsequent deliveries advance strictly by one.
14+
//! Marshal documents this guarantee on `Update::Block`.
15+
//! - **Ready-prefix delivery (anchor-based, chain-aware repair).**
16+
//! When a `ReportFinalization` at height `h` arrives while block
17+
//! `h` is locally available (durable or variant), marshal stores a
18+
//! finalized anchor at `h` in its finalized archive. The driver
19+
//! mirrors this with a persistent `finalized_anchors` set.
20+
//!
21+
//! A `ReportFinalization` only triggers a repair wake when its
22+
//! height is strictly above marshal's `processed_height` AND the
23+
//! block is locally available. At-or-below-floor finalizations
24+
//! are dropped by marshal's `store_finalization` (see
25+
//! actor.rs:1732) and `try_repair_gaps` is gated on store success
26+
//! (actor.rs:648). The driver mirrors this with a shadow
27+
//! `processed_height`: initialized to `setup.height.get()`,
28+
//! advanced on non-stale `AckNext`, and reset to
29+
//! `setup.height.get()` after `Restart`.
30+
//!
31+
//! On each repair wake (every above-floor `ReportFinalization`
32+
//! that found its block, and every `Restart` after the variant
33+
//! cache is cleared, since marshal's startup path runs
34+
//! `try_repair_gaps` unconditionally) the driver finds the largest
35+
//! anchor `a` for which every height `1..=a` is currently
36+
//! available in (`durable_available` union `variant_available`).
37+
//! If `a > ready_prefix`, the gap is repairable: marshal can walk
38+
//! the chain from `a` back to 1 and deliver. The driver bumps
39+
//! `ready_prefix = a` and promotes heights `prev_ready+1..=a` into
40+
//! `durable_available` (marshal moves them to the finalized
41+
//! archive, so they survive future restarts even if originally
42+
//! sourced from the variant cache).
43+
//!
44+
//! Availability state:
45+
//! - `durable_available`: heights set by Propose / Verify /
46+
//! Certify (marshal persists them), anchor blocks persisted by
47+
//! `ReportFinalization` when the block was locally available
48+
//! at that moment (marshal writes the block to
49+
//! `finalized_blocks` alongside the finalization), plus
50+
//! heights promoted by `ready_prefix` advances. Survives
51+
//! restart.
52+
//! - `variant_available`: heights set by `PublishViaVariant`
53+
//! after confirmed local availability. Lives only in the
54+
//! in-memory buffered / shards cache; cleared on `Restart`.
55+
//! - `finalized_anchors`: heights at which a usable finalization
56+
//! is stored. Survives restart.
57+
//! - **At-least-once across restart.** Heights pending ack at the moment
58+
//! of restart are tracked. The new actor instance must redeliver each
59+
//! of them at least once before the run ends.
60+
//! - **Digest fidelity.** Every block surfaced in `application.blocks()`
61+
//! must match the canonical chain digest at its height.
62+
//! - **Durability acks.** `H::propose`/`H::verify`/`H::certify` return
63+
//! `true` on durable persist; `false` surfaces an actor-died panic.
64+
//!
65+
//! # Variant buffer coverage
66+
//!
67+
//! `PublishViaVariant` exercises marshal's interaction with the local
68+
//! variant cache (buffered broadcast engine for Standard, shards engine
69+
//! for Coding). After publishing, the driver verifies the block actually
70+
//! landed in the local cache before counting it as `provided`; a publish
71+
//! that silently drops does not register.
72+
//!
73+
//! The marshal-mailbox path (`H::propose`/`H::verify`/`H::certify`) does
74+
//! NOT route through the shards mailbox for the coding harness: those
75+
//! wrappers call `handle.mailbox.proposed/verified/certified` directly.
76+
//! Shards-mailbox coverage is therefore exclusively via
77+
//! `PublishViaVariant`.
78+
//!
79+
//! # Known scope limitations
80+
//!
81+
//! - Single-validator only: peer-to-peer shard *dissemination* and
82+
//! *reconstruction-from-peer-shards* are not exercised. Multi-validator
83+
//! coding fuzz is a follow-up.
84+
//!
85+
//! # Layout
86+
//!
87+
//! - [`input`] defines the libFuzzer-facing scenario type.
88+
//! - [`variant`] adapts the standard / coding variant mailboxes to a
89+
//! single publish trait the driver can call generically.
90+
//! - [`invariant`] holds the end-of-run assertions, one per property.
91+
//! - [`runner`] holds the deterministic-runtime driver and delegates
92+
//! to [`invariant::check_all`] at the end.
93+
94+
mod input;
95+
pub mod invariant;
96+
mod runner;
97+
mod variant;
98+
99+
pub use input::{MarshalEvent, MarshalFuzzInput};
100+
pub use runner::fuzz_marshal;
101+
pub use variant::VariantPublish;

0 commit comments

Comments
 (0)