Skip to main content

ch_config/environment/
mod.rs

1//! Process-environment and dotenv-file loading for application startup.
2
3use std::collections::HashMap;
4use std::ffi::{OsStr, OsString};
5use std::fs;
6use std::net::SocketAddr;
7use std::path::PathBuf;
8use std::sync::OnceLock;
9
10use crate::config::{ConfigError, Result};
11
12/// Resolves a trimmed environment value or its mutually exclusive `_FILE`
13/// companion.
14///
15/// Empty values, non-Unicode data, conflicting sources, and unreadable secret
16/// files are reported without retaining the configured value in the error.
17pub fn value(variable: &'static str) -> Result<Option<String>> {
18    let direct = read_variable(variable)?;
19    let file = read_variable(&format!("{variable}_FILE"))?;
20    let value = match (direct, file) {
21        (Some(_), Some(_)) => return Err(ConfigError::VariableConflict { variable }),
22        (Some(value), None) => Some(value),
23        (None, Some(path)) => Some(
24            fs::read_to_string(path)
25                .map_err(|source| ConfigError::VariableFile { variable, source })?,
26        ),
27        (None, None) => None,
28    };
29    value
30        .map(|value| {
31            let trimmed = value.trim();
32            if trimmed.is_empty() {
33                Err(ConfigError::VariableValue {
34                    variable,
35                    message: "must not be empty",
36                })
37            } else if trimmed.len() == value.len() {
38                Ok(value)
39            } else {
40                Ok(trimmed.to_owned())
41            }
42        })
43        .transpose()
44}
45
46// One immutable configuration source per application process. Parsing finishes
47// before installation, so concurrent readers never see a partially loaded file.
48static CONFIGURATION: OnceLock<LoadedFiles> = OnceLock::new();
49
50struct LoadedFiles {
51    files: EnvFiles,
52    report: EnvLoad,
53    values: HashMap<OsString, OsString>,
54}
55
56/// Reads a raw value from the startup snapshot or the process environment.
57/// Unlike [`value`], this preserves whitespace and does not read `_FILE` companions.
58/// Errors never contain the configured value.
59pub fn var(variable: &str) -> Result<String> {
60    var_os(variable)
61        .ok_or_else(|| ConfigError::VariableMissing {
62            variable: variable.to_owned(),
63        })?
64        .into_string()
65        .map_err(|_| ConfigError::VariableUnicode {
66            variable: variable.to_owned(),
67        })
68}
69
70/// Reads a raw OS value from the same source as [`var`].
71pub fn var_os(variable: impl AsRef<OsStr>) -> Option<OsString> {
72    match CONFIGURATION.get() {
73        Some(configuration) => configuration
74            .values
75            .get(&environment_key(variable.as_ref()))
76            .cloned(),
77        None => std::env::var_os(variable),
78    }
79}
80
81/// Returns the resolved startup environment for a child process.
82/// Pass these bindings to `std::process::Command::envs`.
83pub fn vars_os() -> impl Iterator<Item = (OsString, OsString)> {
84    match CONFIGURATION.get() {
85        Some(configuration) => configuration.values.clone(),
86        None => std::env::vars_os().collect(),
87    }
88    .into_iter()
89}
90
91fn environment_key(key: &OsStr) -> OsString {
92    #[cfg(windows)]
93    if let Some(key) = key.to_str() {
94        return OsString::from(key.to_ascii_uppercase());
95    }
96    key.to_owned()
97}
98
99fn read_variable(variable: &str) -> Result<Option<String>> {
100    match var(variable) {
101        Ok(value) => Ok(Some(value)),
102        Err(ConfigError::VariableMissing { .. }) => Ok(None),
103        Err(error) => Err(error),
104    }
105}
106
107/// Resolves an optional boolean environment value.
108pub fn flag(variable: &'static str) -> Result<Option<bool>> {
109    value(variable)?
110        .map(|value| match value.to_ascii_lowercase().as_str() {
111            "1" | "true" | "yes" | "on" => Ok(true),
112            "0" | "false" | "no" | "off" => Ok(false),
113            _ => Err(ConfigError::VariableValue {
114                variable,
115                message: "must be a boolean value",
116            }),
117        })
118        .transpose()
119}
120
121/// Resolves and validates the conventional `PUBLIC_URL` value.
122///
123/// The URL must use HTTP or HTTPS, include a host, omit credentials, query,
124/// and fragment components, and is normalized without a trailing slash.
125pub fn public_url() -> Result<Option<url::Url>> {
126    value("PUBLIC_URL")?.map(parse_public_url).transpose()
127}
128
129/// Resolves `PUBLIC_URL` or returns a loopback URL using the listener port.
130pub fn public_url_or_local(bind_addr: SocketAddr) -> Result<url::Url> {
131    public_url()?.map_or_else(
132        || {
133            url::Url::parse(&format!("http://127.0.0.1:{}", bind_addr.port())).map_err(|_| {
134                ConfigError::validation("public_url", "the generated local HTTP URL must be valid")
135            })
136        },
137        Ok,
138    )
139}
140
141fn parse_public_url(value: String) -> Result<url::Url> {
142    let mut url = crate::config::parse_network_url(
143        "public_url",
144        &value,
145        &["http", "https"],
146        false,
147        "must be an HTTP or HTTPS URL with a host and no embedded credentials",
148    )?;
149    if url.query().is_some() || url.fragment().is_some() {
150        return Err(ConfigError::validation(
151            "public_url",
152            "must not contain a query or fragment",
153        ));
154    }
155
156    let normalized_path = url.path().trim_end_matches('/').to_owned();
157    url.set_path(&normalized_path);
158    Ok(url)
159}
160
161/// Environment files selected by the application.
162///
163/// Missing files are ignored so production deployments can use process-level
164/// variables exclusively. The optional override replaces base and process values
165/// in an immutable configuration snapshot. The OS process environment is untouched.
166#[derive(Clone, Debug, Default, PartialEq, Eq)]
167pub struct EnvFiles {
168    /// Base dotenv file.
169    pub base: Option<PathBuf>,
170    /// Local or deployment-specific override file.
171    pub override_file: Option<PathBuf>,
172}
173
174impl EnvFiles {
175    /// Atomically installs one immutable startup configuration.
176    ///
177    /// Read it through this module's accessors and pass [`vars_os`] to child
178    /// processes. Repeating the same selection returns the existing report.
179    /// Selecting different files after successful initialization returns an error.
180    /// A failed load leaves the resolver and the process environment unchanged.
181    pub fn load(&self) -> Result<EnvLoad> {
182        if let Some(configuration) = CONFIGURATION.get() {
183            return if configuration.files == *self {
184                Ok(configuration.report)
185            } else {
186                Err(ConfigError::EnvironmentAlreadyLoaded)
187            };
188        }
189        let mut values = std::env::vars_os()
190            .map(|(key, value)| (environment_key(&key), value))
191            .collect::<HashMap<_, _>>();
192        let mut report = EnvLoad::default();
193        for (path, is_override) in [(&self.base, false), (&self.override_file, true)] {
194            let Some(path) = path else { continue };
195            let source = match fs::read_to_string(path) {
196                Ok(source) => source,
197                Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
198                Err(source) => {
199                    return Err(ConfigError::Environment {
200                        path: path.clone(),
201                        source: crate::EnvironmentFileError::Io(source),
202                    });
203                }
204            };
205            let mut loader = dotenv::EnvLoader::with_reader(source.as_bytes()).substitution(true);
206            if is_override {
207                loader = loader
208                    .sequence(dotenv::EnvSequence::InputOnly)
209                    .substitutions(values.iter().filter_map(|(key, value)| {
210                        Some((key.to_str()?.to_owned(), value.to_str()?.to_owned()))
211                    }));
212            } else {
213                loader = loader.sequence(dotenv::EnvSequence::InputThenEnv);
214            }
215            let parsed = loader
216                .load()
217                .map_err(|error| ConfigError::environment(path.clone(), error))?;
218            values.extend(
219                parsed
220                    .into_iter()
221                    .map(|(key, value)| (environment_key(OsStr::new(&key)), value.into())),
222            );
223            if is_override {
224                report.override_file = true
225            } else {
226                report.base = true
227            }
228        }
229        let configuration = CONFIGURATION.get_or_init(|| LoadedFiles {
230            files: self.clone(),
231            report,
232            values,
233        });
234        if configuration.files == *self {
235            Ok(configuration.report)
236        } else {
237            Err(ConfigError::EnvironmentAlreadyLoaded)
238        }
239    }
240}
241
242/// Reports which explicitly selected dotenv files existed and were loaded.
243#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
244pub struct EnvLoad {
245    /// Whether the base file was loaded.
246    pub base: bool,
247    /// Whether the override file was loaded.
248    pub override_file: bool,
249}
250
251impl EnvLoad {
252    /// Returns whether at least one file was loaded.
253    pub const fn any(self) -> bool {
254        self.base || self.override_file
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use std::error::Error as _;
261    use std::fs;
262    use std::sync::atomic::{AtomicU64, Ordering};
263
264    use super::*;
265    use crate::EnvironmentFileError;
266
267    static FILE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
268
269    #[test]
270    fn load_report_combines_results() {
271        assert!(!EnvLoad::default().any());
272        assert!(
273            EnvLoad {
274                base: true,
275                override_file: false,
276            }
277            .any()
278        );
279    }
280
281    #[test]
282    fn public_urls_are_validated_and_normalized() {
283        let url = parse_public_url("https://example.invalid/app/".to_owned())
284            .expect("valid public URL should resolve");
285        assert_eq!(url.as_str(), "https://example.invalid/app");
286
287        for invalid in [
288            "ftp://example.invalid",
289            "https://user:secret@example.invalid",
290            "https://example.invalid?debug=true",
291            "https://example.invalid#fragment",
292        ] {
293            assert!(
294                parse_public_url(invalid.to_owned()).is_err(),
295                "{invalid} should be rejected"
296            );
297        }
298    }
299
300    #[test]
301    fn malformed_file_reports_a_sanitized_typed_source() {
302        let path = std::env::temp_dir().join(format!(
303            "explicit-env-{}-{}.env",
304            std::process::id(),
305            FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed)
306        ));
307        fs::write(&path, "API_TOKEN='do-not-log-this\n")
308            .expect("invalid dotenv fixture should be writable");
309
310        let error = EnvFiles {
311            base: Some(path.clone()),
312            override_file: None,
313        }
314        .load()
315        .expect_err("malformed dotenv should fail");
316        fs::remove_file(path).ok();
317
318        let display = error.to_string();
319        let debug = format!("{error:?}");
320        assert!(!display.contains("do-not-log-this"));
321        assert!(!debug.contains("do-not-log-this"));
322        assert!(matches!(
323            error
324                .source()
325                .and_then(|source| source.downcast_ref::<EnvironmentFileError>()),
326            Some(EnvironmentFileError::Syntax { .. })
327        ));
328    }
329}