harness/
dogstatsd.rs

1//! Datadog-Agent-normative `DogStatsD` payload classification.
2//!
3//! [`is_malformed`] is a predicate on the SERIALIZED datagram bytes a driver emits. It re-parses the
4//! datagram the way the Datadog Agent's `DogStatsD` parser at `comp/dogstatsd/server/impl` does. It
5//! splits on `\n` and routes each message by prefix: `_e{` to event, `_sc` to service check, else
6//! metric. Then it applies that message type's drop rules, and returns `Ok(())` when the Agent
7//! forwards every message, or the first [`PayloadError`] it drops on.
8//!
9//! The Agent is the differential's reference lane, so a message it drops produces no context even on
10//! the normative side. Malformed is the Agent's behavior, NOT ADP's and NOT the backend intake's. The
11//! Agent does no UTF-8 or charset validation. It forwards non-UTF-8 and exotic names, tags, titles,
12//! and text verbatim, so `PayloadError` covers only the hard structural and numeric-parse failures,
13//! never content bytes.
14//!
15//! The `PayloadError` variants are the contract the load generators are written against: the clean
16//! generator emits only payloads for which this returns `Ok(())`, and a later malformed generator
17//! induces exactly one variant and asserts the Agent drops for it.
18
19/// The first drop rule a serialized `DogStatsD` payload violates, tagged with the 0-based index of
20/// the offending message among the payload's `\n`-split segments. Blank segments are counted in the
21/// index but are never malformed.
22///
23/// One variant per Agent drop rule, across all three message types. The metric `T` timestamp rule is
24/// intentionally absent. See the note in `classify_metric`.
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26pub enum PayloadError {
27    /// Metric: the line carries no `|`.
28    MetricNoPipe {
29        /// Message index.
30        line: usize,
31    },
32    /// Metric: field-0 carries no `:` splitting name from value.
33    MetricNameValueNoColon {
34        /// Message index.
35        line: usize,
36    },
37    /// Metric: the name is empty.
38    MetricEmptyName {
39        /// Message index.
40        line: usize,
41    },
42    /// Metric: the value is empty.
43    MetricEmptyValue {
44        /// Message index.
45        line: usize,
46    },
47    /// Metric: the type is not byte-exactly one of `g` `c` `h` `d` `s` `ms`.
48    MetricBadType {
49        /// Message index.
50        line: usize,
51    },
52    /// Metric: a non-set value segment fails the Go float parse, or none survives.
53    MetricUnparseableValue {
54        /// Message index.
55        line: usize,
56    },
57    /// Metric: an `@` sample-rate chunk fails the Go float parse.
58    MetricBadRate {
59        /// Message index.
60        line: usize,
61    },
62    /// Event: the line carries no `:` splitting header from body.
63    EventNoColon {
64        /// Message index.
65        line: usize,
66    },
67    /// Event: the header is shorter than the minimal `_e{1,0}`.
68    EventHeaderTooShort {
69        /// Message index.
70        line: usize,
71    },
72    /// Event: the header carries no `,` between the two lengths.
73    EventNoLengthComma {
74        /// Message index.
75        line: usize,
76    },
77    /// Event: the title length is not a non-negative integer.
78    EventBadTitleLen {
79        /// Message index.
80        line: usize,
81    },
82    /// Event: the title length is zero.
83    EventEmptyTitle {
84        /// Message index.
85        line: usize,
86    },
87    /// Event: the text length is not a non-negative integer.
88    EventBadTextLen {
89        /// Message index.
90        line: usize,
91    },
92    /// Event: `title_len + 1 + text_len` overflows.
93    EventLengthOverflow {
94        /// Message index.
95        line: usize,
96    },
97    /// Event: the body is shorter than the declared `title_len + 1 + text_len`.
98    EventBodyTooShort {
99        /// Message index.
100        line: usize,
101    },
102    /// Service check: fewer than two `|`.
103    ServiceCheckTooFewPipes {
104        /// Message index.
105        line: usize,
106    },
107    /// Service check: shorter than the four-byte `_sc|` header.
108    ServiceCheckTooShort {
109        /// Message index.
110        line: usize,
111    },
112    /// Service check: the name is empty.
113    ServiceCheckEmptyName {
114        /// Message index.
115        line: usize,
116    },
117    /// Service check: the status is not byte-exactly one of `0` `1` `2` `3`.
118    ServiceCheckBadStatus {
119        /// Message index.
120        line: usize,
121    },
122}
123
124/// Whether the Datadog Agent would forward the whole serialized `DogStatsD` payload.
125///
126/// Frames the payload like the Agent's `server.go` `scanLines`, `nextMessage`, and `dropCR`. It
127/// splits on `\n`, drops a single trailing `\r` from each segment so `\r\n` and `\n` both work, and
128/// skips empty segments, since a blank line is never malformed. The returned index counts every
129/// segment, blanks included. An empty or all-blank payload is `Ok(())`.
130///
131/// The eol-unterminated final-line drop is out of scope: the default `dogstatsd_eol_required=[]`
132/// leaves eol termination off and the generator always `\n`-terminates.
133///
134/// # Errors
135///
136/// Returns the first [`PayloadError`] the Agent would drop on, or `Ok(())` when every message
137/// forwards.
138pub fn is_malformed(payload: &[u8]) -> Result<(), PayloadError> {
139    for (line, segment) in payload.split(|&b| b == b'\n').enumerate() {
140        let segment = segment.strip_suffix(b"\r").unwrap_or(segment);
141        if segment.is_empty() {
142            continue;
143        }
144        if segment.starts_with(b"_e{") {
145            classify_event(line, segment)?;
146        } else if segment.starts_with(b"_sc") {
147            classify_service_check(line, segment)?;
148        } else {
149            classify_metric(line, segment)?;
150        }
151    }
152    Ok(())
153}
154
155/// Apply the Agent `parseMetricSample` M1-M6 drop rules to a single metric line.
156fn classify_metric(line: usize, seg: &[u8]) -> Result<(), PayloadError> {
157    let mut parts = seg.split(|&b| b == b'|');
158    let field0 = parts.next().unwrap_or(&[]);
159    // M1: a metric needs at least one `|`, so a second split part must exist.
160    let Some(type_field) = parts.next() else {
161        return Err(PayloadError::MetricNoPipe { line });
162    };
163    // M2: field-0 must carry a `:` splitting name from value.
164    let Some(colon) = field0.iter().position(|&b| b == b':') else {
165        return Err(PayloadError::MetricNameValueNoColon { line });
166    };
167    let name = &field0[..colon];
168    let value = &field0[colon + 1..];
169    // M3: name and value are both non-empty.
170    if name.is_empty() {
171        return Err(PayloadError::MetricEmptyName { line });
172    }
173    if value.is_empty() {
174        return Err(PayloadError::MetricEmptyValue { line });
175    }
176    // M4 and M5: the type must match exactly, and a non-set value must parse.
177    match type_field {
178        b"s" => {}
179        b"g" | b"c" | b"h" | b"d" | b"ms" => {
180            if value_is_malformed(value) {
181                return Err(PayloadError::MetricUnparseableValue { line });
182            }
183        }
184        _ => return Err(PayloadError::MetricBadType { line }),
185    }
186    // M6: a sample-rate `@` chunk must parse as a Go float. Everything else is skipped.
187    //
188    // The `T` timestamp chunk is deliberately NOT modeled: it drops only when readTimestamps, gated
189    // by `dogstatsd_no_aggregation_pipeline`, is on and the value is not an integer >= 1, a
190    // config-gated surface this generator does not emit. The origin chunks c:/e:/card: never drop.
191    for chunk in parts {
192        if let Some((&first, rest)) = chunk.split_first() {
193            if first == b'@' && !go_parse_float_ok(rest) {
194                return Err(PayloadError::MetricBadRate { line });
195            }
196        }
197    }
198    Ok(())
199}
200
201/// Apply the Agent `parseEvent` header drop rules to a single `_e{...}` line. The body and every
202/// optional field forward verbatim, so only the header can drop.
203fn classify_event(line: usize, seg: &[u8]) -> Result<(), PayloadError> {
204    let Some(colon) = seg.iter().position(|&b| b == b':') else {
205        return Err(PayloadError::EventNoColon { line });
206    };
207    let header = &seg[..colon];
208    let body = &seg[colon + 1..];
209    // The minimal header is `_e{1,0}`, seven bytes. `_e{` and the closing byte are stripped by
210    // position. The closing `}` is assumed, never validated.
211    if header.len() < 7 {
212        return Err(PayloadError::EventHeaderTooShort { line });
213    }
214    let raw_lengths = &header[3..header.len() - 1];
215    let Some(comma) = raw_lengths.iter().position(|&b| b == b',') else {
216        return Err(PayloadError::EventNoLengthComma { line });
217    };
218    let raw_title_len = &raw_lengths[..comma];
219    let raw_text_len = &raw_lengths[comma + 1..];
220    let Some(title_len) = atoi(raw_title_len).filter(|&len| len >= 0) else {
221        return Err(PayloadError::EventBadTitleLen { line });
222    };
223    if title_len == 0 {
224        return Err(PayloadError::EventEmptyTitle { line });
225    }
226    let Some(text_len) = atoi(raw_text_len).filter(|&len| len >= 0) else {
227        return Err(PayloadError::EventBadTextLen { line });
228    };
229    // Both lengths are non-negative here, so the magnitude is the value. Go widens them to uint for
230    // the framing math below.
231    let (title_len, text_len) = (title_len.unsigned_abs(), text_len.unsigned_abs());
232    // Go frames the body as title_len + 1, the title/text `|`, plus text_len, computed on uint64.
233    let Some(content_len) = title_len.checked_add(1).and_then(|v| v.checked_add(text_len)) else {
234        return Err(PayloadError::EventLengthOverflow { line });
235    };
236    let Ok(body_len) = u64::try_from(body.len()) else {
237        // A body longer than u64::MAX cannot be too short.
238        return Ok(());
239    };
240    if body_len < content_len {
241        return Err(PayloadError::EventBodyTooShort { line });
242    }
243    Ok(())
244}
245
246/// Parse an event length exactly as Go's `strconv.Atoi` does: an optional leading sign, decimal
247/// digits, no underscores, overflow beyond the 64-bit `int` is a failure. Returns the signed value so
248/// the caller applies the Agent's own `< 0` test rather than treating the sign byte as the verdict.
249/// That is why `-0` parses as a valid zero-length field: Go parses it to zero, and the Agent accepts.
250fn atoi(s: &[u8]) -> Option<i64> {
251    let (neg, digits) = match s.first() {
252        Some(b'+') => (false, &s[1..]),
253        Some(b'-') => (true, &s[1..]),
254        _ => (false, s),
255    };
256    if digits.is_empty() {
257        return None;
258    }
259    let mut acc: i128 = 0;
260    for &c in digits {
261        if !c.is_ascii_digit() {
262            return None;
263        }
264        acc = acc * 10 + i128::from(c - b'0');
265        if acc > i128::from(u64::MAX) {
266            return None;
267        }
268    }
269    let value = if neg { -acc } else { acc };
270    i64::try_from(value).ok()
271}
272
273/// Apply the Agent `parseServiceCheck` drop rules to a single `_sc` line.
274fn classify_service_check(line: usize, seg: &[u8]) -> Result<(), PayloadError> {
275    // The parser needs a name terminator and a status terminator, so at least two `|`.
276    let two_pipes = seg
277        .iter()
278        .position(|&b| b == b'|')
279        .is_some_and(|i| seg[i + 1..].contains(&b'|'));
280    if !two_pipes {
281        return Err(PayloadError::ServiceCheckTooFewPipes { line });
282    }
283    // The parser strips a four-byte `_sc|` header by position.
284    if seg.len() < 4 {
285        return Err(PayloadError::ServiceCheckTooShort { line });
286    }
287    let mut fields = seg[4..].split(|&b| b == b'|');
288    let name = fields.next().unwrap_or(&[]);
289    if name.is_empty() {
290        return Err(PayloadError::ServiceCheckEmptyName { line });
291    }
292    let status = fields.next().unwrap_or(&[]);
293    if !matches!(status, b"0" | b"1" | b"2" | b"3") {
294        return Err(PayloadError::ServiceCheckBadStatus { line });
295    }
296    Ok(())
297}
298
299/// M5 for a non-set type: a value carrying `:` splits into colon segments, empty segments are
300/// discarded, and the value is malformed when any surviving segment fails the Go-float parse or when
301/// no segment survives. A value with no `:` is malformed when the whole value fails the Go-float
302/// parse.
303fn value_is_malformed(value: &[u8]) -> bool {
304    if value.contains(&b':') {
305        let mut survived = 0usize;
306        for seg in value.split(|&b| b == b':') {
307            if seg.is_empty() {
308                continue;
309            }
310            survived += 1;
311            if !go_parse_float_ok(seg) {
312                return true;
313            }
314        }
315        survived == 0
316    } else {
317        !go_parse_float_ok(value)
318    }
319}
320
321/// Whether Go's `strconv.ParseFloat(s, 64)` would accept these bytes without error.
322///
323/// This mirrors Go, not Rust. It accepts hex-floats such as `0x1p-2`, underscore digit separators
324/// such as `1_000`, and the unsigned specials `nan`, `inf`, and `infinity` with an optional sign on
325/// the infinities. It rejects `+nan`/`-nan` and any finite decimal that overflows f64. Underflow to
326/// `0.0` is not an error.
327fn go_parse_float_ok(s: &[u8]) -> bool {
328    if s.is_empty() {
329        return false;
330    }
331    if is_special_float(s) {
332        return true;
333    }
334    let Some(scan) = scan_float(s) else {
335        return false;
336    };
337    if scan.hex {
338        !hex_value_overflows(s)
339    } else {
340        decimal_is_finite(s)
341    }
342}
343
344/// Whether the bytes match one of Go's special float tokens: `inf`/`infinity` with an optional sign,
345/// or `nan` with no sign, all case-insensitive. Go rejects `+nan`/`-nan`.
346fn is_special_float(s: &[u8]) -> bool {
347    let (rest, signed) = match s.first() {
348        Some(b'+' | b'-') => (&s[1..], true),
349        _ => (s, false),
350    };
351    let eq_ci = |token: &[u8]| rest.len() == token.len() && rest.iter().zip(token).all(|(&a, &b)| (a | 0x20) == b);
352    if eq_ci(b"inf") || eq_ci(b"infinity") {
353        return true;
354    }
355    !signed && eq_ci(b"nan")
356}
357
358/// Result of a successful Go-float syntax scan.
359#[derive(Clone, Copy, Debug)]
360struct FloatScan {
361    hex: bool,
362}
363
364/// Scan the bytes as a Go decimal or hex float, returning `Some` only when the whole slice is
365/// consumed as a syntactically valid number. This mirrors Go's `readFloat` plus `underscoreOK`. It
366/// does not decide magnitude overflow.
367fn scan_float(s: &[u8]) -> Option<FloatScan> {
368    if !underscore_ok(s) {
369        return None;
370    }
371    let n = s.len();
372    let mut i = 0usize;
373
374    if i < n && (s[i] == b'+' || s[i] == b'-') {
375        i += 1;
376    }
377
378    // Go enters hex mode only when at least one byte follows the `0x` prefix.
379    let mut hex = false;
380    let mut exp_char = b'e';
381    if i + 2 < n && s[i] == b'0' && (s[i + 1] | 0x20) == b'x' {
382        hex = true;
383        exp_char = b'p';
384        i += 2;
385    }
386
387    let mut saw_digits = false;
388    let mut saw_dot = false;
389    while i < n {
390        let c = s[i];
391        if c == b'_' {
392            i += 1;
393            continue;
394        }
395        if c == b'.' {
396            if saw_dot {
397                break;
398            }
399            saw_dot = true;
400            i += 1;
401            continue;
402        }
403        if c.is_ascii_digit() {
404            saw_digits = true;
405            i += 1;
406            continue;
407        }
408        if hex && (b'a'..=b'f').contains(&(c | 0x20)) {
409            saw_digits = true;
410            i += 1;
411            continue;
412        }
413        break;
414    }
415    if !saw_digits {
416        return None;
417    }
418
419    if i < n && (s[i] | 0x20) == exp_char {
420        i += 1;
421        if i < n && (s[i] == b'+' || s[i] == b'-') {
422            i += 1;
423        }
424        if i >= n || !s[i].is_ascii_digit() {
425            return None;
426        }
427        while i < n {
428            let c = s[i];
429            if c == b'_' || c.is_ascii_digit() {
430                i += 1;
431            } else {
432                break;
433            }
434        }
435    } else if hex {
436        // A hex float requires a binary `p` exponent.
437        return None;
438    }
439
440    if i != n {
441        return None;
442    }
443    Some(FloatScan { hex })
444}
445
446/// Whether the underscores in `s` sit only between digits or between a base prefix and a digit,
447/// mirroring Go's `underscoreOK`.
448fn underscore_ok(s: &[u8]) -> bool {
449    if !s.contains(&b'_') {
450        return true;
451    }
452    // States: `^` start, `0` digit or base prefix, `_` underscore, `!` other.
453    let mut saw = b'^';
454    let n = s.len();
455    let mut i = 0usize;
456
457    if n >= 1 && (s[0] == b'-' || s[0] == b'+') {
458        i = 1;
459    }
460
461    let mut hex = false;
462    if n - i >= 2 && s[i] == b'0' {
463        let lc = s[i + 1] | 0x20;
464        if lc == b'b' || lc == b'o' || lc == b'x' {
465            saw = b'0';
466            hex = lc == b'x';
467            i += 2;
468        }
469    }
470
471    while i < n {
472        let c = s[i];
473        if c.is_ascii_digit() || (hex && (b'a'..=b'f').contains(&(c | 0x20))) {
474            saw = b'0';
475        } else if c == b'_' {
476            if saw != b'0' {
477                return false;
478            }
479            saw = b'_';
480        } else {
481            if saw == b'_' {
482                return false;
483            }
484            saw = b'!';
485        }
486        i += 1;
487    }
488    saw != b'_'
489}
490
491/// Whether a Go-syntactically-valid decimal float has finite magnitude. Go returns `ErrRange` when a
492/// finite decimal overflows to infinity. Rust's parser surfaces the same overflow as an infinite
493/// result, so a finite parse means Go accepts it.
494fn decimal_is_finite(s: &[u8]) -> bool {
495    let parsed = if s.contains(&b'_') {
496        let cleaned: Vec<u8> = s.iter().copied().filter(|&b| b != b'_').collect();
497        parse_ascii_f64(&cleaned)
498    } else {
499        parse_ascii_f64(s)
500    };
501    match parsed {
502        Some(v) => v.is_finite(),
503        // Go accepted the syntax. Anything Rust cannot re-parse here is a small-magnitude form such
504        // as a trailing-dot mantissa, never an overflow.
505        None => true,
506    }
507}
508
509/// Parse ASCII bytes as an f64, or `None` when they are not valid UTF-8 or not a Rust float.
510fn parse_ascii_f64(s: &[u8]) -> Option<f64> {
511    simdutf8::basic::from_utf8(s).ok()?.parse::<f64>().ok()
512}
513
514/// Whether a Go-syntactically-valid hex float overflows f64.
515///
516/// This mirrors Go's `readFloat`/`atofHex`. It folds at most 16 significant hex digits, at least 64
517/// bits, into the mantissa and tracks the hex-point position. Integer digits beyond the cap grow the
518/// binary exponent rather than the mantissa, and fractional digits beyond the cap are dropped. The
519/// binary exponent is `(point_digits - mantissa_digits) * 4` plus the `p` exponent, so the bounded
520/// mantissa times `2^exponent` gives the true overflow verdict without relying on f64 saturation.
521/// Underflow to zero is not an overflow.
522fn hex_value_overflows(s: &[u8]) -> bool {
523    const MAX_MANT_HEX_DIGITS: i64 = 16;
524
525    let len = s.len();
526    let mut i = 0usize;
527    if i < len && (s[i] == b'+' || s[i] == b'-') {
528        i += 1;
529    }
530    i += 2; // Skip the validated `0x` prefix.
531
532    let mut mantissa: f64 = 0.0;
533    let mut nd: i64 = 0; // Significant digits seen. Drives the hex-point position.
534    let mut nd_mant: i64 = 0; // Digits folded into the mantissa, capped.
535    let mut dp: i64 = 0; // Significant digits before the hex point.
536    let mut saw_dot = false;
537    while i < len {
538        let byte = s[i];
539        if byte == b'_' {
540            i += 1;
541            continue;
542        }
543        if byte == b'.' {
544            if saw_dot {
545                break;
546            }
547            saw_dot = true;
548            dp = nd;
549            i += 1;
550            continue;
551        }
552        let digit: u8 = if byte.is_ascii_digit() {
553            byte - b'0'
554        } else {
555            let lower = byte | 0x20;
556            if (b'a'..=b'f').contains(&lower) {
557                lower - b'a' + 10
558            } else {
559                break;
560            }
561        };
562        // Leading zeros shift the point but never enter the mantissa.
563        if digit == 0 && nd == 0 {
564            dp -= 1;
565            i += 1;
566            continue;
567        }
568        nd += 1;
569        if nd_mant < MAX_MANT_HEX_DIGITS {
570            mantissa = mantissa * 16.0 + f64::from(digit);
571            nd_mant += 1;
572        }
573        i += 1;
574    }
575    // No digit folded into the mantissa means the value is zero, never an overflow.
576    if nd_mant == 0 {
577        return false;
578    }
579    if !saw_dot {
580        dp = nd;
581    }
582    // Count in bits.
583    dp *= 4;
584    nd_mant *= 4;
585
586    i += 1; // Skip the `p`/`P` exponent marker.
587    let mut esign: i64 = 1;
588    if i < len && (s[i] == b'+' || s[i] == b'-') {
589        if s[i] == b'-' {
590            esign = -1;
591        }
592        i += 1;
593    }
594    let mut exp: i64 = 0;
595    while i < len {
596        let byte = s[i];
597        if byte == b'_' {
598            i += 1;
599        } else if byte.is_ascii_digit() {
600            exp = exp.saturating_mul(10).saturating_add(i64::from(byte - b'0'));
601            i += 1;
602        } else {
603            break;
604        }
605    }
606    dp = dp.saturating_add(esign.saturating_mul(exp));
607
608    let total = dp.saturating_sub(nd_mant);
609    if total > 1100 {
610        return true;
611    }
612    if total < -1200 {
613        return false;
614    }
615    let Ok(total_i32) = i32::try_from(total) else {
616        return true;
617    };
618    (mantissa * 2f64.powi(total_i32)).is_infinite()
619}
620
621#[cfg(test)]
622mod tests {
623    use proptest::prelude::*;
624
625    use super::{is_malformed, PayloadError};
626
627    // --- metrics ---
628
629    #[test]
630    fn metric_well_formed_forwards() {
631        // Covers basic, set with an unparsed value, multi-value packed, special values, @rate, tags
632        // and origin chunks with delimiters, and a non-UTF-8 name. All forward through the lenient
633        // Agent.
634        for line in [
635            &b"m:1|c"[..],
636            b"m:1|g",
637            b"m:1|ms",
638            b"m:1|h",
639            b"m:1|d",
640            b"m:anything goes|s",
641            b"m:1:2:3|d",
642            b"m:nan|g",
643            b"m:inf|g",
644            b"m:-inf|g",
645            b"m:0x1p4|g",
646            b"m:1_000|g",
647            b"m:1.|g",
648            b"m:.5|g",
649            b"m:1|c|@0.5",
650            b"m:1|c|@nan",
651            b"m:1|c|#a:b,c:d",
652            b"m:1|c|c:cid-deadbeef",
653            b"m:1|c|card:high",
654            b"na\xff\x00me:1|c",
655        ] {
656            assert_eq!(is_malformed(line), Ok(()), "expected forward: {line:?}");
657        }
658    }
659
660    #[test]
661    fn metric_drop_rules() {
662        assert_eq!(is_malformed(b"m:1"), Err(PayloadError::MetricNoPipe { line: 0 }));
663        assert_eq!(
664            is_malformed(b"noColon|g"),
665            Err(PayloadError::MetricNameValueNoColon { line: 0 })
666        );
667        assert_eq!(is_malformed(b":1|g"), Err(PayloadError::MetricEmptyName { line: 0 }));
668        assert_eq!(is_malformed(b"m:|g"), Err(PayloadError::MetricEmptyValue { line: 0 }));
669        assert_eq!(is_malformed(b"m:1|gg"), Err(PayloadError::MetricBadType { line: 0 }));
670        assert_eq!(is_malformed(b"m:1|"), Err(PayloadError::MetricBadType { line: 0 }));
671        assert_eq!(
672            is_malformed(b"m:notafloat|g"),
673            Err(PayloadError::MetricUnparseableValue { line: 0 })
674        );
675        assert_eq!(
676            is_malformed(b"m:::|d"),
677            Err(PayloadError::MetricUnparseableValue { line: 0 })
678        );
679        assert_eq!(
680            is_malformed(b"m:1|c|@bad"),
681            Err(PayloadError::MetricBadRate { line: 0 })
682        );
683    }
684
685    // --- events ---
686
687    #[test]
688    fn event_well_formed_forwards() {
689        for line in [
690            &b"_e{1,0}:a|"[..],
691            b"_e{5,4}:title|text",
692            b"_e{5,0}:title|",
693            b"_e{5,4}:title|text|h:host|k:key|p:normal|t:error|s:src|#a:b",
694            b"_e{5,4}:title|text|p:bogus|t:bogus|d:notanint", // bad optional fields still forward
695            b"_e{5,4}:title|text|extra bytes past declared body", // body longer than declared is fine
696            b"_e{5,4}:t\xff\x00le|te\xffxt",                  // non-UTF-8 title/text forward
697        ] {
698            assert_eq!(is_malformed(line), Ok(()), "expected forward: {line:?}");
699        }
700    }
701
702    #[test]
703    fn event_drop_rules() {
704        assert_eq!(
705            is_malformed(b"_e{5,4}title|text"),
706            Err(PayloadError::EventNoColon { line: 0 })
707        );
708        assert_eq!(
709            is_malformed(b"_e{}:x"),
710            Err(PayloadError::EventHeaderTooShort { line: 0 })
711        );
712        assert_eq!(
713            is_malformed(b"_e{500}:title"),
714            Err(PayloadError::EventNoLengthComma { line: 0 })
715        );
716        assert_eq!(
717            is_malformed(b"_e{x,0}:title"),
718            Err(PayloadError::EventBadTitleLen { line: 0 })
719        );
720        assert_eq!(
721            is_malformed(b"_e{0,0}:"),
722            Err(PayloadError::EventEmptyTitle { line: 0 })
723        );
724        assert_eq!(
725            is_malformed(b"_e{5,x}:title"),
726            Err(PayloadError::EventBadTextLen { line: 0 })
727        );
728        assert_eq!(
729            is_malformed(b"_e{5,4}:t"),
730            Err(PayloadError::EventBodyTooShort { line: 0 })
731        );
732    }
733
734    // Go parses the header lengths with `strconv.Atoi` and drops only on `< 0`, so a signed zero is a
735    // valid zero-length field the Agent forwards. Rejecting it would hide an Agent-accepted line from
736    // the state search.
737    #[test]
738    fn event_signed_zero_length_matches_atoi() {
739        assert_eq!(is_malformed(b"_e{1,-0}:a|"), Ok(()));
740        assert_eq!(is_malformed(b"_e{1,-00}:a|"), Ok(()));
741        // A genuinely negative length still drops, as `textLength < 0` does in the Agent.
742        assert_eq!(
743            is_malformed(b"_e{1,-1}:a|"),
744            Err(PayloadError::EventBadTextLen { line: 0 })
745        );
746        // A signed-zero title parses to zero, so the Agent's own empty-title check is what drops it.
747        assert_eq!(
748            is_malformed(b"_e{-0,0}:"),
749            Err(PayloadError::EventEmptyTitle { line: 0 })
750        );
751    }
752
753    // --- service checks ---
754
755    #[test]
756    fn service_check_well_formed_forwards() {
757        for line in [
758            &b"_sc|name|0"[..],
759            b"_sc|name|1",
760            b"_sc|name|2",
761            b"_sc|name|3",
762            b"_sc|name|0|h:host|#a:b|m:message text",
763            b"_sc|na\xffme|0", // non-UTF-8 name forwards
764        ] {
765            assert_eq!(is_malformed(line), Ok(()), "expected forward: {line:?}");
766        }
767    }
768
769    #[test]
770    fn service_check_drop_rules() {
771        assert_eq!(
772            is_malformed(b"_sc|nameonly"),
773            Err(PayloadError::ServiceCheckTooFewPipes { line: 0 })
774        );
775        assert_eq!(
776            is_malformed(b"_sc"),
777            Err(PayloadError::ServiceCheckTooFewPipes { line: 0 })
778        );
779        assert_eq!(
780            is_malformed(b"_sc||0"),
781            Err(PayloadError::ServiceCheckEmptyName { line: 0 })
782        );
783        assert_eq!(
784            is_malformed(b"_sc|name|9"),
785            Err(PayloadError::ServiceCheckBadStatus { line: 0 })
786        );
787        assert_eq!(
788            is_malformed(b"_sc|name|"),
789            Err(PayloadError::ServiceCheckBadStatus { line: 0 })
790        );
791    }
792
793    // --- framing / routing ---
794
795    #[test]
796    fn framing_skips_blanks_and_reports_first_offending_line() {
797        // Blank lines are counted in the index but never malformed. The first bad message wins.
798        let payload = b"m:1|c\n\n\nbad|line\nm:2|c";
799        assert_eq!(
800            is_malformed(payload),
801            Err(PayloadError::MetricNameValueNoColon { line: 3 })
802        );
803    }
804
805    #[test]
806    fn framing_handles_crlf_and_empty_payload() {
807        assert_eq!(is_malformed(b"m:1|c\r\nm:2|g\r\n"), Ok(()));
808        assert_eq!(is_malformed(b""), Ok(()));
809        assert_eq!(is_malformed(b"\n\n"), Ok(()));
810    }
811
812    #[test]
813    fn routing_by_prefix() {
814        // `_e` without `{` and any gibberish route to the metric parser.
815        assert_eq!(is_malformed(b"_e:1|g"), Ok(()));
816        assert_eq!(is_malformed(b"gibberish"), Err(PayloadError::MetricNoPipe { line: 0 }));
817    }
818
819    // --- Go strconv parity port ---
820
821    #[test]
822    fn go_float_accepts_go_specific_forms() {
823        // Each rides in as a metric value: forwarded iff Go ParseFloat accepts it.
824        for value in [
825            &b"0x1p-2"[..],
826            b"1_000",
827            b"nan",
828            b"inf",
829            b"infinity",
830            b"+inf",
831            b"-inf",
832            b"1.",
833            b".5",
834            b"0x1.8p3",
835        ] {
836            let mut line = b"m:".to_vec();
837            line.extend_from_slice(value);
838            line.extend_from_slice(b"|g");
839            assert_eq!(is_malformed(&line), Ok(()), "expected forward for value {value:?}");
840        }
841    }
842
843    #[test]
844    fn go_float_rejects_what_go_rejects() {
845        for value in [
846            &b"+nan"[..],
847            b"-nan",
848            b"0x1",
849            b"_1",
850            b"1_",
851            b"1__0",
852            b"1e",
853            b"1e+",
854            b"abc",
855        ] {
856            let mut line = b"m:".to_vec();
857            line.extend_from_slice(value);
858            line.extend_from_slice(b"|g");
859            assert_eq!(
860                is_malformed(&line),
861                Err(PayloadError::MetricUnparseableValue { line: 0 }),
862                "expected drop for value {value:?}"
863            );
864        }
865    }
866
867    #[test]
868    fn hex_float_overflow_boundary() {
869        // 0x1p1023 is finite, 0x1p1024 overflows f64.
870        assert_eq!(is_malformed(b"m:0x1p1023|g"), Ok(()));
871        assert_eq!(
872            is_malformed(b"m:0x1p1024|g"),
873            Err(PayloadError::MetricUnparseableValue { line: 0 })
874        );
875    }
876
877    proptest! {
878        // The predicate must be total over arbitrary bytes: never panic, whatever the input.
879        #[test]
880        fn property_test_never_panics(payload in proptest::collection::vec(any::<u8>(), 0..128)) {
881            let _ = is_malformed(&payload);
882        }
883    }
884}