From 6ad75a47c619d858765f6decf9f895b624c02366 Mon Sep 17 00:00:00 2001 From: Brad Byrd Date: Wed, 23 Sep 2026 01:14:59 -0500 Subject: [PATCH 1/7] test: specify multi-certificate TLS SNI behavior (red) --- tests/web_server_sni_test.rs | 312 +++++++++++++++++++++++++++++++++++ 1 file changed, 312 insertions(+) create mode 100644 tests/web_server_sni_test.rs diff --git a/tests/web_server_sni_test.rs b/tests/web_server_sni_test.rs new file mode 100644 index 00000000..a4700ae6 --- /dev/null +++ b/tests/web_server_sni_test.rs @@ -0,0 +1,312 @@ +//! SNI contract tests: real certificates, verified TLS, and the real WFL binary. +use rustls::pki_types::{CertificateDer, ServerName}; +use std::{net::SocketAddr, path::Path, process::Stdio, sync::Arc, time::Duration}; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; +use wfl::{ + Interpreter, analyzer::Analyzer, lexer::lex_wfl_with_positions, parser::Parser, + typechecker::TypeChecker, +}; + +fn parse(code: &str) -> wfl::parser::ast::Program { + Parser::new(&lex_wfl_with_positions(code)) + .parse() + .expect("SNI syntax must parse") +} + +struct Cert { + cert: String, + key: String, + der: CertificateDer<'static>, +} + +impl Cert { + fn new(dir: &Path, name: &str) -> Self { + let generated = rcgen::generate_simple_self_signed(vec![name.to_owned()]).unwrap(); + let cert = dir.join(format!("{name}.pem")); + let key = dir.join(format!("{name}.key")); + std::fs::write(&cert, generated.cert.pem()).unwrap(); + std::fs::write(&key, generated.signing_key.serialize_pem()).unwrap(); + Self { + cert: cert.to_string_lossy().replace('\\', "/"), + key: key.to_string_lossy().replace('\\', "/"), + der: generated.cert.der().clone(), + } + } + fn clause(&self, name: &str) -> String { + format!( + r#"certificate "{}" and key "{}" for "{name}""#, + self.cert, self.key + ) + } +} + +fn connector(certs: &[&Cert], sni: bool) -> tokio_rustls::TlsConnector { + let mut roots = rustls::RootCertStore::empty(); + for cert in certs { + roots.add(cert.der.clone()).unwrap(); + } + let mut config = rustls::ClientConfig::builder_with_provider(Arc::new( + rustls::crypto::ring::default_provider(), + )) + .with_safe_default_protocol_versions() + .unwrap() + .with_root_certificates(roots) + .with_no_client_auth(); + config.enable_sni = sni; + config.alpn_protocols = vec![b"http/1.1".to_vec()]; + tokio_rustls::TlsConnector::from(Arc::new(config)) +} + +async fn connect( + addr: SocketAddr, + name: &str, + connector: &tokio_rustls::TlsConnector, +) -> std::io::Result> { + let tcp = tokio::net::TcpStream::connect(addr).await?; + tokio::time::timeout( + Duration::from_secs(3), + connector.connect(ServerName::try_from(name.to_owned()).unwrap(), tcp), + ) + .await + .expect("TLS handshake must be bounded") +} + +async fn start_binary( + dir: &Path, + clauses: &str, + requests: usize, +) -> (tokio::process::Child, SocketAddr) { + let mut code = format!( + "listen on port 0 secured with {clauses} as secure_server\ndisplay secure_server\n" + ); + for _ in 0..requests { + code.push_str("wait for request comes in on secure_server as req with timeout 5000\nrespond to req with header \"Host\" of req\n"); + } + code.push_str("close server secure_server\n"); + std::fs::write(dir.join("server.wfl"), code).unwrap(); + std::fs::write(dir.join(".wflcfg"), "web_server_bind_address = 127.0.0.1\n").unwrap(); + let mut child = tokio::process::Command::new(env!("CARGO_BIN_EXE_wfl")) + .args(["--execution-timeout", "15", "server.wfl"]) + .current_dir(dir) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let mut lines = BufReader::new(child.stdout.take().unwrap()).lines(); + let addr = tokio::time::timeout(Duration::from_secs(10), async { + while let Some(line) = lines.next_line().await.unwrap() { + if let Some(addr) = line.trim().strip_prefix("WebServer::") { + return addr.parse().unwrap(); + } + } + panic!("WFL exited before publishing the listener address"); + }) + .await + .expect("WFL startup must be bounded"); + (child, addr) +} + +async fn request( + addr: SocketAddr, + name: &str, + cert: &Cert, + connector: &tokio_rustls::TlsConnector, +) { + let mut stream = connect(addr, name, connector) + .await + .expect("verified SNI handshake"); + assert_eq!( + stream.get_ref().1.peer_certificates().unwrap()[0], + cert.der, + "SNI must choose this domain's certificate" + ); + stream + .write_all( + format!("GET / HTTP/1.1\r\nHost: {name}\r\nConnection: close\r\n\r\n").as_bytes(), + ) + .await + .unwrap(); + let mut response = String::new(); + tokio::time::timeout(Duration::from_secs(3), stream.read_to_string(&mut response)) + .await + .unwrap() + .unwrap(); + assert!(response.starts_with("HTTP/1.1 200"), "{response}"); + assert!(response.ends_with(name), "{response}"); +} + +#[test] +fn sni_syntax_accepts_named_certificates_and_variables() { + let ast = parse( + r#" +store hostname as "one.test" +store cert_path as "one.pem" +store key_path as "one.key" +store listen_port as 0 +listen on port listen_port secured with certificate cert_path and key key_path for hostname + and certificate "two.pem" and key "two.key" for "two.test" as secure_server +close server secure_server +"#, + ); + Analyzer::new() + .analyze(&ast) + .expect("SNI expressions must be analyzed"); + TypeChecker::new() + .check_types(&ast) + .expect("SNI text operands must typecheck"); +} + +#[test] +fn sni_operands_are_analyzed_and_typechecked() { + for operand in [ + "certificate 42 and key \"k\" for \"one.test\"", + "certificate \"c\" and key 42 for \"one.test\"", + "certificate \"c\" and key \"k\" for 42", + ] { + let ast = parse(&format!( + "listen on port 0 secured with {operand} as server" + )); + assert!(TypeChecker::new().check_types(&ast).is_err(), "{operand}"); + } + for operand in [ + "certificate missing_cert and key \"k\" for \"one.test\"", + "certificate \"c\" and key missing_key for \"one.test\"", + "certificate \"c\" and key \"k\" for missing_host", + ] { + let ast = parse(&format!( + "listen on port 0 secured with {operand} as server" + )); + assert!(Analyzer::new().analyze(&ast).is_err(), "{operand}"); + } +} + +#[tokio::test] +async fn sni_binary_selects_two_certs_rejects_unknown_and_survives_stalled_clients() { + let dir = tempfile::tempdir().unwrap(); + let one = Cert::new(dir.path(), "one.test"); + let two = Cert::new(dir.path(), "two.test"); + let (mut child, addr) = start_binary( + dir.path(), + &format!("{} and {}", one.clause("ONE.test"), two.clause("two.test")), + 2, + ) + .await; + let client = connector(&[&one, &two], true); + let mut stalled = tokio::net::TcpStream::connect(addr).await.unwrap(); + // Unknown/missing SNI fails before any HTTP request reaches the application. + assert!(connect(addr, "unknown.test", &client).await.is_err()); + assert!( + connect(addr, "one.test", &connector(&[&one], false)) + .await + .is_err() + ); + let mut idle = connect(addr, "one.test", &client).await.unwrap(); + let ((), ()) = tokio::join!( + request(addr, "one.test", &one, &client), + request(addr, "two.test", &two, &client) + ); + assert!( + tokio::time::timeout(Duration::from_secs(5), child.wait()) + .await + .unwrap() + .unwrap() + .success() + ); + let mut byte = [0]; + for result in [ + tokio::time::timeout(Duration::from_secs(2), stalled.read(&mut byte)).await, + tokio::time::timeout(Duration::from_secs(2), idle.read(&mut byte)).await, + ] { + assert!( + matches!(result, Ok(Ok(0)) | Ok(Err(_))), + "close must cancel established and partial TLS connections: {result:?}" + ); + } + assert!(tokio::net::TcpStream::connect(addr).await.is_err()); +} + +#[tokio::test] +async fn sni_explicit_default_handles_unknown_and_missing_sni() { + let dir = tempfile::tempdir().unwrap(); + let default = Cert::new(dir.path(), "default.test"); + let named = Cert::new(dir.path(), "named.test"); + let clauses = format!( + r#"certificate "{}" and key "{}" and {}"#, + default.cert, + default.key, + named.clause("named.test") + ); + let (mut child, addr) = start_binary(dir.path(), &clauses, 3).await; + let client = connector(&[&default, &named], true); + request(addr, "default.test", &default, &client).await; + request(addr, "named.test", &named, &client).await; + request( + addr, + "default.test", + &default, + &connector(&[&default], false), + ) + .await; + assert!( + tokio::time::timeout(Duration::from_secs(5), child.wait()) + .await + .unwrap() + .unwrap() + .success() + ); +} + +#[tokio::test] +async fn sni_invalid_configuration_fails_before_binding() { + let dir = tempfile::tempdir().unwrap(); + let one = Cert::new(dir.path(), "one.test"); + let two = Cert::new(dir.path(), "two.test"); + let cases = [ + ( + format!("{} and {}", one.clause("one.test"), one.clause("ONE.TEST")), + "Duplicate", + ), + (one.clause("wrong.test"), "one.test.pem"), + (one.clause("*.test"), "DNS"), + (one.clause("one.test:443"), "DNS"), + (one.clause("one.test."), "DNS"), + ( + format!( + r#"certificate "{}" and key "{}" for "one.test""#, + one.cert, two.key + ), + "not a valid pair", + ), + ( + format!( + r#"certificate "missing.pem" and key "{}" for "one.test""#, + one.key + ), + "Cannot open", + ), + ]; + for (clause, expected) in cases { + let ast = parse(&format!( + "listen on port 0 secured with {clause} as secure_server" + )); + let mut interpreter = Interpreter::new(); + let error = format!( + "{:?}", + interpreter + .interpret(&ast) + .await + .expect_err("invalid SNI configuration must fail") + ); + assert!(error.contains(expected), "expected {expected}: {error}"); + assert!( + interpreter + .global_env() + .borrow() + .get("secure_server") + .is_none(), + "invalid listener must not be published" + ); + } +} From b1f8d747726ea99ab5309181efeae93b3337e5cd Mon Sep 17 00:00:00 2001 From: Brad Byrd Date: Wed, 23 Sep 2026 01:31:37 -0500 Subject: [PATCH 2/7] feat: select HTTPS certificates by TLS server name --- Docs/04-advanced-features/web-servers.md | 48 +++++++ Docs/reference/configuration-reference.md | 5 + Docs/reference/keyword-reference.md | 4 + Docs/reference/reserved-keywords.md | 5 + History/dev-diary/2026/2026-09-23-tls-sni.md | 20 +++ .../docs_examples/_meta/manifest.json | 6 + .../tls_sni/multiple_domains.wfl | 10 ++ TestPrograms/tls_sni/startup.test.wfl | 23 ++++ fuzz/seeds/fuzz_parser/seed_tls_sni.wfl | 4 + .../fuzz_parser/seed_tls_sni_default.wfl | 2 + .../fuzz_parser/seed_tls_sni_incomplete.wfl | 2 + src/analyzer/mod.rs | 5 + src/interpreter/mod.rs | 126 ++++++++++++------ src/interpreter/tls.rs | 87 ++++++++++-- src/linter/layout.rs | 7 + src/parser/ast.rs | 13 +- src/parser/stmt/web.rs | 70 ++++++++-- src/typechecker/mod.rs | 10 +- tests/web_server_sni_test.rs | 81 ++++++++++- 19 files changed, 460 insertions(+), 68 deletions(-) create mode 100644 History/dev-diary/2026/2026-09-23-tls-sni.md create mode 100644 TestPrograms/docs_examples/tls_sni/multiple_domains.wfl create mode 100644 TestPrograms/tls_sni/startup.test.wfl create mode 100644 fuzz/seeds/fuzz_parser/seed_tls_sni.wfl create mode 100644 fuzz/seeds/fuzz_parser/seed_tls_sni_default.wfl create mode 100644 fuzz/seeds/fuzz_parser/seed_tls_sni_incomplete.wfl diff --git a/Docs/04-advanced-features/web-servers.md b/Docs/04-advanced-features/web-servers.md index 0e1cf37d..d8e66692 100644 --- a/Docs/04-advanced-features/web-servers.md +++ b/Docs/04-advanced-features/web-servers.md @@ -1194,6 +1194,54 @@ curl -k https://localhost:8443/ ⚠️ Self-signed certificates are for development only. In production, use a certificate from a real authority (e.g. Let's Encrypt via certbot). +### Multiple domains on one HTTPS port + +Add `for "hostname"` to a certificate/key pair, then join additional pairs with +`and certificate`. WFL selects the certificate using the DNS name sent by the +client in its TLS handshake (Server Name Indication, or SNI): + +```wfl +// CI-SKIP: Requires certificate and key files covering the configured domains. +listen on port 8443 secured with + certificate "one.pem" and key "one.key" for "one.example.com" + and certificate "two.pem" and key "two.key" for "two.example.com" as secure_server +``` + +Hostnames and paths may also be text variables or parenthesized expressions. +The listener supports up to 128 named entries. Names are matched exactly and +case-insensitively. Use DNS names (international names in ASCII/Punycode form), +without a URL scheme, port, trailing dot, IP address, or wildcard. A wildcard +certificate can cover a configured exact name, but `for "*.example.com"` is not +a wildcard routing rule. Repeat the pair for each exact name it should serve. + +WFL loads and validates every pair before opening the listening socket. Invalid +DNS names, duplicate names (including different capitalization), unreadable or +malformed files, mismatched keys, and certificates that do not cover their +configured hostname stop startup with an error. Certificates are loaded once; +restart the listener after replacing files to pick up renewals. WFL does not +issue or automatically renew certificates. + +A listener containing only named pairs rejects clients with unknown or missing +SNI during the TLS handshake. It does not silently use the `.wflcfg` certificate +as a fallback. To provide a fallback explicitly, put an unnamed pair first: + +```wfl +// CI-SKIP: Requires certificate and key files covering the configured domains. +listen on port 8443 secured with + certificate "default.pem" and key "default.key" + and certificate "one.pem" and key "one.key" for "one.example.com" as secure_server +``` + +The default pair is used when SNI is absent or does not match a named entry. +Clients still verify that the selected certificate covers the name they used. +Existing single-certificate statements and bare `secured` configuration behave +as before. + +SNI selects the TLS certificate; it does not dispatch application handlers or +authorize access to a site. Route requests by `header "Host" of req` and their +path in your WFL application, and reject unknown HTTP hosts. An HTTP Host header +can differ from the TLS name; WFL does not enforce equality between them. + ### Production notes - The private key file must be readable by the WFL process — protect it with file permissions (`chmod 600 key.pem`). diff --git a/Docs/reference/configuration-reference.md b/Docs/reference/configuration-reference.md index 96d5075e..7e8eeb18 100644 --- a/Docs/reference/configuration-reference.md +++ b/Docs/reference/configuration-reference.md @@ -628,6 +628,11 @@ listen on port 8443 secured as s A plain `listen` (without `secured`) **always** serves HTTP — putting cert paths in `.wflcfg` never silently upgrades HTTP to HTTPS. +For multiple certificates on the same port, use the named certificate clauses +on `listen` described in [Multiple domains on one HTTPS port](../04-advanced-features/web-servers.md#multiple-domains-on-one-https-port). +These default configuration paths apply to bare `secured` statements only; +named-only listeners do not inherit a fallback certificate from configuration. + #### `web_server_tls_key_file` Default TLS private key (PEM) for bare `listen … secured`. diff --git a/Docs/reference/keyword-reference.md b/Docs/reference/keyword-reference.md index 889ac455..88c7b4dd 100644 --- a/Docs/reference/keyword-reference.md +++ b/Docs/reference/keyword-reference.md @@ -370,6 +370,10 @@ connect to database at "sqlite://app.db" as db - 5 appear contextual but are actually always reserved ### "What about `secured`, `certificate`, `key`, `redirecting`, `content_type`, `transaction`, `schema`, `changes`, `without following redirects`?" → Not keywords + +For HTTPS, `certificate ... and key ... for "example.com"` associates a pair +with a DNS name using the existing `for` keyword. Join more named pairs with +`and certificate`. See [multiple-domain HTTPS](../04-advanced-features/web-servers.md#multiple-domains-on-one-https-port). These words are recognized purely by position — inside `listen` / `respond` statements, in `in transaction on db:` / `in transaction on db for schema changes:` / `end transaction`, or as the `and without following redirects` clause in `open url` — and are **never reserved**. Use them as variable names freely. `raise_error` is a standard-library function, not a keyword. See [Marker Words That Are Not Keywords](reserved-keywords.md#marker-words-that-are-not-keywords-at-all). --- diff --git a/Docs/reference/reserved-keywords.md b/Docs/reference/reserved-keywords.md index d09d03b2..03aaf0e8 100644 --- a/Docs/reference/reserved-keywords.md +++ b/Docs/reference/reserved-keywords.md @@ -104,6 +104,11 @@ A few words have special meaning in exactly one statement position but are **not - `schema`, `changes` - optional SQLite transaction mode in `in transaction on db for schema changes:` - `without following redirects` - per-request redirect clause in `open url at address and without following redirects and read response as reply`; the individual words and the complete phrase remain ordinary variable names outside that clause position +TLS pairs can also use the existing `for` keyword to select a DNS name: +`certificate "cert.pem" and key "key.pem" for "example.com"`. Additional named +pairs begin with `and certificate`; this adds no reserved words. See +[multiple-domain HTTPS](../04-advanced-features/web-servers.md#multiple-domains-on-one-https-port). + `raise_error` is an ordinary standard-library function called with `call raise_error with message` or `raise_error of message`; it is not a keyword. diff --git a/History/dev-diary/2026/2026-09-23-tls-sni.md b/History/dev-diary/2026/2026-09-23-tls-sni.md new file mode 100644 index 00000000..c7f735b5 --- /dev/null +++ b/History/dev-diary/2026/2026-09-23-tls-sni.md @@ -0,0 +1,20 @@ +# Multiple TLS certificates on one listener + +WFL now accepts named certificate/key pairs on secured listeners using +`for "hostname"` and additional `and certificate` clauses. The original unnamed +pair remains an optional explicit fallback; bare `secured` still uses the +existing configuration paths. Named-only listeners reject unknown or absent +SNI instead of inheriting a default from configuration. + +The runtime builds a Rustls SNI resolver before binding, validates DNS names and +certificate coverage, and rejects duplicate names and invalid key pairs. The +existing per-connection handshake and cancellation transport is unchanged. +Certificate selection does not add HTTP virtual-host routing or Host/SNI +authorization checks. Certificate renewal still requires a listener restart. + +Tests exercise the real WFL binary with generated certificates and verified TLS +clients, assert the selected certificate bytes and HTTP responses for two names, +and cover fallback, rejection, startup validation, partial handshakes, and +shutdown. Parser, analyzer, typechecker, and WFL startup regression cases cover +the language boundary. See the corresponding engineering evidence record for +commands, outcomes, and remaining release gates. diff --git a/TestPrograms/docs_examples/_meta/manifest.json b/TestPrograms/docs_examples/_meta/manifest.json index 41bf8e0d..aaa8cbac 100644 --- a/TestPrograms/docs_examples/_meta/manifest.json +++ b/TestPrograms/docs_examples/_meta/manifest.json @@ -1,4 +1,10 @@ { + "docs_examples/tls_sni/multiple_domains.wfl": { + "doc_section": "Docs/04-advanced-features/web-servers.md#multiple-domains-on-one-https-port", + "type": "snippet", + "validate_layers": [1, 2, 3, 4], + "skip_execution": true + }, "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://wfl.example.com/schemas/docs-examples-manifest.json", "title": "WFL Documentation Examples Manifest", diff --git a/TestPrograms/docs_examples/tls_sni/multiple_domains.wfl b/TestPrograms/docs_examples/tls_sni/multiple_domains.wfl new file mode 100644 index 00000000..aa078fda --- /dev/null +++ b/TestPrograms/docs_examples/tls_sni/multiple_domains.wfl @@ -0,0 +1,10 @@ +// CI-SKIP: Requires certificate and key files covering the configured domains. +listen on port 8443 secured with + certificate "one.pem" and key "one.key" for "one.example.com" + and certificate "two.pem" and key "two.key" for "two.example.com" as secure_server +close server secure_server + +listen on port 8443 secured with + certificate "default.pem" and key "default.key" + and certificate "one.pem" and key "one.key" for "one.example.com" as fallback_server +close server fallback_server diff --git a/TestPrograms/tls_sni/startup.test.wfl b/TestPrograms/tls_sni/startup.test.wfl new file mode 100644 index 00000000..27668f67 --- /dev/null +++ b/TestPrograms/tls_sni/startup.test.wfl @@ -0,0 +1,23 @@ +// Named TLS certificates are validated before the listener starts. +describe "TLS domain configuration": + test "invalid DNS names fail before certificate files are read": + store caught_message as "" + try: + listen on port 0 secured with certificate "unused.pem" and key "unused.key" for "https://example.com" as secure_server + when error: + change caught_message to error_message + end try + expect caught_message contains "Invalid TLS DNS hostname" to be yes + end test + + test "named certificates retain the existing actionable missing-file error": + store caught_message as "" + try: + listen on port 0 secured with certificate "" and key "" for "one.test" + and certificate "" and key "" for "two.test" as secure_server + when error: + change caught_message to error_message + end try + expect caught_message contains "Cannot open TLS certificate file" to be yes + end test +end describe diff --git a/fuzz/seeds/fuzz_parser/seed_tls_sni.wfl b/fuzz/seeds/fuzz_parser/seed_tls_sni.wfl new file mode 100644 index 00000000..276bea32 --- /dev/null +++ b/fuzz/seeds/fuzz_parser/seed_tls_sni.wfl @@ -0,0 +1,4 @@ +listen on port 0 secured with + certificate "one.pem" and key "one.key" for "one.example.com" + and certificate "two.pem" and key "two.key" for "two.example.com" as secure_server +close server secure_server diff --git a/fuzz/seeds/fuzz_parser/seed_tls_sni_default.wfl b/fuzz/seeds/fuzz_parser/seed_tls_sni_default.wfl new file mode 100644 index 00000000..6dda2967 --- /dev/null +++ b/fuzz/seeds/fuzz_parser/seed_tls_sni_default.wfl @@ -0,0 +1,2 @@ +listen on port 0 secured with certificate "default.pem" and key "default.key" + and certificate "one.pem" and key "one.key" for "one.example.com" as secure_server diff --git a/fuzz/seeds/fuzz_parser/seed_tls_sni_incomplete.wfl b/fuzz/seeds/fuzz_parser/seed_tls_sni_incomplete.wfl new file mode 100644 index 00000000..764f5872 --- /dev/null +++ b/fuzz/seeds/fuzz_parser/seed_tls_sni_incomplete.wfl @@ -0,0 +1,2 @@ +listen on port 0 secured with certificate "one.pem" and key "one.key" for "one.example.com" + and certificate "two.pem" and key "two.key" for diff --git a/src/analyzer/mod.rs b/src/analyzer/mod.rs index d0b5b097..064cdb16 100644 --- a/src/analyzer/mod.rs +++ b/src/analyzer/mod.rs @@ -2755,6 +2755,11 @@ impl Analyzer { if let Some(key_path) = &tls_config.key_path { self.analyze_expression(key_path); } + for certificate in &tls_config.sni_certificates { + self.analyze_expression(&certificate.hostname); + self.analyze_expression(&certificate.cert_path); + self.analyze_expression(&certificate.key_path); + } } // Analyze the redirect target port expression if present diff --git a/src/interpreter/mod.rs b/src/interpreter/mod.rs index 4de2f6d4..aeb26a03 100644 --- a/src/interpreter/mod.rs +++ b/src/interpreter/mod.rs @@ -11571,62 +11571,102 @@ impl Interpreter { // HTTPS server. Certificate/key paths come from the listen // statement itself, falling back to .wflcfg for the bare // `secured` form. - let cert_path = match &tls_config.cert_path { - Some(expr) => { - let v = self.evaluate_expression(expr, Rc::clone(&env)).await?; - match &v { - Value::Text(t) => t.to_string(), - _ => { - return Err(RuntimeError::new( - format!( - "Expected text for TLS certificate path, got {v:?}" - ), - *line, - *column, - )); + let default_paths = if tls_config.cert_path.is_some() + || tls_config.sni_certificates.is_empty() + { + let cert_path = match &tls_config.cert_path { + Some(expr) => { + let v = self.evaluate_expression(expr, Rc::clone(&env)).await?; + match &v { + Value::Text(t) => t.to_string(), + _ => { + return Err(RuntimeError::new( + format!( + "Expected text for TLS certificate path, got {v:?}" + ), + *line, + *column, + )); + } } } - } - None => match &self.config.web_server_tls_cert_file { - Some(path) => path.clone(), - None => { - return Err(RuntimeError::new( + None => match &self.config.web_server_tls_cert_file { + Some(path) => path.clone(), + None => { + return Err(RuntimeError::new( "This listen statement is marked 'secured' but no certificate is configured. Either write 'secured with certificate \"cert.pem\" and key \"key.pem\"' or set web_server_tls_cert_file and web_server_tls_key_file in .wflcfg".to_string(), *line, *column, )); - } - }, - }; - let key_path = match &tls_config.key_path { - Some(expr) => { - let v = self.evaluate_expression(expr, Rc::clone(&env)).await?; - match &v { - Value::Text(t) => t.to_string(), - _ => { - return Err(RuntimeError::new( - format!( - "Expected text for TLS private key path, got {v:?}" - ), - *line, - *column, - )); + } + }, + }; + let key_path = match &tls_config.key_path { + Some(expr) => { + let v = self.evaluate_expression(expr, Rc::clone(&env)).await?; + match &v { + Value::Text(t) => t.to_string(), + _ => { + return Err(RuntimeError::new( + format!( + "Expected text for TLS private key path, got {v:?}" + ), + *line, + *column, + )); + } } } - } - None => match &self.config.web_server_tls_key_file { - Some(path) => path.clone(), - None => { - return Err(RuntimeError::new( + None => match &self.config.web_server_tls_key_file { + Some(path) => path.clone(), + None => { + return Err(RuntimeError::new( "This listen statement is marked 'secured' but no private key is configured. Either write 'secured with certificate \"cert.pem\" and key \"key.pem\"' or set web_server_tls_cert_file and web_server_tls_key_file in .wflcfg".to_string(), *line, *column, )); - } - }, - }; + } + }, + }; - let tls_config = match tls::load_server_config(&cert_path, &key_path) { + Some((cert_path, key_path)) + } else { + None + }; + let mut named_certificates = Vec::new(); + for certificate in &tls_config.sni_certificates { + let mut values = Vec::with_capacity(3); + for (expr, label) in [ + (&certificate.hostname, "hostname"), + (&certificate.cert_path, "certificate path"), + (&certificate.key_path, "private key path"), + ] { + let value = self.evaluate_expression(expr, Rc::clone(&env)).await?; + let Value::Text(text) = value else { + return Err(RuntimeError::new( + format!( + "Expected text for TLS {label}, got {}", + value.type_name() + ), + *line, + *column, + )); + }; + values.push(text.to_string()); + } + let mut values = values.into_iter(); + named_certificates.push(tls::NamedCertificate { + hostname: values.next().unwrap(), + cert_path: values.next().unwrap(), + key_path: values.next().unwrap(), + }); + } + let tls_config = match tls::load_server_config( + default_paths + .as_ref() + .map(|(cert, key)| (cert.as_str(), key.as_str())), + &named_certificates, + ) { Ok(config) => config, Err(message) => { return Err(RuntimeError::new(message, *line, *column)); diff --git a/src/interpreter/tls.rs b/src/interpreter/tls.rs index 31b41bbf..c7959e61 100644 --- a/src/interpreter/tls.rs +++ b/src/interpreter/tls.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fmt; use std::fs::File; use std::future::Future; @@ -290,10 +290,32 @@ impl Accept for SecuredIncoming { } } -pub(super) fn load_server_config( +pub(super) struct NamedCertificate { + pub hostname: String, + pub cert_path: String, + pub key_path: String, +} + +#[derive(Debug)] +struct CertificateResolver { + named: rustls::server::ResolvesServerCertUsingSni, + default: Option>, +} + +impl rustls::server::ResolvesServerCert for CertificateResolver { + fn resolve( + &self, + hello: rustls::server::ClientHello<'_>, + ) -> Option> { + self.named.resolve(hello).or_else(|| self.default.clone()) + } +} + +fn load_certified_key( cert_path: &str, key_path: &str, -) -> Result { + provider: &rustls::crypto::CryptoProvider, +) -> 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\"" @@ -320,18 +342,65 @@ pub(super) fn load_server_config( ) })?; + rustls::sign::CertifiedKey::from_der(certs, key, provider) + .map_err(|error| { + format!( + "TLS certificate '{cert_path}' and private key '{key_path}' are not a valid pair: {error}" + ) + }) +} + +pub(super) fn load_server_config( + default_paths: Option<(&str, &str)>, + certificates: &[NamedCertificate], +) -> Result { + if certificates.len() > crate::parser::ast::MAX_TLS_SNI_CERTIFICATES { + return Err(format!( + "A secured listener supports at most {} named certificates", + crate::parser::ast::MAX_TLS_SNI_CERTIFICATES + )); + } + if default_paths.is_none() && certificates.is_empty() { + return Err("A secured listener needs at least one TLS certificate".to_string()); + } let provider = Arc::new(rustls::crypto::ring::default_provider()); + let default = default_paths + .map(|(cert, key)| load_certified_key(cert, key, &provider).map(Arc::new)) + .transpose()?; + let mut named = rustls::server::ResolvesServerCertUsingSni::new(); + let mut names = HashSet::new(); + for certificate in certificates { + let name = &certificate.hostname; + // SNI contains DNS names, not URLs, ports, IP addresses or wildcard + // patterns. A wildcard certificate can still cover an exact name here. + if name.ends_with('.') + || name.parse::().is_ok() + || rustls::pki_types::DnsName::try_from(name.as_str()).is_err() + { + return Err(format!( + "Invalid TLS DNS hostname '{name}': use an exact DNS name without a port, wildcard or trailing dot" + )); + } + let normalized = name.to_ascii_lowercase(); + if !names.insert(normalized.clone()) { + return Err(format!( + "Duplicate TLS hostname '{name}' (DNS names are case-insensitive)" + )); + } + let key = load_certified_key(&certificate.cert_path, &certificate.key_path, &provider)?; + named.add(&normalized, key).map_err(|error| { + format!( + "TLS certificate '{}' is not valid for hostname '{name}': {error}", + certificate.cert_path + ) + })?; + } 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}" - ) - })?; + .with_cert_resolver(Arc::new(CertificateResolver { named, default })); // Match Warp's prior TLS behavior and ordering: prefer HTTP/2, with a // standards-compatible HTTP/1.1 fallback. diff --git a/src/linter/layout.rs b/src/linter/layout.rs index 2e57618b..b759fc4d 100644 --- a/src/linter/layout.rs +++ b/src/linter/layout.rs @@ -290,6 +290,13 @@ fn statement_expressions<'a>(statement: &'a Statement, pending: &mut Vec<&'a Exp pending.extend(redirect_to_port); if let Some(tls) = tls { pending.extend(tls.cert_path.iter().chain(&tls.key_path)); + for certificate in &tls.sni_certificates { + pending.extend([ + &certificate.hostname, + &certificate.cert_path, + &certificate.key_path, + ]); + } } } Statement::WaitForRequestStatement { diff --git a/src/parser/ast.rs b/src/parser/ast.rs index 803765d0..e0f7497c 100644 --- a/src/parser/ast.rs +++ b/src/parser/ast.rs @@ -91,15 +91,26 @@ pub struct EventDefinition { pub column: usize, } -/// TLS settings on a `listen` statement. Both paths `None` means the bare +/// TLS settings on a `listen` statement. No paths or named certificates means the bare /// `secured` form: certificate and key paths come from .wflcfg at runtime /// (`web_server_tls_cert_file` / `web_server_tls_key_file`). #[derive(Debug, Clone, PartialEq)] pub struct TlsListenConfig { pub cert_path: Option, pub key_path: Option, + pub sni_certificates: Vec, } +/// A certificate selected by an exact DNS name in the TLS ClientHello. +#[derive(Debug, Clone, PartialEq)] +pub struct TlsSniCertificate { + pub hostname: Expression, + pub cert_path: Expression, + pub key_path: Expression, +} + +pub const MAX_TLS_SNI_CERTIFICATES: usize = 128; + /// Which WebSocket lifecycle event an `on websocket ... end on` block handles. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WsHandlerEvent { diff --git a/src/parser/stmt/web.rs b/src/parser/stmt/web.rs index 6fab753f..e569455f 100644 --- a/src/parser/stmt/web.rs +++ b/src/parser/stmt/web.rs @@ -3,7 +3,9 @@ use super::super::{Expression, ParseError, Parser, Statement}; use super::StmtParser; use crate::lexer::token::Token; -use crate::parser::ast::{Argument, TlsListenConfig, WsHandlerEvent}; +use crate::parser::ast::{ + Argument, MAX_TLS_SNI_CERTIFICATES, TlsListenConfig, TlsSniCertificate, WsHandlerEvent, +}; use crate::parser::expr::{ExprParser, PrimaryExprParser}; pub(crate) trait WebParser<'a>: ExprParser<'a> + PrimaryExprParser<'a> { @@ -145,22 +147,68 @@ impl<'a> WebParser<'a> for Parser<'a> { { // `secured with certificate and key ` self.bump_sync(); // Consume "with" - let cert_path = self.parse_tls_path_value("certificate")?; - self.expect_token( - Token::KeywordAnd, - "Expected 'and key ...' after certificate path", - )?; - let key_path = self.parse_tls_path_value("key")?; - tls = Some(TlsListenConfig { - cert_path: Some(cert_path), - key_path: Some(key_path), - }); + let mut config = TlsListenConfig { + cert_path: None, + key_path: None, + sni_certificates: Vec::new(), + }; + loop { + self.skip_eol(); + let cert_path = self.parse_tls_path_value("certificate")?; + self.expect_token( + Token::KeywordAnd, + "Expected 'and key ...' after certificate path", + )?; + let key_path = self.parse_tls_path_value("key")?; + if self + .cursor + .peek() + .is_some_and(|t| t.token == Token::KeywordFor) + { + self.bump_sync(); + let hostname = self.parse_primary_expression()?; + if config.sni_certificates.len() == MAX_TLS_SNI_CERTIFICATES { + return Err(ParseError::from_token( + format!( + "A secured listener supports at most {MAX_TLS_SNI_CERTIFICATES} named certificates" + ), + listen_token, + )); + } + config.sni_certificates.push(TlsSniCertificate { + hostname, + cert_path, + key_path, + }); + } else if config.cert_path.is_none() && config.sni_certificates.is_empty() { + // The original, unnamed pair is an explicit fallback. + config.cert_path = Some(cert_path); + config.key_path = Some(key_path); + } else { + return Err(ParseError::from_token( + "Additional certificates require 'for \"hostname\"'; place an optional default certificate first".to_string(), + listen_token, + )); + } + self.skip_eol(); + if self + .cursor + .peek() + .is_some_and(|t| t.token == Token::KeywordAnd) + { + self.bump_sync(); + } else { + break; + } + } + tls = Some(config); } else { // Bare `secured` — certificate and key paths come from // .wflcfg at runtime. tls = Some(TlsListenConfig { cert_path: None, key_path: None, + sni_certificates: Vec::new(), }); } diff --git a/src/typechecker/mod.rs b/src/typechecker/mod.rs index 9277b5df..77cd4c47 100644 --- a/src/typechecker/mod.rs +++ b/src/typechecker/mod.rs @@ -7194,7 +7194,15 @@ impl TypeChecker { for (path_expr, what) in [ (tls_config.cert_path.as_ref(), "Certificate path"), (tls_config.key_path.as_ref(), "Key path"), - ] { + ] + .into_iter() + .chain(tls_config.sni_certificates.iter().flat_map(|cert| { + [ + (Some(&cert.hostname), "TLS hostname"), + (Some(&cert.cert_path), "Certificate path"), + (Some(&cert.key_path), "Key path"), + ] + })) { if let Some(expr) = path_expr { let path_type = self.infer_expression_type(expr); if path_type != Type::Text && !self.is_gradual_type(&path_type) { diff --git a/tests/web_server_sni_test.rs b/tests/web_server_sni_test.rs index a4700ae6..9326dd26 100644 --- a/tests/web_server_sni_test.rs +++ b/tests/web_server_sni_test.rs @@ -166,7 +166,7 @@ fn sni_operands_are_analyzed_and_typechecked() { "certificate \"c\" and key \"k\" for 42", ] { let ast = parse(&format!( - "listen on port 0 secured with {operand} as server" + "listen on port 0 secured with {operand} as secure_server" )); assert!(TypeChecker::new().check_types(&ast).is_err(), "{operand}"); } @@ -176,7 +176,7 @@ fn sni_operands_are_analyzed_and_typechecked() { "certificate \"c\" and key \"k\" for missing_host", ] { let ast = parse(&format!( - "listen on port 0 secured with {operand} as server" + "listen on port 0 secured with {operand} as secure_server" )); assert!(Analyzer::new().analyze(&ast).is_err(), "{operand}"); } @@ -196,7 +196,16 @@ async fn sni_binary_selects_two_certs_rejects_unknown_and_survives_stalled_clien let client = connector(&[&one, &two], true); let mut stalled = tokio::net::TcpStream::connect(addr).await.unwrap(); // Unknown/missing SNI fails before any HTTP request reaches the application. - assert!(connect(addr, "unknown.test", &client).await.is_err()); + let rejected = connect(addr, "unknown.test", &client).await.unwrap_err(); + assert!( + matches!( + rejected + .get_ref() + .and_then(|e| e.downcast_ref::()), + Some(rustls::Error::AlertReceived(_)) + ), + "The server must reject unknown SNI, not merely present a mismatched certificate: {rejected}" + ); assert!( connect(addr, "one.test", &connector(&[&one], false)) .await @@ -272,6 +281,10 @@ async fn sni_invalid_configuration_fails_before_binding() { (one.clause("*.test"), "DNS"), (one.clause("one.test:443"), "DNS"), (one.clause("one.test."), "DNS"), + (one.clause("127.0.0.1"), "DNS"), + (one.clause("https://one.test"), "DNS"), + (one.clause(""), "DNS"), + (one.clause(&format!("{}.test", "a".repeat(64))), "DNS"), ( format!( r#"certificate "{}" and key "{}" for "one.test""#, @@ -310,3 +323,65 @@ async fn sni_invalid_configuration_fails_before_binding() { ); } } + +#[test] +fn sni_parser_rejects_incomplete_ambiguous_and_oversized_lists() { + for clause in [ + r#"certificate "c" and key "k" for"#, + r#"certificate "c" and key "k" for "one.test" and"#, + r#"certificate "c" and key "k" for "one.test" and certificate "c" for "two.test""#, + r#"certificate "c" and key "k" for "one.test" and certificate "d" and key "e""#, + r#"certificate "c" and key "k" and certificate "d" and key "e""#, + ] { + let code = format!("listen on port 0 secured with {clause} as secure_server"); + assert!( + Parser::new(&lex_wfl_with_positions(&code)).parse().is_err(), + "{code}" + ); + } + let clauses = (0..129) + .map(|i| format!(r#"certificate "c" and key "k" for "host{i}.test""#)) + .collect::>(); + parse(&format!( + "listen on port 0 secured with {} as secure_server", + clauses[..128].join(" and ") + )); + let code = format!( + "listen on port 0 secured with {} as secure_server", + clauses.join(" and ") + ); + let error = Parser::new(&lex_wfl_with_positions(&code)) + .parse() + .unwrap_err(); + assert!(format!("{error:?}").contains("at most 128")); +} + +#[tokio::test] +async fn sni_runtime_checks_dynamic_operands_and_ignores_implicit_default() { + for operand in [ + "certificate 42 and key \"k\" for \"one.test\"", + "certificate \"c\" and key 42 for \"one.test\"", + "certificate \"c\" and key \"k\" for 42", + ] { + let ast = parse(&format!( + "listen on port 0 secured with {operand} as secure_server" + )); + let error = Interpreter::new().interpret(&ast).await.unwrap_err(); + assert!(format!("{error:?}").contains("Expected text for TLS")); + } + let dir = tempfile::tempdir().unwrap(); + let one = Cert::new(dir.path(), "one.test"); + let config = wfl::config::WflConfig { + web_server_tls_cert_file: Some("missing-default.pem".to_owned()), + web_server_tls_key_file: Some("missing-default.key".to_owned()), + ..Default::default() + }; + let mut interpreter = Interpreter::with_config(Arc::new(config)); + interpreter + .interpret(&parse(&format!( + "listen on port 0 secured with {} as secure_server\nclose server secure_server", + one.clause("one.test") + ))) + .await + .unwrap(); +} From 69a49ea8c460f4cef38c7b1f378b3d4af94445a8 Mon Sep 17 00:00:00 2001 From: Brad Byrd Date: Wed, 23 Sep 2026 01:36:35 -0500 Subject: [PATCH 3/7] docs: record SNI test evidence and local validation limits --- Engineering/evidence/2026-09-23-tls-sni.md | 116 +++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 Engineering/evidence/2026-09-23-tls-sni.md diff --git a/Engineering/evidence/2026-09-23-tls-sni.md b/Engineering/evidence/2026-09-23-tls-sni.md new file mode 100644 index 00000000..7c3cbbff --- /dev/null +++ b/Engineering/evidence/2026-09-23-tls-sni.md @@ -0,0 +1,116 @@ +# TLS SNI certificate selection + +## Scope and risk + +Risk class **R3**: TLS certificate selection, untrusted ClientHello input, +language syntax, and backward compatibility. One secured listener can select +among at most 128 named certificate/key pairs, with an optional explicit default +pair. Named-only listeners reject missing and unknown SNI. No HTTP routing, +client authentication, certificate issuance, renewal, or configuration-file +format changes are included. + +The existing connection transport, queues, request budgets, streaming behavior, +and shutdown ownership are unchanged. Rustls handles ClientHello decoding and +hostname/certificate verification. Selection performs an immutable map lookup; +there is no file I/O or configuration mutation during a handshake. + +## Test-first history + +- Published base: `9c7c8f55057789c1c5f0a275989b433ed0d8438b` (`main`). +- Published Red: `6ad75a47` (`test: specify multi-certificate TLS SNI behavior (red)`). +- Published Green: `b1f8d747` (`feat: select HTTPS certificates by TLS server name`), + a direct descendant of the Red commit. +- The original development lineage was base `9bf3fa48`, Red `43b4889b`, + Green `341099ee`. Before publication, only the three SNI commits were rebased + onto current `main` to exclude the unrelated, unmerged ES256 feature present + in the starting checkout. The original validation below describes that local + development lineage; post-rebase validation and PR CI are recorded separately. +- Before any production change, `cargo test --test web_server_sni_test -- --nocapture` + compiled and failed all five initial tests: `for` and additional certificate + clauses were rejected by the existing parser; the real binary exited before + creating a listener. These are the missing language capability under test. +- During implementation, the multiline case exposed the need to consume line + boundaries between certificate clauses. The operand test also needed its + fixture variable changed from reserved `server` to `secure_server` before its + analyzer/typechecker assertions could execute. That fixture failure is not + counted as evidence of an operand-validation defect. +- The final suite broadens the initial contracts to seven tests, including + malformed/oversized lists and runtime operand checks. + +## Acceptance criteria and coverage + +| Contract | Evidence | +| --- | --- | +| Two DNS names share one TLS port and receive the correct distinct certificate | `sni_binary_selects_two_certs_rejects_unknown_and_survives_stalled_clients`: real WFL child process, generated self-signed certificates added to the client trust store, hostname verification enabled, exact peer certificate bytes and HTTP response asserted | +| Unknown/missing SNI is rejected without a default | Same test; unknown SNI must return a server TLS alert, rather than merely fail client hostname verification | +| An explicit unnamed first pair handles unknown/missing SNI; named entries take precedence | `sni_explicit_default_handles_unknown_and_missing_sni` | +| Configuration does not silently add a fallback to named-only listeners | `sni_runtime_checks_dynamic_operands_and_ignores_implicit_default` | +| Invalid DNS names, case-insensitive duplicates, SAN mismatch, missing files and key mismatch fail startup | `sni_invalid_configuration_fails_before_binding`; existing malformed PEM/key cases in `web_server_tls_test` | +| Literals, variables, multiline declarations, semantic/type checks and runtime text checks work | `sni_syntax_accepts_named_certificates_and_variables`, `sni_operands_are_analyzed_and_typechecked`, and runtime operand test | +| Incomplete, ambiguous and oversized lists are rejected; 128 entries parse | `sni_parser_rejects_incomplete_ambiguous_and_oversized_lists`; retained `fuzz/seeds/fuzz_parser/seed_tls_sni*.wfl` inputs | +| Slow/failed handshakes do not stop either domain; closing the listener cancels idle established/partial connections | Real-binary SNI test runs two domain requests concurrently beside partial and idle clients, checks close/error rather than post-close bytes, and verifies the port stops accepting | +| Single-certificate/configured TLS, HTTP/2, HTTP/1.1, TLS 1.2/1.3, redirects, peer IP, and lifecycle remain compatible | All 27 existing TLS parser/runtime tests plus web integration and the full Rust suite | +| WFL startup errors remain catchable | Both tests in `TestPrograms/tls_sni/startup.test.wfl` with the release binary | + +Existing cancellation, timeout, disconnect, bounded-queue/backpressure, +resource-limit and handler-isolation tests passed in the full Rust suite. This +change does not introduce a new queue, task, handshake scheduler or streaming +path. The existing parser fuzz target builds against the change; no extended +fuzz campaign or quantitative coverage run is claimed. + +## Verification + +Local platform: Windows x86-64, Rust 1.98.1. No dependencies or lockfiles changed. + +- `cargo fmt --all -- --check`: passed. +- `cargo clippy --all-targets --all-features -- -D warnings`: passed. +- `cargo test --all --no-fail-fast`: passed, 2,431 tests across 177 result groups; + 27 pre-existing ignored cases remain unchanged. All seven new tests ran. +- `cargo build --release`: passed. +- `cargo build --locked -p wfl-lsp`: passed. +- `cargo check --locked --manifest-path fuzz/Cargo.toml`: passed. +- `scripts/run_web_tests.ps1`: passed all three scenarios, including native TLS + and HTTP-to-HTTPS redirection. +- `scripts/run_integration_tests.ps1 -TestOnly`: Rust integration tests passed; + the WFL sweep reported **165 passed, 1 failed, 24 pre-existing exclusions**. + The new `tls_sni/startup.test.wfl` passed. The failure was + `file_io_comprehensive.wfl` scanning `.` recursively and encountering + `target/test-artifacts/config-review/snapshot-5n0sbodd`, an inaccessible + artifact directory created September 17, before this change. A diagnostic + rerun from the repository root reproduced `Access is denied (os error 5)` at + line 123. Python directory traversal independently identified that directory. + The unchanged program passed from an isolated directory under + `target/test-artifacts/tls-sni-file-io/`. The original suite failure remains + recorded; this is not a claim that the original full sweep passed. Three + source-root test files left by its failed cleanup were removed afterward. +- `target/release/wfl --execution-timeout 330 --test tests/fixtures/cli_budget/long-run.test.wfl`: + passed the real 305-second execution-budget check. +- `python -X utf8 scripts/validate_docs_examples.py --ci --force`: passed all + 38 registered examples. The external-certificate example is explicitly + non-executable and checked at layers 1–4; real TLS execution is tested above. +- The built `wfl-lsp --mcp` passed `parse_wfl`, `analyze_wfl`, `typecheck_wfl` + and `lint_wfl` on both new WFL files, with no diagnostics/type/lint errors. +- `python scripts/check_repo_hygiene.py --mode static`: passed. + +Raw local reports are ephemeral under `target/reports/tls-sni/`. The first +sandboxed full test run failed in the existing Git patch round-trip test because +Git could not access the temporary directory. Running the unchanged suite with +that filesystem restriction removed passed. The first documentation run also +hit Python's Windows text-decoding error; explicit UTF-8 fixed the environment. +Neither failure was hidden through skipped tests or relaxed assertions. The +validator still emits existing warnings for its manifest schema metadata keys. + +## Release boundaries and recovery + +No deployment, remote push, CI run, Linux execution, or independent R3 review is +claimed. Clean Windows/Linux CI and independent review remain merge/release +gates; this record is local implementation evidence. Extension host tests are +not run: no VS Code extension code, dependencies, or protocol changes are made. +The repository-root file-I/O sweep failure needs a clean test workspace (or +repair of the old inaccessible artifact directory) before that gate can pass. + +Revert the feature commit to remove the new syntax. Existing single-certificate +programs require no migration. Programs using the new named syntax must return +to a single certificate or proxy TLS before running on an older binary. No +persistent state, certificate files, or external server configuration is changed +by this implementation task. From 02972e3a8ef549039e62ecfec68a690bee14eec1 Mon Sep 17 00:00:00 2001 From: Brad Byrd Date: Wed, 23 Sep 2026 01:48:40 -0500 Subject: [PATCH 4/7] test: reproduce SNI operand ordering and check key fallback --- tests/web_server_sni_test.rs | 119 +++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/tests/web_server_sni_test.rs b/tests/web_server_sni_test.rs index 9326dd26..43b89669 100644 --- a/tests/web_server_sni_test.rs +++ b/tests/web_server_sni_test.rs @@ -385,3 +385,122 @@ async fn sni_runtime_checks_dynamic_operands_and_ignores_implicit_default() { .await .unwrap(); } + +#[tokio::test] +async fn sni_evaluates_operands_once_in_source_order() { + let dir = tempfile::tempdir().unwrap(); + let cert = Cert::new(dir.path(), "one.test"); + let source = format!( + r#" +store evaluation_order as "" +define action called choose_certificate: + change evaluation_order to evaluation_order with "cert;" + return "{}" +end action +define action called choose_key: + change evaluation_order to evaluation_order with "key;" + return "{}" +end action +define action called choose_hostname: + change evaluation_order to evaluation_order with "host;" + return "one.test" +end action +listen on port 0 secured with certificate choose_certificate and key choose_key for choose_hostname as secure_server +close server secure_server +"#, + cert.cert, cert.key + ); + let mut interpreter = Interpreter::new(); + interpreter.interpret(&parse(&source)).await.unwrap(); + let order = interpreter + .global_env() + .borrow() + .get("evaluation_order") + .unwrap(); + let wfl::interpreter::value::Value::Text(order) = order else { + panic!("order must be text") + }; + assert_eq!(order.as_ref(), "cert;key;host;"); +} + +#[tokio::test] +async fn sni_incompatible_named_key_does_not_select_compatible_default() { + let dir = tempfile::tempdir().unwrap(); + let named = Cert::new(dir.path(), "one.test"); + let fallback_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ED25519).unwrap(); + let fallback_cert = rcgen::CertificateParams::new(vec!["one.test".to_owned()]) + .unwrap() + .self_signed(&fallback_key) + .unwrap(); + let fallback = Cert { + cert: dir + .path() + .join("fallback.pem") + .to_string_lossy() + .replace('\\', "/"), + key: dir + .path() + .join("fallback.key") + .to_string_lossy() + .replace('\\', "/"), + der: fallback_cert.der().clone(), + }; + std::fs::write(&fallback.cert, fallback_cert.pem()).unwrap(); + std::fs::write(&fallback.key, fallback_key.serialize_pem()).unwrap(); + let clauses = format!( + r#"certificate "{}" and key "{}" and {}"#, + fallback.cert, + fallback.key, + named.clause("one.test") + ); + let (mut child, addr) = start_binary(dir.path(), &clauses, 2).await; + let mut roots = rustls::RootCertStore::empty(); + roots.add(named.der.clone()).unwrap(); + roots.add(fallback.der.clone()).unwrap(); + let mut provider = rustls::crypto::ring::default_provider(); + let mapping = provider.signature_verification_algorithms.mapping; + let index = mapping + .iter() + .position(|(scheme, _)| *scheme == rustls::SignatureScheme::ED25519) + .unwrap(); + provider.signature_verification_algorithms.mapping = &mapping[index..index + 1]; + let config = rustls::ClientConfig::builder_with_provider(Arc::new(provider)) + .with_protocol_versions(&[&rustls::version::TLS13]) + .unwrap() + .with_root_certificates(roots) + .with_no_client_auth(); + let restricted = tokio_rustls::TlsConnector::from(Arc::new(config.clone())); + let rejected = connect(addr, "one.test", &restricted).await.unwrap_err(); + assert!( + matches!( + rejected + .get_ref() + .and_then(|e| e.downcast_ref::()), + Some(rustls::Error::AlertReceived(_)) + ), + "a known incompatible named key must fail, not fall back: {rejected}" + ); + let mut no_sni = config; + no_sni.enable_sni = false; + request( + addr, + "one.test", + &fallback, + &tokio_rustls::TlsConnector::from(Arc::new(no_sni)), + ) + .await; + request( + addr, + "one.test", + &named, + &connector(&[&named, &fallback], true), + ) + .await; + assert!( + tokio::time::timeout(Duration::from_secs(5), child.wait()) + .await + .unwrap() + .unwrap() + .success() + ); +} From b1de5a4fff94cd4b8d713189c8e0b0fb1d0b40c3 Mon Sep 17 00:00:00 2001 From: Brad Byrd Date: Wed, 23 Sep 2026 01:51:12 -0500 Subject: [PATCH 5/7] fix: evaluate named TLS operands in source order --- Docs/04-advanced-features/web-servers.md | 2 + Engineering/evidence/2026-09-23-tls-sni.md | 39 ++++++++++++++++++-- History/dev-diary/2026/2026-09-23-tls-sni.md | 10 +++++ src/interpreter/mod.rs | 33 +++++++++++------ tests/web_server_sni_test.rs | 18 +++++++++ 5 files changed, 86 insertions(+), 16 deletions(-) diff --git a/Docs/04-advanced-features/web-servers.md b/Docs/04-advanced-features/web-servers.md index d8e66692..55a80a08 100644 --- a/Docs/04-advanced-features/web-servers.md +++ b/Docs/04-advanced-features/web-servers.md @@ -1208,6 +1208,8 @@ listen on port 8443 secured with ``` Hostnames and paths may also be text variables or parenthesized expressions. +Each clause evaluates its certificate path, key path, then hostname exactly once +in source order, stopping at the first invalid operand. The listener supports up to 128 named entries. Names are matched exactly and case-insensitively. Use DNS names (international names in ASCII/Punycode form), without a URL scheme, port, trailing dot, IP address, or wildcard. A wildcard diff --git a/Engineering/evidence/2026-09-23-tls-sni.md b/Engineering/evidence/2026-09-23-tls-sni.md index 7c3cbbff..5ed8a02c 100644 --- a/Engineering/evidence/2026-09-23-tls-sni.md +++ b/Engineering/evidence/2026-09-23-tls-sni.md @@ -100,12 +100,43 @@ hit Python's Windows text-decoding error; explicit UTF-8 fixed the environment. Neither failure was hidden through skipped tests or relaxed assertions. The validator still emits existing warnings for its manifest schema metadata keys. +## PR review follow-up (2026-09-23) + +[PR #746](https://github.com/WebFirstLanguage/wfl/pull/746) publishes only the +SNI changes. After rebasing onto `main`, all 34 initial TLS tests passed locally. + +The operand-order finding was reproduced in test-only commit `02972e3a`: +`sni_evaluates_operands_once_in_source_order` observed `host;cert;key;` instead +of `cert;key;host;`. The follow-up fix assigns named result fields in source +order and short-circuits on an invalid operand. All 36 TLS tests (nine SNI, +14 original parser, 13 original runtime) then passed. The added negative case +also verifies that an invalid certificate operand prevents key/hostname side +effects. + +The fallback finding was investigated against the actual locked Rustls 0.23.45 +resolver: `ResolvesServerCertUsingSni::resolve` only looks up the supplied name; +it does not filter the key by signature schemes. The new +`sni_incompatible_named_key_does_not_select_compatible_default` test passed +before any runtime fix. It gives both an ECDSA named certificate and an Ed25519 +default certificate the same DNS name, then restricts a TLS 1.3 client to +Ed25519. Configured SNI receives a server fatal alert; absent SNI succeeds with +the exact default certificate; an unrestricted named request receives the exact +named certificate. No resolver behavior change was needed. + +The initial PR head's Linux/Windows WFL-program suites, build/test/Clippy, +fuzz compilation, repository hygiene, database, release scripts, config lint and +Docker jobs passed. Its integration jobs reached the long-budget step without +failures. These results are superseded by final-head checks after pushing the +review fix; final CI links and review disposition are recorded on the PR. + ## Release boundaries and recovery -No deployment, remote push, CI run, Linux execution, or independent R3 review is -claimed. Clean Windows/Linux CI and independent review remain merge/release -gates; this record is local implementation evidence. Extension host tests are -not run: no VS Code extension code, dependencies, or protocol changes are made. +No deployment or merge is claimed. The original local validation did not include +Linux execution or independent R3 review; the PR now supplies platform CI and +independent review. Clean final-head Windows/Linux CI and resolved review findings +remain merge/release gates. Extension host tests were not run locally because +no VS Code extension code, dependencies, or protocol changes were made; the PR's +Build, Test, Clippy job runs the extension checks. The repository-root file-I/O sweep failure needs a clean test workspace (or repair of the old inaccessible artifact directory) before that gate can pass. diff --git a/History/dev-diary/2026/2026-09-23-tls-sni.md b/History/dev-diary/2026/2026-09-23-tls-sni.md index c7f735b5..bf29f8b3 100644 --- a/History/dev-diary/2026/2026-09-23-tls-sni.md +++ b/History/dev-diary/2026/2026-09-23-tls-sni.md @@ -18,3 +18,13 @@ and cover fallback, rejection, startup validation, partial handshakes, and shutdown. Parser, analyzer, typechecker, and WFL startup regression cases cover the language boundary. See the corresponding engineering evidence record for commands, outcomes, and remaining release gates. + +## Review follow-up (2026-09-23) + +PR review identified that named-clause operands ran in hostname-first order. +A failing regression observed `host;cert;key;` instead of the source order +`cert;key;host;`. The runtime now evaluates each operand once in source order +and stops on an invalid operand. A separate verified TLS regression confirms +that a named ECDSA key incompatible with an Ed25519-only client fails the +handshake rather than falling back to a compatible default certificate covering +the same hostname; the pinned Rustls resolver already preserves that behavior. diff --git a/src/interpreter/mod.rs b/src/interpreter/mod.rs index aeb26a03..a3bbc300 100644 --- a/src/interpreter/mod.rs +++ b/src/interpreter/mod.rs @@ -11635,11 +11635,25 @@ impl Interpreter { }; let mut named_certificates = Vec::new(); for certificate in &tls_config.sni_certificates { - let mut values = Vec::with_capacity(3); - for (expr, label) in [ - (&certificate.hostname, "hostname"), - (&certificate.cert_path, "certificate path"), - (&certificate.key_path, "private key path"), + let mut resolved = tls::NamedCertificate { + cert_path: String::new(), + key_path: String::new(), + hostname: String::new(), + }; + // Expressions can call actions: preserve source order + // and stop at the first invalid operand. + for (expr, destination, label) in [ + ( + &certificate.cert_path, + &mut resolved.cert_path, + "certificate path", + ), + ( + &certificate.key_path, + &mut resolved.key_path, + "private key path", + ), + (&certificate.hostname, &mut resolved.hostname, "hostname"), ] { let value = self.evaluate_expression(expr, Rc::clone(&env)).await?; let Value::Text(text) = value else { @@ -11652,14 +11666,9 @@ impl Interpreter { *column, )); }; - values.push(text.to_string()); + *destination = text.to_string(); } - let mut values = values.into_iter(); - named_certificates.push(tls::NamedCertificate { - hostname: values.next().unwrap(), - cert_path: values.next().unwrap(), - key_path: values.next().unwrap(), - }); + named_certificates.push(resolved); } let tls_config = match tls::load_server_config( default_paths diff --git a/tests/web_server_sni_test.rs b/tests/web_server_sni_test.rs index 43b89669..f1b66003 100644 --- a/tests/web_server_sni_test.rs +++ b/tests/web_server_sni_test.rs @@ -421,6 +421,24 @@ close server secure_server panic!("order must be text") }; assert_eq!(order.as_ref(), "cert;key;host;"); + + let invalid = source.replace(&format!("return \"{}\"", cert.cert), "return 42"); + let mut interpreter = Interpreter::new(); + let error = interpreter.interpret(&parse(&invalid)).await.unwrap_err(); + assert!(format!("{error:?}").contains("Expected text for TLS certificate path")); + let order = interpreter + .global_env() + .borrow() + .get("evaluation_order") + .unwrap(); + let wfl::interpreter::value::Value::Text(order) = order else { + panic!("order must be text") + }; + assert_eq!( + order.as_ref(), + "cert;", + "later operands must not run after an invalid certificate path" + ); } #[tokio::test] From 707457015cebcea9d28c237b165ae4d7ce861e8a Mon Sep 17 00:00:00 2001 From: Brad Byrd Date: Wed, 23 Sep 2026 01:57:37 -0500 Subject: [PATCH 6/7] test: reproduce unused-variable diagnostics for TLS operands --- src/analyzer/static_analyzer.rs | 21 ++++++++++++++++ tests/web_server_sni_test.rs | 43 +++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/src/analyzer/static_analyzer.rs b/src/analyzer/static_analyzer.rs index 55788503..a4e1c121 100644 --- a/src/analyzer/static_analyzer.rs +++ b/src/analyzer/static_analyzer.rs @@ -2275,6 +2275,27 @@ mod tests { assert_eq!(diagnostics[0].code, "ANALYZE-UNUSED"); } + #[test] + fn test_tls_operands_are_used_but_unreferenced_variables_remain_unused() { + let input = r#" +store default_cert as "default.pem" +store default_key as "default.key" +store named_cert as "one.pem" +store named_key as "one.key" +store hostname as "one.test" +store unused_tls_setting as "unused" +listen on port 0 secured with certificate default_cert and key default_key + and certificate named_cert and key named_key for hostname as secure_server +close server secure_server +"#; + let tokens = crate::lexer::lex_wfl_with_positions(input); + let program = crate::parser::Parser::new(&tokens).parse().unwrap(); + let diagnostics = Analyzer::new().check_unused_variables(&program, 0); + assert_eq!(diagnostics.len(), 1, "{diagnostics:?}"); + assert_eq!(diagnostics[0].code, "ANALYZE-UNUSED"); + assert!(diagnostics[0].message.contains("unused_tls_setting")); + } + #[test] fn test_streaming_statement_variables_are_not_reported_unused() { // Variables referenced only inside the streaming/incremental-read diff --git a/tests/web_server_sni_test.rs b/tests/web_server_sni_test.rs index f1b66003..ab79a92c 100644 --- a/tests/web_server_sni_test.rs +++ b/tests/web_server_sni_test.rs @@ -441,6 +441,49 @@ close server secure_server ); } +#[tokio::test] +async fn sni_cli_analyzer_counts_certificate_and_hostname_variables_as_used() { + let dir = tempfile::tempdir().unwrap(); + let code = r#" +store default_cert as "default.pem" +store default_key as "default.key" +store named_cert as "one.pem" +store named_key as "one.key" +store hostname as "one.test" +listen on port 0 secured with certificate default_cert and key default_key + and certificate named_cert and key named_key for hostname as secure_server +close server secure_server +"#; + std::fs::write(dir.path().join("analyze.wfl"), code).unwrap(); + let result = tokio::time::timeout( + Duration::from_secs(10), + tokio::process::Command::new(env!("CARGO_BIN_EXE_wfl")) + .args(["--analyze", "analyze.wfl"]) + .current_dir(dir.path()) + .env( + "WFL_GLOBAL_CONFIG_PATH", + dir.path().join("absent-global-config"), + ) + .env("NO_COLOR", "1") + .stdin(Stdio::null()) + .kill_on_drop(true) + .output(), + ) + .await + .unwrap() + .unwrap(); + let output = format!( + "{}{}", + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr) + ); + assert!( + result.status.success(), + "--analyze must accept used TLS operands: {output}" + ); + assert!(!output.contains("ANALYZE-UNUSED"), "{output}"); +} + #[tokio::test] async fn sni_incompatible_named_key_does_not_select_compatible_default() { let dir = tempfile::tempdir().unwrap(); From 9c7cd998d3a5cd804027f263962cba319326ff5b Mon Sep 17 00:00:00 2001 From: Brad Byrd Date: Wed, 23 Sep 2026 02:03:01 -0500 Subject: [PATCH 7/7] fix: count TLS operands in unused-variable analysis --- Engineering/evidence/2026-09-23-tls-sni.md | 9 +++++++++ History/dev-diary/2026/2026-09-23-tls-sni.md | 6 ++++++ src/analyzer/static_analyzer.rs | 12 +++++++++++- 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/Engineering/evidence/2026-09-23-tls-sni.md b/Engineering/evidence/2026-09-23-tls-sni.md index 5ed8a02c..3fd8158b 100644 --- a/Engineering/evidence/2026-09-23-tls-sni.md +++ b/Engineering/evidence/2026-09-23-tls-sni.md @@ -123,6 +123,15 @@ Ed25519. Configured SNI receives a server fatal alert; absent SNI succeeds with the exact default certificate; an unrestricted named request receives the exact named certificate. No resolver behavior change was needed. +A subsequent review found that the separate unused-variable pass did not visit +TLS operands. Test-only commit `70745701` records two failing regressions: the +real `wfl --analyze` CLI incorrectly reported all five default/named TLS +variables unused, and the analyzer unit test reported six unused variables +instead of only its deliberately unused sentinel. The fix visits both default +paths and each named certificate's certificate, key and hostname expressions. +All 38 analyzer tests and all 37 TLS tests (ten SNI, 14 parser, 13 runtime) +then passed, retaining detection of genuinely unused variables. + The initial PR head's Linux/Windows WFL-program suites, build/test/Clippy, fuzz compilation, repository hygiene, database, release scripts, config lint and Docker jobs passed. Its integration jobs reached the long-budget step without diff --git a/History/dev-diary/2026/2026-09-23-tls-sni.md b/History/dev-diary/2026/2026-09-23-tls-sni.md index bf29f8b3..43f824b6 100644 --- a/History/dev-diary/2026/2026-09-23-tls-sni.md +++ b/History/dev-diary/2026/2026-09-23-tls-sni.md @@ -28,3 +28,9 @@ and stops on an invalid operand. A separate verified TLS regression confirms that a named ECDSA key incompatible with an Ed25519-only client fails the handshake rather than falling back to a compatible default certificate covering the same hostname; the pinned Rustls resolver already preserves that behavior. + +A further review caught a separate static-analysis visitor that did not count +TLS operands as variable uses. Unit and real-CLI regressions reproduced false +unused-variable diagnostics for certificate paths, key paths, and hostnames. +That visitor now traverses both default and named pairs while retaining warnings +for genuinely unused variables. diff --git a/src/analyzer/static_analyzer.rs b/src/analyzer/static_analyzer.rs index a4e1c121..a384b161 100644 --- a/src/analyzer/static_analyzer.rs +++ b/src/analyzer/static_analyzer.rs @@ -1347,8 +1347,18 @@ impl Analyzer { self.mark_used_in_expression(headers, usages); } } - Statement::ListenStatement { port, .. } => { + Statement::ListenStatement { port, tls, .. } => { self.mark_used_in_expression(port, usages); + if let Some(tls) = tls { + for path in tls.cert_path.iter().chain(&tls.key_path) { + self.mark_used_in_expression(path, usages); + } + for certificate in &tls.sni_certificates { + self.mark_used_in_expression(&certificate.cert_path, usages); + self.mark_used_in_expression(&certificate.key_path, usages); + self.mark_used_in_expression(&certificate.hostname, usages); + } + } } Statement::WaitForRequestStatement { server,