use std::collections::HashMap;
use std::ffi::{OsStr, OsString};
use std::fs;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::OnceLock;
use crate::config::{ConfigError, Result};
pub fn value(variable: &'static str) -> Result<Option<String>> {
let direct = read_variable(variable)?;
let file = read_variable(&format!("{variable}_FILE"))?;
let value = match (direct, file) {
(Some(_), Some(_)) => return Err(ConfigError::VariableConflict { variable }),
(Some(value), None) => Some(value),
(None, Some(path)) => Some(
fs::read_to_string(path)
.map_err(|source| ConfigError::VariableFile { variable, source })?,
),
(None, None) => None,
};
value
.map(|value| {
let trimmed = value.trim();
if trimmed.is_empty() {
Err(ConfigError::VariableValue {
variable,
message: "must not be empty",
})
} else if trimmed.len() == value.len() {
Ok(value)
} else {
Ok(trimmed.to_owned())
}
})
.transpose()
}
static CONFIGURATION: OnceLock<LoadedFiles> = OnceLock::new();
struct LoadedFiles {
files: EnvFiles,
report: EnvLoad,
values: HashMap<OsString, OsString>,
}
pub fn var(variable: &str) -> Result<String> {
var_os(variable)
.ok_or_else(|| ConfigError::VariableMissing {
variable: variable.to_owned(),
})?
.into_string()
.map_err(|_| ConfigError::VariableUnicode {
variable: variable.to_owned(),
})
}
pub fn var_os(variable: impl AsRef<OsStr>) -> Option<OsString> {
match CONFIGURATION.get() {
Some(configuration) => configuration
.values
.get(&environment_key(variable.as_ref()))
.cloned(),
None => std::env::var_os(variable),
}
}
pub fn vars_os() -> impl Iterator<Item = (OsString, OsString)> {
match CONFIGURATION.get() {
Some(configuration) => configuration.values.clone(),
None => std::env::vars_os().collect(),
}
.into_iter()
}
fn environment_key(key: &OsStr) -> OsString {
#[cfg(windows)]
if let Some(key) = key.to_str() {
return OsString::from(key.to_ascii_uppercase());
}
key.to_owned()
}
fn read_variable(variable: &str) -> Result<Option<String>> {
match var(variable) {
Ok(value) => Ok(Some(value)),
Err(ConfigError::VariableMissing { .. }) => Ok(None),
Err(error) => Err(error),
}
}
pub fn flag(variable: &'static str) -> Result<Option<bool>> {
value(variable)?
.map(|value| match value.to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Ok(true),
"0" | "false" | "no" | "off" => Ok(false),
_ => Err(ConfigError::VariableValue {
variable,
message: "must be a boolean value",
}),
})
.transpose()
}
pub fn public_url() -> Result<Option<url::Url>> {
value("PUBLIC_URL")?.map(parse_public_url).transpose()
}
pub fn public_url_or_local(bind_addr: SocketAddr) -> Result<url::Url> {
public_url()?.map_or_else(
|| {
url::Url::parse(&format!("http://127.0.0.1:{}", bind_addr.port())).map_err(|_| {
ConfigError::validation("public_url", "the generated local HTTP URL must be valid")
})
},
Ok,
)
}
fn parse_public_url(value: String) -> Result<url::Url> {
let mut url = crate::config::parse_network_url(
"public_url",
&value,
&["http", "https"],
false,
"must be an HTTP or HTTPS URL with a host and no embedded credentials",
)?;
if url.query().is_some() || url.fragment().is_some() {
return Err(ConfigError::validation(
"public_url",
"must not contain a query or fragment",
));
}
let normalized_path = url.path().trim_end_matches('/').to_owned();
url.set_path(&normalized_path);
Ok(url)
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct EnvFiles {
pub base: Option<PathBuf>,
pub override_file: Option<PathBuf>,
}
impl EnvFiles {
pub fn load(&self) -> Result<EnvLoad> {
if let Some(configuration) = CONFIGURATION.get() {
return if configuration.files == *self {
Ok(configuration.report)
} else {
Err(ConfigError::EnvironmentAlreadyLoaded)
};
}
let mut values = std::env::vars_os()
.map(|(key, value)| (environment_key(&key), value))
.collect::<HashMap<_, _>>();
let mut report = EnvLoad::default();
for (path, is_override) in [(&self.base, false), (&self.override_file, true)] {
let Some(path) = path else { continue };
let source = match fs::read_to_string(path) {
Ok(source) => source,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(source) => {
return Err(ConfigError::Environment {
path: path.clone(),
source: crate::EnvironmentFileError::Io(source),
});
}
};
let mut loader = dotenv::EnvLoader::with_reader(source.as_bytes()).substitution(true);
if is_override {
loader = loader
.sequence(dotenv::EnvSequence::InputOnly)
.substitutions(values.iter().filter_map(|(key, value)| {
Some((key.to_str()?.to_owned(), value.to_str()?.to_owned()))
}));
} else {
loader = loader.sequence(dotenv::EnvSequence::InputThenEnv);
}
let parsed = loader
.load()
.map_err(|error| ConfigError::environment(path.clone(), error))?;
values.extend(
parsed
.into_iter()
.map(|(key, value)| (environment_key(OsStr::new(&key)), value.into())),
);
if is_override {
report.override_file = true
} else {
report.base = true
}
}
let configuration = CONFIGURATION.get_or_init(|| LoadedFiles {
files: self.clone(),
report,
values,
});
if configuration.files == *self {
Ok(configuration.report)
} else {
Err(ConfigError::EnvironmentAlreadyLoaded)
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct EnvLoad {
pub base: bool,
pub override_file: bool,
}
impl EnvLoad {
pub const fn any(self) -> bool {
self.base || self.override_file
}
}
#[cfg(test)]
mod tests {
use std::error::Error as _;
use std::fs;
use std::sync::atomic::{AtomicU64, Ordering};
use super::*;
use crate::EnvironmentFileError;
static FILE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
#[test]
fn load_report_combines_results() {
assert!(!EnvLoad::default().any());
assert!(
EnvLoad {
base: true,
override_file: false,
}
.any()
);
}
#[test]
fn public_urls_are_validated_and_normalized() {
let url = parse_public_url("https://example.invalid/app/".to_owned())
.expect("valid public URL should resolve");
assert_eq!(url.as_str(), "https://example.invalid/app");
for invalid in [
"ftp://example.invalid",
"https://user:secret@example.invalid",
"https://example.invalid?debug=true",
"https://example.invalid#fragment",
] {
assert!(
parse_public_url(invalid.to_owned()).is_err(),
"{invalid} should be rejected"
);
}
}
#[test]
fn malformed_file_reports_a_sanitized_typed_source() {
let path = std::env::temp_dir().join(format!(
"explicit-env-{}-{}.env",
std::process::id(),
FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed)
));
fs::write(&path, "API_TOKEN='do-not-log-this\n")
.expect("invalid dotenv fixture should be writable");
let error = EnvFiles {
base: Some(path.clone()),
override_file: None,
}
.load()
.expect_err("malformed dotenv should fail");
fs::remove_file(path).ok();
let display = error.to_string();
let debug = format!("{error:?}");
assert!(!display.contains("do-not-log-this"));
assert!(!debug.contains("do-not-log-this"));
assert!(matches!(
error
.source()
.and_then(|source| source.downcast_ref::<EnvironmentFileError>()),
Some(EnvironmentFileError::Syntax { .. })
));
}
}