fix(channels): duplicate adopt/open must not destroy the live channel; fuzz targets 3-5
Found by the manager_routing fuzz target (docs/research/fuzzing.md \u00a77.8): - ChannelManager::open_channel / adopt_channel used HashMap::insert(...).is_some() as a collision check \u2014 insert REPLACES the existing entry, so a duplicate adopt/open installed the new state, dropped the live channel's demux_sender (spurious EOF to its readers, subsequently routed chunks lost) and still returned Err(ChannelExists). Fixed with contains_key pre-check; map untouched on collision. Regression tests: adopt_channel_duplicate_id_leaves_ live_channel_intact, open_channel_duplicate_id_leaves_live_channel_ intact. New fuzz targets (\u00a77.4 step 3): - manager_routing: Arbitrary op sequences over ChannelManager; exact counter models (parked/dropped must equal the manager's monotonic counters), parked-bytes bound per \u00a76.2-1, clear_all ledger-vs-map semantics, drainer-byte reconciliation (lossless routing) - envelope_semantic: constructors -> serde -> write_frame/read_frame structural round-trip; event-type constants; call.error parse-back - spec_parse: OpRegisterRequest::from_json -> rebuild -> registry registration (attacker schemas compile at register, CF-003) - fuzz/shared/src/arbitrary_value.rs: bounded Arbitrary for serde_json::Value; 20 spec_parse + 4 manager_routing seeds - corpus replay for the new targets in fuzz/shared tests Verification: cargo test 684 passed (682 + 2 regression); clippy -D warnings clean (main + fuzz/shared); fmt clean (main + fuzz); cargo fuzz build clean; 20 s smoke on all three new targets clean (manager_routing 79k, envelope_semantic 141k, spec_parse 517k runs); crash input replays clean post-fix
This commit is contained in:
1 parent
c211c163fd
commit
a1f257757b
14 files changed
+1133
-5
No files matched your search
@@ -0,0 +1,394 @@
|
||||
//! Invariants for `ChannelManager` routing under adversarial op
|
||||
//! sequences (ADR-039/ADR-040). The harness mirrors the manager's
|
||||
//! documented behavior exactly — parked-chunk and drop counters must
|
||||
//! match to the unit after every op, parked bytes must respect the
|
||||
//! per-channel cap bound, and every byte routed into a known channel
|
||||
//! must be readable until an EOF sentinel (lossless bounded-buffer
|
||||
//! routing, ADR-040 REQ-CH-05). The §6.2-1 probe: distinct
|
||||
//! never-adopted channel ids each park up to 64 chunks — the fuzzer
|
||||
//! explores whether the total can exceed that documented bound.
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
|
||||
use alkcall::channels::manager::{ChannelManager, ChannelSide, ManagerError};
|
||||
use alkcall::channels::mux::MuxRunner;
|
||||
use alkcall::channels::reassembly::{MpscRecvStream, DEFAULT_BUFFER_CAP};
|
||||
|
||||
/// The documented per-channel early-arrival cap (`EARLY_ARRIVAL_CAP`,
|
||||
/// private in `manager.rs`; review 006 E-04 treats 64 parked chunks as
|
||||
/// the observable bound).
|
||||
pub const EARLY_ARRIVAL_CAP: usize = 64;
|
||||
|
||||
/// The maximum chunk payload length (16 MiB, ADR-034).
|
||||
pub const MAX_CHUNK_LEN: u32 = 16 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, arbitrary::Arbitrary, Clone)]
|
||||
pub enum ManagerOp {
|
||||
Route { channel_id: u32, len: u16, byte: u8 },
|
||||
Open { alpn_idx: u8, opener_idx: u8 },
|
||||
Adopt { channel_id: u32 },
|
||||
Teardown { channel_id: u32 },
|
||||
ClearAll,
|
||||
}
|
||||
|
||||
#[derive(Debug, arbitrary::Arbitrary)]
|
||||
pub struct ManagerSequence {
|
||||
pub ops: Vec<ManagerOp>,
|
||||
}
|
||||
|
||||
/// Exact model of the manager's early-arrival bookkeeping.
|
||||
#[derive(Default)]
|
||||
struct ParkModel {
|
||||
/// Parked chunk payloads per channel id, FIFO (0-length marks the
|
||||
/// EOF sentinel a drained queue will deliver).
|
||||
parked: HashMap<u32, VecDeque<u16>>,
|
||||
/// Model of the monotonic `early_arrival_count`.
|
||||
parked_total: u64,
|
||||
/// Model of the monotonic `dropped_unknown_chunks`.
|
||||
dropped_total: u64,
|
||||
}
|
||||
|
||||
impl ParkModel {
|
||||
fn route_to_unknown(&mut self, channel_id: u32, len: u16) {
|
||||
let queue = self.parked.entry(channel_id).or_default();
|
||||
if queue.len() >= EARLY_ARRIVAL_CAP {
|
||||
self.dropped_total += 1;
|
||||
} else {
|
||||
queue.push_back(len);
|
||||
self.parked_total += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn parked_bytes(&self) -> u64 {
|
||||
self.parked
|
||||
.values()
|
||||
.map(|q| q.iter().map(|l| u64::from(*l)).sum::<u64>())
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Bytes of a drained queue that are readable (everything before
|
||||
/// the first zero-length sentinel — the reassembled stream latches
|
||||
/// EOF there and drops the rest, reassembly.rs `poll_read`).
|
||||
fn readable_bytes_until_sentinel(queue: &VecDeque<u16>) -> u64 {
|
||||
let mut total = 0;
|
||||
for len in queue {
|
||||
if *len == 0 {
|
||||
break;
|
||||
}
|
||||
total += u64::from(*len);
|
||||
}
|
||||
total
|
||||
}
|
||||
}
|
||||
|
||||
struct ManagerHarness {
|
||||
manager: ChannelManager,
|
||||
model: ParkModel,
|
||||
/// Readable-byte model per drained channel id (what its drainer
|
||||
/// will have read once the channel ends). Drainers accumulate:
|
||||
/// `open_channel` then `adopt_channel` on the same id each install
|
||||
/// a fresh stream; earlier drainers end at the sender drop but the
|
||||
/// id's routed bytes must reconcile exactly once, so each new
|
||||
/// drainer supersedes the id's model entry.
|
||||
readable: HashMap<u32, u64>,
|
||||
/// Byte totals for drainers that ended mid-sequence (sender drop
|
||||
/// on adopt-over-adopt) — reconciled into the post-sequence total.
|
||||
retired_drainer_bytes: u64,
|
||||
/// Channels whose stream latched EOF (sentinel routed while known).
|
||||
sentinel_seen: HashMap<u32, bool>,
|
||||
/// (channel_id, bytes-read counter, drainer task).
|
||||
drainers: Vec<(u32, Arc<AtomicU64>, tokio::task::JoinHandle<()>)>,
|
||||
_mux_task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl ManagerHarness {
|
||||
fn new() -> Self {
|
||||
let (_client, server) = tokio::io::duplex(4096);
|
||||
let (_reader, writer) = tokio::io::split(server);
|
||||
let (mux_handle, runner) = MuxRunner::new(Box::new(writer));
|
||||
let mux_task = tokio::spawn(async move {
|
||||
let _ = runner.run().await;
|
||||
});
|
||||
let manager = ChannelManager::new(
|
||||
mux_handle,
|
||||
64,
|
||||
DEFAULT_BUFFER_CAP,
|
||||
None,
|
||||
ChannelSide::Accept,
|
||||
);
|
||||
Self {
|
||||
manager,
|
||||
model: ParkModel::default(),
|
||||
readable: HashMap::new(),
|
||||
retired_drainer_bytes: 0,
|
||||
sentinel_seen: HashMap::new(),
|
||||
drainers: Vec::new(),
|
||||
_mux_task: mux_task,
|
||||
}
|
||||
}
|
||||
|
||||
async fn step(&mut self, op: &ManagerOp, max_payload: usize) -> Result<(), String> {
|
||||
match op {
|
||||
ManagerOp::Route {
|
||||
channel_id,
|
||||
len,
|
||||
byte,
|
||||
} => {
|
||||
let payload_len = (*len as usize) % (max_payload + 1);
|
||||
let len = payload_len as u16;
|
||||
let known = self.manager.has_channel(*channel_id);
|
||||
self.manager
|
||||
.route_payload(*channel_id, Bytes::from(vec![*byte; payload_len]))
|
||||
.await;
|
||||
if known {
|
||||
if !self.sentinel_seen(*channel_id) {
|
||||
*self.readable.entry(*channel_id).or_insert(0) += u64::from(len);
|
||||
if len == 0 {
|
||||
self.sentinel_seen.insert(*channel_id, true);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.model.route_to_unknown(*channel_id, len);
|
||||
}
|
||||
}
|
||||
ManagerOp::Open {
|
||||
alpn_idx,
|
||||
opener_idx,
|
||||
} => {
|
||||
let alpn = pick(alpn_idx, ["alk/tty", "alk/sub", "alk/x"]);
|
||||
let opener = pick(opener_idx, ["alice", "bob", "carol"]);
|
||||
match self.manager.open_channel(alpn, opener, None).await {
|
||||
Ok((_id, _send, recv)) => self.spawn_drainer(_id, recv),
|
||||
// The per-connection cap is a documented bound.
|
||||
Err(ManagerError::TooManyChannels { .. }) => {}
|
||||
Err(other) => return Err(format!("open_channel: unexpected {other:?}")),
|
||||
}
|
||||
}
|
||||
ManagerOp::Adopt { channel_id } => {
|
||||
match self
|
||||
.manager
|
||||
.adopt_channel(*channel_id, "alk/tty", None)
|
||||
.await
|
||||
{
|
||||
Ok((_send, recv)) => {
|
||||
// Adoption drains the parked queue FIFO into
|
||||
// the receiver — readable until a sentinel.
|
||||
if let Some(queue) = self.model.parked.remove(channel_id) {
|
||||
let drained_readable = ParkModel::readable_bytes_until_sentinel(&queue);
|
||||
*self.readable.entry(*channel_id).or_insert(0) += drained_readable;
|
||||
if queue.contains(&0) {
|
||||
self.sentinel_seen.insert(*channel_id, true);
|
||||
}
|
||||
}
|
||||
self.spawn_drainer(*channel_id, recv);
|
||||
}
|
||||
Err(ManagerError::ChannelExists(_)) => {}
|
||||
Err(ManagerError::TooManyChannels { .. }) => {}
|
||||
Err(other) => return Err(format!("adopt_channel: unexpected {other:?}")),
|
||||
}
|
||||
}
|
||||
ManagerOp::Teardown { channel_id } => {
|
||||
match self.manager.teardown_channel(*channel_id) {
|
||||
Ok(_task) => {}
|
||||
Err(ManagerError::UnknownChannel(_)) => {}
|
||||
Err(other) => return Err(format!("teardown_channel: unexpected {other:?}")),
|
||||
}
|
||||
}
|
||||
ManagerOp::ClearAll => {
|
||||
let dropped_before = self.manager.dropped_unknown_chunks();
|
||||
let _ledger_entries = self.manager.clear_all();
|
||||
// clear_all returns the opener-ledger entries (ADR-047
|
||||
// §7 — the per-identity decrement source), not the
|
||||
// channel map: adopted channels carry no ledger entry
|
||||
// (the remote side owns their count) and torn-down
|
||||
// channels keep theirs.
|
||||
self.model.parked.clear();
|
||||
assert_eq!(
|
||||
self.manager.dropped_unknown_chunks(),
|
||||
dropped_before,
|
||||
"clear_all does not reset the monotonic drop counter"
|
||||
);
|
||||
assert_eq!(
|
||||
self.manager.open_count(),
|
||||
0,
|
||||
"clear_all empties the channel map"
|
||||
);
|
||||
}
|
||||
}
|
||||
self.assert_counters()
|
||||
}
|
||||
|
||||
fn sentinel_seen(&self, channel_id: u32) -> bool {
|
||||
self.sentinel_seen
|
||||
.get(&channel_id)
|
||||
.copied()
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn spawn_drainer(&mut self, channel_id: u32, recv: MpscRecvStream) {
|
||||
// A fresh drainer for an id supersedes any earlier one: the
|
||||
// earlier stream's sender was dropped (teardown) or routed
|
||||
// past an EOF sentinel, so its readable-byte model is retired
|
||||
// into the reconciled total and the id's model restarts.
|
||||
if let Some(bytes) = self.readable.remove(&channel_id) {
|
||||
self.retired_drainer_bytes += bytes;
|
||||
}
|
||||
self.sentinel_seen.remove(&channel_id);
|
||||
let counter = Arc::new(AtomicU64::new(0));
|
||||
let task = tokio::spawn(drain_task(recv, Arc::clone(&counter)));
|
||||
self.drainers.push((channel_id, counter, task));
|
||||
}
|
||||
|
||||
fn assert_counters(&self) -> Result<(), String> {
|
||||
let actual_parked = self.manager.early_arrival_count();
|
||||
let actual_dropped = self.manager.dropped_unknown_chunks();
|
||||
if self.model.parked_total != actual_parked {
|
||||
return Err(format!(
|
||||
"parked-chunk model {} != manager counter {actual_parked}",
|
||||
self.model.parked_total
|
||||
));
|
||||
}
|
||||
if self.model.dropped_total != actual_dropped {
|
||||
return Err(format!(
|
||||
"dropped-chunk model {} != manager counter {actual_dropped}",
|
||||
self.model.dropped_total
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain a channel's reassembled stream to EOF, counting bytes read.
|
||||
/// Runs until Ok(0) — sender drop (teardown / clear_all) or EOF
|
||||
/// sentinel both end the stream after queued bytes drain.
|
||||
async fn drain_task(mut recv: MpscRecvStream, counter: Arc<AtomicU64>) {
|
||||
use tokio::io::AsyncReadExt;
|
||||
let mut buf = [0u8; 512];
|
||||
loop {
|
||||
match recv.read(&mut buf).await {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
counter.fetch_add(n as u64, Ordering::Relaxed);
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pick<const N: usize>(idx: &u8, options: [&'static str; N]) -> &'static str {
|
||||
options[(*idx as usize) % N]
|
||||
}
|
||||
|
||||
/// The whole-sequence driver. Bounds: 256 ops and 4 KiB payloads keep
|
||||
/// exec/s high while covering the DoS class (distinct-id spray: a
|
||||
/// 256-op sequence at 4 KiB parks up to ~1 MiB, and the exact counter
|
||||
/// plus cap-bound invariants are what the fuzzer must not break).
|
||||
pub fn fuzz_manager_routing(seq: &ManagerSequence) {
|
||||
const MAX_OPS: usize = 256;
|
||||
const MAX_PAYLOAD: usize = 4096;
|
||||
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("current-thread runtime builds")
|
||||
.block_on(async {
|
||||
let mut harness = ManagerHarness::new();
|
||||
for (i, op) in seq.ops.iter().take(MAX_OPS).enumerate() {
|
||||
if let Err(msg) = harness.step(op, MAX_PAYLOAD).await {
|
||||
panic!("invariant violated at op {i} ({op:?}): {msg}");
|
||||
}
|
||||
// The parked-bytes total must respect the documented
|
||||
// per-channel bound summed over distinct parked ids —
|
||||
// the §6.2-1 property.
|
||||
let bound = harness.model.parked.len() as u64
|
||||
* EARLY_ARRIVAL_CAP as u64
|
||||
* u64::from(MAX_CHUNK_LEN);
|
||||
assert!(
|
||||
harness.model.parked_bytes() <= bound,
|
||||
"parked bytes {} exceed the documented bound {bound} at op {i}",
|
||||
harness.model.parked_bytes()
|
||||
);
|
||||
}
|
||||
// Post-sequence: clear_all (the transport-EOF path) drops
|
||||
// every sender — all drainers run to EOF and their byte
|
||||
// totals must reconcile with the readable-byte model
|
||||
// (lossless bounded-buffer routing, sentinel semantics
|
||||
// included).
|
||||
harness
|
||||
.step(&ManagerOp::ClearAll, MAX_PAYLOAD)
|
||||
.await
|
||||
.expect("clear_all");
|
||||
let mut expected_total = harness.retired_drainer_bytes;
|
||||
for bytes in harness.readable.values() {
|
||||
expected_total += bytes;
|
||||
}
|
||||
let mut actual_total = 0u64;
|
||||
for (_id, counter, task) in harness.drainers.drain(..) {
|
||||
let _ = task.await;
|
||||
actual_total += counter.load(Ordering::Relaxed);
|
||||
}
|
||||
assert_eq!(
|
||||
actual_total, expected_total,
|
||||
"routed-to-known bytes must reconcile with drained bytes (lossless reassembly)"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod corpus_replay {
|
||||
use super::*;
|
||||
|
||||
/// Replay the committed op-sequence corpus through the driver.
|
||||
/// Note `fuzz_manager_routing` spawns a current-thread runtime per
|
||||
/// call; each sequence runs to completion inside it.
|
||||
#[test]
|
||||
fn committed_corpus_replays_through_the_invariants() {
|
||||
let corpus =
|
||||
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../corpus/manager_routing");
|
||||
let mut count = 0usize;
|
||||
for entry in std::fs::read_dir(&corpus).expect("corpus directory is committed") {
|
||||
let path = entry.expect("corpus entry readable").path();
|
||||
let data = std::fs::read(&path).expect("corpus entry readable");
|
||||
let u = arbitrary::Unstructured::new(&data);
|
||||
let seq: ManagerSequence =
|
||||
arbitrary::Arbitrary::arbitrary_take_rest(u).expect("seed decodes");
|
||||
fuzz_manager_routing(&seq);
|
||||
count += 1;
|
||||
}
|
||||
assert!(
|
||||
count >= 2,
|
||||
"committed seed corpus is present, found {count}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Semantic fixtures: the op sequences that pinned the adopt/open
|
||||
/// insert-replace bug, hand-encoded through `Unstructured` bytes
|
||||
/// the same way libFuzzer would feed them.
|
||||
#[test]
|
||||
fn duplicate_adopt_sequence_survives_the_fix() {
|
||||
// Encoded via the mr-decode tool's format (arbitrary 1.4.x):
|
||||
// 3 adopts of one id, a route, an EOF sentinel route.
|
||||
let seq = ManagerSequence {
|
||||
ops: vec![
|
||||
ManagerOp::Adopt { channel_id: 42 },
|
||||
ManagerOp::Adopt { channel_id: 42 },
|
||||
ManagerOp::Adopt { channel_id: 42 },
|
||||
ManagerOp::Route {
|
||||
channel_id: 42,
|
||||
len: 2418,
|
||||
byte: 121,
|
||||
},
|
||||
ManagerOp::Route {
|
||||
channel_id: 0,
|
||||
len: 0,
|
||||
byte: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
fuzz_manager_routing(&seq);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user