// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! Configuration loading, validation, and path resolution. //! //! Configuration is a TOML file plus environment overrides, merged with //! [`figment`]. The resolution order (later wins) is: //! //! 1. built-in defaults ([`Config::default`]); //! 2. `/etc/fabrica/fabrica.toml`; //! 3. `$XDG_CONFIG_HOME/fabrica/fabrica.toml`; //! 4. an explicit `--config `; //! 5. `FABRICA__*` environment variables (nested keys use `__`, e.g. //! `FABRICA__SERVER__PORT=8080`). //! //! Missing files at the fixed locations are skipped; a missing `--config` path //! is likewise tolerated so the same code path serves first-run. //! //! Any `*_file` key (`auth.secret_file`, `mail.password_file`) is read from disk //! after merging and supersedes its inline sibling. Secrets are wrapped in //! [`Secret`], which redacts itself in every `Debug`/`Serialize` context, so //! neither logging nor `fabrica config show` can leak them. mod secret; mod types; use std::path::{Path, PathBuf}; use figment::Figment; use figment::providers::{Env, Format, Serialized, Toml}; pub use crate::secret::{REDACTED, Secret}; pub use crate::types::{ Auth, Captcha, CaptchaProvider, Config, Database, DefaultVisibility, Git, Instance, Log, LogFormat, LogLevel, Mail, MailBackend, MailEncryption, S3, Search, Server, Ssh, Storage, StorageBackend, Ui, }; /// System-wide configuration path, the lowest-priority file source. const ETC_PATH: &str = "/etc/fabrica/fabrica.toml"; /// Prefix and nesting separator for environment overrides. const ENV_PREFIX: &str = "FABRICA__"; /// Errors produced while loading or validating configuration. #[derive(Debug, thiserror::Error)] pub enum ConfigError { /// A file could not be parsed, a value had the wrong type, or an unknown key /// was present. Boxed because `figment::Error` is large relative to the /// other variants. #[error("failed to load configuration: {0}")] Load(Box), /// A `*_file` secret could not be read. The path is included; the file's /// contents never are. #[error("failed to read secret file {path}: {source}")] SecretFile { /// The path that could not be read. path: PathBuf, /// The underlying I/O error. source: std::io::Error, }, /// Validation failed. Each line is a distinct, actionable problem. #[error("invalid configuration:\n{0}")] Invalid(String), /// The effective configuration could not be serialized for display. #[error("failed to render configuration: {0}")] Render(#[from] toml::ser::Error), } /// A successfully loaded configuration together with any non-fatal warnings. #[derive(Debug, Clone)] pub struct Loaded { /// The validated, effective configuration. pub config: Config, /// Non-fatal advisories (e.g. an insecure cookie setting). The caller is /// expected to surface these to the operator. pub warnings: Vec, } /// Load configuration from all standard sources, applying the `--config` /// override when supplied. /// /// # Errors /// /// Returns [`ConfigError`] if a source fails to parse, an unknown key is /// present, a `*_file` secret cannot be read, or validation fails. pub fn load(explicit: Option<&Path>) -> Result { load_from(&base_figment(explicit)) } /// Load configuration from an already-assembled [`Figment`]. /// /// This is the seam used by [`load`] and by tests that need to bypass the fixed /// filesystem locations. The provided figment is expected to include the /// [`Config::default`] layer as its base. /// /// # Errors /// /// Returns [`ConfigError`] under the same conditions as [`load`]. pub fn load_from(figment: &Figment) -> Result { let mut config: Config = figment .extract() .map_err(|e| ConfigError::Load(Box::new(e)))?; resolve_secret_files(&mut config)?; let warnings = validate(&config)?; Ok(Loaded { config, warnings }) } /// Assemble the layered figment for the standard load path. fn base_figment(explicit: Option<&Path>) -> Figment { let mut figment = Figment::from(Serialized::defaults(Config::default())).merge(Toml::file(ETC_PATH)); if let Some(path) = xdg_config_path() { figment = figment.merge(Toml::file(path)); } if let Some(path) = explicit { figment = figment.merge(Toml::file(path)); } figment.merge(Env::prefixed(ENV_PREFIX).split("__")) } /// Resolve `$XDG_CONFIG_HOME/fabrica/fabrica.toml`, falling back to /// `$HOME/.config`. fn xdg_config_path() -> Option { let base = match std::env::var_os("XDG_CONFIG_HOME") { Some(dir) if !dir.is_empty() => PathBuf::from(dir), _ => { let home = std::env::var_os("HOME").filter(|h| !h.is_empty())?; PathBuf::from(home).join(".config") } }; Some(base.join("fabrica").join("fabrica.toml")) } /// Read any `*_file` secrets and fold them onto their inline siblings. fn resolve_secret_files(config: &mut Config) -> Result<(), ConfigError> { if let Some(path) = &config.auth.secret_file { // The HS256 signing key is generated on first run (see the CLI's // `ensure_secret`), so a not-yet-created file is expected and fine — the // inline secret stays `None` and the server writes one. Only a file that // exists but cannot be read (e.g. permissions) is a hard error. match read_secret(path) { Ok(secret) => config.auth.secret = Some(secret), Err(ConfigError::SecretFile { source, .. }) if source.kind() == std::io::ErrorKind::NotFound => {} Err(e) => return Err(e), } } if let Some(path) = &config.mail.password_file { config.mail.password = Some(read_secret(path)?); } Ok(()) } /// Read a secret from a file, trimming a single trailing newline. fn read_secret(path: &Path) -> Result { let raw = std::fs::read_to_string(path).map_err(|source| ConfigError::SecretFile { path: path.to_path_buf(), source, })?; Ok(Secret::new(raw.trim_end_matches(['\r', '\n']).to_string())) } /// Validate the effective configuration, failing fast with actionable messages. /// /// Returns the list of non-fatal warnings on success; returns /// [`ConfigError::Invalid`] with every hard error joined by newlines otherwise. fn validate(config: &Config) -> Result, ConfigError> { let mut errors: Vec = Vec::new(); let mut warnings: Vec = Vec::new(); // instance.url must parse and must not carry a trailing slash. if let Err(e) = url::Url::parse(&config.instance.url) { errors.push(format!( "instance.url ({}) is not a valid URL: {e}", config.instance.url )); } else if config.instance.url.ends_with('/') { errors.push(format!( "instance.url ({}) must not have a trailing slash", config.instance.url )); } if config.instance.default_branch.trim().is_empty() { errors.push("instance.default_branch must not be empty".to_string()); } // Storage directories must exist or be creatable. The bare repositories are // always local; the LFS directory is only used by the local upload backend. check_dir_creatable("storage.data_dir", &config.storage.data_dir, &mut errors); check_dir_creatable("storage.repo_dir", &config.storage.repo_dir, &mut errors); if config.storage.backend == StorageBackend::Local { check_dir_creatable("storage.lfs_dir", &config.storage.lfs_dir, &mut errors); } // The S3 backend needs a bucket and credentials. if config.storage.backend == StorageBackend::S3 { match &config.storage.s3 { None => errors.push( "storage.backend is \"s3\" but the [storage.s3] section is missing".to_string(), ), Some(s3) => { if s3.bucket.trim().is_empty() { errors.push("storage.s3.bucket must not be empty".to_string()); } if s3.access_key_id.trim().is_empty() { errors.push("storage.s3.access_key_id must not be empty".to_string()); } if s3.secret_access_key.expose().trim().is_empty() { errors.push("storage.s3.secret_access_key must not be empty".to_string()); } } } } // SMTP delivery needs a host and a from address. if config.mail.backend == MailBackend::Smtp { if config.mail.host.trim().is_empty() { errors.push("mail.backend is \"smtp\" but mail.host is empty".to_string()); } if config.mail.from.trim().is_empty() { errors.push("mail.backend is \"smtp\" but mail.from is empty".to_string()); } } // A secure cookie over plain HTTP will be dropped by browsers. if config.auth.cookie_secure && !config.instance.url.starts_with("https://") { warnings.push( "auth.cookie_secure is true but instance.url is not https; browsers will drop the \ session cookie" .to_string(), ); } if errors.is_empty() { Ok(warnings) } else { Err(ConfigError::Invalid( errors .iter() .map(|e| format!(" - {e}")) .collect::>() .join("\n"), )) } } /// Push an error unless `path` is an existing directory or has an existing /// directory ancestor (i.e. it could be created). Performs no filesystem /// mutation, so it is safe to call from `fabrica config check`. fn check_dir_creatable(label: &str, path: &Path, errors: &mut Vec) { match path.metadata() { Ok(meta) if meta.is_dir() => {} Ok(_) => errors.push(format!( "{label} ({}) exists but is not a directory", path.display() )), Err(_) => { let mut ancestor = path.parent(); while let Some(dir) = ancestor { if dir.as_os_str().is_empty() { break; } match dir.metadata() { Ok(meta) if meta.is_dir() => return, Ok(_) => { errors.push(format!( "{label} ({}) is not creatable: {} is not a directory", path.display(), dir.display() )); return; } Err(_) => ancestor = dir.parent(), } } errors.push(format!( "{label} ({}) does not exist and has no existing parent directory to create it in", path.display() )); } } } impl Config { /// Render the effective configuration as TOML, with secrets redacted. /// /// # Errors /// /// Returns [`ConfigError::Render`] if serialization fails. pub fn to_toml_string(&self) -> Result { Ok(toml::to_string_pretty(self)?) } } #[cfg(test)] mod tests { #![allow( clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::result_large_err )] use figment::Jail; use figment::providers::{Format, Serialized, Toml}; use super::*; /// Build a figment from defaults plus an inline TOML fragment — no /// filesystem or environment sources, so tests stay hermetic. fn from_toml(fragment: &str) -> Result { load_from( &Figment::from(Serialized::defaults(Config::default())).merge(Toml::string(fragment)), ) } #[test] fn defaults_load_and_match() { // A bare config validates; the only advisory is the cookie-over-http // warning, since the default url is http and cookie_secure is true. let loaded = from_toml("").unwrap(); assert_eq!(loaded.config, Config::default()); } #[test] fn later_sources_win() { let loaded = from_toml("[server]\nport = 9000\n").unwrap(); assert_eq!(loaded.config.server.port, 9000); // Untouched keys keep their defaults. assert_eq!(loaded.config.server.address, "0.0.0.0"); } #[test] fn unknown_key_is_rejected() { let err = from_toml("[server]\nprot = 9000\n").unwrap_err(); assert!(matches!(err, ConfigError::Load(_)), "got {err:?}"); assert!(format!("{err}").contains("prot")); } #[test] fn unknown_section_is_rejected() { let err = from_toml("[nope]\nx = 1\n").unwrap_err(); assert!(matches!(err, ConfigError::Load(_)), "got {err:?}"); } #[test] fn enum_values_are_validated() { let err = from_toml("[mail]\nbackend = \"carrier-pigeon\"\n").unwrap_err(); assert!(matches!(err, ConfigError::Load(_)), "got {err:?}"); } #[test] fn trailing_slash_url_is_rejected() { let err = from_toml("[instance]\nurl = \"https://git.example.com/\"\n").unwrap_err(); match err { ConfigError::Invalid(msg) => assert!(msg.contains("trailing slash"), "{msg}"), other => panic!("expected Invalid, got {other:?}"), } } #[test] fn unparseable_url_is_rejected() { let err = from_toml("[instance]\nurl = \"not a url\"\n").unwrap_err(); assert!(matches!(err, ConfigError::Invalid(_)), "got {err:?}"); } #[test] fn s3_backend_without_config_is_rejected() { let err = from_toml("[storage]\nbackend = \"s3\"\n").unwrap_err(); match err { ConfigError::Invalid(msg) => assert!(msg.contains("[storage.s3]"), "{msg}"), other => panic!("expected Invalid, got {other:?}"), } } #[test] fn s3_backend_with_config_validates() { let loaded = from_toml( "[storage]\nbackend = \"s3\"\n\n[storage.s3]\nbucket = \"b\"\n\ access_key_id = \"k\"\nsecret_access_key = \"s\"\nendpoint = \"https://s3.example.com\"\n\ region = \"auto\"\npath_style = true\nprefix = \"fabrica/\"\n", ) .unwrap(); let s3 = loaded.config.storage.s3.as_ref().unwrap(); assert_eq!(s3.bucket, "b"); assert_eq!(s3.region, "auto"); assert!(s3.path_style); assert_eq!(s3.prefix, "fabrica/"); assert_eq!(s3.endpoint.as_deref(), Some("https://s3.example.com")); } #[test] fn s3_backend_with_empty_bucket_is_rejected() { let err = from_toml( "[storage]\nbackend = \"s3\"\n\n[storage.s3]\nbucket = \"\"\n\ access_key_id = \"k\"\nsecret_access_key = \"s\"\n", ) .unwrap_err(); match err { ConfigError::Invalid(msg) => assert!(msg.contains("storage.s3.bucket"), "{msg}"), other => panic!("expected Invalid, got {other:?}"), } } #[test] fn smtp_without_host_is_rejected() { let err = from_toml("[mail]\nbackend = \"smtp\"\n").unwrap_err(); match err { ConfigError::Invalid(msg) => assert!(msg.contains("mail.host"), "{msg}"), other => panic!("expected Invalid, got {other:?}"), } } #[test] fn insecure_cookie_over_http_warns_but_loads() { // Default url is http and cookie_secure defaults to true. let loaded = from_toml("").unwrap(); assert!( loaded.warnings.iter().any(|w| w.contains("cookie_secure")), "expected a cookie warning, got {:?}", loaded.warnings ); } #[test] fn https_url_produces_no_cookie_warning() { let loaded = from_toml("[instance]\nurl = \"https://git.example.com\"\n").unwrap(); assert!(!loaded.warnings.iter().any(|w| w.contains("cookie_secure"))); } #[test] fn secrets_are_redacted_in_rendered_toml() { let loaded = from_toml("[auth]\nsecret = \"super-secret-key\"\n").unwrap(); assert_eq!( loaded.config.auth.secret.as_ref().unwrap().expose(), "super-secret-key" ); let rendered = loaded.config.to_toml_string().unwrap(); assert!(rendered.contains(REDACTED)); assert!(!rendered.contains("super-secret-key")); } #[test] fn secret_file_supersedes_inline_and_reads_from_disk() { Jail::expect_with(|jail| { let path = jail.directory().join("secret.key"); std::fs::write(&path, "from-file\n").unwrap(); let fragment = format!( "[auth]\nsecret = \"inline\"\nsecret_file = \"{}\"\n", path.display() ); let loaded = from_toml(&fragment).map_err(|e| e.to_string())?; assert_eq!(loaded.config.auth.secret.unwrap().expose(), "from-file"); Ok(()) }); } #[test] fn missing_auth_secret_file_is_tolerated() { // The signing key is generated on first run, so a not-yet-created // secret_file must not fail config load — the inline secret stays unset // and the server writes one. let loaded = from_toml("[auth]\nsecret_file = \"/no/such/fabrica/secret\"\n").unwrap(); assert!(loaded.config.auth.secret.is_none()); } #[test] fn missing_mail_password_file_errors() { // A mail password file is operator-provided, not generated, so a missing // one is a real misconfiguration. let err = from_toml("[mail]\npassword_file = \"/no/such/fabrica/mail-pass\"\n").unwrap_err(); match err { ConfigError::SecretFile { path, .. } => assert!(path.ends_with("mail-pass")), other => panic!("expected SecretFile, got {other:?}"), } } #[test] fn env_overrides_apply_with_double_underscore_nesting() { Jail::expect_with(|jail| { jail.set_env("FABRICA__SERVER__PORT", "7000"); jail.set_env("FABRICA__INSTANCE__DEFAULT_BRANCH", "trunk"); let loaded = load_from(&base_figment(None)).map_err(|e| e.to_string())?; assert_eq!(loaded.config.server.port, 7000); assert_eq!(loaded.config.instance.default_branch, "trunk"); Ok(()) }); } #[test] fn explicit_config_path_is_merged() { Jail::expect_with(|jail| { let path = jail.directory().join("fabrica.toml"); std::fs::write(&path, "[instance]\nname = \"from-explicit\"\n").unwrap(); let loaded = load_from(&base_figment(Some(&path))).map_err(|e| e.to_string())?; assert_eq!(loaded.config.instance.name, "from-explicit"); Ok(()) }); } }