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,166 @@
|
||||
//! Invariants for the `EventEnvelope` constructors and `CallError`
|
||||
//! payloads (ADR-014/ADR-016): structural round-trip through
|
||||
//! `write_frame`/`read_frame` for every event kind, `CallError`
|
||||
//! serialization never panics and parses back, and the wire event-type
|
||||
//! constants survive the constructors.
|
||||
|
||||
use crate::arbitrary_value::JsonValue;
|
||||
use alkcall::protocol::wire::{
|
||||
CallError, EventEnvelope, FrameError, FrameFramedReader, FrameFramedWriter,
|
||||
};
|
||||
use std::io::Cursor;
|
||||
|
||||
#[derive(Debug, arbitrary::Arbitrary)]
|
||||
pub enum EnvelopeKind {
|
||||
Requested {
|
||||
id_len: u8,
|
||||
id_byte: u8,
|
||||
payload: JsonValue,
|
||||
},
|
||||
Responded {
|
||||
id_len: u8,
|
||||
id_byte: u8,
|
||||
output: JsonValue,
|
||||
},
|
||||
Completed {
|
||||
id_len: u8,
|
||||
id_byte: u8,
|
||||
},
|
||||
Published {
|
||||
id_len: u8,
|
||||
id_byte: u8,
|
||||
chunk: JsonValue,
|
||||
},
|
||||
Aborted {
|
||||
id_len: u8,
|
||||
id_byte: u8,
|
||||
},
|
||||
Error {
|
||||
id_len: u8,
|
||||
id_byte: u8,
|
||||
code_len: u8,
|
||||
code_byte: u8,
|
||||
message_len: u8,
|
||||
message_byte: u8,
|
||||
retryable: bool,
|
||||
details: Option<JsonValue>,
|
||||
},
|
||||
}
|
||||
|
||||
fn repeated_string(len: u8, byte: u8) -> String {
|
||||
// Bounded: at most 64 repetitions of one char. Long strings are a
|
||||
// frame-size concern covered by `envelope_frame`; this target is
|
||||
// about the constructors and structural invariants.
|
||||
let n = (len % 64) as usize;
|
||||
std::iter::repeat_n(byte as char, n).collect()
|
||||
}
|
||||
|
||||
fn assert_round_trip(envelope: &EventEnvelope) {
|
||||
let mut bytes = Vec::new();
|
||||
{
|
||||
let mut writer = FrameFramedWriter::new(&mut bytes);
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.build()
|
||||
.expect("runtime builds")
|
||||
.block_on(writer.write_frame(envelope))
|
||||
.expect("write_frame of any constructor output must succeed");
|
||||
}
|
||||
let mut reader = FrameFramedReader::new(Cursor::new(bytes));
|
||||
let decoded = match tokio::runtime::Builder::new_current_thread()
|
||||
.build()
|
||||
.expect("runtime builds")
|
||||
.block_on(reader.read_frame())
|
||||
{
|
||||
Ok(decoded) => decoded,
|
||||
Err(FrameError::Json(e)) => {
|
||||
panic!("a written envelope must re-decode: {e}");
|
||||
}
|
||||
Err(other) => panic!("a written envelope must re-decode, got {other:?}"),
|
||||
};
|
||||
assert_eq!(
|
||||
&decoded, envelope,
|
||||
"structural round-trip (serde_json key order is not preserved)"
|
||||
);
|
||||
}
|
||||
|
||||
pub fn fuzz_envelope_semantic(kind: &EnvelopeKind) {
|
||||
let envelope = match kind {
|
||||
EnvelopeKind::Requested {
|
||||
id_len,
|
||||
id_byte,
|
||||
payload,
|
||||
} => EventEnvelope::requested(repeated_string(*id_len, *id_byte), payload.0.clone()),
|
||||
EnvelopeKind::Responded {
|
||||
id_len,
|
||||
id_byte,
|
||||
output,
|
||||
} => EventEnvelope::responded(repeated_string(*id_len, *id_byte), output.0.clone()),
|
||||
EnvelopeKind::Completed { id_len, id_byte } => {
|
||||
EventEnvelope::completed(repeated_string(*id_len, *id_byte))
|
||||
}
|
||||
EnvelopeKind::Published {
|
||||
id_len,
|
||||
id_byte,
|
||||
chunk,
|
||||
} => EventEnvelope::published(repeated_string(*id_len, *id_byte), chunk.0.clone()),
|
||||
EnvelopeKind::Aborted { id_len, id_byte } => {
|
||||
EventEnvelope::aborted(repeated_string(*id_len, *id_byte))
|
||||
}
|
||||
EnvelopeKind::Error {
|
||||
id_len,
|
||||
id_byte,
|
||||
code_len,
|
||||
code_byte,
|
||||
message_len,
|
||||
message_byte,
|
||||
retryable,
|
||||
details,
|
||||
} => {
|
||||
let mut error = CallError::new(
|
||||
repeated_string(*code_len, *code_byte),
|
||||
repeated_string(*message_len, *message_byte),
|
||||
*retryable,
|
||||
);
|
||||
if let Some(details) = details {
|
||||
error = error.with_details(details.0.clone());
|
||||
}
|
||||
EventEnvelope::error(repeated_string(*id_len, *id_byte), &error)
|
||||
}
|
||||
};
|
||||
|
||||
// The event-type constants are wire-stable; the constructors must
|
||||
// emit exactly them.
|
||||
let expected_type = match kind {
|
||||
EnvelopeKind::Requested { .. } => alkcall::protocol::wire::EVENT_REQUESTED,
|
||||
EnvelopeKind::Responded { .. } => alkcall::protocol::wire::EVENT_RESPONDED,
|
||||
EnvelopeKind::Completed { .. } => alkcall::protocol::wire::EVENT_COMPLETED,
|
||||
EnvelopeKind::Published { .. } => alkcall::protocol::wire::EVENT_PUBLISHED,
|
||||
EnvelopeKind::Aborted { .. } => alkcall::protocol::wire::EVENT_ABORTED,
|
||||
EnvelopeKind::Error { .. } => alkcall::protocol::wire::EVENT_ERROR,
|
||||
};
|
||||
assert_eq!(envelope.r#type, expected_type);
|
||||
|
||||
// The `payload` field is schema-free JSON: any Value must survive
|
||||
// a serde round-trip intact (the serde_json 128-depth recursion
|
||||
// limit is a decode-side guard, not an encode-side one).
|
||||
let as_value = serde_json::to_value(&envelope).expect("envelope serializes");
|
||||
let reparsed: EventEnvelope =
|
||||
serde_json::from_value(as_value).expect("a serialized envelope re-parses");
|
||||
assert_eq!(
|
||||
reparsed, envelope,
|
||||
"serde round-trip is structural identity"
|
||||
);
|
||||
|
||||
// A `call.error` payload always parses back into a `CallError`
|
||||
// (ADR-016: the error schema is closed and serde-derivable).
|
||||
if matches!(kind, EnvelopeKind::Error { .. }) {
|
||||
let parsed: Result<CallError, _> = serde_json::from_value(envelope.payload.clone());
|
||||
assert!(
|
||||
parsed.is_ok(),
|
||||
"call.error payload must parse back as CallError, got {:?}",
|
||||
envelope.payload
|
||||
);
|
||||
}
|
||||
|
||||
assert_round_trip(&envelope);
|
||||
}
|
||||
Reference in new issue
Block a user