diff --git a/Cargo.lock b/Cargo.lock index 969e504..05a40aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -378,9 +378,10 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "smarm" -version = "0.5.0" -source = "git+https://git.kalsbeek.dev/Markk116/smarm.git?tag=v0.5.0#a03a7ca01ef1ecaeb8aabc00bebe7ddef4f3879a" +version = "0.6.0" +source = "git+https://git.kalsbeek.dev/Markk116/smarm.git?tag=v0.6.0#301e3463e3abb11ee25b94ee2ec31c497fb4e2b4" dependencies = [ + "cc", "libc", "loom", ] @@ -473,8 +474,8 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "urus" -version = "0.2.0" -source = "git+https://git.kalsbeek.dev/Markk116/urus.git#b77448191ec33de1662305f3035473d64f8cd34f" +version = "0.2.2" +source = "git+https://git.kalsbeek.dev/Markk116/urus.git?tag=v0.2.2#535f7bcc687bcdf4ef5cf366177ac166aa8466a1" dependencies = [ "httparse", "libc", diff --git a/Cargo.toml b/Cargo.toml index 83ada72..4654eec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,10 +5,10 @@ edition = "2024" license = "AGPL-3.0-only" [dependencies] -urus = { git = "https://git.kalsbeek.dev/Markk116/urus.git" } +urus = { git = "https://git.kalsbeek.dev/Markk116/urus.git", tag = "v0.2.2" } # Pinned to the same tag urus itself depends on, so Cargo unifies both # into a single source id/copy of smarm (avoids duplicate #[global_allocator]). -smarm = { git = "https://git.kalsbeek.dev/Markk116/smarm.git", tag = "v0.5.0" } +smarm = { git = "https://git.kalsbeek.dev/Markk116/smarm.git", tag = "v0.6.0" } rusqlite = { version = "0.31", features = ["bundled"] } flate2 = "1.0" serde = { version = "1", features = ["derive"] } diff --git a/src/main.rs b/src/main.rs index ed8efe3..1d1b9d4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -111,6 +111,7 @@ fn print_usage() { \x20 ccc create \n\ \x20 ccc add \n\ \x20 ccc archive []\n\ + \x20 ccc stats\n\ \x20 ccc serve [-p|--port ]\n" ); } @@ -148,6 +149,21 @@ fn run_cli() -> Option { } None } + Some("stats") => { + match store::stats() { + Ok(s) => { + println!("packages: {} active, {} archived", s.packages_active, s.packages_archived); + println!("versions: {} active, {} archived", s.versions_active, s.versions_archived); + println!("stored: {} bytes (gzipped)", s.total_gzipped_bytes); + println!("db path: {}", store::db_path().display()); + } + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } + } + None + } Some("archive") => { let Some(package) = args.get(2) else { print_usage(); diff --git a/src/store.rs b/src/store.rs index 90f9b88..90c77a3 100644 --- a/src/store.rs +++ b/src/store.rs @@ -127,6 +127,47 @@ pub fn archive(package: &str, version: Option<&str>) -> io::Result<()> { Ok(()) } +#[derive(Debug, PartialEq, Eq)] +pub struct Stats { + pub packages_active: i64, + pub packages_archived: i64, + pub versions_active: i64, + pub versions_archived: i64, + pub total_gzipped_bytes: i64, +} + +pub fn stats() -> io::Result { + let conn = open().map_err(io_err)?; + + let packages_active: i64 = conn + .query_row("SELECT COUNT(*) FROM packages WHERE archived = 0", [], |r| r.get(0)) + .map_err(io_err)?; + let packages_archived: i64 = conn + .query_row("SELECT COUNT(*) FROM packages WHERE archived = 1", [], |r| r.get(0)) + .map_err(io_err)?; + let versions_active: i64 = conn + .query_row("SELECT COUNT(*) FROM versions WHERE archived = 0", [], |r| r.get(0)) + .map_err(io_err)?; + let versions_archived: i64 = conn + .query_row("SELECT COUNT(*) FROM versions WHERE archived = 1", [], |r| r.get(0)) + .map_err(io_err)?; + let total_gzipped_bytes: i64 = conn + .query_row( + "SELECT COALESCE(SUM(LENGTH(gzipped_bytes)), 0) FROM versions", + [], + |r| r.get(0), + ) + .map_err(io_err)?; + + Ok(Stats { + packages_active, + packages_archived, + versions_active, + versions_archived, + total_gzipped_bytes, + }) +} + fn guess_mime(path: &std::path::Path) -> &'static str { match path.extension().and_then(|e| e.to_str()).unwrap_or("") { "js" | "mjs" => "application/javascript", @@ -144,3 +185,266 @@ fn guess_mime(path: &std::path::Path) -> &'static str { _ => "application/octet-stream", } } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use std::sync::Mutex; + + // store::open()/db_path() read the process-wide CCC_DB_PATH env var, so + // tests that touch it must not run concurrently on separate threads. + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + struct TempDb { + path: PathBuf, + _guard: std::sync::MutexGuard<'static, ()>, + } + + impl TempDb { + fn new(tag: &str) -> Self { + let guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let path = std::env::temp_dir().join(format!( + "ccc-test-{tag}-{}-{:?}.db", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let _ = std::fs::remove_file(&path); + unsafe { std::env::set_var("CCC_DB_PATH", &path) }; + Self { path, _guard: guard } + } + } + + impl Drop for TempDb { + fn drop(&mut self) { + unsafe { std::env::remove_var("CCC_DB_PATH") }; + let _ = std::fs::remove_file(&self.path); + } + } + + fn write_temp_file(name: &str, contents: &[u8]) -> PathBuf { + // Keep `name` (with its extension) as the trailing path component so + // add_version()'s guess_mime()/filename logic sees the real extension. + let dir = std::env::temp_dir().join(format!( + "ccc-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 db_path_defaults_to_cdn_db_when_unset() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + unsafe { std::env::remove_var("CCC_DB_PATH") }; + assert_eq!(db_path(), PathBuf::from("cdn.db")); + } + + #[test] + fn open_creates_schema_and_is_idempotent() { + let db = TempDb::new("schema"); + let conn = open().expect("first open should create schema"); + drop(conn); + // Re-opening an existing DB must not fail on `CREATE TABLE IF NOT EXISTS`. + let conn2 = open().expect("second open should reuse schema"); + let count: i64 = conn2 + .query_row("SELECT COUNT(*) FROM packages", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 0); + let _ = db; + } + + #[test] + fn create_package_then_duplicate_errors() { + let _db = TempDb::new("create-dup"); + create_package("demo").expect("create should succeed"); + let err = create_package("demo").expect_err("duplicate create should fail"); + assert_eq!(err.kind(), io::ErrorKind::AlreadyExists); + } + + #[test] + fn add_version_without_package_errors() { + let _db = TempDb::new("add-missing-pkg"); + let src = write_temp_file("nope.txt", b"hello"); + let err = add_version("ghost", src.to_str().unwrap(), "1.0.0") + .expect_err("adding to a nonexistent package should fail"); + assert_eq!(err.kind(), io::ErrorKind::NotFound); + let _ = std::fs::remove_file(&src); + } + + #[test] + fn add_version_round_trips_gzip_and_mime() { + let _db = TempDb::new("add-roundtrip"); + create_package("demo").unwrap(); + let src = write_temp_file("app.js", b"console.log('hi');"); + add_version("demo", src.to_str().unwrap(), "1.0.0").expect("add should succeed"); + + let conn = open().unwrap(); + let (filename, mime, blob): (String, String, Vec) = conn + .query_row( + "SELECT filename, mime_type, gzipped_bytes FROM versions WHERE package = ? AND version = ?", + params!["demo", "1.0.0"], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .unwrap(); + + assert_eq!(mime, "application/javascript"); + assert!(filename.ends_with("app.js") || filename.contains("app.js")); + + use std::io::Read; + let mut decoder = flate2::read::GzDecoder::new(&blob[..]); + let mut decompressed = Vec::new(); + decoder.read_to_end(&mut decompressed).unwrap(); + assert_eq!(decompressed, b"console.log('hi');"); + + let _ = std::fs::remove_file(&src); + } + + #[test] + fn add_version_upsert_replaces_existing_version() { + let _db = TempDb::new("add-upsert"); + create_package("demo").unwrap(); + let src1 = write_temp_file("a.txt", b"first"); + let src2 = write_temp_file("b.css", b"body{color:red}"); + add_version("demo", src1.to_str().unwrap(), "1.0.0").unwrap(); + add_version("demo", src2.to_str().unwrap(), "1.0.0").unwrap(); + + let conn = open().unwrap(); + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM versions WHERE package = ? AND version = ?", + params!["demo", "1.0.0"], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 1, "upsert should replace, not duplicate, the row"); + + let mime: String = conn + .query_row( + "SELECT mime_type FROM versions WHERE package = ? AND version = ?", + params!["demo", "1.0.0"], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(mime, "text/css"); + + let _ = std::fs::remove_file(&src1); + let _ = std::fs::remove_file(&src2); + } + + #[test] + fn stats_reports_active_archived_counts_and_bytes() { + let _db = TempDb::new("stats"); + create_package("demo").unwrap(); + create_package("other").unwrap(); + let src1 = write_temp_file("a.txt", b"hello"); + let src2 = write_temp_file("b.txt", b"world!!"); + add_version("demo", src1.to_str().unwrap(), "1.0.0").unwrap(); + add_version("demo", src2.to_str().unwrap(), "2.0.0").unwrap(); + archive("demo", Some("1.0.0")).unwrap(); + archive("other", None).unwrap(); + + let s = stats().expect("stats should succeed"); + assert_eq!(s.packages_active, 1); + assert_eq!(s.packages_archived, 1); + assert_eq!(s.versions_active, 1); + assert_eq!(s.versions_archived, 1); + assert!(s.total_gzipped_bytes > 0, "gzipped bytes should be non-zero"); + + let _ = std::fs::remove_file(&src1); + let _ = std::fs::remove_file(&src2); + } + + #[test] + fn stats_on_empty_db_is_all_zero() { + let _db = TempDb::new("stats-empty"); + let s = stats().expect("stats should succeed on empty db"); + assert_eq!( + s, + Stats { + packages_active: 0, + packages_archived: 0, + versions_active: 0, + versions_archived: 0, + total_gzipped_bytes: 0, + } + ); + } + + #[test] + fn archive_unknown_package_errors() { + let _db = TempDb::new("archive-missing"); + let err = archive("ghost", None).expect_err("archiving unknown package should fail"); + assert_eq!(err.kind(), io::ErrorKind::NotFound); + } + + #[test] + fn archive_unknown_version_errors() { + let _db = TempDb::new("archive-missing-version"); + create_package("demo").unwrap(); + let err = archive("demo", Some("9.9.9")) + .expect_err("archiving unknown version should fail"); + assert_eq!(err.kind(), io::ErrorKind::NotFound); + } + + #[test] + fn archive_whole_package_hides_it_but_keeps_versions_row() { + let _db = TempDb::new("archive-package"); + create_package("demo").unwrap(); + let src = write_temp_file("a.txt", b"x"); + add_version("demo", src.to_str().unwrap(), "1.0.0").unwrap(); + + archive("demo", None).expect("archive whole package should succeed"); + + let conn = open().unwrap(); + let archived: i64 = conn + .query_row("SELECT archived FROM packages WHERE name = ?", params!["demo"], |r| r.get(0)) + .unwrap(); + assert_eq!(archived, 1); + + let _ = std::fs::remove_file(&src); + } + + #[test] + fn archive_single_version_only_affects_that_version() { + let _db = TempDb::new("archive-version"); + create_package("demo").unwrap(); + let src1 = write_temp_file("a.txt", b"x"); + let src2 = write_temp_file("c.txt", b"y"); + add_version("demo", src1.to_str().unwrap(), "1.0.0").unwrap(); + add_version("demo", src2.to_str().unwrap(), "2.0.0").unwrap(); + + archive("demo", Some("1.0.0")).unwrap(); + + let conn = open().unwrap(); + let v1: i64 = conn + .query_row( + "SELECT archived FROM versions WHERE package = ? AND version = ?", + params!["demo", "1.0.0"], + |r| r.get(0), + ) + .unwrap(); + let v2: i64 = conn + .query_row( + "SELECT archived FROM versions WHERE package = ? AND version = ?", + params!["demo", "2.0.0"], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(v1, 1); + assert_eq!(v2, 0); + + let _ = std::fs::remove_file(&src1); + let _ = std::fs::remove_file(&src2); + } +} diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 0000000..f3f0921 --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,170 @@ +//! Black-box integration tests for the `ccc` binary's CLI subcommands +//! (create/add/archive). Each test runs the real compiled binary as a +//! subprocess against its own throwaway SQLite file, so no in-process +//! state is shared between tests. + +use std::io::Write; +use std::path::PathBuf; +use std::process::Command; + +fn bin() -> &'static str { + env!("CARGO_BIN_EXE_CCC") +} + +struct TestEnv { + db_path: PathBuf, +} + +impl TestEnv { + fn new(tag: &str) -> Self { + let db_path = std::env::temp_dir().join(format!( + "ccc-cli-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); + Self { db_path } + } + + fn cmd(&self, args: &[&str]) -> std::process::Output { + Command::new(bin()) + .args(args) + .env("CCC_DB_PATH", &self.db_path) + .output() + .expect("failed to run ccc binary") + } +} + +impl Drop for TestEnv { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.db_path); + } +} + +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-cli-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 no_args_prints_usage_and_errors() { + let out = Command::new(bin()) + .args(["bogus-subcommand"]) + .output() + .expect("failed to run ccc binary"); + assert!(!out.status.success()); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("unknown subcommand"), "stderr was: {stderr}"); +} + +#[test] +fn create_package_succeeds() { + let env = TestEnv::new("create-ok"); + let out = env.cmd(&["create", "demo"]); + assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("created package 'demo'"), "stdout was: {stdout}"); +} + +#[test] +fn create_duplicate_package_fails() { + let env = TestEnv::new("create-dup"); + let first = env.cmd(&["create", "demo"]); + assert!(first.status.success()); + + let second = env.cmd(&["create", "demo"]); + assert!(!second.status.success()); + let stderr = String::from_utf8_lossy(&second.stderr); + assert!(stderr.contains("already exists"), "stderr was: {stderr}"); +} + +#[test] +fn add_to_missing_package_fails() { + let env = TestEnv::new("add-missing"); + let src = write_temp_file("app.js", b"console.log(1)"); + let out = env.cmd(&["add", "demo", src.to_str().unwrap(), "1.0.0"]); + assert!(!out.status.success()); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("does not exist"), "stderr was: {stderr}"); + let _ = std::fs::remove_file(&src); +} + +#[test] +fn add_missing_source_file_fails() { + let env = TestEnv::new("add-nofile"); + assert!(env.cmd(&["create", "demo"]).status.success()); + let out = env.cmd(&["add", "demo", "/nonexistent/path/does-not-exist.js", "1.0.0"]); + assert!(!out.status.success()); +} + +#[test] +fn full_lifecycle_create_add_archive() { + let env = TestEnv::new("lifecycle"); + let src = write_temp_file("app.js", b"console.log('hello world');"); + + assert!(env.cmd(&["create", "demo"]).status.success()); + let add_out = env.cmd(&["add", "demo", src.to_str().unwrap(), "1.0.0"]); + assert!(add_out.status.success(), "stderr: {}", String::from_utf8_lossy(&add_out.stderr)); + + let archive_version = env.cmd(&["archive", "demo", "1.0.0"]); + assert!(archive_version.status.success()); + + let archive_package = env.cmd(&["archive", "demo"]); + assert!(archive_package.status.success()); + + let archive_again = env.cmd(&["archive", "demo", "9.9.9"]); + assert!(!archive_again.status.success(), "archiving an unknown version should fail"); + + let _ = std::fs::remove_file(&src); +} + +#[test] +fn stats_on_empty_db_reports_zeros() { + let env = TestEnv::new("stats-empty"); + let out = env.cmd(&["stats"]); + assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("0 active, 0 archived"), "stdout was: {stdout}"); + assert!(stdout.contains("0 bytes (gzipped)"), "stdout was: {stdout}"); +} + +#[test] +fn stats_reflects_added_content() { + let env = TestEnv::new("stats-content"); + let src = write_temp_file("app.js", b"console.log('hello');"); + assert!(env.cmd(&["create", "demo"]).status.success()); + assert!(env.cmd(&["add", "demo", src.to_str().unwrap(), "1.0.0"]).status.success()); + + let out = env.cmd(&["stats"]); + assert!(out.status.success()); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("1 active, 0 archived"), "stdout was: {stdout}"); + assert!(!stdout.contains("0 bytes (gzipped)"), "stdout was: {stdout}"); + + let _ = std::fs::remove_file(&src); +} + +#[test] +fn missing_required_args_exit_with_usage_code() { + let env = TestEnv::new("usage"); + let out = env.cmd(&["create"]); + assert_eq!(out.status.code(), Some(2)); + let out = env.cmd(&["add", "demo"]); + assert_eq!(out.status.code(), Some(2)); +} diff --git a/tests/server.rs b/tests/server.rs new file mode 100644 index 0000000..cb0c474 --- /dev/null +++ b/tests/server.rs @@ -0,0 +1,252 @@ +//! 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); + } +}