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}