Skip to main content

datadog_agent_config/
size_in_bytes.rs

1//! Parse byte-size settings the way the Agent's `GetSizeInBytes` does.
2//!
3//! The Agent reads `log_file_max_size` and `dogstatsd_log_file_max_size` with
4//! `parseSizeInBytes` (`pkg/config/nodetreemodel/helpers.go`), which accepts a narrower unit grammar
5//! than the `bytesize` crate and reads every unit as a power of two.
6
7/// Parses a size setting into a byte count, as the Agent's `GetSizeInBytes` does.
8///
9/// This ports `parseSizeInBytes` and the string path of `cast.ToInt`:
10///
11/// 1. Surrounding whitespace is trimmed.
12/// 2. If the text is at least three bytes long and ends in `b` or `B`, a preceding `k`, `m`, or `g`
13///    (either case) selects a multiplier of 2^10, 2^20, or 2^30 and both letters are removed. Any
14///    other preceding byte leaves a multiplier of one and removes only the `b`. A two-byte value such
15///    as `1B` is never stripped, so it is not a number. There is no `T`, no bare `K`/`M`/`G`, and no
16///    `iB` spelling.
17/// 3. The remaining text is trimmed again, an all-zero fraction is dropped (`10.0` is `10`), and the
18///    result is read as a Go integer literal with base 0: an optional sign, then decimal digits or a
19///    `0x`, `0o`, `0b`, or leading-`0` (octal) prefix, with `_` digit separators only between digits
20///    or after a prefix. The value must fit in an `i64`.
21/// 4. The count is multiplied by the multiplier and must fit in a `u64`.
22///
23/// Returns `None` when the Agent's parse fails, the value is negative, or the product overflows. The
24/// Agent reads all of these as `0` without a diagnostic, so `None` keeps them apart from a
25/// configured `0`. Otherwise, returns the Agent's byte count.
26pub fn parse_size_in_bytes(raw: &str) -> Option<u64> {
27    let (digits, multiplier) = split_unit(raw.trim());
28    let count = u64::try_from(parse_go_int(trim_zero_decimal(digits))?).ok()?;
29    count.checked_mul(multiplier)
30}
31
32/// Splits trimmed text into its numeric part and the multiplier its unit suffix selects.
33///
34/// Works on bytes: unit suffixes are ASCII, so removing them preserves UTF-8 character boundaries.
35fn split_unit(text: &str) -> (&str, u64) {
36    let bytes = text.as_bytes();
37    let len = bytes.len();
38    if len < 3 || !matches!(bytes[len - 1], b'b' | b'B') {
39        return (text, 1);
40    }
41
42    let (multiplier, suffix_len) = match bytes[len - 2].to_ascii_lowercase() {
43        b'k' => (1 << 10, 2),
44        b'm' => (1 << 20, 2),
45        b'g' => (1 << 30, 2),
46        _ => (1, 1),
47    };
48    (text[..len - suffix_len].trim(), multiplier)
49}
50
51/// `cast`'s `trimZeroDecimal`: scans back over `0` and `.` and cuts at the first `.` that has a `0`
52/// after it, so `"10.0"` and `"10.0."` are both `"10"`.
53///
54/// [`crate::cast_de`] keeps a narrower form for integer settings that drops only a single all-zero
55/// fraction.
56fn trim_zero_decimal(text: &str) -> &str {
57    let mut found_zero = false;
58    for (index, byte) in text.bytes().enumerate().rev() {
59        match byte {
60            b'.' if found_zero => return &text[..index],
61            b'.' => {}
62            b'0' => found_zero = true,
63            _ => return text,
64        }
65    }
66    text
67}
68
69/// `strconv.ParseInt(s, 0, 0)` on a 64-bit platform, returning `None` where Go returns an error.
70fn parse_go_int(text: &str) -> Option<i64> {
71    let (negative, unsigned) = match text.as_bytes().first() {
72        Some(b'+') => (false, &text[1..]),
73        Some(b'-') => (true, &text[1..]),
74        _ => (false, text),
75    };
76
77    let magnitude = i128::from(parse_go_uint(unsigned)?);
78    // `i64::MIN` has a magnitude one larger than `i64::MAX`.
79    i64::try_from(if negative { -magnitude } else { magnitude }).ok()
80}
81
82/// `strconv.ParseUint(s, 0, 64)`: base prefixes and underscores follow Go's integer literal rules.
83fn parse_go_uint(text: &str) -> Option<u64> {
84    let bytes = text.as_bytes();
85    let &first = bytes.first()?;
86
87    // A prefix needs a digit position after it, so a bare `0x` is an octal `0` followed by `x`.
88    let (base, digits) = if first == b'0' {
89        match bytes.get(1).map(u8::to_ascii_lowercase) {
90            Some(b'b') if bytes.len() >= 3 => (2, &bytes[2..]),
91            Some(b'o') if bytes.len() >= 3 => (8, &bytes[2..]),
92            Some(b'x') if bytes.len() >= 3 => (16, &bytes[2..]),
93            _ => (8, &bytes[1..]),
94        }
95    } else {
96        (10, bytes)
97    };
98
99    let mut value: u64 = 0;
100    let mut saw_underscore = false;
101    for &byte in digits {
102        let digit = match byte {
103            b'_' => {
104                saw_underscore = true;
105                continue;
106            }
107            b'0'..=b'9' => byte - b'0',
108            b'a'..=b'z' | b'A'..=b'Z' => byte.to_ascii_lowercase() - b'a' + 10,
109            _ => return None,
110        };
111        if digit >= base {
112            return None;
113        }
114        value = value.checked_mul(u64::from(base))?.checked_add(u64::from(digit))?;
115    }
116
117    if saw_underscore && !underscores_ok(bytes) {
118        return None;
119    }
120    Some(value)
121}
122
123/// `strconv.underscoreOK` for text without a sign: an underscore must sit between two digits, or
124/// between a base prefix and a digit.
125fn underscores_ok(bytes: &[u8]) -> bool {
126    /// What the previous byte was.
127    #[derive(PartialEq)]
128    enum Previous {
129        Start,
130        Digit,
131        Underscore,
132        Other,
133    }
134
135    let mut previous = Previous::Start;
136    let mut hex = false;
137    let mut rest = bytes;
138    if let [b'0', prefix, tail @ ..] = bytes {
139        if matches!(prefix.to_ascii_lowercase(), b'b' | b'o' | b'x') {
140            // A base prefix counts as a digit for separator placement.
141            previous = Previous::Digit;
142            hex = prefix.eq_ignore_ascii_case(&b'x');
143            rest = tail;
144        }
145    }
146
147    for &byte in rest {
148        if byte.is_ascii_digit() || (hex && byte.is_ascii_hexdigit()) {
149            previous = Previous::Digit;
150        } else if byte == b'_' {
151            if previous != Previous::Digit {
152                return false;
153            }
154            previous = Previous::Underscore;
155        } else if previous == Previous::Underscore {
156            return false;
157        } else {
158            previous = Previous::Other;
159        }
160    }
161    previous != Previous::Underscore
162}
163
164#[cfg(test)]
165mod tests {
166    use super::parse_size_in_bytes;
167
168    /// Asserts the exact byte count for each input.
169    #[track_caller]
170    fn assert_sizes(cases: &[(&str, u64)]) {
171        for &(input, expected) in cases {
172            assert_eq!(parse_size_in_bytes(input), Some(expected), "input {input:?}");
173        }
174    }
175
176    /// Asserts each input is one the Agent reads as `0` because its parse fails.
177    #[track_caller]
178    fn assert_unparsed(inputs: &[&str]) {
179        for &input in inputs {
180            assert_eq!(parse_size_in_bytes(input), None, "input {input:?}");
181        }
182    }
183
184    // Expected sizes were produced by running the Agent's `parseSizeInBytes` on each input:
185    // https://github.com/DataDog/datadog-agent/blob/281d921619d52ce7b99aef40607285992c9c2e89/pkg/config/nodetreemodel/helpers.go#L36-L71
186    // Where the Agent returns 0 because its parse failed, the value was negative, or the product
187    // overflowed, the input is asserted as unparsed.
188
189    // Ported from the Agent's `TestGetSizeInBytes`:
190    // https://github.com/DataDog/datadog-agent/blob/281d921619d52ce7b99aef40607285992c9c2e89/pkg/config/nodetreemodel/getter_test.go#L226-L242
191    #[test]
192    fn agent_get_size_in_bytes_cases() {
193        assert_sizes(&[
194            ("123", 123),
195            ("1kb", 1024),
196            ("1Mb", 1024 * 1024),
197            ("1 GB", 1024 * 1024 * 1024),
198        ]);
199        assert_unparsed(&["-1", "invalid"]);
200    }
201
202    #[test]
203    fn schema_default_and_common_spellings_are_binary_units() {
204        assert_sizes(&[
205            ("10Mb", 10_485_760),
206            ("10MB", 10_485_760),
207            ("10mb", 10_485_760),
208            ("10 MB", 10_485_760),
209            (" 10MB ", 10_485_760),
210            ("10 MB ", 10_485_760),
211            ("10.0MB", 10_485_760),
212            ("10.00MB", 10_485_760),
213            ("+10MB", 10_485_760),
214            ("5KB", 5_120),
215            ("1GB", 1 << 30),
216        ]);
217    }
218
219    #[test]
220    fn trailing_dot_after_an_all_zero_fraction_is_dropped() {
221        // Quirk of the Agent's zero-decimal trimming: it scans backward and cuts at the first `.` with a
222        // `0` to its right.
223        assert_sizes(&[("10.0.MB", 10 << 20), ("1.0.", 1)]);
224    }
225
226    #[test]
227    fn unit_letters_fold_case_independently() {
228        assert_sizes(&[
229            ("1kB", 1 << 10),
230            ("1Kb", 1 << 10),
231            ("1KB", 1 << 10),
232            ("1mB", 1 << 20),
233            ("1Mb", 1 << 20),
234            ("1gb", 1 << 30),
235            ("1gB", 1 << 30),
236            ("1Gb", 1 << 30),
237        ]);
238    }
239
240    #[test]
241    fn plain_numbers_and_bare_byte_suffix_are_literal() {
242        assert_sizes(&[
243            ("100", 100),
244            ("100b", 100),
245            ("100 b", 100),
246            ("3 b", 3),
247            ("0", 0),
248            ("0KB", 0),
249            ("-0", 0),
250            ("10485760", 10_485_760),
251        ]);
252    }
253
254    #[test]
255    fn two_byte_byte_suffix_is_not_stripped() {
256        // The Agent only strips a suffix from text of three bytes or more, so `1B` is not a number while
257        // `10b` and `1 b` are.
258        assert_unparsed(&["1B", "1b", "kb", "KB", "b", "B"]);
259        assert_sizes(&[("10b", 10), ("1 b", 1)]);
260    }
261
262    #[test]
263    fn base_prefixes_follow_go_integer_literals() {
264        assert_sizes(&[
265            ("0x10KB", 16 << 10),
266            ("0X10KB", 16 << 10),
267            ("0xffkb", 255 << 10),
268            ("0x10", 16),
269            ("0o10KB", 8 << 10),
270            ("0O10KB", 8 << 10),
271            ("010KB", 8 << 10),
272            ("010", 8),
273            ("0b10KB", 2 << 10),
274            ("0B10KB", 2 << 10),
275            ("0b1", 1),
276            ("0o7", 7),
277            ("00", 0),
278            ("0x1.0", 1),
279        ]);
280        assert_unparsed(&["08", "0x", "0b", "0o", "0b2", "0o8", "0xg", "0x_"]);
281    }
282
283    #[test]
284    fn underscores_must_separate_digits() {
285        assert_sizes(&[
286            ("1_000KB", 1_000 << 10),
287            ("1_0", 10),
288            ("1_0_0", 100),
289            ("0_7", 7),
290            ("0_10", 8),
291            ("0x_1", 1),
292            ("0x_ff", 255),
293            ("0xf_f", 255),
294            ("0b_1", 1),
295            ("0o_7", 7),
296            ("0b1_0KB", 2 << 10),
297        ]);
298        assert_unparsed(&["_1KB", "1_KB", "1__0", "1__0KB", "1_", "0_x1", "0xf__f", "0b1_"]);
299    }
300
301    #[test]
302    fn units_the_agent_does_not_know_are_unparsed() {
303        assert_unparsed(&[
304            "10MiB", "10K", "10M", "1G", "10TB", "3tb", "1.5MB", "1KBB", "1 K B", "2k b", "MB",
305        ]);
306    }
307
308    #[test]
309    fn negative_empty_and_malformed_numbers_are_unparsed() {
310        assert_unparsed(&[
311            "-1MB", "-1", "", " ", "abc", "+", "-", "+-1", "--1", "1 0", "10.MB", ".0MB", "1.0.0MB", "10 . 0MB",
312        ]);
313    }
314
315    #[test]
316    fn integer_range_is_the_signed_64_bit_range() {
317        assert_sizes(&[
318            ("9223372036854775807", i64::MAX as u64),
319            ("9223372036854775807b", i64::MAX as u64),
320        ]);
321        // A negative value is unparsed even when it is out of range, and `i64::MIN` is still negative.
322        assert_unparsed(&["9223372036854775808", "-9223372036854775808", "-9223372036854775809"]);
323        assert_unparsed(&["99999999999999999999999"]);
324    }
325
326    #[test]
327    fn product_may_exceed_i64_but_not_u64() {
328        // 2^63 bytes is representable as a `u64` even though the count times the unit exceeds `i64::MAX`.
329        assert_sizes(&[
330            ("8589934592GB", 1 << 63),
331            ("17592186044415MB", 18_446_744_073_708_503_040),
332        ]);
333        assert_unparsed(&["17592186044416MB", "9223372036854775807KB", "9223372036854775807GB"]);
334    }
335
336    #[test]
337    fn whitespace_follows_go_trim_space() {
338        assert_sizes(&[
339            ("10\u{a0}MB", 10 << 20),
340            ("10\u{3000}MB", 10 << 20),
341            ("\u{a0}1\u{a0}kb\u{a0}", 1 << 10),
342            ("1\tKB", 1 << 10),
343            ("1\nKB", 1 << 10),
344        ]);
345    }
346
347    #[test]
348    fn non_ascii_input_is_unparsed_without_panicking() {
349        // Slicing off a suffix by byte index must not split a multi-byte character.
350        assert_unparsed(&["é", "10éB", "10MéB", "10Mé", "10MB", "10€b", "ééb", "日本語"]);
351    }
352}