1use anyhow::{Context, Result};
7use serde::de::DeserializeOwned;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use super::defaults;
12use super::types::{Config, Server, UserCredentials};
13
14type TomlTable = toml::map::Map<String, toml::Value>;
15const CREDENTIALS_FILE_ENV: &str = "NNTP_PROXY_CREDENTIALS_FILE";
16
17#[derive(Debug, Default, serde::Deserialize)]
18struct CredentialsOverlay {
19 #[serde(default)]
20 servers: Vec<ServerCredentialsOverlay>,
21 #[serde(default)]
22 client_auth: ClientAuthCredentialsOverlay,
23}
24
25#[derive(Debug, serde::Deserialize)]
26struct ServerCredentialsOverlay {
27 name: String,
28 #[serde(default)]
29 username: Option<String>,
30 #[serde(default)]
31 password: Option<String>,
32}
33
34#[derive(Debug, Default, serde::Deserialize)]
35struct ClientAuthCredentialsOverlay {
36 #[serde(default)]
37 users: Vec<UserCredentials>,
38}
39
40fn table<'a>(value: &'a toml::Value, key: &str) -> Option<&'a TomlTable> {
41 value.get(key)?.as_table()
42}
43
44fn table_has_key(value: &toml::Value, section: &str, key: &str) -> bool {
45 table(value, section).is_some_and(|t| t.contains_key(key))
46}
47
48fn table_has_any_key(value: &toml::Value, section: &str, keys: &[&str]) -> bool {
49 keys.iter().any(|key| table_has_key(value, section, key))
50}
51
52fn path_with_suffix(path: &Path, suffix: &str) -> PathBuf {
53 let mut path = path.as_os_str().to_os_string();
54 path.push(suffix);
55 PathBuf::from(path)
56}
57
58fn apply_credentials_overlay(config: &mut Config, overlay_path: &str) -> Result<()> {
59 let overlay_content = fs::read_to_string(overlay_path)
60 .with_context(|| format!("Failed to read credentials overlay file '{overlay_path}'"))?;
61 let overlay: CredentialsOverlay = toml::from_str(&overlay_content)
62 .with_context(|| format!("Failed to parse credentials overlay file '{overlay_path}'"))?;
63
64 for server_overlay in overlay.servers {
65 let server = config
66 .servers
67 .iter_mut()
68 .find(|server| server.name.as_str() == server_overlay.name)
69 .with_context(|| {
70 format!(
71 "Credentials overlay '{}' references unknown server '{}'",
72 overlay_path, server_overlay.name
73 )
74 })?;
75
76 if let Some(username) = server_overlay.username {
77 server.username = Some(username);
78 }
79 if let Some(password) = server_overlay.password {
80 server.password = Some(password);
81 }
82 }
83
84 for user in overlay.client_auth.users {
85 if let Some(existing) = config
86 .client_auth
87 .users
88 .iter_mut()
89 .find(|existing| existing.username == user.username)
90 {
91 existing.password = user.password;
92 } else {
93 config.client_auth.users.push(user);
94 }
95 }
96
97 Ok(())
98}
99
100fn apply_credentials_overlay_from_env<E: EnvProvider>(config: &mut Config, env: &E) -> Result<()> {
101 let Some(overlay_path) = env.get(CREDENTIALS_FILE_ENV) else {
102 return Ok(());
103 };
104
105 apply_credentials_overlay(config, &overlay_path)?;
106 tracing::info!(
107 "Loaded credentials overlay from '{}' via {}",
108 overlay_path,
109 CREDENTIALS_FILE_ENV
110 );
111 Ok(())
112}
113
114fn write_canonical_config(config_path: &str, config: &Config) -> Result<()> {
115 let canonical =
116 toml::to_string_pretty(config).context("Failed to serialize migrated config")?;
117 let path = Path::new(config_path);
118 let backup_path = path_with_suffix(path, ".bak");
119
120 fs::copy(path, &backup_path)
121 .with_context(|| format!("Failed to create backup config '{}'", backup_path.display()))?;
122
123 let tmp_path = path_with_suffix(path, ".tmp");
124 fs::write(&tmp_path, canonical).with_context(|| {
125 format!(
126 "Failed to write migrated config to temporary file '{}'",
127 tmp_path.display()
128 )
129 })?;
130
131 let original_permissions = fs::metadata(path)
132 .with_context(|| format!("Failed to read config metadata '{}'", path.display()))?
133 .permissions();
134 fs::set_permissions(&tmp_path, original_permissions).with_context(|| {
135 format!(
136 "Failed to preserve config permissions on temporary file '{}'",
137 tmp_path.display()
138 )
139 })?;
140
141 fs::rename(&tmp_path, path).with_context(|| {
142 format!(
143 "Failed to atomically replace config '{}' with migrated schema",
144 path.display()
145 )
146 })?;
147
148 Ok(())
149}
150
151fn assign_from_table<T>(target: &mut T, source: &TomlTable, key: &str) -> bool
152where
153 T: DeserializeOwned,
154{
155 let Some(value) = source
156 .get(key)
157 .and_then(|value| value.clone().try_into::<T>().ok())
158 else {
159 return false;
160 };
161
162 *target = value;
163 true
164}
165
166fn migrate_proxy_routing(config: &mut Config, raw: &toml::Value) -> bool {
167 let Some(proxy) = table(raw, "proxy") else {
168 return false;
169 };
170
171 let mut migrated = false;
172 if !table_has_any_key(raw, "routing", &["mode", "routing_mode"]) {
173 migrated |= assign_from_table(&mut config.routing.routing_mode, proxy, "routing_mode");
174 }
175 if !table_has_any_key(raw, "routing", &["backend_selection", "strategy"]) {
176 migrated |= assign_from_table(
177 &mut config.routing.backend_selection,
178 proxy,
179 "backend_selection",
180 );
181 }
182 migrated
183}
184
185fn migrate_proxy_memory(config: &mut Config, raw: &toml::Value) -> bool {
186 let Some(proxy) = table(raw, "proxy") else {
187 return false;
188 };
189
190 let mut migrated = false;
191 if !table_has_key(raw, "memory", "buffer_pool_count") {
192 migrated |= assign_from_table(
193 &mut config.memory.buffer_pool_count,
194 proxy,
195 "buffer_pool_count",
196 );
197 }
198 if !table_has_key(raw, "memory", "capture_pool_count") {
199 migrated |= assign_from_table(
200 &mut config.memory.capture_pool_count,
201 proxy,
202 "capture_pool_count",
203 );
204 }
205 migrated
206}
207
208fn migrate_cache_precheck(config: &mut Config, raw: &toml::Value) -> bool {
209 if !table_has_key(raw, "routing", "adaptive_precheck")
210 && let Some(cache) = table(raw, "cache")
211 && assign_from_table(
212 &mut config.routing.adaptive_precheck,
213 cache,
214 "adaptive_precheck",
215 )
216 {
217 return true;
218 }
219
220 false
221}
222
223fn migrate_client_auth_users(config: &mut Config, raw: &toml::Value) -> bool {
224 if !config.client_auth.users.is_empty() {
225 return false;
226 }
227
228 let Some(client_auth) = table(raw, "client_auth") else {
229 return false;
230 };
231
232 let Some(username) = client_auth
233 .get("username")
234 .and_then(|value| value.as_str())
235 .map(str::to_owned)
236 else {
237 return false;
238 };
239 let Some(password) = client_auth
240 .get("password")
241 .and_then(|value| value.as_str())
242 .map(str::to_owned)
243 else {
244 return false;
245 };
246
247 config
248 .client_auth
249 .users
250 .push(UserCredentials { username, password });
251 true
252}
253
254fn migrate_legacy_config(config: &mut Config, raw: &toml::Value) -> bool {
255 migrate_proxy_routing(config, raw)
256 | migrate_proxy_memory(config, raw)
257 | migrate_cache_precheck(config, raw)
258 | migrate_client_auth_users(config, raw)
259}
260
261pub trait EnvProvider {
263 fn get(&self, key: &str) -> Option<String>;
264}
265
266#[derive(Default)]
268pub struct StdEnvProvider;
269
270impl EnvProvider for StdEnvProvider {
271 fn get(&self, key: &str) -> Option<String> {
272 std::env::var(key).ok()
273 }
274}
275
276pub fn parse_server_from_env<E: EnvProvider>(index: usize, env: &E) -> Option<Server> {
285 let host_key = format!("NNTP_SERVER_{index}_HOST");
287 let host = env.get(&host_key)?;
288
289 let port_key = format!("NNTP_SERVER_{index}_PORT");
291 let port = env
292 .get(&port_key)
293 .and_then(|p| p.parse::<u16>().ok())
294 .unwrap_or(119); let name_key = format!("NNTP_SERVER_{index}_NAME");
298 let name = env
299 .get(&name_key)
300 .unwrap_or_else(|| format!("Server {index}"));
301
302 let username_key = format!("NNTP_SERVER_{index}_USERNAME");
304 let username = env.get(&username_key);
305
306 let password_key = format!("NNTP_SERVER_{index}_PASSWORD");
307 let password = env.get(&password_key);
308
309 let max_conn_key = format!("NNTP_SERVER_{index}_MAX_CONNECTIONS");
310 let max_connections = env
311 .get(&max_conn_key)
312 .and_then(|m| m.parse::<usize>().ok())
313 .and_then(|m| crate::types::MaxConnections::try_new(m).ok())
314 .unwrap_or_else(defaults::max_connections);
315
316 let use_tls_key = format!("NNTP_SERVER_{index}_USE_TLS");
318 let use_tls = env
319 .get(&use_tls_key)
320 .and_then(|v| v.parse::<bool>().ok())
321 .unwrap_or(false);
322
323 let tls_verify_key = format!("NNTP_SERVER_{index}_TLS_VERIFY_CERT");
324 let tls_verify_cert = env
325 .get(&tls_verify_key)
326 .and_then(|v| v.parse::<bool>().ok())
327 .unwrap_or_else(defaults::tls_verify_cert);
328
329 let tls_cert_path_key = format!("NNTP_SERVER_{index}_TLS_CERT_PATH");
330 let tls_cert_path = env.get(&tls_cert_path_key);
331
332 let keepalive_key = format!("NNTP_SERVER_{index}_CONNECTION_KEEPALIVE");
334 let connection_keepalive = env
335 .get(&keepalive_key)
336 .and_then(|k| k.parse::<u64>().ok())
337 .map(std::time::Duration::from_secs);
338
339 let health_max_key = format!("NNTP_SERVER_{index}_HEALTH_CHECK_MAX_PER_CYCLE");
341 let health_check_max_per_cycle = env
342 .get(&health_max_key)
343 .and_then(|h| h.parse::<usize>().ok())
344 .unwrap_or_else(defaults::health_check_max_per_cycle);
345
346 let health_timeout_key = format!("NNTP_SERVER_{index}_HEALTH_CHECK_POOL_TIMEOUT");
347 let health_check_pool_timeout = env
348 .get(&health_timeout_key)
349 .and_then(|h| h.parse::<u64>().ok())
350 .map_or_else(
351 defaults::health_check_pool_timeout,
352 std::time::Duration::from_secs,
353 );
354
355 let tier_key = format!("NNTP_SERVER_{index}_TIER");
356 let tier = env.get(&tier_key).map_or(0, |tier_str| {
357 tier_str
358 .parse::<u8>()
359 .unwrap_or_else(|_| panic!("Invalid tier in {tier_key}: '{tier_str}' (must be 0-255)"))
360 });
361
362 Some(Server {
363 host: crate::types::HostName::try_new(host.clone())
364 .unwrap_or_else(|_| panic!("Invalid hostname in {host_key}: '{host}'")),
365 port: crate::types::Port::try_new(port)
366 .unwrap_or_else(|_| panic!("Invalid port in {port_key}: {port}")),
367 name: crate::types::ServerName::try_new(name.clone())
368 .unwrap_or_else(|_| panic!("Invalid server name in {name_key}: '{name}'")),
369 username,
370 password,
371 max_connections,
372 use_tls,
373 tls_verify_cert,
374 tls_cert_path,
375 connection_keepalive,
376 replacement_cooldown: crate::config::defaults::replacement_cooldown_option(),
377 health_check_max_per_cycle,
378 health_check_pool_timeout,
379 tier,
380 compress: None,
381 compress_level: None,
382 backend_idle_timeout: crate::config::defaults::backend_idle_timeout(),
383 })
384}
385
386pub fn load_servers_from_env_provider<E: EnvProvider>(env: &E) -> Option<Vec<Server>> {
388 let servers: Vec<Server> = (0..)
389 .map_while(|index| parse_server_from_env(index, env))
390 .collect();
391
392 if servers.is_empty() {
393 None
394 } else {
395 Some(servers)
396 }
397}
398
399#[must_use]
403pub fn has_server_env_vars() -> bool {
404 std::env::var("NNTP_SERVER_0_HOST").is_ok()
405}
406
407pub fn load_config_from_env() -> Result<Config> {
415 load_config_from_env_provider(&StdEnvProvider)
416}
417
418fn load_config_from_env_provider<E: EnvProvider>(env: &E) -> Result<Config> {
419 use anyhow::Context;
420
421 let servers = load_servers_from_env_provider(env)
422 .context("No backend servers configured via environment variables. Set NNTP_SERVER_0_HOST, NNTP_SERVER_0_PORT, etc.")?;
423
424 let mut config = Config {
425 servers,
426 ..Default::default()
427 };
428
429 apply_credentials_overlay_from_env(&mut config, env)?;
430
431 config.validate()?;
433
434 Ok(config)
435}
436
437pub fn load_config(config_path: &str) -> Result<Config> {
452 load_config_with_env_provider(config_path, &StdEnvProvider)
453}
454
455fn load_config_with_env_provider<E: EnvProvider>(config_path: &str, env: &E) -> Result<Config> {
456 use anyhow::Context;
457
458 let config_content = std::fs::read_to_string(config_path)
459 .with_context(|| format!("Failed to read config file '{config_path}'"))?;
460
461 let raw_config: toml::Value = toml::from_str(&config_content)
462 .with_context(|| format!("Failed to parse config file '{config_path}'"))?;
463
464 let mut config: Config = toml::from_str(&config_content)
465 .with_context(|| format!("Failed to parse config file '{config_path}'"))?;
466
467 let mut file_config = config.clone();
468 let migrated = migrate_legacy_config(&mut file_config, &raw_config);
469
470 if migrated {
471 tracing::info!(
472 "Migrating legacy configuration schema in '{}' to the new routing/cache/memory/client-auth layout",
473 config_path
474 );
475 config = file_config;
476 if let Err(error) = write_canonical_config(config_path, &config) {
477 tracing::warn!(
478 "Failed to write migrated config schema for '{}': {:#}. Continuing with migrated in-memory config.",
479 config_path,
480 error
481 );
482 }
483 }
484
485 if let Some(env_servers) = load_servers_from_env_provider(env) {
488 tracing::info!(
489 "Using {} backend server(s) from environment variables (overriding config file)",
490 env_servers.len()
491 );
492 config.servers = env_servers;
493 }
494
495 apply_credentials_overlay_from_env(&mut config, env)?;
496
497 config.validate()?;
499
500 Ok(config)
501}
502
503#[derive(Debug, Clone, Copy, PartialEq, Eq)]
505pub enum ConfigSource {
506 File,
508 Environment,
510 DefaultCreated,
512}
513
514impl ConfigSource {
515 #[must_use]
517 pub const fn description(&self) -> &'static str {
518 match self {
519 Self::File => "configuration file",
520 Self::Environment => "environment variables",
521 Self::DefaultCreated => "default configuration (created)",
522 }
523 }
524}
525
526pub fn load_config_with_fallback(config_path: &str) -> Result<(Config, ConfigSource)> {
545 use anyhow::Context;
546
547 if std::path::Path::new(config_path).exists() {
549 match load_config(config_path) {
550 Ok(config) => {
551 tracing::info!("Loaded configuration from file: {}", config_path);
552 return Ok((config, ConfigSource::File));
553 }
554 Err(e) => {
555 tracing::error!(
556 "Failed to load existing config file '{}': {}",
557 config_path,
558 e
559 );
560 tracing::error!("Please check your config file syntax and try again");
561 return Err(e);
562 }
563 }
564 }
565
566 if has_server_env_vars() {
568 match load_config_from_env() {
569 Ok(config) => {
570 tracing::info!(
571 "Using configuration from environment variables (no config file found)"
572 );
573 return Ok((config, ConfigSource::Environment));
574 }
575 Err(e) => {
576 tracing::error!(
577 "Failed to load configuration from environment variables: {}",
578 e
579 );
580 return Err(e);
581 }
582 }
583 }
584
585 tracing::warn!(
587 "Config file '{}' not found and no NNTP_SERVER_* environment variables set",
588 config_path
589 );
590 tracing::warn!("Creating default config file - please edit it to add your backend servers");
591
592 let default_config = create_default_config();
593 let config_toml =
594 toml::to_string_pretty(&default_config).context("Failed to serialize default config")?;
595
596 std::fs::write(config_path, &config_toml)
597 .with_context(|| format!("Failed to write default config to '{config_path}'"))?;
598
599 tracing::info!("Created default config file: {}", config_path);
600 Ok((default_config, ConfigSource::DefaultCreated))
601}
602
603#[must_use]
605pub fn create_default_config() -> Config {
610 Config {
611 servers: vec![Server {
612 host: crate::types::HostName::try_new("news.example.com".to_string())
613 .expect("Valid hostname"),
614 port: crate::types::Port::try_new(119).expect("Valid port"),
615 name: crate::types::ServerName::try_new("Example News Server".to_string())
616 .expect("Valid server name"),
617 username: None,
618 password: None,
619 max_connections: defaults::max_connections(),
620 use_tls: false,
621 tls_verify_cert: defaults::tls_verify_cert(),
622 tls_cert_path: None,
623 connection_keepalive: None,
624 replacement_cooldown: defaults::replacement_cooldown_option(),
625 health_check_max_per_cycle: defaults::health_check_max_per_cycle(),
626 health_check_pool_timeout: defaults::health_check_pool_timeout(),
627 tier: 0,
628 compress: None,
629 compress_level: None,
630 backend_idle_timeout: defaults::backend_idle_timeout(),
631 }],
632 ..Default::default()
633 }
634}
635
636#[cfg(test)]
637mod tests {
638 use super::*;
639 use crate::config::{BackendSelectionStrategy, RoutingMode};
640 use std::collections::HashMap;
641 use std::io::Write;
642 use tempfile::NamedTempFile;
643
644 struct MockEnv {
646 vars: HashMap<String, String>,
647 }
648
649 impl MockEnv {
650 fn new() -> Self {
651 Self {
652 vars: HashMap::new(),
653 }
654 }
655
656 fn set(&mut self, key: impl Into<String>, value: impl Into<String>) -> &mut Self {
657 self.vars.insert(key.into(), value.into());
658 self
659 }
660 }
661
662 impl EnvProvider for MockEnv {
663 fn get(&self, key: &str) -> Option<String> {
664 self.vars.get(key).cloned()
665 }
666 }
667
668 #[test]
669 fn test_parse_server_from_env_minimal() {
670 let mut env = MockEnv::new();
671 env.set("NNTP_SERVER_0_HOST", "news.example.com");
672
673 let server = parse_server_from_env(0, &env);
674 assert!(server.is_some());
675
676 let server = server.unwrap();
677 assert_eq!(server.host.as_str(), "news.example.com");
678 assert_eq!(server.port.get(), 119); assert_eq!(server.name.as_str(), "Server 0"); assert!(server.username.is_none());
681 assert!(server.password.is_none());
682 }
683
684 #[test]
685 fn test_parse_server_from_env_full() {
686 let mut env = MockEnv::new();
687 env.set("NNTP_SERVER_0_HOST", "secure.example.com")
688 .set("NNTP_SERVER_0_PORT", "563")
689 .set("NNTP_SERVER_0_NAME", "Secure News")
690 .set("NNTP_SERVER_0_USERNAME", "testuser")
691 .set("NNTP_SERVER_0_PASSWORD", "testpass")
692 .set("NNTP_SERVER_0_MAX_CONNECTIONS", "20")
693 .set("NNTP_SERVER_0_USE_TLS", "true")
694 .set("NNTP_SERVER_0_TLS_VERIFY_CERT", "false");
695
696 let server = parse_server_from_env(0, &env).unwrap();
697 assert_eq!(server.host.as_str(), "secure.example.com");
698 assert_eq!(server.port.get(), 563);
699 assert_eq!(server.name.as_str(), "Secure News");
700 assert_eq!(server.username, Some("testuser".to_string()));
701 assert_eq!(server.password, Some("testpass".to_string()));
702 assert_eq!(server.max_connections.get(), 20);
703 assert!(server.use_tls);
704 assert!(!server.tls_verify_cert);
705 }
706
707 #[test]
708 fn test_parse_server_from_env_no_host() {
709 let env = MockEnv::new();
710 let server = parse_server_from_env(0, &env);
711 assert!(server.is_none());
712 }
713
714 #[test]
715 fn test_parse_server_from_env_invalid_port() {
716 let mut env = MockEnv::new();
717 env.set("NNTP_SERVER_0_HOST", "news.example.com")
718 .set("NNTP_SERVER_0_PORT", "invalid");
719
720 let server = parse_server_from_env(0, &env).unwrap();
721 assert_eq!(server.port.get(), 119); }
723
724 #[test]
725 fn test_parse_server_from_env_invalid_max_connections() {
726 let mut env = MockEnv::new();
727 env.set("NNTP_SERVER_0_HOST", "news.example.com")
728 .set("NNTP_SERVER_0_MAX_CONNECTIONS", "not_a_number");
729
730 let server = parse_server_from_env(0, &env).unwrap();
731 assert_eq!(server.max_connections.get(), 10); }
733
734 #[test]
735 fn test_parse_server_from_env_zero_max_connections() {
736 let mut env = MockEnv::new();
737 env.set("NNTP_SERVER_0_HOST", "news.example.com")
738 .set("NNTP_SERVER_0_MAX_CONNECTIONS", "0");
739
740 let server = parse_server_from_env(0, &env).unwrap();
741 assert_eq!(server.max_connections.get(), 10); }
743
744 #[test]
745 fn test_parse_server_from_env_keepalive() {
746 let mut env = MockEnv::new();
747 env.set("NNTP_SERVER_0_HOST", "news.example.com")
748 .set("NNTP_SERVER_0_CONNECTION_KEEPALIVE", "300");
749
750 let server = parse_server_from_env(0, &env).unwrap();
751 assert_eq!(
752 server.connection_keepalive,
753 Some(crate::constants::duration_polyfill::from_minutes(5))
754 );
755 }
756
757 #[test]
758 fn test_parse_server_from_env_health_check_config() {
759 let mut env = MockEnv::new();
760 env.set("NNTP_SERVER_0_HOST", "news.example.com")
761 .set("NNTP_SERVER_0_HEALTH_CHECK_MAX_PER_CYCLE", "5")
762 .set("NNTP_SERVER_0_HEALTH_CHECK_POOL_TIMEOUT", "15");
763
764 let server = parse_server_from_env(0, &env).unwrap();
765 assert_eq!(server.health_check_max_per_cycle, 5);
766 assert_eq!(
767 server.health_check_pool_timeout,
768 std::time::Duration::from_secs(15)
769 );
770 }
771
772 #[test]
773 fn test_parse_server_from_env_tls_cert_path() {
774 let mut env = MockEnv::new();
775 env.set("NNTP_SERVER_0_HOST", "news.example.com")
776 .set("NNTP_SERVER_0_USE_TLS", "true")
777 .set("NNTP_SERVER_0_TLS_CERT_PATH", "/path/to/ca.pem");
778
779 let server = parse_server_from_env(0, &env).unwrap();
780 assert!(server.use_tls);
781 assert_eq!(server.tls_cert_path, Some("/path/to/ca.pem".to_string()));
782 }
783
784 #[test]
785 fn test_load_servers_from_env_provider_empty() {
786 let env = MockEnv::new();
787 let servers = load_servers_from_env_provider(&env);
788 assert!(servers.is_none());
789 }
790
791 #[test]
792 fn test_load_servers_from_env_provider_single() {
793 let mut env = MockEnv::new();
794 env.set("NNTP_SERVER_0_HOST", "news1.example.com");
795
796 let servers = load_servers_from_env_provider(&env);
797 assert!(servers.is_some());
798
799 let servers = servers.unwrap();
800 assert_eq!(servers.len(), 1);
801 assert_eq!(servers[0].host.as_str(), "news1.example.com");
802 }
803
804 #[test]
805 fn test_load_servers_from_env_provider_multiple() {
806 let mut env = MockEnv::new();
807 env.set("NNTP_SERVER_0_HOST", "news1.example.com")
808 .set("NNTP_SERVER_0_PORT", "119")
809 .set("NNTP_SERVER_1_HOST", "news2.example.com")
810 .set("NNTP_SERVER_1_PORT", "563")
811 .set("NNTP_SERVER_1_USE_TLS", "true")
812 .set("NNTP_SERVER_2_HOST", "news3.example.com");
813
814 let servers = load_servers_from_env_provider(&env);
815 assert!(servers.is_some());
816
817 let servers = servers.unwrap();
818 assert_eq!(servers.len(), 3);
819 assert_eq!(servers[0].host.as_str(), "news1.example.com");
820 assert_eq!(servers[1].host.as_str(), "news2.example.com");
821 assert_eq!(servers[2].host.as_str(), "news3.example.com");
822 assert!(servers[1].use_tls);
823 assert!(!servers[0].use_tls);
824 }
825
826 #[test]
827 fn test_load_servers_from_env_provider_gaps() {
828 let mut env = MockEnv::new();
829 env.set("NNTP_SERVER_0_HOST", "news1.example.com")
831 .set("NNTP_SERVER_2_HOST", "news3.example.com");
832
833 let servers = load_servers_from_env_provider(&env);
834 assert!(servers.is_some());
835
836 let servers = servers.unwrap();
837 assert_eq!(servers.len(), 1);
839 assert_eq!(servers[0].host.as_str(), "news1.example.com");
840 }
841
842 #[test]
843 fn test_parse_server_from_env_bool_variations() {
844 let mut env = MockEnv::new();
845 env.set("NNTP_SERVER_0_HOST", "news.example.com")
846 .set("NNTP_SERVER_0_USE_TLS", "True")
847 .set("NNTP_SERVER_0_TLS_VERIFY_CERT", "FALSE");
848
849 let server = parse_server_from_env(0, &env).unwrap();
850 assert!(!server.use_tls); assert!(server.tls_verify_cert); }
855
856 #[test]
857 fn test_parse_server_from_env_correct_bool() {
858 let mut env = MockEnv::new();
859 env.set("NNTP_SERVER_0_HOST", "news.example.com")
860 .set("NNTP_SERVER_0_USE_TLS", "true")
861 .set("NNTP_SERVER_0_TLS_VERIFY_CERT", "false");
862
863 let server = parse_server_from_env(0, &env).unwrap();
864 assert!(server.use_tls);
865 assert!(!server.tls_verify_cert);
866 }
867
868 #[test]
869 fn test_config_source_description() {
870 assert_eq!(ConfigSource::File.description(), "configuration file");
871 assert_eq!(
872 ConfigSource::Environment.description(),
873 "environment variables"
874 );
875 assert_eq!(
876 ConfigSource::DefaultCreated.description(),
877 "default configuration (created)"
878 );
879 }
880
881 #[test]
882 fn test_config_source_equality() {
883 assert_eq!(ConfigSource::File, ConfigSource::File);
884 assert_ne!(ConfigSource::File, ConfigSource::Environment);
885 assert_ne!(ConfigSource::Environment, ConfigSource::DefaultCreated);
886 }
887
888 #[test]
889 fn test_load_config_with_fallback_creates_default() {
890 use tempfile::NamedTempFile;
891
892 let temp_file = NamedTempFile::new().unwrap();
893 let path = temp_file.path().to_str().unwrap().to_string();
894
895 drop(temp_file);
897
898 let result = load_config_with_fallback(&path);
900 assert!(result.is_ok());
901
902 let (config, source) = result.unwrap();
903 assert_eq!(source, ConfigSource::DefaultCreated);
904 assert_eq!(config.servers.len(), 1);
905 assert_eq!(config.servers[0].host.as_str(), "news.example.com");
906
907 let _ = std::fs::remove_file(&path);
909 }
910
911 #[test]
912 fn test_load_config_with_fallback_reads_existing() {
913 use std::io::Write;
914 use tempfile::NamedTempFile;
915
916 let mut temp_file = NamedTempFile::new().unwrap();
917
918 let config_content = r#"
920[[servers]]
921host = "test.example.com"
922port = 119
923name = "Test Server"
924"#;
925 temp_file.write_all(config_content.as_bytes()).unwrap();
926 temp_file.flush().unwrap();
927
928 let path = temp_file.path().to_str().unwrap().to_string();
930
931 let result = load_config_with_fallback(&path);
932 assert!(result.is_ok());
933
934 let (config, source) = result.unwrap();
935 assert_eq!(source, ConfigSource::File);
936 assert_eq!(config.servers.len(), 1);
937 assert_eq!(config.servers[0].host.as_str(), "test.example.com");
938 }
939
940 #[test]
941 fn test_load_config_applies_credentials_overlay() {
942 let mut config_file = NamedTempFile::new().unwrap();
943 let config_content = r#"
944[[servers]]
945host = "news.example.com"
946port = 563
947name = "Primary"
948use_tls = true
949
950[proxy]
951port = 8121
952"#;
953 config_file.write_all(config_content.as_bytes()).unwrap();
954 config_file.flush().unwrap();
955
956 let mut credentials_file = NamedTempFile::new().unwrap();
957 let credentials_content = r#"
958[[servers]]
959name = "Primary"
960username = "backend-user"
961password = "backend-pass"
962
963[[client_auth.users]]
964username = "sabnzbd"
965password = "client-pass"
966"#;
967 credentials_file
968 .write_all(credentials_content.as_bytes())
969 .unwrap();
970 credentials_file.flush().unwrap();
971
972 let mut env = MockEnv::new();
973 env.set(
974 CREDENTIALS_FILE_ENV,
975 credentials_file.path().to_str().unwrap(),
976 );
977
978 let config =
979 load_config_with_env_provider(config_file.path().to_str().unwrap(), &env).unwrap();
980
981 assert_eq!(config.servers[0].username.as_deref(), Some("backend-user"));
982 assert_eq!(config.servers[0].password.as_deref(), Some("backend-pass"));
983 assert_eq!(config.client_auth.users.len(), 1);
984 assert_eq!(config.client_auth.users[0].username, "sabnzbd");
985 assert_eq!(config.client_auth.users[0].password, "client-pass");
986 }
987
988 #[test]
989 fn test_load_config_overlay_updates_existing_client_auth_user() {
990 let mut config_file = NamedTempFile::new().unwrap();
991 let config_content = r#"
992[[servers]]
993host = "news.example.com"
994port = 563
995name = "Primary"
996
997[[client_auth.users]]
998username = "sabnzbd"
999password = "old-pass"
1000"#;
1001 config_file.write_all(config_content.as_bytes()).unwrap();
1002 config_file.flush().unwrap();
1003
1004 let mut credentials_file = NamedTempFile::new().unwrap();
1005 let credentials_content = r#"
1006[[client_auth.users]]
1007username = "sabnzbd"
1008password = "new-pass"
1009"#;
1010 credentials_file
1011 .write_all(credentials_content.as_bytes())
1012 .unwrap();
1013 credentials_file.flush().unwrap();
1014
1015 let mut env = MockEnv::new();
1016 env.set(
1017 CREDENTIALS_FILE_ENV,
1018 credentials_file.path().to_str().unwrap(),
1019 );
1020
1021 let config =
1022 load_config_with_env_provider(config_file.path().to_str().unwrap(), &env).unwrap();
1023
1024 assert_eq!(config.client_auth.users.len(), 1);
1025 assert_eq!(config.client_auth.users[0].password, "new-pass");
1026 }
1027
1028 #[test]
1029 fn test_load_config_overlay_errors_on_unknown_server() {
1030 let mut config_file = NamedTempFile::new().unwrap();
1031 let config_content = r#"
1032[[servers]]
1033host = "news.example.com"
1034port = 563
1035name = "Primary"
1036"#;
1037 config_file.write_all(config_content.as_bytes()).unwrap();
1038 config_file.flush().unwrap();
1039
1040 let mut credentials_file = NamedTempFile::new().unwrap();
1041 let credentials_content = r#"
1042[[servers]]
1043name = "Missing"
1044username = "backend-user"
1045"#;
1046 credentials_file
1047 .write_all(credentials_content.as_bytes())
1048 .unwrap();
1049 credentials_file.flush().unwrap();
1050
1051 let mut env = MockEnv::new();
1052 env.set(
1053 CREDENTIALS_FILE_ENV,
1054 credentials_file.path().to_str().unwrap(),
1055 );
1056
1057 let error =
1058 load_config_with_env_provider(config_file.path().to_str().unwrap(), &env).unwrap_err();
1059
1060 assert!(
1061 error
1062 .to_string()
1063 .contains("references unknown server 'Missing'")
1064 );
1065 }
1066
1067 #[test]
1068 fn test_create_default_config() {
1069 let config = create_default_config();
1070 assert_eq!(config.servers.len(), 1);
1071 assert_eq!(config.servers[0].host.as_str(), "news.example.com");
1072 assert_eq!(config.servers[0].port.get(), 119);
1073 assert!(!config.servers[0].use_tls);
1074 }
1075
1076 #[test]
1077 fn test_load_config_migrates_legacy_schema() {
1078 let mut temp_file = NamedTempFile::new().unwrap();
1079
1080 let config_content = r#"
1081[[servers]]
1082host = "legacy.example.com"
1083port = 119
1084name = "Legacy Server"
1085
1086[proxy]
1087routing_mode = "per-command"
1088backend_selection = "least-loaded"
1089buffer_pool_count = 64
1090capture_pool_count = 32
1091
1092[cache]
1093max_capacity = "256mib"
1094ttl = 1800
1095cache_articles = true
1096adaptive_precheck = true
1097"#;
1098 temp_file.write_all(config_content.as_bytes()).unwrap();
1099 temp_file.flush().unwrap();
1100
1101 let path = temp_file.path().to_str().unwrap().to_string();
1102 let config = load_config(&path).unwrap();
1103
1104 assert_eq!(config.routing.routing_mode, RoutingMode::PerCommand);
1105 assert_eq!(
1106 config.routing.backend_selection,
1107 BackendSelectionStrategy::LeastLoaded
1108 );
1109 assert_eq!(config.memory.buffer_pool_count, 64);
1110 assert_eq!(config.memory.capture_pool_count, 32);
1111 assert!(config.routing.adaptive_precheck);
1112 let cache = config.cache.as_ref().unwrap();
1113 assert_eq!(cache.article_cache_capacity.get(), 256 * 1024 * 1024);
1114 assert_eq!(cache.article_cache_ttl_secs.as_secs(), 1800);
1115 assert!(cache.store_article_bodies);
1116
1117 let migrated = std::fs::read_to_string(&path).unwrap();
1118 assert!(migrated.contains("[routing]"));
1119 assert!(migrated.contains("[memory]"));
1120 assert!(migrated.contains("mode = \"per-command\""));
1121 assert!(migrated.contains("buffer_pool_count = 64"));
1122 assert!(migrated.contains("capture_pool_count = 32"));
1123 assert!(migrated.contains("article_cache_capacity = 268435456"));
1124 assert!(migrated.contains("article_cache_ttl_secs = 1800"));
1125 assert!(migrated.contains("store_article_bodies = true"));
1126
1127 let proxy_offset = migrated.find("[proxy]").unwrap();
1128 let routing_offset = migrated.find("[routing]").unwrap();
1129 let memory_offset = migrated.find("[memory]").unwrap();
1130 let cache_offset = migrated.find("[cache]").unwrap();
1131 let health_check_offset = migrated.find("[health_check]").unwrap();
1132 let client_auth_offset = migrated.find("[client_auth]").unwrap();
1133 let servers_offset = migrated.find("[[servers]]").unwrap();
1134
1135 assert!(proxy_offset < routing_offset);
1136 assert!(routing_offset < memory_offset);
1137 assert!(memory_offset < cache_offset);
1138 assert!(cache_offset < health_check_offset);
1139 assert!(health_check_offset < client_auth_offset);
1140 assert!(client_auth_offset < servers_offset);
1141
1142 let backup_path = format!("{path}.bak");
1143 assert!(std::path::Path::new(&backup_path).exists());
1144 }
1145
1146 #[cfg(unix)]
1147 #[test]
1148 fn test_load_config_preserves_file_mode_when_migrating() {
1149 use std::os::unix::fs::{MetadataExt, PermissionsExt};
1150
1151 let mut temp_file = NamedTempFile::new().unwrap();
1152
1153 let config_content = r#"
1154[[servers]]
1155host = "legacy.example.com"
1156port = 119
1157name = "Legacy Server"
1158
1159[proxy]
1160routing_mode = "per-command"
1161backend_selection = "least-loaded"
1162"#;
1163 temp_file.write_all(config_content.as_bytes()).unwrap();
1164 temp_file.flush().unwrap();
1165
1166 let path = temp_file.path().to_path_buf();
1167 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
1168
1169 load_config_with_env_provider(path.to_str().unwrap(), &MockEnv::new()).unwrap();
1170
1171 let mode = std::fs::metadata(&path).unwrap().mode() & 0o777;
1172 assert_eq!(mode, 0o600);
1173 }
1174
1175 #[test]
1176 fn test_load_config_preserves_env_server_overrides_after_migration() {
1177 let mut temp_file = NamedTempFile::new().unwrap();
1178
1179 let config_content = r#"
1180[proxy]
1181routing_mode = "per-command"
1182backend_selection = "least-loaded"
1183buffer_pool_count = 64
1184capture_pool_count = 32
1185"#;
1186 temp_file.write_all(config_content.as_bytes()).unwrap();
1187 temp_file.flush().unwrap();
1188
1189 let mut env = MockEnv::new();
1190 env.set("NNTP_SERVER_0_HOST", "env.example.com")
1191 .set("NNTP_SERVER_0_PORT", "563")
1192 .set("NNTP_SERVER_0_NAME", "Env Server")
1193 .set("NNTP_SERVER_0_USE_TLS", "true");
1194
1195 let path = temp_file.path().to_str().unwrap().to_string();
1196 let config = load_config_with_env_provider(&path, &env).unwrap();
1197
1198 assert_eq!(config.routing.routing_mode, RoutingMode::PerCommand);
1199 assert_eq!(config.servers.len(), 1);
1200 assert_eq!(config.servers[0].host.as_str(), "env.example.com");
1201 assert_eq!(config.servers[0].port.get(), 563);
1202 assert_eq!(config.servers[0].name.as_str(), "Env Server");
1203 assert!(config.servers[0].use_tls);
1204 }
1205
1206 #[test]
1207 fn test_load_config_merges_legacy_proxy_routing_into_partial_routing_section() {
1208 let mut temp_file = NamedTempFile::new().unwrap();
1209
1210 let config_content = r#"
1211[[servers]]
1212host = "legacy.example.com"
1213port = 119
1214name = "Legacy Server"
1215
1216[proxy]
1217routing_mode = "per-command"
1218backend_selection = "least-loaded"
1219
1220[routing]
1221adaptive_precheck = true
1222"#;
1223 temp_file.write_all(config_content.as_bytes()).unwrap();
1224 temp_file.flush().unwrap();
1225
1226 let path = temp_file.path().to_str().unwrap().to_string();
1227 let config = load_config_with_env_provider(&path, &MockEnv::new()).unwrap();
1228
1229 assert_eq!(config.routing.routing_mode, RoutingMode::PerCommand);
1230 assert_eq!(
1231 config.routing.backend_selection,
1232 BackendSelectionStrategy::LeastLoaded
1233 );
1234 assert!(config.routing.adaptive_precheck);
1235
1236 let migrated = std::fs::read_to_string(&path).unwrap();
1237 assert!(migrated.contains("[routing]"));
1238 assert!(migrated.contains("mode = \"per-command\""));
1239 assert!(migrated.contains("backend_selection = \"least-loaded\""));
1240 assert!(migrated.contains("adaptive_precheck = true"));
1241 }
1242
1243 #[test]
1244 fn test_load_config_merges_legacy_proxy_memory_into_partial_memory_section() {
1245 let mut temp_file = NamedTempFile::new().unwrap();
1246
1247 let config_content = r#"
1248[[servers]]
1249host = "legacy.example.com"
1250port = 119
1251name = "Legacy Server"
1252
1253[proxy]
1254buffer_pool_count = 64
1255capture_pool_count = 32
1256
1257[memory]
1258socket_recv_buffer_size = 8388608
1259"#;
1260 temp_file.write_all(config_content.as_bytes()).unwrap();
1261 temp_file.flush().unwrap();
1262
1263 let path = temp_file.path().to_str().unwrap().to_string();
1264 let config = load_config_with_env_provider(&path, &MockEnv::new()).unwrap();
1265
1266 assert_eq!(config.memory.socket_recv_buffer_size, 8 * 1024 * 1024);
1267 assert_eq!(config.memory.buffer_pool_count, 64);
1268 assert_eq!(config.memory.capture_pool_count, 32);
1269
1270 let migrated = std::fs::read_to_string(&path).unwrap();
1271 assert!(migrated.contains("[memory]"));
1272 assert!(migrated.contains("socket_recv_buffer_size = 8388608"));
1273 assert!(migrated.contains("buffer_pool_count = 64"));
1274 assert!(migrated.contains("capture_pool_count = 32"));
1275 }
1276
1277 #[test]
1278 fn test_load_config_migrates_legacy_client_auth_single_user() {
1279 let mut temp_file = NamedTempFile::new().unwrap();
1280
1281 let config_content = r#"
1282[[servers]]
1283host = "legacy.example.com"
1284port = 119
1285name = "Legacy Server"
1286
1287[client_auth]
1288greeting = "201 auth required"
1289username = "legacy-user"
1290password = "legacy-pass"
1291"#;
1292 temp_file.write_all(config_content.as_bytes()).unwrap();
1293 temp_file.flush().unwrap();
1294
1295 let path = temp_file.path().to_str().unwrap().to_string();
1296 let config = load_config_with_env_provider(&path, &MockEnv::new()).unwrap();
1297
1298 assert_eq!(
1299 config.client_auth.greeting.as_deref(),
1300 Some("201 auth required")
1301 );
1302 assert_eq!(config.client_auth.users.len(), 1);
1303 assert_eq!(config.client_auth.users[0].username, "legacy-user");
1304 assert_eq!(config.client_auth.users[0].password, "legacy-pass");
1305
1306 let migrated = std::fs::read_to_string(&path).unwrap();
1307 assert!(migrated.contains("[client_auth]"));
1308 assert!(migrated.contains("greeting = \"201 auth required\""));
1309 assert!(migrated.contains("[[client_auth.users]]"));
1310 assert!(migrated.contains("username = \"legacy-user\""));
1311 assert!(migrated.contains("password = \"legacy-pass\""));
1312 }
1313
1314 #[test]
1315 fn test_load_config_uses_migrated_config_when_writeback_fails() {
1316 let temp_dir = tempfile::tempdir().unwrap();
1317 let path = temp_dir.path().join("config.toml");
1318
1319 let config_content = r#"
1320[[servers]]
1321host = "legacy.example.com"
1322port = 119
1323name = "Legacy Server"
1324
1325[proxy]
1326routing_mode = "per-command"
1327backend_selection = "least-loaded"
1328"#;
1329 std::fs::write(&path, config_content).unwrap();
1330
1331 let backup_path = path_with_suffix(&path, ".bak");
1332 std::fs::create_dir(&backup_path).unwrap();
1333
1334 let result = load_config_with_env_provider(path.to_str().unwrap(), &MockEnv::new());
1335
1336 let config = result.unwrap();
1337 assert_eq!(config.routing.routing_mode, RoutingMode::PerCommand);
1338 assert_eq!(
1339 config.routing.backend_selection,
1340 BackendSelectionStrategy::LeastLoaded
1341 );
1342 assert_eq!(config.servers.len(), 1);
1343 assert_eq!(config.servers[0].host.as_str(), "legacy.example.com");
1344 assert!(backup_path.is_dir());
1345 }
1346}