Krolyxon
Commit 4e844bb

ferreniumsrcconfig.rs

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