fabrica

hanna/fabrica

19023 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Configuration loading, validation, and path resolution.
6//!
7//! Configuration is a TOML file plus environment overrides, merged with
8//! [`figment`]. The resolution order (later wins) is:
9//!
10//! 1. built-in defaults ([`Config::default`]);
11//! 2. `/etc/fabrica/fabrica.toml`;
12//! 3. `$XDG_CONFIG_HOME/fabrica/fabrica.toml`;
13//! 4. an explicit `--config <path>`;
14//! 5. `FABRICA__*` environment variables (nested keys use `__`, e.g.
15//! `FABRICA__SERVER__PORT=8080`).
16//!
17//! Missing files at the fixed locations are skipped; a missing `--config` path
18//! is likewise tolerated so the same code path serves first-run.
19//!
20//! Any `*_file` key (`auth.secret_file`, `mail.password_file`) is read from disk
21//! after merging and supersedes its inline sibling. Secrets are wrapped in
22//! [`Secret`], which redacts itself in every `Debug`/`Serialize` context, so
23//! neither logging nor `fabrica config show` can leak them.
24
25mod secret;
26mod types;
27
28use std::path::{Path, PathBuf};
29
30use figment::Figment;
31use figment::providers::{Env, Format, Serialized, Toml};
32
33pub use crate::secret::{REDACTED, Secret};
34pub 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/// System-wide configuration path, the lowest-priority file source.
41const ETC_PATH: &str = "/etc/fabrica/fabrica.toml";
42
43/// Prefix and nesting separator for environment overrides.
44const ENV_PREFIX: &str = "FABRICA__";
45
46/// Errors produced while loading or validating configuration.
47#[derive(Debug, thiserror::Error)]
48pub enum ConfigError {
49 /// A file could not be parsed, a value had the wrong type, or an unknown key
50 /// was present. Boxed because `figment::Error` is large relative to the
51 /// other variants.
52 #[error("failed to load configuration: {0}")]
53 Load(Box<figment::Error>),
54
55 /// A `*_file` secret could not be read. The path is included; the file's
56 /// contents never are.
57 #[error("failed to read secret file {path}: {source}")]
58 SecretFile {
59 /// The path that could not be read.
60 path: PathBuf,
61 /// The underlying I/O error.
62 source: std::io::Error,
63 },
64
65 /// Validation failed. Each line is a distinct, actionable problem.
66 #[error("invalid configuration:\n{0}")]
67 Invalid(String),
68
69 /// The effective configuration could not be serialized for display.
70 #[error("failed to render configuration: {0}")]
71 Render(#[from] toml::ser::Error),
72}
73
74/// A successfully loaded configuration together with any non-fatal warnings.
75#[derive(Debug, Clone)]
76pub struct Loaded {
77 /// The validated, effective configuration.
78 pub config: Config,
79 /// Non-fatal advisories (e.g. an insecure cookie setting). The caller is
80 /// expected to surface these to the operator.
81 pub warnings: Vec<String>,
82}
83
84/// Load configuration from all standard sources, applying the `--config`
85/// override when supplied.
86///
87/// # Errors
88///
89/// Returns [`ConfigError`] if a source fails to parse, an unknown key is
90/// present, a `*_file` secret cannot be read, or validation fails.
91pub fn load(explicit: Option<&Path>) -> Result<Loaded, ConfigError> {
92 load_from(&base_figment(explicit))
93}
94
95/// Load configuration from an already-assembled [`Figment`].
96///
97/// This is the seam used by [`load`] and by tests that need to bypass the fixed
98/// filesystem locations. The provided figment is expected to include the
99/// [`Config::default`] layer as its base.
100///
101/// # Errors
102///
103/// Returns [`ConfigError`] under the same conditions as [`load`].
104pub 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/// Assemble the layered figment for the standard load path.
114fn 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/// Resolve `$XDG_CONFIG_HOME/fabrica/fabrica.toml`, falling back to
127/// `$HOME/.config`.
128fn 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/// Read any `*_file` secrets and fold them onto their inline siblings.
140fn resolve_secret_files(config: &mut Config) -> Result<(), ConfigError> {
141 if let Some(path) = &config.auth.secret_file {
142 // The HS256 signing key is generated on first run (see the CLI's
143 // `ensure_secret`), so a not-yet-created file is expected and fine — the
144 // inline secret stays `None` and the server writes one. Only a file that
145 // exists but cannot be read (e.g. permissions) is a hard error.
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/// Read a secret from a file, trimming a single trailing newline.
160fn 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/// Validate the effective configuration, failing fast with actionable messages.
169///
170/// Returns the list of non-fatal warnings on success; returns
171/// [`ConfigError::Invalid`] with every hard error joined by newlines otherwise.
172fn 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 // instance.url must parse and must not carry a trailing slash.
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 // Storage directories must exist or be creatable. The bare repositories are
194 // always local; the LFS directory is only used by the local upload backend.
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 // The S3 backend needs a bucket and credentials.
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 // SMTP delivery needs a host and a from address.
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 // A secure cookie over plain HTTP will be dropped by browsers.
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/// Push an error unless `path` is an existing directory or has an existing
254/// directory ancestor (i.e. it could be created). Performs no filesystem
255/// mutation, so it is safe to call from `fabrica config check`.
256fn 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
290impl Config {
291 /// Render the effective configuration as TOML, with secrets redacted.
292 ///
293 /// # Errors
294 ///
295 /// Returns [`ConfigError::Render`] if serialization fails.
296 pub fn to_toml_string(&self) -> Result<String, ConfigError> {
297 Ok(toml::to_string_pretty(self)?)
298 }
299}
300
301#[cfg(test)]
302mod 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 /// Build a figment from defaults plus an inline TOML fragment — no
316 /// filesystem or environment sources, so tests stay hermetic.
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 // A bare config validates; the only advisory is the cookie-over-http
326 // warning, since the default url is http and cookie_secure is true.
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 // Untouched keys keep their defaults.
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 // Default url is http and cookie_secure defaults to true.
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 // The signing key is generated on first run, so a not-yet-created
467 // secret_file must not fail config load — the inline secret stays unset
468 // and the server writes one.
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 // A mail password file is operator-provided, not generated, so a missing
476 // one is a real misconfiguration.
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}