ch-config 0.2.0

Typed configuration loading with validation and secret-file support
Documentation
//! Regression coverage for immutable startup configuration.

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};

// Each fixture gets its own process because startup configuration is immutable.
// Command::env supplies input without changing the test runner's environment.
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());

            // Loading again neither reparses nor replaces the startup snapshot.
            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());
}