Krolyxon
Commit 3cb9866

ferreniumsrcconfig.rs

702 lines · 20.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 /// Default order of the repository index (`?sort=` overrides it).
158 pub index_sort: IndexSort,
159 pub code_font: String,
160 pub max_blob_kb: u64,
161 pub max_highlight_kb: u64,
162 /// Commits per log page.
163 pub log_page: usize,
164}
165
166impl Default for UiConfig {
167 fn default() -> Self {
168 Self {
169 theme: Theme::Auto,
170 index_sort: IndexSort::Activity,
171 code_font: DEFAULT_CODE_FONT.into(),
172 max_blob_kb: 1024,
173 max_highlight_kb: 256,
174 log_page: 50,
175 }
176 }
177}
178
179pub const DEFAULT_CODE_FONT: &str = "Space Mono";
180
181#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
182#[serde(rename_all = "lowercase")]
183pub enum IndexSort {
184 /// Most recently pushed first, as one list.
185 #[default]
186 Activity,
187 /// By section, then name.
188 Name,
189}
190
191impl IndexSort {
192 pub fn as_str(self) -> &'static str {
193 match self {
194 IndexSort::Activity => "activity",
195 IndexSort::Name => "name",
196 }
197 }
198}
199
200#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
201#[serde(rename_all = "lowercase")]
202pub enum Theme {
203 #[default]
204 Auto,
205 Dark,
206 Light,
207}
208
209impl Theme {
210 pub fn as_str(self) -> &'static str {
211 match self {
212 Theme::Auto => "auto",
213 Theme::Dark => "dark",
214 Theme::Light => "light",
215 }
216 }
217}
218
219#[derive(Debug, Clone, Deserialize)]
220#[serde(deny_unknown_fields, default)]
221pub struct CacheConfig {
222 pub size_mb: u64,
223}
224
225impl Default for CacheConfig {
226 fn default() -> Self {
227 Self { size_mb: 64 }
228 }
229}
230
231#[derive(Debug, Clone, Deserialize)]
232#[serde(deny_unknown_fields, default)]
233pub struct WatchConfig {
234 pub enabled: bool,
235 pub debounce_ms: u64,
236 /// Full rescan interval; 0 disables polling.
237 pub poll_interval_secs: u64,
238}
239
240impl Default for WatchConfig {
241 fn default() -> Self {
242 Self {
243 enabled: true,
244 debounce_ms: 500,
245 poll_interval_secs: 300,
246 }
247 }
248}
249
250#[derive(Debug, Clone, Deserialize)]
251#[serde(deny_unknown_fields, default)]
252pub struct LimitsConfig {
253 /// 0 = available CPUs, at most 8.
254 pub git_parallel: usize,
255 pub heavy_parallel: usize,
256 /// Seconds a heavy request may wait for a permit before a 503.
257 pub heavy_wait_secs: u64,
258 /// Requests in flight at once; more are refused with a 503.
259 pub max_requests: usize,
260 pub request_timeout_secs: u64,
261 /// Commits a filtered log (path or grep) examines per page.
262 pub log_scan: usize,
263 /// Above these, a commit's diff is not shown in full.
264 pub diff_files: usize,
265 pub diff_lines: usize,
266 pub diff_file_lines: usize,
267}
268
269impl Default for LimitsConfig {
270 fn default() -> Self {
271 Self {
272 git_parallel: 0,
273 heavy_parallel: 2,
274 heavy_wait_secs: 10,
275 max_requests: 512,
276 request_timeout_secs: 30,
277 log_scan: 10_000,
278 diff_files: 300,
279 diff_lines: 10_000,
280 diff_file_lines: 2_000,
281 }
282 }
283}
284
285#[derive(Debug, Clone, Deserialize)]
286#[serde(deny_unknown_fields, default)]
287pub struct LogConfig {
288 pub level: String,
289}
290
291impl Default for LogConfig {
292 fn default() -> Self {
293 Self {
294 level: "info".into(),
295 }
296 }
297}
298
299/// Where the server listens: a TCP socket address or `unix:/abs/path`.
300#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
301#[serde(try_from = "String")]
302pub enum Listen {
303 Tcp(SocketAddr),
304 Unix(PathBuf),
305}
306
307impl FromStr for Listen {
308 type Err = String;
309
310 fn from_str(s: &str) -> Result<Self, Self::Err> {
311 if let Some(path) = s.strip_prefix("unix:") {
312 let path = PathBuf::from(path);
313 if !path.is_absolute() {
314 return Err(format!("unix socket path must be absolute: {s:?}"));
315 }
316 return Ok(Listen::Unix(path));
317 }
318 s.parse::<SocketAddr>().map(Listen::Tcp).map_err(|_| {
319 format!("invalid listen address {s:?} (expected HOST:PORT or unix:/path/to.sock)")
320 })
321 }
322}
323
324impl TryFrom<String> for Listen {
325 type Error = String;
326
327 fn try_from(s: String) -> Result<Self, Self::Error> {
328 s.parse()
329 }
330}
331
332impl fmt::Display for Listen {
333 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
334 match self {
335 Listen::Tcp(addr) => write!(f, "http://{addr}"),
336 Listen::Unix(path) => write!(f, "unix:{}", path.display()),
337 }
338 }
339}
340
341/// Find the config file: explicit path (flag or `$FERRENIUM_CONFIG`), then the
342/// system location, then `$XDG_CONFIG_HOME/ferrenium/ferrenium.toml`.
343///
344/// Returns `None` when no explicit path was given and no file exists, in which
345/// case the built-in defaults apply.
346pub fn resolve_path(explicit: Option<&Path>) -> Option<PathBuf> {
347 if let Some(path) = explicit {
348 return Some(path.to_path_buf());
349 }
350 let system = PathBuf::from(SYSTEM_CONFIG);
351 if system.is_file() {
352 return Some(system);
353 }
354 let xdg = std::env::var_os("XDG_CONFIG_HOME")
355 .map(PathBuf::from)
356 .filter(|p| p.is_absolute())
357 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?;
358 let user = xdg.join("ferrenium/ferrenium.toml");
359 user.is_file().then_some(user)
360}
361
362impl Config {
363 /// Load, normalize and validate the config at `path` (or the defaults).
364 pub fn load(path: Option<&Path>) -> Result<Config, ConfigError> {
365 let mut config = match path {
366 Some(path) => {
367 let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Io {
368 path: path.to_path_buf(),
369 source,
370 })?;
371 let mut config = Config::parse(&text).map_err(|message| ConfigError::Parse {
372 path: path.to_path_buf(),
373 message,
374 })?;
375 config.source = Some(path.to_path_buf());
376 config
377 }
378 None => Config::default(),
379 };
380 config.finish()?;
381 Ok(config)
382 }
383
384 /// Parse TOML without validation. Errors carry line/column and a snippet.
385 pub fn parse(text: &str) -> Result<Config, String> {
386 toml::from_str(text).map_err(|e| e.to_string())
387 }
388
389 /// Directory that relative paths in the config are resolved against.
390 pub fn base_dir(&self) -> PathBuf {
391 match self.source.as_deref().and_then(Path::parent) {
392 Some(dir) if !dir.as_os_str().is_empty() => dir.to_path_buf(),
393 _ => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
394 }
395 }
396
397 /// Normalize values, resolve relative paths, read customization files and
398 /// validate. All problems are reported together.
399 pub fn finish(&mut self) -> Result<(), ConfigError> {
400 let mut errors = Vec::new();
401 let base_dir = self.base_dir();
402
403 // server
404 let bp = self.server.base_path.trim();
405 if !bp.starts_with('/') {
406 errors.push(format!("server.base_path must start with '/': {bp:?}"));
407 } else if bp.contains("//") || bp.split('/').any(|s| s == "." || s == "..") {
408 errors.push(format!("server.base_path is not a clean path: {bp:?}"));
409 }
410 let mut bp = bp.trim_end_matches('/').to_string();
411 bp.push('/');
412 self.server.base_path = bp;
413
414 if let Some(url) = &mut self.server.base_url {
415 let trimmed = url.trim().trim_end_matches('/').to_string();
416 if !(trimmed.starts_with("https://") || trimmed.starts_with("http://")) {
417 errors.push(format!(
418 "server.base_url must start with http:// or https://: {url:?}"
419 ));
420 }
421 *url = trimmed;
422 }
423
424 // site
425 if self.site.title.trim().is_empty() {
426 errors.push("site.title must not be empty".into());
427 }
428 for (i, url) in self.site.clone_urls.iter().enumerate() {
429 if !url.contains("{name}") {
430 errors.push(format!(
431 "site.clone_urls[{i}] must contain the {{name}} placeholder: {url:?}"
432 ));
433 }
434 }
435 self.custom = Customization {
436 css: read_custom(
437 &base_dir,
438 "site.custom_css",
439 &mut self.site.custom_css,
440 &mut errors,
441 ),
442 header: read_custom(&base_dir, "site.header", &mut self.site.header, &mut errors),
443 footer: read_custom(&base_dir, "site.footer", &mut self.site.footer, &mut errors),
444 };
445
446 // repos
447 if self.repos.scan_path.is_relative() {
448 self.repos.scan_path = base_dir.join(&self.repos.scan_path);
449 }
450 if !(1..=16).contains(&self.repos.max_depth) {
451 errors.push(format!(
452 "repos.max_depth must be between 1 and 16, got {}",
453 self.repos.max_depth
454 ));
455 }
456 let mut seen = std::collections::HashSet::new();
457 for (i, repo) in self.repo_overrides.iter_mut().enumerate() {
458 let name = repo.name.trim().trim_matches('/').trim_end_matches(".git");
459 if name.is_empty() {
460 errors.push(format!("[[repo]] #{} has an empty name", i + 1));
461 continue;
462 }
463 repo.name = name.to_string();
464 if !seen.insert(repo.name.clone()) {
465 errors.push(format!(
466 "[[repo]] {:?} is defined more than once",
467 repo.name
468 ));
469 }
470 }
471
472 // ui
473 if self.ui.code_font.trim().is_empty() {
474 errors.push("ui.code_font must not be empty".into());
475 } else if self
476 .ui
477 .code_font
478 .chars()
479 .any(|c| matches!(c, '"' | '\'' | ';' | '{' | '}' | '<' | '>' | '\\'))
480 {
481 errors.push(format!(
482 "ui.code_font contains characters not allowed in a font name: {:?}",
483 self.ui.code_font
484 ));
485 }
486 if self.ui.max_blob_kb == 0 {
487 errors.push("ui.max_blob_kb must be greater than 0".into());
488 }
489
490 // cache / watch / limits
491 if self.cache.size_mb == 0 {
492 errors.push("cache.size_mb must be greater than 0".into());
493 }
494 if self.watch.debounce_ms > 60_000 {
495 errors.push("watch.debounce_ms must be at most 60000".into());
496 }
497 if self.limits.heavy_parallel == 0 {
498 errors.push("limits.heavy_parallel must be at least 1".into());
499 }
500 if self.limits.max_requests == 0 {
501 errors.push("limits.max_requests must be at least 1".into());
502 }
503 if self.limits.request_timeout_secs == 0 {
504 errors.push("limits.request_timeout_secs must be at least 1".into());
505 }
506 if !(1..=1000).contains(&self.ui.log_page) {
507 errors.push("ui.log_page must be between 1 and 1000".into());
508 }
509 for (key, value) in [
510 ("log_scan", self.limits.log_scan),
511 ("diff_files", self.limits.diff_files),
512 ("diff_lines", self.limits.diff_lines),
513 ("diff_file_lines", self.limits.diff_file_lines),
514 ] {
515 if value == 0 {
516 errors.push(format!("limits.{key} must be at least 1"));
517 }
518 }
519
520 // log
521 if let Err(e) = tracing_subscriber::EnvFilter::try_new(&self.log.level) {
522 errors.push(format!("log.level {:?} is invalid: {e}", self.log.level));
523 }
524
525 if errors.is_empty() {
526 Ok(())
527 } else {
528 Err(ConfigError::Invalid(errors))
529 }
530 }
531
532 /// Effective number of concurrent git workers.
533 pub fn git_parallel(&self) -> usize {
534 match self.limits.git_parallel {
535 // Git work is CPU-bound; more workers than cores only add memory.
536 0 => std::thread::available_parallelism().map_or(4, |n| n.get().min(8)),
537 n => n,
538 }
539 }
540}
541
542/// Resolve a customization file path against `base_dir` and read it.
543fn read_custom(
544 base_dir: &Path,
545 key: &str,
546 path: &mut Option<PathBuf>,
547 errors: &mut Vec<String>,
548) -> Option<Arc<str>> {
549 let p = path.as_mut()?;
550 if p.is_relative() {
551 *p = base_dir.join(&*p);
552 }
553 match std::fs::read_to_string(&*p) {
554 Ok(text) => Some(text.into()),
555 Err(e) => {
556 errors.push(format!("{key}: cannot read {}: {e}", p.display()));
557 None
558 }
559 }
560}
561
562#[cfg(test)]
563mod tests {
564 use super::*;
565
566 fn finish(text: &str) -> Result<Config, ConfigError> {
567 let mut c = Config::parse(text).map_err(|message| ConfigError::Parse {
568 path: "test.toml".into(),
569 message,
570 })?;
571 c.finish()?;
572 Ok(c)
573 }
574
575 #[test]
576 fn empty_config_uses_defaults() {
577 let c = finish("").unwrap();
578 assert_eq!(c.server.listen, "127.0.0.1:8080".parse().unwrap());
579 assert_eq!(c.server.base_path, "/");
580 assert_eq!(c.repos.scan_path, PathBuf::from("/home/git"));
581 assert_eq!(c.repos.max_depth, 3);
582 assert_eq!(c.ui.theme, Theme::Auto);
583 assert_eq!(c.ui.index_sort, IndexSort::Activity);
584 assert_eq!(c.cache.size_mb, 64);
585 assert_eq!(
586 c.repos.snapshots,
587 vec![SnapshotFormat::TarGz, SnapshotFormat::Zip]
588 );
589 assert!(c.git_parallel() >= 1);
590 }
591
592 #[test]
593 fn example_config_is_valid() {
594 let c = finish(crate::examples::CONFIG).unwrap();
595 assert_eq!(c.site.title, "ferrenium");
596 }
597
598 #[test]
599 fn unknown_keys_are_rejected_with_position() {
600 let err = finish("[server]\nlisten = \"127.0.0.1:1\"\nlistn = 1\n").unwrap_err();
601 let msg = err.to_string();
602 assert!(msg.contains("unknown field `listn`"), "{msg}");
603 assert!(msg.contains("line 3"), "{msg}");
604
605 let err = finish("[sever]\n").unwrap_err().to_string();
606 assert!(err.contains("unknown field `sever`"), "{err}");
607 }
608
609 #[test]
610 fn listen_parsing() {
611 assert_eq!(
612 "unix:/run/ferrenium/ferrenium.sock".parse::<Listen>(),
613 Ok(Listen::Unix("/run/ferrenium/ferrenium.sock".into()))
614 );
615 assert!("unix:relative.sock".parse::<Listen>().is_err());
616 assert!("localhost".parse::<Listen>().is_err());
617 assert_eq!(
618 "[::1]:9000".parse::<Listen>(),
619 Ok(Listen::Tcp("[::1]:9000".parse().unwrap()))
620 );
621 let err = finish("[server]\nlisten = \"nope\"\n")
622 .unwrap_err()
623 .to_string();
624 assert!(err.contains("invalid listen address"), "{err}");
625 }
626
627 #[test]
628 fn base_path_and_url_are_normalized() {
629 let c = finish("[server]\nbase_path = \"/git\"\nbase_url = \"https://x.org/\"\n").unwrap();
630 assert_eq!(c.server.base_path, "/git/");
631 assert_eq!(c.server.base_url.as_deref(), Some("https://x.org"));
632 assert_eq!(
633 finish("[server]\nbase_path = \"/\"\n")
634 .unwrap()
635 .server
636 .base_path,
637 "/"
638 );
639 }
640
641 #[test]
642 fn validation_reports_all_errors() {
643 let err = finish(
644 r#"
645 [server]
646 base_path = "git"
647 base_url = "ftp://x"
648 [repos]
649 max_depth = 0
650 [ui]
651 theme = "dark"
652 code_font = "x; } body { display: none"
653 [cache]
654 size_mb = 0
655 [[repo]]
656 name = "a.git"
657 [[repo]]
658 name = "a"
659 "#,
660 )
661 .unwrap_err();
662 let ConfigError::Invalid(errors) = err else {
663 panic!("expected validation errors, got {err}");
664 };
665 let all = errors.join("\n");
666 for needle in [
667 "base_path",
668 "base_url",
669 "max_depth",
670 "code_font",
671 "size_mb",
672 "defined more than once",
673 ] {
674 assert!(all.contains(needle), "missing {needle:?} in:\n{all}");
675 }
676 }
677
678 #[test]
679 fn invalid_enum_values_are_rejected() {
680 assert!(finish("[ui]\ntheme = \"purple\"\n").is_err());
681 assert!(finish("[repos]\nsnapshots = [\"rar\"]\n").is_err());
682 }
683
684 #[test]
685 fn custom_files_resolve_relative_to_config() {
686 let dir = tempfile::tempdir().unwrap();
687 std::fs::write(dir.path().join("custom.css"), "body{}").unwrap();
688 let path = dir.path().join("ferrenium.toml");
689 std::fs::write(
690 &path,
691 "[site]\ncustom_css = \"custom.css\"\n[repos]\nscan_path = \"repos\"\n",
692 )
693 .unwrap();
694 let c = Config::load(Some(&path)).unwrap();
695 assert_eq!(c.custom.css.as_deref(), Some("body{}"));
696 assert_eq!(c.repos.scan_path, dir.path().join("repos"));
697
698 std::fs::write(&path, "[site]\nfooter = \"missing.html\"\n").unwrap();
699 let err = Config::load(Some(&path)).unwrap_err().to_string();
700 assert!(err.contains("site.footer"), "{err}");
701 }
702}