Krolyxon
Commit 55c1225

ferreniumsrcconfig.rs

643 lines · 18.9 KB · Rust

1//! Configuration schema, defaults, validation and loading.
2//!
3//! Every key is optional. Unknown keys are rejected so typos surface at load
4//! time instead of being silently ignored.
5
6use std::fmt;
7use std::net::SocketAddr;
8use std::path::{Path, PathBuf};
9use std::str::FromStr;
10use std::sync::Arc;
11
12use serde::Deserialize;
13
14/// Well-known config location used in production.
15pub const SYSTEM_CONFIG: &str = "/etc/ferrenium/ferrenium.toml";
16
17#[derive(Debug, thiserror::Error)]
18pub enum ConfigError {
19 #[error("cannot read {path}: {source}")]
20 Io {
21 path: PathBuf,
22 source: std::io::Error,
23 },
24 #[error("{path}: {message}")]
25 Parse { path: PathBuf, message: String },
26 #[error("invalid configuration:\n - {}", .0.join("\n - "))]
27 Invalid(Vec<String>),
28}
29
30#[derive(Debug, Clone, Default, Deserialize)]
31#[serde(deny_unknown_fields, default)]
32pub struct Config {
33 pub server: ServerConfig,
34 pub site: SiteConfig,
35 pub repos: ReposConfig,
36 /// `[[repo]]` per-repository overrides.
37 #[serde(rename = "repo")]
38 pub repo_overrides: Vec<RepoOverride>,
39 pub ui: UiConfig,
40 pub cache: CacheConfig,
41 pub watch: WatchConfig,
42 pub limits: LimitsConfig,
43 pub log: LogConfig,
44
45 /// File the config was loaded from (`None` = built-in defaults).
46 #[serde(skip)]
47 pub source: Option<PathBuf>,
48 /// Contents of `custom_css` / `header` / `footer`, read at load time so
49 /// that a reload picks up edits and requests never touch the filesystem.
50 #[serde(skip)]
51 pub custom: Customization,
52}
53
54#[derive(Debug, Clone, Default)]
55pub struct Customization {
56 pub css: Option<Arc<str>>,
57 pub header: Option<Arc<str>>,
58 pub footer: Option<Arc<str>>,
59}
60
61#[derive(Debug, Clone, Deserialize)]
62#[serde(deny_unknown_fields, default)]
63pub struct ServerConfig {
64 pub listen: Listen,
65 pub base_url: Option<String>,
66 pub base_path: String,
67 pub trusted_proxy: bool,
68 pub compression: bool,
69}
70
71impl Default for ServerConfig {
72 fn default() -> Self {
73 Self {
74 listen: Listen::Tcp(SocketAddr::from(([127, 0, 0, 1], 8080))),
75 base_url: None,
76 base_path: "/".into(),
77 trusted_proxy: false,
78 compression: true,
79 }
80 }
81}
82
83#[derive(Debug, Clone, Deserialize)]
84#[serde(deny_unknown_fields, default)]
85pub struct SiteConfig {
86 pub title: String,
87 pub description: String,
88 pub clone_urls: Vec<String>,
89 pub custom_css: Option<PathBuf>,
90 pub header: Option<PathBuf>,
91 pub footer: Option<PathBuf>,
92}
93
94impl Default for SiteConfig {
95 fn default() -> Self {
96 Self {
97 title: "ferrenium".into(),
98 description: String::new(),
99 clone_urls: Vec::new(),
100 custom_css: None,
101 header: None,
102 footer: None,
103 }
104 }
105}
106
107#[derive(Debug, Clone, Deserialize)]
108#[serde(deny_unknown_fields, default)]
109pub struct ReposConfig {
110 pub scan_path: PathBuf,
111 pub max_depth: u32,
112 pub follow_symlinks: bool,
113 pub section_from_path: bool,
114 pub hide: Vec<String>,
115 pub snapshots: Vec<SnapshotFormat>,
116 pub https_clone: bool,
117}
118
119impl Default for ReposConfig {
120 fn default() -> Self {
121 Self {
122 scan_path: "/home/git".into(),
123 max_depth: 3,
124 follow_symlinks: false,
125 section_from_path: false,
126 hide: Vec::new(),
127 snapshots: vec![SnapshotFormat::TarGz, SnapshotFormat::Zip],
128 https_clone: true,
129 }
130 }
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
134pub enum SnapshotFormat {
135 #[serde(rename = "tar.gz")]
136 TarGz,
137 #[serde(rename = "zip")]
138 Zip,
139}
140
141#[derive(Debug, Clone, Default, Deserialize)]
142#[serde(deny_unknown_fields)]
143pub struct RepoOverride {
144 pub name: String,
145 pub description: Option<String>,
146 pub owner: Option<String>,
147 pub section: Option<String>,
148 pub hidden: Option<bool>,
149 pub readme: Option<String>,
150 pub default_branch: Option<String>,
151}
152
153#[derive(Debug, Clone, Deserialize)]
154#[serde(deny_unknown_fields, default)]
155pub struct UiConfig {
156 pub theme: Theme,
157 pub code_font: String,
158 pub max_blob_kb: u64,
159 pub max_highlight_kb: u64,
160}
161
162impl Default for UiConfig {
163 fn default() -> Self {
164 Self {
165 theme: Theme::Auto,
166 code_font: DEFAULT_CODE_FONT.into(),
167 max_blob_kb: 1024,
168 max_highlight_kb: 256,
169 }
170 }
171}
172
173pub const DEFAULT_CODE_FONT: &str = "Space Mono";
174
175#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
176#[serde(rename_all = "lowercase")]
177pub enum Theme {
178 #[default]
179 Auto,
180 Dark,
181 Light,
182}
183
184impl Theme {
185 pub fn as_str(self) -> &'static str {
186 match self {
187 Theme::Auto => "auto",
188 Theme::Dark => "dark",
189 Theme::Light => "light",
190 }
191 }
192}
193
194#[derive(Debug, Clone, Deserialize)]
195#[serde(deny_unknown_fields, default)]
196pub struct CacheConfig {
197 pub size_mb: u64,
198}
199
200impl Default for CacheConfig {
201 fn default() -> Self {
202 Self { size_mb: 64 }
203 }
204}
205
206#[derive(Debug, Clone, Deserialize)]
207#[serde(deny_unknown_fields, default)]
208pub struct WatchConfig {
209 pub enabled: bool,
210 pub debounce_ms: u64,
211 /// Full rescan interval; 0 disables polling.
212 pub poll_interval_secs: u64,
213}
214
215impl Default for WatchConfig {
216 fn default() -> Self {
217 Self {
218 enabled: true,
219 debounce_ms: 500,
220 poll_interval_secs: 300,
221 }
222 }
223}
224
225#[derive(Debug, Clone, Deserialize)]
226#[serde(deny_unknown_fields, default)]
227pub struct LimitsConfig {
228 /// 0 = 2 × available CPUs.
229 pub git_parallel: usize,
230 pub heavy_parallel: usize,
231 pub request_timeout_secs: u64,
232}
233
234impl Default for LimitsConfig {
235 fn default() -> Self {
236 Self {
237 git_parallel: 0,
238 heavy_parallel: 2,
239 request_timeout_secs: 30,
240 }
241 }
242}
243
244#[derive(Debug, Clone, Deserialize)]
245#[serde(deny_unknown_fields, default)]
246pub struct LogConfig {
247 pub level: String,
248}
249
250impl Default for LogConfig {
251 fn default() -> Self {
252 Self {
253 level: "info".into(),
254 }
255 }
256}
257
258/// Where the server listens: a TCP socket address or `unix:/abs/path`.
259#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
260#[serde(try_from = "String")]
261pub enum Listen {
262 Tcp(SocketAddr),
263 Unix(PathBuf),
264}
265
266impl FromStr for Listen {
267 type Err = String;
268
269 fn from_str(s: &str) -> Result<Self, Self::Err> {
270 if let Some(path) = s.strip_prefix("unix:") {
271 let path = PathBuf::from(path);
272 if !path.is_absolute() {
273 return Err(format!("unix socket path must be absolute: {s:?}"));
274 }
275 return Ok(Listen::Unix(path));
276 }
277 s.parse::<SocketAddr>().map(Listen::Tcp).map_err(|_| {
278 format!("invalid listen address {s:?} (expected HOST:PORT or unix:/path/to.sock)")
279 })
280 }
281}
282
283impl TryFrom<String> for Listen {
284 type Error = String;
285
286 fn try_from(s: String) -> Result<Self, Self::Error> {
287 s.parse()
288 }
289}
290
291impl fmt::Display for Listen {
292 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
293 match self {
294 Listen::Tcp(addr) => write!(f, "http://{addr}"),
295 Listen::Unix(path) => write!(f, "unix:{}", path.display()),
296 }
297 }
298}
299
300/// Find the config file: explicit path (flag or `$FERRENIUM_CONFIG`), then the
301/// system location, then `$XDG_CONFIG_HOME/ferrenium/ferrenium.toml`.
302///
303/// Returns `None` when no explicit path was given and no file exists, in which
304/// case the built-in defaults apply.
305pub fn resolve_path(explicit: Option<&Path>) -> Option<PathBuf> {
306 if let Some(path) = explicit {
307 return Some(path.to_path_buf());
308 }
309 let system = PathBuf::from(SYSTEM_CONFIG);
310 if system.is_file() {
311 return Some(system);
312 }
313 let xdg = std::env::var_os("XDG_CONFIG_HOME")
314 .map(PathBuf::from)
315 .filter(|p| p.is_absolute())
316 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?;
317 let user = xdg.join("ferrenium/ferrenium.toml");
318 user.is_file().then_some(user)
319}
320
321impl Config {
322 /// Load, normalize and validate the config at `path` (or the defaults).
323 pub fn load(path: Option<&Path>) -> Result<Config, ConfigError> {
324 let mut config = match path {
325 Some(path) => {
326 let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Io {
327 path: path.to_path_buf(),
328 source,
329 })?;
330 let mut config = Config::parse(&text).map_err(|message| ConfigError::Parse {
331 path: path.to_path_buf(),
332 message,
333 })?;
334 config.source = Some(path.to_path_buf());
335 config
336 }
337 None => Config::default(),
338 };
339 config.finish()?;
340 Ok(config)
341 }
342
343 /// Parse TOML without validation. Errors carry line/column and a snippet.
344 pub fn parse(text: &str) -> Result<Config, String> {
345 toml::from_str(text).map_err(|e| e.to_string())
346 }
347
348 /// Directory that relative paths in the config are resolved against.
349 pub fn base_dir(&self) -> PathBuf {
350 match self.source.as_deref().and_then(Path::parent) {
351 Some(dir) if !dir.as_os_str().is_empty() => dir.to_path_buf(),
352 _ => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
353 }
354 }
355
356 /// Normalize values, resolve relative paths, read customization files and
357 /// validate. All problems are reported together.
358 pub fn finish(&mut self) -> Result<(), ConfigError> {
359 let mut errors = Vec::new();
360 let base_dir = self.base_dir();
361
362 // server
363 let bp = self.server.base_path.trim();
364 if !bp.starts_with('/') {
365 errors.push(format!("server.base_path must start with '/': {bp:?}"));
366 } else if bp.contains("//") || bp.split('/').any(|s| s == "." || s == "..") {
367 errors.push(format!("server.base_path is not a clean path: {bp:?}"));
368 }
369 let mut bp = bp.trim_end_matches('/').to_string();
370 bp.push('/');
371 self.server.base_path = bp;
372
373 if let Some(url) = &mut self.server.base_url {
374 let trimmed = url.trim().trim_end_matches('/').to_string();
375 if !(trimmed.starts_with("https://") || trimmed.starts_with("http://")) {
376 errors.push(format!(
377 "server.base_url must start with http:// or https://: {url:?}"
378 ));
379 }
380 *url = trimmed;
381 }
382
383 // site
384 if self.site.title.trim().is_empty() {
385 errors.push("site.title must not be empty".into());
386 }
387 for (i, url) in self.site.clone_urls.iter().enumerate() {
388 if !url.contains("{name}") {
389 errors.push(format!(
390 "site.clone_urls[{i}] must contain the {{name}} placeholder: {url:?}"
391 ));
392 }
393 }
394 self.custom = Customization {
395 css: read_custom(
396 &base_dir,
397 "site.custom_css",
398 &mut self.site.custom_css,
399 &mut errors,
400 ),
401 header: read_custom(&base_dir, "site.header", &mut self.site.header, &mut errors),
402 footer: read_custom(&base_dir, "site.footer", &mut self.site.footer, &mut errors),
403 };
404
405 // repos
406 if self.repos.scan_path.is_relative() {
407 self.repos.scan_path = base_dir.join(&self.repos.scan_path);
408 }
409 if !(1..=16).contains(&self.repos.max_depth) {
410 errors.push(format!(
411 "repos.max_depth must be between 1 and 16, got {}",
412 self.repos.max_depth
413 ));
414 }
415 let mut seen = std::collections::HashSet::new();
416 for (i, repo) in self.repo_overrides.iter_mut().enumerate() {
417 let name = repo.name.trim().trim_matches('/').trim_end_matches(".git");
418 if name.is_empty() {
419 errors.push(format!("[[repo]] #{} has an empty name", i + 1));
420 continue;
421 }
422 repo.name = name.to_string();
423 if !seen.insert(repo.name.clone()) {
424 errors.push(format!(
425 "[[repo]] {:?} is defined more than once",
426 repo.name
427 ));
428 }
429 }
430
431 // ui
432 if self.ui.code_font.trim().is_empty() {
433 errors.push("ui.code_font must not be empty".into());
434 } else if self
435 .ui
436 .code_font
437 .chars()
438 .any(|c| matches!(c, '"' | '\'' | ';' | '{' | '}' | '<' | '>' | '\\'))
439 {
440 errors.push(format!(
441 "ui.code_font contains characters not allowed in a font name: {:?}",
442 self.ui.code_font
443 ));
444 }
445 if self.ui.max_blob_kb == 0 {
446 errors.push("ui.max_blob_kb must be greater than 0".into());
447 }
448
449 // cache / watch / limits
450 if self.cache.size_mb == 0 {
451 errors.push("cache.size_mb must be greater than 0".into());
452 }
453 if self.watch.debounce_ms > 60_000 {
454 errors.push("watch.debounce_ms must be at most 60000".into());
455 }
456 if self.limits.heavy_parallel == 0 {
457 errors.push("limits.heavy_parallel must be at least 1".into());
458 }
459 if self.limits.request_timeout_secs == 0 {
460 errors.push("limits.request_timeout_secs must be at least 1".into());
461 }
462
463 // log
464 if let Err(e) = tracing_subscriber::EnvFilter::try_new(&self.log.level) {
465 errors.push(format!("log.level {:?} is invalid: {e}", self.log.level));
466 }
467
468 if errors.is_empty() {
469 Ok(())
470 } else {
471 Err(ConfigError::Invalid(errors))
472 }
473 }
474
475 /// Effective number of concurrent git workers.
476 pub fn git_parallel(&self) -> usize {
477 match self.limits.git_parallel {
478 0 => std::thread::available_parallelism().map_or(4, |n| n.get() * 2),
479 n => n,
480 }
481 }
482}
483
484/// Resolve a customization file path against `base_dir` and read it.
485fn read_custom(
486 base_dir: &Path,
487 key: &str,
488 path: &mut Option<PathBuf>,
489 errors: &mut Vec<String>,
490) -> Option<Arc<str>> {
491 let p = path.as_mut()?;
492 if p.is_relative() {
493 *p = base_dir.join(&*p);
494 }
495 match std::fs::read_to_string(&*p) {
496 Ok(text) => Some(text.into()),
497 Err(e) => {
498 errors.push(format!("{key}: cannot read {}: {e}", p.display()));
499 None
500 }
501 }
502}
503
504#[cfg(test)]
505mod tests {
506 use super::*;
507
508 fn finish(text: &str) -> Result<Config, ConfigError> {
509 let mut c = Config::parse(text).map_err(|message| ConfigError::Parse {
510 path: "test.toml".into(),
511 message,
512 })?;
513 c.finish()?;
514 Ok(c)
515 }
516
517 #[test]
518 fn empty_config_uses_defaults() {
519 let c = finish("").unwrap();
520 assert_eq!(c.server.listen, "127.0.0.1:8080".parse().unwrap());
521 assert_eq!(c.server.base_path, "/");
522 assert_eq!(c.repos.scan_path, PathBuf::from("/home/git"));
523 assert_eq!(c.repos.max_depth, 3);
524 assert_eq!(c.ui.theme, Theme::Auto);
525 assert_eq!(c.cache.size_mb, 64);
526 assert_eq!(
527 c.repos.snapshots,
528 vec![SnapshotFormat::TarGz, SnapshotFormat::Zip]
529 );
530 assert!(c.git_parallel() >= 1);
531 }
532
533 #[test]
534 fn example_config_is_valid() {
535 let c = finish(crate::examples::CONFIG).unwrap();
536 assert_eq!(c.site.title, "krolyxon");
537 }
538
539 #[test]
540 fn unknown_keys_are_rejected_with_position() {
541 let err = finish("[server]\nlisten = \"127.0.0.1:1\"\nlistn = 1\n").unwrap_err();
542 let msg = err.to_string();
543 assert!(msg.contains("unknown field `listn`"), "{msg}");
544 assert!(msg.contains("line 3"), "{msg}");
545
546 let err = finish("[sever]\n").unwrap_err().to_string();
547 assert!(err.contains("unknown field `sever`"), "{err}");
548 }
549
550 #[test]
551 fn listen_parsing() {
552 assert_eq!(
553 "unix:/run/ferrenium/ferrenium.sock".parse::<Listen>(),
554 Ok(Listen::Unix("/run/ferrenium/ferrenium.sock".into()))
555 );
556 assert!("unix:relative.sock".parse::<Listen>().is_err());
557 assert!("localhost".parse::<Listen>().is_err());
558 assert_eq!(
559 "[::1]:9000".parse::<Listen>(),
560 Ok(Listen::Tcp("[::1]:9000".parse().unwrap()))
561 );
562 let err = finish("[server]\nlisten = \"nope\"\n")
563 .unwrap_err()
564 .to_string();
565 assert!(err.contains("invalid listen address"), "{err}");
566 }
567
568 #[test]
569 fn base_path_and_url_are_normalized() {
570 let c = finish("[server]\nbase_path = \"/git\"\nbase_url = \"https://x.org/\"\n").unwrap();
571 assert_eq!(c.server.base_path, "/git/");
572 assert_eq!(c.server.base_url.as_deref(), Some("https://x.org"));
573 assert_eq!(
574 finish("[server]\nbase_path = \"/\"\n")
575 .unwrap()
576 .server
577 .base_path,
578 "/"
579 );
580 }
581
582 #[test]
583 fn validation_reports_all_errors() {
584 let err = finish(
585 r#"
586 [server]
587 base_path = "git"
588 base_url = "ftp://x"
589 [repos]
590 max_depth = 0
591 [ui]
592 theme = "dark"
593 code_font = "x; } body { display: none"
594 [cache]
595 size_mb = 0
596 [[repo]]
597 name = "a.git"
598 [[repo]]
599 name = "a"
600 "#,
601 )
602 .unwrap_err();
603 let ConfigError::Invalid(errors) = err else {
604 panic!("expected validation errors, got {err}");
605 };
606 let all = errors.join("\n");
607 for needle in [
608 "base_path",
609 "base_url",
610 "max_depth",
611 "code_font",
612 "size_mb",
613 "defined more than once",
614 ] {
615 assert!(all.contains(needle), "missing {needle:?} in:\n{all}");
616 }
617 }
618
619 #[test]
620 fn invalid_enum_values_are_rejected() {
621 assert!(finish("[ui]\ntheme = \"purple\"\n").is_err());
622 assert!(finish("[repos]\nsnapshots = [\"rar\"]\n").is_err());
623 }
624
625 #[test]
626 fn custom_files_resolve_relative_to_config() {
627 let dir = tempfile::tempdir().unwrap();
628 std::fs::write(dir.path().join("custom.css"), "body{}").unwrap();
629 let path = dir.path().join("ferrenium.toml");
630 std::fs::write(
631 &path,
632 "[site]\ncustom_css = \"custom.css\"\n[repos]\nscan_path = \"repos\"\n",
633 )
634 .unwrap();
635 let c = Config::load(Some(&path)).unwrap();
636 assert_eq!(c.custom.css.as_deref(), Some("body{}"));
637 assert_eq!(c.repos.scan_path, dir.path().join("repos"));
638
639 std::fs::write(&path, "[site]\nfooter = \"missing.html\"\n").unwrap();
640 let err = Config::load(Some(&path)).unwrap_err().to_string();
641 assert!(err.contains("site.footer"), "{err}");
642 }
643}