diff --git a/Cargo.lock b/Cargo.lock index 950de5a..36d9082 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + [[package]] name = "bitflags" version = "2.11.0" @@ -29,6 +38,12 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[package]] name = "bytes" version = "1.11.1" @@ -99,6 +114,28 @@ dependencies = [ "tracing", ] +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic", + "parking_lot", + "pear", + "serde", + "tempfile", + "toml", + "uncased", + "version_check", +] + [[package]] name = "foldhash" version = "0.1.5" @@ -225,8 +262,13 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" name = "horismos" version = "0.1.0" dependencies = [ + "figment", "harmonia-common", + "rstest", + "serde", + "serde_json", "snafu", + "tempfile", "tracing", ] @@ -248,6 +290,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inlinable_string" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" + [[package]] name = "itoa" version = "1.0.17" @@ -326,6 +374,12 @@ version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "lock_api" version = "0.4.14" @@ -396,6 +450,29 @@ dependencies = [ "tracing", ] +[[package]] +name = "pear" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" +dependencies = [ + "inlinable_string", + "pear_codegen", + "yansi", +] + +[[package]] +name = "pear_codegen" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -433,7 +510,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit", + "toml_edit 0.25.4+spec-1.1.0", ] [[package]] @@ -445,6 +522,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "version_check", + "yansi", +] + [[package]] name = "quote" version = "1.0.45" @@ -543,6 +633,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -610,6 +713,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -689,6 +801,19 @@ dependencies = [ "tracing", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "tokio" version = "1.50.0" @@ -717,6 +842,27 @@ dependencies = [ "syn", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + [[package]] name = "toml_datetime" version = "1.0.0+spec-1.1.0" @@ -726,6 +872,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow", +] + [[package]] name = "toml_edit" version = "0.25.4+spec-1.1.0" @@ -733,7 +893,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 1.0.0+spec-1.1.0", "toml_parser", "winnow", ] @@ -747,6 +907,12 @@ dependencies = [ "winnow", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tracing" version = "0.1.44" @@ -778,6 +944,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -802,6 +977,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1017,6 +1198,12 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "zmij" version = "1.0.21" diff --git a/crates/horismos/Cargo.toml b/crates/horismos/Cargo.toml index f8232b7..79684aa 100644 --- a/crates/horismos/Cargo.toml +++ b/crates/horismos/Cargo.toml @@ -3,9 +3,17 @@ name = "horismos" version.workspace = true edition.workspace = true license.workspace = true -description = "Configuration — Harmonia subsystem config loader" +description = "Configuration loading and validation for Harmonia" [dependencies] harmonia-common.workspace = true +figment = { workspace = true, features = ["toml", "env"] } +serde.workspace = true snafu.workspace = true tracing.workspace = true + +[dev-dependencies] +rstest.workspace = true +tempfile = "3" +serde_json.workspace = true +figment = { workspace = true, features = ["toml", "env", "test"] } diff --git a/crates/horismos/src/config.rs b/crates/horismos/src/config.rs new file mode 100644 index 0000000..09200ea --- /dev/null +++ b/crates/horismos/src/config.rs @@ -0,0 +1,32 @@ +use serde::{Deserialize, Serialize}; + +use crate::subsystems::{ + AggeliaConfig, DatabaseConfig, EpignosisConfig, ErgasiaConfig, ExousiaConfig, KritikeConfig, + ParocheConfig, ProsthekeConfig, SyntaxisConfig, TaxisConfig, ZetesisConfig, +}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Config { + #[serde(default)] + pub database: DatabaseConfig, + #[serde(default)] + pub exousia: ExousiaConfig, + #[serde(default)] + pub paroche: ParocheConfig, + #[serde(default)] + pub taxis: TaxisConfig, + #[serde(default)] + pub epignosis: EpignosisConfig, + #[serde(default)] + pub kritike: KritikeConfig, + #[serde(default)] + pub aggelia: AggeliaConfig, + #[serde(default)] + pub zetesis: ZetesisConfig, + #[serde(default)] + pub ergasia: ErgasiaConfig, + #[serde(default)] + pub syntaxis: SyntaxisConfig, + #[serde(default)] + pub prostheke: ProsthekeConfig, +} diff --git a/crates/horismos/src/error.rs b/crates/horismos/src/error.rs new file mode 100644 index 0000000..c7c7145 --- /dev/null +++ b/crates/horismos/src/error.rs @@ -0,0 +1,20 @@ +use snafu::Snafu; + +#[derive(Debug, Snafu)] +#[snafu(visibility(pub(crate)))] +pub enum HorismosError { + #[snafu(display("configuration parse error: {source}"))] + ConfigParse { + #[snafu(source(from(figment::Error, Box::new)))] + source: Box, + #[snafu(implicit)] + location: snafu::Location, + }, + + #[snafu(display("configuration validation failed: {message}"))] + Validation { + message: String, + #[snafu(implicit)] + location: snafu::Location, + }, +} diff --git a/crates/horismos/src/lib.rs b/crates/horismos/src/lib.rs index 69791b4..ded9327 100644 --- a/crates/horismos/src/lib.rs +++ b/crates/horismos/src/lib.rs @@ -1 +1,274 @@ -// Stub — implementation in P2-02 +mod config; +mod error; +mod secrets; +mod subsystems; +mod validation; + +pub use config::Config; +pub use error::HorismosError; +pub use subsystems::{ + AggeliaConfig, DatabaseConfig, EpignosisConfig, ErgasiaConfig, ExousiaConfig, KritikeConfig, + LibraryConfig, MediaType, ParocheConfig, ProsthekeConfig, SyntaxisConfig, TaxisConfig, + WatcherMode, ZetesisConfig, +}; +pub use validation::ValidationWarning; + +use std::path::Path; + +use figment::{ + Figment, + providers::{Env, Format, Serialized, Toml}, +}; +use snafu::ResultExt; + +use crate::error::ConfigParseSnafu; +use crate::secrets::secrets_path; +use crate::validation::validate_config; + +/// Load and validate configuration. +/// +/// Applies providers in priority order (lowest to highest): +/// 1. Compiled-in `Default` values +/// 2. `harmonia.toml` (or the given path) +/// 3. `secrets.toml` (sibling of config file, gitignored) +/// 4. `HARMONIA__SECTION__KEY` environment variables +/// +/// Returns the validated config along with any non-fatal warnings. +pub fn load_config( + config_path: Option<&Path>, +) -> Result<(Config, Vec), HorismosError> { + let config_path = config_path.unwrap_or_else(|| Path::new("harmonia.toml")); + + let figment = Figment::new() + .merge(Serialized::defaults(Config::default())) + .merge(Toml::file(config_path)) + .merge(Toml::file(secrets_path(config_path))) + .merge(Env::prefixed("HARMONIA__").split("__")); + + let config: Config = figment.extract().context(ConfigParseSnafu)?; + let warnings = validate_config(&config)?; + Ok((config, warnings)) +} + +#[cfg(test)] +mod tests { + use figment::Jail; + + use super::*; + + fn valid_jwt_secret() -> &'static str { + "a-very-long-secret-key-that-is-at-least-32-bytes-long" + } + + // ── Default config ──────────────────────────────────────────────────────── + + #[test] + fn default_config_has_correct_values() { + let config = Config::default(); + assert_eq!(config.exousia.access_token_ttl_secs, 900); + assert_eq!(config.exousia.refresh_token_ttl_days, 30); + assert_eq!(config.paroche.port, 8096); + assert_eq!(config.aggelia.buffer_size, 1024); + assert_eq!(config.aggelia.download_queue_size, 512); + assert_eq!(config.zetesis.request_timeout_secs, 30); + assert_eq!(config.epignosis.cache_ttl_secs, 86400); + assert_eq!(config.kritike.scan_interval_hours, 24); + } + + // ── TOML file overrides defaults ────────────────────────────────────────── + + #[test] + fn toml_overrides_defaults() { + Jail::expect_with(|jail: &mut Jail| { + jail.create_file( + "harmonia.toml", + &format!( + "[exousia]\naccess_token_ttl_secs = 1800\njwt_secret = \"{}\"\n\n[paroche]\nport = 9090\n", + valid_jwt_secret() + ), + )?; + let (config, _) = load_config(Some(Path::new("harmonia.toml"))).unwrap(); + assert_eq!(config.exousia.access_token_ttl_secs, 1800); + assert_eq!(config.paroche.port, 9090); + Ok(()) + }); + } + + // ── Environment variables override TOML ─────────────────────────────────── + + #[test] + fn env_vars_override_toml() { + Jail::expect_with(|jail: &mut Jail| { + jail.create_file( + "harmonia.toml", + &format!( + "[exousia]\naccess_token_ttl_secs = 900\njwt_secret = \"{}\"\n\n[paroche]\nport = 8096\n", + valid_jwt_secret() + ), + )?; + jail.set_env("HARMONIA__PAROCHE__PORT", "7777"); + let (config, _) = load_config(Some(Path::new("harmonia.toml"))).unwrap(); + assert_eq!(config.paroche.port, 7777); + Ok(()) + }); + } + + // ── secrets.toml is loaded ──────────────────────────────────────────────── + + #[test] + fn secrets_toml_is_loaded() { + Jail::expect_with(|jail: &mut Jail| { + let secrets_secret = "secrets-toml-jwt-secret-long-enough-for-validation"; + jail.create_file("harmonia.toml", "[exousia]\naccess_token_ttl_secs = 900\n")?; + jail.create_file( + "secrets.toml", + &format!("[exousia]\njwt_secret = \"{secrets_secret}\"\n"), + )?; + let (config, _) = load_config(Some(Path::new("harmonia.toml"))).unwrap(); + assert_eq!(config.exousia.jwt_secret, secrets_secret); + Ok(()) + }); + } + + // ── Missing config file falls back to defaults ──────────────────────────── + + #[test] + fn missing_config_file_uses_defaults() { + Jail::expect_with(|jail: &mut Jail| { + jail.set_env("HARMONIA__EXOUSIA__JWT_SECRET", valid_jwt_secret()); + let (config, _) = load_config(Some(Path::new("nonexistent.toml"))).unwrap(); + assert_eq!(config.exousia.access_token_ttl_secs, 900); + assert_eq!(config.paroche.port, 8096); + Ok(()) + }); + } + + // ── JWT secret validation ───────────────────────────────────────────────── + + fn config_with_jwt(secret: &str) -> Config { + let mut config = Config::default(); + config.exousia.jwt_secret = secret.to_string(); + config + } + + #[test] + fn validation_rejects_empty_jwt_secret() { + let config = config_with_jwt(""); + let err = validate_config(&config).unwrap_err(); + assert!(err.to_string().contains("jwt_secret")); + } + + #[test] + fn validation_rejects_changeme_jwt_secret() { + let config = config_with_jwt("changeme"); + let err = validate_config(&config).unwrap_err(); + assert!(err.to_string().contains("jwt_secret")); + } + + #[test] + fn validation_rejects_short_jwt_secret() { + let config = config_with_jwt("tooshort"); + let err = validate_config(&config).unwrap_err(); + assert!(err.to_string().contains("jwt_secret")); + } + + #[test] + fn validation_accepts_valid_jwt_secret() { + let config = config_with_jwt(valid_jwt_secret()); + assert!(validate_config(&config).is_ok()); + } + + // ── Library path warnings ───────────────────────────────────────────────── + + #[test] + fn validation_warns_on_inaccessible_library_paths() { + let mut config = config_with_jwt(valid_jwt_secret()); + let mut library = LibraryConfig::default(); + library.path = std::path::PathBuf::from("/nonexistent/library/path"); + config.taxis.libraries.insert("music".to_string(), library); + let warnings = validate_config(&config).unwrap(); + assert!(!warnings.is_empty()); + assert!(warnings[0].field.contains("taxis.libraries.music.path")); + } + + #[test] + fn validation_no_warnings_for_accessible_library_paths() { + let mut config = config_with_jwt(valid_jwt_secret()); + let mut library = LibraryConfig::default(); + library.path = std::path::PathBuf::from("/tmp"); + config.taxis.libraries.insert("music".to_string(), library); + let warnings = validate_config(&config).unwrap(); + assert!(warnings.is_empty()); + } + + // ── Port validation ─────────────────────────────────────────────────────── + + #[test] + fn validation_rejects_privileged_port() { + let mut config = config_with_jwt(valid_jwt_secret()); + config.paroche.port = 80; + let err = validate_config(&config).unwrap_err(); + assert!(err.to_string().contains("port")); + } + + // ── Serialize/Deserialize roundtrip ─────────────────────────────────────── + + #[test] + fn config_roundtrip() { + let mut original = Config::default(); + original.exousia.jwt_secret = valid_jwt_secret().to_string(); + let json = serde_json::to_string(&original).unwrap(); + let restored: Config = serde_json::from_str(&json).unwrap(); + assert_eq!(restored.exousia.jwt_secret, original.exousia.jwt_secret); + assert_eq!(restored.paroche.port, original.paroche.port); + assert_eq!(restored.aggelia.buffer_size, original.aggelia.buffer_size); + } + + #[test] + fn exousia_config_roundtrip() { + let original = ExousiaConfig { + access_token_ttl_secs: 1800, + refresh_token_ttl_days: 60, + jwt_secret: valid_jwt_secret().to_string(), + }; + let json = serde_json::to_string(&original).unwrap(); + let restored: ExousiaConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(restored.access_token_ttl_secs, 1800); + assert_eq!(restored.refresh_token_ttl_days, 60); + } + + #[test] + fn taxis_config_roundtrip() { + let mut original = TaxisConfig::default(); + let mut lib = LibraryConfig::default(); + lib.path = std::path::PathBuf::from("/data/music"); + lib.media_type = MediaType::Music; + lib.watcher_mode = WatcherMode::Inotify; + original.libraries.insert("music".to_string(), lib); + let json = serde_json::to_string(&original).unwrap(); + let restored: TaxisConfig = serde_json::from_str(&json).unwrap(); + assert!(restored.libraries.contains_key("music")); + assert_eq!( + restored.libraries["music"].path, + std::path::PathBuf::from("/data/music") + ); + } + + #[test] + fn database_config_roundtrip() { + let original = DatabaseConfig::default(); + let json = serde_json::to_string(&original).unwrap(); + let restored: DatabaseConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(restored.write_pool_max, 1); + assert_eq!(restored.read_pool_size, 0); + } + + #[test] + fn aggelia_config_roundtrip() { + let original = AggeliaConfig::default(); + let json = serde_json::to_string(&original).unwrap(); + let restored: AggeliaConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(restored.buffer_size, 1024); + assert_eq!(restored.download_queue_size, 512); + } +} diff --git a/crates/horismos/src/secrets.rs b/crates/horismos/src/secrets.rs new file mode 100644 index 0000000..0372605 --- /dev/null +++ b/crates/horismos/src/secrets.rs @@ -0,0 +1,9 @@ +use std::path::{Path, PathBuf}; + +/// Returns the secrets.toml path as a sibling of the given config file. +pub fn secrets_path(config_path: &Path) -> PathBuf { + config_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join("secrets.toml") +} diff --git a/crates/horismos/src/subsystems.rs b/crates/horismos/src/subsystems.rs new file mode 100644 index 0000000..8d23789 --- /dev/null +++ b/crates/horismos/src/subsystems.rs @@ -0,0 +1,220 @@ +use std::{collections::HashMap, path::PathBuf}; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DatabaseConfig { + pub db_path: PathBuf, + pub read_pool_size: u32, + pub write_pool_max: u32, +} + +impl Default for DatabaseConfig { + fn default() -> Self { + Self { + db_path: PathBuf::from("harmonia.db"), + read_pool_size: 0, // 0 = auto-detect + write_pool_max: 1, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExousiaConfig { + pub access_token_ttl_secs: u64, + pub refresh_token_ttl_days: u64, + pub jwt_secret: String, +} + +impl Default for ExousiaConfig { + fn default() -> Self { + Self { + access_token_ttl_secs: 900, + refresh_token_ttl_days: 30, + jwt_secret: String::new(), // intentionally invalid — validation rejects it + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ParocheConfig { + pub listen_addr: String, + pub port: u16, + pub stream_buffer_kb: usize, + pub transcode_concurrency: usize, + pub opds_page_size: usize, +} + +impl Default for ParocheConfig { + fn default() -> Self { + Self { + listen_addr: "0.0.0.0".to_string(), + port: 8096, + stream_buffer_kb: 256, + transcode_concurrency: 2, + opds_page_size: 50, + } + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum WatcherMode { + #[default] + Auto, + Inotify, + Poll, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum MediaType { + #[default] + Music, + Video, + Book, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LibraryConfig { + pub path: PathBuf, + pub media_type: MediaType, + pub watcher_mode: WatcherMode, + pub poll_interval_seconds: u64, + pub auto_import: bool, + pub scan_interval_hours: u64, +} + +impl Default for LibraryConfig { + fn default() -> Self { + Self { + path: PathBuf::new(), + media_type: MediaType::default(), + watcher_mode: WatcherMode::default(), + poll_interval_seconds: 300, + auto_import: true, + scan_interval_hours: 24, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct TaxisConfig { + pub libraries: HashMap, + pub file_naming_dry_run: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EpignosisConfig { + pub cache_ttl_secs: u64, + pub max_retries: u32, + pub provider_timeout_secs: u64, +} + +impl Default for EpignosisConfig { + fn default() -> Self { + Self { + cache_ttl_secs: 86400, + max_retries: 3, + provider_timeout_secs: 10, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KritikeConfig { + pub scan_interval_hours: u64, + pub quality_check_concurrency: usize, +} + +impl Default for KritikeConfig { + fn default() -> Self { + Self { + scan_interval_hours: 24, + quality_check_concurrency: 4, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AggeliaConfig { + pub buffer_size: usize, + pub download_queue_size: usize, +} + +impl Default for AggeliaConfig { + fn default() -> Self { + Self { + buffer_size: 1024, + download_queue_size: 512, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ZetesisConfig { + pub request_timeout_secs: u64, + pub max_results_per_indexer: usize, +} + +impl Default for ZetesisConfig { + fn default() -> Self { + Self { + request_timeout_secs: 30, + max_results_per_indexer: 100, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ErgasiaConfig { + pub download_dir: PathBuf, + pub max_concurrent_downloads: usize, + pub seeding_ratio_limit: f64, + pub seeding_time_limit_hours: u64, +} + +impl Default for ErgasiaConfig { + fn default() -> Self { + Self { + download_dir: PathBuf::from("/data/downloads"), + max_concurrent_downloads: 3, + seeding_ratio_limit: 2.0, + seeding_time_limit_hours: 168, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SyntaxisConfig { + pub max_queue_size: usize, + pub max_retries: u32, + pub retry_delay_secs: u64, +} + +impl Default for SyntaxisConfig { + fn default() -> Self { + Self { + max_queue_size: 1000, + max_retries: 3, + retry_delay_secs: 60, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProsthekeConfig { + pub languages: Vec, + pub hearing_impaired: bool, + pub provider_timeout_secs: u64, +} + +impl Default for ProsthekeConfig { + fn default() -> Self { + Self { + languages: vec!["en".to_string()], + hearing_impaired: false, + provider_timeout_secs: 15, + } + } +} diff --git a/crates/horismos/src/validation.rs b/crates/horismos/src/validation.rs new file mode 100644 index 0000000..f426d0d --- /dev/null +++ b/crates/horismos/src/validation.rs @@ -0,0 +1,93 @@ +use tracing::warn; + +use crate::{ + Config, + error::{HorismosError, ValidationSnafu}, +}; + +#[derive(Debug)] +pub struct ValidationWarning { + pub field: String, + pub message: String, +} + +pub fn validate_config(config: &Config) -> Result, HorismosError> { + let mut warnings = Vec::new(); + + validate_jwt_secret(config)?; + validate_ports(config)?; + validate_timeouts(config)?; + collect_library_warnings(config, &mut warnings); + + Ok(warnings) +} + +fn validate_jwt_secret(config: &Config) -> Result<(), HorismosError> { + let secret = &config.exousia.jwt_secret; + if secret.is_empty() || secret == "changeme" || secret == "default" { + return ValidationSnafu { + message: "exousia.jwt_secret must not be empty or a placeholder value — set via secrets.toml or HARMONIA__EXOUSIA__JWT_SECRET".to_string(), + } + .fail(); + } + if secret.len() < 32 { + return ValidationSnafu { + message: format!( + "exousia.jwt_secret is too short ({} bytes); minimum is 32 bytes", + secret.len() + ), + } + .fail(); + } + Ok(()) +} + +fn validate_ports(config: &Config) -> Result<(), HorismosError> { + let port = config.paroche.port; + if port < 1024 { + return ValidationSnafu { + message: format!("paroche.port ({port}) is below 1024 — Harmonia must not run as root"), + } + .fail(); + } + Ok(()) +} + +fn validate_timeouts(config: &Config) -> Result<(), HorismosError> { + if config.zetesis.request_timeout_secs == 0 { + return ValidationSnafu { + message: "zetesis.request_timeout_secs must be greater than 0".to_string(), + } + .fail(); + } + if config.epignosis.provider_timeout_secs == 0 { + return ValidationSnafu { + message: "epignosis.provider_timeout_secs must be greater than 0".to_string(), + } + .fail(); + } + if config.prostheke.provider_timeout_secs == 0 { + return ValidationSnafu { + message: "prostheke.provider_timeout_secs must be greater than 0".to_string(), + } + .fail(); + } + Ok(()) +} + +fn collect_library_warnings(config: &Config, warnings: &mut Vec) { + for (name, library) in &config.taxis.libraries { + if !library.path.exists() { + let message = format!( + "library '{}' path '{}' is not accessible at startup", + name, + library.path.display() + ); + warn!(library = %name, path = %library.path.display(), "library path not accessible at startup"); + warnings.push(ValidationWarning { + field: format!("taxis.libraries.{name}.path"), + message, + }); + } + } +}