Skip to content

Commit 7e3d076

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 966965a commit 7e3d076

13 files changed

Lines changed: 681 additions & 197 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/antithesis/harness/proptest-regressions/payload/dogstatsd.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,6 @@
66
# everyone who runs the test benefits from these saved cases.
77
cc 377cba92b14295b3fe5c6f2738a398ee042be8e35f529398ecf9e275e6b0a1da # shrinks to seed = 0
88
cc 1f807f27fdbf0b983fc773b815b21d6023e1bce87691d07133b438b862edcc5b # shrinks to seed = 6079028945602138863, limit_bytes = 21
9+
cc 966dfab50a6a0af70bafa9c882cd664e62586012ec563187766e7b07773b588e # shrinks to seed = 816969264511539406, limit_bytes = 9
10+
cc 4470fd2185844af9954fc8e973eee2ac8f6c939167fb7d950e448a5ca2d2b52a # shrinks to seed = 881271281136835980, limit_bytes = 8
11+
cc c084e24d26194bedbf39e3055ace5c28a2d3ce2f2b071be7277a18f49e568849 # shrinks to seed = 6133403856440011550

test/antithesis/harness/src/bin/first_sample_config/main.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ fn main() -> anyhow::Result<()> {
7373
let context_source_path = cli.config_dir.join("context_source.yaml");
7474
fs::write(
7575
&context_source_path,
76-
ContextSourceConfig::sample(&mut rng, driver.payload_byte_limit)
76+
ContextSourceConfig::sample(&mut rng, driver.datagram_byte_limit)
7777
.to_yaml()?
7878
.as_bytes(),
7979
)

test/antithesis/harness/src/config.rs

Lines changed: 27 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ use rand::distr::{Distribution, StandardUniform};
1515
use rand::{Rng, RngExt};
1616
use serde::{Deserialize, Serialize};
1717

18-
use crate::payload::dogstatsd::PAYLOAD_BYTE_LIMIT;
18+
use crate::payload::dogstatsd::DATAGRAM_BYTE_LIMIT;
1919
use crate::rand::Probe;
2020

2121
/// Agent log level.
@@ -258,9 +258,9 @@ const MAX_CONTEXTS_PER_REQUEST: usize = 65_536;
258258
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
259259
pub struct DriverConfig {
260260
/// Max bytes a generator packs into one datagram, the smaller of the SUT's
261-
/// sampled receive buffer and [`PAYLOAD_BYTE_LIMIT`]. A datagram this size
261+
/// sampled receive buffer and [`DATAGRAM_BYTE_LIMIT`]. A datagram this size
262262
/// fits one read, so the SUT never truncates a line mid-token.
263-
pub payload_byte_limit: usize,
263+
pub datagram_byte_limit: usize,
264264
/// Datagrams a driver invocation ships this timeline.
265265
pub datagram_count: usize,
266266
/// Distinct contexts a driver invocation fetches from the shared pool as its working set.
@@ -276,14 +276,14 @@ pub struct DriverConfig {
276276
impl DriverConfig {
277277
/// Sample the driver knobs for a SUT whose receive buffer is `buffer_size`.
278278
fn sample<R: Rng + ?Sized>(rng: &mut R, buffer_size: u64) -> Self {
279-
// The min is at most PAYLOAD_BYTE_LIMIT, so a buffer wider than usize
279+
// The min is at most DATAGRAM_BYTE_LIMIT, so a buffer wider than usize
280280
// caps to the ceiling like any other oversized buffer.
281-
let payload_byte_limit = match usize::try_from(buffer_size.min(PAYLOAD_BYTE_LIMIT as u64)) {
281+
let datagram_byte_limit = match usize::try_from(buffer_size.min(DATAGRAM_BYTE_LIMIT as u64)) {
282282
Ok(bytes) => bytes,
283-
Err(_) => PAYLOAD_BYTE_LIMIT,
283+
Err(_) => DATAGRAM_BYTE_LIMIT,
284284
};
285285
Self {
286-
payload_byte_limit,
286+
datagram_byte_limit,
287287
datagram_count: rng.random_range(0..=MAX_DATAGRAMS),
288288
context_count: usize::try_from(Probe::new(1, MAX_WORKING_SET).sample(rng)).unwrap_or(usize::MAX),
289289
}
@@ -304,7 +304,7 @@ impl DriverConfig {
304304
/// # Errors
305305
///
306306
/// Returns an error if the config is unreadable or is not valid YAML with an
307-
/// integer `payload_byte_limit`.
307+
/// integer `datagram_byte_limit`.
308308
pub fn read(config_dir: &Path) -> anyhow::Result<Self> {
309309
let path = config_dir.join("driver.yaml");
310310
let yaml = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
@@ -333,14 +333,16 @@ const MAX_CONTEXTS_TOTAL: u64 = 1_000_000;
333333
pub struct ContextSourceConfig {
334334
/// Bytes a rendered line of any pooled context must fit, this timeline's real datagram budget
335335
/// rather than the protocol ceiling. Mint builds identities against it, so every served context
336-
/// has a rendering the driver can pack. Defaults to the sampled `payload_byte_limit`, which is the
337-
/// smaller of the SUT's receive buffer and [`PAYLOAD_BYTE_LIMIT`].
338-
pub payload_byte_limit: usize,
336+
/// has a rendering the driver can pack. Defaults to the sampled `datagram_byte_limit`, which is the
337+
/// smaller of the SUT's receive buffer and [`DATAGRAM_BYTE_LIMIT`].
338+
pub datagram_byte_limit: usize,
339339
/// Distinct metric contexts the pool holds before it recurs the ones it has.
340340
///
341-
/// Sampled in `1..=MAX_CONTEXTS_TOTAL` minus what the other kinds took. A larger cap explores more
341+
/// Sampled in `2..=MAX_CONTEXTS_TOTAL` minus what the other kinds took. A larger cap explores more
342342
/// identities and puts fewer points in each, and costs memory: the pool retains every context it
343-
/// mints for the life of the run. Zero is not sampled and serves nothing for the kind.
343+
/// mints for the life of the run. Two is the floor because the pool holds one context carrying an
344+
/// invalid UTF-8 byte per kind alongside the rest, and a kind capped at one could hold only one of
345+
/// the two.
344346
pub metric_contexts: usize,
345347
/// Distinct event contexts the pool holds. Same range and trade as [`Self::metric_contexts`].
346348
pub event_contexts: usize,
@@ -354,16 +356,16 @@ impl ContextSourceConfig {
354356
///
355357
/// The draws run in order and each spends from one shared ceiling, so the total is bounded by
356358
/// construction rather than by scaling three independent draws afterwards. Every kind keeps at
357-
/// least one context, since a kind capped at zero panics the pool's draw.
359+
/// least two contexts, one of which carries an invalid UTF-8 byte.
358360
#[must_use]
359-
pub fn sample<R: Rng + ?Sized>(rng: &mut R, payload_byte_limit: usize) -> Self {
360-
// Two contexts held back so the later kinds can each keep their one.
361-
let metric_contexts = sample_cap(rng, MAX_CONTEXTS_TOTAL - 2);
361+
pub fn sample<R: Rng + ?Sized>(rng: &mut R, datagram_byte_limit: usize) -> Self {
362+
// Four contexts held back so the two later kinds can each keep their two.
363+
let metric_contexts = sample_cap(rng, MAX_CONTEXTS_TOTAL - 4);
362364
let free = MAX_CONTEXTS_TOTAL - metric_contexts as u64;
363-
let event_contexts = sample_cap(rng, free - 1);
365+
let event_contexts = sample_cap(rng, free - 2);
364366
let free = free - event_contexts as u64;
365367
Self {
366-
payload_byte_limit,
368+
datagram_byte_limit,
367369
metric_contexts,
368370
event_contexts,
369371
service_check_contexts: sample_cap(rng, free),
@@ -392,10 +394,10 @@ impl ContextSourceConfig {
392394
}
393395
}
394396

395-
/// A single per-kind cap in `1..=ceiling`. The ceiling is well within `usize` on every supported
397+
/// A single per-kind cap in `2..=ceiling`. The ceiling is well within `usize` on every supported
396398
/// target, so the saturating conversion is unreachable in practice.
397399
fn sample_cap<R: Rng + ?Sized>(rng: &mut R, ceiling: u64) -> usize {
398-
usize::try_from(Probe::new(1, ceiling.max(1)).sample(rng)).unwrap_or(usize::MAX)
400+
usize::try_from(Probe::new(2, ceiling.max(2)).sample(rng)).unwrap_or(usize::MAX)
399401
}
400402

401403
#[cfg(test)]
@@ -452,12 +454,12 @@ mod tests {
452454

453455
#[test]
454456
fn driver_config_caps_payload_to_the_smaller_bound() {
455-
assert_eq!(DriverConfig::sample(&mut SeqRng(0), 512).payload_byte_limit, 512);
457+
assert_eq!(DriverConfig::sample(&mut SeqRng(0), 512).datagram_byte_limit, 512);
456458
assert_eq!(
457-
DriverConfig::sample(&mut SeqRng(0), 1 << 30).payload_byte_limit,
458-
PAYLOAD_BYTE_LIMIT
459+
DriverConfig::sample(&mut SeqRng(0), 1 << 30).datagram_byte_limit,
460+
DATAGRAM_BYTE_LIMIT
459461
);
460-
assert_eq!(DriverConfig::sample(&mut SeqRng(0), 0).payload_byte_limit, 0);
462+
assert_eq!(DriverConfig::sample(&mut SeqRng(0), 0).datagram_byte_limit, 0);
461463
}
462464

463465
#[test]

test/antithesis/harness/src/contexts.rs

Lines changed: 107 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,18 @@
1414
use rand::{Rng, RngExt};
1515

1616
use crate::dogstatsd::is_malformed;
17+
use crate::payload::dogstatsd::common;
1718

1819
pub mod event;
1920
pub mod metric;
2021
pub mod service_check;
2122

22-
/// How many times to re-mint an identity whose probe render the Agent would drop before falling back
23-
/// to a guaranteed-sound identity. Mint is mostly-valid, so the fallback is rare.
23+
/// How many times to re-mint an identity whose probe render the Agent would drop before yielding
24+
/// nothing. Mint is mostly-valid, so an exhausted loop is rare.
2425
const REMINT_TRIES: usize = 16;
2526

2627
/// How many times to re-render a context whose per-occurrence payload the Agent would drop before
27-
/// falling back to a guaranteed-well-formed line.
28+
/// yielding nothing. 2.3% of single renders need a retry and none has yet exhausted the loop.
2829
const RENDER_TRIES: usize = 8;
2930

3031
/// Digits allowed for a rendered length field, generous so a floor is never an underestimate.
@@ -105,6 +106,82 @@ impl Context {
105106
}
106107
}
107108

109+
/// Replace one byte of this identity with an invalid UTF-8 byte, so the identity itself is the
110+
/// corrupt one rather than a datagram being edited after the fact.
111+
///
112+
/// The Agent does no charset validation, so the line still forwards and the criterion stays "does
113+
/// the Agent discard it". Which identity field takes the byte is what the intake distinguishes: a
114+
/// v3 name dictionary rejects the whole payload, a tag dictionary coerces. A delimiter is never
115+
/// overwritten, since removing one reshapes the line. Returns whether a byte was replaced.
116+
fn poison(&mut self, rng: &mut (impl Rng + ?Sized)) -> bool {
117+
let fields: Vec<&mut Vec<u8>> = match self {
118+
Context::Metric(c) => std::iter::once(&mut c.name).chain(c.tags.iter_mut()).collect(),
119+
Context::Event(c) => std::iter::once(&mut c.title).chain(c.tags.iter_mut()).collect(),
120+
Context::ServiceCheck(c) => std::iter::once(&mut c.name).chain(c.tags.iter_mut()).collect(),
121+
};
122+
let targets: Vec<(usize, usize)> = fields
123+
.iter()
124+
.enumerate()
125+
.flat_map(|(f, bytes)| {
126+
bytes
127+
.iter()
128+
.enumerate()
129+
.filter(|(_, &b)| !matches!(b, b':' | b'|' | b',' | b'#' | b'@'))
130+
.map(move |(i, _)| (f, i))
131+
})
132+
.collect();
133+
if targets.is_empty() {
134+
return false;
135+
}
136+
let (field, at) = targets[rng.random_range(0..targets.len())];
137+
let mut fields = fields;
138+
fields[field][at] = common::invalid_utf8_byte(rng);
139+
true
140+
}
141+
142+
/// Mint an identity of `kind` that carries an invalid UTF-8 byte, or `None` when none can be built
143+
/// within `budget`. Corrupt identities live in the pool like any other, so they recur across
144+
/// datagrams and count against the kind's cap instead of appearing as fresh one-offs.
145+
#[must_use]
146+
pub fn mint_non_utf8_within(kind: Kind, rng: &mut (impl Rng + ?Sized), budget: usize) -> Option<Context> {
147+
for _ in 0..REMINT_TRIES {
148+
let mut context = Context::mint_within(kind, rng, budget)?;
149+
if !context.poison(rng) {
150+
continue;
151+
}
152+
// A replaced byte does not always invalidate the field. `0x80` is a valid continuation byte,
153+
// so poisoning the trailing byte of `café` turns `C3 A9` into the valid `C3 80`. Verify the
154+
// field instead of trimming `0x80` from the pool, which would drop it from the byte space the
155+
// SUT ever sees. Unverified, 3.4% of corrupt mints carried no invalid byte at all.
156+
if !context.has_non_utf8() {
157+
continue;
158+
}
159+
let mut probe = Vec::new();
160+
context.render(rng, &mut probe);
161+
if is_malformed(&probe).is_ok() {
162+
return Some(context);
163+
}
164+
}
165+
None
166+
}
167+
168+
/// Whether this identity carries an invalid UTF-8 byte.
169+
#[must_use]
170+
pub fn has_non_utf8(&self) -> bool {
171+
let fields: Vec<&[u8]> = match self {
172+
Context::Metric(c) => std::iter::once(c.name.as_slice())
173+
.chain(c.tags.iter().map(Vec::as_slice))
174+
.collect(),
175+
Context::Event(c) => std::iter::once(c.title.as_slice())
176+
.chain(c.tags.iter().map(Vec::as_slice))
177+
.collect(),
178+
Context::ServiceCheck(c) => std::iter::once(c.name.as_slice())
179+
.chain(c.tags.iter().map(Vec::as_slice))
180+
.collect(),
181+
};
182+
fields.iter().any(|f| simdutf8::basic::from_utf8(f).is_err())
183+
}
184+
108185
/// Bytes every render of this identity must spend, whatever the per-occurrence payload.
109186
#[must_use]
110187
pub fn floor(&self) -> usize {
@@ -131,9 +208,17 @@ impl Context {
131208
pub fn render_wellformed_within(
132209
&self, rng: &mut (impl Rng + ?Sized), out: &mut Vec<u8>, budget: usize,
133210
) -> Option<usize> {
134-
for _ in 0..RENDER_TRIES {
211+
for try_index in 0..RENDER_TRIES {
135212
let start = out.len();
136-
let packed = self.render_within(rng, out, budget)?;
213+
// The last try renders at the identity's floor, where the occurrence is the shortest the
214+
// identity admits and no extension chunk has room. Sampling a wide occurrence one more time
215+
// would be hoping again, and a caller has nowhere to go when the tries run out.
216+
let attempt = if try_index + 1 == RENDER_TRIES {
217+
self.floor().min(budget)
218+
} else {
219+
budget
220+
};
221+
let packed = self.render_within(rng, out, attempt)?;
137222
if is_malformed(&out[start..]).is_ok() {
138223
return Some(packed);
139224
}
@@ -286,6 +371,19 @@ mod tests {
286371
}
287372

288373
proptest! {
374+
/// A corrupt mint always carries an invalid byte. The replacement byte does not guarantee it on
375+
/// its own, and an identity that looks corrupt but is not lands in the clean half of the working
376+
/// set and quietly thins the non-UTF-8 rate.
377+
#[test]
378+
fn property_test_a_corrupt_mint_is_corrupt(seed: u64) {
379+
let mut rng = SmallRng::seed_from_u64(seed);
380+
for _ in 0..16 {
381+
if let Some(context) = Context::mint_non_utf8_within(Kind::sample(&mut rng), &mut rng, 8_191) {
382+
prop_assert!(context.has_non_utf8(), "a corrupt mint carried no invalid byte: {context:?}");
383+
}
384+
}
385+
}
386+
289387
/// A minted context conforms to is_malformed. Content carries delimiters, so a raw render may
290388
/// land on the drop side. The repair loop is the sorter, and any line it does yield forwards.
291389
#[test]
@@ -294,7 +392,10 @@ mod tests {
294392
let Some(context) = Context::mint_within(kind, &mut rng, 8_192) else { return Ok(()) };
295393
for _ in 0..8 {
296394
let mut line = Vec::new();
297-
if context.render_wellformed_within(&mut rng, &mut line, 8_192).is_some() {
395+
if context
396+
.render_wellformed_within(&mut rng, &mut line, 8_192)
397+
.is_some()
398+
{
298399
prop_assert_eq!(is_malformed(&line), Ok(()), "a rendered line was droppable");
299400
}
300401
}

test/antithesis/harness/src/contexts/service_check.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,8 @@ impl ServiceCheckContext {
9393
out.extend_from_slice(itoa.format(fresh_timestamp(rng)).as_bytes());
9494
common::serialize_tags(&self.tags, out);
9595
out.extend_from_slice(b"|m:");
96-
out.extend_from_slice(&common::optional_text_within(rng, message_room));
96+
let message = common::optional_text_within(rng, message_room);
97+
out.extend_from_slice(&message);
9798
Some(0)
9899
}
99100

0 commit comments

Comments
 (0)