diff --git a/.github/workflows/macos-tls.yml b/.github/workflows/macos-tls.yml new file mode 100644 index 0000000000..37c971026f --- /dev/null +++ b/.github/workflows/macos-tls.yml @@ -0,0 +1,41 @@ +name: macOS TLS trust + +on: + pull_request: + paths: + - Cargo.toml + - Cargo.lock + - rust-toolchain + - moli-curl/** + - .github/workflows/macos-tls.yml + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: macos-tls-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + sectrust: + name: SecTrust + runs-on: macos-latest + timeout-minutes: 25 + env: + MACOSX_DEPLOYMENT_TARGET: "13.0" + CARGO_INCREMENTAL: "0" + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Install pinned Rust toolchain + run: | + toolchain=$(tr -d '[:space:]' < rust-toolchain) + rustup toolchain install "$toolchain" --profile minimal --component clippy --no-self-update + - name: Check macOS code + run: cargo clippy --locked -p moli-curl --all-targets -- -D warnings + - name: Test HTTP and WebSocket runtime + run: cargo test --locked -p moli-curl + - name: Test native trust and explicit CA overrides + run: cargo test --locked -p moli-curl --test apple_sectrust -- --ignored --nocapture diff --git a/Cargo.lock b/Cargo.lock index 7ce6816abe..7aed1966f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -845,7 +845,7 @@ dependencies = [ [[package]] name = "curl" version = "0.4.49" -source = "git+https://github.com/lexmount/curl-rust?branch=moli#bcd4d9f9d7dcd4ec68cfa42f80e9e1f872d87229" +source = "git+https://github.com/lexmount/curl-rust?rev=a0ea59f5ca1b1e4bc8627a28c29463ac96aeb758#a0ea59f5ca1b1e4bc8627a28c29463ac96aeb758" dependencies = [ "curl-sys", "libc", @@ -859,7 +859,7 @@ dependencies = [ [[package]] name = "curl-sys" version = "0.4.87+curl-8.19.0" -source = "git+https://github.com/lexmount/curl-rust?branch=moli#bcd4d9f9d7dcd4ec68cfa42f80e9e1f872d87229" +source = "git+https://github.com/lexmount/curl-rust?rev=a0ea59f5ca1b1e4bc8627a28c29463ac96aeb758#a0ea59f5ca1b1e4bc8627a28c29463ac96aeb758" dependencies = [ "brotlic-sys", "cc", @@ -2596,6 +2596,7 @@ dependencies = [ "parking_lot", "rcgen", "rustls", + "tempfile", "tokio", "tokio-tungstenite", "tracing", diff --git a/Cargo.toml b/Cargo.toml index 3e72a25b6b..a6da1555a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -96,8 +96,8 @@ license = "MIT OR Apache-2.0" [patch.crates-io] xml5ever = { path = "vendor/xml5ever-0.39.0" } -curl = { git = "https://github.com/lexmount/curl-rust", branch = "moli" } -curl-sys = { git = "https://github.com/lexmount/curl-rust", branch = "moli" } +curl = { git = "https://github.com/lexmount/curl-rust", rev = "a0ea59f5ca1b1e4bc8627a28c29463ac96aeb758" } +curl-sys = { git = "https://github.com/lexmount/curl-rust", rev = "a0ea59f5ca1b1e4bc8627a28c29463ac96aeb758" } cookie = { git = "https://github.com/ldm0/cookie-rs", branch = "priority" } v8 = { path = "vendor/v8-152.2.0" } deno_v8 = { path = "vendor/deno_v8-0.3.0" } diff --git a/moli-curl/Cargo.toml b/moli-curl/Cargo.toml index b69aab7bee..3f02f95d01 100644 --- a/moli-curl/Cargo.toml +++ b/moli-curl/Cargo.toml @@ -21,6 +21,7 @@ url = "2.5.7" [dev-dependencies] rcgen = { version = "0.13", default-features = false, features = ["aws_lc_rs", "pem"] } rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "std", "tls12"] } +tempfile = "3" tokio = { version = "1.51.0", features = ["macros", "rt", "time"] } tokio-tungstenite = { version = "0.28.0", default-features = false, features = ["handshake"] } tracing-subscriber = { version = "0.3.19", features = ["fmt"] } diff --git a/moli-curl/README.md b/moli-curl/README.md index f641eb12b4..58be8beedc 100644 --- a/moli-curl/README.md +++ b/moli-curl/README.md @@ -29,6 +29,29 @@ The native owner keeps reads and writes independent and parks I/O after AGAIN until the corresponding socket is signalled. It shares one spare receive Vec; successful reads transfer that Vec to the event consumer. +## TLS trust + +On macOS, HTTPS, WSS, and HTTPS proxy connections use libcurl's Apple SecTrust +integration by default. AWS-LC still handles TLS; SecTrust evaluates certificate +trust through the operating system. The pinned curl-rust build enables this +integration and avoids automatically selecting a CA file or directory, which +would disable libcurl's default SecTrust selection. + +An explicit `CurlTlsConfig::ca_cert` (`--ca-cert` in the CLI) takes precedence +over certificate environment variables. Otherwise, `SSL_CERT_FILE` and +`SSL_CERT_DIR` select file-based verification for both origins and HTTPS proxies. +These explicit sources use the existing TLS backend's verifier, without adding +the system trust store. Certificate-chain and hostname verification remain +enabled by default. Other platforms retain their existing trust configuration. + +The `macOS TLS trust` workflow builds the AWS-LC/SecTrust combination on +`macos-latest`. Its `apple_sectrust` integration test covers HTTPS, WSS, +HTTPS proxy verification, rejected chains and hostnames, and CA file/directory +overrides. It checks libcurl's verifier diagnostics during real local handshakes. +The test is ignored in normal runs because it installs a temporary keychain and +administrator trust setting; it is restricted to ephemeral macOS CI runners and +cleans up the trust setting and keychain afterward. + ## Enable counters Set `MOLI_CURL_WEBSOCKET_DIAGNOSTICS=1` before creating the runtime and enable diff --git a/moli-curl/src/tls.rs b/moli-curl/src/tls.rs index 92bf4e85fa..f9e2bfeec2 100644 --- a/moli-curl/src/tls.rs +++ b/moli-curl/src/tls.rs @@ -64,6 +64,12 @@ impl CurlTlsConfig { ) })?; } + #[cfg(target_os = "macos")] + if self.ca_cert.is_some() { + // An explicit CA file overrides SSL_CERT_DIR as well as + // SSL_CERT_FILE, which curl-rust applies to a fresh handle. + clear_ca_directory(easy, curl_sys::CURLOPT_CAPATH)?; + } if include_client_identity { if let Some(client_cert) = &self.client_cert { easy.ssl_cert(client_cert).with_context(|| { @@ -127,10 +133,40 @@ impl CurlTlsConfig { ) })?; } + #[cfg(target_os = "macos")] + if self.ca_cert.is_some() { + clear_ca_directory(easy, curl_sys::CURLOPT_PROXY_CAPATH)?; + } else if self.verify { + // curl-rust applies explicit certificate environment variables to + // the origin. Moli shares these sources with HTTPS proxies too. + // Leave every CA option untouched when no override was supplied: + // libcurl then selects its built-in Apple SecTrust verifier. + if let Some(file) = std::env::var_os("SSL_CERT_FILE") { + easy.proxy_cainfo(file.to_str().context("SSL_CERT_FILE is not valid UTF-8")?) + .context("failed to configure HTTPS proxy SSL_CERT_FILE")?; + } + if let Some(directory) = std::env::var_os("SSL_CERT_DIR") { + easy.proxy_capath(std::path::Path::new(&directory)) + .context("failed to configure HTTPS proxy SSL_CERT_DIR")?; + } + } Ok(()) } } +#[cfg(target_os = "macos")] +fn clear_ca_directory(easy: &mut Easy2, option: curl_sys::CURLoption) -> Result<()> { + // SAFETY: the caller supplies a CA directory string option, `easy` is + // exclusively borrowed, and libcurl accepts NULL to clear this setting. + let result = unsafe { + curl_sys::curl_easy_setopt(easy.raw(), option, std::ptr::null::()) + }; + if result != curl_sys::CURLE_OK { + return Err(curl::Error::new(result)).context("failed to clear curl CA directory override"); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/moli-curl/tests/apple_sectrust.rs b/moli-curl/tests/apple_sectrust.rs new file mode 100644 index 0000000000..ec56fee3aa --- /dev/null +++ b/moli-curl/tests/apple_sectrust.rs @@ -0,0 +1,507 @@ +//! Real macOS trust evaluation. Run only on an ephemeral GitHub Actions runner: +//! this test installs a temporary keychain and administrator trust setting. + +use std::{ + fs, + io::{self, Read, Seek, Write}, + net::{TcpListener, TcpStream}, + path::{Path, PathBuf}, + process::{Command, Output}, + sync::Arc, + thread, + time::{Duration, Instant, SystemTime}, +}; + +use curl::easy::{Easy2, Handler, InfoType, List, WriteError}; +use moli_curl::CurlTlsConfig; +use rcgen::{BasicConstraints, CertificateParams, DnType, IsCa, KeyPair, KeyUsagePurpose}; +use rustls::{ + ServerConfig, ServerConnection, StreamOwned, + pki_types::{CertificateDer, PrivatePkcs8KeyDer}, +}; + +const DEADLINE: Duration = Duration::from_secs(10); +const COMMAND_DEADLINE: Duration = Duration::from_secs(15); + +#[test] +#[ignore = "requires an ephemeral macOS GitHub Actions runner and modifies its trust settings"] +fn apple_sectrust() { + assert_eq!(std::env::consts::OS, "macos"); + assert_eq!(std::env::var("GITHUB_ACTIONS").as_deref(), Ok("true")); + if let Ok(case) = std::env::var("MOLI_SECTRUST_CASE") { + run_case( + &case, + &PathBuf::from(std::env::var_os("MOLI_SECTRUST_FIXTURES").unwrap()), + ); + return; + } + + let fixtures = tempfile::tempdir().unwrap(); + for name in ["trusted", "untrusted"] { + generate_certificates(fixtures.path(), name); + create_ca_directory(fixtures.path(), name); + } + let _keychain = Keychain::install(fixtures.path()); + + // Separate processes keep certificate environment variables isolated from + // other tests and exercise the binding's fresh-handle initialization. + for route in ["origin", "proxy", "wss"] { + for policy in [ + "native", + "untrusted", + "hostname", + "ca", + "ca-untrusted", + "env-file", + "env-file-untrusted", + "env-dir", + "env-dir-untrusted", + "ca-over-env", + "ca-untrusted-over-env", + "insecure", + ] { + let case = format!("{route}/{policy}"); + let mut command = Command::new(std::env::current_exe().unwrap()); + command + .args(["--ignored", "--exact", "apple_sectrust", "--nocapture"]) + .env("MOLI_SECTRUST_CASE", &case) + .env("MOLI_SECTRUST_FIXTURES", fixtures.path()) + .env_remove("SSL_CERT_FILE") + .env_remove("SSL_CERT_DIR"); + match policy { + "env-file" | "env-file-untrusted" => { + let ca = if policy == "env-file" { + "trusted" + } else { + "untrusted" + }; + command.env("SSL_CERT_FILE", fixtures.path().join(format!("{ca}.pem"))); + } + "env-dir" | "env-dir-untrusted" => { + let ca = if policy == "env-dir" { + "trusted" + } else { + "untrusted" + }; + command.env("SSL_CERT_DIR", fixtures.path().join(ca)); + } + "ca-over-env" | "ca-untrusted-over-env" => { + command.env("SSL_CERT_FILE", fixtures.path().join("untrusted.pem")); + // If CAPATH leaks through the explicit CA override, the + // negative case would unexpectedly trust this certificate. + command.env("SSL_CERT_DIR", fixtures.path().join("trusted")); + } + _ => {} + } + let output = command_output(&mut command, COMMAND_DEADLINE).unwrap(); + assert!( + output.status.success(), + "{case}:\n{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + println!("passed {case}"); + } + } +} + +fn generate_certificates(directory: &Path, name: &str) -> CertificateDer<'static> { + let mut params = CertificateParams::new(Vec::::new()).unwrap(); + params + .distinguished_name + .push(DnType::CommonName, format!("Moli {name} test CA")); + params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; + let root_key = KeyPair::generate().unwrap(); + let root = params.self_signed(&root_key).unwrap(); + let root_path = directory.join(format!("{name}.pem")); + fs::write(&root_path, root.pem()).unwrap(); + + let mut params = CertificateParams::new(vec!["fixture.test".to_owned()]).unwrap(); + params + .distinguished_name + .push(DnType::CommonName, "fixture.test"); + params.use_authority_key_identifier_extension = true; + params.not_before = (SystemTime::now() - Duration::from_secs(86400)).into(); + params.not_after = (SystemTime::now() + Duration::from_secs(86400)).into(); + params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ServerAuth]; + let key = KeyPair::generate().unwrap(); + let certificate = params.signed_by(&key, &root, &root_key).unwrap(); + fs::write(directory.join(format!("{name}.der")), certificate.der()).unwrap(); + fs::write(directory.join(format!("{name}.key")), key.serialize_der()).unwrap(); + root.der().clone() +} + +fn create_ca_directory(directory: &Path, name: &str) { + let root_path = directory.join(format!("{name}.pem")); + let hash = command_output( + Command::new("openssl") + .args(["x509", "-hash", "-noout", "-in"]) + .arg(&root_path), + COMMAND_DEADLINE, + ) + .unwrap(); + assert!(hash.status.success()); + let ca_directory = directory.join(name); + fs::create_dir(&ca_directory).unwrap(); + fs::copy( + root_path, + ca_directory.join(format!( + "{}.0", + String::from_utf8(hash.stdout).unwrap().trim() + )), + ) + .unwrap(); +} + +struct Keychain { + path: PathBuf, + root: PathBuf, + previous: Vec, +} + +impl Keychain { + fn install(directory: &Path) -> Self { + let previous = security(&["list-keychains", "-d", "user"]); + let keychain = Self { + path: directory.join("test.keychain-db"), + root: directory.join("trusted.pem"), + previous: previous + .lines() + .map(|line| line.trim().trim_matches('"').to_owned()) + .collect(), + }; + let path = keychain.path.to_str().unwrap(); + security(&["create-keychain", "-p", "moli-test", path]); + security(&["unlock-keychain", "-p", "moli-test", path]); + let mut search_list = vec!["list-keychains", "-d", "user", "-s"]; + search_list.extend(keychain.previous.iter().map(String::as_str)); + search_list.push(path); + security(&search_list); + let output = command_output( + Command::new("sudo") + .args([ + "-n", + "security", + "add-trusted-cert", + "-d", + "-r", + "trustRoot", + "-p", + "ssl", + "-k", + path, + ]) + .arg(&keychain.root), + COMMAND_DEADLINE, + ) + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + keychain + } +} + +impl Drop for Keychain { + fn drop(&mut self) { + cleanup( + Command::new("sudo") + .args(["-n", "security", "remove-trusted-cert", "-d"]) + .arg(&self.root), + ); + cleanup( + Command::new("security") + .args(["list-keychains", "-d", "user", "-s"]) + .args(&self.previous), + ); + cleanup( + Command::new("security") + .arg("delete-keychain") + .arg(&self.path), + ); + } +} + +fn security(arguments: &[&str]) -> String { + let output = + command_output(Command::new("security").args(arguments), COMMAND_DEADLINE).unwrap(); + assert!( + output.status.success(), + "security {arguments:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap() +} + +fn cleanup(command: &mut Command) { + match command_output(command, COMMAND_DEADLINE) { + Ok(output) if output.status.success() => {} + Ok(output) => eprintln!( + "cleanup {command:?}: {}", + String::from_utf8_lossy(&output.stderr) + ), + Err(error) => eprintln!("cleanup {command:?}: {error}"), + } +} + +fn command_output(command: &mut Command, deadline: Duration) -> io::Result { + // Files keep verbose child output from blocking on a full pipe while the + // parent enforces the deadline, including during panic cleanup. + let mut stdout = tempfile::tempfile()?; + let mut stderr = tempfile::tempfile()?; + command + .stdout(stdout.try_clone()?) + .stderr(stderr.try_clone()?); + let mut child = command.spawn()?; + let start = Instant::now(); + let status = loop { + if let Some(status) = child.try_wait()? { + break status; + } + if start.elapsed() >= deadline { + if let Err(error) = child.kill() { + // `sudo security` runs as root; the runner user cannot kill it + // directly. Restrict escalation to this still-owned child PID. + if error.kind() != io::ErrorKind::PermissionDenied + || !Command::new("sudo") + .args(["-n", "/bin/kill", "-KILL", &child.id().to_string()]) + .status()? + .success() + { + return Err(error); + } + } + child.wait()?; + return Err(io::Error::new( + io::ErrorKind::TimedOut, + format!("{command:?} exceeded {deadline:?}"), + )); + } + thread::sleep(Duration::from_millis(10)); + }; + stdout.rewind()?; + stderr.rewind()?; + let mut output = Output { + status, + stdout: Vec::new(), + stderr: Vec::new(), + }; + stdout.read_to_end(&mut output.stdout)?; + stderr.read_to_end(&mut output.stderr)?; + Ok(output) +} + +#[derive(Default)] +struct Response { + body: Vec, + debug: String, +} + +impl Handler for Response { + fn write(&mut self, data: &[u8]) -> Result { + self.body.extend_from_slice(data); + Ok(data.len()) + } + + fn debug(&mut self, kind: InfoType, data: &[u8]) { + if matches!(kind, InfoType::Text) { + self.debug.push_str(&String::from_utf8_lossy(data)); + } + } +} + +fn run_case(case: &str, directory: &Path) { + let (route, policy) = case.split_once('/').unwrap(); + let certificate = if matches!(policy, "untrusted" | "insecure") { + "untrusted" + } else { + "trusted" + }; + let (port, server) = server(directory, certificate, route == "wss"); + let host = if policy == "hostname" { + "wrong.test" + } else { + "fixture.test" + }; + let mut easy = Easy2::new(Response::default()); + easy.timeout(DEADLINE).unwrap(); + easy.verbose(true).unwrap(); + easy.noproxy("").unwrap(); + let mut resolve = List::new(); + resolve.append(&format!("{host}:{port}:127.0.0.1")).unwrap(); + easy.resolve(resolve).unwrap(); + let mut tls = CurlTlsConfig { + verify: policy != "insecure", + ..Default::default() + }; + if policy.starts_with("ca") { + let ca = if policy.starts_with("ca-untrusted") { + "untrusted" + } else { + "trusted" + }; + tls.ca_cert = Some(directory.join(format!("{ca}.pem"))); + } + if route == "proxy" { + easy.url("http://target.invalid/resource").unwrap(); + easy.proxy(&format!("https://{host}:{port}")).unwrap(); + tls.configure_https_proxy(&mut easy).unwrap(); + } else { + let scheme = if route == "wss" { "wss" } else { "https" }; + easy.url(&format!("{scheme}://{host}:{port}/resource")) + .unwrap(); + easy.proxy("").unwrap(); + tls.configure(&mut easy, false).unwrap(); + if route == "wss" { + easy.ws_connect_only(true).unwrap(); + } + } + let result = easy.perform(); + let Response { body, debug } = std::mem::take(easy.get_mut()); + drop(easy); + let server_result = server.join().expect("TLS fixture panicked"); + if let Err(error) = &server_result { + eprintln!("{case}: TLS fixture: {error}"); + } + let failure = policy.contains("untrusted") || policy == "hostname"; + if failure { + assert_eq!(result.unwrap_err().code(), 60, "{case}: {debug}"); + } else { + result.unwrap_or_else(|error| panic!("{case}: {error}: {debug}")); + server_result.unwrap(); + if route != "wss" { + assert_eq!(body, b"ok"); + } + if policy != "insecure" { + let verifier = if policy == "native" { + "Apple SecTrust" + } else { + "OpenSSL" + }; + assert!( + debug.contains(&format!("SSL certificate verified via {verifier}.")), + "{case}: {debug}" + ); + } + } +} + +fn server_config(directory: &Path, certificate: &str) -> ServerConfig { + let certificate_der = fs::read(directory.join(format!("{certificate}.der"))) + .unwrap() + .into(); + let key = + PrivatePkcs8KeyDer::from(fs::read(directory.join(format!("{certificate}.key"))).unwrap()); + ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(vec![certificate_der], key.into()) + .unwrap() +} + +fn server( + directory: &Path, + certificate: &str, + websocket: bool, +) -> (u16, thread::JoinHandle>) { + let config = server_config(directory, certificate); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + listener.set_nonblocking(true).unwrap(); + let task = thread::spawn(move || { + let start = Instant::now(); + let stream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(error) + if error.kind() == std::io::ErrorKind::WouldBlock + && start.elapsed() < DEADLINE => + { + thread::sleep(Duration::from_millis(10)) + } + Err(error) => panic!("TLS fixture accept: {error}"), + } + }; + serve_connection(stream, config, websocket) + }); + (port, task) +} + +fn serve_connection(stream: TcpStream, config: ServerConfig, websocket: bool) -> io::Result<()> { + // Darwin can inherit O_NONBLOCK from the listener. StreamOwned and the + // synchronous WebSocket handshake need blocking I/O on the accepted socket. + stream.set_nonblocking(false)?; + stream.set_read_timeout(Some(DEADLINE))?; + stream.set_write_timeout(Some(DEADLINE))?; + let mut tls = StreamOwned::new(ServerConnection::new(Arc::new(config)).unwrap(), stream); + if websocket { + let mut socket = tokio_tungstenite::tungstenite::accept(tls) + .map_err(|error| io::Error::other(error.to_string()))?; + let _ = socket.close(None); + return Ok(()); + } + let mut request = Vec::new(); + let mut byte = [0]; + while request.len() < 16 * 1024 && !request.ends_with(b"\r\n\r\n") { + tls.read_exact(&mut byte)?; + request.push(byte[0]); + } + tls.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok") +} + +#[test] +fn fixture_completes_tls_on_an_initially_nonblocking_socket() { + use rustls::{ClientConfig, ClientConnection, RootCertStore}; + + let fixtures = tempfile::tempdir().unwrap(); + let root = generate_certificates(fixtures.path(), "trusted"); + let mut roots = RootCertStore::empty(); + roots.add(root).unwrap(); + let client_config = ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let client = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + client.set_read_timeout(Some(DEADLINE)).unwrap(); + client.set_write_timeout(Some(DEADLINE)).unwrap(); + let (accepted, _) = listener.accept().unwrap(); + accepted.set_nonblocking(true).unwrap(); + let server_config = server_config(fixtures.path(), "trusted"); + let server = thread::spawn(move || serve_connection(accepted, server_config, false)); + // The fixture must wait for ClientHello rather than treating WouldBlock as + // rejection and closing the connection before the client starts TLS. + thread::sleep(Duration::from_millis(50)); + let mut tls = StreamOwned::new( + ClientConnection::new(Arc::new(client_config), "fixture.test".try_into().unwrap()).unwrap(), + client, + ); + tls.write_all(b"GET / HTTP/1.1\r\nHost: fixture.test\r\n\r\n") + .unwrap(); + let expected = b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok"; + let mut response = vec![0; expected.len()]; + tls.read_exact(&mut response).unwrap(); + assert_eq!(response, expected); + server.join().unwrap().unwrap(); +} + +#[test] +fn fixture_certificates_work_with_explicit_ca_files() { + let fixtures = tempfile::tempdir().unwrap(); + generate_certificates(fixtures.path(), "trusted"); + generate_certificates(fixtures.path(), "untrusted"); + for route in ["origin", "wss", "proxy"] { + for policy in ["ca", "ca-untrusted"] { + run_case(&format!("{route}/{policy}"), fixtures.path()); + } + } +} + +#[cfg(unix)] +#[test] +fn command_deadline_terminates_a_stalled_process() { + let error = + command_output(Command::new("sleep").arg("30"), Duration::from_millis(50)).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::TimedOut); +}