Skip to content

Commit fdea3d5

Browse files
committed
chore(antithesis): is_malformed based load generation
The essential property we want to determine is whether ADP-on/ADP-off emits payloads to intake API that the intake API rejects. This is especially of interest for inputs that we know are ultimately rejected by intake API -- non-utf8 bytes in the wrong spot -- but are _not_ rejected by the SUT. The old mechanism had a feral/clean 'vibe' which served for a while but was confusing to debug. Is feral malformed? Is feral well-formed but wild? Anyway I got tired of it. There's now a predicate which defines whether a payload is well-formed or not -- that is, accepted by ADP-off Datadog Agent -- independent of whether intake API ultimately accepts the payloads that ingress inspires. Later I will build a generator that only emits malformed ingress but that is only hinted at in this work.
1 parent 39f3b2a commit fdea3d5

2 files changed

Lines changed: 66 additions & 8 deletions

File tree

test/antithesis/harness/src/payload/dogstatsd.rs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ pub struct Payload {
3333
pub max_packed: usize,
3434
}
3535

36+
/// One in this many datagrams gets a single non-UTF-8 byte spliced onto a metric name, so a
37+
/// controlled fraction of agent-lane v3 payloads carry a non-UTF-8 strict field and whole-reject.
38+
const NON_UTF8_PAYLOAD_RATE: u32 = 100;
39+
3640
/// Pack whole `\n`-terminated lines into `buf`, each a fresh render of a sampled context, until the
3741
/// next line does not fit the space left under `limit_bytes`. That overflowing line ends the payload,
3842
/// so the packed datagram never exceeds `limit_bytes` and holds only whole lines the Agent forwards.
@@ -46,6 +50,7 @@ pub fn write_payload(
4650
return payload;
4751
}
4852
let mut line = Vec::new();
53+
let mut line_starts = Vec::new();
4954
loop {
5055
line.clear();
5156
let context = &contexts[rng.random_range(0..contexts.len())];
@@ -54,11 +59,29 @@ pub fn write_payload(
5459
if buf.len() + line.len() + 1 > limit_bytes {
5560
break;
5661
}
62+
line_starts.push(buf.len());
5763
buf.extend_from_slice(&line);
5864
buf.push(b'\n');
5965
payload.lines += 1;
6066
payload.max_packed = payload.max_packed.max(packed);
6167
}
68+
// A budgeted fraction of datagrams carry one non-UTF-8 byte on a metric name -- the dictNameStr
69+
// strict field that drives the v3 whole-payload reject. Events and service checks route to other
70+
// endpoints, so target a metric line. The byte leaves the line `!is_malformed`, the Agent forwards
71+
// a non-UTF-8 name, so the green-by-construction anchor holds while the intake still sees the
72+
// reject at the budgeted rate. Rolling once per datagram, not per minted context, keeps the blast
73+
// radius budgeted rather than compounding across every render of a bad-named context.
74+
if rng.random_range(0..NON_UTF8_PAYLOAD_RATE) == 0 && buf.len() < limit_bytes {
75+
let metric_starts: Vec<usize> = line_starts
76+
.iter()
77+
.copied()
78+
.filter(|&i| !buf[i..].starts_with(b"_e{") && !buf[i..].starts_with(b"_sc"))
79+
.collect();
80+
if !metric_starts.is_empty() {
81+
let at = metric_starts[rng.random_range(0..metric_starts.len())];
82+
buf.insert(at, common::invalid_utf8_byte(rng));
83+
}
84+
}
6285
payload
6386
}
6487

@@ -84,6 +107,35 @@ mod test {
84107
buf.iter().filter(|&&b| b == b'\n').count()
85108
}
86109

