//! Server-level configuration: `~/.config/crabidy/crabidy-server.toml`. //! //! Carries the enabled-providers list ([`ServerSettings::providers`]) and the //! `[auth]` role hashes (architecture/roles-auth.md). The server writes a //! default (all providers, no auth) on first start via //! [`ServerSettings::ensure_default`]. The file is optional — a missing file //! runs the server open with every provider — but a file that exists and does //! not parse aborts startup: silently ignoring a broken auth config would run //! an intended-to-be-locked server open (fail-closed, quality/roles-auth.md). use std::path::Path; use serde::{Deserialize, Serialize}; /// The server config file name inside the crabidy config directory. pub const SETTINGS_FILE: &str = "crabidy-server.toml"; /// Every built-in provider, in the order the default config lists them. Each /// name is a library root (`/tidal`, `/youtube`, `/fyyd`, `/fs`, `/crabidy`, /// `/orphans`). `orphans` is a view over the store, so it needs `crabidy`. pub const ALL_PROVIDERS: [&str; 6] = ["tidal", "youtube", "fyyd", "fs", "crabidy", "orphans"]; /// Contents of `crabidy-server.toml`. #[derive(Debug, Default, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct ServerSettings { /// Enabled providers. The default config we write lists all of them /// ([`ALL_PROVIDERS`]); remove a name to disable that provider. Absent — /// the key deleted, or a fresh install with no file — enables all of them, /// so a server never silently loses every provider (fail-open for the /// library, unlike auth). #[serde(default, skip_serializing_if = "Option::is_none")] pub providers: Option>, /// Role credentials; absent (or empty) means the server runs open. #[serde(default, skip_serializing_if = "AuthSettings::is_default")] pub auth: AuthSettings, /// Audio output selection; absent means the system default device. #[serde(default, skip_serializing_if = "AudioSettings::is_default")] pub audio: AudioSettings, } /// Audio output configuration. #[derive(Debug, Default, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct AudioSettings { /// A substring of the output device's name (case-insensitive) to send /// audio to. Absent uses the system default device — which on a Raspberry /// Pi is often HDMI, so audio plays but is silent on the jack/DAC. List /// the available names with `crabidy-server audio-devices`, then set e.g. /// `device = "Headphones"`. #[serde(skip_serializing_if = "Option::is_none")] pub device: Option, } impl AudioSettings { /// Whether this is the empty default (no device pinned), so the /// auto-written config omits the `[audio]` table entirely. fn is_default(&self) -> bool { self.device.is_none() } } /// Which providers to mount, resolved from [`ServerSettings::providers`]. #[derive(Debug, Clone, Copy)] pub struct ProviderToggles { pub tidal: bool, pub youtube: bool, pub fyyd: bool, pub fs: bool, pub crabidy: bool, pub orphans: bool, } impl ProviderToggles { /// Every provider on — the default, and what a missing/keyless config /// yields. pub fn all() -> Self { Self { tidal: true, youtube: true, fyyd: true, fs: true, crabidy: true, orphans: true, } } } /// One PHC password hash per role; a role without a hash cannot /// authenticate. Generate hashes with `crabidy-server guard `. /// Hashes are not passwords, but the file should stay private anyway. #[derive(Debug, Default, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct AuthSettings { #[serde(skip_serializing_if = "Option::is_none")] pub owner: Option, #[serde(skip_serializing_if = "Option::is_none")] pub queue_owner: Option, #[serde(skip_serializing_if = "Option::is_none")] pub queue_appender: Option, } impl AuthSettings { /// Whether any role is credentialed — the switch that turns /// authentication on for every RPC. pub fn enabled(&self) -> bool { self.owner.is_some() || self.queue_owner.is_some() || self.queue_appender.is_some() } /// Whether this is the empty default (no roles) — so the auto-written /// default config omits the `[auth]` table entirely. fn is_default(&self) -> bool { self.owner.is_none() && self.queue_owner.is_none() && self.queue_appender.is_none() } /// Enforces the guarding order: roles must be locked from the most /// privileged down. Anonymous callers inherit the highest *unguarded* /// role (architecture/roles-auth.md), so guarding a lower role while a /// higher one is open is meaningless — the anonymous role would still /// outrank it. Such a config is a mistake, not a subtle preference, so /// it aborts startup rather than running with a surprising posture. /// /// Valid guarded sets are prefixes of `[owner, queue_owner, /// queue_appender]`: nothing, `owner`, `owner`+`queue_owner`, or all /// three. `queue_owner` without `owner`, or `queue_appender` without /// `queue_owner`, is rejected. pub fn validate(&self) -> Result<(), String> { if self.queue_owner.is_some() && self.owner.is_none() { return Err( "[auth] queue_owner is guarded but owner is not; guard roles from most to \ least privileged (owner, then queue_owner, then queue_appender)" .to_string(), ); } if self.queue_appender.is_some() && self.queue_owner.is_none() { return Err( "[auth] queue_appender is guarded but queue_owner is not; guard roles from most \ to least privileged (owner, then queue_owner, then queue_appender)" .to_string(), ); } Ok(()) } } impl ServerSettings { /// Loads the settings from `config_dir`. /// /// A missing file yields the defaults (auth disabled). An existing /// file that cannot be read or parsed is an error — the caller /// must abort startup rather than run open. pub fn load(config_dir: &Path) -> Result { let file = config_dir.join(SETTINGS_FILE); let raw = match std::fs::read_to_string(&file) { Ok(raw) => raw, Err(err) if err.kind() == std::io::ErrorKind::NotFound => { return Ok(Self::default()); } Err(err) => return Err(format!("cannot read {}: {err}", file.display())), }; let settings: ServerSettings = toml::from_str(&raw).map_err(|err| format!("invalid {}: {err}", file.display()))?; // A broken guarding order is fail-closed like any other parse error: // abort startup rather than run with a surprising auth posture. settings .auth .validate() .map_err(|err| format!("invalid {}: {err}", file.display()))?; Ok(settings) } /// Whether the named provider is enabled: every provider when the /// `providers` key is absent, otherwise only the names the list holds. pub fn provider_enabled(&self, name: &str) -> bool { self.providers .as_ref() .is_none_or(|list| list.iter().any(|p| p == name)) } /// The per-provider mount decisions for the orchestrator. `orphans` also /// requires `crabidy` (it is a view over the store); that dependency is /// enforced where the store is built, so it is not folded in here. pub fn provider_toggles(&self) -> ProviderToggles { ProviderToggles { tidal: self.provider_enabled("tidal"), youtube: self.provider_enabled("youtube"), fyyd: self.provider_enabled("fyyd"), fs: self.provider_enabled("fs"), crabidy: self.provider_enabled("crabidy"), orphans: self.provider_enabled("orphans"), } } /// Writes a default `crabidy-server.toml` — all providers enabled, no auth /// — when none exists yet, so users have a full list to prune. A file that /// already exists (even a pruned one) is left untouched. Best-effort: the /// caller treats a write failure as a warning, not a startup error. pub fn ensure_default(config_dir: &Path) -> Result<(), String> { let file = config_dir.join(SETTINGS_FILE); match std::fs::metadata(&file) { Ok(_) => return Ok(()), Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} Err(err) => return Err(format!("cannot check {}: {err}", file.display())), } let settings = ServerSettings { providers: Some(ALL_PROVIDERS.iter().map(|s| s.to_string()).collect()), auth: AuthSettings::default(), audio: AudioSettings::default(), }; settings.store(config_dir) } /// Serializes the current settings back to `crabidy-server.toml` in /// `config_dir`, creating the directory if missing. /// /// The flat `[auth]` shape is preserved (each role a top-level key), so a /// reload under `#[serde(deny_unknown_fields)]` still parses. Roles left /// `None` are simply omitted — they cannot authenticate, exactly as a /// missing key. The password never appears here (only the PHC hash). pub fn store(&self, config_dir: &Path) -> Result<(), String> { std::fs::create_dir_all(config_dir) .map_err(|err| format!("cannot create {}: {err}", config_dir.display()))?; let text = toml::to_string_pretty(self) .map_err(|err| format!("cannot serialize server settings: {err}"))?; let file = config_dir.join(SETTINGS_FILE); std::fs::write(&file, text).map_err(|err| format!("cannot write {}: {err}", file.display())) } } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; #[test] fn a_missing_file_disables_auth() { let dir = TempDir::new().expect("tempdir"); let settings = ServerSettings::load(dir.path()).expect("defaults"); assert!(!settings.auth.enabled()); } #[test] fn hashes_load_and_enable_auth() { let dir = TempDir::new().expect("tempdir"); std::fs::write( dir.path().join(SETTINGS_FILE), "[auth]\nowner = \"$argon2id$fake\"\n", ) .expect("write"); let settings = ServerSettings::load(dir.path()).expect("parse"); assert!(settings.auth.enabled()); assert_eq!(settings.auth.owner.as_deref(), Some("$argon2id$fake")); assert!(settings.auth.queue_owner.is_none()); assert!(settings.auth.queue_appender.is_none()); } #[test] fn validate_accepts_top_down_guarded_prefixes() { // Every valid guarded set is a prefix of owner → queue_owner → // queue_appender. let owner = "$argon2id$o".to_string(); let qo = "$argon2id$qo".to_string(); let qa = "$argon2id$qa".to_string(); let cases = [ AuthSettings::default(), AuthSettings { owner: Some(owner.clone()), queue_owner: None, queue_appender: None, }, AuthSettings { owner: Some(owner.clone()), queue_owner: Some(qo.clone()), queue_appender: None, }, AuthSettings { owner: Some(owner), queue_owner: Some(qo), queue_appender: Some(qa), }, ]; for case in cases { assert!(case.validate().is_ok(), "{case:?}"); } } #[test] fn validate_rejects_a_role_guarded_below_an_open_one() { // queue_owner without owner. let broken = AuthSettings { owner: None, queue_owner: Some("$argon2id$qo".to_string()), queue_appender: None, }; assert!(broken.validate().is_err()); // queue_appender without queue_owner. let broken = AuthSettings { owner: Some("$argon2id$o".to_string()), queue_owner: None, queue_appender: Some("$argon2id$qa".to_string()), }; assert!(broken.validate().is_err()); } #[test] fn a_broken_guarding_order_aborts_load() { let dir = TempDir::new().expect("tempdir"); std::fs::write( dir.path().join(SETTINGS_FILE), "[auth]\nqueue_appender = \"$argon2id$fake\"\n", ) .expect("write"); let err = ServerSettings::load(dir.path()).expect_err("must reject"); assert!(err.contains("crabidy-server.toml"), "{err}"); assert!(err.contains("queue_appender"), "{err}"); } #[test] fn store_sets_one_role_and_preserves_the_others() { let dir = TempDir::new().expect("tempdir"); // Seed a file with two roles already set. std::fs::write( dir.path().join(SETTINGS_FILE), "[auth]\nowner = \"$argon2id$owner\"\nqueue_owner = \"$argon2id$qo\"\n", ) .expect("seed"); let mut settings = ServerSettings::load(dir.path()).expect("load"); // Set the third role and write it back. settings.auth.queue_appender = Some("$argon2id$appender".to_string()); settings.store(dir.path()).expect("store"); // Reload: all three roles present, still parses under deny_unknown. let reloaded = ServerSettings::load(dir.path()).expect("reload"); assert_eq!(reloaded.auth.owner.as_deref(), Some("$argon2id$owner")); assert_eq!(reloaded.auth.queue_owner.as_deref(), Some("$argon2id$qo")); assert_eq!( reloaded.auth.queue_appender.as_deref(), Some("$argon2id$appender") ); } #[test] fn store_creates_the_config_dir_when_missing() { let dir = TempDir::new().expect("tempdir"); let nested = dir.path().join("config").join("crabidy"); let mut settings = ServerSettings::default(); settings.auth.owner = Some("$argon2id$x".to_string()); settings.store(&nested).expect("store creates dir"); assert!(nested.join(SETTINGS_FILE).is_file()); } #[test] fn an_absent_providers_key_enables_everything() { let settings = ServerSettings::default(); for provider in ALL_PROVIDERS { assert!(settings.provider_enabled(provider), "{provider}"); } let toggles = settings.provider_toggles(); assert!( toggles.tidal && toggles.youtube && toggles.fs && toggles.crabidy && toggles.orphans ); } #[test] fn an_explicit_list_enables_only_its_names() { let dir = TempDir::new().expect("tempdir"); std::fs::write( dir.path().join(SETTINGS_FILE), "providers = [\"fs\", \"crabidy\"]\n", ) .expect("write"); let settings = ServerSettings::load(dir.path()).expect("load"); assert!(settings.provider_enabled("fs") && settings.provider_enabled("crabidy")); assert!(!settings.provider_enabled("tidal")); assert!(!settings.provider_enabled("youtube")); assert!(!settings.provider_enabled("orphans")); let toggles = settings.provider_toggles(); assert!(toggles.fs && toggles.crabidy); assert!(!toggles.tidal && !toggles.youtube && !toggles.orphans); } #[test] fn ensure_default_writes_all_providers_and_never_clobbers() { let dir = TempDir::new().expect("tempdir"); ServerSettings::ensure_default(dir.path()).expect("write default"); let text = std::fs::read_to_string(dir.path().join(SETTINGS_FILE)).expect("read"); for provider in ALL_PROVIDERS { assert!(text.contains(provider), "default lists {provider}"); } assert!( !text.contains("[auth]"), "no empty auth table in the default" ); let reloaded = ServerSettings::load(dir.path()).expect("reload"); assert_eq!( reloaded.providers.as_ref().map(Vec::len), Some(ALL_PROVIDERS.len()) ); // A second call must not overwrite a file the user has since pruned. std::fs::write(dir.path().join(SETTINGS_FILE), "providers = [\"fs\"]\n").expect("prune"); ServerSettings::ensure_default(dir.path()).expect("no-op"); let after = std::fs::read_to_string(dir.path().join(SETTINGS_FILE)).expect("read"); assert_eq!(after.trim(), "providers = [\"fs\"]"); } #[test] fn providers_and_auth_round_trip() { let dir = TempDir::new().expect("tempdir"); std::fs::write( dir.path().join(SETTINGS_FILE), "providers = [\"tidal\", \"fs\"]\n[auth]\nowner = \"$argon2id$x\"\n", ) .expect("seed"); let settings = ServerSettings::load(dir.path()).expect("load"); assert!(settings.auth.enabled()); settings.store(dir.path()).expect("store"); let reloaded = ServerSettings::load(dir.path()).expect("reload"); assert_eq!( reloaded.providers, Some(vec!["tidal".to_string(), "fs".to_string()]) ); assert_eq!(reloaded.auth.owner.as_deref(), Some("$argon2id$x")); } #[test] fn a_broken_file_is_a_startup_error_not_an_open_server() { let dir = TempDir::new().expect("tempdir"); for bad in [ "[auth\n", "[auth]\nowner = 3\n", "[auth]\nonwer = \"typo\"\n", ] { std::fs::write(dir.path().join(SETTINGS_FILE), bad).expect("write"); let err = ServerSettings::load(dir.path()).expect_err(bad); assert!(err.contains("crabidy-server.toml"), "{err}"); } } }