use std::error::Error;
use std::fs;
use std::process::Command;
use std::sync::{Arc, Barrier};
use ch_config::environment::{self, EnvFiles};
use ch_config::{AppConfig, ConfigError, EnvironmentFileError};
fn run_fixture(case: &str) {
let output = Command::new(std::env::current_exe().unwrap())
.args(["--exact", "snapshot_fixture", "--ignored", "--nocapture"])
.env_clear()
.env("CONFIG_FIXTURE", case)
.env("PROCESS_BASE", "process")
.env("PROCESS_OVERRIDE", "process")
.output()
.unwrap();
assert!(
output.status.success(),
"fixture {case} failed:\n{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn file_precedence_expansion_and_child_environment() {
run_fixture("precedence");
}
#[test]
fn missing_files_still_initialize_process_configuration() {
run_fixture("missing");
}
#[test]
fn failed_loads_are_atomic_and_retryable() {
run_fixture("errors");
}
#[test]
fn secret_sources_share_the_loaded_configuration() {
run_fixture("secrets");
}
#[test]
fn concurrent_initialization_installs_one_complete_snapshot() {
run_fixture("concurrent");
}
#[test]
#[ignore = "invoked in an isolated process by the fixture tests"]
fn snapshot_fixture() {
let case = std::env::var("CONFIG_FIXTURE").unwrap();
if case == "child" {
assert_eq!(std::env::var("BASE_ONLY").unwrap(), "base");
assert_eq!(std::env::var("PROCESS_OVERRIDE").unwrap(), "override");
return;
}
let directory = tempfile::tempdir().unwrap();
let base = directory.path().join("base.env");
let overrides = directory.path().join("override.env");
let files = EnvFiles {
base: Some(base.clone()),
override_file: Some(overrides.clone()),
};
match case.as_str() {
"missing" => {
assert_eq!(files.load().unwrap(), environment::EnvLoad::default());
assert_eq!(environment::var("PROCESS_BASE").unwrap(), "process");
assert_eq!(files.load().unwrap(), environment::EnvLoad::default());
assert!(matches!(
EnvFiles::default().load(),
Err(ConfigError::EnvironmentAlreadyLoaded)
));
}
"precedence" => {
fs::write(
&base,
"BASE_ONLY=base\nPROCESS_BASE=base\nPROCESS_OVERRIDE=base\nEXPANDED=${PROCESS_BASE}/base\nRAW=' untrimmed '\n",
)
.unwrap();
fs::write(
&overrides,
"PROCESS_OVERRIDE=override\nMERGED=${BASE_ONLY}/${PROCESS_BASE}/${PROCESS_OVERRIDE}\n",
)
.unwrap();
let report = files.load().unwrap();
assert!(report.base && report.override_file);
assert_eq!(environment::var("PROCESS_BASE").unwrap(), "process");
assert_eq!(environment::var("PROCESS_OVERRIDE").unwrap(), "override");
assert_eq!(environment::var("EXPANDED").unwrap(), "process/base");
assert_eq!(environment::var("MERGED").unwrap(), "base/process/override");
assert_eq!(environment::var("RAW").unwrap(), " untrimmed ");
assert_eq!(
environment::value("RAW").unwrap().as_deref(),
Some("untrimmed")
);
assert!(std::env::var_os("BASE_ONLY").is_none());
assert_eq!(std::env::var("PROCESS_OVERRIDE").unwrap(), "process");
let status = Command::new(std::env::current_exe().unwrap())
.args(["--exact", "snapshot_fixture", "--ignored"])
.env_clear()
.envs(environment::vars_os())
.env("CONFIG_FIXTURE", "child")
.status()
.unwrap();
assert!(status.success());
fs::write(&base, "INVALID='unterminated").unwrap();
assert_eq!(files.load().unwrap(), report);
assert_eq!(environment::var("BASE_ONLY").unwrap(), "base");
assert!(matches!(
EnvFiles::default().load(),
Err(ConfigError::EnvironmentAlreadyLoaded)
));
}
"errors" => {
fs::write(&base, "PARTIAL=must-not-appear\n").unwrap();
for source in [
"SECRET='do-not-log-this\n",
"SECRET=do-not-log-this\0invalid\n",
] {
fs::write(&overrides, source).unwrap();
let error = files.load().unwrap_err();
assert!(matches!(
error,
ConfigError::Environment {
source: EnvironmentFileError::Syntax { .. },
..
}
));
assert_sanitized(&error, "do-not-log-this");
assert!(environment::var_os("PARTIAL").is_none());
assert!(std::env::var_os("PARTIAL").is_none());
}
fs::remove_file(&overrides).unwrap();
fs::create_dir(&overrides).unwrap();
assert!(matches!(
files.load(),
Err(ConfigError::Environment {
source: EnvironmentFileError::Io(_),
..
})
));
assert!(environment::var_os("PARTIAL").is_none());
fs::remove_dir(&overrides).unwrap();
fs::write(&overrides, "COMPLETE=yes\n").unwrap();
files.load().unwrap();
assert_eq!(environment::var("PARTIAL").unwrap(), "must-not-appear");
assert_eq!(environment::var("COMPLETE").unwrap(), "yes");
}
"secrets" => {
let secret = directory.path().join("database-secret");
fs::write(&secret, " mysql://user:secret@database.invalid/app \n").unwrap();
fs::write(
&base,
format!(
"DATABASE_URL_FILE='{}'\nCONFLICT=hidden\nCONFLICT_FILE='{}'\nEMPTY=' '\nUNREADABLE_FILE='{}'\n",
secret.display(),
secret.display(),
directory.path().join("absent").display()
),
)
.unwrap();
files.load().unwrap();
let config = AppConfig::from_env(AppConfig::default()).unwrap();
assert_eq!(
config.mariadb.url,
"mysql://user:secret@database.invalid/app"
);
assert!(std::env::var_os("DATABASE_URL_FILE").is_none());
assert!(matches!(
environment::value("CONFLICT"),
Err(ConfigError::VariableConflict { .. })
));
assert!(matches!(
environment::value("EMPTY"),
Err(ConfigError::VariableValue { .. })
));
assert!(matches!(
environment::value("UNREADABLE"),
Err(ConfigError::VariableFile { .. })
));
}
"concurrent" => {
fs::write(&base, "FIRST=one\n").unwrap();
fs::write(&overrides, "SECOND=two\n").unwrap();
let files = Arc::new(files);
let barrier = Arc::new(Barrier::new(8));
std::thread::scope(|scope| {
for _ in 0..8 {
let files = &files;
let barrier = &barrier;
scope.spawn(move || {
barrier.wait();
assert!(files.load().unwrap().any());
assert_eq!(environment::var("FIRST").unwrap(), "one");
assert_eq!(environment::var("SECOND").unwrap(), "two");
});
}
});
assert!(std::env::var_os("FIRST").is_none());
}
_ => panic!("unknown fixture"),
}
}
fn assert_sanitized(error: &(dyn Error + 'static), secret: &str) {
assert!(!format!("{error:?}").contains(secret));
assert!(!error.to_string().contains(secret));
if let Some(source) = error.source() {
assert_sanitized(source, secret);
}
}
#[cfg(unix)]
#[test]
fn unicode_errors_do_not_retain_the_rejected_value() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
if std::env::var_os("UNICODE_FIXTURE").is_some() {
let error = environment::var("INVALID_UNICODE").unwrap_err();
assert!(matches!(error, ConfigError::VariableUnicode { .. }));
assert_sanitized(&error, "hidden-unicode");
assert!(environment::var_os("INVALID_UNICODE").is_some());
return;
}
let status = Command::new(std::env::current_exe().unwrap())
.args(["--exact", "unicode_errors_do_not_retain_the_rejected_value"])
.env("UNICODE_FIXTURE", "1")
.env(
"INVALID_UNICODE",
OsString::from_vec(b"hidden-unicode\xff".to_vec()),
)
.status()
.unwrap();
assert!(status.success());
}