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
167 lines
5.6 KiB
Rust
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);
|
|
}
|