diff --git a/Cargo.lock b/Cargo.lock index 7ea7ea5d..b66459bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1427,9 +1427,9 @@ dependencies = [ "http 1.4.2", "hyper 1.10.1", "hyper-util", - "rustls 0.23.42", + "rustls", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tower-service", ] @@ -2267,7 +2267,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash 2.1.3", - "rustls 0.23.42", + "rustls", "socket2 0.6.4", "thiserror 2.0.18", "tokio", @@ -2289,7 +2289,7 @@ dependencies = [ "rand_pcg", "ring", "rustc-hash 2.1.3", - "rustls 0.23.42", + "rustls", "rustls-pki-types", "slab", "thiserror 2.0.18", @@ -2489,14 +2489,14 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.42", + "rustls", "rustls-pki-types", "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tokio-util", "tower 0.5.3", "tower-http", @@ -2571,20 +2571,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "rustls" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" -dependencies = [ - "log", - "ring", - "rustls-pki-types", - "rustls-webpki 0.102.8", - "subtle", - "zeroize", -] - [[package]] name = "rustls" version = "0.23.42" @@ -2595,7 +2581,7 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.13", + "rustls-webpki", "subtle", "zeroize", ] @@ -2642,10 +2628,10 @@ dependencies = [ "jni", "log", "once_cell", - "rustls 0.23.42", + "rustls", "rustls-native-certs", "rustls-platform-verifier-android", - "rustls-webpki 0.103.13", + "rustls-webpki", "security-framework", "security-framework-sys", "webpki-root-certs", @@ -2658,17 +2644,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" -[[package]] -name = "rustls-webpki" -version = "0.102.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - [[package]] name = "rustls-webpki" version = "0.103.13" @@ -3028,7 +3003,7 @@ dependencies = [ "log", "memchr", "percent-encoding", - "rustls 0.23.42", + "rustls", "serde", "serde_json", "sha2 0.10.9", @@ -3406,24 +3381,13 @@ dependencies = [ "syn", ] -[[package]] -name = "tokio-rustls" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" -dependencies = [ - "rustls 0.22.4", - "rustls-pki-types", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.42", + "rustls", "tokio", ] @@ -3806,13 +3770,11 @@ dependencies = [ "multer", "percent-encoding", "pin-project", - "rustls-pemfile", "scoped-tls", "serde", "serde_json", "serde_urlencoded", "tokio", - "tokio-rustls 0.25.0", "tokio-tungstenite 0.21.0", "tokio-util", "tower-service", @@ -3958,7 +3920,7 @@ dependencies = [ "rcgen", "regex", "reqwest", - "rustls 0.23.42", + "rustls", "rustls-pemfile", "rustyline", "scrypt", @@ -3970,6 +3932,7 @@ dependencies = [ "tempfile", "time", "tokio", + "tokio-rustls", "tokio-tungstenite 0.30.0", "uuid", "warp", diff --git a/Cargo.toml b/Cargo.toml index cf6116cf..5df48f7a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,10 +61,11 @@ encoding_rs = "0.8.35" # and TLS backend; `tls-rustls` aliases the ring-backed rustls stack we used before. sqlx = { version = "0.9.0", features = ["runtime-tokio", "tls-rustls", "sqlite", "mysql", "postgres", "chrono"] } serde_json = "1.0.150" -# Held at 0.3.x: warp 0.4 dropped the `tls` feature (its TLS code is gated behind -# an undeclared `tls` feature), which would remove HTTPS support from WFL's web -# server (`secured with certificate ... and key ...`) โ€” a backward-compat break. -warp = { version = "0.3.7", features = ["tls"] } +# Keep Warp 0.3.x for its stable routing/filter API, but do not enable its legacy +# TLS adapter: that adapter pins rustls 0.22. WFL serves the same filters through +# Tokio-Rustls 0.26 below, preserving secured-listener behavior on rustls 0.23. +warp = "0.3.7" +tokio-rustls = { version = "0.26.4", default-features = false, features = ["ring", "tls12"] } rustls-pemfile = "2" uuid = { version = "1.23.5", features = ["v4"] } bytes = "1.12.1" @@ -94,7 +95,7 @@ num-bigint-dig = "0.8.6" # into the same rustls 0.23, and rustls refuses to pick a default when both are # present; installing one up front keeps any ambient-default TLS path from # panicking. `ring` matches sqlx. -rustls = { version = "0.23", default-features = false, features = ["ring"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } hyper = "1" [features] diff --git a/Engineering/plans/2026-07-31-dependabot-remediation.md b/Engineering/plans/2026-07-31-dependabot-remediation.md new file mode 100644 index 00000000..61b291c7 --- /dev/null +++ b/Engineering/plans/2026-07-31-dependabot-remediation.md @@ -0,0 +1,137 @@ +# Dependabot Alert Remediation Plan + +> **Date:** 2026-07-31 +> **Owner:** WFL maintainers +> **Branch:** `warden/dependabot-remediation` +> **Base:** `438780ae038608783c54fdc661273f040c4c58dd` +> **Risk:** **R3** โ€” the Rust remediation replaces TLS transport plumbing and +> therefore touches a security boundary, protocol negotiation, concurrency, and +> server lifecycle. The npm lockfile refresh is dependency maintenance. + +## Goal + +Resolve all ten Dependabot alerts open on 2026-07-31 without changing WFL +language syntax or the public behavior of plain HTTP, secured HTTP, redirects, +WebSockets, request metadata, or `close server`. + +The open-alert baseline is: + +- `Cargo.lock`: four `rustls-webpki` advisories through + `warp 0.3.7 -> tokio-rustls 0.25 -> rustls 0.22 -> rustls-webpki 0.102.8`. +- `fuzz/Cargo.lock`: the same four advisories through the path dependency on + WFL. +- `vscode-extension/package-lock.json`: `js-yaml 4.2.0` and the + `brace-expansion 5.0.7` copy nested below `minimatch 9.0.7`. + +## Compatibility constraints + +- Keep Warp 0.3.7 for the existing routing/filter API; do not mix a Warp + migration into this security change. +- Remove only Warp's legacy `tls` feature and serve the same Warp filters over + Tokio-Rustls 0.26 / Rustls 0.23. +- Preserve TLS 1.2 and TLS 1.3, HTTP/2 and HTTP/1.1 ALPN, peer IP reporting, + port-zero address reporting, actionable certificate/key errors, handshake + isolation, and server shutdown semantics. +- Keep plain HTTP, redirect, and standalone WebSocket listeners on their + current Warp server path. +- Preserve existing user-owned staged worktree entries; stage and commit only + files named by this plan. + +## Evidence model + +Most of this work is behavior-preserving dependency maintenance under +`testing.md` ยง6.3, so artificial behavioral failures are not appropriate. The +new characterization suite found one genuine pre-existing failure: Warp's +Rustls 0.22 configuration accepted a certificate paired with an unrelated +private key when the listener started. The replacement must reject that invalid +pair before binding, so that criterion follows full R3 Red โ†’ Green chronology. +Independent review then found a second genuine failure in the first replacement +draft: aborting the outer Hyper server left accepted connection tasks alive. +The lifecycle test was committed while Red before tracked cancellation was +added. The other TLS characterizations establish a passing pre-change baseline. +The security acceptance criteria also have an observable failing baseline: the +ten live alerts and both lockfiles' inverse dependency path to +`rustls-webpki 0.102.8`. + +After the change, run the same suite and prove the mismatched pair is rejected +and the vulnerable package is absent from both lockfiles. Retain command output +and commit IDs in the pull request. An independent agent that did not author the +implementation must review the final diff and evidence. + +## Tasks + +### 1. Characterize the secured-listener contract + +Add real-socket integration coverage in `tests/web_server_tls_test.rs` for: + +- occupied-port errors returned synchronously and with useful context; +- a stalled or invalid TLS client not blocking a subsequent valid client; +- request `client_ip` surviving the custom TLS transport; +- HTTP/2 ALPN plus HTTP/1.1 fallback; +- malformed key and certificate/key mismatch errors; +- `close server` cancelling an established idle connection and a ClientHello- + stalled connection, followed by immediate address reuse; +- a port-zero secured listener reporting its actual ephemeral address. + +Run the complete existing TLS integration test target on the base and record +the passing result before production changes. + +### 2. Remove the legacy Rustls dependency chain + +- Change `Cargo.toml` so Warp no longer enables its `tls` feature. +- Add Tokio-Rustls 0.26 with the ring provider and TLS 1.2 enabled; reuse the + existing direct Rustls 0.23 and Rustls-PEMFile 2 dependencies. +- Implement the secured listener with an explicit ring-backed + `rustls::ServerConfig`. +- Accept handshakes concurrently and serve the unchanged Warp filter through + Warp's matching Hyper 0.14 re-export. +- Track Hyper's executor-spawned connection tasks and cancel them when the WFL + server task is aborted or otherwise ends. +- Inject the accepted peer `SocketAddr` into request extensions and make the + shared request filter fall back to that extension only for this custom path. +- Keep Hyper's server lifecycle under the existing WFL task handle so aborting + it stops accepts, then cancel every tracked accepted-connection task after + WFL's existing 50 ms response-flush allowance. +- Return certificate, key, key-mismatch, and bind failures as WFL runtime + errors rather than panics. + +Regenerate both `Cargo.lock` and `fuzz/Cargo.lock`. Prove that neither contains +`rustls-webpki 0.102.8`, `rustls 0.22`, or `tokio-rustls 0.25`. + +### 3. Remediate the extension lockfile + +- Raise the existing `js-yaml` override to 4.3.0. +- Regenerate `vscode-extension/package-lock.json` with npm rather than editing + integrity metadata manually. +- Allow the semver-compatible nested `brace-expansion` update to 5.0.8. +- Confirm the lockfile's product version agrees with root `Cargo.toml`. +- Run `npm ci`, compile, lint, and the extension tests. + +### 4. Broaden and record + +Run: + +```text +cargo fmt --all -- --check +cargo clippy --all-targets --all-features -- -D warnings +cargo test --all +cargo build --release +scripts/run_integration_tests.ps1 +scripts/run_web_tests.ps1 +python scripts/validate_docs_examples.py +python scripts/check_repo_hygiene.py --mode static +``` + +Also compile the fuzz workspace and run dependency-tree checks for both Rust +lockfiles. Document the implementation and evidence in a dated Dev Diary entry. + +### 5. Publish without merging + +- Commit the passing characterization baseline separately from the production + remediation. +- Request independent R3 review and address actionable findings. +- Push `warden/dependabot-remediation`, open a normal pull request, and do not + merge it. +- Re-query Dependabot after GitHub processes the pushed manifests. If alert + rescanning is still pending, report that explicitly rather than claiming the + dashboard is already clear. diff --git a/History/dev-diary/2026/2026-07-31-dependabot-tls-remediation.md b/History/dev-diary/2026/2026-07-31-dependabot-tls-remediation.md new file mode 100644 index 00000000..16f12fe6 --- /dev/null +++ b/History/dev-diary/2026/2026-07-31-dependabot-tls-remediation.md @@ -0,0 +1,102 @@ +# Dependabot remediation: secured listeners on Rustls 0.23 + +**Date:** 2026-07-31 +**Risk class:** R3 (TLS boundary, protocol negotiation, concurrency, lifecycle) + +## Why this changed + +WFL's secured web listener still used Warp 0.3.7's optional TLS adapter. That +adapter pinned Tokio-Rustls 0.25, Rustls 0.22, and Rustls-WebPKI 0.102.8 even +though the rest of WFL already used the patched Rustls 0.23 / +Rustls-WebPKI 0.103 line. The duplicate legacy chain produced four Dependabot +alerts in the root lockfile and the same four alerts in the fuzz lockfile. + +Warp remains at 0.3.7 for its routing/filter API. Only its `tls` feature was +removed. WFL now supplies the small transport adapter that serves the unchanged +Warp filters through Tokio-Rustls 0.26 and Rustls 0.23. + +## Compatibility decisions + +- TLS configuration uses an explicit ring crypto provider, so embedders and + tests do not depend on a process-global provider having been installed first. +- TLS 1.2 and TLS 1.3 remain enabled. +- ALPN still prefers HTTP/2 and falls back to HTTP/1.1. +- A TLS handshake is driven by Hyper's per-connection task rather than the + listener accept loop. A silent or malformed client therefore cannot block + unrelated connections. +- Hyper's executor-spawned connection tasks are tracked without retaining + completed tasks. Dropping or aborting WFL's existing server task cancels all + accepted connections, including an established idle connection and a client + stalled before its ClientHello. +- The accepted peer address is copied into the Hyper request extensions before + the Warp filter runs. The shared request filter prefers Warp's normal remote + address and falls back to that extension on the secured path, preserving + `client_ip`. +- Certificate and key files are loaded once before binding. Rustls 0.23 also + verifies that the private key matches the end-entity certificate, producing + an actionable runtime error instead of starting a listener with an invalid + pair. + +Plain HTTP, redirect listeners, standalone WebSocket listeners, language syntax, +and configuration precedence were not changed. + +The migration does not add a separate application-level cap for pre-HTTP TLS +connections. The tracker retains only currently active Hyper tasks and remains +bounded by the operating system's accepted-connection resources; adding a +configurable handshake limit or timeout is a separate hardening change. + +## Other alert fixes + +The VS Code extension lockfile was regenerated with: + +- `js-yaml` overridden to 4.3.0 for GHSA-52cp-r559-cp3m; +- the affected nested `brace-expansion` copy updated past 5.0.7 for + GHSA-mh99-v99m-4gvg; +- the lockfile's product version synchronized to the current repository version. + +## Test evidence + +The test-first revision added real-socket coverage for occupied ports, a stalled +TLS handshake followed by healthy clients, TLS 1.2 and 1.3, HTTP/2 ALPN and +HTTP/1.1 fallback, peer-address propagation, malformed keys, certificate/key +mismatch, and listener shutdown. + +On the pre-change revision, eleven TLS tests passed and the new mismatch test +failed because the invalid pair was accepted. The first transport draft made +all twelve pass. Independent R3 review then required stronger lifecycle and +port-zero evidence. The lifecycle test failed Red because an established TLS +connection remained open after `close server`; after tracked task cancellation, +all thirteen tests passed, including a ClientHello-stalled connection, immediate +address reuse, and actual ephemeral-port reporting. Both root and fuzz +dependency trees now contain only Rustls-WebPKI 0.103.13; Cargo cannot resolve +the removed 0.102.8 package from either lockfile. + +The final independent review also exercised a tracked task that panics. Before +the RAII registration guard, the active-task count remained at one after +unwinding; afterward it returns to zero on normal completion, cancellation, and +panic. The reviewer approved the resulting race handling and lifecycle evidence +with no remaining actionable findings. + +The broader Green verification was: + +- `cargo fmt --all -- --check`; +- `cargo clippy --all-targets --all-features -- -D warnings`; +- `cargo test --all -j 2` (the unrestricted parallel compile exceeded this + Windows host's paging-file limit; two jobs completed the same suite); +- `cargo build --release -j 2`; +- `cargo check --manifest-path fuzz/Cargo.toml --all-targets`; +- 130 ordinary `TestPrograms` cases with the integration runner's timeout, + expected-failure, `--test`, exclusion, and `CI-SKIP` rules; +- `scripts/run_web_tests.ps1` (2/2; its optional OpenSSL-generated TLS fixture + was skipped because OpenSSL is unavailable, while the Rust TLS target ran + all 13 real-socket cases); +- `python scripts/validate_docs_examples.py` (19/19); +- `npm ci --ignore-scripts` and `npm run compile`. + +The extension's existing `npm run lint` command cannot find an ESLint +configuration or a matching `src` target, so its `pretest` stops before the +test runner. Invoking the test runner directly produced 24 passing, 2 pending, +and one pre-existing Windows line-ending assertion failure. Static repository +hygiene also reports the user-owned staged `.worktrees` entries and archive +hash drift caused by this checkout's CRLF-normalized files; none of those paths +is part of this remediation. diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 42217080..a52c6aad 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -976,9 +976,9 @@ dependencies = [ "http 1.4.2", "hyper 1.10.1", "hyper-util", - "rustls 0.23.42", + "rustls", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tower-service", ] @@ -1633,7 +1633,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls 0.23.42", + "rustls", "socket2 0.6.5", "thiserror 2.0.18", "tokio", @@ -1655,7 +1655,7 @@ dependencies = [ "rand_pcg", "ring", "rustc-hash", - "rustls 0.23.42", + "rustls", "rustls-pki-types", "slab", "thiserror 2.0.18", @@ -1821,14 +1821,14 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.42", + "rustls", "rustls-pki-types", "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -1869,20 +1869,6 @@ dependencies = [ "semver", ] -[[package]] -name = "rustls" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" -dependencies = [ - "log", - "ring", - "rustls-pki-types", - "rustls-webpki 0.102.8", - "subtle", - "zeroize", -] - [[package]] name = "rustls" version = "0.23.42" @@ -1893,7 +1879,7 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.13", + "rustls-webpki", "subtle", "zeroize", ] @@ -1940,10 +1926,10 @@ dependencies = [ "jni", "log", "once_cell", - "rustls 0.23.42", + "rustls", "rustls-native-certs", "rustls-platform-verifier-android", - "rustls-webpki 0.103.13", + "rustls-webpki", "security-framework", "security-framework-sys", "webpki-root-certs", @@ -1956,17 +1942,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" -[[package]] -name = "rustls-webpki" -version = "0.102.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - [[package]] name = "rustls-webpki" version = "0.103.13" @@ -2315,7 +2290,7 @@ dependencies = [ "log", "memchr", "percent-encoding", - "rustls 0.23.42", + "rustls", "serde", "serde_json", "sha2 0.10.9", @@ -2664,24 +2639,13 @@ dependencies = [ "syn", ] -[[package]] -name = "tokio-rustls" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" -dependencies = [ - "rustls 0.22.4", - "rustls-pki-types", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.42", + "rustls", "tokio", ] @@ -2970,13 +2934,11 @@ dependencies = [ "multer", "percent-encoding", "pin-project", - "rustls-pemfile", "scoped-tls", "serde", "serde_json", "serde_urlencoded", "tokio", - "tokio-rustls 0.25.0", "tokio-tungstenite", "tokio-util", "tower-service", @@ -3118,7 +3080,7 @@ dependencies = [ "rand 0.10.2", "regex", "reqwest", - "rustls 0.23.42", + "rustls", "rustls-pemfile", "rustyline", "scrypt", @@ -3129,6 +3091,7 @@ dependencies = [ "subtle", "time", "tokio", + "tokio-rustls", "uuid", "warp", "zeroize", diff --git a/src/interpreter/mod.rs b/src/interpreter/mod.rs index 0113486b..eb14475a 100644 --- a/src/interpreter/mod.rs +++ b/src/interpreter/mod.rs @@ -15,6 +15,7 @@ mod op_refactor_error_tests; mod op_refactor_tests; #[cfg(test)] mod tests; +mod tls; pub mod value; use self::control_flow::ControlFlow; @@ -761,48 +762,22 @@ fn strip_host_port(host: &str) -> &str { } } -/// Validates TLS certificate/key files before handing them to warp, which -/// would otherwise panic inside the spawned server task on a bad file. Returns -/// an actionable message naming the offending file. -fn validate_tls_pem_files(cert_path: &str, key_path: &str) -> Result<(), String> { - let cert_file = std::fs::File::open(cert_path).map_err(|e| { - format!( - "Cannot open TLS certificate file '{cert_path}': {e}. For local development you can create a self-signed certificate with: openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem -days 365 -subj \"/CN=localhost\"" - ) - })?; - let cert_items: Vec = - rustls_pemfile::read_all(&mut std::io::BufReader::new(cert_file)) - .collect::>() - .map_err(|e| format!("TLS certificate file '{cert_path}' is not valid PEM: {e}"))?; - if !cert_items - .iter() - .any(|item| matches!(item, rustls_pemfile::Item::X509Certificate(_))) - { - return Err(format!( - "TLS certificate file '{cert_path}' contains no certificates. Expected at least one PEM 'CERTIFICATE' block" - )); - } - - let key_file = std::fs::File::open(key_path) - .map_err(|e| format!("Cannot open TLS private key file '{key_path}': {e}"))?; - let key_items: Vec = - rustls_pemfile::read_all(&mut std::io::BufReader::new(key_file)) - .collect::>() - .map_err(|e| format!("TLS private key file '{key_path}' is not valid PEM: {e}"))?; - if !key_items.iter().any(|item| { - matches!( - item, - rustls_pemfile::Item::Pkcs1Key(_) - | rustls_pemfile::Item::Pkcs8Key(_) - | rustls_pemfile::Item::Sec1Key(_) +/// Resolves a request's accepted peer address on both server transports. +/// +/// Warp's normal server path stores the address in its private route state. +/// The custom Rustls/Hyper path cannot call Warp's private `call_with_addr`, +/// so it places the same address in the request extensions instead. +fn request_remote_addr() +-> impl warp::Filter,), Error = std::convert::Infallible> + Clone +{ + warp::addr::remote() + .and(warp::ext::optional::()) + .map( + |warp_addr: Option, + extension_addr: Option| { + warp_addr.or(extension_addr) + }, ) - }) { - return Err(format!( - "TLS private key file '{key_path}' contains no private key. Expected a PEM 'PRIVATE KEY', 'RSA PRIVATE KEY', or 'EC PRIVATE KEY' block" - )); - } - - Ok(()) } /// RAII guard for the interpreter's live recursion depth. Increments on @@ -9800,7 +9775,7 @@ impl Interpreter { }) }) .and(warp::body::stream()) - .and(warp::addr::remote()) + .and(request_remote_addr()) .and_then( move |method: warp::http::Method, path: warp::path::FullPath, @@ -10222,28 +10197,57 @@ impl Interpreter { }, }; - // Validate up front for actionable errors; warp would - // otherwise surface a bad certificate as a generic - // bind-time failure. - if let Err(msg) = validate_tls_pem_files(&cert_path, &key_path) { - return Err(RuntimeError::new(msg, *line, *column)); - } + let tls_config = match tls::load_server_config(&cert_path, &key_path) { + Ok(config) => config, + Err(message) => { + return Err(RuntimeError::new(message, *line, *column)); + } + }; - // try_bind_with_graceful_shutdown is the only TlsServer - // constructor that returns bind/TLS errors instead of - // panicking inside the spawned task; the never-completing - // signal keeps the server running until `close server` - // aborts its task. - match warp::serve(routes) - .tls() - .cert_path(&cert_path) - .key_path(&key_path) - .try_bind_with_graceful_shutdown( - (bind_addr, port_num), - std::future::pending::<()>(), - ) { - Ok((addr, server)) => { - let server_handle = tokio::spawn(server); + // Keep Warp's routing/filter contract while using the + // patched Rustls stack. Hyper owns the listener and + // connection lifecycle just as it did under Warp's legacy + // TLS adapter; each accepted stream drives its handshake + // independently, so a silent client cannot block accepts. + match tls::SecuredIncoming::bind( + std::net::SocketAddr::new(bind_addr, port_num), + tls_config, + ) { + Ok((addr, incoming)) => { + let filter_service = warp::service(routes); + let make_service = warp::hyper::service::make_service_fn( + move |connection: &tls::SecuredStream| { + let filter_service = filter_service.clone(); + let remote_addr = connection.remote_addr(); + async move { + Ok::<_, std::convert::Infallible>( + warp::hyper::service::service_fn( + move |mut request: warp::hyper::Request< + warp::hyper::Body, + >| { + request.extensions_mut().insert(remote_addr); + let mut service = filter_service.clone(); + warp::hyper::service::Service::call( + &mut service, + request, + ) + }, + ), + ) + } + }, + ); + let tracked_connections = tls::TrackedConnections::new(); + let server = warp::hyper::Server::builder(incoming) + .executor(tracked_connections.executor()) + .serve(make_service) + .with_graceful_shutdown(std::future::pending::<()>()); + let server_handle = tokio::spawn(async move { + let _cancel_connections = tracked_connections.cancel_on_drop(); + if let Err(error) = server.await { + log::error!("Secure web server stopped: {error}"); + } + }); let wfl_server = WflWebServer { request_receiver: request_receiver.clone(), diff --git a/src/interpreter/tls.rs b/src/interpreter/tls.rs new file mode 100644 index 00000000..31b41bbf --- /dev/null +++ b/src/interpreter/tls.rs @@ -0,0 +1,366 @@ +use std::collections::HashMap; +use std::fmt; +use std::fs::File; +use std::future::Future; +use std::io::{self, BufReader}; +use std::net::SocketAddr; +use std::pin::Pin; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll, ready}; + +use futures_util::future::{AbortHandle, Abortable}; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; +use tokio_rustls::server::TlsStream as EstablishedTlsStream; +use tokio_rustls::{Accept as PendingTlsHandshake, TlsAcceptor}; +use warp::hyper::rt::Executor; +use warp::hyper::server::accept::Accept; +use warp::hyper::server::conn::{AddrIncoming, AddrStream}; + +enum SecuredStreamState { + Handshaking(PendingTlsHandshake), + Established(EstablishedTlsStream), +} + +/// A TCP connection whose TLS handshake is driven by Hyper's per-connection +/// task. Keeping the handshake out of `poll_accept` prevents an incomplete +/// ClientHello from blocking unrelated clients. +pub(super) struct SecuredStream { + state: SecuredStreamState, + remote_addr: SocketAddr, +} + +impl SecuredStream { + fn new(stream: AddrStream, config: Arc) -> Self { + let remote_addr = stream.remote_addr(); + let handshake = TlsAcceptor::from(config).accept(stream); + Self { + state: SecuredStreamState::Handshaking(handshake), + remote_addr, + } + } + + pub(super) fn remote_addr(&self) -> SocketAddr { + self.remote_addr + } +} + +impl AsyncRead for SecuredStream { + fn poll_read( + self: Pin<&mut Self>, + context: &mut Context<'_>, + buffer: &mut ReadBuf<'_>, + ) -> Poll> { + let this = self.get_mut(); + loop { + match &mut this.state { + SecuredStreamState::Handshaking(handshake) => { + let stream = ready!(Pin::new(handshake).poll(context))?; + this.state = SecuredStreamState::Established(stream); + } + SecuredStreamState::Established(stream) => { + return Pin::new(stream).poll_read(context, buffer); + } + } + } + } +} + +impl AsyncWrite for SecuredStream { + fn poll_write( + self: Pin<&mut Self>, + context: &mut Context<'_>, + bytes: &[u8], + ) -> Poll> { + let this = self.get_mut(); + loop { + match &mut this.state { + SecuredStreamState::Handshaking(handshake) => { + let stream = ready!(Pin::new(handshake).poll(context))?; + this.state = SecuredStreamState::Established(stream); + } + SecuredStreamState::Established(stream) => { + return Pin::new(stream).poll_write(context, bytes); + } + } + } + } + + fn poll_flush(self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll> { + match &mut self.get_mut().state { + SecuredStreamState::Handshaking(_) => Poll::Ready(Ok(())), + SecuredStreamState::Established(stream) => Pin::new(stream).poll_flush(context), + } + } + + fn poll_shutdown(self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll> { + match &mut self.get_mut().state { + SecuredStreamState::Handshaking(_) => Poll::Ready(Ok(())), + SecuredStreamState::Established(stream) => Pin::new(stream).poll_shutdown(context), + } + } +} + +struct TrackedConnectionInner { + cancelled: AtomicBool, + next_id: AtomicU64, + tasks: Mutex>, +} + +/// Tracks every connection task that Hyper launches for a secured listener. +/// +/// Hyper normally detaches these tasks from its outer `Server` future. WFL +/// controls a listener through that outer task, so retaining abort handles is +/// necessary for `close server` and interpreter teardown to cancel accepted +/// connections, including clients parked in a partial TLS handshake. +#[derive(Clone)] +pub(super) struct TrackedConnections { + inner: Arc, +} + +impl TrackedConnections { + pub(super) fn new() -> Self { + Self { + inner: Arc::new(TrackedConnectionInner { + cancelled: AtomicBool::new(false), + next_id: AtomicU64::new(0), + tasks: Mutex::new(HashMap::new()), + }), + } + } + + pub(super) fn executor(&self) -> TrackedConnectionExecutor { + TrackedConnectionExecutor { + connections: self.clone(), + } + } + + pub(super) fn cancel_on_drop(self) -> CancelConnectionsOnDrop { + CancelConnectionsOnDrop(self) + } + + fn cancel_all(&self) { + self.inner.cancelled.store(true, Ordering::Release); + let handles = { + let mut tasks = self + .inner + .tasks + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + tasks.drain().map(|(_, handle)| handle).collect::>() + }; + for handle in handles { + handle.abort(); + } + } + + #[cfg(test)] + fn active_task_count(&self) -> usize { + self.inner + .tasks + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .len() + } +} + +impl fmt::Debug for TrackedConnections { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let active_tasks = self + .inner + .tasks + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .len(); + formatter + .debug_struct("TrackedConnections") + .field("cancelled", &self.inner.cancelled.load(Ordering::Acquire)) + .field("active_tasks", &active_tasks) + .finish() + } +} + +pub(super) struct CancelConnectionsOnDrop(TrackedConnections); + +impl Drop for CancelConnectionsOnDrop { + fn drop(&mut self) { + self.0.cancel_all(); + } +} + +#[derive(Clone, Debug)] +pub(super) struct TrackedConnectionExecutor { + connections: TrackedConnections, +} + +struct TrackedTaskRegistration { + id: u64, + inner: Arc, +} + +impl Drop for TrackedTaskRegistration { + fn drop(&mut self) { + self.inner + .tasks + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .remove(&self.id); + } +} + +impl Executor for TrackedConnectionExecutor +where + F: Future + Send + 'static, +{ + fn execute(&self, future: F) { + if self.connections.inner.cancelled.load(Ordering::Acquire) { + return; + } + + let id = self + .connections + .inner + .next_id + .fetch_add(1, Ordering::Relaxed); + let (abort_handle, abort_registration) = AbortHandle::new_pair(); + { + let mut tasks = self + .connections + .inner + .tasks + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + // Recheck while holding the task-map lock so `cancel_all` cannot + // drain the map between this check and insertion. + if self.connections.inner.cancelled.load(Ordering::Acquire) { + return; + } + tasks.insert(id, abort_handle); + } + + let inner = Arc::clone(&self.connections.inner); + tokio::spawn(async move { + let _registration = TrackedTaskRegistration { id, inner }; + let _ = Abortable::new(future, abort_registration).await; + }); + } +} + +/// Hyper incoming transport backed by the patched Rustls stack. +pub(super) struct SecuredIncoming { + config: Arc, + incoming: AddrIncoming, +} + +impl SecuredIncoming { + pub(super) fn bind( + addr: SocketAddr, + config: rustls::ServerConfig, + ) -> Result<(SocketAddr, Self), warp::hyper::Error> { + let mut incoming = AddrIncoming::bind(&addr)?; + incoming.set_nodelay(true); + let bound_addr = incoming.local_addr(); + Ok(( + bound_addr, + Self { + config: Arc::new(config), + incoming, + }, + )) + } +} + +impl Accept for SecuredIncoming { + type Conn = SecuredStream; + type Error = io::Error; + + fn poll_accept( + self: Pin<&mut Self>, + context: &mut Context<'_>, + ) -> Poll>> { + let this = self.get_mut(); + match ready!(Pin::new(&mut this.incoming).poll_accept(context)) { + Some(Ok(stream)) => Poll::Ready(Some(Ok(SecuredStream::new( + stream, + Arc::clone(&this.config), + )))), + Some(Err(error)) => Poll::Ready(Some(Err(error))), + None => Poll::Ready(None), + } + } +} + +pub(super) fn load_server_config( + cert_path: &str, + key_path: &str, +) -> Result { + let cert_file = File::open(cert_path).map_err(|error| { + format!( + "Cannot open TLS certificate file '{cert_path}': {error}. For local development you can create a self-signed certificate with: openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem -days 365 -subj \"/CN=localhost\"" + ) + })?; + let certs = rustls_pemfile::certs(&mut BufReader::new(cert_file)) + .collect::, _>>() + .map_err(|error| format!("TLS certificate file '{cert_path}' is not valid PEM: {error}"))?; + if certs.is_empty() { + return Err(format!( + "TLS certificate file '{cert_path}' contains no certificates. Expected at least one PEM 'CERTIFICATE' block" + )); + } + + let key_file = File::open(key_path) + .map_err(|error| format!("Cannot open TLS private key file '{key_path}': {error}"))?; + let key = rustls_pemfile::private_key(&mut BufReader::new(key_file)) + .map_err(|error| { + format!("TLS private key file '{key_path}' is not valid PEM: {error}") + })? + .ok_or_else(|| { + format!( + "TLS private key file '{key_path}' contains no private key. Expected a PEM 'PRIVATE KEY', 'RSA PRIVATE KEY', or 'EC PRIVATE KEY' block" + ) + })?; + + let provider = Arc::new(rustls::crypto::ring::default_provider()); + let builder = rustls::ServerConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions() + .map_err(|error| format!("Failed to configure safe TLS protocol versions: {error}"))?; + let mut config = builder + .with_no_client_auth() + .with_single_cert(certs, key) + .map_err(|error| { + format!( + "TLS certificate '{cert_path}' and private key '{key_path}' are not a valid pair: {error}" + ) + })?; + + // Match Warp's prior TLS behavior and ordering: prefer HTTP/2, with a + // standards-compatible HTTP/1.1 fallback. + config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; + Ok(config) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn panicking_tracked_task_is_unregistered() { + let connections = TrackedConnections::new(); + connections.executor().execute(async { + panic!("intentional tracked-task panic"); + }); + + for _ in 0..100 { + if connections.active_task_count() == 0 { + return; + } + tokio::task::yield_now().await; + } + + assert_eq!( + connections.active_task_count(), + 0, + "A panicking task must not leave a dead abort handle registered" + ); + } +} diff --git a/tests/web_server_tls_test.rs b/tests/web_server_tls_test.rs index db4a44d9..09e33957 100644 --- a/tests/web_server_tls_test.rs +++ b/tests/web_server_tls_test.rs @@ -5,13 +5,17 @@ // // Self-signed certificates are generated per test with rcgen (localhost + // 127.0.0.1 SANs); reqwest clients accept them via -// danger_accept_invalid_certs. Ports 8210-8219 (the bind-address tests use -// 8200-8203). +// danger_accept_invalid_certs. Ports 8210-8224 (the bind-address tests use +// 8200-8203); the ephemeral-address test uses port 0. +use reqwest::tls::Version as TlsVersion; +use std::net::{IpAddr, Ipv4Addr}; use std::sync::Arc; use std::time::Duration; +use tokio::io::{AsyncRead, AsyncReadExt}; use wfl::Interpreter; use wfl::config::WflConfig; +use wfl::interpreter::value::Value; use wfl::lexer::lex_wfl_with_positions; use wfl::parser::Parser; @@ -39,7 +43,10 @@ fn write_self_signed_cert(dir: &std::path::Path) -> (String, String) { } /// Runs a WFL program in its own thread + runtime, like the bind-address tests. -fn start_server_with_config(code: String, config: WflConfig) -> std::thread::JoinHandle<()> { +fn start_server_with_config( + code: String, + config: WflConfig, +) -> std::thread::JoinHandle> { std::thread::spawn(move || { let rt = tokio::runtime::Runtime::new().expect("Failed to create runtime"); rt.block_on(async { @@ -47,11 +54,22 @@ fn start_server_with_config(code: String, config: WflConfig) -> std::thread::Joi let mut parser = Parser::new(&tokens); let ast = parser.parse().expect("Failed to parse WFL code"); let mut interpreter = Interpreter::with_config(Arc::new(config)); - let _ = interpreter.interpret(&ast).await; - }); + interpreter + .interpret(&ast) + .await + .map(|_| ()) + .map_err(|errors| format!("{errors:?}")) + }) }) } +fn assert_server_program_completed(handle: std::thread::JoinHandle>) { + handle + .join() + .expect("TLS server program panicked") + .expect("TLS server program returned runtime errors"); +} + fn insecure_client() -> reqwest::Client { reqwest::Client::builder() .danger_accept_invalid_certs(true) @@ -88,7 +106,7 @@ async fn test_https_server_serves_requests() { assert_eq!(response.status(), 200); assert_eq!(response.text().await.unwrap(), "Hello over HTTPS"); - let _ = server_handle.join(); + assert_server_program_completed(server_handle); } #[tokio::test] @@ -100,8 +118,7 @@ async fn test_plain_http_to_tls_port_fails() { let server_code = format!( r#" listen on port {port} secured with certificate "{cert_path}" and key "{key_path}" as secure_server - wait for request comes in on secure_server as req with timeout 3000 - respond to req with "unreachable" + wait for 2500 milliseconds close server secure_server "# ); @@ -120,7 +137,7 @@ async fn test_plain_http_to_tls_port_fails() { "Plain HTTP request to a TLS port should fail" ); - let _ = server_handle.join(); + assert_server_program_completed(server_handle); } #[tokio::test] @@ -129,12 +146,12 @@ async fn test_redirect_server_returns_301_with_location() { let https_port = 8213; // The redirect is answered natively by the server, so the program never - // sees a request; the timed-out wait just keeps the server alive long - // enough for the test to hit it. + // sees a request; a bounded delay keeps the server alive for the client + // without manufacturing a runtime timeout error. let server_code = format!( r#" listen on port {http_port} redirecting to port {https_port} as redirect_server - wait for request comes in on redirect_server as req with timeout 4000 + wait for 2500 milliseconds close server redirect_server "# ); @@ -162,7 +179,7 @@ async fn test_redirect_server_returns_301_with_location() { "Location should preserve host, path and query, swapping scheme and port" ); - let _ = server_handle.join(); + assert_server_program_completed(server_handle); } #[tokio::test] @@ -196,7 +213,7 @@ async fn test_bare_secured_uses_config_paths() { .expect("HTTPS request using config-supplied cert should succeed"); assert_eq!(response.text().await.unwrap(), "Config-driven TLS"); - let _ = server_handle.join(); + assert_server_program_completed(server_handle); } #[tokio::test] @@ -296,5 +313,313 @@ async fn test_dual_http_and_https_servers() { .expect("HTTPS server should answer"); assert_eq!(https_response.text().await.unwrap(), "HTTPS OK"); - let _ = server_handle.join(); + assert_server_program_completed(server_handle); +} + +#[tokio::test] +async fn test_tls_listener_reports_occupied_port_without_panicking() { + let port = 8220; + let occupied = std::net::TcpListener::bind(("127.0.0.1", port)) + .expect("Failed to reserve the TLS test port"); + let temp_dir = tempfile::tempdir().unwrap(); + let (cert_path, key_path) = write_self_signed_cert(temp_dir.path()); + let code = format!( + r#"listen on port {port} secured with certificate "{cert_path}" and key "{key_path}" as secure_server"# + ); + let tokens = lex_wfl_with_positions(&code); + let mut parser = Parser::new(&tokens); + let ast = parser.parse().expect("Failed to parse"); + + let mut interpreter = Interpreter::with_config(Arc::new(WflConfig::default())); + let result = interpreter.interpret(&ast).await; + + drop(occupied); + let errors = result.expect_err("An occupied TLS port should be a runtime error"); + let message = format!("{errors:?}"); + assert!( + message.contains("Failed to start secure web server") + && message.contains(&port.to_string()), + "Error should identify the secure listener and occupied port, got: {message}" + ); +} + +#[tokio::test] +async fn test_stalled_tls_handshake_does_not_block_protocol_matrix_or_peer_ip() { + let port = 8221; + let temp_dir = tempfile::tempdir().unwrap(); + let (cert_path, key_path) = write_self_signed_cert(temp_dir.path()); + let server_code = format!( + r#" + listen on port {port} secured with certificate "{cert_path}" and key "{key_path}" as secure_server + wait for request comes in on secure_server as req1 with timeout 5000 + respond to req1 with client_ip of req1 + wait for request comes in on secure_server as req2 with timeout 5000 + respond to req2 with "http1" + wait for request comes in on secure_server as req3 with timeout 5000 + respond to req3 with "tls12" + wait for request comes in on secure_server as req4 with timeout 5000 + respond to req4 with "tls13" + close server secure_server + "# + ); + + let server_handle = start_server_with_config(server_code, WflConfig::default()); + tokio::time::sleep(Duration::from_millis(500)).await; + + // Leave one TCP client connected without sending a TLS ClientHello. A + // serial handshake in the accept loop would prevent every valid request + // below from reaching the WFL request queue. + let stalled_client = tokio::net::TcpStream::connect(("127.0.0.1", port)) + .await + .expect("Failed to open stalled TLS client"); + + let h2_response = insecure_client() + .get(format!("https://127.0.0.1:{port}/peer")) + .timeout(Duration::from_secs(3)) + .send() + .await + .expect("A stalled handshake must not block a valid HTTP/2 client"); + assert_eq!( + h2_response.version(), + reqwest::Version::HTTP_2, + "The secured listener must advertise h2 through ALPN" + ); + assert_eq!( + h2_response.text().await.unwrap(), + "127.0.0.1", + "The custom TLS transport must preserve the accepted peer address" + ); + + let http1_client = reqwest::Client::builder() + .danger_accept_invalid_certs(true) + .http1_only() + .build() + .expect("Failed to build HTTP/1.1 client"); + let http1_response = http1_client + .get(format!("https://127.0.0.1:{port}/http1")) + .timeout(Duration::from_secs(3)) + .send() + .await + .expect("The TLS listener must retain HTTP/1.1 fallback"); + assert_eq!(http1_response.version(), reqwest::Version::HTTP_11); + assert_eq!(http1_response.text().await.unwrap(), "http1"); + + let tls12_client = reqwest::Client::builder() + .danger_accept_invalid_certs(true) + .min_tls_version(TlsVersion::TLS_1_2) + .max_tls_version(TlsVersion::TLS_1_2) + .build() + .expect("Failed to build TLS 1.2 client"); + assert_eq!( + tls12_client + .get(format!("https://127.0.0.1:{port}/tls12")) + .timeout(Duration::from_secs(3)) + .send() + .await + .expect("The secured listener must retain TLS 1.2") + .text() + .await + .unwrap(), + "tls12" + ); + + let tls13_client = reqwest::Client::builder() + .danger_accept_invalid_certs(true) + .min_tls_version(TlsVersion::TLS_1_3) + .max_tls_version(TlsVersion::TLS_1_3) + .build() + .expect("Failed to build TLS 1.3 client"); + assert_eq!( + tls13_client + .get(format!("https://127.0.0.1:{port}/tls13")) + .timeout(Duration::from_secs(3)) + .send() + .await + .expect("The secured listener must retain TLS 1.3") + .text() + .await + .unwrap(), + "tls13" + ); + + drop(stalled_client); + assert_server_program_completed(server_handle); +} + +#[tokio::test] +async fn test_tls_configuration_rejects_malformed_and_mismatched_keys() { + let malformed_dir = tempfile::tempdir().unwrap(); + let (cert_path, malformed_key_path) = write_self_signed_cert(malformed_dir.path()); + std::fs::write(&malformed_key_path, "not a private key") + .expect("Failed to replace the test key"); + + let malformed_code = format!( + r#"listen on port 8222 secured with certificate "{cert_path}" and key "{malformed_key_path}" as secure_server"# + ); + let tokens = lex_wfl_with_positions(&malformed_code); + let mut parser = Parser::new(&tokens); + let ast = parser.parse().expect("Failed to parse malformed-key case"); + let mut interpreter = Interpreter::with_config(Arc::new(WflConfig::default())); + let malformed_result = interpreter.interpret(&ast).await; + let malformed_message = format!( + "{:?}", + malformed_result.expect_err("Malformed private key should be rejected") + ); + assert!( + malformed_message.contains("contains no private key"), + "Malformed-key error should explain the PEM requirement, got: {malformed_message}" + ); + + let cert_dir = tempfile::tempdir().unwrap(); + let key_dir = tempfile::tempdir().unwrap(); + let (cert_path, _) = write_self_signed_cert(cert_dir.path()); + let (_, unrelated_key_path) = write_self_signed_cert(key_dir.path()); + let mismatch_code = format!( + r#"listen on port 8223 secured with certificate "{cert_path}" and key "{unrelated_key_path}" as secure_server"# + ); + let tokens = lex_wfl_with_positions(&mismatch_code); + let mut parser = Parser::new(&tokens); + let ast = parser.parse().expect("Failed to parse mismatched-key case"); + let mut interpreter = Interpreter::with_config(Arc::new(WflConfig::default())); + let mismatch_result = interpreter.interpret(&ast).await; + let mismatch_message = format!( + "{:?}", + mismatch_result.expect_err("Certificate/key mismatch should be rejected") + ); + assert!( + mismatch_message.contains(&cert_path) + && mismatch_message.contains(&unrelated_key_path) + && mismatch_message.contains("not a valid pair"), + "Certificate/key mismatch should identify both invalid inputs, got: {mismatch_message}" + ); +} + +#[tokio::test] +async fn test_close_server_stops_tls_listener() { + let port = 8224; + let temp_dir = tempfile::tempdir().unwrap(); + let (cert_path, key_path) = write_self_signed_cert(temp_dir.path()); + let server_code = format!( + r#" + listen on port {port} secured with certificate "{cert_path}" and key "{key_path}" as secure_server + wait for 1500 milliseconds + close server secure_server + "# + ); + + let tokens = lex_wfl_with_positions(&server_code); + let mut parser = Parser::new(&tokens); + let ast = parser.parse().expect("Failed to parse TLS close program"); + let mut interpreter = Interpreter::with_config(Arc::new(WflConfig::default())); + + let cert_file = std::fs::File::open(&cert_path).expect("Failed to reopen test certificate"); + let cert = rustls_pemfile::certs(&mut std::io::BufReader::new(cert_file)) + .next() + .expect("Test certificate PEM was empty") + .expect("Test certificate PEM was invalid"); + let mut roots = rustls::RootCertStore::empty(); + roots.add(cert).expect("Failed to trust test certificate"); + let provider = Arc::new(rustls::crypto::ring::default_provider()); + let mut client_config = rustls::ClientConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions() + .expect("Failed to configure client protocol versions") + .with_root_certificates(roots) + .with_no_client_auth(); + client_config.alpn_protocols = vec![b"http/1.1".to_vec()]; + let connector = tokio_rustls::TlsConnector::from(Arc::new(client_config)); + + let exercise_lifecycle = async { + let deadline = tokio::time::Instant::now() + Duration::from_secs(3); + let tcp = loop { + match tokio::net::TcpStream::connect(("127.0.0.1", port)).await { + Ok(stream) => break stream, + Err(error) if tokio::time::Instant::now() < deadline => { + let _ = error; + tokio::time::sleep(Duration::from_millis(20)).await; + } + Err(error) => panic!("TLS listener never became reachable: {error}"), + } + }; + + let server_name = + rustls::pki_types::ServerName::IpAddress(IpAddr::V4(Ipv4Addr::LOCALHOST).into()); + let mut established = + tokio::time::timeout(Duration::from_secs(2), connector.connect(server_name, tcp)) + .await + .expect("TLS handshake timed out") + .expect("TLS listener did not complete a valid handshake"); + + // This second accepted socket deliberately never sends a ClientHello. + // It must be cancelled along with the established idle connection. + let mut stalled = tokio::net::TcpStream::connect(("127.0.0.1", port)) + .await + .expect("Failed to open stalled TLS connection"); + tokio::time::sleep(Duration::from_millis(100)).await; + + async fn assert_peer_closed(stream: &mut S, label: &str) { + let mut byte = [0_u8; 1]; + match tokio::time::timeout(Duration::from_secs(2), stream.read(&mut byte)).await { + Ok(Ok(0)) | Ok(Err(_)) => {} + Ok(Ok(count)) => { + panic!("{label} received {count} unexpected bytes after close server") + } + Err(_) => panic!("{label} remained open after close server"), + } + } + + assert_peer_closed(&mut established, "Established TLS connection").await; + assert_peer_closed(&mut stalled, "Stalled TLS handshake").await; + + std::net::TcpListener::bind(("127.0.0.1", port)) + .expect("The TLS address must be immediately reusable after close server"); + }; + + let (interpret_result, ()) = tokio::join!(interpreter.interpret(&ast), exercise_lifecycle); + interpret_result.expect("TLS close program returned runtime errors"); +} + +#[tokio::test] +async fn test_tls_port_zero_reports_actual_bound_address() { + let temp_dir = tempfile::tempdir().unwrap(); + let (cert_path, key_path) = write_self_signed_cert(temp_dir.path()); + let listen_code = format!( + r#"listen on port 0 secured with certificate "{cert_path}" and key "{key_path}" as secure_server"# + ); + let tokens = lex_wfl_with_positions(&listen_code); + let mut parser = Parser::new(&tokens); + let ast = parser + .parse() + .expect("Failed to parse ephemeral TLS listener"); + let mut interpreter = Interpreter::with_config(Arc::new(WflConfig::default())); + + interpreter + .interpret(&ast) + .await + .expect("Ephemeral TLS listener failed to start"); + let server_value = interpreter + .global_env() + .borrow() + .get("secure_server") + .expect("TLS listener did not define its server variable"); + let Value::Text(server_text) = server_value else { + panic!("TLS server variable was not text: {server_value:?}"); + }; + let actual_port = server_text + .rsplit_once(':') + .and_then(|(_, port)| port.parse::().ok()) + .expect("TLS server value did not contain a numeric bound port"); + assert_ne!( + actual_port, 0, + "A port-zero TLS listener must report the actual ephemeral port" + ); + + let close_tokens = lex_wfl_with_positions("close server secure_server"); + let mut close_parser = Parser::new(&close_tokens); + let close_ast = close_parser + .parse() + .expect("Failed to parse ephemeral TLS close statement"); + interpreter + .interpret(&close_ast) + .await + .expect("Ephemeral TLS listener failed to close"); } diff --git a/vscode-extension/package-lock.json b/vscode-extension/package-lock.json index d6a418d5..30ab4f8f 100644 --- a/vscode-extension/package-lock.json +++ b/vscode-extension/package-lock.json @@ -90,9 +90,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -150,9 +150,9 @@ } }, "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -773,9 +773,9 @@ } }, "node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -1245,9 +1245,9 @@ } }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -1907,9 +1907,9 @@ } }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, "funding": [ { @@ -2124,16 +2124,16 @@ } }, "node_modules/minimatch/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/minipass": { @@ -2777,9 +2777,9 @@ } }, "node_modules/rimraf/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -3090,9 +3090,9 @@ } }, "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/vscode-extension/package.json b/vscode-extension/package.json index f690e9a2..e9d4f6ed 100644 --- a/vscode-extension/package.json +++ b/vscode-extension/package.json @@ -174,7 +174,7 @@ "@vscode/test-electron": "^2.5.2" }, "overrides": { - "js-yaml": "4.2.0", + "js-yaml": "4.3.0", "serialize-javascript": "7.0.6" } -} \ No newline at end of file +}