//! Everything that can be wrong with a configuration, said by name. //! //! Validation is separate from parsing because the two answer different //! questions. Parsing asks whether the file is the right shape; this asks //! whether what it says can be true — a profile naming a service that is not //! there, two services on one port, a scrobble host writing to a different //! server than the profile it fills a role in. //! //! Nothing here contacts anything. Whether a port is *actually* held, and by //! what, is a question for the machine rather than the file. use std::collections::BTreeMap; use std::fmt; use crate::config::{Config, ServiceKind}; /// One thing wrong with a configuration. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Problem { /// A profile names a service that does not exist. NoSuchService { /// The profile that names it. profile: String, /// Which role it was named for. role: &'static str, /// The name that resolves to nothing. service: String, }, /// A profile names a service of the wrong kind for the role. WrongKind { /// The profile that names it. profile: String, /// Which role it was named for. role: &'static str, /// The service named. service: String, /// What it actually is. found: ServiceKind, }, /// A service names an upstream that does not exist. NoSuchUpstream { /// The service with the dangling reference. service: String, /// Which upstream. role: &'static str, /// The name that resolves to nothing. upstream: String, }, /// A profile's scrobble host writes to a different server than the /// profile's own. /// /// The failure this prevents is silent and confusing: the hook provisions /// an account on one server, and the scrobble carrying that account's DID /// is written to another, which has never heard of it. Disagrees { /// The profile. profile: String, /// The scrobble host it names. mcp: String, /// The server that host writes to. mcp_pds: String, /// The server the profile says accounts are on. profile_pds: String, }, /// Two services want the same address. PortClash { /// The address both want. listen: String, /// The services that want it. services: Vec, }, /// A directory is bound to a profile that does not exist. NoSuchProfile { /// The bound directory. dir: String, /// The name that resolves to nothing. profile: String, }, /// There is no profile named `default`. /// /// Not fatal, but worth saying: an unbound directory resolves to it, and /// on a machine without one every directory nobody thought about stops /// working at once. NoDefaultProfile, } impl fmt::Display for Problem { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::NoSuchService { profile, role, service, } => write!( f, "profile {profile}: {role} names {service}, which does not exist" ), Self::WrongKind { profile, role, service, found, } => write!( f, "profile {profile}: {role} names {service}, which is a {found}" ), Self::NoSuchUpstream { service, role, upstream, } => write!( f, "service {service}: {role} names {upstream}, which does not exist" ), Self::Disagrees { profile, mcp, mcp_pds, profile_pds, } => write!( f, "profile {profile}: accounts are minted on {profile_pds} but {mcp} writes to \ {mcp_pds}, so every scrobble would name an account that server has never seen" ), Self::PortClash { listen, services } => { write!(f, "{} both want {listen}", services.join(" and ")) } Self::NoSuchProfile { dir, profile } => { write!( f, "{dir} is bound to profile {profile}, which does not exist" ) } Self::NoDefaultProfile => write!( f, "there is no profile named default, so any directory nobody bound resolves to \ nothing" ), } } } /// Everything wrong with one configuration. pub type Problems = Vec; /// Checks a configuration against itself. pub(crate) fn check(config: &Config) -> Problems { let mut problems = Problems::new(); for (name, profile) in &config.profiles { let roles: [(&'static str, Option<&str>, ServiceKind); 5] = [ ("pds", Some(profile.pds.as_str()), ServiceKind::Pds), ("mcp", profile.mcp.as_deref(), ServiceKind::Mcp), ("index", profile.index.as_deref(), ServiceKind::Index), ("query", profile.query.as_deref(), ServiceKind::Query), ("web", profile.web.as_deref(), ServiceKind::Web), ]; for (role, named, expected) in roles { let Some(named) = named else { continue }; match config.services.get(named) { None => problems.push(Problem::NoSuchService { profile: name.clone(), role, service: named.to_owned(), }), Some(service) if service.kind != expected => problems.push(Problem::WrongKind { profile: name.clone(), role, service: named.to_owned(), found: service.kind, }), Some(_) => {} } } // The one cross-role check, and the reason a profile names its server // explicitly rather than inheriting it from the scrobble host. if let Some(mcp_name) = profile.mcp.as_deref() { if let Some(mcp) = config.services.get(mcp_name) { if let Some(mcp_pds) = mcp.pds.as_deref() { if mcp_pds != profile.pds { problems.push(Problem::Disagrees { profile: name.clone(), mcp: mcp_name.to_owned(), mcp_pds: mcp_pds.to_owned(), profile_pds: profile.pds.clone(), }); } } } } } for (name, service) in &config.services { for (role, named) in [ ("pds", service.pds.as_deref()), ("index", service.index.as_deref()), ("query", service.query.as_deref()), ] { let Some(named) = named else { continue }; if !config.services.contains_key(named) { problems.push(Problem::NoSuchUpstream { service: name.clone(), role, upstream: named.to_owned(), }); } } } let mut by_listen: BTreeMap> = BTreeMap::new(); for (name, service) in &config.services { by_listen .entry(service.listen()) .or_default() .push(name.clone()); } for (listen, services) in by_listen { if services.len() > 1 { problems.push(Problem::PortClash { listen, services }); } } for binding in config.bindings() { if !config.profiles.contains_key(binding.profile) { problems.push(Problem::NoSuchProfile { dir: binding.dir.display().to_string(), profile: binding.profile.to_owned(), }); } } if !config.profiles.contains_key(crate::DEFAULT_PROFILE) { problems.push(Problem::NoDefaultProfile); } problems } #[cfg(test)] mod tests { use super::*; fn parse(raw: &str) -> Config { toml::from_str(raw).expect("parses") } #[test] fn a_profile_naming_a_missing_service_is_reported() { let problems = parse( r#" [profile.default] pds = "nowhere" "#, ) .problems(); assert!( matches!( problems.as_slice(), [Problem::NoSuchService { service, .. }] if service == "nowhere" ), "{problems:?}" ); } #[test] fn a_role_filled_by_the_wrong_kind_is_reported() { let problems = parse( r#" [service.pds-main] kind = "pds" port = 3000 [profile.default] pds = "pds-main" mcp = "pds-main" "#, ) .problems(); assert!( problems .iter() .any(|problem| matches!(problem, Problem::WrongKind { role: "mcp", .. })), "{problems:?}" ); } #[test] fn a_scrobble_host_writing_somewhere_else_is_reported() { // The silent failure this exists to make loud: accounts minted on one // server, scrobbles written to another. let problems = parse( r#" [service.pds-a] kind = "pds" port = 3000 [service.pds-b] kind = "pds" port = 3100 [service.mcp-a] kind = "mcp" port = 3001 pds = "pds-b" [profile.default] pds = "pds-a" mcp = "mcp-a" "#, ) .problems(); assert!( problems .iter() .any(|problem| matches!(problem, Problem::Disagrees { .. })), "{problems:?}" ); } #[test] fn two_services_on_one_port_are_reported() { let problems = parse( r#" [service.pds-a] kind = "pds" port = 3000 [service.pds-b] kind = "pds" port = 3000 [profile.default] pds = "pds-a" "#, ) .problems(); assert!( problems.iter().any(|problem| matches!( problem, Problem::PortClash { listen, .. } if listen == "127.0.0.1:3000" )), "{problems:?}" ); } #[test] fn the_same_port_on_two_interfaces_is_not_a_clash() { let problems = parse( r#" [service.pds-a] kind = "pds" port = 3000 [service.pds-b] kind = "pds" port = 3000 host = "127.0.0.2" [profile.default] pds = "pds-a" "#, ) .problems(); assert!( !problems .iter() .any(|problem| matches!(problem, Problem::PortClash { .. })), "{problems:?}" ); } #[test] fn a_binding_to_a_missing_profile_is_reported() { let problems = parse( r#" [service.pds-main] kind = "pds" port = 3000 [profile.default] pds = "pds-main" [bind] "/repo/a" = "ghost" "#, ) .problems(); assert!( problems .iter() .any(|problem| matches!(problem, Problem::NoSuchProfile { .. })), "{problems:?}" ); } #[test] fn a_configuration_with_no_default_profile_says_so() { let problems = parse( r#" [service.pds-main] kind = "pds" port = 3000 [profile.beta] pds = "pds-main" "#, ) .problems(); assert!( problems.contains(&Problem::NoDefaultProfile), "{problems:?}" ); } #[test] fn the_builtin_configuration_is_clean() { assert_eq!(Config::builtin().problems(), Problems::new()); } }