From 40917fcceead72c9a9fc07161c8ebf2cf19d63a4 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Tue, 22 Sep 2026 16:52:38 +0800 Subject: [PATCH] fix(tls): use Apple SecTrust by default on macOS Pin the curl-rust build that enables libcurl's Apple SecTrust verifier with the existing AWS-LC TLS backend. Leave default CA options unset so macOS trust comes from the operating system rather than an automatically discovered certificate file. Preserve explicit CA files and certificate environment overrides for origins and HTTPS proxies, including explicit-file precedence over SSL_CERT_DIR. Keep other platforms' existing trust configuration. Add macos-latest CI coverage for local HTTPS, WSS, and HTTPS proxy handshakes, including native-verifier diagnostics, rejected certificates and hostnames, and CA overrides. Use distinct CA and server identities, blocking accepted sockets, and bounded fixture commands. Exercise the generated chains, nonblocking socket transition, and process deadlines in portable regression tests. Validated on Linux with cargo fmt --all, workspace Clippy with all targets and features, and cargo nextest run --no-fail-fast: 18447 passed, 14 skipped. macOS-specific checks run in CI. Refs #701 --- .github/workflows/macos-tls.yml | 41 +++ Cargo.lock | 5 +- Cargo.toml | 4 +- moli-curl/Cargo.toml | 1 + moli-curl/README.md | 23 ++ moli-curl/src/tls.rs | 36 +++ moli-curl/tests/apple_sectrust.rs | 507 ++++++++++++++++++++++++++++++ 7 files changed, 613 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/macos-tls.yml create mode 100644 moli-curl/tests/apple_sectrust.rs 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); +}