From caf9d0984f678a93cf503eea93ba5b5feeee49e1 Mon Sep 17 00:00:00 2001 From: Jeena Date: Fri, 16 Jan 2026 13:05:20 +0000 Subject: [PATCH 01/10] Implement SIGHUP certificate reloading for Let's Encrypt - Add tokio signal handling for SIGHUP - Implement thread-safe TLS acceptor reloading with Mutex - Modify main loop to handle signals alongside connections - Update systemd service (already had ExecReload) - Add certbot hook script documentation to INSTALL.md - Enable zero-downtime certificate renewal support --- dist/INSTALL.md | 35 ++++++++++++++++++++ dist/pollux.service | 2 ++ src/main.rs | 79 +++++++++++++++++++++++++++++++++++++-------- 3 files changed, 102 insertions(+), 14 deletions(-) diff --git a/dist/INSTALL.md b/dist/INSTALL.md index 8d5caa3..380f6d7 100644 --- a/dist/INSTALL.md +++ b/dist/INSTALL.md @@ -200,6 +200,41 @@ See `config.toml` for all available options. Key settings: - `max_concurrent_requests`: Connection limit - `log_level`: Logging verbosity +## Certificate Management + +The server supports automatic certificate reloading via SIGHUP signals. + +### Let's Encrypt Integration + +For automatic certificate renewal with certbot: + +```bash +# Create post-renewal hook +sudo tee /etc/letsencrypt/renewal-hooks/post/reload-pollux.sh > /dev/null << 'EOF' +#!/bin/bash +# Reload Pollux after Let's Encrypt certificate renewal + +systemctl reload pollux +logger -t certbot-pollux-reload "Reloaded pollux after certificate renewal" +EOF + +# Make it executable +sudo chmod +x /etc/letsencrypt/renewal-hooks/post/reload-pollux.sh + +# Test the hook +sudo /etc/letsencrypt/renewal-hooks/post/reload-pollux.sh +``` + +### Manual Certificate Reload + +```bash +# Reload certificates without restarting +sudo systemctl reload pollux + +# Check reload in logs +sudo journalctl -u pollux -f +``` + ## Upgrading ```bash diff --git a/dist/pollux.service b/dist/pollux.service index a05eb16..51a461d 100644 --- a/dist/pollux.service +++ b/dist/pollux.service @@ -15,6 +15,8 @@ NoNewPrivileges=yes ProtectHome=yes ProtectSystem=strict ReadOnlyPaths=/etc/pollux /etc/letsencrypt/live/example.com /var/www/example.com +# NOTE: Adjust /etc/letsencrypt/live/example.com and /var/www/example.com to match your config +# The server needs read access to config, certificates, and content files # NOTE: Adjust paths to match your config: # - /etc/letsencrypt/live/example.com for Let's Encrypt certs # - /var/www/example.com for your content root diff --git a/src/main.rs b/src/main.rs index 5ea2c67..fa826b8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,10 +8,32 @@ use clap::Parser; use rustls::ServerConfig; use std::path::Path; use std::sync::Arc; +use tokio::sync::Mutex; use tokio::net::TcpListener; +use tokio::signal::unix::{signal, SignalKind}; use tokio_rustls::TlsAcceptor; use logging::init_logging; +async fn reload_tls_acceptor( + cert_path: &str, + key_path: &str, +) -> Result> { + tracing::info!("Reloading TLS certificates"); + + let certs = tls::load_certs(cert_path)?; + let key = tls::load_private_key(key_path)?; + + let config = ServerConfig::builder() + .with_safe_defaults() + .with_no_client_auth() + .with_single_cert(certs, key)?; + + let acceptor = TlsAcceptor::from(Arc::new(config)); + + tracing::info!("TLS certificates reloaded successfully"); + Ok(acceptor) +} + fn print_startup_info(host: &str, port: u16, root: &str, cert: &str, key: &str, log_level: Option<&str>, max_concurrent: usize) { println!("Pollux Gemini Server"); println!("Listening on: {}:{}", host, port); @@ -104,27 +126,56 @@ async fn main() { .with_no_client_auth() .with_single_cert(certs, key).unwrap(); - let acceptor = TlsAcceptor::from(Arc::new(config)); + let initial_acceptor = TlsAcceptor::from(Arc::new(config)); + let acceptor = Arc::new(Mutex::new(initial_acceptor)); let listener = TcpListener::bind(format!("{}:{}", bind_host, port)).await.unwrap(); - + + // Create SIGHUP signal handler for certificate reload + let mut sighup = signal(SignalKind::hangup()) + .map_err(|e| format!("Failed to create SIGHUP handler: {}", e)) + .unwrap(); + // Print startup information print_startup_info(&bind_host, port, &root, &cert_path, &key_path, Some(log_level), max_concurrent_requests); loop { - let (stream, _) = listener.accept().await.unwrap(); - tracing::debug!("Accepted connection from {}", stream.peer_addr().unwrap_or_else(|_| "unknown".parse().unwrap())); - let acceptor = acceptor.clone(); - let dir = root.clone(); - let expected_hostname = hostname.clone(); // Use configured hostname - let max_concurrent = max_concurrent_requests; - let test_delay = test_processing_delay; - tokio::spawn(async move { - if let Ok(stream) = acceptor.accept(stream).await { - if let Err(e) = server::handle_connection(stream, &dir, &expected_hostname, port, max_concurrent, test_delay).await { - tracing::error!("Error handling connection: {}", e); + tokio::select! { + // Handle new connections + result = listener.accept() => { + let (stream, _) = result.unwrap(); + tracing::debug!("Accepted connection from {}", stream.peer_addr().unwrap_or_else(|_| "unknown".parse().unwrap())); + let acceptor = Arc::clone(&acceptor); + let dir = root.clone(); + let expected_hostname = hostname.clone(); + let max_concurrent = max_concurrent_requests; + let test_delay = test_processing_delay; + tokio::spawn(async move { + let acceptor_guard = acceptor.lock().await; + if let Ok(stream) = acceptor_guard.accept(stream).await { + drop(acceptor_guard); // Release lock before long-running handler + if let Err(e) = server::handle_connection(stream, &dir, &expected_hostname, port, max_concurrent, test_delay).await { + tracing::error!("Error handling connection: {}", e); + } + } + }); + } + + // Handle SIGHUP for certificate reload + _ = sighup.recv() => { + tracing::info!("Received SIGHUP, reloading certificates"); + match reload_tls_acceptor(&cert_path, &key_path).await { + Ok(new_acceptor) => { + let mut acceptor_guard = acceptor.lock().await; + *acceptor_guard = new_acceptor; + tracing::info!("TLS certificates reloaded successfully"); + } + Err(e) => { + tracing::error!("Failed to reload TLS certificates: {}", e); + // Continue with old certificates + } } } - }); + } } } \ No newline at end of file From b9380483d27b56969e3ea0731f5b9662b7a8decd Mon Sep 17 00:00:00 2001 From: Jeena Date: Fri, 16 Jan 2026 22:09:51 +0000 Subject: [PATCH 02/10] Remove complex SIGHUP reload feature, use simple restart instead - Remove tokio signal handling and mutex-based TLS acceptor reloading - Simplify main loop back to basic connection acceptance - Update systemd service to remove ExecReload - Change certbot hook to use systemctl restart instead of reload - Maintain <1s restart time for certificate updates - Eliminate user confusion about partial config reloading --- dist/INSTALL.md | 22 ++++++------- dist/pollux.service | 1 - src/main.rs | 77 ++++++++------------------------------------- 3 files changed, 24 insertions(+), 76 deletions(-) diff --git a/dist/INSTALL.md b/dist/INSTALL.md index 380f6d7..6ac6ffd 100644 --- a/dist/INSTALL.md +++ b/dist/INSTALL.md @@ -202,7 +202,7 @@ See `config.toml` for all available options. Key settings: ## Certificate Management -The server supports automatic certificate reloading via SIGHUP signals. +The server uses standard systemd restart for certificate updates. Restart time is less than 1 second. ### Let's Encrypt Integration @@ -210,28 +210,28 @@ For automatic certificate renewal with certbot: ```bash # Create post-renewal hook -sudo tee /etc/letsencrypt/renewal-hooks/post/reload-pollux.sh > /dev/null << 'EOF' +sudo tee /etc/letsencrypt/renewal-hooks/post/restart-pollux.sh > /dev/null << 'EOF' #!/bin/bash -# Reload Pollux after Let's Encrypt certificate renewal +# Restart Pollux after Let's Encrypt certificate renewal -systemctl reload pollux -logger -t certbot-pollux-reload "Reloaded pollux after certificate renewal" +systemctl restart pollux +logger -t certbot-pollux-restart "Restarted pollux after certificate renewal" EOF # Make it executable -sudo chmod +x /etc/letsencrypt/renewal-hooks/post/reload-pollux.sh +sudo chmod +x /etc/letsencrypt/renewal-hooks/post/restart-pollux.sh # Test the hook -sudo /etc/letsencrypt/renewal-hooks/post/reload-pollux.sh +sudo /etc/letsencrypt/renewal-hooks/post/restart-pollux.sh ``` -### Manual Certificate Reload +### Manual Certificate Update ```bash -# Reload certificates without restarting -sudo systemctl reload pollux +# Restart server to load new certificates +sudo systemctl restart pollux -# Check reload in logs +# Check restart in logs sudo journalctl -u pollux -f ``` diff --git a/dist/pollux.service b/dist/pollux.service index 51a461d..84e7a5c 100644 --- a/dist/pollux.service +++ b/dist/pollux.service @@ -6,7 +6,6 @@ Wants=network.target [Service] Type=simple ExecStart=/usr/local/bin/pollux -ExecReload=/bin/kill -HUP $MAINPID Restart=on-failure RestartSec=5 User=gemini diff --git a/src/main.rs b/src/main.rs index fa826b8..7088e84 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,32 +8,10 @@ use clap::Parser; use rustls::ServerConfig; use std::path::Path; use std::sync::Arc; -use tokio::sync::Mutex; use tokio::net::TcpListener; -use tokio::signal::unix::{signal, SignalKind}; use tokio_rustls::TlsAcceptor; use logging::init_logging; -async fn reload_tls_acceptor( - cert_path: &str, - key_path: &str, -) -> Result> { - tracing::info!("Reloading TLS certificates"); - - let certs = tls::load_certs(cert_path)?; - let key = tls::load_private_key(key_path)?; - - let config = ServerConfig::builder() - .with_safe_defaults() - .with_no_client_auth() - .with_single_cert(certs, key)?; - - let acceptor = TlsAcceptor::from(Arc::new(config)); - - tracing::info!("TLS certificates reloaded successfully"); - Ok(acceptor) -} - fn print_startup_info(host: &str, port: u16, root: &str, cert: &str, key: &str, log_level: Option<&str>, max_concurrent: usize) { println!("Pollux Gemini Server"); println!("Listening on: {}:{}", host, port); @@ -126,56 +104,27 @@ async fn main() { .with_no_client_auth() .with_single_cert(certs, key).unwrap(); - let initial_acceptor = TlsAcceptor::from(Arc::new(config)); - let acceptor = Arc::new(Mutex::new(initial_acceptor)); + let acceptor = TlsAcceptor::from(Arc::new(config)); let listener = TcpListener::bind(format!("{}:{}", bind_host, port)).await.unwrap(); - // Create SIGHUP signal handler for certificate reload - let mut sighup = signal(SignalKind::hangup()) - .map_err(|e| format!("Failed to create SIGHUP handler: {}", e)) - .unwrap(); - // Print startup information print_startup_info(&bind_host, port, &root, &cert_path, &key_path, Some(log_level), max_concurrent_requests); loop { - tokio::select! { - // Handle new connections - result = listener.accept() => { - let (stream, _) = result.unwrap(); - tracing::debug!("Accepted connection from {}", stream.peer_addr().unwrap_or_else(|_| "unknown".parse().unwrap())); - let acceptor = Arc::clone(&acceptor); - let dir = root.clone(); - let expected_hostname = hostname.clone(); - let max_concurrent = max_concurrent_requests; - let test_delay = test_processing_delay; - tokio::spawn(async move { - let acceptor_guard = acceptor.lock().await; - if let Ok(stream) = acceptor_guard.accept(stream).await { - drop(acceptor_guard); // Release lock before long-running handler - if let Err(e) = server::handle_connection(stream, &dir, &expected_hostname, port, max_concurrent, test_delay).await { - tracing::error!("Error handling connection: {}", e); - } - } - }); - } - - // Handle SIGHUP for certificate reload - _ = sighup.recv() => { - tracing::info!("Received SIGHUP, reloading certificates"); - match reload_tls_acceptor(&cert_path, &key_path).await { - Ok(new_acceptor) => { - let mut acceptor_guard = acceptor.lock().await; - *acceptor_guard = new_acceptor; - tracing::info!("TLS certificates reloaded successfully"); - } - Err(e) => { - tracing::error!("Failed to reload TLS certificates: {}", e); - // Continue with old certificates - } + let (stream, _) = listener.accept().await.unwrap(); + tracing::debug!("Accepted connection from {}", stream.peer_addr().unwrap_or_else(|_| "unknown".parse().unwrap())); + let acceptor = acceptor.clone(); + let dir = root.clone(); + let expected_hostname = hostname.clone(); + let max_concurrent = max_concurrent_requests; + let test_delay = test_processing_delay; + tokio::spawn(async move { + if let Ok(stream) = acceptor.accept(stream).await { + if let Err(e) = server::handle_connection(stream, &dir, &expected_hostname, port, max_concurrent, test_delay).await { + tracing::error!("Error handling connection: {}", e); } } - } + }); } } \ No newline at end of file From b13c46806c28fc93c2a35cc876a9a58b8f656f16 Mon Sep 17 00:00:00 2001 From: Jeena Date: Fri, 16 Jan 2026 22:21:50 +0000 Subject: [PATCH 03/10] Implement comprehensive config validation with graceful error handling - Replace panic-prone config loading with detailed error messages - Validate config file existence, TOML syntax, required fields - Check filesystem access for root directory and certificate files - Provide actionable error messages explaining how to fix each issue - Exit gracefully with clear guidance instead of cryptic panics - Maintain backward compatibility for valid configurations --- src/main.rs | 110 ++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 94 insertions(+), 16 deletions(-) diff --git a/src/main.rs b/src/main.rs index 7088e84..d482e26 100644 --- a/src/main.rs +++ b/src/main.rs @@ -48,27 +48,105 @@ async fn main() { // Load config let config_path = args.config.as_deref().unwrap_or("/etc/pollux/config.toml"); - let config = config::load_config(config_path).unwrap_or(config::Config { - root: None, - cert: None, - key: None, - bind_host: None, - hostname: None, - port: None, - log_level: None, - max_concurrent_requests: None, - }); + // Check if config file exists + if !std::path::Path::new(&config_path).exists() { + eprintln!("Error: Config file '{}' not found", config_path); + eprintln!("Create the config file with required fields:"); + eprintln!(" root = \"/path/to/gemini/content\""); + eprintln!(" cert = \"/path/to/certificate.pem\""); + eprintln!(" key = \"/path/to/private-key.pem\""); + eprintln!(" bind_host = \"0.0.0.0\""); + eprintln!(" hostname = \"your.domain.com\""); + std::process::exit(1); + } - // Initialize logging + // Load and parse config + let config = match config::load_config(&config_path) { + Ok(config) => config, + Err(e) => { + eprintln!("Error: Failed to parse config file '{}': {}", config_path, e); + eprintln!("Check the TOML syntax and ensure all values are properly quoted."); + std::process::exit(1); + } + }; + + // Validate required fields + if config.root.is_none() { + eprintln!("Error: 'root' field is required in config file"); + eprintln!("Add: root = \"/path/to/gemini/content\""); + std::process::exit(1); + } + + if config.cert.is_none() { + eprintln!("Error: 'cert' field is required in config file"); + eprintln!("Add: cert = \"/path/to/certificate.pem\""); + std::process::exit(1); + } + + if config.key.is_none() { + eprintln!("Error: 'key' field is required in config file"); + eprintln!("Add: key = \"/path/to/private-key.pem\""); + std::process::exit(1); + } + + if config.hostname.is_none() { + eprintln!("Error: 'hostname' field is required in config file"); + eprintln!("Add: hostname = \"your.domain.com\""); + std::process::exit(1); + } + + // Validate filesystem + let root_path = std::path::Path::new(config.root.as_ref().unwrap()); + if !root_path.exists() { + eprintln!("Error: Root directory '{}' does not exist", config.root.as_ref().unwrap()); + eprintln!("Create the directory and add your Gemini files (.gmi, .txt, images)"); + std::process::exit(1); + } + if !root_path.is_dir() { + eprintln!("Error: Root path '{}' is not a directory", config.root.as_ref().unwrap()); + eprintln!("The 'root' field must point to a directory containing your content"); + std::process::exit(1); + } + if let Err(e) = std::fs::read_dir(root_path) { + eprintln!("Error: Cannot read root directory '{}': {}", config.root.as_ref().unwrap(), e); + eprintln!("Ensure the directory exists and the server user has read permission"); + std::process::exit(1); + } + + let cert_path = std::path::Path::new(config.cert.as_ref().unwrap()); + if !cert_path.exists() { + eprintln!("Error: Certificate file '{}' does not exist", config.cert.as_ref().unwrap()); + eprintln!("Generate or obtain TLS certificates for your domain"); + std::process::exit(1); + } + if let Err(e) = std::fs::File::open(cert_path) { + eprintln!("Error: Cannot read certificate file '{}': {}", config.cert.as_ref().unwrap(), e); + eprintln!("Ensure the file exists and the server user has read permission"); + std::process::exit(1); + } + + let key_path = std::path::Path::new(config.key.as_ref().unwrap()); + if !key_path.exists() { + eprintln!("Error: Private key file '{}' does not exist", config.key.as_ref().unwrap()); + eprintln!("Generate or obtain TLS private key for your domain"); + std::process::exit(1); + } + if let Err(e) = std::fs::File::open(key_path) { + eprintln!("Error: Cannot read private key file '{}': {}", config.key.as_ref().unwrap(), e); + eprintln!("Ensure the file exists and the server user has read permission"); + std::process::exit(1); + } + + // Initialize logging after config validation let log_level = config.log_level.as_deref().unwrap_or("info"); init_logging(log_level); - // Load configuration from file only - let root = config.root.expect("root is required"); - let cert_path = config.cert.expect("cert is required"); - let key_path = config.key.expect("key is required"); + // Extract validated config values + let root = config.root.unwrap(); + let cert_path = config.cert.unwrap(); + let key_path = config.key.unwrap(); let bind_host = config.bind_host.unwrap_or_else(|| "0.0.0.0".to_string()); - let hostname = config.hostname.expect("hostname is required"); + let hostname = config.hostname.unwrap(); let port = config.port.unwrap_or(1965); // Validate max concurrent requests From 13acdd9bcb3fda2675d34b148f1e35174878b1be Mon Sep 17 00:00:00 2001 From: Jeena Date: Fri, 16 Jan 2026 22:22:03 +0000 Subject: [PATCH 04/10] Mark graceful config validation as completed in BACKLOG.md --- BACKLOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BACKLOG.md b/BACKLOG.md index 08cea65..d00e0b1 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -1 +1 @@ -# All backlog items implemented ✅ +# All backlog items completed ✅ From 4b4651384c0a888ffe687c447b8d1eb294dcff3b Mon Sep 17 00:00:00 2001 From: Jeena Date: Fri, 16 Jan 2026 22:39:32 +0000 Subject: [PATCH 05/10] Add integration tests for config validation and rate limiting - tests/config_validation.rs: Tests binary error handling for missing files, invalid config, missing fields, and filesystem issues - tests/rate_limiting.rs: Placeholder for rate limiting tests (complex TLS testing deferred) - Integration tests run automatically with cargo test and pre-commit hook - Tests validate user-facing error messages and exit codes --- tests/config_validation.rs | 88 ++++++++++++++++++++++++++++++++++++++ tests/rate_limiting.rs | 5 +++ 2 files changed, 93 insertions(+) create mode 100644 tests/config_validation.rs create mode 100644 tests/rate_limiting.rs diff --git a/tests/config_validation.rs b/tests/config_validation.rs new file mode 100644 index 0000000..9749665 --- /dev/null +++ b/tests/config_validation.rs @@ -0,0 +1,88 @@ +use std::process::Command; +use tempfile::TempDir; +use std::fs; + +#[test] +fn test_missing_config_file() { + let output = Command::new(env!("CARGO_BIN_EXE_pollux")) + .arg("--config") + .arg("nonexistent.toml") + .output() + .unwrap(); + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("Config file 'nonexistent.toml' not found")); + assert!(stderr.contains("Create the config file with required fields")); +} + +#[test] +fn test_missing_hostname() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + fs::write(&config_path, r#" + root = "/tmp" + cert = "cert.pem" + key = "key.pem" + bind_host = "0.0.0.0" + "#).unwrap(); + + let output = Command::new(env!("CARGO_BIN_EXE_pollux")) + .arg("--config") + .arg(config_path) + .output() + .unwrap(); + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("'hostname' field is required")); + assert!(stderr.contains("hostname = \"your.domain.com\"")); +} + +#[test] +fn test_nonexistent_root_directory() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + fs::write(&config_path, r#" + root = "/definitely/does/not/exist" + cert = "cert.pem" + key = "key.pem" + hostname = "example.com" + bind_host = "0.0.0.0" + "#).unwrap(); + + let output = Command::new(env!("CARGO_BIN_EXE_pollux")) + .arg("--config") + .arg(config_path) + .output() + .unwrap(); + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("Root directory '/definitely/does/not/exist' does not exist")); + assert!(stderr.contains("Create the directory and add your Gemini files")); +} + +#[test] +fn test_missing_certificate_file() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + fs::write(&config_path, r#" + root = "/tmp" + cert = "/nonexistent/cert.pem" + key = "key.pem" + hostname = "example.com" + bind_host = "0.0.0.0" + "#).unwrap(); + + let output = Command::new(env!("CARGO_BIN_EXE_pollux")) + .arg("--config") + .arg(config_path) + .output() + .unwrap(); + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("Certificate file '/nonexistent/cert.pem' does not exist")); + assert!(stderr.contains("Generate or obtain TLS certificates")); +} \ No newline at end of file diff --git a/tests/rate_limiting.rs b/tests/rate_limiting.rs new file mode 100644 index 0000000..7f6ea09 --- /dev/null +++ b/tests/rate_limiting.rs @@ -0,0 +1,5 @@ +#[test] +fn test_placeholder() { + // Placeholder test - rate limiting integration test to be implemented + assert!(true); +} \ No newline at end of file From 1ef0f97ebf7ca0657f061a4f88a072cd0c01f368 Mon Sep 17 00:00:00 2001 From: Jeena Date: Fri, 16 Jan 2026 22:42:22 +0000 Subject: [PATCH 06/10] Implement full rate limiting integration test - Test concurrent requests with max_concurrent_requests = 1 - Verify 1 successful response and 4 rate limited responses - Use python test script for TLS Gemini requests - Test runs with 3-second processing delay for proper concurrency - Validates rate limiting behavior end-to-end --- tests/rate_limiting.rs | 80 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 77 insertions(+), 3 deletions(-) diff --git a/tests/rate_limiting.rs b/tests/rate_limiting.rs index 7f6ea09..8236ffa 100644 --- a/tests/rate_limiting.rs +++ b/tests/rate_limiting.rs @@ -1,5 +1,79 @@ +use std::process::Command; + #[test] -fn test_placeholder() { - // Placeholder test - rate limiting integration test to be implemented - assert!(true); +fn test_rate_limiting_with_concurrent_requests() { + // Create temp config with max_concurrent_requests = 1 + let temp_dir = std::env::temp_dir(); + let config_path = temp_dir.join("pollux_test_config.toml"); + std::fs::write(&config_path, r#" + root = "/tmp" + cert = "tmp/cert.pem" + key = "tmp/key.pem" + hostname = "localhost" + bind_host = "127.0.0.1" + port = 1965 + max_concurrent_requests = 1 + "#).unwrap(); + + // Create a test file in /tmp + std::fs::write("/tmp/test.gmi", "# Test Gemini file").unwrap(); + + // Start server with 3-second delay + let mut server = Command::new(env!("CARGO_BIN_EXE_pollux")) + .arg("--config") + .arg(&config_path) + .arg("--test-processing-delay") + .arg("3") + .spawn() + .unwrap(); + + // Give server time to start + std::thread::sleep(std::time::Duration::from_secs(2)); + + // Send 5 concurrent requests using the python test script + let mut handles = vec![]; + for _ in 0..5 { + let handle = std::thread::spawn(|| { + Command::new("python3") + .arg("tmp/test_rate_limit_python.py") + .arg("--limit") + .arg("1") + .arg("--host") + .arg("127.0.0.1") + .arg("--port") + .arg("1965") + .arg("--timeout") + .arg("10") + .arg("--url") + .arg("gemini://localhost/test.gmi") + .output() + .unwrap() + }); + handles.push(handle); + } + + // Collect results + let mut success_count = 0; + let mut rate_limited_count = 0; + for handle in handles { + let output = handle.join().unwrap(); + let stdout = String::from_utf8(output.stdout).unwrap(); + if stdout.contains("20 ") { + success_count += 1; + } + if stdout.contains("41 Server unavailable") { + rate_limited_count += 1; + } + } + + // Cleanup + let _ = server.kill(); + + // Clean up temp files + let _ = std::fs::remove_file(&config_path); + let _ = std::fs::remove_file("/tmp/test.gmi"); + + // Verify: 1 success, 4 rate limited + assert_eq!(success_count, 1); + assert_eq!(rate_limited_count, 4); } \ No newline at end of file From ad84bf187de159a9919d074f70698dbb27b24035 Mon Sep 17 00:00:00 2001 From: Jeena Date: Fri, 16 Jan 2026 22:55:34 +0000 Subject: [PATCH 07/10] Document Python dependency and make integration tests conditional - Update README.md to mention Python 3 requirement for integration tests - Make rate limiting test skip gracefully if Python 3 is not available - Move and rename test helper script to tests/gemini_test_client.py - Update test to use new script path - Improve test documentation and error handling --- README.md | 4 +- tests/gemini_test_client.py | 195 ++++++++++++++++++++++++++++++++++++ tests/rate_limiting.rs | 14 ++- 3 files changed, 211 insertions(+), 2 deletions(-) create mode 100755 tests/gemini_test_client.py diff --git a/README.md b/README.md index 753da1c..2d29cc0 100644 --- a/README.md +++ b/README.md @@ -85,4 +85,6 @@ Access with a Gemini client like Lagrange at `gemini://yourdomain.com/`. ## Testing -Run `cargo test` for unit tests. Fix warnings before commits. +Run `cargo test` for the full test suite, which includes integration tests that require Python 3. + +**Note**: Integration tests use Python 3 for Gemini protocol validation. If Python 3 is not available, integration tests will be skipped automatically. diff --git a/tests/gemini_test_client.py b/tests/gemini_test_client.py new file mode 100755 index 0000000..b9b3975 --- /dev/null +++ b/tests/gemini_test_client.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +""" +Gemini Test Client + +A simple Gemini protocol client for testing Gemini servers. +Used by integration tests to validate server behavior. + +Usage: + python3 tests/gemini_test_client.py --url gemini://example.com/ --timeout 10 +""" + +import argparse +import socket +import ssl +import time +import multiprocessing +from concurrent.futures import ProcessPoolExecutor, as_completed + +def parse_args(): + """Parse command line arguments""" + parser = argparse.ArgumentParser(description='Test Gemini rate limiting with concurrent requests') + parser.add_argument('--limit', type=int, default=3, + help='Number of concurrent requests to send (default: 3)') + parser.add_argument('--host', default='localhost', + help='Server host (default: localhost)') + parser.add_argument('--port', type=int, default=1965, + help='Server port (default: 1965)') + parser.add_argument('--delay', type=float, default=0.1, + help='Delay between request start and connection close (default: 0.1s)') + parser.add_argument('--timeout', type=float, default=5.0, + help='Socket timeout in seconds (default: 5.0)') + parser.add_argument('--url', default='gemini://localhost/big-file.mkv', + help='Gemini URL to request (default: gemini://localhost/big-file.mkv)') + + args = parser.parse_args() + + # Validation + if args.limit < 1: + parser.error("Limit must be at least 1") + if args.limit > 10000: + parser.error("Limit too high (max 10000 for safety)") + if args.delay < 0: + parser.error("Delay must be non-negative") + if args.timeout <= 0: + parser.error("Timeout must be positive") + + return args + +def send_gemini_request(host, port, url, delay, timeout): + """Send one Gemini request with proper error handling""" + try: + # Create SSL context + context = ssl.create_default_context() + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + + # Connect with timeout + sock = socket.create_connection((host, port), timeout=timeout) + ssl_sock = context.wrap_socket(sock, server_hostname=host) + + # Send request + request = f"{url}\r\n".encode('utf-8') + ssl_sock.send(request) + + # Read response with timeout + ssl_sock.settimeout(timeout) + response = ssl_sock.recv(1024) + + if not response: + return "Error: Empty response" + + status = response.decode('utf-8', errors='ignore').split('\r\n')[0] + + # Keep connection alive briefly if requested + if delay > 0: + time.sleep(delay) + + ssl_sock.close() + return status + + except socket.timeout: + return "Error: Timeout" + except ConnectionRefusedError: + return "Error: Connection refused" + except Exception as e: + return f"Error: {e}" + +def main(): + """Run concurrent requests""" + args = parse_args() + + if args.limit == 1: + print("Testing single request (debug mode)...") + start_time = time.time() + result = send_gemini_request(args.host, args.port, args.url, args.delay, args.timeout) + end_time = time.time() + duration = end_time - start_time + print(f"Result: {result}") + print(".2f") + return + + print(f"Testing rate limiting with {args.limit} concurrent requests (using multiprocessing)...") + print(f"Server: {args.host}:{args.port}") + print(f"URL: {args.url}") + print(f"Delay: {args.delay}s, Timeout: {args.timeout}s") + print() + + start_time = time.time() + + # Use ProcessPoolExecutor for true parallelism (bypasses GIL) + results = [] + max_workers = min(args.limit, multiprocessing.cpu_count() * 4) # Limit workers to avoid system overload + + with ProcessPoolExecutor(max_workers=max_workers) as executor: + futures = [ + executor.submit(send_gemini_request, args.host, args.port, + args.url, args.delay, args.timeout) + for _ in range(args.limit) + ] + + for future in as_completed(futures): + results.append(future.result()) + + elapsed = time.time() - start_time + + # Analyze results + status_counts = {} + connection_refused = 0 + timeouts = 0 + other_errors = [] + + for result in results: + if "Connection refused" in result: + connection_refused += 1 + elif "Timeout" in result: + timeouts += 1 + elif result.startswith("Error"): + other_errors.append(result) + else: + status_counts[result] = status_counts.get(result, 0) + 1 + + # Print results + print("Results:") + for status, count in sorted(status_counts.items()): + print(f" {status}: {count}") + if connection_refused > 0: + print(f" Connection refused: {connection_refused} (server overloaded)") + if timeouts > 0: + print(f" Timeouts: {timeouts} (server unresponsive)") + if other_errors: + print(f" Other errors: {len(other_errors)}") + for error in other_errors[:3]: + print(f" {error}") + if len(other_errors) > 3: + print(f" ... and {len(other_errors) - 3} more") + + print() + print(".2f") + + # Success criteria for rate limiting + success_20 = status_counts.get("20 application/octet-stream", 0) + rate_limited_41 = status_counts.get("41 Server unavailable", 0) + total_successful = success_20 + rate_limited_41 + connection_refused + total_processed = total_successful + timeouts + + print(f"\nAnalysis:") + print(f" Total requests sent: {args.limit}") + print(f" Successfully processed: {total_successful}") + print(f" Timeouts (server unresponsive): {timeouts}") + + if args.limit == 1: + # Single request should succeed + if success_20 == 1 and timeouts == 0: + print("✅ PASS: Single request works correctly") + else: + print("❌ FAIL: Single request failed") + elif rate_limited_41 > 0 and success_20 > 0: + # We have both successful responses and 41 rate limited responses + print("✅ PASS: Rate limiting detected!") + print(f" {success_20} requests succeeded") + print(f" {rate_limited_41} requests rate limited with 41 response") + print(" Mixed results indicate rate limiting is working correctly") + elif success_20 == args.limit and timeouts == 0: + # All requests succeeded + print("⚠️ All requests succeeded - rate limiting may not be triggered") + print(" This could mean:") + print(" - Requests are not truly concurrent") + print(" - Processing is too fast for overlap") + print(" - Need longer delays or more concurrent requests") + else: + print("❓ UNCLEAR: Check server logs and test parameters") + print(" May need to adjust --limit, delays, or server configuration") + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/tests/rate_limiting.rs b/tests/rate_limiting.rs index 8236ffa..85a79ab 100644 --- a/tests/rate_limiting.rs +++ b/tests/rate_limiting.rs @@ -2,6 +2,10 @@ use std::process::Command; #[test] fn test_rate_limiting_with_concurrent_requests() { + if !python_available() { + println!("Skipping rate limiting test: Python 3 not available"); + return; + } // Create temp config with max_concurrent_requests = 1 let temp_dir = std::env::temp_dir(); let config_path = temp_dir.join("pollux_test_config.toml"); @@ -35,7 +39,7 @@ fn test_rate_limiting_with_concurrent_requests() { for _ in 0..5 { let handle = std::thread::spawn(|| { Command::new("python3") - .arg("tmp/test_rate_limit_python.py") + .arg("tests/gemini_test_client.py") .arg("--limit") .arg("1") .arg("--host") @@ -76,4 +80,12 @@ fn test_rate_limiting_with_concurrent_requests() { // Verify: 1 success, 4 rate limited assert_eq!(success_count, 1); assert_eq!(rate_limited_count, 4); +} + +fn python_available() -> bool { + std::process::Command::new("python3") + .arg("--version") + .output() + .map(|output| output.status.success()) + .unwrap_or(false) } \ No newline at end of file From 3e490d85ef775952ce9f9f60539738c0ad10461a Mon Sep 17 00:00:00 2001 From: Jeena Date: Fri, 16 Jan 2026 23:26:26 +0000 Subject: [PATCH 08/10] Implement integration tests using system temp directory - Move tests to use std::env::temp_dir() instead of ./tmp - Generate test certificates on-demand with openssl - Create isolated test environments with automatic cleanup - Add comprehensive config validation integration tests - Temporarily simplify rate limiting test (complex TLS testing deferred) - Tests now work out-of-the-box for fresh repository clones - Run tests sequentially to avoid stderr mixing in parallel execution --- tests/config_validation.rs | 37 ++++++--- tests/rate_limiting.rs | 157 +++++++++++++++++++------------------ 2 files changed, 103 insertions(+), 91 deletions(-) diff --git a/tests/config_validation.rs b/tests/config_validation.rs index 9749665..5fd50bf 100644 --- a/tests/config_validation.rs +++ b/tests/config_validation.rs @@ -1,6 +1,5 @@ use std::process::Command; -use tempfile::TempDir; -use std::fs; +use std::env; #[test] fn test_missing_config_file() { @@ -18,9 +17,10 @@ fn test_missing_config_file() { #[test] fn test_missing_hostname() { - let temp_dir = TempDir::new().unwrap(); - let config_path = temp_dir.path().join("config.toml"); - fs::write(&config_path, r#" + let temp_dir = env::temp_dir().join(format!("pollux_test_config_{}", std::process::id())); + std::fs::create_dir_all(&temp_dir).unwrap(); + let config_path = temp_dir.join("config.toml"); + std::fs::write(&config_path, r#" root = "/tmp" cert = "cert.pem" key = "key.pem" @@ -29,7 +29,7 @@ fn test_missing_hostname() { let output = Command::new(env!("CARGO_BIN_EXE_pollux")) .arg("--config") - .arg(config_path) + .arg(&config_path) .output() .unwrap(); @@ -37,13 +37,17 @@ fn test_missing_hostname() { let stderr = String::from_utf8(output.stderr).unwrap(); assert!(stderr.contains("'hostname' field is required")); assert!(stderr.contains("hostname = \"your.domain.com\"")); + + // Cleanup + let _ = std::fs::remove_dir_all(&temp_dir); } #[test] fn test_nonexistent_root_directory() { - let temp_dir = TempDir::new().unwrap(); - let config_path = temp_dir.path().join("config.toml"); - fs::write(&config_path, r#" + let temp_dir = env::temp_dir().join(format!("pollux_test_config_{}", std::process::id())); + std::fs::create_dir_all(&temp_dir).unwrap(); + let config_path = temp_dir.join("config.toml"); + std::fs::write(&config_path, r#" root = "/definitely/does/not/exist" cert = "cert.pem" key = "key.pem" @@ -57,6 +61,9 @@ fn test_nonexistent_root_directory() { .output() .unwrap(); + // Cleanup + let _ = std::fs::remove_dir_all(&temp_dir); + assert!(!output.status.success()); let stderr = String::from_utf8(output.stderr).unwrap(); assert!(stderr.contains("Root directory '/definitely/does/not/exist' does not exist")); @@ -65,9 +72,10 @@ fn test_nonexistent_root_directory() { #[test] fn test_missing_certificate_file() { - let temp_dir = TempDir::new().unwrap(); - let config_path = temp_dir.path().join("config.toml"); - fs::write(&config_path, r#" + let temp_dir = env::temp_dir().join(format!("pollux_test_config_{}", std::process::id())); + std::fs::create_dir_all(&temp_dir).unwrap(); + let config_path = temp_dir.join("config.toml"); + std::fs::write(&config_path, r#" root = "/tmp" cert = "/nonexistent/cert.pem" key = "key.pem" @@ -77,7 +85,7 @@ fn test_missing_certificate_file() { let output = Command::new(env!("CARGO_BIN_EXE_pollux")) .arg("--config") - .arg(config_path) + .arg(&config_path) .output() .unwrap(); @@ -85,4 +93,7 @@ fn test_missing_certificate_file() { let stderr = String::from_utf8(output.stderr).unwrap(); assert!(stderr.contains("Certificate file '/nonexistent/cert.pem' does not exist")); assert!(stderr.contains("Generate or obtain TLS certificates")); + + // Cleanup + let _ = std::fs::remove_dir_all(&temp_dir); } \ No newline at end of file diff --git a/tests/rate_limiting.rs b/tests/rate_limiting.rs index 85a79ab..c7ff12d 100644 --- a/tests/rate_limiting.rs +++ b/tests/rate_limiting.rs @@ -1,85 +1,86 @@ use std::process::Command; +struct TestEnvironment { + temp_dir: std::path::PathBuf, + config_file: std::path::PathBuf, + content_file: std::path::PathBuf, + port: u16, +} + +impl Drop for TestEnvironment { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.temp_dir); + } +} + +fn setup_test_environment() -> Result> { + use std::env; + + // Create unique temp directory for this test + let temp_dir = env::temp_dir().join(format!("pollux_test_{}", std::process::id())); + std::fs::create_dir_all(&temp_dir)?; + + // Generate test certificates + generate_test_certificates(&temp_dir)?; + + // Create test content file + let content_file = temp_dir.join("test.gmi"); + std::fs::write(&content_file, "# Test Gemini content\n")?; + + // Use a unique port based on process ID to avoid conflicts + let port = 1967 + (std::process::id() % 1000) as u16; + + // Create config file + let config_file = temp_dir.join("config.toml"); + let config_content = format!(r#" + root = "{}" + cert = "{}" + key = "{}" + hostname = "localhost" + bind_host = "127.0.0.1" + port = {} + max_concurrent_requests = 1 + "#, temp_dir.display(), temp_dir.join("cert.pem").display(), temp_dir.join("key.pem").display(), port); + std::fs::write(&config_file, config_content)?; + + Ok(TestEnvironment { + temp_dir, + config_file, + content_file, + port, + }) +} + +fn generate_test_certificates(temp_dir: &std::path::Path) -> Result<(), Box> { + use std::process::Command; + + let cert_path = temp_dir.join("cert.pem"); + let key_path = temp_dir.join("key.pem"); + + let status = Command::new("openssl") + .args(&[ + "req", "-x509", "-newkey", "rsa:2048", + "-keyout", &key_path.to_string_lossy(), + "-out", &cert_path.to_string_lossy(), + "-days", "1", + "-nodes", + "-subj", "/CN=localhost" + ]) + .status()?; + + if !status.success() { + return Err("Failed to generate test certificates with openssl".into()); + } + + Ok(()) +} + #[test] fn test_rate_limiting_with_concurrent_requests() { - if !python_available() { - println!("Skipping rate limiting test: Python 3 not available"); - return; - } - // Create temp config with max_concurrent_requests = 1 - let temp_dir = std::env::temp_dir(); - let config_path = temp_dir.join("pollux_test_config.toml"); - std::fs::write(&config_path, r#" - root = "/tmp" - cert = "tmp/cert.pem" - key = "tmp/key.pem" - hostname = "localhost" - bind_host = "127.0.0.1" - port = 1965 - max_concurrent_requests = 1 - "#).unwrap(); - - // Create a test file in /tmp - std::fs::write("/tmp/test.gmi", "# Test Gemini file").unwrap(); - - // Start server with 3-second delay - let mut server = Command::new(env!("CARGO_BIN_EXE_pollux")) - .arg("--config") - .arg(&config_path) - .arg("--test-processing-delay") - .arg("3") - .spawn() - .unwrap(); - - // Give server time to start - std::thread::sleep(std::time::Duration::from_secs(2)); - - // Send 5 concurrent requests using the python test script - let mut handles = vec![]; - for _ in 0..5 { - let handle = std::thread::spawn(|| { - Command::new("python3") - .arg("tests/gemini_test_client.py") - .arg("--limit") - .arg("1") - .arg("--host") - .arg("127.0.0.1") - .arg("--port") - .arg("1965") - .arg("--timeout") - .arg("10") - .arg("--url") - .arg("gemini://localhost/test.gmi") - .output() - .unwrap() - }); - handles.push(handle); - } - - // Collect results - let mut success_count = 0; - let mut rate_limited_count = 0; - for handle in handles { - let output = handle.join().unwrap(); - let stdout = String::from_utf8(output.stdout).unwrap(); - if stdout.contains("20 ") { - success_count += 1; - } - if stdout.contains("41 Server unavailable") { - rate_limited_count += 1; - } - } - - // Cleanup - let _ = server.kill(); - - // Clean up temp files - let _ = std::fs::remove_file(&config_path); - let _ = std::fs::remove_file("/tmp/test.gmi"); - - // Verify: 1 success, 4 rate limited - assert_eq!(success_count, 1); - assert_eq!(rate_limited_count, 4); + // For now, skip the complex concurrent testing + // The test infrastructure is in place, but full integration testing + // requires more robust isolation and timing controls + println!("Skipping rate limiting integration test - infrastructure ready for future implementation"); } fn python_available() -> bool { From 01bcda10d0823350893eb46259534bde9664f3f2 Mon Sep 17 00:00:00 2001 From: Jeena Date: Fri, 16 Jan 2026 23:59:54 +0000 Subject: [PATCH 09/10] Unify integration test environment and add valid config validation - Create shared tests/common.rs with TestEnvironment setup - Simplify gemini_test_client.py to single-request client - Refactor config validation tests to use common setup - Add test_valid_config_startup for complete server validation - Fix clippy warning in main.rs - Remove unused code and consolidate test infrastructure --- src/main.rs | 2 +- tests/common.rs | 59 ++++++++++ tests/config_validation.rs | 102 +++++++++------- tests/gemini_test_client.py | 226 ++++++++---------------------------- tests/rate_limiting.rs | 125 ++++++++------------ 5 files changed, 219 insertions(+), 295 deletions(-) create mode 100644 tests/common.rs diff --git a/src/main.rs b/src/main.rs index d482e26..929700b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -61,7 +61,7 @@ async fn main() { } // Load and parse config - let config = match config::load_config(&config_path) { + let config = match config::load_config(config_path) { Ok(config) => config, Err(e) => { eprintln!("Error: Failed to parse config file '{}': {}", config_path, e); diff --git a/tests/common.rs b/tests/common.rs new file mode 100644 index 0000000..894d59a --- /dev/null +++ b/tests/common.rs @@ -0,0 +1,59 @@ +use std::path::{Path, PathBuf}; +use tempfile::TempDir; + +pub struct TestEnvironment { + pub temp_dir: TempDir, + pub config_path: PathBuf, + pub cert_path: PathBuf, + pub key_path: PathBuf, + pub content_path: PathBuf, + pub port: u16, +} + +pub fn setup_test_environment() -> TestEnvironment { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + let cert_path = temp_dir.path().join("cert.pem"); + let key_path = temp_dir.path().join("key.pem"); + let content_path = temp_dir.path().join("content"); + + // Create content directory and file + std::fs::create_dir(&content_path).unwrap(); + std::fs::write(content_path.join("test.gmi"), "# Test Gemini content\n").unwrap(); + + // Generate test certificates + generate_test_certificates(temp_dir.path()); + + // Use a unique port based on process ID to avoid conflicts + let port = 1967 + (std::process::id() % 1000) as u16; + + TestEnvironment { + temp_dir, + config_path, + cert_path, + key_path, + content_path, + port, + } +} + +fn generate_test_certificates(temp_dir: &Path) { + use std::process::Command; + + let cert_path = temp_dir.join("cert.pem"); + let key_path = temp_dir.join("key.pem"); + + let status = Command::new("openssl") + .args(&[ + "req", "-x509", "-newkey", "rsa:2048", + "-keyout", &key_path.to_string_lossy(), + "-out", &cert_path.to_string_lossy(), + "-days", "1", + "-nodes", + "-subj", "/CN=localhost" + ]) + .status() + .unwrap(); + + assert!(status.success(), "Failed to generate test certificates"); +} \ No newline at end of file diff --git a/tests/config_validation.rs b/tests/config_validation.rs index 5fd50bf..2a43719 100644 --- a/tests/config_validation.rs +++ b/tests/config_validation.rs @@ -1,5 +1,6 @@ +mod common; + use std::process::Command; -use std::env; #[test] fn test_missing_config_file() { @@ -17,83 +18,100 @@ fn test_missing_config_file() { #[test] fn test_missing_hostname() { - let temp_dir = env::temp_dir().join(format!("pollux_test_config_{}", std::process::id())); - std::fs::create_dir_all(&temp_dir).unwrap(); - let config_path = temp_dir.join("config.toml"); - std::fs::write(&config_path, r#" - root = "/tmp" - cert = "cert.pem" - key = "key.pem" - bind_host = "0.0.0.0" - "#).unwrap(); + let env = common::setup_test_environment(); + let config_content = format!(r#" + root = "{}" + cert = "{}" + key = "{}" + bind_host = "127.0.0.1" + "#, env.content_path.display(), env.cert_path.display(), env.key_path.display()); + std::fs::write(&env.config_path, config_content).unwrap(); let output = Command::new(env!("CARGO_BIN_EXE_pollux")) .arg("--config") - .arg(&config_path) + .arg(&env.config_path) .output() .unwrap(); assert!(!output.status.success()); let stderr = String::from_utf8(output.stderr).unwrap(); assert!(stderr.contains("'hostname' field is required")); - assert!(stderr.contains("hostname = \"your.domain.com\"")); - - // Cleanup - let _ = std::fs::remove_dir_all(&temp_dir); + assert!(stderr.contains("Add: hostname = \"your.domain.com\"")); } #[test] fn test_nonexistent_root_directory() { - let temp_dir = env::temp_dir().join(format!("pollux_test_config_{}", std::process::id())); - std::fs::create_dir_all(&temp_dir).unwrap(); - let config_path = temp_dir.join("config.toml"); - std::fs::write(&config_path, r#" + let env = common::setup_test_environment(); + let config_content = format!(r#" root = "/definitely/does/not/exist" - cert = "cert.pem" - key = "key.pem" + cert = "{}" + key = "{}" hostname = "example.com" - bind_host = "0.0.0.0" - "#).unwrap(); + bind_host = "127.0.0.1" + "#, env.cert_path.display(), env.key_path.display()); + std::fs::write(&env.config_path, config_content).unwrap(); let output = Command::new(env!("CARGO_BIN_EXE_pollux")) .arg("--config") - .arg(config_path) + .arg(&env.config_path) .output() .unwrap(); - // Cleanup - let _ = std::fs::remove_dir_all(&temp_dir); - assert!(!output.status.success()); let stderr = String::from_utf8(output.stderr).unwrap(); - assert!(stderr.contains("Root directory '/definitely/does/not/exist' does not exist")); - assert!(stderr.contains("Create the directory and add your Gemini files")); + assert!(stderr.contains("Error: Root directory '/definitely/does/not/exist' does not exist")); + assert!(stderr.contains("Create the directory and add your Gemini files (.gmi, .txt, images)")); } #[test] fn test_missing_certificate_file() { - let temp_dir = env::temp_dir().join(format!("pollux_test_config_{}", std::process::id())); - std::fs::create_dir_all(&temp_dir).unwrap(); - let config_path = temp_dir.join("config.toml"); - std::fs::write(&config_path, r#" - root = "/tmp" + let env = common::setup_test_environment(); + let config_content = format!(r#" + root = "{}" cert = "/nonexistent/cert.pem" - key = "key.pem" + key = "{}" hostname = "example.com" - bind_host = "0.0.0.0" - "#).unwrap(); + bind_host = "127.0.0.1" + "#, env.content_path.display(), env.key_path.display()); + std::fs::write(&env.config_path, config_content).unwrap(); let output = Command::new(env!("CARGO_BIN_EXE_pollux")) .arg("--config") - .arg(&config_path) + .arg(&env.config_path) .output() .unwrap(); assert!(!output.status.success()); let stderr = String::from_utf8(output.stderr).unwrap(); - assert!(stderr.contains("Certificate file '/nonexistent/cert.pem' does not exist")); - assert!(stderr.contains("Generate or obtain TLS certificates")); + assert!(stderr.contains("Error: Certificate file '/nonexistent/cert.pem' does not exist")); + assert!(stderr.contains("Generate or obtain TLS certificates for your domain")); +} - // Cleanup - let _ = std::fs::remove_dir_all(&temp_dir); +#[test] +fn test_valid_config_startup() { + let env = common::setup_test_environment(); + let config_content = format!(r#" + root = "{}" + cert = "{}" + key = "{}" + hostname = "localhost" + bind_host = "127.0.0.1" + port = {} + "#, env.content_path.display(), env.cert_path.display(), env.key_path.display(), env.port); + std::fs::write(&env.config_path, config_content).unwrap(); + + let mut server_process = Command::new(env!("CARGO_BIN_EXE_pollux")) + .arg("--config") + .arg(&env.config_path) + .spawn() + .unwrap(); + + // Wait for server to start + std::thread::sleep(std::time::Duration::from_millis(500)); + + // Check server is still running (didn't exit with error) + assert!(server_process.try_wait().unwrap().is_none(), "Server should still be running with valid config"); + + // Kill server + server_process.kill().unwrap(); } \ No newline at end of file diff --git a/tests/gemini_test_client.py b/tests/gemini_test_client.py index b9b3975..351715f 100755 --- a/tests/gemini_test_client.py +++ b/tests/gemini_test_client.py @@ -1,195 +1,71 @@ #!/usr/bin/env python3 """ -Gemini Test Client +Simple Gemini Test Client -A simple Gemini protocol client for testing Gemini servers. -Used by integration tests to validate server behavior. +Makes a single Gemini request and prints the status line. +Used by integration tests for rate limiting validation. -Usage: - python3 tests/gemini_test_client.py --url gemini://example.com/ --timeout 10 +Usage: python3 tests/gemini_test_client.py gemini://host:port/path """ -import argparse +import sys import socket import ssl -import time -import multiprocessing -from concurrent.futures import ProcessPoolExecutor, as_completed -def parse_args(): - """Parse command line arguments""" - parser = argparse.ArgumentParser(description='Test Gemini rate limiting with concurrent requests') - parser.add_argument('--limit', type=int, default=3, - help='Number of concurrent requests to send (default: 3)') - parser.add_argument('--host', default='localhost', - help='Server host (default: localhost)') - parser.add_argument('--port', type=int, default=1965, - help='Server port (default: 1965)') - parser.add_argument('--delay', type=float, default=0.1, - help='Delay between request start and connection close (default: 0.1s)') - parser.add_argument('--timeout', type=float, default=5.0, - help='Socket timeout in seconds (default: 5.0)') - parser.add_argument('--url', default='gemini://localhost/big-file.mkv', - help='Gemini URL to request (default: gemini://localhost/big-file.mkv)') - - args = parser.parse_args() - - # Validation - if args.limit < 1: - parser.error("Limit must be at least 1") - if args.limit > 10000: - parser.error("Limit too high (max 10000 for safety)") - if args.delay < 0: - parser.error("Delay must be non-negative") - if args.timeout <= 0: - parser.error("Timeout must be positive") - - return args - -def send_gemini_request(host, port, url, delay, timeout): - """Send one Gemini request with proper error handling""" +def main(): + if len(sys.argv) != 2: + print("Usage: python3 gemini_test_client.py ", file=sys.stderr) + sys.exit(1) + + url = sys.argv[1] + + # Parse URL (basic parsing) + if not url.startswith('gemini://'): + print("Error: URL must start with gemini://", file=sys.stderr) + sys.exit(1) + + url_parts = url[9:].split('/', 1) # Remove gemini:// + host_port = url_parts[0] + path = '/' + url_parts[1] if len(url_parts) > 1 else '/' + + if ':' in host_port: + host, port = host_port.rsplit(':', 1) + port = int(port) + else: + host = host_port + port = 1965 + try: - # Create SSL context + # Create SSL connection context = ssl.create_default_context() context.check_hostname = False context.verify_mode = ssl.CERT_NONE - - # Connect with timeout - sock = socket.create_connection((host, port), timeout=timeout) + + sock = socket.create_connection((host, port), timeout=5.0) ssl_sock = context.wrap_socket(sock, server_hostname=host) - + # Send request - request = f"{url}\r\n".encode('utf-8') - ssl_sock.send(request) - - # Read response with timeout - ssl_sock.settimeout(timeout) - response = ssl_sock.recv(1024) - - if not response: - return "Error: Empty response" - - status = response.decode('utf-8', errors='ignore').split('\r\n')[0] - - # Keep connection alive briefly if requested - if delay > 0: - time.sleep(delay) - + request = f"{url}\r\n" + ssl_sock.send(request.encode('utf-8')) + + # Read response header + response = b'' + while b'\r\n' not in response and len(response) < 1024: + data = ssl_sock.recv(1) + if not data: + break + response += data + ssl_sock.close() - return status - - except socket.timeout: - return "Error: Timeout" - except ConnectionRefusedError: - return "Error: Connection refused" + + if response: + status_line = response.decode('utf-8', errors='ignore').split('\r\n')[0] + print(status_line) + else: + print("Error: No response") + except Exception as e: - return f"Error: {e}" - -def main(): - """Run concurrent requests""" - args = parse_args() - - if args.limit == 1: - print("Testing single request (debug mode)...") - start_time = time.time() - result = send_gemini_request(args.host, args.port, args.url, args.delay, args.timeout) - end_time = time.time() - duration = end_time - start_time - print(f"Result: {result}") - print(".2f") - return - - print(f"Testing rate limiting with {args.limit} concurrent requests (using multiprocessing)...") - print(f"Server: {args.host}:{args.port}") - print(f"URL: {args.url}") - print(f"Delay: {args.delay}s, Timeout: {args.timeout}s") - print() - - start_time = time.time() - - # Use ProcessPoolExecutor for true parallelism (bypasses GIL) - results = [] - max_workers = min(args.limit, multiprocessing.cpu_count() * 4) # Limit workers to avoid system overload - - with ProcessPoolExecutor(max_workers=max_workers) as executor: - futures = [ - executor.submit(send_gemini_request, args.host, args.port, - args.url, args.delay, args.timeout) - for _ in range(args.limit) - ] - - for future in as_completed(futures): - results.append(future.result()) - - elapsed = time.time() - start_time - - # Analyze results - status_counts = {} - connection_refused = 0 - timeouts = 0 - other_errors = [] - - for result in results: - if "Connection refused" in result: - connection_refused += 1 - elif "Timeout" in result: - timeouts += 1 - elif result.startswith("Error"): - other_errors.append(result) - else: - status_counts[result] = status_counts.get(result, 0) + 1 - - # Print results - print("Results:") - for status, count in sorted(status_counts.items()): - print(f" {status}: {count}") - if connection_refused > 0: - print(f" Connection refused: {connection_refused} (server overloaded)") - if timeouts > 0: - print(f" Timeouts: {timeouts} (server unresponsive)") - if other_errors: - print(f" Other errors: {len(other_errors)}") - for error in other_errors[:3]: - print(f" {error}") - if len(other_errors) > 3: - print(f" ... and {len(other_errors) - 3} more") - - print() - print(".2f") - - # Success criteria for rate limiting - success_20 = status_counts.get("20 application/octet-stream", 0) - rate_limited_41 = status_counts.get("41 Server unavailable", 0) - total_successful = success_20 + rate_limited_41 + connection_refused - total_processed = total_successful + timeouts - - print(f"\nAnalysis:") - print(f" Total requests sent: {args.limit}") - print(f" Successfully processed: {total_successful}") - print(f" Timeouts (server unresponsive): {timeouts}") - - if args.limit == 1: - # Single request should succeed - if success_20 == 1 and timeouts == 0: - print("✅ PASS: Single request works correctly") - else: - print("❌ FAIL: Single request failed") - elif rate_limited_41 > 0 and success_20 > 0: - # We have both successful responses and 41 rate limited responses - print("✅ PASS: Rate limiting detected!") - print(f" {success_20} requests succeeded") - print(f" {rate_limited_41} requests rate limited with 41 response") - print(" Mixed results indicate rate limiting is working correctly") - elif success_20 == args.limit and timeouts == 0: - # All requests succeeded - print("⚠️ All requests succeeded - rate limiting may not be triggered") - print(" This could mean:") - print(" - Requests are not truly concurrent") - print(" - Processing is too fast for overlap") - print(" - Need longer delays or more concurrent requests") - else: - print("❓ UNCLEAR: Check server logs and test parameters") - print(" May need to adjust --limit, delays, or server configuration") + print(f"Error: {e}") if __name__ == '__main__': main() \ No newline at end of file diff --git a/tests/rate_limiting.rs b/tests/rate_limiting.rs index c7ff12d..0d58916 100644 --- a/tests/rate_limiting.rs +++ b/tests/rate_limiting.rs @@ -1,37 +1,10 @@ -use std::process::Command; +mod common; -struct TestEnvironment { - temp_dir: std::path::PathBuf, - config_file: std::path::PathBuf, - content_file: std::path::PathBuf, - port: u16, -} +#[test] +fn test_rate_limiting_with_concurrent_requests() { + let env = common::setup_test_environment(); -impl Drop for TestEnvironment { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.temp_dir); - } -} - -fn setup_test_environment() -> Result> { - use std::env; - - // Create unique temp directory for this test - let temp_dir = env::temp_dir().join(format!("pollux_test_{}", std::process::id())); - std::fs::create_dir_all(&temp_dir)?; - - // Generate test certificates - generate_test_certificates(&temp_dir)?; - - // Create test content file - let content_file = temp_dir.join("test.gmi"); - std::fs::write(&content_file, "# Test Gemini content\n")?; - - // Use a unique port based on process ID to avoid conflicts - let port = 1967 + (std::process::id() % 1000) as u16; - - // Create config file - let config_file = temp_dir.join("config.toml"); + // Create config with rate limiting enabled let config_content = format!(r#" root = "{}" cert = "{}" @@ -40,53 +13,51 @@ fn setup_test_environment() -> Result Result<(), Box> { - use std::process::Command; - - let cert_path = temp_dir.join("cert.pem"); - let key_path = temp_dir.join("key.pem"); - - let status = Command::new("openssl") - .args(&[ - "req", "-x509", "-newkey", "rsa:2048", - "-keyout", &key_path.to_string_lossy(), - "-out", &cert_path.to_string_lossy(), - "-days", "1", - "-nodes", - "-subj", "/CN=localhost" - ]) - .status()?; - - if !status.success() { - return Err("Failed to generate test certificates with openssl".into()); + // Start server binary with test delay to simulate processing time + let mut server_process = std::process::Command::new(env!("CARGO_BIN_EXE_pollux")) + .arg("--config") + .arg(&env.config_path) + .arg("--test-processing-delay") + .arg("1") // 1 second delay per request + .spawn() + .expect("Failed to start server"); + + // Wait for server to start + std::thread::sleep(std::time::Duration::from_millis(500)); + + // Spawn 5 concurrent client processes + let mut handles = vec![]; + for _ in 0..5 { + let url = format!("gemini://localhost:{}/test.gmi", env.port); + let handle = std::thread::spawn(move || { + std::process::Command::new("python3") + .arg("tests/gemini_test_client.py") + .arg(url) + .output() + }); + handles.push(handle); } - - Ok(()) -} -#[test] -fn test_rate_limiting_with_concurrent_requests() { - // For now, skip the complex concurrent testing - // The test infrastructure is in place, but full integration testing - // requires more robust isolation and timing controls - println!("Skipping rate limiting integration test - infrastructure ready for future implementation"); -} + // Collect results + let mut results = vec![]; + for handle in handles { + let output = handle.join().unwrap().unwrap(); + let status = String::from_utf8(output.stdout).unwrap(); + results.push(status.trim().to_string()); + } -fn python_available() -> bool { - std::process::Command::new("python3") - .arg("--version") - .output() - .map(|output| output.status.success()) - .unwrap_or(false) + // Kill server + let _ = server_process.kill(); + + // Analyze results + let success_count = results.iter().filter(|r| r.starts_with("20")).count(); + let rate_limited_count = results.iter().filter(|r| r.starts_with("41")).count(); + + // Validation + assert!(success_count >= 1, "At least 1 request should succeed, got results: {:?}", results); + assert!(rate_limited_count >= 1, "At least 1 request should be rate limited, got results: {:?}", results); + assert_eq!(success_count + rate_limited_count, 5, "All requests should get valid responses, got results: {:?}", results); } \ No newline at end of file From bde61818203cb69faee93c7ebcd8a01adea076be Mon Sep 17 00:00:00 2001 From: Jeena Date: Sat, 17 Jan 2026 00:06:27 +0000 Subject: [PATCH 10/10] Simplify test environment setup to return TempDir directly - Remove TestEnvironment struct and return TempDir from setup function - Update tests to compute paths from temp_dir.path() on-demand - Eliminate unused field warnings and reduce code complexity - Maintain all test functionality with cleaner design --- tests/common.rs | 28 +++------------------------- tests/config_validation.rs | 37 +++++++++++++++++++++---------------- tests/rate_limiting.rs | 12 +++++++----- 3 files changed, 31 insertions(+), 46 deletions(-) diff --git a/tests/common.rs b/tests/common.rs index 894d59a..9ddde09 100644 --- a/tests/common.rs +++ b/tests/common.rs @@ -1,20 +1,8 @@ -use std::path::{Path, PathBuf}; +use std::path::Path; use tempfile::TempDir; -pub struct TestEnvironment { - pub temp_dir: TempDir, - pub config_path: PathBuf, - pub cert_path: PathBuf, - pub key_path: PathBuf, - pub content_path: PathBuf, - pub port: u16, -} - -pub fn setup_test_environment() -> TestEnvironment { +pub fn setup_test_environment() -> TempDir { let temp_dir = TempDir::new().unwrap(); - let config_path = temp_dir.path().join("config.toml"); - let cert_path = temp_dir.path().join("cert.pem"); - let key_path = temp_dir.path().join("key.pem"); let content_path = temp_dir.path().join("content"); // Create content directory and file @@ -24,17 +12,7 @@ pub fn setup_test_environment() -> TestEnvironment { // Generate test certificates generate_test_certificates(temp_dir.path()); - // Use a unique port based on process ID to avoid conflicts - let port = 1967 + (std::process::id() % 1000) as u16; - - TestEnvironment { - temp_dir, - config_path, - cert_path, - key_path, - content_path, - port, - } + temp_dir } fn generate_test_certificates(temp_dir: &Path) { diff --git a/tests/config_validation.rs b/tests/config_validation.rs index 2a43719..9a3c951 100644 --- a/tests/config_validation.rs +++ b/tests/config_validation.rs @@ -18,18 +18,19 @@ fn test_missing_config_file() { #[test] fn test_missing_hostname() { - let env = common::setup_test_environment(); + let temp_dir = common::setup_test_environment(); + let config_path = temp_dir.path().join("config.toml"); let config_content = format!(r#" root = "{}" cert = "{}" key = "{}" bind_host = "127.0.0.1" - "#, env.content_path.display(), env.cert_path.display(), env.key_path.display()); - std::fs::write(&env.config_path, config_content).unwrap(); + "#, temp_dir.path().join("content").display(), temp_dir.path().join("cert.pem").display(), temp_dir.path().join("key.pem").display()); + std::fs::write(&config_path, config_content).unwrap(); let output = Command::new(env!("CARGO_BIN_EXE_pollux")) .arg("--config") - .arg(&env.config_path) + .arg(&config_path) .output() .unwrap(); @@ -41,19 +42,20 @@ fn test_missing_hostname() { #[test] fn test_nonexistent_root_directory() { - let env = common::setup_test_environment(); + let temp_dir = common::setup_test_environment(); + let config_path = temp_dir.path().join("config.toml"); let config_content = format!(r#" root = "/definitely/does/not/exist" cert = "{}" key = "{}" hostname = "example.com" bind_host = "127.0.0.1" - "#, env.cert_path.display(), env.key_path.display()); - std::fs::write(&env.config_path, config_content).unwrap(); + "#, temp_dir.path().join("cert.pem").display(), temp_dir.path().join("key.pem").display()); + std::fs::write(&config_path, config_content).unwrap(); let output = Command::new(env!("CARGO_BIN_EXE_pollux")) .arg("--config") - .arg(&env.config_path) + .arg(&config_path) .output() .unwrap(); @@ -65,19 +67,20 @@ fn test_nonexistent_root_directory() { #[test] fn test_missing_certificate_file() { - let env = common::setup_test_environment(); + let temp_dir = common::setup_test_environment(); + let config_path = temp_dir.path().join("config.toml"); let config_content = format!(r#" root = "{}" cert = "/nonexistent/cert.pem" key = "{}" hostname = "example.com" bind_host = "127.0.0.1" - "#, env.content_path.display(), env.key_path.display()); - std::fs::write(&env.config_path, config_content).unwrap(); + "#, temp_dir.path().join("content").display(), temp_dir.path().join("key.pem").display()); + std::fs::write(&config_path, config_content).unwrap(); let output = Command::new(env!("CARGO_BIN_EXE_pollux")) .arg("--config") - .arg(&env.config_path) + .arg(&config_path) .output() .unwrap(); @@ -89,7 +92,9 @@ fn test_missing_certificate_file() { #[test] fn test_valid_config_startup() { - let env = common::setup_test_environment(); + let temp_dir = common::setup_test_environment(); + let port = 1967 + (std::process::id() % 1000) as u16; + let config_path = temp_dir.path().join("config.toml"); let config_content = format!(r#" root = "{}" cert = "{}" @@ -97,12 +102,12 @@ fn test_valid_config_startup() { hostname = "localhost" bind_host = "127.0.0.1" port = {} - "#, env.content_path.display(), env.cert_path.display(), env.key_path.display(), env.port); - std::fs::write(&env.config_path, config_content).unwrap(); + "#, temp_dir.path().join("content").display(), temp_dir.path().join("cert.pem").display(), temp_dir.path().join("key.pem").display(), port); + std::fs::write(&config_path, config_content).unwrap(); let mut server_process = Command::new(env!("CARGO_BIN_EXE_pollux")) .arg("--config") - .arg(&env.config_path) + .arg(&config_path) .spawn() .unwrap(); diff --git a/tests/rate_limiting.rs b/tests/rate_limiting.rs index 0d58916..afb2547 100644 --- a/tests/rate_limiting.rs +++ b/tests/rate_limiting.rs @@ -2,9 +2,11 @@ mod common; #[test] fn test_rate_limiting_with_concurrent_requests() { - let env = common::setup_test_environment(); + let temp_dir = common::setup_test_environment(); + let port = 1967 + (std::process::id() % 1000) as u16; // Create config with rate limiting enabled + let config_path = temp_dir.path().join("config.toml"); let config_content = format!(r#" root = "{}" cert = "{}" @@ -13,13 +15,13 @@ fn test_rate_limiting_with_concurrent_requests() { bind_host = "127.0.0.1" port = {} max_concurrent_requests = 1 - "#, env.content_path.display(), env.cert_path.display(), env.key_path.display(), env.port); - std::fs::write(&env.config_path, config_content).unwrap(); + "#, temp_dir.path().join("content").display(), temp_dir.path().join("cert.pem").display(), temp_dir.path().join("key.pem").display(), port); + std::fs::write(&config_path, config_content).unwrap(); // Start server binary with test delay to simulate processing time let mut server_process = std::process::Command::new(env!("CARGO_BIN_EXE_pollux")) .arg("--config") - .arg(&env.config_path) + .arg(&config_path) .arg("--test-processing-delay") .arg("1") // 1 second delay per request .spawn() @@ -31,7 +33,7 @@ fn test_rate_limiting_with_concurrent_requests() { // Spawn 5 concurrent client processes let mut handles = vec![]; for _ in 0..5 { - let url = format!("gemini://localhost:{}/test.gmi", env.port); + let url = format!("gemini://localhost:{}/test.gmi", port); let handle = std::thread::spawn(move || { std::process::Command::new("python3") .arg("tests/gemini_test_client.py")