| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | |
| 9 | |
| 10 | |
| 11 | |
| 12 | |
| 13 | |
| 14 | |
| 15 | |
| 16 | |
| 17 | |
| 18 | |
| 19 | |
| 20 | |
| 21 | |
| 22 | |
| 23 | |
| 24 | |
| 25 | mod secret; |
| 26 | mod types; |
| 27 | |
| 28 | use std::path::{Path, PathBuf}; |
| 29 | |
| 30 | use figment::Figment; |
| 31 | use figment::providers::{Env, Format, Serialized, Toml}; |
| 32 | |
| 33 | pub use crate::secret::{REDACTED, Secret}; |
| 34 | pub use crate::types::{ |
| 35 | Auth, Captcha, CaptchaProvider, Config, Database, DefaultVisibility, Git, Instance, Log, |
| 36 | LogFormat, LogLevel, Mail, MailBackend, MailEncryption, S3, Search, Server, Ssh, Storage, |
| 37 | StorageBackend, Ui, |
| 38 | }; |
| 39 | |
| 40 | |
| 41 | const ETC_PATH: &str = "/etc/fabrica/fabrica.toml"; |
| 42 | |
| 43 | |
| 44 | const ENV_PREFIX: &str = "FABRICA__"; |
| 45 | |
| 46 | |
| 47 | #[derive(Debug, thiserror::Error)] |
| 48 | pub enum ConfigError { |
| 49 | |
| 50 | |
| 51 | |
| 52 | #[error("failed to load configuration: {0}")] |
| 53 | Load(Box<figment::Error>), |
| 54 | |
| 55 | |
| 56 | |
| 57 | #[error("failed to read secret file {path}: {source}")] |
| 58 | SecretFile { |
| 59 | |
| 60 | path: PathBuf, |
| 61 | |
| 62 | source: std::io::Error, |
| 63 | }, |
| 64 | |
| 65 | |
| 66 | #[error("invalid configuration:\n{0}")] |
| 67 | Invalid(String), |
| 68 | |
| 69 | |
| 70 | #[error("failed to render configuration: {0}")] |
| 71 | Render(#[from] toml::ser::Error), |
| 72 | } |
| 73 | |
| 74 | |
| 75 | #[derive(Debug, Clone)] |
| 76 | pub struct Loaded { |
| 77 | |
| 78 | pub config: Config, |
| 79 | |
| 80 | |
| 81 | pub warnings: Vec<String>, |
| 82 | } |
| 83 | |
| 84 | |
| 85 | |
| 86 | |
| 87 | |
| 88 | |
| 89 | |
| 90 | |
| 91 | pub fn load(explicit: Option<&Path>) -> Result<Loaded, ConfigError> { |
| 92 | load_from(&base_figment(explicit)) |
| 93 | } |
| 94 | |
| 95 | |
| 96 | |
| 97 | |
| 98 | |
| 99 | |
| 100 | |
| 101 | |
| 102 | |
| 103 | |
| 104 | pub fn load_from(figment: &Figment) -> Result<Loaded, ConfigError> { |
| 105 | let mut config: Config = figment |
| 106 | .extract() |
| 107 | .map_err(|e| ConfigError::Load(Box::new(e)))?; |
| 108 | resolve_secret_files(&mut config)?; |
| 109 | let warnings = validate(&config)?; |
| 110 | Ok(Loaded { config, warnings }) |
| 111 | } |
| 112 | |
| 113 | |
| 114 | fn base_figment(explicit: Option<&Path>) -> Figment { |
| 115 | let mut figment = |
| 116 | Figment::from(Serialized::defaults(Config::default())).merge(Toml::file(ETC_PATH)); |
| 117 | if let Some(path) = xdg_config_path() { |
| 118 | figment = figment.merge(Toml::file(path)); |
| 119 | } |
| 120 | if let Some(path) = explicit { |
| 121 | figment = figment.merge(Toml::file(path)); |
| 122 | } |
| 123 | figment.merge(Env::prefixed(ENV_PREFIX).split("__")) |
| 124 | } |
| 125 | |
| 126 | |
| 127 | |
| 128 | fn xdg_config_path() -> Option<PathBuf> { |
| 129 | let base = match std::env::var_os("XDG_CONFIG_HOME") { |
| 130 | Some(dir) if !dir.is_empty() => PathBuf::from(dir), |
| 131 | _ => { |
| 132 | let home = std::env::var_os("HOME").filter(|h| !h.is_empty())?; |
| 133 | PathBuf::from(home).join(".config") |
| 134 | } |
| 135 | }; |
| 136 | Some(base.join("fabrica").join("fabrica.toml")) |
| 137 | } |
| 138 | |
| 139 | |
| 140 | fn resolve_secret_files(config: &mut Config) -> Result<(), ConfigError> { |
| 141 | if let Some(path) = &config.auth.secret_file { |
| 142 | |
| 143 | |
| 144 | |
| 145 | |
| 146 | match read_secret(path) { |
| 147 | Ok(secret) => config.auth.secret = Some(secret), |
| 148 | Err(ConfigError::SecretFile { source, .. }) |
| 149 | if source.kind() == std::io::ErrorKind::NotFound => {} |
| 150 | Err(e) => return Err(e), |
| 151 | } |
| 152 | } |
| 153 | if let Some(path) = &config.mail.password_file { |
| 154 | config.mail.password = Some(read_secret(path)?); |
| 155 | } |
| 156 | Ok(()) |
| 157 | } |
| 158 | |
| 159 | |
| 160 | fn read_secret(path: &Path) -> Result<Secret, ConfigError> { |
| 161 | let raw = std::fs::read_to_string(path).map_err(|source| ConfigError::SecretFile { |
| 162 | path: path.to_path_buf(), |
| 163 | source, |
| 164 | })?; |
| 165 | Ok(Secret::new(raw.trim_end_matches(['\r', '\n']).to_string())) |
| 166 | } |
| 167 | |
| 168 | |
| 169 | |
| 170 | |
| 171 | |
| 172 | fn validate(config: &Config) -> Result<Vec<String>, ConfigError> { |
| 173 | let mut errors: Vec<String> = Vec::new(); |
| 174 | let mut warnings: Vec<String> = Vec::new(); |
| 175 | |
| 176 | |
| 177 | if let Err(e) = url::Url::parse(&config.instance.url) { |
| 178 | errors.push(format!( |
| 179 | "instance.url ({}) is not a valid URL: {e}", |
| 180 | config.instance.url |
| 181 | )); |
| 182 | } else if config.instance.url.ends_with('/') { |
| 183 | errors.push(format!( |
| 184 | "instance.url ({}) must not have a trailing slash", |
| 185 | config.instance.url |
| 186 | )); |
| 187 | } |
| 188 | |
| 189 | if config.instance.default_branch.trim().is_empty() { |
| 190 | errors.push("instance.default_branch must not be empty".to_string()); |
| 191 | } |
| 192 | |
| 193 | |
| 194 | |
| 195 | check_dir_creatable("storage.data_dir", &config.storage.data_dir, &mut errors); |
| 196 | check_dir_creatable("storage.repo_dir", &config.storage.repo_dir, &mut errors); |
| 197 | if config.storage.backend == StorageBackend::Local { |
| 198 | check_dir_creatable("storage.lfs_dir", &config.storage.lfs_dir, &mut errors); |
| 199 | } |
| 200 | |
| 201 | |
| 202 | if config.storage.backend == StorageBackend::S3 { |
| 203 | match &config.storage.s3 { |
| 204 | None => errors.push( |
| 205 | "storage.backend is \"s3\" but the [storage.s3] section is missing".to_string(), |
| 206 | ), |
| 207 | Some(s3) => { |
| 208 | if s3.bucket.trim().is_empty() { |
| 209 | errors.push("storage.s3.bucket must not be empty".to_string()); |
| 210 | } |
| 211 | if s3.access_key_id.trim().is_empty() { |
| 212 | errors.push("storage.s3.access_key_id must not be empty".to_string()); |
| 213 | } |
| 214 | if s3.secret_access_key.expose().trim().is_empty() { |
| 215 | errors.push("storage.s3.secret_access_key must not be empty".to_string()); |
| 216 | } |
| 217 | } |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | |
| 222 | if config.mail.backend == MailBackend::Smtp { |
| 223 | if config.mail.host.trim().is_empty() { |
| 224 | errors.push("mail.backend is \"smtp\" but mail.host is empty".to_string()); |
| 225 | } |
| 226 | if config.mail.from.trim().is_empty() { |
| 227 | errors.push("mail.backend is \"smtp\" but mail.from is empty".to_string()); |
| 228 | } |
| 229 | } |
| 230 | |
| 231 | |
| 232 | if config.auth.cookie_secure && !config.instance.url.starts_with("https://") { |
| 233 | warnings.push( |
| 234 | "auth.cookie_secure is true but instance.url is not https; browsers will drop the \ |
| 235 | session cookie" |
| 236 | .to_string(), |
| 237 | ); |
| 238 | } |
| 239 | |
| 240 | if errors.is_empty() { |
| 241 | Ok(warnings) |
| 242 | } else { |
| 243 | Err(ConfigError::Invalid( |
| 244 | errors |
| 245 | .iter() |
| 246 | .map(|e| format!(" - {e}")) |
| 247 | .collect::<Vec<_>>() |
| 248 | .join("\n"), |
| 249 | )) |
| 250 | } |
| 251 | } |
| 252 | |
| 253 | |
| 254 | |
| 255 | |
| 256 | fn check_dir_creatable(label: &str, path: &Path, errors: &mut Vec<String>) { |
| 257 | match path.metadata() { |
| 258 | Ok(meta) if meta.is_dir() => {} |
| 259 | Ok(_) => errors.push(format!( |
| 260 | "{label} ({}) exists but is not a directory", |
| 261 | path.display() |
| 262 | )), |
| 263 | Err(_) => { |
| 264 | let mut ancestor = path.parent(); |
| 265 | while let Some(dir) = ancestor { |
| 266 | if dir.as_os_str().is_empty() { |
| 267 | break; |
| 268 | } |
| 269 | match dir.metadata() { |
| 270 | Ok(meta) if meta.is_dir() => return, |
| 271 | Ok(_) => { |
| 272 | errors.push(format!( |
| 273 | "{label} ({}) is not creatable: {} is not a directory", |
| 274 | path.display(), |
| 275 | dir.display() |
| 276 | )); |
| 277 | return; |
| 278 | } |
| 279 | Err(_) => ancestor = dir.parent(), |
| 280 | } |
| 281 | } |
| 282 | errors.push(format!( |
| 283 | "{label} ({}) does not exist and has no existing parent directory to create it in", |
| 284 | path.display() |
| 285 | )); |
| 286 | } |
| 287 | } |
| 288 | } |
| 289 | |
| 290 | impl Config { |
| 291 | |
| 292 | |
| 293 | |
| 294 | |
| 295 | |
| 296 | pub fn to_toml_string(&self) -> Result<String, ConfigError> { |
| 297 | Ok(toml::to_string_pretty(self)?) |
| 298 | } |
| 299 | } |
| 300 | |
| 301 | #[cfg(test)] |
| 302 | mod tests { |
| 303 | #![allow( |
| 304 | clippy::unwrap_used, |
| 305 | clippy::expect_used, |
| 306 | clippy::panic, |
| 307 | clippy::result_large_err |
| 308 | )] |
| 309 | |
| 310 | use figment::Jail; |
| 311 | use figment::providers::{Format, Serialized, Toml}; |
| 312 | |
| 313 | use super::*; |
| 314 | |
| 315 | |
| 316 | |
| 317 | fn from_toml(fragment: &str) -> Result<Loaded, ConfigError> { |
| 318 | load_from( |
| 319 | &Figment::from(Serialized::defaults(Config::default())).merge(Toml::string(fragment)), |
| 320 | ) |
| 321 | } |
| 322 | |
| 323 | #[test] |
| 324 | fn defaults_load_and_match() { |
| 325 | |
| 326 | |
| 327 | let loaded = from_toml("").unwrap(); |
| 328 | assert_eq!(loaded.config, Config::default()); |
| 329 | } |
| 330 | |
| 331 | #[test] |
| 332 | fn later_sources_win() { |
| 333 | let loaded = from_toml("[server]\nport = 9000\n").unwrap(); |
| 334 | assert_eq!(loaded.config.server.port, 9000); |
| 335 | |
| 336 | assert_eq!(loaded.config.server.address, "0.0.0.0"); |
| 337 | } |
| 338 | |
| 339 | #[test] |
| 340 | fn unknown_key_is_rejected() { |
| 341 | let err = from_toml("[server]\nprot = 9000\n").unwrap_err(); |
| 342 | assert!(matches!(err, ConfigError::Load(_)), "got {err:?}"); |
| 343 | assert!(format!("{err}").contains("prot")); |
| 344 | } |
| 345 | |
| 346 | #[test] |
| 347 | fn unknown_section_is_rejected() { |
| 348 | let err = from_toml("[nope]\nx = 1\n").unwrap_err(); |
| 349 | assert!(matches!(err, ConfigError::Load(_)), "got {err:?}"); |
| 350 | } |
| 351 | |
| 352 | #[test] |
| 353 | fn enum_values_are_validated() { |
| 354 | let err = from_toml("[mail]\nbackend = \"carrier-pigeon\"\n").unwrap_err(); |
| 355 | assert!(matches!(err, ConfigError::Load(_)), "got {err:?}"); |
| 356 | } |
| 357 | |
| 358 | #[test] |
| 359 | fn trailing_slash_url_is_rejected() { |
| 360 | let err = from_toml("[instance]\nurl = \"https://git.example.com/\"\n").unwrap_err(); |
| 361 | match err { |
| 362 | ConfigError::Invalid(msg) => assert!(msg.contains("trailing slash"), "{msg}"), |
| 363 | other => panic!("expected Invalid, got {other:?}"), |
| 364 | } |
| 365 | } |
| 366 | |
| 367 | #[test] |
| 368 | fn unparseable_url_is_rejected() { |
| 369 | let err = from_toml("[instance]\nurl = \"not a url\"\n").unwrap_err(); |
| 370 | assert!(matches!(err, ConfigError::Invalid(_)), "got {err:?}"); |
| 371 | } |
| 372 | |
| 373 | #[test] |
| 374 | fn s3_backend_without_config_is_rejected() { |
| 375 | let err = from_toml("[storage]\nbackend = \"s3\"\n").unwrap_err(); |
| 376 | match err { |
| 377 | ConfigError::Invalid(msg) => assert!(msg.contains("[storage.s3]"), "{msg}"), |
| 378 | other => panic!("expected Invalid, got {other:?}"), |
| 379 | } |
| 380 | } |
| 381 | |
| 382 | #[test] |
| 383 | fn s3_backend_with_config_validates() { |
| 384 | let loaded = from_toml( |
| 385 | "[storage]\nbackend = \"s3\"\n\n[storage.s3]\nbucket = \"b\"\n\ |
| 386 | access_key_id = \"k\"\nsecret_access_key = \"s\"\nendpoint = \"https://s3.example.com\"\n\ |
| 387 | region = \"auto\"\npath_style = true\nprefix = \"fabrica/\"\n", |
| 388 | ) |
| 389 | .unwrap(); |
| 390 | let s3 = loaded.config.storage.s3.as_ref().unwrap(); |
| 391 | assert_eq!(s3.bucket, "b"); |
| 392 | assert_eq!(s3.region, "auto"); |
| 393 | assert!(s3.path_style); |
| 394 | assert_eq!(s3.prefix, "fabrica/"); |
| 395 | assert_eq!(s3.endpoint.as_deref(), Some("https://s3.example.com")); |
| 396 | } |
| 397 | |
| 398 | #[test] |
| 399 | fn s3_backend_with_empty_bucket_is_rejected() { |
| 400 | let err = from_toml( |
| 401 | "[storage]\nbackend = \"s3\"\n\n[storage.s3]\nbucket = \"\"\n\ |
| 402 | access_key_id = \"k\"\nsecret_access_key = \"s\"\n", |
| 403 | ) |
| 404 | .unwrap_err(); |
| 405 | match err { |
| 406 | ConfigError::Invalid(msg) => assert!(msg.contains("storage.s3.bucket"), "{msg}"), |
| 407 | other => panic!("expected Invalid, got {other:?}"), |
| 408 | } |
| 409 | } |
| 410 | |
| 411 | #[test] |
| 412 | fn smtp_without_host_is_rejected() { |
| 413 | let err = from_toml("[mail]\nbackend = \"smtp\"\n").unwrap_err(); |
| 414 | match err { |
| 415 | ConfigError::Invalid(msg) => assert!(msg.contains("mail.host"), "{msg}"), |
| 416 | other => panic!("expected Invalid, got {other:?}"), |
| 417 | } |
| 418 | } |
| 419 | |
| 420 | #[test] |
| 421 | fn insecure_cookie_over_http_warns_but_loads() { |
| 422 | |
| 423 | let loaded = from_toml("").unwrap(); |
| 424 | assert!( |
| 425 | loaded.warnings.iter().any(|w| w.contains("cookie_secure")), |
| 426 | "expected a cookie warning, got {:?}", |
| 427 | loaded.warnings |
| 428 | ); |
| 429 | } |
| 430 | |
| 431 | #[test] |
| 432 | fn https_url_produces_no_cookie_warning() { |
| 433 | let loaded = from_toml("[instance]\nurl = \"https://git.example.com\"\n").unwrap(); |
| 434 | assert!(!loaded.warnings.iter().any(|w| w.contains("cookie_secure"))); |
| 435 | } |
| 436 | |
| 437 | #[test] |
| 438 | fn secrets_are_redacted_in_rendered_toml() { |
| 439 | let loaded = from_toml("[auth]\nsecret = \"super-secret-key\"\n").unwrap(); |
| 440 | assert_eq!( |
| 441 | loaded.config.auth.secret.as_ref().unwrap().expose(), |
| 442 | "super-secret-key" |
| 443 | ); |
| 444 | let rendered = loaded.config.to_toml_string().unwrap(); |
| 445 | assert!(rendered.contains(REDACTED)); |
| 446 | assert!(!rendered.contains("super-secret-key")); |
| 447 | } |
| 448 | |
| 449 | #[test] |
| 450 | fn secret_file_supersedes_inline_and_reads_from_disk() { |
| 451 | Jail::expect_with(|jail| { |
| 452 | let path = jail.directory().join("secret.key"); |
| 453 | std::fs::write(&path, "from-file\n").unwrap(); |
| 454 | let fragment = format!( |
| 455 | "[auth]\nsecret = \"inline\"\nsecret_file = \"{}\"\n", |
| 456 | path.display() |
| 457 | ); |
| 458 | let loaded = from_toml(&fragment).map_err(|e| e.to_string())?; |
| 459 | assert_eq!(loaded.config.auth.secret.unwrap().expose(), "from-file"); |
| 460 | Ok(()) |
| 461 | }); |
| 462 | } |
| 463 | |
| 464 | #[test] |
| 465 | fn missing_auth_secret_file_is_tolerated() { |
| 466 | |
| 467 | |
| 468 | |
| 469 | let loaded = from_toml("[auth]\nsecret_file = \"/no/such/fabrica/secret\"\n").unwrap(); |
| 470 | assert!(loaded.config.auth.secret.is_none()); |
| 471 | } |
| 472 | |
| 473 | #[test] |
| 474 | fn missing_mail_password_file_errors() { |
| 475 | |
| 476 | |
| 477 | let err = |
| 478 | from_toml("[mail]\npassword_file = \"/no/such/fabrica/mail-pass\"\n").unwrap_err(); |
| 479 | match err { |
| 480 | ConfigError::SecretFile { path, .. } => assert!(path.ends_with("mail-pass")), |
| 481 | other => panic!("expected SecretFile, got {other:?}"), |
| 482 | } |
| 483 | } |
| 484 | |
| 485 | #[test] |
| 486 | fn env_overrides_apply_with_double_underscore_nesting() { |
| 487 | Jail::expect_with(|jail| { |
| 488 | jail.set_env("FABRICA__SERVER__PORT", "7000"); |
| 489 | jail.set_env("FABRICA__INSTANCE__DEFAULT_BRANCH", "trunk"); |
| 490 | let loaded = load_from(&base_figment(None)).map_err(|e| e.to_string())?; |
| 491 | assert_eq!(loaded.config.server.port, 7000); |
| 492 | assert_eq!(loaded.config.instance.default_branch, "trunk"); |
| 493 | Ok(()) |
| 494 | }); |
| 495 | } |
| 496 | |
| 497 | #[test] |
| 498 | fn explicit_config_path_is_merged() { |
| 499 | Jail::expect_with(|jail| { |
| 500 | let path = jail.directory().join("fabrica.toml"); |
| 501 | std::fs::write(&path, "[instance]\nname = \"from-explicit\"\n").unwrap(); |
| 502 | let loaded = load_from(&base_figment(Some(&path))).map_err(|e| e.to_string())?; |
| 503 | assert_eq!(loaded.config.instance.name, "from-explicit"); |
| 504 | Ok(()) |
| 505 | }); |
| 506 | } |
| 507 | } |