//! End-to-end smoke test that actually boots `ccc serve` on the real //! smarm/urus runtime and drives it over a raw TCP socket. This is the //! regression test for the segfault-on-startup issue: if the runtime //! still crashes on boot or on first request, this test hangs/fails //! instead of the bug only showing up in production. use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream}; use std::path::PathBuf; use std::process::{Child, Command, Stdio}; use std::time::{Duration, Instant}; fn bin() -> &'static str { env!("CARGO_BIN_EXE_CCC") } fn free_port() -> u16 { let listener = TcpListener::bind("127.0.0.1:0").expect("failed to bind ephemeral port"); listener.local_addr().unwrap().port() } struct Server { child: Child, port: u16, db_path: PathBuf, } impl Server { fn start(tag: &str) -> Self { let db_path = std::env::temp_dir().join(format!( "ccc-server-test-{tag}-{}-{:?}.db", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos() )); let _ = std::fs::remove_file(&db_path); let port = free_port(); let child = Command::new(bin()) .args(["serve", "-p", &port.to_string()]) .env("CCC_DB_PATH", &db_path) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .expect("failed to spawn ccc serve"); let server = Server { child, port, db_path }; server.wait_for_ready(); server } fn wait_for_ready(&self) { let deadline = Instant::now() + Duration::from_secs(10); loop { if Instant::now() > deadline { panic!("server on port {} did not become ready in time (possible segfault/hang on boot)", self.port); } match TcpStream::connect(("127.0.0.1", self.port)) { Ok(_) => return, Err(_) => std::thread::sleep(Duration::from_millis(50)), } } } fn cli(&self, args: &[&str]) -> std::process::Output { Command::new(bin()) .args(args) .env("CCC_DB_PATH", &self.db_path) .output() .expect("failed to run ccc CLI") } /// Sends a bare-bones HTTP/1.1 GET request and returns (status, headers-lowercased, body). fn get(&self, path: &str) -> (u16, Vec<(String, String)>, Vec) { let mut stream = TcpStream::connect(("127.0.0.1", self.port)).expect("connect failed"); stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); let req = format!( "GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nAccept-Encoding: identity\r\nConnection: close\r\n\r\n" ); stream.write_all(req.as_bytes()).expect("write failed"); let mut buf = Vec::new(); stream.read_to_end(&mut buf).expect("read failed"); parse_http_response(&buf) } /// Like `get`, but advertises gzip support so we can check the raw compressed path too. fn get_gzip(&self, path: &str) -> (u16, Vec<(String, String)>, Vec) { let mut stream = TcpStream::connect(("127.0.0.1", self.port)).expect("connect failed"); stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); let req = format!( "GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nAccept-Encoding: gzip\r\nConnection: close\r\n\r\n" ); stream.write_all(req.as_bytes()).expect("write failed"); let mut buf = Vec::new(); stream.read_to_end(&mut buf).expect("read failed"); parse_http_response(&buf) } } impl Drop for Server { fn drop(&mut self) { let _ = self.child.kill(); let _ = self.child.wait(); let _ = std::fs::remove_file(&self.db_path); } } fn parse_http_response(buf: &[u8]) -> (u16, Vec<(String, String)>, Vec) { let sep = b"\r\n\r\n"; let split_at = buf .windows(sep.len()) .position(|w| w == sep) .expect("response missing header/body separator"); let head = std::str::from_utf8(&buf[..split_at]).expect("head not valid utf8"); let body = buf[split_at + sep.len()..].to_vec(); let mut lines = head.split("\r\n"); let status_line = lines.next().expect("missing status line"); let status: u16 = status_line .split_whitespace() .nth(1) .expect("malformed status line") .parse() .expect("status code not numeric"); let headers = lines .filter_map(|l| l.split_once(':')) .map(|(k, v)| (k.trim().to_lowercase(), v.trim().to_string())) .collect(); (status, headers, body) } fn header<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> { headers.iter().find(|(k, _)| k == name).map(|(_, v)| v.as_str()) } fn write_temp_file(name: &str, contents: &[u8]) -> PathBuf { // Keep `name` (with its extension) as the trailing path component so // the CLI's mime-guessing logic sees the real file extension. let dir = std::env::temp_dir().join(format!( "ccc-server-src-{}-{:?}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos() )); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join(name); let mut f = std::fs::File::create(&path).unwrap(); f.write_all(contents).unwrap(); path } #[test] fn server_boots_without_segfaulting_and_answers_health_ish_route() { let mut server = Server::start("boot"); // If we got past Server::start() the process accepted a TCP connection, // i.e. it did not segfault/panic during startup. let (status, _headers, body) = server.get("/packages"); assert_eq!(status, 200, "body: {}", String::from_utf8_lossy(&body)); let text = String::from_utf8_lossy(&body); assert_eq!(text, "[]", "fresh DB should list no packages, got: {text}"); // The child must still be alive (no crash-on-request) after serving it. match server.child.try_wait() { Ok(None) => {} Ok(Some(status)) => panic!("server process exited unexpectedly: {status}"), Err(e) => panic!("failed to poll child status: {e}"), } } #[test] fn server_serves_added_asset_uncompressed_and_gzip() { let server = Server::start("asset"); assert!(server.cli(&["create", "demo"]).status.success()); let src = write_temp_file("app.js", b"console.log('hello from ccc');"); let add = server.cli(&["add", "demo", src.to_str().unwrap(), "1.0.0"]); assert!(add.status.success(), "stderr: {}", String::from_utf8_lossy(&add.stderr)); let (status, headers, body) = server.get("/assets/demo/1.0.0/app.js"); assert_eq!(status, 200, "body: {}", String::from_utf8_lossy(&body)); assert_eq!(header(&headers, "content-type"), Some("application/javascript")); assert_eq!(body, b"console.log('hello from ccc');"); let (gz_status, gz_headers, gz_body) = server.get_gzip("/assets/demo/1.0.0/app.js"); assert_eq!(gz_status, 200); assert_eq!(header(&gz_headers, "content-encoding"), Some("gzip")); // Decompress and confirm round-trip integrity through the actual HTTP path. use flate2::read::GzDecoder; let mut decoder = GzDecoder::new(&gz_body[..]); let mut decompressed = Vec::new(); decoder.read_to_end(&mut decompressed).unwrap(); assert_eq!(decompressed, b"console.log('hello from ccc');"); let _ = std::fs::remove_file(&src); } #[test] fn server_returns_404_for_unknown_asset() { let server = Server::start("404"); let (status, _headers, body) = server.get("/assets/ghost/9.9.9/missing.js"); assert_eq!(status, 404, "body: {}", String::from_utf8_lossy(&body)); } #[test] fn server_lists_packages_created_out_of_band_via_cli() { let server = Server::start("listing"); assert!(server.cli(&["create", "alpha"]).status.success()); assert!(server.cli(&["create", "beta"]).status.success()); let src = write_temp_file("a.txt", b"x"); assert!(server.cli(&["add", "alpha", src.to_str().unwrap(), "1.0.0"]).status.success()); let (status, _headers, body) = server.get("/packages"); assert_eq!(status, 200); let text = String::from_utf8_lossy(&body); assert!(text.contains("alpha"), "body: {text}"); assert!(text.contains("beta"), "body: {text}"); assert!(text.contains("1.0.0"), "body: {text}"); let _ = std::fs::remove_file(&src); } #[test] fn archived_package_is_hidden_from_listing() { let server = Server::start("archived-listing"); assert!(server.cli(&["create", "demo"]).status.success()); assert!(server.cli(&["archive", "demo"]).status.success()); let (status, _headers, body) = server.get("/packages"); assert_eq!(status, 200); let text = String::from_utf8_lossy(&body); assert!(!text.contains("demo"), "archived package leaked into listing: {text}"); } #[test] fn server_survives_multiple_sequential_requests() { // Repeated request/response cycles against the same long-lived process // are exactly the pattern that would surface a use-after-free / double // free in a bespoke runtime, hence several round trips here rather than // just one. let server = Server::start("multi"); for _ in 0..10 { let (status, _headers, _body) = server.get("/packages"); assert_eq!(status, 200); } }