1use 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
12pub 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
46static CONFIGURATION: OnceLock<LoadedFiles> = OnceLock::new();
49
50struct LoadedFiles {
51 files: EnvFiles,
52 report: EnvLoad,
53 values: HashMap<OsString, OsString>,
54}
55
56pub 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
70pub 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
81pub 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
107pub 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
121pub fn public_url() -> Result<Option<url::Url>> {
126 value("PUBLIC_URL")?.map(parse_public_url).transpose()
127}
128
129pub 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#[derive(Clone, Debug, Default, PartialEq, Eq)]
167pub struct EnvFiles {
168 pub base: Option<PathBuf>,
170 pub override_file: Option<PathBuf>,
172}
173
174impl EnvFiles {
175 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
244pub struct EnvLoad {
245 pub base: bool,
247 pub override_file: bool,
249}
250
251impl EnvLoad {
252 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}