Krolyxon
Commit e758199

ferreniumsrcconfig.rs

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