| 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 | |
| 6 | use std::fmt; |
| 7 | use std::net::SocketAddr; |
| 8 | use std::path::{Path, PathBuf}; |
| 9 | use std::str::FromStr; |
| 10 | use std::sync::Arc; |
| 11 | |
| 12 | use serde::Deserialize; |
| 13 | |
| 14 | /// Well-known config location used in production. |
| 15 | pub const SYSTEM_CONFIG: &str = "/etc/ferrenium/ferrenium.toml"; |
| 16 | |
| 17 | #[derive(Debug, thiserror::Error)] |
| 18 | pub 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)] |
| 32 | pub 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)] |
| 56 | pub 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)] |
| 66 | pub 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. |
| 74 | const MAX_IMAGE_BYTES: u64 = 1024 * 1024; |
| 75 | |
| 76 | #[derive(Debug, Clone, Deserialize)] |
| 77 | #[serde(deny_unknown_fields, default)] |
| 78 | pub 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 | |
| 86 | impl 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)] |
| 100 | pub 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 | |
| 113 | impl 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)] |
| 131 | pub 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 | |
| 141 | impl 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)] |
| 156 | pub 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)] |
| 165 | pub 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)] |
| 177 | pub 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 | |
| 190 | impl 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 | |
| 204 | pub const DEFAULT_CODE_FONT: &str = "Space Mono"; |
| 205 | |
| 206 | #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] |
| 207 | #[serde(rename_all = "lowercase")] |
| 208 | pub enum IndexSort { |
| 209 | /// Most recently pushed first, as one list. |
| 210 | #[default] |
| 211 | Activity, |
| 212 | /// By section, then name. |
| 213 | Name, |
| 214 | } |
| 215 | |
| 216 | impl 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")] |
| 227 | pub enum Theme { |
| 228 | #[default] |
| 229 | Auto, |
| 230 | Dark, |
| 231 | Light, |
| 232 | } |
| 233 | |
| 234 | impl 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)] |
| 246 | pub struct CacheConfig { |
| 247 | pub size_mb: u64, |
| 248 | } |
| 249 | |
| 250 | impl 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)] |
| 258 | pub 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 | |
| 265 | impl 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)] |
| 277 | pub 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 | |
| 294 | impl 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)] |
| 312 | pub struct LogConfig { |
| 313 | pub level: String, |
| 314 | } |
| 315 | |
| 316 | impl 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")] |
| 327 | pub enum Listen { |
| 328 | Tcp(SocketAddr), |
| 329 | Unix(PathBuf), |
| 330 | } |
| 331 | |
| 332 | impl 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 | |
| 349 | impl 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 | |
| 357 | impl 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. |
| 371 | pub 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 | |
| 387 | impl 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. |
| 598 | fn 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. |
| 619 | fn 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. |
| 679 | pub 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)] |
| 693 | mod 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 | } |