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.
13//!
14//! One type is intentionally left as a string: a `Duration` leaf keeps its raw text and is parsed by
15//! `crate::duration_de` at deserialize time, because a duration also arrives from the file (a Go
16//! duration string) and from the Agent stream (integer nanoseconds), so that leaf must stay
17//! shape-tolerant regardless of the environment.
18
19use serde_json::{Map, Number, Value};
20
21/// How to decode a raw environment string into a JSON value for one leaf.
22///
23/// The named-parser variants mirror the schema's `env_parser`; the rest are the type-based fallback
24/// the Agent applies when no `env_parser` is declared, keyed by the leaf's declared type.
25#[derive(Clone, Copy, Debug, Eq, PartialEq)]
26pub enum EnvDecode {
27    /// `env_parser: json`: parse the string as a JSON document.
28    Json,
29    /// `env_parser: comma_separated`: split on `,`, no trimming.
30    CommaSeparated,
31    /// `env_parser: comma_and_space_separated`: split on `,` or space, dropping empties.
32    CommaAndSpaceSeparated,
33    /// `env_parser: comma_then_space_separated`: split on `,` if present else space, trimmed.
34    CommaThenSpaceSeparated,
35    /// `env_parser: json_list_or_comma_separated`: a `[...]` JSON list, else split on `,`.
36    JsonListOrCommaSeparated,
37    /// `env_parser: json_list_or_space_separated`: a `[...]` JSON list, else split on space.
38    JsonListOrSpaceSeparated,
39    /// `env_parser: traces_span`: `svc|op=rate,...` into a `{name: rate}` object.
40    TracesSpan,
41    /// Type fallback: a plain string, taken verbatim.
42    RawString,
43    /// Type fallback: a boolean, parsed with Go's `strconv.ParseBool` grammar.
44    Bool,
45    /// Type fallback: a signed integer.
46    Integer,
47    /// Type fallback: a floating-point number.
48    Float,
49    /// Type fallback: a string list, split on whitespace (the Agent's `[]string` cast).
50    StringList,
51    /// Type fallback: a duration, carried through as a string for `crate::duration_de`.
52    DurationString,
53    /// Type fallback: a map/object (or any non-scalar), parsed as a JSON document.
54    JsonValue,
55}
56
57/// Decodes `raw` into the JSON value `how` prescribes.
58///
59/// # Errors
60///
61/// Returns a human-readable message when `raw` is malformed for `how` (invalid JSON, a bad boolean
62/// or number, or a `traces_span` token that is not `name=rate`). The caller pairs it with the
63/// environment variable name.
64pub fn decode(raw: &str, how: EnvDecode) -> Result<Value, String> {
65    match how {
66        EnvDecode::Json | EnvDecode::JsonValue => parse_json(raw),
67        EnvDecode::CommaSeparated => Ok(comma_separated(raw)),
68        EnvDecode::CommaAndSpaceSeparated => Ok(comma_and_space_separated(raw)),
69        EnvDecode::CommaThenSpaceSeparated => Ok(comma_then_space_separated(raw)),
70        EnvDecode::JsonListOrCommaSeparated => json_list_or_split(raw, ','),
71        EnvDecode::JsonListOrSpaceSeparated => json_list_or_split(raw, ' '),
72        EnvDecode::TracesSpan => traces_span(raw),
73        EnvDecode::RawString | EnvDecode::DurationString => Ok(Value::String(raw.to_string())),
74        EnvDecode::Bool => parse_bool(raw).map(Value::Bool),
75        EnvDecode::Integer => parse_integer(raw),
76        EnvDecode::Float => parse_float(raw),
77        EnvDecode::StringList => Ok(whitespace_list(raw)),
78    }
79}
80
81/// `env_parser: json` and the map/object type fallback (`ParseEnvJSON`, `config.go:734`).
82///
83/// The Agent decodes into the declared type; we parse to a generic `Value` and let the leaf's
84/// own deserializer enforce its shape. A parse error propagates (the Agent logs and yields the zero
85/// value).
86fn parse_json(raw: &str) -> Result<Value, String> {
87    serde_json::from_str(raw).map_err(|e| format!("invalid JSON: {e}"))
88}
89
90/// `env_parser: comma_separated` (`ParseEnvSplitComma`, `config.go:704`).
91///
92/// `strings.Split` on `,` with no trimming; an empty string yields an empty list (the Agent's
93/// explicit special case), not a one-element list containing `""`.
94fn comma_separated(raw: &str) -> Value {
95    if raw.is_empty() {
96        return Value::Array(Vec::new());
97    }
98    string_array(raw.split(','))
99}
100
101/// `env_parser: comma_and_space_separated` (`ParseEnvSplitCommaAndSpace`, `helper/env.go:87`).
102///
103/// `strings.FieldsFunc` on `,` or space: consecutive separators collapse and empty fields are
104/// dropped.
105fn comma_and_space_separated(raw: &str) -> Value {
106    string_array(raw.split([',', ' ']).filter(|s| !s.is_empty()))
107}
108
109/// `env_parser: comma_then_space_separated` (`ParseEnvSplitCommaThenSpace`, `helper/env.go:99`).
110///
111/// If the string contains a comma, split on commas only; otherwise split on spaces. Each element is
112/// trimmed, and empties are kept (`strings.Split` semantics), so `a,,b` yields three elements.
113fn comma_then_space_separated(raw: &str) -> Value {
114    let sep = if raw.contains(',') { ',' } else { ' ' };
115    string_array(raw.split(sep).map(str::trim))
116}
117
118/// `env_parser: json_list_or_{comma,space}_separated` (`jsonOrSplitBy`, `helper/env.go:120`).
119///
120/// A value delimited by `[` and `]` is parsed as a JSON string list; anything else is split on
121/// `sep`. The input is trimmed before the `[...]` test. A JSON list that fails to parse propagates
122/// an error (the Agent logs and yields nil).
123fn json_list_or_split(raw: &str, sep: char) -> Result<Value, String> {
124    let trimmed = raw.trim();
125    if trimmed.starts_with('[') && trimmed.ends_with(']') {
126        let list: Vec<String> = serde_json::from_str(trimmed).map_err(|e| format!("invalid JSON string list: {e}"))?;
127        return Ok(string_array(list));
128    }
129    Ok(string_array(trimmed.split(sep)))
130}
131
132/// `env_parser: traces_span` (`parseAnalyzedSpans`, `helper/env.go:35`).
133///
134/// `service|operation=rate,...` into `{ "service|operation": rate }`. An empty string yields an
135/// empty object; a token that is not `name=rate` with a numeric rate propagates an error.
136fn traces_span(raw: &str) -> Result<Value, String> {
137    let mut map = Map::new();
138    if raw.is_empty() {
139        return Ok(Value::Object(map));
140    }
141    for token in raw.split(',') {
142        let (name, rate) = token
143            .split_once('=')
144            .ok_or_else(|| format!("bad traces_span token `{token}`: expected name=rate"))?;
145        let rate: f64 = rate.parse().map_err(|_| format!("bad traces_span rate in `{token}`"))?;
146        let rate = Number::from_f64(rate).ok_or_else(|| format!("non-finite traces_span rate in `{token}`"))?;
147        map.insert(name.to_string(), Value::Number(rate));
148    }
149    Ok(Value::Object(map))
150}
151
152/// The `[]string` type fallback (`cast.ToStringSliceE`): split on whitespace, dropping empties.
153fn whitespace_list(raw: &str) -> Value {
154    string_array(raw.split_whitespace())
155}
156
157/// Boolean type fallback (`cast.ToBoolE` → `strconv.ParseBool`).
158///
159/// Accepts exactly Go's grammar: `1`, `t`, `T`, `TRUE`, `true`, `True`, and the false equivalents.
160fn parse_bool(raw: &str) -> Result<bool, String> {
161    match raw {
162        "1" | "t" | "T" | "TRUE" | "true" | "True" => Ok(true),
163        "0" | "f" | "F" | "FALSE" | "false" | "False" => Ok(false),
164        other => Err(format!("invalid boolean `{other}`")),
165    }
166}
167
168/// Integer type fallback (`cast.ToIntE`).
169fn parse_integer(raw: &str) -> Result<Value, String> {
170    raw.trim()
171        .parse::<i64>()
172        .map(|n| Value::Number(n.into()))
173        .map_err(|_| format!("invalid integer `{raw}`"))
174}
175
176/// Float type fallback (`cast.ToFloat64E`).
177fn parse_float(raw: &str) -> Result<Value, String> {
178    let n: f64 = raw.trim().parse().map_err(|_| format!("invalid number `{raw}`"))?;
179    Number::from_f64(n)
180        .map(Value::Number)
181        .ok_or_else(|| format!("non-finite number `{raw}`"))
182}
183
184/// Collects strings into a JSON array of strings.
185fn string_array<S: AsRef<str>, I: IntoIterator<Item = S>>(items: I) -> Value {
186    Value::Array(
187        items
188            .into_iter()
189            .map(|s| Value::String(s.as_ref().to_string()))
190            .collect(),
191    )
192}
193
194#[cfg(test)]
195mod tests {
196    use serde_json::json;
197
198    use super::*;
199
200    fn arr(items: &[&str]) -> Value {
201        json!(items)
202    }
203
204    #[test]
205    fn json_parses_arbitrary_documents() {
206        assert_eq!(decode(r#"["a","b"]"#, EnvDecode::Json).unwrap(), arr(&["a", "b"]));
207        assert_eq!(
208            decode(r#"{"k":["v"]}"#, EnvDecode::JsonValue).unwrap(),
209            json!({"k": ["v"]})
210        );
211        assert!(decode("{bad", EnvDecode::Json).is_err());
212    }
213
214    #[test]
215    fn comma_separated_does_not_trim() {
216        assert_eq!(decode("a, b", EnvDecode::CommaSeparated).unwrap(), arr(&["a", " b"]));
217        assert_eq!(decode("", EnvDecode::CommaSeparated).unwrap(), Value::Array(vec![]));
218    }
219
220    #[test]
221    fn comma_and_space_drops_empties() {
222        assert_eq!(
223            decode("a, b c,,d", EnvDecode::CommaAndSpaceSeparated).unwrap(),
224            arr(&["a", "b", "c", "d"])
225        );
226    }
227
228    #[test]
229    fn comma_then_space_keeps_empties_and_trims() {
230        assert_eq!(
231            decode("a,,b", EnvDecode::CommaThenSpaceSeparated).unwrap(),
232            arr(&["a", "", "b"])
233        );
234        assert_eq!(
235            decode("a b  c", EnvDecode::CommaThenSpaceSeparated).unwrap(),
236            arr(&["a", "b", "", "c"])
237        );
238    }
239
240    #[test]
241    fn json_list_or_comma_takes_either_branch() {
242        assert_eq!(
243            decode(r#"  ["a","b"] "#, EnvDecode::JsonListOrCommaSeparated).unwrap(),
244            arr(&["a", "b"])
245        );
246        assert_eq!(
247            decode("a,b", EnvDecode::JsonListOrCommaSeparated).unwrap(),
248            arr(&["a", "b"])
249        );
250        assert!(decode("[bad", EnvDecode::JsonListOrCommaSeparated).is_ok()); // no closing ], so split
251        assert!(decode(r#"["unterminated"#, EnvDecode::JsonListOrCommaSeparated).is_ok());
252        assert!(decode("[1,2]", EnvDecode::JsonListOrCommaSeparated).is_err()); // JSON list of non-strings
253    }
254
255    #[test]
256    fn json_list_or_space_splits_on_space() {
257        assert_eq!(
258            decode("a b", EnvDecode::JsonListOrSpaceSeparated).unwrap(),
259            arr(&["a", "b"])
260        );
261    }
262
263    #[test]
264    fn traces_span_builds_a_rate_map() {
265        assert_eq!(
266            decode("svc|op=0.5,other|op2=1", EnvDecode::TracesSpan).unwrap(),
267            json!({ "svc|op": 0.5, "other|op2": 1.0 })
268        );
269        assert_eq!(decode("", EnvDecode::TracesSpan).unwrap(), json!({}));
270        assert!(decode("svc|op", EnvDecode::TracesSpan).is_err());
271        assert!(decode("svc|op=notnum", EnvDecode::TracesSpan).is_err());
272    }
273
274    #[test]
275    fn scalar_fallbacks() {
276        assert_eq!(decode("hello", EnvDecode::RawString).unwrap(), json!("hello"));
277        assert_eq!(decode("true", EnvDecode::Bool).unwrap(), json!(true));
278        assert_eq!(decode("T", EnvDecode::Bool).unwrap(), json!(true));
279        assert!(decode("yes", EnvDecode::Bool).is_err());
280        assert_eq!(decode("9125", EnvDecode::Integer).unwrap(), json!(9125));
281        assert!(decode("9125.0", EnvDecode::Integer).is_err());
282        assert_eq!(decode("1.5", EnvDecode::Float).unwrap(), json!(1.5));
283    }
284
285    #[test]
286    fn string_list_splits_on_whitespace() {
287        assert_eq!(
288            decode("env:prod  team:core", EnvDecode::StringList).unwrap(),
289            arr(&["env:prod", "team:core"])
290        );
291        assert_eq!(decode("   ", EnvDecode::StringList).unwrap(), Value::Array(vec![]));
292    }
293
294    #[test]
295    fn duration_is_carried_through_as_a_string() {
296        // A duration leaf stays shape-tolerant; the raw text reaches `duration_de` unchanged.
297        assert_eq!(decode("10s", EnvDecode::DurationString).unwrap(), json!("10s"));
298        assert_eq!(decode("30", EnvDecode::DurationString).unwrap(), json!("30"));
299    }
300}