Skip to main content

datadog_agent_config/
env_decode.rs

1//! Decode a raw environment-variable string into the JSON shape the schema declares for a leaf.
2//!
3//! The Datadog Agent turns an environment string into a typed value in one place
4//! (`insertNodeFromString`): a registered per-key transformer when the schema names an
5//! `env_parser`, otherwise a cast against the key's default type. This module ports both halves so
6//! ADP can build a resolved view of real JSON arrays, objects, numbers, and booleans at each
7//! nested path, which a single ordinary deserialization then reads.
8//!
9//! Supported splitting and delimiter behavior matches the Agent bug-for-bug (see the per-function
10//! notes, which cite the Agent source). Error handling deliberately diverges: the Agent logs a malformed
11//! value and substitutes an empty result, while this module propagates a hard error so the caller
12//! can reject the value instead of silently losing it. [`crate::cast_de`] notes the one exception.
13//!
14//! The scalar type fallbacks are the string half of [`crate::cast_de`], which every configuration
15//! source shares, so an environment variable and a file accept the same spellings for a leaf.
16//!
17//! One type is intentionally left as a string: a `Duration` leaf keeps its raw text and is parsed by
18//! `crate::duration_de` at deserialize time, because a duration also arrives from the file (a Go
19//! duration string) and from the Agent stream (integer nanoseconds), so that leaf must stay
20//! shape-tolerant regardless of the environment.
21
22use serde_json::{Map, Number, Value};
23
24use crate::cast_de;
25
26/// How to decode a raw environment string into a JSON value for one leaf.
27///
28/// The named-parser variants mirror the schema's `env_parser`, and the type fallbacks are the casts
29/// the Agent applies when no `env_parser` is declared, keyed by the leaf's declared type. The
30/// remaining variants are for Saluki-only keys, which have no Agent cast to match; the Datadog table
31/// never uses them.
32#[derive(Clone, Copy, Debug, Eq, PartialEq)]
33pub enum EnvDecode {
34    /// `env_parser: json`: parse the string as a JSON document.
35    Json,
36    /// `env_parser: comma_separated`: split on `,`, no trimming.
37    CommaSeparated,
38    /// `env_parser: comma_and_space_separated`: split on `,` or space, dropping empties.
39    CommaAndSpaceSeparated,
40    /// `env_parser: comma_then_space_separated`: split on `,` if present else space, trimmed.
41    CommaThenSpaceSeparated,
42    /// `env_parser: json_list_or_comma_separated`: a `[...]` JSON list, else split on `,`.
43    JsonListOrCommaSeparated,
44    /// `env_parser: json_list_or_space_separated`: a `[...]` JSON list, else split on space.
45    JsonListOrSpaceSeparated,
46    /// `env_parser: traces_span`: `svc|op=rate,...` into a `{name: rate}` object.
47    TracesSpan,
48    /// Type fallback: a plain string, taken verbatim.
49    RawString,
50    /// Type fallback: a boolean, parsed with Go's `strconv.ParseBool` grammar.
51    Bool,
52    /// Type fallback: a signed integer, in Go's `strconv.ParseInt` base-0 grammar (the Agent's `int`
53    /// cast): optional sign, `0x`/`0o`/`0b` prefixes, a leading `0` as octal, and `_` separators.
54    Integer,
55    /// Type fallback: a finite floating-point number, in Go's `strconv.ParseFloat` grammar.
56    Float,
57    /// Type fallback: a string list (the Agent's `[]string` cast). A JSON array of strings or `null`
58    /// is decoded as JSON; anything else is split on whitespace.
59    StringList,
60    /// Type fallback: a duration, carried through as a string for `crate::duration_de`.
61    DurationString,
62    /// Type fallback: a map/object (or any non-scalar), parsed as a JSON document.
63    JsonValue,
64    /// Saluki-only: a signed decimal integer, optionally followed by an all-zero fraction. A leading
65    /// `0` is decimal.
66    DecimalInteger,
67    /// Saluki-only: a decimal floating-point number, with no `_` separators or hexadecimal form.
68    DecimalFloat,
69    /// Saluki-only: a string list split on whitespace, with no JSON form.
70    WhitespaceList,
71}
72
73/// Decodes `raw` into the JSON value `how` prescribes.
74///
75/// # Errors
76///
77/// Returns a human-readable message when `raw` is malformed for `how` (invalid JSON, a bad boolean
78/// or number, or a `traces_span` token that is not `name=rate`). The caller pairs it with the
79/// environment variable name.
80pub fn decode(raw: &str, how: EnvDecode) -> Result<Value, String> {
81    match how {
82        EnvDecode::Json | EnvDecode::JsonValue => parse_json(raw),
83        EnvDecode::CommaSeparated => Ok(comma_separated(raw)),
84        EnvDecode::CommaAndSpaceSeparated => Ok(comma_and_space_separated(raw)),
85        EnvDecode::CommaThenSpaceSeparated => Ok(comma_then_space_separated(raw)),
86        EnvDecode::JsonListOrCommaSeparated => json_list_or_split(raw, ','),
87        EnvDecode::JsonListOrSpaceSeparated => json_list_or_split(raw, ' '),
88        EnvDecode::TracesSpan => traces_span(raw),
89        EnvDecode::RawString | EnvDecode::DurationString => Ok(Value::String(raw.to_string())),
90        EnvDecode::Bool => cast_de::parse_bool(raw).map(Value::Bool),
91        EnvDecode::Integer => cast_de::parse_i64(raw).map(|n| Value::Number(n.into())),
92        EnvDecode::Float => cast_de::parse_f64(raw).map(finite_number),
93        EnvDecode::StringList => Ok(string_array(cast_de::parse_string_list(raw))),
94        EnvDecode::DecimalInteger => cast_de::parse_decimal_i64(raw).map(|n| Value::Number(n.into())),
95        EnvDecode::DecimalFloat => cast_de::parse_decimal_f64(raw).map(finite_number),
96        EnvDecode::WhitespaceList => Ok(string_array(raw.split_whitespace())),
97    }
98}
99
100/// `env_parser: json` and the map/object type fallback (`ParseEnvJSON`, `config.go:734`).
101///
102/// The Agent decodes into the declared type; we parse to a generic `Value` and let the leaf's
103/// own deserializer enforce its shape. A parse error propagates (the Agent logs and yields the zero
104/// value).
105fn parse_json(raw: &str) -> Result<Value, String> {
106    serde_json::from_str(raw).map_err(|e| format!("invalid JSON: {e}"))
107}
108
109/// `env_parser: comma_separated` (`ParseEnvSplitComma`, `config.go:704`).
110///
111/// `strings.Split` on `,` with no trimming; an empty string yields an empty list (the Agent's
112/// explicit special case), not a one-element list containing `""`.
113fn comma_separated(raw: &str) -> Value {
114    if raw.is_empty() {
115        return Value::Array(Vec::new());
116    }
117    string_array(raw.split(','))
118}
119
120/// `env_parser: comma_and_space_separated` (`ParseEnvSplitCommaAndSpace`, `helper/env.go:87`).
121///
122/// `strings.FieldsFunc` on `,` or space: consecutive separators collapse and empty fields are
123/// dropped.
124fn comma_and_space_separated(raw: &str) -> Value {
125    string_array(raw.split([',', ' ']).filter(|s| !s.is_empty()))
126}
127
128/// `env_parser: comma_then_space_separated` (`ParseEnvSplitCommaThenSpace`, `helper/env.go:99`).
129///
130/// If the string contains a comma, split on commas only; otherwise split on spaces. Each element is
131/// trimmed, and empties are kept (`strings.Split` semantics), so `a,,b` yields three elements.
132fn comma_then_space_separated(raw: &str) -> Value {
133    let sep = if raw.contains(',') { ',' } else { ' ' };
134    string_array(raw.split(sep).map(str::trim))
135}
136
137/// `env_parser: json_list_or_{comma,space}_separated` (`jsonOrSplitBy`, `helper/env.go:120`).
138///
139/// A value delimited by `[` and `]` is parsed as a JSON string list, where a `null` element is an
140/// empty string as in Go's `json.Unmarshal`; anything else is split on `sep`. The input is trimmed
141/// before the `[...]` test. A JSON list that fails to parse propagates an error (the Agent logs and
142/// yields nil).
143fn json_list_or_split(raw: &str, sep: char) -> Result<Value, String> {
144    let trimmed = raw.trim();
145    if trimmed.starts_with('[') && trimmed.ends_with(']') {
146        let list: Vec<Option<String>> =
147            serde_json::from_str(trimmed).map_err(|e| format!("invalid JSON string list: {e}"))?;
148        return Ok(string_array(list.into_iter().map(Option::unwrap_or_default)));
149    }
150    Ok(string_array(trimmed.split(sep)))
151}
152
153/// `env_parser: traces_span` (`parseAnalyzedSpans`, `helper/env.go:35`).
154///
155/// `service|operation=rate,...` into `{ "service|operation": rate }`. An empty string yields an
156/// empty object; a token that is not `name=rate` with a numeric rate propagates an error.
157fn traces_span(raw: &str) -> Result<Value, String> {
158    let mut map = Map::new();
159    if raw.is_empty() {
160        return Ok(Value::Object(map));
161    }
162    for token in raw.split(',') {
163        let (name, rate) = token
164            .split_once('=')
165            .ok_or_else(|| format!("bad traces_span token `{token}`: expected name=rate"))?;
166        // Unlike `parse_f64`, the Agent's `strconv.ParseFloat` here does not trim whitespace.
167        let rate = Some(rate)
168            .filter(|rate| rate.trim() == *rate)
169            .and_then(|rate| cast_de::parse_f64(rate).ok())
170            .ok_or_else(|| format!("bad traces_span rate in `{token}`"))?;
171        map.insert(name.to_string(), finite_number(rate));
172    }
173    Ok(Value::Object(map))
174}
175
176/// A JSON number for a float the `cast_de` parsers accepted, which they guarantee is finite.
177fn finite_number(parsed: f64) -> Value {
178    Value::Number(Number::from_f64(parsed).expect("a finite float is a JSON number"))
179}
180
181/// Collects strings into a JSON array of strings.
182fn string_array<S: AsRef<str>, I: IntoIterator<Item = S>>(items: I) -> Value {
183    Value::Array(
184        items
185            .into_iter()
186            .map(|s| Value::String(s.as_ref().to_string()))
187            .collect(),
188    )
189}
190
191#[cfg(test)]
192mod tests {
193    use serde_json::json;
194
195    use super::*;
196
197    fn arr(items: &[&str]) -> Value {
198        json!(items)
199    }
200
201    #[test]
202    fn json_parses_arbitrary_documents() {
203        assert_eq!(decode(r#"["a","b"]"#, EnvDecode::Json).unwrap(), arr(&["a", "b"]));
204        assert_eq!(
205            decode(r#"{"k":["v"]}"#, EnvDecode::JsonValue).unwrap(),
206            json!({"k": ["v"]})
207        );
208        assert!(decode("{bad", EnvDecode::Json).is_err());
209    }
210
211    #[test]
212    fn comma_separated_does_not_trim() {
213        assert_eq!(decode("a, b", EnvDecode::CommaSeparated).unwrap(), arr(&["a", " b"]));
214        assert_eq!(decode("", EnvDecode::CommaSeparated).unwrap(), Value::Array(vec![]));
215    }
216
217    #[test]
218    fn comma_and_space_drops_empties() {
219        assert_eq!(
220            decode("a, b c,,d", EnvDecode::CommaAndSpaceSeparated).unwrap(),
221            arr(&["a", "b", "c", "d"])
222        );
223    }
224
225    #[test]
226    fn comma_then_space_keeps_empties_and_trims() {
227        assert_eq!(
228            decode("a,,b", EnvDecode::CommaThenSpaceSeparated).unwrap(),
229            arr(&["a", "", "b"])
230        );
231        assert_eq!(
232            decode("a b  c", EnvDecode::CommaThenSpaceSeparated).unwrap(),
233            arr(&["a", "b", "", "c"])
234        );
235    }
236
237    #[test]
238    fn json_list_or_comma_takes_either_branch() {
239        assert_eq!(
240            decode(r#"  ["a","b"] "#, EnvDecode::JsonListOrCommaSeparated).unwrap(),
241            arr(&["a", "b"])
242        );
243        assert_eq!(
244            decode("a,b", EnvDecode::JsonListOrCommaSeparated).unwrap(),
245            arr(&["a", "b"])
246        );
247        assert!(decode("[bad", EnvDecode::JsonListOrCommaSeparated).is_ok()); // no closing ], so split
248        assert!(decode(r#"["unterminated"#, EnvDecode::JsonListOrCommaSeparated).is_ok());
249        assert!(decode("[1,2]", EnvDecode::JsonListOrCommaSeparated).is_err()); // JSON list of non-strings
250    }
251
252    #[test]
253    fn json_list_reads_a_null_element_as_an_empty_string() {
254        assert_eq!(
255            decode(r#"["a",null]"#, EnvDecode::JsonListOrSpaceSeparated).unwrap(),
256            arr(&["a", ""])
257        );
258    }
259
260    #[test]
261    fn json_list_or_space_splits_on_space() {
262        assert_eq!(
263            decode("a b", EnvDecode::JsonListOrSpaceSeparated).unwrap(),
264            arr(&["a", "b"])
265        );
266    }
267
268    #[test]
269    fn traces_span_builds_a_rate_map() {
270        assert_eq!(
271            decode("svc|op=0.5,other|op2=1", EnvDecode::TracesSpan).unwrap(),
272            json!({ "svc|op": 0.5, "other|op2": 1.0 })
273        );
274        assert_eq!(
275            decode("svc|op=0x1p-1,other|op2=1_0", EnvDecode::TracesSpan).unwrap(),
276            json!({ "svc|op": 0.5, "other|op2": 10.0 })
277        );
278        assert_eq!(decode("", EnvDecode::TracesSpan).unwrap(), json!({}));
279        assert!(decode("svc|op", EnvDecode::TracesSpan).is_err());
280        assert!(decode("svc|op=notnum", EnvDecode::TracesSpan).is_err());
281        assert!(decode("svc|op= 0.5", EnvDecode::TracesSpan).is_err());
282    }
283
284    #[test]
285    fn scalar_fallbacks() {
286        assert_eq!(decode("hello", EnvDecode::RawString).unwrap(), json!("hello"));
287        assert_eq!(decode("true", EnvDecode::Bool).unwrap(), json!(true));
288        assert_eq!(decode("T", EnvDecode::Bool).unwrap(), json!(true));
289        assert!(decode("yes", EnvDecode::Bool).is_err());
290        assert_eq!(decode("9125", EnvDecode::Integer).unwrap(), json!(9125));
291        assert_eq!(decode("9125.0", EnvDecode::Integer).unwrap(), json!(9125));
292        assert!(decode("9125.5", EnvDecode::Integer).is_err());
293        assert_eq!(decode("1.5", EnvDecode::Float).unwrap(), json!(1.5));
294    }
295
296    #[test]
297    fn string_list_follows_the_go_slice_cast() {
298        for (raw, expected) in cast_de::STRING_LIST_CASES {
299            assert_eq!(decode(raw, EnvDecode::StringList).unwrap(), arr(expected), "{raw:?}");
300        }
301    }
302
303    #[test]
304    fn integer_reads_the_go_base_prefixes() {
305        for (raw, expected) in [
306            ("0x10", 16),
307            ("010", 8),
308            ("0b11", 3),
309            ("0o17", 15),
310            ("1_000", 1000),
311            ("-0x10", -16),
312        ] {
313            assert_eq!(decode(raw, EnvDecode::Integer).unwrap(), json!(expected), "{raw}");
314        }
315        // An invalid octal digit reads as 0, as with the Agent's integer getter.
316        assert_eq!(decode("08", EnvDecode::Integer).unwrap(), json!(0));
317        assert!(decode("08.", EnvDecode::Integer).is_err());
318    }
319
320    #[test]
321    fn float_reads_go_literals() {
322        assert_eq!(decode("0x1p4", EnvDecode::Float).unwrap(), json!(16.0));
323        assert_eq!(decode("1_000.5", EnvDecode::Float).unwrap(), json!(1000.5));
324        assert!(decode("0x10", EnvDecode::Float).is_err());
325        assert!(decode("inf", EnvDecode::Float).is_err());
326    }
327
328    #[test]
329    fn decimal_integer_keeps_a_leading_zero_decimal() {
330        assert_eq!(decode("010", EnvDecode::DecimalInteger).unwrap(), json!(10));
331        assert_eq!(decode("9125.0", EnvDecode::DecimalInteger).unwrap(), json!(9125));
332        for rejected in ["0x10", "0b11", "1_000", "9125.5"] {
333            assert!(decode(rejected, EnvDecode::DecimalInteger).is_err(), "{rejected}");
334        }
335    }
336
337    #[test]
338    fn decimal_float_has_no_go_only_spellings() {
339        assert_eq!(decode("1.5e1", EnvDecode::DecimalFloat).unwrap(), json!(15.0));
340        for rejected in ["0x1p4", "1_000.5", "inf"] {
341            assert!(decode(rejected, EnvDecode::DecimalFloat).is_err(), "{rejected}");
342        }
343    }
344
345    #[test]
346    fn whitespace_list_never_reads_json() {
347        assert_eq!(
348            decode("env:prod  team:core", EnvDecode::WhitespaceList).unwrap(),
349            arr(&["env:prod", "team:core"])
350        );
351        assert_eq!(
352            decode(r#"["a","b"]"#, EnvDecode::WhitespaceList).unwrap(),
353            arr(&[r#"["a","b"]"#])
354        );
355        assert_eq!(decode("   ", EnvDecode::WhitespaceList).unwrap(), Value::Array(vec![]));
356    }
357
358    #[test]
359    fn duration_is_carried_through_as_a_string() {
360        // A duration leaf stays shape-tolerant; the raw text reaches `duration_de` unchanged.
361        assert_eq!(decode("10s", EnvDecode::DurationString).unwrap(), json!("10s"));
362        assert_eq!(decode("30", EnvDecode::DurationString).unwrap(), json!("30"));
363    }
364}