Files
alkcall/fuzz/shared/src/envelope_semantic.rs
T
glm-5.3-flash a1f257757b 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
2026-09-27 23:19:18 +00:00

167 lines
5.6 KiB
Rust

//! 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);
}