Krolyxon
Commit 4030c43

ferreniumsrcconfig.rs

898 lines · 27.7 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` / `favicon` / `logo`,
49 /// read at load time so that a reload picks up edits and requests never
50 /// touch the filesystem.
51 #[serde(skip)]
52 pub custom: Customization,
53}
54
55#[derive(Debug, Clone, Default)]
56pub struct Customization {
57 pub css: Option<Arc<str>>,
58 pub header: Option<Arc<str>>,
59 pub footer: Option<Arc<str>>,
60 pub favicon: Option<SiteImage>,
61 pub logo: Option<SiteImage>,
62}
63
64/// An image from `site.favicon` / `site.logo`, served from memory.
65#[derive(Debug, Clone)]
66pub struct SiteImage {
67 pub data: bytes::Bytes,
68 pub content_type: &'static str,
69 /// Short content hash for the `?v=` cache-busting query.
70 pub version: String,
71}
72
73/// Largest accepted `site.favicon` / `site.logo` file.
74const MAX_IMAGE_BYTES: u64 = 1024 * 1024;
75
76#[derive(Debug, Clone, Deserialize)]
77#[serde(deny_unknown_fields, default)]
78pub struct ServerConfig {
79 pub listen: Listen,
80 pub base_url: Option<String>,
81 pub base_path: String,
82 pub trusted_proxy: bool,
83 pub compression: bool,
84}
85
86impl Default for ServerConfig {
87 fn default() -> Self {
88 Self {
89 listen: Listen::Tcp(SocketAddr::from(([127, 0, 0, 1], 8080))),
90 base_url: None,
91 base_path: "/".into(),
92 trusted_proxy: false,
93 compression: true,
94 }
95 }
96}
97
98#[derive(Debug, Clone, Deserialize)]
99#[serde(deny_unknown_fields, default)]
100pub struct SiteConfig {
101 pub title: String,
102 pub description: String,
103 pub clone_urls: Vec<String>,
104 pub custom_css: Option<PathBuf>,
105 pub header: Option<PathBuf>,
106 pub footer: Option<PathBuf>,
107 pub favicon: Option<PathBuf>,
108 pub logo: Option<PathBuf>,
109 /// Directory served at `/-/site/` for files used by `custom_css`.
110 pub assets_dir: Option<PathBuf>,
111}
112
113impl Default for SiteConfig {
114 fn default() -> Self {
115 Self {
116 title: "ferrenium".into(),
117 description: String::new(),
118 clone_urls: Vec::new(),
119 custom_css: None,
120 header: None,
121 footer: None,
122 favicon: None,
123 logo: None,
124 assets_dir: None,
125 }
126 }
127}
128
129#[derive(Debug, Clone, Deserialize)]
130#[serde(deny_unknown_fields, default)]
131pub struct ReposConfig {
132 pub scan_path: PathBuf,
133 pub max_depth: u32,
134 pub follow_symlinks: bool,
135 pub section_from_path: bool,
136 pub hide: Vec<String>,
137 pub snapshots: Vec<SnapshotFormat>,
138 pub https_clone: bool,
139}
140
141impl Default for ReposConfig {
142 fn default() -> Self {
143 Self {
144 scan_path: "/home/git".into(),
145 max_depth: 3,
146 follow_symlinks: false,
147 section_from_path: false,
148 hide: Vec::new(),
149 snapshots: vec![SnapshotFormat::TarGz, SnapshotFormat::Zip],
150 https_clone: true,
151 }
152 }
153}
154
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
156pub enum SnapshotFormat {
157 #[serde(rename = "tar.gz")]
158 TarGz,
159 #[serde(rename = "zip")]
160 Zip,
161}
162
163#[derive(Debug, Clone, Default, Deserialize)]
164#[serde(deny_unknown_fields)]
165pub struct RepoOverride {
166 pub name: String,
167 pub description: Option<String>,
168 pub owner: Option<String>,
169 pub section: Option<String>,
170 pub hidden: Option<bool>,
171 pub readme: Option<String>,
172 pub default_branch: Option<String>,
173}
174
175#[derive(Debug, Clone, Deserialize)]
176#[serde(deny_unknown_fields, default)]
177pub struct UiConfig {
178 pub theme: Theme,
179 /// Default order of the repository index (`?sort=` overrides it).
180 pub index_sort: IndexSort,
181 pub code_font: String,
182 /// `#rgb` or `#rrggbb`; replaces the default red accent.
183 pub accent: Option<String>,
184 pub max_blob_kb: u64,
185 pub max_highlight_kb: u64,
186 /// Commits per log page.
187 pub log_page: usize,
188}
189
190impl Default for UiConfig {
191 fn default() -> Self {
192 Self {
193 theme: Theme::Auto,
194 index_sort: IndexSort::Activity,
195 code_font: DEFAULT_CODE_FONT.into(),
196 accent: None,
197 max_blob_kb: 1024,
198 max_highlight_kb: 256,
199 log_page: 50,
200 }
201 }
202}
203
204pub const DEFAULT_CODE_FONT: &str = "Space Mono";
205
206#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
207#[serde(rename_all = "lowercase")]
208pub enum IndexSort {
209 /// Most recently pushed first, as one list.
210 #[default]
211 Activity,
212 /// By section, then name.
213 Name,
214}
215
216impl IndexSort {
217 pub fn as_str(self) -> &'static str {
218 match self {
219 IndexSort::Activity => "activity",
220 IndexSort::Name => "name",
221 }
222 }
223}
224
225#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
226#[serde(rename_all = "lowercase")]
227pub enum Theme {
228 #[default]
229 Auto,
230 Dark,
231 Light,
232}
233
234impl Theme {
235 pub fn as_str(self) -> &'static str {
236 match self {
237 Theme::Auto => "auto",
238 Theme::Dark => "dark",
239 Theme::Light => "light",
240 }
241 }
242}
243
244#[derive(Debug, Clone, Deserialize)]
245#[serde(deny_unknown_fields, default)]
246pub struct CacheConfig {
247 pub size_mb: u64,
248}
249
250impl Default for CacheConfig {
251 fn default() -> Self {
252 Self { size_mb: 64 }
253 }
254}
255
256#[derive(Debug, Clone, Deserialize)]
257#[serde(deny_unknown_fields, default)]
258pub struct WatchConfig {
259 pub enabled: bool,
260 pub debounce_ms: u64,
261 /// Full rescan interval; 0 disables polling.
262 pub poll_interval_secs: u64,
263}
264
265impl Default for WatchConfig {
266 fn default() -> Self {
267 Self {
268 enabled: true,
269 debounce_ms: 500,
270 poll_interval_secs: 300,
271 }
272 }
273}
274
275#[derive(Debug, Clone, Deserialize)]
276#[serde(deny_unknown_fields, default)]
277pub struct LimitsConfig {
278 /// 0 = available CPUs, at most 8.
279 pub git_parallel: usize,
280 pub heavy_parallel: usize,
281 /// Seconds a heavy request may wait for a permit before a 503.
282 pub heavy_wait_secs: u64,
283 /// Requests in flight at once; more are refused with a 503.
284 pub max_requests: usize,
285 pub request_timeout_secs: u64,
286 /// Commits a filtered log (path or grep) examines per page.
287 pub log_scan: usize,
288 /// Above these, a commit's diff is not shown in full.
289 pub diff_files: usize,
290 pub diff_lines: usize,
291 pub diff_file_lines: usize,
292}
293
294impl Default for LimitsConfig {
295 fn default() -> Self {
296 Self {
297 git_parallel: 0,
298 heavy_parallel: 2,
299 heavy_wait_secs: 10,
300 max_requests: 512,
301 request_timeout_secs: 30,
302 log_scan: 10_000,
303 diff_files: 300,
304 diff_lines: 10_000,
305 diff_file_lines: 2_000,
306 }
307 }
308}
309
310#[derive(Debug, Clone, Deserialize)]
311#[serde(deny_unknown_fields, default)]
312pub struct LogConfig {
313 pub level: String,
314}
315
316impl Default for LogConfig {
317 fn default() -> Self {
318 Self {
319 level: "info".into(),
320 }
321 }
322}
323
324/// Where the server listens: a TCP socket address or `unix:/abs/path`.
325#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
326#[serde(try_from = "String")]
327pub enum Listen {
328 Tcp(SocketAddr),
329 Unix(PathBuf),
330}
331
332impl FromStr for Listen {
333 type Err = String;
334
335 fn from_str(s: &str) -> Result<Self, Self::Err> {
336 if let Some(path) = s.strip_prefix("unix:") {
337 let path = PathBuf::from(path);
338 if !path.is_absolute() {
339 return Err(format!("unix socket path must be absolute: {s:?}"));
340 }
341 return Ok(Listen::Unix(path));
342 }
343 s.parse::<SocketAddr>().map(Listen::Tcp).map_err(|_| {
344 format!("invalid listen address {s:?} (expected HOST:PORT or unix:/path/to.sock)")
345 })
346 }
347}
348
349impl TryFrom<String> for Listen {
350 type Error = String;
351
352 fn try_from(s: String) -> Result<Self, Self::Error> {
353 s.parse()
354 }
355}
356
357impl fmt::Display for Listen {
358 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
359 match self {
360 Listen::Tcp(addr) => write!(f, "http://{addr}"),
361 Listen::Unix(path) => write!(f, "unix:{}", path.display()),
362 }
363 }
364}
365
366/// Find the config file: explicit path (flag or `$FERRENIUM_CONFIG`), then the
367/// system location, then `$XDG_CONFIG_HOME/ferrenium/ferrenium.toml`.
368///
369/// Returns `None` when no explicit path was given and no file exists, in which
370/// case the built-in defaults apply.
371pub fn resolve_path(explicit: Option<&Path>) -> Option<PathBuf> {
372 if let Some(path) = explicit {
373 return Some(path.to_path_buf());
374 }
375 let system = PathBuf::from(SYSTEM_CONFIG);
376 if system.is_file() {
377 return Some(system);
378 }
379 let xdg = std::env::var_os("XDG_CONFIG_HOME")
380 .map(PathBuf::from)
381 .filter(|p| p.is_absolute())
382 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?;
383 let user = xdg.join("ferrenium/ferrenium.toml");
384 user.is_file().then_some(user)
385}
386
387impl Config {
388 /// Load, normalize and validate the config at `path` (or the defaults).
389 pub fn load(path: Option<&Path>) -> Result<Config, ConfigError> {
390 let mut config = match path {
391 Some(path) => {
392 let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Io {
393 path: path.to_path_buf(),
394 source,
395 })?;
396 let mut config = Config::parse(&text).map_err(|message| ConfigError::Parse {
397 path: path.to_path_buf(),
398 message,
399 })?;
400 config.source = Some(path.to_path_buf());
401 config
402 }
403 None => Config::default(),
404 };
405 config.finish()?;
406 Ok(config)
407 }
408
409 /// Parse TOML without validation. Errors carry line/column and a snippet.
410 pub fn parse(text: &str) -> Result<Config, String> {
411 toml::from_str(text).map_err(|e| e.to_string())
412 }
413
414 /// Directory that relative paths in the config are resolved against.
415 pub fn base_dir(&self) -> PathBuf {
416 match self.source.as_deref().and_then(Path::parent) {
417 Some(dir) if !dir.as_os_str().is_empty() => dir.to_path_buf(),
418 _ => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
419 }
420 }
421
422 /// Normalize values, resolve relative paths, read customization files and
423 /// validate. All problems are reported together.
424 pub fn finish(&mut self) -> Result<(), ConfigError> {
425 let mut errors = Vec::new();
426 let base_dir = self.base_dir();
427
428 // server
429 let bp = self.server.base_path.trim();
430 if !bp.starts_with('/') {
431 errors.push(format!("server.base_path must start with '/': {bp:?}"));
432 } else if bp.contains("//") || bp.split('/').any(|s| s == "." || s == "..") {
433 errors.push(format!("server.base_path is not a clean path: {bp:?}"));
434 }
435 let mut bp = bp.trim_end_matches('/').to_string();
436 bp.push('/');
437 self.server.base_path = bp;
438
439 if let Some(url) = &mut self.server.base_url {
440 let trimmed = url.trim().trim_end_matches('/').to_string();
441 if !(trimmed.starts_with("https://") || trimmed.starts_with("http://")) {
442 errors.push(format!(
443 "server.base_url must start with http:// or https://: {url:?}"
444 ));
445 }
446 *url = trimmed;
447 }
448
449 // site
450 if self.site.title.trim().is_empty() {
451 errors.push("site.title must not be empty".into());
452 }
453 for (i, url) in self.site.clone_urls.iter().enumerate() {
454 if !url.contains("{name}") {
455 errors.push(format!(
456 "site.clone_urls[{i}] must contain the {{name}} placeholder: {url:?}"
457 ));
458 }
459 }
460 self.custom = Customization {
461 css: read_custom(
462 &base_dir,
463 "site.custom_css",
464 &mut self.site.custom_css,
465 &mut errors,
466 ),
467 header: read_custom(&base_dir, "site.header", &mut self.site.header, &mut errors),
468 footer: read_custom(&base_dir, "site.footer", &mut self.site.footer, &mut errors),
469 favicon: read_image(
470 &base_dir,
471 "site.favicon",
472 &mut self.site.favicon,
473 &mut errors,
474 ),
475 logo: read_image(&base_dir, "site.logo", &mut self.site.logo, &mut errors),
476 };
477
478 if let Some(dir) = &mut self.site.assets_dir {
479 // Canonical, so requests can check that symlinks stay inside it.
480 match base_dir.join(&*dir).canonicalize() {
481 Ok(canonical) if canonical.is_dir() => *dir = canonical,
482 Ok(canonical) => errors.push(format!(
483 "site.assets_dir: {} is not a directory",
484 canonical.display()
485 )),
486 Err(e) => errors.push(format!(
487 "site.assets_dir: cannot open {}: {e}",
488 base_dir.join(&*dir).display()
489 )),
490 }
491 }
492
493 // repos
494 if self.repos.scan_path.is_relative() {
495 self.repos.scan_path = base_dir.join(&self.repos.scan_path);
496 }
497 if !(1..=16).contains(&self.repos.max_depth) {
498 errors.push(format!(
499 "repos.max_depth must be between 1 and 16, got {}",
500 self.repos.max_depth
501 ));
502 }
503 let mut seen = std::collections::HashSet::new();
504 for (i, repo) in self.repo_overrides.iter_mut().enumerate() {
505 let name = repo.name.trim().trim_matches('/').trim_end_matches(".git");
506 if name.is_empty() {
507 errors.push(format!("[[repo]] #{} has an empty name", i + 1));
508 continue;
509 }
510 repo.name = name.to_string();
511 if !seen.insert(repo.name.clone()) {
512 errors.push(format!(
513 "[[repo]] {:?} is defined more than once",
514 repo.name
515 ));
516 }
517 }
518
519 // ui
520 if self.ui.code_font.trim().is_empty() {
521 errors.push("ui.code_font must not be empty".into());
522 } else if self
523 .ui
524 .code_font
525 .chars()
526 .any(|c| matches!(c, '"' | '\'' | ';' | '{' | '}' | '<' | '>' | '\\'))
527 {
528 errors.push(format!(
529 "ui.code_font contains characters not allowed in a font name: {:?}",
530 self.ui.code_font
531 ));
532 }
533 if let Some(accent) = &mut self.ui.accent {
534 *accent = accent.trim().to_ascii_lowercase();
535 if parse_hex_color(accent).is_none() {
536 errors.push(format!(
537 "ui.accent must be a hex color like \"#d71921\": {accent:?}"
538 ));
539 }
540 }
541 if self.ui.max_blob_kb == 0 {
542 errors.push("ui.max_blob_kb must be greater than 0".into());
543 }
544
545 // cache / watch / limits
546 if self.cache.size_mb == 0 {
547 errors.push("cache.size_mb must be greater than 0".into());
548 }
549 if self.watch.debounce_ms > 60_000 {
550 errors.push("watch.debounce_ms must be at most 60000".into());
551 }
552 if self.limits.heavy_parallel == 0 {
553 errors.push("limits.heavy_parallel must be at least 1".into());
554 }
555 if self.limits.max_requests == 0 {
556 errors.push("limits.max_requests must be at least 1".into());
557 }
558 if self.limits.request_timeout_secs == 0 {
559 errors.push("limits.request_timeout_secs must be at least 1".into());
560 }
561 if !(1..=1000).contains(&self.ui.log_page) {
562 errors.push("ui.log_page must be between 1 and 1000".into());
563 }
564 for (key, value) in [
565 ("log_scan", self.limits.log_scan),
566 ("diff_files", self.limits.diff_files),
567 ("diff_lines", self.limits.diff_lines),
568 ("diff_file_lines", self.limits.diff_file_lines),
569 ] {
570 if value == 0 {
571 errors.push(format!("limits.{key} must be at least 1"));
572 }
573 }
574
575 // log
576 if let Err(e) = tracing_subscriber::EnvFilter::try_new(&self.log.level) {
577 errors.push(format!("log.level {:?} is invalid: {e}", self.log.level));
578 }
579
580 if errors.is_empty() {
581 Ok(())
582 } else {
583 Err(ConfigError::Invalid(errors))
584 }
585 }
586
587 /// Effective number of concurrent git workers.
588 pub fn git_parallel(&self) -> usize {
589 match self.limits.git_parallel {
590 // Git work is CPU-bound; more workers than cores only add memory.
591 0 => std::thread::available_parallelism().map_or(4, |n| n.get().min(8)),
592 n => n,
593 }
594 }
595}
596
597/// Resolve a customization file path against `base_dir` and read it.
598fn read_custom(
599 base_dir: &Path,
600 key: &str,
601 path: &mut Option<PathBuf>,
602 errors: &mut Vec<String>,
603) -> Option<Arc<str>> {
604 let p = path.as_mut()?;
605 if p.is_relative() {
606 *p = base_dir.join(&*p);
607 }
608 match std::fs::read_to_string(&*p) {
609 Ok(text) => Some(text.into()),
610 Err(e) => {
611 errors.push(format!("{key}: cannot read {}: {e}", p.display()));
612 None
613 }
614 }
615}
616
617/// Resolve an image path against `base_dir`, check its type and size, and
618/// read it.
619fn read_image(
620 base_dir: &Path,
621 key: &str,
622 path: &mut Option<PathBuf>,
623 errors: &mut Vec<String>,
624) -> Option<SiteImage> {
625 let p = path.as_mut()?;
626 if p.is_relative() {
627 *p = base_dir.join(&*p);
628 }
629 let ext = p
630 .extension()
631 .and_then(|e| e.to_str())
632 .map(str::to_ascii_lowercase);
633 let content_type = match ext.as_deref() {
634 Some("ico") => "image/x-icon",
635 Some("png") => "image/png",
636 Some("svg") => "image/svg+xml",
637 Some("webp") => "image/webp",
638 Some("gif") => "image/gif",
639 Some("jpg" | "jpeg") => "image/jpeg",
640 _ => {
641 errors.push(format!(
642 "{key}: unsupported image type (use .ico, .png, .svg, .webp, .gif or .jpg): {}",
643 p.display()
644 ));
645 return None;
646 }
647 };
648 let data = match std::fs::metadata(&*p) {
649 Ok(m) if m.len() > MAX_IMAGE_BYTES => {
650 errors.push(format!(
651 "{key}: {} is larger than {} KiB",
652 p.display(),
653 MAX_IMAGE_BYTES / 1024
654 ));
655 return None;
656 }
657 Ok(_) => std::fs::read(&*p),
658 Err(e) => Err(e),
659 };
660 match data {
661 Ok(data) => {
662 use std::hash::{Hash, Hasher};
663 let mut hasher = std::hash::DefaultHasher::new();
664 data.hash(&mut hasher);
665 Some(SiteImage {
666 data: data.into(),
667 content_type,
668 version: format!("{:08x}", hasher.finish() as u32),
669 })
670 }
671 Err(e) => {
672 errors.push(format!("{key}: cannot read {}: {e}", p.display()));
673 None
674 }
675 }
676}
677
678/// `#rgb` / `#rrggbb` to its components.
679pub fn parse_hex_color(s: &str) -> Option<[u8; 3]> {
680 let hex = s.strip_prefix('#')?;
681 if !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
682 return None;
683 }
684 let digit = |i: usize, len: usize| u8::from_str_radix(&hex[i * len..(i + 1) * len], 16).ok();
685 match hex.len() {
686 3 => Some([digit(0, 1)? * 17, digit(1, 1)? * 17, digit(2, 1)? * 17]),
687 6 => Some([digit(0, 2)?, digit(1, 2)?, digit(2, 2)?]),
688 _ => None,
689 }
690}
691
692#[cfg(test)]
693mod tests {
694 use super::*;
695
696 fn finish(text: &str) -> Result<Config, ConfigError> {
697 let mut c = Config::parse(text).map_err(|message| ConfigError::Parse {
698 path: "test.toml".into(),
699 message,
700 })?;
701 c.finish()?;
702 Ok(c)
703 }
704
705 #[test]
706 fn empty_config_uses_defaults() {
707 let c = finish("").unwrap();
708 assert_eq!(c.server.listen, "127.0.0.1:8080".parse().unwrap());
709 assert_eq!(c.server.base_path, "/");
710 assert_eq!(c.repos.scan_path, PathBuf::from("/home/git"));
711 assert_eq!(c.repos.max_depth, 3);
712 assert_eq!(c.ui.theme, Theme::Auto);
713 assert_eq!(c.ui.index_sort, IndexSort::Activity);
714 assert_eq!(c.cache.size_mb, 64);
715 assert_eq!(
716 c.repos.snapshots,
717 vec![SnapshotFormat::TarGz, SnapshotFormat::Zip]
718 );
719 assert!(c.git_parallel() >= 1);
720 }
721
722 #[test]
723 fn example_config_is_valid() {
724 let c = finish(crate::examples::CONFIG).unwrap();
725 assert_eq!(c.site.title, "ferrenium");
726 }
727
728 #[test]
729 fn unknown_keys_are_rejected_with_position() {
730 let err = finish("[server]\nlisten = \"127.0.0.1:1\"\nlistn = 1\n").unwrap_err();
731 let msg = err.to_string();
732 assert!(msg.contains("unknown field `listn`"), "{msg}");
733 assert!(msg.contains("line 3"), "{msg}");
734
735 let err = finish("[sever]\n").unwrap_err().to_string();
736 assert!(err.contains("unknown field `sever`"), "{err}");
737 }
738
739 #[test]
740 fn listen_parsing() {
741 assert_eq!(
742 "unix:/run/ferrenium/ferrenium.sock".parse::<Listen>(),
743 Ok(Listen::Unix("/run/ferrenium/ferrenium.sock".into()))
744 );
745 assert!("unix:relative.sock".parse::<Listen>().is_err());
746 assert!("localhost".parse::<Listen>().is_err());
747 assert_eq!(
748 "[::1]:9000".parse::<Listen>(),
749 Ok(Listen::Tcp("[::1]:9000".parse().unwrap()))
750 );
751 let err = finish("[server]\nlisten = \"nope\"\n")
752 .unwrap_err()
753 .to_string();
754 assert!(err.contains("invalid listen address"), "{err}");
755 }
756
757 #[test]
758 fn base_path_and_url_are_normalized() {
759 let c = finish("[server]\nbase_path = \"/git\"\nbase_url = \"https://x.org/\"\n").unwrap();
760 assert_eq!(c.server.base_path, "/git/");
761 assert_eq!(c.server.base_url.as_deref(), Some("https://x.org"));
762 assert_eq!(
763 finish("[server]\nbase_path = \"/\"\n")
764 .unwrap()
765 .server
766 .base_path,
767 "/"
768 );
769 }
770
771 #[test]
772 fn validation_reports_all_errors() {
773 let err = finish(
774 r#"
775 [server]
776 base_path = "git"
777 base_url = "ftp://x"
778 [repos]
779 max_depth = 0
780 [ui]
781 theme = "dark"
782 code_font = "x; } body { display: none"
783 [cache]
784 size_mb = 0
785 [[repo]]
786 name = "a.git"
787 [[repo]]
788 name = "a"
789 "#,
790 )
791 .unwrap_err();
792 let ConfigError::Invalid(errors) = err else {
793 panic!("expected validation errors, got {err}");
794 };
795 let all = errors.join("\n");
796 for needle in [
797 "base_path",
798 "base_url",
799 "max_depth",
800 "code_font",
801 "size_mb",
802 "defined more than once",
803 ] {
804 assert!(all.contains(needle), "missing {needle:?} in:\n{all}");
805 }
806 }
807
808 #[test]
809 fn invalid_enum_values_are_rejected() {
810 assert!(finish("[ui]\ntheme = \"purple\"\n").is_err());
811 assert!(finish("[repos]\nsnapshots = [\"rar\"]\n").is_err());
812 }
813
814 #[test]
815 fn custom_files_resolve_relative_to_config() {
816 let dir = tempfile::tempdir().unwrap();
817 std::fs::write(dir.path().join("custom.css"), "body{}").unwrap();
818 let path = dir.path().join("ferrenium.toml");
819 std::fs::write(
820 &path,
821 "[site]\ncustom_css = \"custom.css\"\n[repos]\nscan_path = \"repos\"\n",
822 )
823 .unwrap();
824 let c = Config::load(Some(&path)).unwrap();
825 assert_eq!(c.custom.css.as_deref(), Some("body{}"));
826 assert_eq!(c.repos.scan_path, dir.path().join("repos"));
827
828 std::fs::write(&path, "[site]\nfooter = \"missing.html\"\n").unwrap();
829 let err = Config::load(Some(&path)).unwrap_err().to_string();
830 assert!(err.contains("site.footer"), "{err}");
831 }
832
833 #[test]
834 fn images_are_read_and_checked() {
835 let dir = tempfile::tempdir().unwrap();
836 std::fs::write(dir.path().join("icon.PNG"), b"\x89PNG").unwrap();
837 std::fs::write(dir.path().join("logo.bmp"), b"BM").unwrap();
838 std::fs::write(
839 dir.path().join("big.svg"),
840 vec![b' '; MAX_IMAGE_BYTES as usize + 1],
841 )
842 .unwrap();
843 let path = dir.path().join("ferrenium.toml");
844 std::fs::write(&path, "[site]\nfavicon = \"icon.PNG\"\n").unwrap();
845 let c = Config::load(Some(&path)).unwrap();
846 let icon = c.custom.favicon.unwrap();
847 assert_eq!(icon.content_type, "image/png");
848 assert_eq!(&icon.data[..], b"\x89PNG");
849 assert_eq!(icon.version.len(), 8);
850
851 std::fs::write(
852 &path,
853 "[site]\nfavicon = \"big.svg\"\nlogo = \"logo.bmp\"\n",
854 )
855 .unwrap();
856 let err = Config::load(Some(&path)).unwrap_err().to_string();
857 assert!(
858 err.contains("site.favicon") && err.contains("larger"),
859 "{err}"
860 );
861 assert!(
862 err.contains("site.logo") && err.contains("unsupported"),
863 "{err}"
864 );
865 }
866
867 #[test]
868 fn assets_dir_must_be_a_directory() {
869 let dir = tempfile::tempdir().unwrap();
870 std::fs::create_dir(dir.path().join("assets")).unwrap();
871 std::fs::write(dir.path().join("file"), "").unwrap();
872 let path = dir.path().join("ferrenium.toml");
873 std::fs::write(&path, "[site]\nassets_dir = \"assets\"\n").unwrap();
874 let c = Config::load(Some(&path)).unwrap();
875 assert_eq!(
876 c.site.assets_dir,
877 Some(dir.path().join("assets").canonicalize().unwrap())
878 );
879 for bad in ["file", "missing"] {
880 std::fs::write(&path, format!("[site]\nassets_dir = {bad:?}\n")).unwrap();
881 let err = Config::load(Some(&path)).unwrap_err().to_string();
882 assert!(err.contains("site.assets_dir"), "{err}");
883 }
884 }
885
886 #[test]
887 fn accent_colors() {
888 assert_eq!(parse_hex_color("#d71921"), Some([215, 25, 33]));
889 assert_eq!(parse_hex_color("#fA0"), Some([255, 170, 0]));
890 for bad in ["d71921", "#d7192", "#gggggg", "red", "#+1+2+3"] {
891 assert_eq!(parse_hex_color(bad), None, "{bad}");
892 }
893 let c = finish("[ui]\naccent = \" #FF5500 \"\n").unwrap();
894 assert_eq!(c.ui.accent.as_deref(), Some("#ff5500"));
895 let err = finish("[ui]\naccent = \"red; } body {\"\n").unwrap_err();
896 assert!(err.to_string().contains("ui.accent"), "{err}");
897 }
898}