//! Regression tests for connection and request lifecycle handling. //! //! These cover remotely triggerable availability bugs rather than routing //! behaviour: a server that stops answering is as broken as one that leaks. mod common; use std::process::{Child, Command}; use std::time::Duration; /// Owns a server process and reaps it on drop. /// /// A panicking assertion must not leave a live server behind: the orphan keeps /// the test harness's stdout pipe open, so the whole run hangs instead of /// reporting the failure. struct ServerProcess(Child); impl Drop for ServerProcess { fn drop(&mut self) { let _ = self.0.kill(); let _ = self.0.wait(); } } fn start_server(config_path: &std::path::Path) -> ServerProcess { let child = Command::new(env!("CARGO_BIN_EXE_pollux")) .arg("--config") .arg(config_path) .arg("--quiet") .spawn() .expect("failed to start server"); std::thread::sleep(Duration::from_millis(600)); ServerProcess(child) } fn write_config(dir: &std::path::Path, root: &std::path::Path, port: u16) -> std::path::PathBuf { let config_path = dir.join("config.toml"); let content = format!( r#" bind_host = "127.0.0.1" port = {} ["localhost"] root = "{}" cert = "{}" key = "{}" "#, port, root.display(), dir.join("cert.pem").display(), dir.join("key.pem").display() ); std::fs::write(&config_path, content).unwrap(); config_path } fn request(port: u16, url: &str) -> String { let output = Command::new("python3") .arg("tests/gemini_test_client.py") .arg(url) .env("GEMINI_PORT", port.to_string()) .env("GEMINI_CONNECT_HOST", "127.0.0.1") .output() .expect("failed to run test client"); String::from_utf8_lossy(&output.stdout).trim().to_string() } fn abort_connections(port: u16, count: usize) { let output = Command::new("python3") .arg("tests/abort_connection.py") .arg("localhost") .arg(count.to_string()) .env("GEMINI_PORT", port.to_string()) .env("GEMINI_CONNECT_HOST", "127.0.0.1") .output() .expect("failed to run abort helper"); let stdout = String::from_utf8_lossy(&output.stdout); assert!( stdout.contains(&format!("aborted {}", count)), "abort helper did not complete: {}", stdout ); } /// A client that completes the TLS handshake and disconnects without sending a /// request must not affect later requests. /// /// This previously decremented the in-flight request counter without a matching /// increment. The counter is unsigned, so it wrapped to usize::MAX and every /// subsequent request was refused with "41 Server unavailable" until restart. /// One aborted connection was enough to take the capsule down permanently. #[test] fn test_aborted_connection_does_not_disable_server() { let temp_dir = common::setup_test_environment(); let root = temp_dir.path().join("content"); let port = 2300 + (std::process::id() % 700) as u16; let config_path = write_config(temp_dir.path(), &root, port); let _server = start_server(&config_path); let before = request(port, "gemini://localhost/test.gmi"); assert!( before.starts_with("20"), "baseline request should succeed, got: {}", before ); abort_connections(port, 1); let after = request(port, "gemini://localhost/test.gmi"); assert!( after.starts_with("20"), "server refused a request after one aborted connection (counter underflow), got: {}", after ); // A sustained burst must not degrade it either. abort_connections(port, 25); let after_burst = request(port, "gemini://localhost/test.gmi"); assert!( after_burst.starts_with("20"), "server refused a request after 25 aborted connections, got: {}", after_burst ); } /// Files above the response size limit are refused instead of being buffered /// whole into memory, where concurrent requests multiplied the cost. #[test] fn test_oversized_file_is_refused() { let temp_dir = common::setup_test_environment(); let root = temp_dir.path().join("content"); let port = 3100 + (std::process::id() % 700) as u16; // Just over the 64 MiB response limit. let big = vec![b'x'; 64 * 1024 * 1024 + 1024]; std::fs::write(root.join("big.gmi"), &big).unwrap(); let config_path = write_config(temp_dir.path(), &root, port); let _server = start_server(&config_path); let response = request(port, "gemini://localhost/big.gmi"); assert!( response.starts_with("50"), "oversized file should be refused with 50, got: {}", response ); // A normal file on the same server still works. let ok = request(port, "gemini://localhost/test.gmi"); assert!( ok.starts_with("20"), "normal file should still serve, got: {}", ok ); } /// Dotfiles are not served. Capsule roots are frequently git working copies, so /// this is what keeps .git/config and any credentials in it private. #[test] fn test_hidden_files_are_not_served() { let temp_dir = common::setup_test_environment(); let root = temp_dir.path().join("content"); let port = 3800 + (std::process::id() % 700) as u16; std::fs::write(root.join(".env"), "SECRET_TOKEN=hunter2").unwrap(); std::fs::create_dir_all(root.join(".git")).unwrap(); std::fs::write(root.join(".git/config"), "[remote]\n url = secret").unwrap(); let config_path = write_config(temp_dir.path(), &root, port); let _server = start_server(&config_path); for hidden in ["gemini://localhost/.env", "gemini://localhost/.git/config"] { let response = request(port, hidden); assert!( response.starts_with("51"), "hidden path {} should not be served, got: {}", hidden, response ); assert!( !response.contains("hunter2") && !response.contains("secret"), "hidden file contents leaked for {}: {}", hidden, response ); } } /// Routing accepts the authority forms the Gemini specification permits: /// mixed case, an explicit port, userinfo, and a fully-qualified trailing dot. #[test] fn test_authority_forms_are_normalized() { let temp_dir = common::setup_test_environment(); let root = temp_dir.path().join("content"); let port = 4500 + (std::process::id() % 700) as u16; let config_path = write_config(temp_dir.path(), &root, port); let _server = start_server(&config_path); for url in [ "gemini://localhost/test.gmi", "gemini://LOCALHOST/test.gmi", "gemini://localhost./test.gmi", &format!("gemini://localhost:{}/test.gmi", port), ] { let response = request(port, url); assert!( response.starts_with("20"), "{} should be routed to the localhost vhost, got: {}", url, response ); } }