use crate::canary::Canary;
use crate::request::Request;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
pub enum SmuggleArtifact {
Headers(Vec<(String, String)>),
BodyWithContentType {
content_type: String,
body: Vec<u8>,
},
Frames(Vec<Vec<u8>>),
}
impl SmuggleArtifact {
#[must_use]
pub fn wire_byte_count(&self) -> usize {
match self {
Self::Headers(hs) => hs
.iter()
.map(|(n, v)| n.len() + 2 + v.len() + 2) .sum(),
Self::BodyWithContentType { content_type, body } => {
"Content-Type: ".len() + content_type.len() + 2 + body.len()
}
Self::Frames(fs) => fs.iter().map(Vec::len).sum(),
}
}
}
pub trait SmuggleProbe {
fn canary(&self) -> &Canary;
fn technique(&self) -> String;
fn description(&self) -> &str;
fn artifact(&self) -> SmuggleArtifact;
}
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ComposedArtifact {
pub headers: Vec<(String, String)>,
pub body: Option<(String, Vec<u8>)>,
pub frames: Vec<Vec<u8>>,
pub techniques: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub canaries: Vec<String>,
}
impl ComposedArtifact {
pub fn apply_to_request(&self, req: &mut Request) -> Vec<Vec<u8>> {
req.headers.extend(self.headers.iter().cloned());
if let Some((ct, body)) = &self.body {
req.headers
.retain(|(n, _)| !n.eq_ignore_ascii_case("content-type"));
req.headers.push(("Content-Type".to_string(), ct.clone()));
req.body = Some(body.clone());
}
self.frames.clone()
}
}
#[must_use]
pub fn compose_artifacts(probes: &[&dyn SmuggleProbe]) -> ComposedArtifact {
let mut out = ComposedArtifact::default();
for p in probes {
out.techniques.push(p.technique());
out.canaries.push(p.canary().token.clone());
match p.artifact() {
SmuggleArtifact::Headers(hs) => out.headers.extend(hs),
SmuggleArtifact::BodyWithContentType { content_type, body } => {
out.body = Some((content_type, body));
}
SmuggleArtifact::Frames(fs) => out.frames.extend(fs),
}
}
out
}
#[must_use]
pub fn compose_n_product(families: &[&[Box<dyn SmuggleProbe>]]) -> Vec<ComposedArtifact> {
if families.is_empty() || families.iter().any(|f| f.is_empty()) {
return Vec::new();
}
let total: usize = families.iter().map(|f| f.len()).product();
let mut out = Vec::with_capacity(total);
let mut idx = vec![0usize; families.len()];
loop {
let refs: Vec<&dyn SmuggleProbe> = families
.iter()
.zip(idx.iter())
.map(|(f, &i)| f[i].as_ref())
.collect();
out.push(compose_artifacts(&refs));
let mut k = families.len();
loop {
if k == 0 {
return out;
}
k -= 1;
idx[k] += 1;
if idx[k] < families[k].len() {
break;
}
idx[k] = 0;
}
}
}
#[must_use]
pub fn compose_cross_product(
lhs: &[Box<dyn SmuggleProbe>],
rhs: &[Box<dyn SmuggleProbe>],
) -> Vec<ComposedArtifact> {
compose_n_product(&[lhs, rhs])
}
#[cfg(test)]
mod tests {
use super::*;
struct StubProbe {
canary: Canary,
technique: String,
description: String,
artifact: SmuggleArtifact,
}
impl SmuggleProbe for StubProbe {
fn canary(&self) -> &Canary {
&self.canary
}
fn technique(&self) -> String {
self.technique.clone()
}
fn description(&self) -> &str {
&self.description
}
fn artifact(&self) -> SmuggleArtifact {
self.artifact.clone()
}
}
fn header_probe(name: &str, value: &str, tag: &str) -> StubProbe {
StubProbe {
canary: Canary::generate(),
technique: tag.into(),
description: "header probe stub".into(),
artifact: SmuggleArtifact::Headers(vec![(name.into(), value.into())]),
}
}
fn body_probe(ct: &str, body: &[u8], tag: &str) -> StubProbe {
StubProbe {
canary: Canary::generate(),
technique: tag.into(),
description: "body probe stub".into(),
artifact: SmuggleArtifact::BodyWithContentType {
content_type: ct.into(),
body: body.into(),
},
}
}
fn frames_probe(frames: Vec<Vec<u8>>, tag: &str) -> StubProbe {
StubProbe {
canary: Canary::generate(),
technique: tag.into(),
description: "frames probe stub".into(),
artifact: SmuggleArtifact::Frames(frames),
}
}
#[test]
fn headers_wire_byte_count_includes_separator_and_crlf() {
let a = SmuggleArtifact::Headers(vec![("X".into(), "Y".into())]);
assert_eq!(a.wire_byte_count(), 1 + 2 + 1 + 2);
}
#[test]
fn body_with_content_type_wire_count_sums_header_and_body() {
let a = SmuggleArtifact::BodyWithContentType {
content_type: "text/plain".into(),
body: b"hello".to_vec(),
};
assert_eq!(a.wire_byte_count(), 14 + 10 + 2 + 5);
}
#[test]
fn frames_wire_count_sums_each_frame() {
let a = SmuggleArtifact::Frames(vec![vec![1, 2, 3], vec![4, 5]]);
assert_eq!(a.wire_byte_count(), 5);
}
#[test]
fn empty_artifacts_have_zero_byte_count() {
assert_eq!(SmuggleArtifact::Headers(vec![]).wire_byte_count(), 0);
assert_eq!(SmuggleArtifact::Frames(vec![]).wire_byte_count(), 0);
let a = SmuggleArtifact::BodyWithContentType {
content_type: String::new(),
body: Vec::new(),
};
assert_eq!(a.wire_byte_count(), "Content-Type: ".len() + 2);
}
#[test]
fn compose_empty_input_returns_default() {
let composed = compose_artifacts(&[]);
assert_eq!(composed, ComposedArtifact::default());
}
#[test]
fn compose_two_header_probes_concatenates_headers() {
let a = header_probe("Cookie", "session=evil", "cookie.x");
let b = header_probe("Authorization", "Bearer T", "auth.y");
let probes: Vec<&dyn SmuggleProbe> = vec![&a, &b];
let composed = compose_artifacts(&probes);
assert_eq!(composed.headers.len(), 2);
assert_eq!(
composed.headers[0],
("Cookie".into(), "session=evil".into())
);
assert_eq!(
composed.headers[1],
("Authorization".into(), "Bearer T".into())
);
assert_eq!(composed.techniques, vec!["cookie.x", "auth.y"]);
assert!(composed.body.is_none());
assert!(composed.frames.is_empty());
}
#[test]
fn compose_header_plus_body_carries_both() {
let h = header_probe("Cookie", "a=evil", "cookie.dup");
let b = body_probe(
"multipart/form-data; boundary=xyz",
b"--xyz\r\n\r\nbody\r\n--xyz--\r\n",
"multipart.preamble",
);
let probes: Vec<&dyn SmuggleProbe> = vec![&h, &b];
let composed = compose_artifacts(&probes);
assert_eq!(composed.headers.len(), 1);
let (ct, body) = composed.body.as_ref().expect("body present");
assert_eq!(ct, "multipart/form-data; boundary=xyz");
assert!(body.starts_with(b"--xyz"));
assert_eq!(composed.techniques.len(), 2);
}
#[test]
fn compose_last_body_wins_when_two_supplied() {
let b1 = body_probe("text/plain", b"first", "x.first");
let b2 = body_probe("application/json", b"{\"k\":\"second\"}", "x.second");
let probes: Vec<&dyn SmuggleProbe> = vec![&b1, &b2];
let composed = compose_artifacts(&probes);
let (ct, body) = composed.body.as_ref().expect("body");
assert_eq!(ct, "application/json");
assert_eq!(body, b"{\"k\":\"second\"}");
}
#[test]
fn compose_frame_probes_concatenate_in_input_order() {
let f1 = frames_probe(vec![vec![1, 2], vec![3, 4]], "frame.a");
let f2 = frames_probe(vec![vec![5]], "frame.b");
let probes: Vec<&dyn SmuggleProbe> = vec![&f1, &f2];
let composed = compose_artifacts(&probes);
assert_eq!(composed.frames, vec![vec![1, 2], vec![3, 4], vec![5]]);
}
#[test]
fn compose_mixed_three_kinds_in_one_request() {
let h = header_probe("X-Probe", "abc", "tag.h");
let b = body_probe("text/plain", b"hello", "tag.b");
let f = frames_probe(vec![vec![0xFF]], "tag.f");
let probes: Vec<&dyn SmuggleProbe> = vec![&h, &b, &f];
let composed = compose_artifacts(&probes);
assert_eq!(composed.headers.len(), 1);
assert!(composed.body.is_some());
assert_eq!(composed.frames, vec![vec![0xFF]]);
assert_eq!(composed.techniques, vec!["tag.h", "tag.b", "tag.f"]);
}
#[test]
fn compose_preserves_techniques_in_input_order() {
let a = header_probe("X", "1", "alpha");
let b = header_probe("Y", "2", "beta");
let c = header_probe("Z", "3", "gamma");
let composed = compose_artifacts(&[&a, &b, &c]);
assert_eq!(composed.techniques, vec!["alpha", "beta", "gamma"]);
}
#[test]
fn apply_to_request_extends_headers_in_place() {
let h1 = header_probe("Cookie", "a=1", "cookie.one");
let h2 = header_probe("Authorization", "Bearer T", "auth.one");
let composed = compose_artifacts(&[&h1, &h2]);
let mut req = Request::get("https://example.com/");
req.add_header("Accept", "*/*");
let leftover_frames = composed.apply_to_request(&mut req);
assert!(
leftover_frames.is_empty(),
"header-only compose returns no frames"
);
assert_eq!(req.headers.len(), 3);
assert!(req.headers.iter().any(|(n, v)| n == "Accept" && v == "*/*"));
assert!(req.headers.iter().any(|(n, v)| n == "Cookie" && v == "a=1"));
assert!(
req.headers
.iter()
.any(|(n, v)| n == "Authorization" && v == "Bearer T")
);
assert!(req.body.is_none());
}
#[test]
fn apply_to_request_replaces_existing_content_type_when_body_present() {
let b = body_probe("multipart/form-data; boundary=xyz", b"body", "mp.one");
let composed = compose_artifacts(&[&b]);
let mut req = Request::post("https://example.com/", b"original-body".to_vec());
req.add_header("Content-Type", "application/x-www-form-urlencoded");
let _ = composed.apply_to_request(&mut req);
let ct: Vec<&str> = req
.headers
.iter()
.filter(|(n, _)| n.eq_ignore_ascii_case("content-type"))
.map(|(_, v)| v.as_str())
.collect();
assert_eq!(ct.len(), 1, "exactly one Content-Type header");
assert_eq!(ct[0], "multipart/form-data; boundary=xyz");
assert_eq!(req.body.as_deref(), Some(b"body".as_slice()));
}
#[test]
fn apply_to_request_preserves_unrelated_headers() {
let h = header_probe("Cookie", "x", "c.x");
let composed = compose_artifacts(&[&h]);
let mut req = Request::get("https://example.com/");
req.add_header("User-Agent", "Mozilla/5.0");
req.add_header("Accept-Language", "en-US");
let _ = composed.apply_to_request(&mut req);
assert!(req.headers.iter().any(|(n, _)| n == "User-Agent"));
assert!(req.headers.iter().any(|(n, _)| n == "Accept-Language"));
assert!(req.headers.iter().any(|(n, _)| n == "Cookie"));
}
#[test]
fn composed_artifact_roundtrips_through_json() {
let h = header_probe("Cookie", "a=1", "cookie.x");
let b = body_probe("text/plain", b"body", "tag.b");
let composed = compose_artifacts(&[&h, &b]);
let json = serde_json::to_string(&composed).expect("serialize");
let back: ComposedArtifact = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, composed);
}
#[test]
fn smuggle_artifact_json_tags_kind_and_value_fields() {
let a = SmuggleArtifact::Headers(vec![("X".into(), "Y".into())]);
let json = serde_json::to_string(&a).expect("serialize");
assert!(json.contains("\"kind\":\"headers\""), "json: {json}");
assert!(json.contains("\"value\""), "json: {json}");
let a = SmuggleArtifact::Frames(vec![vec![1, 2]]);
let json = serde_json::to_string(&a).expect("serialize");
assert!(json.contains("\"kind\":\"frames\""), "json: {json}");
let a = SmuggleArtifact::BodyWithContentType {
content_type: "text/plain".into(),
body: b"abc".to_vec(),
};
let json = serde_json::to_string(&a).expect("serialize");
assert!(
json.contains("\"kind\":\"body_with_content_type\""),
"json: {json}"
);
assert!(json.contains("\"value\""), "json: {json}");
assert!(json.contains("\"content_type\""), "json: {json}");
}
#[test]
fn smuggle_artifact_each_variant_roundtrips_through_json() {
for original in [
SmuggleArtifact::Headers(vec![
("Cookie".into(), "a=1".into()),
("Authorization".into(), "Bearer T".into()),
]),
SmuggleArtifact::Frames(vec![vec![1, 2, 3], vec![4, 5]]),
SmuggleArtifact::BodyWithContentType {
content_type: "multipart/form-data; boundary=x".into(),
body: b"--x\r\n\r\nhi\r\n--x--\r\n".to_vec(),
},
] {
let json = serde_json::to_string(&original).expect("serialize");
let back: SmuggleArtifact = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, original);
}
}
#[test]
fn compose_cross_product_emits_lhs_times_rhs_artifacts() {
let h1 = header_probe("Cookie", "a=1", "c.1");
let h2 = header_probe("Cookie", "b=2", "c.2");
let h3 = header_probe("Authorization", "Bearer X", "a.1");
let lhs: Vec<Box<dyn SmuggleProbe>> = vec![Box::new(h1), Box::new(h2)];
let rhs: Vec<Box<dyn SmuggleProbe>> = vec![Box::new(h3)];
let cross = compose_cross_product(&lhs, &rhs);
assert_eq!(cross.len(), 2);
for c in &cross {
assert_eq!(c.headers.len(), 2);
assert_eq!(c.techniques.len(), 2);
assert!(c.techniques[0].starts_with("c."));
assert!(c.techniques[1].starts_with("a."));
assert_eq!(c.canaries.len(), 2);
for token in &c.canaries {
assert_eq!(token.len(), 16, "canary token must be 16 chars");
}
}
}
#[test]
fn compose_artifacts_preserves_canaries_in_technique_order() {
let a = header_probe("Cookie", "x", "cookie.a");
let b = header_probe("Authorization", "Bearer y", "auth.b");
let c = header_probe("Range", "bytes=0-1", "range.c");
let composed = compose_artifacts(&[&a, &b, &c]);
assert_eq!(composed.canaries.len(), 3);
assert_eq!(composed.canaries[0], a.canary.token);
assert_eq!(composed.canaries[1], b.canary.token);
assert_eq!(composed.canaries[2], c.canary.token);
assert_eq!(composed.canaries.len(), composed.techniques.len());
}
#[test]
fn composed_canaries_field_omitted_from_json_when_empty() {
let empty = ComposedArtifact::default();
let json = serde_json::to_string(&empty).expect("serialize");
assert!(
!json.contains("\"canaries\""),
"empty canaries must be skip-serialized: {json}"
);
}
#[test]
fn composed_artifact_roundtrips_with_canaries() {
let a = header_probe("Cookie", "x", "cookie.a");
let composed = compose_artifacts(&[&a]);
let json = serde_json::to_string(&composed).expect("serialize");
assert!(json.contains("\"canaries\""), "json: {json}");
let back: ComposedArtifact = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, composed);
}
#[test]
fn legacy_composed_json_without_canaries_field_still_loads() {
let legacy = r#"{
"headers": [["Cookie", "a=1"]],
"body": null,
"frames": [],
"techniques": ["cookie.x"]
}"#;
let parsed: ComposedArtifact = serde_json::from_str(legacy).expect("legacy load");
assert_eq!(parsed.techniques, vec!["cookie.x"]);
assert!(
parsed.canaries.is_empty(),
"legacy JSON without canaries field must default to empty"
);
}
#[test]
fn compose_n_product_three_families_emits_full_cartesian_product() {
let h1 = header_probe("Cookie", "x", "cookie.a");
let h2 = header_probe("Cookie", "y", "cookie.b");
let a1 = header_probe("Authorization", "Bearer p", "auth.a");
let a2 = header_probe("Authorization", "Bearer q", "auth.b");
let r1 = header_probe("Range", "bytes=0-1", "range.a");
let r2 = header_probe("Range", "bytes=2-3", "range.b");
let cookies: Vec<Box<dyn SmuggleProbe>> = vec![Box::new(h1), Box::new(h2)];
let auths: Vec<Box<dyn SmuggleProbe>> = vec![Box::new(a1), Box::new(a2)];
let ranges: Vec<Box<dyn SmuggleProbe>> = vec![Box::new(r1), Box::new(r2)];
let nway = compose_n_product(&[&cookies, &auths, &ranges]);
assert_eq!(nway.len(), 8, "2 × 2 × 2 = 8");
for c in &nway {
assert_eq!(c.headers.len(), 3);
assert_eq!(c.techniques.len(), 3);
assert_eq!(c.canaries.len(), 3);
assert!(c.techniques[0].starts_with("cookie."));
assert!(c.techniques[1].starts_with("auth."));
assert!(c.techniques[2].starts_with("range."));
}
}
#[test]
fn compose_n_product_empty_input_returns_empty() {
let out = compose_n_product(&[]);
assert!(out.is_empty());
}
#[test]
fn compose_n_product_any_empty_family_yields_empty_output() {
let h1 = header_probe("Cookie", "x", "cookie.a");
let nonempty: Vec<Box<dyn SmuggleProbe>> = vec![Box::new(h1)];
let empty: Vec<Box<dyn SmuggleProbe>> = vec![];
assert!(compose_n_product(&[&empty, &nonempty]).is_empty());
assert!(compose_n_product(&[&nonempty, &empty]).is_empty());
assert!(compose_n_product(&[&nonempty, &empty, &nonempty]).is_empty());
}
#[test]
fn compose_n_product_single_family_emits_one_per_probe() {
let h1 = header_probe("Cookie", "x", "cookie.a");
let h2 = header_probe("Cookie", "y", "cookie.b");
let h3 = header_probe("Cookie", "z", "cookie.c");
let one: Vec<Box<dyn SmuggleProbe>> = vec![Box::new(h1), Box::new(h2), Box::new(h3)];
let out = compose_n_product(&[&one]);
assert_eq!(out.len(), 3);
for c in &out {
assert_eq!(c.techniques.len(), 1);
assert_eq!(c.canaries.len(), 1);
}
}
#[test]
fn compose_n_product_equals_compose_cross_product_for_two_families() {
let h1 = header_probe("Cookie", "x", "cookie.a");
let h2 = header_probe("Cookie", "y", "cookie.b");
let a1 = header_probe("Authorization", "Bearer p", "auth.a");
let lhs: Vec<Box<dyn SmuggleProbe>> = vec![Box::new(h1), Box::new(h2)];
let rhs: Vec<Box<dyn SmuggleProbe>> = vec![Box::new(a1)];
let cross = compose_cross_product(&lhs, &rhs);
let nway = compose_n_product(&[&lhs, &rhs]);
assert_eq!(cross.len(), nway.len());
for (a, b) in cross.iter().zip(nway.iter()) {
assert_eq!(a, b, "cross_product must equal n_product[N=2]");
}
}
#[test]
fn compose_cross_product_empty_inputs_yield_empty_output() {
let empty: Vec<Box<dyn SmuggleProbe>> = vec![Box::new(header_probe("X", "1", "t.x"))];
let none: Vec<Box<dyn SmuggleProbe>> = vec![];
assert!(compose_cross_product(&none, &empty).is_empty());
assert!(compose_cross_product(&empty, &none).is_empty());
assert!(compose_cross_product(&none, &none).is_empty());
}
#[test]
fn apply_to_request_returns_frame_stream_for_non_http_transports() {
let f = frames_probe(vec![vec![0xFF, 0xAB], vec![0x42]], "frame.x");
let composed = compose_artifacts(&[&f]);
let mut req = Request::get("https://example.com/");
let frames = composed.apply_to_request(&mut req);
assert_eq!(frames, vec![vec![0xFF, 0xAB], vec![0x42]]);
assert!(req.headers.is_empty());
assert!(req.body.is_none());
}
}