Skip to main content

hocon/adapters/
env.rs

1//! Environment variables, and `.env` files, as HOCON config.
2//!
3//! This is the bulk-mount case: a whole prefixed namespace becomes a config
4//! subtree. Reading one variable needs nothing from here — HOCON's own
5//! `${?VAR}` already does that.
6//!
7//! # How names become paths
8//!
9//! `__` is the only thing that creates hierarchy. A single `_` stays part of
10//! the segment, and a literal `.` is key *text*, not a separator (spec F1.2):
11//!
12//! ```text
13//! APP_DB__MAX_CONN=10   ->  db.max_conn    (nested)
14//! APP_FOO.BAR=flat      ->  "foo.bar"      (one key that contains a dot)
15//! ```
16//!
17//! The two spellings are distinct paths, so they coexist: the first is read as
18//! `cfg.get_string("foo.bar")`, the second needs the quoted path
19//! `cfg.get_string("\"foo.bar\"")`. Segments are lowercased after mapping
20//! (F1.3).
21//!
22//! Two names that really do map to one path (`APP_A__B` and `APP_a__b`) are an
23//! error, not a last-wins, because environment iteration order is not
24//! deterministic (F1.6). A `.env` file has a definite line order, so there the
25//! later line wins (F0.7).
26//!
27//! # Non-UTF-8 entries
28//!
29//! [`load`] **errors** when an entry matching the mount prefix has a name or
30//! value that is not valid UTF-8 (spec F1.9b). A bulk mount is a request for a
31//! whole namespace, so omitting one key would produce a subtree that looks
32//! complete while the operator's setting is missing, and a stale config
33//! default would then win invisibly. It would also defeat F1.6: if the
34//! undecodable entry were dropped, a colliding name would silently win, making
35//! the surviving value depend on an encoding property of the *other* value —
36//! precisely the nondeterminism F1.6 exists to forbid.
37//!
38//! Entries that do not match the prefix are ignored regardless, so an
39//! undecodable variable elsewhere in the environment never fails a mount. That
40//! is the opposite of the `${VAR}` path, where an undecodable entry is simply
41//! absent (F1.9a) because `${?VAR}` explicitly means "optional".
42
43use std::collections::HashMap;
44
45use indexmap::IndexMap;
46
47use super::{config_from_object, AdapterError};
48use crate::value::{HoconValue, ScalarValue};
49use crate::Config;
50
51/// The double underscore that marks a path boundary; a single one stays part
52/// of the segment, so `APP_DB__MAX_CONN` is `db.max_conn` (spec F1.2). Fixed
53/// rather than configurable so every language's adapter nests identically.
54const SEPARATOR: &str = "__";
55
56/// Ceiling on mapped path depth. `set_nested` and the resulting tree's `Drop`
57/// are both recursive, so an unbounded depth overflows the stack and aborts
58/// the process. Nothing legitimate nests this far.
59const MAX_DEPTH: usize = 64;
60
61/// How variable names become config paths.
62#[derive(Debug, Clone, Default)]
63pub struct Options {
64    /// Selects which variables to mount, and is stripped from the path.
65    /// Required by [`load`]: mounting the whole environment would pull in
66    /// PATH, HOME and whatever secrets happen to be set (spec F1.1).
67    /// [`parse_dotenv`] allows it to be empty, a `.env` file being a closed
68    /// set the caller chose deliberately.
69    pub prefix: String,
70    /// Source name for error messages.
71    pub origin: Option<String>,
72}
73
74/// Mount a prefixed slice of the process environment.
75///
76/// An entry that matches the prefix but whose name or value is not valid UTF-8
77/// is an **error** (spec F1.9b). A bulk mount is the caller asking for a whole
78/// namespace, so dropping one key would hand back a subtree that looks
79/// complete while the operator's setting is missing — and a stale config
80/// default would then win with no signal anywhere. Entries that do *not* match
81/// the prefix are ignored whether they decode or not, which bounds this to
82/// variables the caller named: an unrelated undecodable entry elsewhere in the
83/// environment can never fail the mount.
84pub fn load(opts: Options) -> Result<Config, AdapterError> {
85    if opts.prefix.is_empty() {
86        return Err(AdapterError::new(
87            "env: a prefix is required when mounting the environment (spec F1.1)",
88        ));
89    }
90    // Filter while iterating rather than collecting the whole environment
91    // first: everything else is never used, and some of it is secret. The
92    // prefix test runs on the raw bytes so an undecodable *name* can still be
93    // matched against the (ASCII) prefix — ASCII bytes survive
94    // `as_encoded_bytes` unchanged on every platform.
95    let mut vars: HashMap<String, String> = HashMap::new();
96    for e in crate::sysenv::entries() {
97        if !e.name_bytes.starts_with(opts.prefix.as_bytes()) {
98            continue;
99        }
100        let undecodable = match (&e.name, &e.value) {
101            (None, _) => "name",
102            (Some(_), None) => "value",
103            (Some(name), Some(value)) => {
104                vars.insert(name.clone(), value.clone());
105                continue;
106            }
107        };
108        return Err(AdapterError::new(format!(
109            "env: {} matches the mount prefix {:?} but its {undecodable} is not valid UTF-8; \
110             a bulk mount cannot silently omit it (spec F1.9)",
111            e.display_name(),
112            opts.prefix,
113        )));
114    }
115    load_from(&vars, opts)
116}
117
118/// Mount a prefixed slice of the supplied variables. Used by [`load`], and by
119/// tests that must not touch the real environment.
120pub fn load_from(vars: &HashMap<String, String>, opts: Options) -> Result<Config, AdapterError> {
121    if opts.prefix.is_empty() {
122        return Err(AdapterError::new(
123            "env: a prefix is required when mounting the environment (spec F1.1)",
124        ));
125    }
126
127    // Sorted so a collision is reported the same way on every run.
128    let mut names: Vec<&String> = vars.keys().collect();
129    names.sort();
130
131    // Keyed by the segment list itself, so only identical segment lists can
132    // collide — never two distinct paths that happen to share a rendering.
133    let mut seen: HashMap<Vec<String>, &str> = HashMap::new();
134    let mut pairs: Vec<(Vec<String>, String)> = Vec::new();
135    for name in names {
136        let Some(rest) = name.strip_prefix(&opts.prefix) else {
137            continue;
138        };
139        let path = to_path(rest, name)?;
140        if let Some(prev) = seen.get(&path) {
141            // F1.6: two names can reach one path and the environment has no
142            // meaningful order to break the tie with, so neither wins.
143            return Err(AdapterError::new(format!(
144                "env: {prev} and {name} both map to {}",
145                display_path(&path)
146            )));
147        }
148        seen.insert(path.clone(), name);
149        pairs.push((path, vars[name].clone()));
150    }
151
152    let origin = opts.origin.as_deref().unwrap_or("environment variables");
153    Ok(config_from_object(nest(pairs), Some(origin)))
154}
155
156/// Read `.env` file content.
157///
158/// The dialect is deliberately small (spec F1.7): `NAME=value`, an optional
159/// `export ` prefix, whole-line `#` comments, single quotes taken literally,
160/// double quotes with `\n \r \t \\ \"`. Multi-line values and trailing
161/// comments are unsupported — an unquoted value containing ` #` is an error
162/// rather than a guess. No `${...}` expansion.
163pub fn parse_dotenv(input: &str, opts: Options) -> Result<Config, AdapterError> {
164    let origin = opts.origin.clone().unwrap_or_else(|| ".env".to_string());
165    let mut pairs: Vec<(Vec<String>, String)> = Vec::new();
166
167    let normalized = super::strip_bom(input)
168        .replace("\r\n", "\n")
169        .replace('\r', "\n");
170    for (i, raw) in normalized.split('\n').enumerate() {
171        let line = raw.trim();
172        if line.is_empty() || line.starts_with('#') {
173            continue;
174        }
175        let line = line.strip_prefix("export ").unwrap_or(line);
176        let Some((name, rest)) = line.split_once('=') else {
177            return Err(AdapterError::new(format!(
178                "{origin}:{}: expected NAME=value",
179                i + 1
180            )));
181        };
182        let name = name.trim();
183        if name.is_empty() {
184            return Err(AdapterError::new(format!(
185                "{origin}:{}: empty variable name",
186                i + 1
187            )));
188        }
189        let value = dotenv_value(rest.trim_start_matches([' ', '\t']), &origin, i + 1, name)?;
190        let Some(stripped) = name.strip_prefix(&opts.prefix) else {
191            continue;
192        };
193        pairs.push((to_path(stripped, name)?, value));
194    }
195
196    // A file has a definite line order, so a repeated name is last-wins (F0.7)
197    // rather than the collision error the environment gets.
198    Ok(config_from_object(nest(pairs), Some(&origin)))
199}
200
201/// Strip the prefix, split on `__`, lowercase each segment (F1.2, F1.3).
202///
203/// The path stays a **segment list** from here to the built tree: joining on
204/// `.` and re-splitting later would turn a literal `.` inside a variable name
205/// into a manufactured boundary, when F1.2 says only `__` creates hierarchy.
206/// `APP_FOO.BAR` is the single top-level key `"foo.bar"`, distinct from
207/// `APP_FOO__BAR`'s `foo` → `bar`.
208fn to_path(rest: &str, name: &str) -> Result<Vec<String>, AdapterError> {
209    // ASCII-only folding (F1.3). Rust's `to_lowercase` applies the full
210    // Unicode mapping, which turns `İ` (U+0130) into `i` + U+0307 while Go's
211    // simple mapping yields plain `i` — that difference decides whether
212    // `APP_İ` collides with `APP_I`, so the mapping is pinned here rather than
213    // inherited from the stdlib. Environment names are ASCII in practice.
214    let segs: Vec<String> = rest
215        .split(SEPARATOR)
216        .map(|s| s.to_ascii_lowercase())
217        .collect();
218    if segs.iter().any(|s| s.is_empty()) {
219        return Err(AdapterError::new(format!(
220            "env: \"{name}\" produces an empty path segment"
221        )));
222    }
223    // Depth cap. `set_nested` recurses once per segment and the resulting tree
224    // drops recursively, so an unbounded segment count overflows the stack —
225    // which aborts the process rather than raising a catchable panic. Linux
226    // allows a 128 KiB environment entry and `parse_dotenv` takes arbitrary
227    // file text, so this is reachable from input. A path this deep is a
228    // mistake in every real config, and an error says so.
229    if segs.len() > MAX_DEPTH {
230        return Err(AdapterError::new(format!(
231            "env: \"{name}\" maps to a path {} segments deep, over the limit of {MAX_DEPTH}",
232            segs.len()
233        )));
234    }
235    Ok(segs)
236}
237
238/// Render a segment list as a HOCON path expression for an error message.
239///
240/// A segment is written bare when it is safely unquoted (`[a-z0-9_-]`, the
241/// shape F1.3 lowercasing produces) and double-quoted otherwise, with `\` and
242/// `"` escaped so two different paths can never render identically. Matches
243/// py.hocon's format so the four implementations report collisions alike.
244fn display_path(segments: &[String]) -> String {
245    segments
246        .iter()
247        .map(|s| {
248            let bare = !s.is_empty()
249                && s.chars()
250                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-');
251            if bare {
252                s.clone()
253            } else {
254                format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
255            }
256        })
257        .collect::<Vec<_>>()
258        .join(".")
259}
260
261/// Nest segment-list paths, applying the objects-win rule over the whole set
262/// so the outcome does not depend on input order (spec F1.8, mirroring F2.5).
263/// The sort is stable, so equal paths keep their line order and F0.7
264/// last-wins still holds for `.env` files.
265fn nest(mut pairs: Vec<(Vec<String>, String)>) -> HoconValue {
266    pairs.sort_by(|a, b| a.0.cmp(&b.0));
267    let mut root: IndexMap<String, HoconValue> = IndexMap::new();
268    for (path, value) in pairs {
269        set_nested(
270            &mut root,
271            &path,
272            HoconValue::Scalar(ScalarValue::string(value)),
273        );
274    }
275    HoconValue::Object(root)
276}
277
278fn set_nested(map: &mut IndexMap<String, HoconValue>, segments: &[String], value: HoconValue) {
279    if segments.is_empty() {
280        return;
281    }
282    if segments.len() == 1 {
283        if !matches!(map.get(&segments[0]), Some(HoconValue::Object(_))) {
284            map.insert(segments[0].clone(), value);
285        }
286        return;
287    }
288    let entry = map
289        .entry(segments[0].clone())
290        .or_insert_with(|| HoconValue::Object(IndexMap::new()));
291    if !matches!(entry, HoconValue::Object(_)) {
292        *entry = HoconValue::Object(IndexMap::new());
293    }
294    if let HoconValue::Object(inner) = entry {
295        set_nested(inner, &segments[1..], value);
296    }
297}
298
299fn dotenv_value(v: &str, origin: &str, line: usize, name: &str) -> Result<String, AdapterError> {
300    let fail = |msg: &str| AdapterError::new(format!("{origin}:{line}: {name}: {msg}"));
301
302    if let Some(rest) = v.strip_prefix('\'') {
303        let Some(end) = rest.find('\'') else {
304            return Err(fail(
305                "unterminated ' quote (multi-line values are not supported)",
306            ));
307        };
308        if !rest[end + 1..].trim().is_empty() {
309            return Err(fail("unexpected text after the closing quote"));
310        }
311        return Ok(rest[..end].to_string());
312    }
313
314    if let Some(rest) = v.strip_prefix('"') {
315        let mut out = String::new();
316        let chars: Vec<char> = rest.chars().collect();
317        let mut i = 0;
318        while i < chars.len() {
319            match chars[i] {
320                '"' => {
321                    let tail: String = chars[i + 1..].iter().collect();
322                    if !tail.trim().is_empty() {
323                        return Err(fail("unexpected text after the closing quote"));
324                    }
325                    return Ok(out);
326                }
327                '\\' => {
328                    i += 1;
329                    match chars.get(i) {
330                        Some('n') => out.push('\n'),
331                        Some('r') => out.push('\r'),
332                        Some('t') => out.push('\t'),
333                        Some('\\') => out.push('\\'),
334                        Some('"') => out.push('"'),
335                        Some(other) => {
336                            return Err(fail(&format!(
337                                "unknown escape \\{other} (supported: \\n \\r \\t \\\\ \\\")"
338                            )))
339                        }
340                        None => return Err(fail("dangling \\ at end of line")),
341                    }
342                }
343                c => out.push(c),
344            }
345            i += 1;
346        }
347        return Err(fail(
348            "unterminated \" quote (multi-line values are not supported)",
349        ));
350    }
351
352    let trimmed = v.trim_end_matches([' ', '\t']);
353    let bytes = trimmed.as_bytes();
354    for i in 1..bytes.len() {
355        if bytes[i] == b'#' && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t') {
356            return Err(fail(&format!(
357                "ambiguous value \"{trimmed}\": trailing comments are not supported, so quote the value if the # belongs to it"
358            )));
359        }
360    }
361    Ok(trimmed.to_string())
362}