seq_runtime/tls.rs
1//! TLS client for Seq.
2//!
3//! Wraps a connected `may::net::TcpStream` in a `rustls::ClientConnection`
4//! and stores the result in the shared `STREAMS` registry as
5//! `StreamKind::Tls`. Existing `net.tcp.read` / `net.tcp.write` /
6//! `net.tcp.close` builtins dispatch over the `StreamKind` enum
7//! transparently — the user upgrades a Socket and keeps using it.
8//!
9//! ## Surface
10//!
11//! `net.tls.client ( Socket String -- Socket Bool )` — consumes a
12//! connected TCP socket and a hostname, returns the *same* Socket id
13//! now pointing at a TLS-wrapped stream. The hostname drives SNI and
14//! webpki certificate validation; trust roots come from `webpki-roots`.
15//!
16//! ## Handshake timing
17//!
18//! Eager: the handshake completes inside this builtin via
19//! `conn.complete_io(&mut tcp)`. A bad cert, expired cert, hostname
20//! mismatch, or any other TLS-layer error surfaces as
21//! `(0, false)` — matching the way every other fallible Seq
22//! networking word reports failure. A subsequent `net.tcp.read` reads
23//! application data only.
24//!
25//! ## Known limitations (v1)
26//!
27//! - `net.tcp.close` on a TLS-wrapped socket is a *hard* close — the
28//! underlying `TcpStream` is dropped without first sending the TLS
29//! `close_notify` alert. RFC 5246 expects clients to send the alert
30//! before closing; modern servers tolerate truncation but some older
31//! stacks log it as a truncation-attack indicator. A graceful-shutdown
32//! variant is a planned follow-up.
33//! - No client-certificate authentication (mTLS).
34//! - No caller-side ALPN selection — rustls defaults apply.
35//! - No way to inspect the negotiated cipher / peer certificate from
36//! Seq. Planned follow-ups once the four-layer stack stabilises.
37
38use crate::http_client::conn::Conn;
39use crate::stack::{Stack, pop, push};
40use crate::tcp::{STREAMS, StreamKind};
41use crate::value::Value;
42use rustls::pki_types::ServerName;
43use rustls::{ClientConfig, ClientConnection, RootCertStore, StreamOwned};
44use std::sync::{Arc, LazyLock};
45
46/// Process-wide TLS client config. Trust roots are the Mozilla CA
47/// bundle shipped by `webpki-roots`; the `ring` crypto provider is
48/// installed defensively here so we don't depend on rustls's
49/// crate-features auto-install — if any transitive dep ever enables
50/// `aws_lc_rs` alongside `ring`, the auto-install path would panic at
51/// first use ("multiple default providers"). Cached for the process
52/// lifetime — the `Arc<ClientConfig>` is cheap to clone into each
53/// handshake.
54static TLS_CONFIG: LazyLock<Arc<ClientConfig>> = LazyLock::new(|| {
55 // Ignore the "already installed" Err — if another module beat us
56 // to it (or the crate-features path raced us), the provider is
57 // still ring, which is the only one this build pulls.
58 let _ = rustls::crypto::ring::default_provider().install_default();
59
60 let mut roots = RootCertStore::empty();
61 roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
62 let config = ClientConfig::builder()
63 .with_root_certificates(roots)
64 .with_no_client_auth();
65 Arc::new(config)
66});
67
68/// Upgrade a connected Socket to TLS.
69///
70/// Stack effect: `( Socket String -- Socket Bool )` — top of stack
71/// is the hostname (String), with the existing TCP socket id beneath
72/// it. On success, returns `(socket_id, true)` where `socket_id` is
73/// the *same* id the caller passed in: the registry slot is upgraded
74/// in place from `Tcp` to `Tls`, so any caller-side data structures
75/// keyed on the socket id remain valid. On failure (empty hostname,
76/// type mismatch, wrong-kind socket, handshake error, no slot found),
77/// returns `(0, false)`; on the failure paths that already took the
78/// stream out of the registry, the underlying socket is closed (the
79/// `TcpStream` is dropped) and the slot is freed.
80///
81/// # Safety
82/// Stack must have a String (hostname) on top of a Socket (Int).
83#[unsafe(no_mangle)]
84pub unsafe extern "C" fn patch_seq_tls_client(stack: Stack) -> Stack {
85 unsafe {
86 let (stack, host_val) = pop(stack);
87 let host = match host_val {
88 Value::String(s) => s,
89 _ => return push_failure(stack),
90 };
91 let (stack, sock_val) = pop(stack);
92 let socket_id = match sock_val {
93 Value::Int(id) => id as usize,
94 _ => return push_failure(stack),
95 };
96 let hostname = host.as_str_or_empty().to_string();
97 if hostname.is_empty() {
98 return push_failure(stack);
99 }
100
101 // Pull the underlying TcpStream out of the registry. If the
102 // id refers to a TLS-wrapped stream (double-upgrade) or
103 // doesn't exist, fail without disturbing the slot.
104 //
105 // Critically, we do NOT call free(socket_id) here. The slot
106 // is now Some-but-None (the Vec entry exists, but holds an
107 // empty Option) — which reserves the id for the upgrade.
108 // Freeing would push id onto the free list, and the handshake
109 // below yields the strand on every network round-trip; another
110 // strand could allocate that id in the interim, leaving the
111 // user holding a Socket integer that now refers to someone
112 // else's stream.
113 let tcp = match take_tcp(socket_id) {
114 Some(t) => t,
115 None => return push_failure(stack),
116 };
117
118 // Build the rustls ClientConnection and run the handshake to
119 // completion. complete_io drives reads/writes on the
120 // underlying may TcpStream, which yields the strand
121 // cooperatively while waiting on the network.
122 let stream = match build_tls(tcp, hostname) {
123 Ok(s) => s,
124 Err(()) => {
125 // Handshake (or earlier setup) failed. build_tls
126 // dropped the TcpStream, so the socket is closed.
127 // Release the id back to the free list so it can be
128 // reused.
129 STREAMS.lock().unwrap().free(socket_id);
130 return push_failure(stack);
131 }
132 };
133
134 // Reinstall under the *same* id. The slot has been reserved
135 // for us since take_tcp; no other strand could have claimed
136 // it across the handshake yield.
137 let installed = {
138 let mut streams = STREAMS.lock().unwrap();
139 match streams.get_mut(socket_id) {
140 Some(slot) => {
141 *slot = Some(StreamKind::Tls(Box::new(stream)));
142 true
143 }
144 None => false,
145 }
146 };
147 if !installed {
148 // The Vec shrunk under us — currently impossible with the
149 // append-only registry, but treated as a failure rather
150 // than a panic so a future eviction policy doesn't blow up
151 // the process.
152 return push_failure(stack);
153 }
154 let stack = push(stack, Value::Int(socket_id as i64));
155 push(stack, Value::Bool(true))
156 }
157}
158
159/// Take the underlying TcpStream out of STREAMS at `id`, only if the
160/// slot holds a `Tcp` variant. A `Tls` variant or empty slot
161/// short-circuits to `None`; a wrong-kind variant is restored so
162/// `tls.client` on a TLS socket doesn't accidentally destroy it.
163///
164/// On `Some` return, the slot at `id` is left holding `None` (i.e.
165/// reserved for the caller). The caller MUST either reinstall a
166/// value into that slot or call `free(id)`.
167fn take_tcp(id: usize) -> Option<may::net::TcpStream> {
168 let mut streams = STREAMS.lock().unwrap();
169 let slot = streams.get_mut(id)?;
170 match slot.take() {
171 Some(StreamKind::Tcp(t)) => Some(t),
172 Some(other) => {
173 *slot = Some(other);
174 None
175 }
176 None => None,
177 }
178}
179
180/// Build a fully-handshaked TLS stream over `tcp`. The TCP stream is
181/// consumed regardless of outcome — on Err, it is dropped (which
182/// closes the socket). The hostname is moved in: rustls's
183/// `ServerName<'static>` takes an owned `String`, so threading the
184/// caller's owned hostname through avoids a redundant clone.
185fn build_tls(
186 mut tcp: may::net::TcpStream,
187 hostname: String,
188) -> Result<StreamOwned<ClientConnection, may::net::TcpStream>, ()> {
189 let server_name = ServerName::try_from(hostname).map_err(|_| ())?;
190 let mut conn = ClientConnection::new(TLS_CONFIG.clone(), server_name).map_err(|_| ())?;
191 conn.complete_io(&mut tcp).map_err(|_| ())?;
192 Ok(StreamOwned::new(conn, tcp))
193}
194
195/// Variant of `build_tls` exposed to the HTTP client. Returns the
196/// handshaked stream already type-erased as `Conn` (a
197/// `Box<dyn HttpStream + Send>`).
198///
199/// Why erase here: the `Box::new(stream) as Conn` cast emits the
200/// vtable for `dyn HttpStream` over `StreamOwned<ClientConnection,
201/// TcpStream>`, and *that* vtable references rustls's drop chain.
202/// Keeping the cast inside `tls.rs` means the vtable is reachable
203/// only via `dial_tls`, which is itself reachable only via the HTTP
204/// client's HTTPS path. When no Seq program reaches that path,
205/// `--gc-sections` strips the vtable and the rustls drop chain
206/// disappears from the binary. The HTTP client never holds a
207/// concretely-typed TLS stream — it only ever sees `Conn`.
208pub(crate) fn dial_tls(tcp: may::net::TcpStream, hostname: String) -> Result<Conn, ()> {
209 let stream = build_tls(tcp, hostname)?;
210 Ok(Box::new(stream) as Conn)
211}
212
213unsafe fn push_failure(stack: Stack) -> Stack {
214 unsafe {
215 let stack = push(stack, Value::Int(0));
216 push(stack, Value::Bool(false))
217 }
218}