110+
/// Routine content and minted contexts are all valid UTF-8, so an invalid byte in a datagram means
111+
/// the per-payload injection fired. It must fire at roughly the budgeted rate and never break
112+
/// well-formedness -- a non-UTF-8 name is still a line the Agent forwards.
113+
#[test]
114+
fn non_utf8_injection_is_rare_and_stays_well_formed() {
115+
let mut rng = SmallRng::seed_from_u64(0x00C0_FFEE);
116+
let contexts = pool(&mut rng, 8);
117+
let mut buf = Vec::new();
118+
let total = 2000usize;
119+
let mut injected = 0usize;
120+
for _ in 0..total {
121+
write_payload(&mut rng, &contexts, &mut buf, 1024);
122+
assert_eq!(
123+
is_malformed(&buf),
124+
Ok(()),
125+
"injection broke well-formedness: {:?}",
126+
String::from_utf8_lossy(&buf)
127+
);
128+
if simdutf8::basic::from_utf8(&buf).is_err() {
129+
injected += 1;
130+
}
131+
}
132+
// ~1% expected; assert it is neither ~0 nor rampant.
133+
assert!(
134+
(3..120).contains(&injected),
135+
"non-UTF-8 datagrams {injected}/{total}, expected ~1%"
136+
);
137+
}
138+
87139
proptest! {
88140
/// The whole point: every datagram the driver packs from pooled contexts is one the Agent
89141
/// forwards. A packed datagram must be entirely well-formed.

test/antithesis/harness/src/payload/dogstatsd/common.rs

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,18 +28,16 @@ const COMPLIANT_WORD: &[&[u8]] = &[
2828
b"workers",
2929
];
3030

31-
/// Aberrant identifier segments: whitespace, NUL, ill-formed and non-conforming UTF-8, and exotic
32-
/// Unicode. Omits `\n` and `\r`, which are datagram framing rather than content.
31+
/// Aberrant identifier segments: whitespace, NUL, and exotic but valid UTF-8. Invalid-UTF-8 bytes are
32+
/// deliberately absent. The per-datagram injection in `write_payload` adds one at a fixed rate rather
33+
/// than sprinkling them per segment, so their blast radius stays a budgeted fraction of datagrams
34+
/// instead of compounding across every minted context into a near-certain whole-payload v3 reject.
35+
/// Omits `\n` and `\r`, which are datagram framing rather than content.
3336
const ABERRANT_WORD: &[&[u8]] = &[
3437
b" ",
3538
b"\t",
3639
b"\0",
37-
b"\x80", // lone continuation byte
38-
b"\xc3", // truncated two-byte lead
39-
b"\xed\xa0\x80", // UTF-16 surrogate, ill-formed UTF-8
40-
b"\xc0\x80", // overlong NUL
41-
b"\xff\xfe", // non-character bytes
42-
"café".as_bytes(), // non-conforming but valid UTF-8
40+
"café".as_bytes(), // non-ASCII but valid UTF-8
4341
"Ωμέγα".as_bytes(), // Greek
4442
"日本語".as_bytes(), // CJK
4543
"🦆".as_bytes(), // emoji, non-ASCII multi-byte
@@ -49,6 +47,14 @@ const ABERRANT_WORD: &[&[u8]] = &[
4947
"\u{feff}".as_bytes(), // byte-order mark
5048
];
5149

50+
/// Bytes that are never valid UTF-8 in any position, for the per-datagram non-UTF-8 injection.
51+
const INVALID_UTF8: &[u8] = &[0x80, 0xC0, 0xC1, 0xF5, 0xFE, 0xFF];
52+
53+
/// One byte that makes any surrounding ASCII field invalid UTF-8.
54+
pub(crate) fn invalid_utf8_byte(rng: &mut (impl Rng + ?Sized)) -> u8 {
55+
INVALID_UTF8[rng.random_range(0..INVALID_UTF8.len())]
56+
}
57+
5258
/// Message delimiters, mixed into content so the generator explores delimiter-bearing fields. Most
5359
/// land the message in the drop set and are repaired away; the survivors are the forwarded oddities.
5460
const DELIMITERS: &[&[u8]] = &[b":", b"|", b",", b"#", b"@"];

0 commit comments

Comments
 (0)