harness/
contexts.rs

1//! The context protocol: reusable `DogStatsD` identities a driver renders load against.
2//!
3//! A [`Context`] is a per-type stable identity — a metric's `(kind, name, tags)`, an event's title +
4//! tags + option fields, a service check's name + tags + host. It is minted over the content alphabet in
5//! `payload/dogstatsd/common.rs` and accepted only when a probe render is `!is_malformed`, see
6//! [`crate::dogstatsd`], conforming to the Agent parser and nothing stricter. The driver varies the
7//! per-occurrence payload each render, the value, text, status, extensions and timestamp, so a pooled
8//! identity recurs while its load varies.
9//!
10//! [`Context::mint_non_utf8_within`] mints an identity carrying an invalid UTF-8 byte in its name or a
11//! tag. That is the only source of such a byte in generated load, and it lives in the identity so the
12//! pool counts it against a cap. Poisoning a rendered datagram instead would invent an identity the pool
13//! never issued, one per datagram, which is how bounded cardinality leaks.
14//!
15//! A shared intake pool mints identities up to a per-kind cap then recurs them, and serves them to
16//! drivers over the length-prefixed binary codec here ([`encode_response`] / [`decode_response`]),
17//! which carries non-UTF-8 names and tags that JSON could not.
18
19use rand::{Rng, RngExt};
20
21use crate::dogstatsd::is_malformed;
22use crate::payload::dogstatsd::common;
23
24pub mod event;
25pub mod metric;
26pub mod service_check;
27
28/// How many times to re-mint an identity whose probe render the Agent would drop before yielding
29/// nothing. Mint is mostly-valid, so an exhausted loop is rare.
30const REMINT_TRIES: usize = 16;
31
32/// How many times to re-render a context whose per-occurrence payload the Agent would drop before
33/// yielding nothing. 2.3% of single renders need a retry and none has yet exhausted the loop.
34const RENDER_TRIES: usize = 8;
35
36/// Digits allowed for a rendered length field, generous so a floor is never an underestimate.
37pub(crate) const LEN_DIGITS: usize = 5;
38
39/// Digits allowed for a rendered timestamp, likewise generous.
40pub(crate) const TS_DIGITS: usize = 20;
41
42/// The three context kinds.
43#[derive(Clone, Copy, Debug, PartialEq, Eq)]
44pub enum Kind {
45    /// A metric context.
46    Metric,
47    /// An event context.
48    Event,
49    /// A service-check context.
50    ServiceCheck,
51}
52
53impl Kind {
54    /// Sample a kind by the message-type weight — 98% metric, 1% event, 1% service check.
55    #[must_use]
56    pub fn sample(rng: &mut (impl Rng + ?Sized)) -> Kind {
57        match rng.random_range(0..100u32) {
58            0 => Kind::Event,
59            1 => Kind::ServiceCheck,
60            _ => Kind::Metric,
61        }
62    }
63}
64
65/// A reusable `DogStatsD` identity of one of the three kinds.
66#[derive(Clone, Debug, PartialEq, Eq, Hash)]
67pub enum Context {
68    /// A metric identity.
69    Metric(metric::MetricContext),
70    /// An event identity.
71    Event(event::EventContext),
72    /// A service-check identity.
73    ServiceCheck(service_check::ServiceCheckContext),
74}
75
76impl Context {
77    /// Mint a context of `kind` whose renders fit `budget` and that the Agent forwards, or `None` when
78    /// no such identity is available.
79    ///
80    /// The identity is built against the budget, so it fits by construction and nothing is minted then
81    /// measured. The remaining re-mint loop is about content alone: the alphabet carries protocol
82    /// delimiters and some combinations land on the drop side, which a probe render is what detects.
83    /// An exhausted loop yields `None` rather than an identity of another kind, so the caller never
84    /// stores a metric in the event or service-check working set.
85    #[must_use]
86    pub fn mint_within(kind: Kind, rng: &mut (impl Rng + ?Sized), budget: usize) -> Option<Context> {
87        for _ in 0..REMINT_TRIES {
88            let context = match kind {
89                Kind::Metric => Context::Metric(metric::MetricContext::mint_within(rng, budget)?),
90                Kind::Event => Context::Event(event::EventContext::mint_within(rng, budget)?),
91                Kind::ServiceCheck => {
92                    Context::ServiceCheck(service_check::ServiceCheckContext::mint_within(rng, budget)?)
93                }
94            };
95            let mut probe = Vec::new();
96            context.render(rng, &mut probe);
97            if is_malformed(&probe).is_ok() {
98                return Some(context);
99            }
100        }
101        None
102    }
103
104    /// Render one datagram line (no trailing `\n`) for this identity with a fresh per-occurrence
105    /// payload. Returns the packed multi-value run length, or zero.
106    pub fn render(&self, rng: &mut (impl Rng + ?Sized), out: &mut Vec<u8>) -> usize {
107        match self {
108            Context::Metric(c) => c.render(rng, out),
109            Context::Event(c) => c.render(rng, out),
110            Context::ServiceCheck(c) => c.render(rng, out),
111        }
112    }
113
114    /// Replace one byte of this identity with an invalid UTF-8 byte, so the identity itself is the
115    /// corrupt one rather than a datagram being edited after the fact.
116    ///
117    /// The Agent does no charset validation, so the line still forwards and the criterion stays "does
118    /// the Agent discard it". Which identity field takes the byte is what the intake distinguishes: a
119    /// v3 name dictionary rejects the whole payload, a tag dictionary coerces. A delimiter is never
120    /// overwritten, since removing one reshapes the line. Returns whether a byte was replaced.
121    fn poison(&mut self, rng: &mut (impl Rng + ?Sized)) -> bool {
122        let fields: Vec<&mut Vec<u8>> = match self {
123            Context::Metric(c) => std::iter::once(&mut c.name).chain(c.tags.iter_mut()).collect(),
124            Context::Event(c) => std::iter::once(&mut c.title).chain(c.tags.iter_mut()).collect(),
125            Context::ServiceCheck(c) => std::iter::once(&mut c.name).chain(c.tags.iter_mut()).collect(),
126        };
127        let targets: Vec<(usize, usize)> = fields
128            .iter()
129            .enumerate()
130            .flat_map(|(f, bytes)| {
131                bytes
132                    .iter()
133                    .enumerate()
134                    .filter(|(_, &b)| !matches!(b, b':' | b'|' | b',' | b'#' | b'@'))
135                    .map(move |(i, _)| (f, i))
136            })
137            .collect();
138        if targets.is_empty() {
139            return false;
140        }
141        let (field, at) = targets[rng.random_range(0..targets.len())];
142        let mut fields = fields;
143        fields[field][at] = common::invalid_utf8_byte(rng);
144        true
145    }
146
147    /// Mint an identity of `kind` that carries an invalid UTF-8 byte, or `None` when none can be built
148    /// within `budget`. Corrupt identities live in the pool like any other, so they recur across
149    /// datagrams and count against the kind's cap instead of appearing as fresh one-offs.
150    #[must_use]
151    pub fn mint_non_utf8_within(kind: Kind, rng: &mut (impl Rng + ?Sized), budget: usize) -> Option<Context> {
152        for _ in 0..REMINT_TRIES {
153            let mut context = Context::mint_within(kind, rng, budget)?;
154            if !context.poison(rng) {
155                continue;
156            }
157            // A replaced byte does not always invalidate the field. `0x80` is a valid continuation byte,
158            // so poisoning the trailing byte of `café` turns `C3 A9` into the valid `C3 80`. Verify the
159            // field instead of trimming `0x80` from the pool, which would drop it from the byte space the
160            // SUT ever sees. Unverified, 3.4% of corrupt mints carried no invalid byte at all.
161            if !context.has_non_utf8() {
162                continue;
163            }
164            let mut probe = Vec::new();
165            context.render(rng, &mut probe);
166            if is_malformed(&probe).is_ok() {
167                return Some(context);
168            }
169        }
170        None
171    }
172
173    /// Whether this identity carries an invalid UTF-8 byte.
174    #[must_use]
175    pub fn has_non_utf8(&self) -> bool {
176        let fields: Vec<&[u8]> = match self {
177            Context::Metric(c) => std::iter::once(c.name.as_slice())
178                .chain(c.tags.iter().map(Vec::as_slice))
179                .collect(),
180            Context::Event(c) => std::iter::once(c.title.as_slice())
181                .chain(c.tags.iter().map(Vec::as_slice))
182                .collect(),
183            Context::ServiceCheck(c) => std::iter::once(c.name.as_slice())
184                .chain(c.tags.iter().map(Vec::as_slice))
185                .collect(),
186        };
187        fields.iter().any(|f| simdutf8::basic::from_utf8(f).is_err())
188    }
189
190    /// Bytes every render of this identity must spend, whatever the per-occurrence payload.
191    #[must_use]
192    pub fn floor(&self) -> usize {
193        match self {
194            Context::Metric(c) => c.floor(),
195            Context::Event(c) => c.floor(),
196            Context::ServiceCheck(c) => c.floor(),
197        }
198    }
199
200    /// Render one line within `budget`, or `None` when the budget cannot hold this identity.
201    fn render_within(&self, rng: &mut (impl Rng + ?Sized), out: &mut Vec<u8>, budget: usize) -> Option<usize> {
202        match self {
203            Context::Metric(c) => c.render_within(rng, out, budget),
204            Context::Event(c) => c.render_within(rng, out, budget),
205            Context::ServiceCheck(c) => c.render_within(rng, out, budget),
206        }
207    }
208
209    /// Render one datagram line the Agent forwards within `budget`, or `None` when this context has no
210    /// forwardable rendering that fits. The budget is a construction input to each attempt rather than
211    /// a filter on the result, and an exhausted attempt count yields nothing rather than a line
212    /// carrying an identity the pool never issued.
213    pub fn render_wellformed_within(
214        &self, rng: &mut (impl Rng + ?Sized), out: &mut Vec<u8>, budget: usize,
215    ) -> Option<usize> {
216        for try_index in 0..RENDER_TRIES {
217            let start = out.len();
218            // The last try renders at the identity's floor, where the occurrence is the shortest the
219            // identity admits and no extension chunk has room. Sampling a wide occurrence one more time
220            // would be hoping again, and a caller has nowhere to go when the tries run out.
221            let attempt = if try_index + 1 == RENDER_TRIES {
222                self.floor().min(budget)
223            } else {
224                budget
225            };
226            let packed = self.render_within(rng, out, attempt)?;
227            if is_malformed(&out[start..]).is_ok() {
228                return Some(packed);
229            }
230            out.truncate(start);
231        }
232        None
233    }
234
235    /// Append this context's tagged, length-prefixed encoding.
236    pub fn encode(&self, out: &mut Vec<u8>) {
237        match self {
238            Context::Metric(c) => {
239                put_u8(out, 0);
240                c.encode(out);
241            }
242            Context::Event(c) => {
243                put_u8(out, 1);
244                c.encode(out);
245            }
246            Context::ServiceCheck(c) => {
247                put_u8(out, 2);
248                c.encode(out);
249            }
250        }
251    }
252
253    /// Decode one context, advancing `*pos`. Returns `None` on truncation or an unknown tag.
254    fn decode(buf: &[u8], pos: &mut usize) -> Option<Context> {
255        Some(match get_u8(buf, pos)? {
256            0 => Context::Metric(metric::MetricContext::decode(buf, pos)?),
257            1 => Context::Event(event::EventContext::decode(buf, pos)?),
258            2 => Context::ServiceCheck(service_check::ServiceCheckContext::decode(buf, pos)?),
259            _ => return None,
260        })
261    }
262}
263
264/// Encode a `GET /contexts` response body: a `u32` count then each context.
265#[must_use]
266pub fn encode_response(contexts: &[Context]) -> Vec<u8> {
267    let mut out = Vec::new();
268    // A response holds the N contexts a driver asked for, far below u32::MAX.
269    let count = u32::try_from(contexts.len()).unwrap_or(u32::MAX);
270    out.extend_from_slice(&count.to_le_bytes());
271    for context in contexts {
272        context.encode(&mut out);
273    }
274    out
275}
276
277/// Decode a `GET /contexts` response body. Returns `None` on any truncation or malformed field, so a
278/// partial or corrupt body is an error, not a panic. Never pre-sizes from the wire count.
279#[must_use]
280pub fn decode_response(buf: &[u8]) -> Option<Vec<Context>> {
281    let mut pos = 0;
282    let count = get_u32(buf, &mut pos)?;
283    let mut contexts = Vec::new();
284    for _ in 0..count {
285        contexts.push(Context::decode(buf, &mut pos)?);
286    }
287    Some(contexts)
288}
289
290/// A fresh per-occurrence Unix timestamp for a `d:` field. Any positive integer forwards.
291pub(crate) fn fresh_timestamp(rng: &mut (impl Rng + ?Sized)) -> u64 {
292    rng.random_range(1..=2_000_000_000u64)
293}
294
295// --- shared length-prefixed binary codec ---
296
297/// Append one byte.
298pub(crate) fn put_u8(out: &mut Vec<u8>, b: u8) {
299    out.push(b);
300}
301
302/// Append `len` as a little-endian `u16`, saturating an over-long field. Minted fields are bounded
303/// well under `u16::MAX`, so saturation never fires in practice.
304fn put_u16(out: &mut Vec<u8>, len: usize) {
305    let len = u16::try_from(len).unwrap_or(u16::MAX);
306    out.extend_from_slice(&len.to_le_bytes());
307}
308
309/// Append a `u16` length prefix then the bytes.
310pub(crate) fn put_bytes(out: &mut Vec<u8>, bytes: &[u8]) {
311    put_u16(out, bytes.len());
312    out.extend_from_slice(bytes);
313}
314
315/// Append a `u16` count then each byte run.
316pub(crate) fn put_tags(out: &mut Vec<u8>, tags: &[Vec<u8>]) {
317    put_u16(out, tags.len());
318    for tag in tags {
319        put_bytes(out, tag);
320    }
321}
322
323/// Read one byte, advancing `*pos`.
324pub(crate) fn get_u8(buf: &[u8], pos: &mut usize) -> Option<u8> {
325    let byte = *buf.get(*pos)?;
326    *pos += 1;
327    Some(byte)
328}
329
330/// Read a little-endian `u16` as a `usize`, advancing `*pos`.
331fn get_u16(buf: &[u8], pos: &mut usize) -> Option<usize> {
332    let end = pos.checked_add(2)?;
333    let slice = buf.get(*pos..end)?;
334    *pos = end;
335    Some(u16::from_le_bytes([slice[0], slice[1]]) as usize)
336}
337
338/// Read a little-endian `u32` as a `usize`, advancing `*pos`.
339fn get_u32(buf: &[u8], pos: &mut usize) -> Option<usize> {
340    let end = pos.checked_add(4)?;
341    let slice = buf.get(*pos..end)?;
342    *pos = end;
343    Some(u32::from_le_bytes([slice[0], slice[1], slice[2], slice[3]]) as usize)
344}
345
346/// Read a `u16`-prefixed byte run, advancing `*pos`.
347pub(crate) fn get_bytes<'a>(buf: &'a [u8], pos: &mut usize) -> Option<&'a [u8]> {
348    let len = get_u16(buf, pos)?;
349    let end = pos.checked_add(len)?;
350    let slice = buf.get(*pos..end)?;
351    *pos = end;
352    Some(slice)
353}
354
355/// Read a `u16`-counted list of byte runs, advancing `*pos`. Does not pre-size from the wire count.
356pub(crate) fn get_tags(buf: &[u8], pos: &mut usize) -> Option<Vec<Vec<u8>>> {
357    let count = get_u16(buf, pos)?;
358    let mut tags = Vec::new();
359    for _ in 0..count {
360        tags.push(get_bytes(buf, pos)?.to_vec());
361    }
362    Some(tags)
363}
364
365#[cfg(test)]
366mod tests {
367    use proptest::prelude::*;
368    use rand::rngs::SmallRng;
369    use rand::SeedableRng;
370
371    use super::{decode_response, encode_response, Context, Kind};
372    use crate::dogstatsd::is_malformed;
373
374    fn any_kind() -> impl Strategy<Value = Kind> {
375        prop_oneof![Just(Kind::Metric), Just(Kind::Event), Just(Kind::ServiceCheck)]
376    }
377
378    proptest! {
379        /// A corrupt mint always carries an invalid byte. The replacement byte does not guarantee it on
380        /// its own, and an identity that looks corrupt but is not lands in the clean half of the working
381        /// set and quietly thins the non-UTF-8 rate.
382        #[test]
383        fn property_test_a_corrupt_mint_is_corrupt(seed: u64) {
384            let mut rng = SmallRng::seed_from_u64(seed);
385            for _ in 0..16 {
386                if let Some(context) = Context::mint_non_utf8_within(Kind::sample(&mut rng), &mut rng, 8_191) {
387                    prop_assert!(context.has_non_utf8(), "a corrupt mint carried no invalid byte: {context:?}");
388                }
389            }
390        }
391
392        /// A minted context conforms to is_malformed. Content carries delimiters, so a raw render may
393        /// land on the drop side. The repair loop is the sorter, and any line it does yield forwards.
394        #[test]
395        fn property_test_render_wellformed_always_forwards(seed: u64, kind in any_kind()) {
396            let mut rng = SmallRng::seed_from_u64(seed);
397            let Some(context) = Context::mint_within(kind, &mut rng, 8_192) else { return Ok(()) };
398            for _ in 0..8 {
399                let mut line = Vec::new();
400                if context
401                    .render_wellformed_within(&mut rng, &mut line, 8_192)
402                    .is_some()
403                {
404                    prop_assert_eq!(is_malformed(&line), Ok(()), "a rendered line was droppable");
405                }
406            }
407        }
408
409        /// A response of minted contexts round-trips through the binary codec, non-UTF-8 and all.
410        #[test]
411        fn property_test_response_round_trips(seed: u64) {
412            let mut rng = SmallRng::seed_from_u64(seed);
413            let contexts: Vec<Context> = (0..8)
414                .filter_map(|_| Context::mint_within(Kind::sample(&mut rng), &mut rng, 8_192))
415                .collect();
416            let wire = encode_response(&contexts);
417            let decoded = decode_response(&wire);
418            prop_assert_eq!(decoded.as_deref(), Some(contexts.as_slice()));
419        }
420
421        /// Decode never panics and rejects every truncated prefix of a valid body.
422        #[test]
423        fn property_test_decode_rejects_truncation(seed: u64) {
424            let mut rng = SmallRng::seed_from_u64(seed);
425            let contexts: Vec<Context> = (0..4)
426                .filter_map(|_| Context::mint_within(Kind::sample(&mut rng), &mut rng, 8_192))
427                .collect();
428            let wire = encode_response(&contexts);
429            for cut in 0..wire.len() {
430                let _ = decode_response(&wire[..cut]);
431            }
432            prop_assert_eq!(decode_response(&wire).map(|c| c.len()), Some(contexts.len()));
433        }
434    }
435}