//! Loading and validating the TOML config file. //! //! The config is the *only* thing that decides which repositories are served. //! A request for a repo name that is not listed here gets a 404, so nobody can //! ask the server to read an arbitrary directory on disk. use serde::Deserialize; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; /// The whole config file, as written by the user. #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct Config { #[serde(default)] pub server: ServerConfig, /// Each `[[repo]]` table in the TOML file becomes one entry here. #[serde(default, rename = "repo")] pub repos: Vec, } #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct ServerConfig { /// Address to listen on, e.g. "127.0.0.1:8090". #[serde(default = "default_listen")] pub listen: String, /// Title shown at the top of every page. #[serde(default = "default_title")] pub site_title: String, /// Base URL shown in "git clone ..." hints, e.g. "https://git.example.com". /// When unset, the `Host` header of the request is used. #[serde(default)] pub clone_base_url: Option, } impl Default for ServerConfig { fn default() -> Self { ServerConfig { listen: default_listen(), site_title: default_title(), clone_base_url: None } } } fn default_listen() -> String { "127.0.0.1:8090".to_string() } fn default_title() -> String { "git".to_string() } #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct RepoConfig { /// URL name, e.g. "rust-kernel" -> http://host/rust-kernel/ pub name: String, /// Path on disk: a bare repo (`foo.git`) or a normal checkout (`foo/`). pub path: PathBuf, #[serde(default)] pub description: String, } /// The validated, ready-to-use set of repos, looked up by name. #[derive(Debug, Clone)] pub struct Repos { by_name: BTreeMap, } impl Repos { pub fn get(&self, name: &str) -> Option<&RepoConfig> { // Allow `/name.git/...` as an alias for `/name/...` (people expect it in clone URLs). let name = name.strip_suffix(".git").unwrap_or(name); self.by_name.get(name) } /// All repos, sorted by name. pub fn iter(&self) -> impl Iterator { self.by_name.values() } pub fn len(&self) -> usize { self.by_name.len() } pub fn is_empty(&self) -> bool { self.by_name.is_empty() } } impl Config { /// Parse a config from TOML text. pub fn from_toml(text: &str) -> Result { toml::from_str(text).map_err(|e| format!("invalid config: {e}")) } /// Read and parse a config file from disk. pub fn load(path: &Path) -> Result { let text = std::fs::read_to_string(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?; Config::from_toml(&text) } /// Check names and paths, and build the lookup table. /// /// Paths are canonicalized (symlinks and `..` resolved) once, here, so the /// rest of the program only ever deals with fixed absolute paths. pub fn validate(&self) -> Result { let mut by_name = BTreeMap::new(); for repo in &self.repos { let name = repo.name.strip_suffix(".git").unwrap_or(&repo.name); if !is_valid_repo_name(name) { return Err(format!( "repo name {:?} is not allowed (use letters, digits, '-', '_' and '.', not starting with '.')", repo.name )); } let path = std::fs::canonicalize(&repo.path) .map_err(|e| format!("repo {name:?}: path {}: {e}", repo.path.display()))?; let fixed = RepoConfig { name: name.to_string(), path, description: repo.description.clone() }; if by_name.insert(name.to_string(), fixed).is_some() { return Err(format!("repo name {name:?} is listed twice")); } } Ok(Repos { by_name }) } } /// Repo names end up in URLs, so keep them boring. pub fn is_valid_repo_name(name: &str) -> bool { !name.is_empty() && name.len() <= 100 && !name.starts_with('.') && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') } #[cfg(test)] mod tests { use super::*; #[test] fn parses_full_config() { let cfg = Config::from_toml( r#" [server] listen = "0.0.0.0:9000" site_title = "my git" [[repo]] name = "one" path = "/tmp" description = "first" "#, ) .unwrap(); assert_eq!(cfg.server.listen, "0.0.0.0:9000"); assert_eq!(cfg.server.site_title, "my git"); assert_eq!(cfg.repos.len(), 1); assert_eq!(cfg.repos[0].description, "first"); } #[test] fn defaults_apply() { let cfg = Config::from_toml("").unwrap(); assert_eq!(cfg.server.listen, "127.0.0.1:8090"); assert!(cfg.repos.is_empty()); } #[test] fn rejects_unknown_keys() { assert!(Config::from_toml("bogus = 1").is_err()); } #[test] fn repo_names() { assert!(is_valid_repo_name("rust-kernel")); assert!(is_valid_repo_name("a_b.c")); assert!(!is_valid_repo_name("")); assert!(!is_valid_repo_name("..")); assert!(!is_valid_repo_name(".hidden")); assert!(!is_valid_repo_name("a/b")); assert!(!is_valid_repo_name("a b")); assert!(!is_valid_repo_name("