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