use chrono::{DateTime, Utc};
use serde::Serialize;
use serde_json::Value;
use std::collections::BTreeMap;
mod catalog;
mod schema;
mod validation;
pub struct AsyncMessage {
pub protocol: String,
pub operation: String,
pub address: Option<String>,
pub headers: Option<Value>,
pub payload: Option<Vec<u8>>,
pub timestamp: DateTime<Utc>,
}
#[derive(Serialize)]
pub struct AsyncSampleRoute {
pub endpoint: String,
pub status: &'static str,
pub exchanges_checked: usize,
pub findings: Vec<String>,
pub notes: Vec<String>,
}
#[derive(Serialize)]
pub struct AsyncSampleReport {
pub exchanges_checked: usize,
pub latest_capture_at: String,
pub routes: Vec<AsyncSampleRoute>,
}
pub fn verify(
source: &Value,
records: &[AsyncMessage],
max_bytes: usize,
) -> Result<AsyncSampleReport, String> {
let latest = records
.iter()
.map(|r| r.timestamp)
.max()
.ok_or("The sample contains no asynchronous messages")?;
let (definitions, operations) = catalog::build(source, max_bytes)?;
let mut routes: BTreeMap<_, _> =
definitions.keys().map(|name| (name.clone(), new_route(name.clone()))).collect();
let mut checker = validation::Checker::new(source);
let mut detail_bytes = 0;
for record in records {
assess_record(record, &operations, &mut checker, &mut routes, &mut detail_bytes)?;
}
Ok(AsyncSampleReport {
exchanges_checked: records.len(),
latest_capture_at: latest.to_rfc3339(),
routes: routes.into_values().collect(),
})
}
fn assess_record(
record: &AsyncMessage,
operations: &[catalog::Operation],
checker: &mut validation::Checker<'_>,
routes: &mut BTreeMap<String, AsyncSampleRoute>,
detail_bytes: &mut usize,
) -> Result<(), String> {
let action = direction(record);
let candidates: Vec<_> = operations
.iter()
.filter(|operation| {
action == Some(operation.action.as_str())
&& operation.address.as_ref().is_some_and(|r| {
record.address.as_deref().is_some_and(|address| r.is_match(address))
})
&& protocol_matches(&operation.protocols, &record.protocol)
})
.collect();
if candidates.is_empty() {
return unattributed(record, operations, routes, detail_bytes);
}
let ambiguous = candidates.len() > 1;
let outcomes = candidates
.into_iter()
.map(|operation| {
checker
.alternatives(&operation.messages, record)
.map(|outcome| (operation, outcome))
})
.collect::<Result<Vec<_>, _>>()?;
let all_invalid = outcomes.iter().all(|(_, outcome)| !outcome.findings.is_empty());
let mut channels: BTreeMap<String, validation::Outcome> = BTreeMap::new();
for (operation, mut outcome) in outcomes {
let known_failures = if all_invalid {
outcome.findings.clone()
} else {
Vec::new()
};
if ambiguous {
outcome.findings.clear();
outcome.notes.push("Multiple source operations share this channel and direction; the capture does not identify which operation ran".into());
}
if operation.protocols.is_empty() {
outcome.notes.push("The source does not identify the channel protocol".into());
}
update(
routes.get_mut(&operation.endpoint).ok_or("Missing sample operation")?,
&outcome,
detail_bytes,
)?;
let channel = channels.entry(operation.channel_endpoint.clone()).or_default();
channel.findings.extend(outcome.findings);
channel.findings.extend(known_failures);
channel.notes.extend(outcome.notes);
}
for (endpoint, outcome) in channels {
update(
routes.get_mut(&endpoint).ok_or("Missing sample channel")?,
&outcome,
detail_bytes,
)?;
}
Ok(())
}
fn unattributed(
record: &AsyncMessage,
operations: &[catalog::Operation],
routes: &mut BTreeMap<String, AsyncSampleRoute>,
detail_bytes: &mut usize,
) -> Result<(), String> {
let endpoint = format!(
"{} {} {}",
record.protocol,
record.operation,
record.address.as_deref().unwrap_or("<unknown>")
);
let route = routes.entry(endpoint.clone()).or_insert_with(|| new_route(endpoint));
let uncertain = record.address.is_none()
|| direction(record).is_none()
|| operations.is_empty()
|| operations.iter().any(|o| o.address.is_none());
let outcome = if uncertain {
validation::Outcome { notes:vec!["Recorded operation or a dynamic channel address cannot be attributed to a source operation".into()], ..Default::default() }
} else {
validation::Outcome { findings:vec!["Recorded message channel, protocol or direction is absent from the source definition".into()], ..Default::default() }
};
update(route, &outcome, detail_bytes)
}
fn new_route(endpoint: String) -> AsyncSampleRoute {
AsyncSampleRoute {
endpoint,
status: "not-sampled",
exchanges_checked: 0,
findings: Vec::new(),
notes: Vec::new(),
}
}
fn update(
route: &mut AsyncSampleRoute,
outcome: &validation::Outcome,
detail_bytes: &mut usize,
) -> Result<(), String> {
route.exchanges_checked += 1;
for (target, incoming) in [
(&mut route.findings, &outcome.findings),
(&mut route.notes, &outcome.notes),
] {
for detail in incoming {
if !target.contains(detail) {
*detail_bytes += detail.len();
if *detail_bytes > 1024 * 1024 {
return Err(
"Sample comparison produced too many details; export a smaller sample"
.into(),
);
}
target.push(detail.clone());
}
}
}
route.status = if !route.findings.is_empty() {
"drift-detected"
} else if !route.notes.is_empty() {
"not-verified"
} else {
"verified"
};
Ok(())
}
fn direction(record: &AsyncMessage) -> Option<&'static str> {
match (
record.protocol.to_ascii_lowercase().as_str(),
record.operation.to_ascii_lowercase().as_str(),
) {
("mqtt" | "amqp", "publish") | ("kafka", "produce") | ("websocket", "inbound") => {
Some("RECEIVE")
}
("mqtt" | "amqp", "deliver") | ("kafka", "consume") | ("websocket", "outbound") => {
Some("SEND")
}
_ => None,
}
}
fn protocol_matches(protocols: &[String], protocol: &str) -> bool {
protocols.is_empty()
|| protocols.iter().any(|declared| match protocol.to_ascii_lowercase().as_str() {
"mqtt" => matches!(declared.as_str(), "mqtt" | "mqtts" | "secure-mqtt"),
"kafka" => matches!(declared.as_str(), "kafka" | "kafka-secure"),
"amqp" => matches!(declared.as_str(), "amqp" | "amqps"),
"websocket" => matches!(declared.as_str(), "ws" | "wss"),
_ => false,
})
}
#[cfg(test)]
mod tests;