Skip to main content

ts_runtime/
serve.rs

1//! Stored Serve config + accept-loop runtime (`tsnet`'s `Get/SetServeConfig` + serving runtime).
2//!
3//! Go `tsnet` stores an `ipn.ServeConfig` on the node and runs one accept loop per configured
4//! tailnet port, dispatching each accepted connection per its handler (proxy / text / raw TCP
5//! forward / hand-back). This module is the faithful equivalent on the **application** netstack: a
6//! [`ServeManager`](crate::serve::ServeManager) owns the current [`ServeState`](ts_control::ServeState) (Go's `ipn.ServeConfig`,
7//! in its wire shape), one accept-loop task per bound port, and tears every loop down on drop / on
8//! the next `set`. The ports and what each one does come from
9//! [`ServeState::serve_plan`](ts_control::ServeState::serve_plan), which lowers the stored config
10//! onto per-port [`ServeTarget`](ts_control::ServeTarget)s; the manager stores the config and dispatches the plan.
11//!
12//! ## Storage + reconcile (full-replace)
13//!
14//! The manager holds the current [`ServeState`](ts_control::ServeState) plus one [`tokio::task::AbortHandle`] per bound
15//! port behind a single `Arc<Mutex<Inner>>` (mirroring [`crate::fallback_tcp::FallbackTcpManager`]).
16//! [`ServeManager::set`](crate::serve::ServeManager::set) uses **full-replace** semantics: it aborts *every* existing accept loop and
17//! respawns from the new config. Go reconciles incrementally (leaving unchanged ports running); we
18//! do full-replace because it is simpler and correct, and a `SetServeConfig` is a rare control-plane
19//! operation, not a hot path. The passed [`ServeState`](ts_control::ServeState) becomes the whole config (REPLACE, matching
20//! Go). `pure_reconcile` computes the add/remove port deltas for testing and documentation, even
21//! though the live path replaces wholesale.
22//!
23//! ## TLS termination
24//!
25//! TLS-terminating ports (`ServeTarget::terminates_tls`) need a `TlsAcceptor`; the caller
26//! (`Device::set_serve_config`) obtains it **once** via the cert path and hands it in per port. The
27//! manager never builds an acceptor and never touches the cert/ACME machinery — that keeps
28//! `ts_runtime` off the cert path and lets the device fail the whole `set` closed if a cert cannot
29//! be issued (no plaintext downgrade).
30//!
31//! ## Anti-leak
32//!
33//! Every accept loop binds the **overlay** netstack only (via `Channel::tcp_listen` on the
34//! device's own tailnet IPv4) — never a host socket. The `ServeTarget::Proxy` /
35//! `ServeTarget::TcpForward` backend dial is a **local host socket** to the embedder's own backend
36//! (exactly like Go's reverse-proxy to `127.0.0.1` and like [`crate::Runtime`]'s loopback proxy) —
37//! it is intentionally NOT routed through the `ts_forwarder` exit-egress path, so the exit-node
38//! anti-leak chokepoint is untouched. A backend dial failure drops the connection (fail-closed,
39//! logged); it never falls back to anything.
40
41use std::{
42    collections::{BTreeMap, BTreeSet},
43    net::{Ipv4Addr, SocketAddr},
44    sync::{Arc, Mutex},
45};
46
47use netstack::{CreateSocket, netcore::Channel, netsock::TcpStream as OverlayStream};
48use tokio::{
49    io::{AsyncRead, AsyncWrite, AsyncWriteExt},
50    sync::{Semaphore, mpsc},
51};
52use ts_control::{ServeState, ServeTarget, tls::TlsAcceptor};
53
54/// Max concurrent in-flight connections served per bound port. Bounds the per-port spawn fan-out so
55/// a flood of accepts on one serve port cannot grow tasks (and overlay sockets) without limit;
56/// saturated => the accept loop back-pressures (stops accepting) until an in-flight conn finishes.
57/// Mirrors the loopback proxy's `MAX_CONCURRENT_CONNS` rationale (each accepted conn pins an overlay
58/// TCP socket, ~512 KiB of rx+tx buffers — see `tcp_buffer_size` in AGENTS.md).
59const MAX_SERVE_CONNS_PER_PORT: usize = 256;
60
61/// A connection handed back to the embedder for a [`ServeTarget::Accept`] port (the in-process
62/// stand-in for Go `tsnet`'s `ListenTLS`-returned `net.Listener`).
63///
64/// `stream` is already TLS-terminated (the overlay stream wrapped in `tokio_rustls`'s server
65/// `TlsStream`), boxed so the channel is target-agnostic. `port` is the serve port it arrived on so
66/// an embedder serving `Accept` on several ports can demultiplex.
67pub struct ServeAccepted {
68    /// The tailnet (overlay) port this connection was accepted on.
69    pub port: u16,
70    /// The accepted, TLS-terminated stream, ready to read/write.
71    pub stream: Box<dyn AsyncReadWrite>,
72}
73
74/// Object-safe alias for the boxed accepted stream: an `AsyncRead + AsyncWrite` the embedder drives.
75pub trait AsyncReadWrite: AsyncRead + AsyncWrite + Send + Unpin {}
76impl<T: AsyncRead + AsyncWrite + Send + Unpin> AsyncReadWrite for T {}
77
78/// Receiver side of the [`ServeTarget::Accept`] hand-back channel (mirrors a `net.Listener`'s accept
79/// queue). [`ServeManager::set`] returns one; await [`recv`](mpsc::Receiver::recv) to take the next
80/// accepted, TLS-terminated connection. Dropped/replaced when the next `set` runs.
81pub type ServeAcceptedReceiver = mpsc::Receiver<ServeAccepted>;
82
83/// A fully-resolved per-port serve plan: the target plus, for TLS-terminating targets, the acceptor
84/// the device built up-front from the cert path. The caller guarantees `acceptor.is_some()` exactly
85/// when `target.terminates_tls()` — the manager asserts this is never violated by failing the bind.
86pub struct ResolvedPort {
87    /// What to serve on this port.
88    pub target: ServeTarget,
89    /// The TLS acceptor for this port, present iff `target.terminates_tls()`.
90    pub acceptor: Option<TlsAcceptor>,
91}
92
93/// Shared manager state behind a single lock.
94struct Inner {
95    /// The currently-stored config (what [`get`](ServeManager::get) returns). Empty default until
96    /// the first `set`.
97    state: ServeState,
98    /// One accept-loop abort handle per currently-bound port. Aborting a handle stops that port's
99    /// accept loop (and, transitively, drops its listener so the overlay port is released).
100    ports: BTreeMap<u16, tokio::task::AbortHandle>,
101}
102
103impl Drop for Inner {
104    fn drop(&mut self) {
105        for h in self.ports.values() {
106            h.abort();
107        }
108    }
109}
110
111/// Owns the stored Serve config and the live per-port accept loops (`tsnet` serving runtime).
112///
113/// Built once from the application netstack [`Channel`] and the device's overlay IPv4, held by the
114/// [`crate::Runtime`]. [`set`](Self::set) replaces the whole config (full-replace reconcile);
115/// dropping the manager (with the runtime / device) aborts every accept loop.
116pub struct ServeManager {
117    inner: Arc<Mutex<Inner>>,
118    channel: Channel,
119    self_ipv4: Ipv4Addr,
120}
121
122impl ServeManager {
123    /// Build a manager bound to the application netstack `channel` and the device's own tailnet
124    /// `self_ipv4` (the overlay address every serve listener binds on). No accept loop runs until the
125    /// first [`set`](Self::set).
126    pub fn new(channel: Channel, self_ipv4: Ipv4Addr) -> Self {
127        Self {
128            inner: Arc::new(Mutex::new(Inner {
129                state: ServeState::default(),
130                ports: BTreeMap::new(),
131            })),
132            channel,
133            self_ipv4,
134        }
135    }
136
137    /// The currently-stored config (Go `GetServeConfig`); empty default if none was ever set.
138    pub fn get(&self) -> ServeState {
139        self.inner
140            .lock()
141            .unwrap_or_else(|e| e.into_inner())
142            .state
143            .clone()
144    }
145
146    /// Replace the whole Serve config (Go `SetServeConfig`, REPLACE semantics), full-replace
147    /// reconcile.
148    ///
149    /// `state` is the new config; `resolved` carries the per-port target + (for TLS ports) the
150    /// pre-built acceptor, one entry per port of `state.serve_plan()`. Aborts every existing accept
151    /// loop and spawns one per port in `resolved`. Returns a fresh [`ServeAcceptedReceiver`] delivering
152    /// connections for every [`ServeTarget::Accept`] port (empty if there are none).
153    ///
154    /// The caller is responsible for validating and planning `state` and for obtaining the acceptors
155    /// (failing the whole call closed if a cert can't be issued) before calling this; the manager only
156    /// binds and dispatches.
157    pub fn set(
158        &self,
159        state: ServeState,
160        resolved: BTreeMap<u16, ResolvedPort>,
161    ) -> ServeAcceptedReceiver {
162        // A bounded channel back-pressures a slow embedder rather than buffering unboundedly.
163        let (accept_tx, accept_rx) = mpsc::channel::<ServeAccepted>(MAX_SERVE_CONNS_PER_PORT);
164
165        let mut new_ports: BTreeMap<u16, tokio::task::AbortHandle> = BTreeMap::new();
166        for (port, rp) in resolved {
167            let channel = self.channel.clone();
168            let self_ipv4 = self.self_ipv4;
169            let accept_tx = accept_tx.clone();
170            let handle = tokio::spawn(async move {
171                if let Err(e) = run_port(channel, self_ipv4, port, rp, accept_tx).await {
172                    tracing::warn!(%port, error = %e, "serve listener exited");
173                }
174            })
175            .abort_handle();
176            new_ports.insert(port, handle);
177        }
178
179        // Swap in the new state + handles under the lock; aborting the OLD handles happens when the
180        // replaced map is dropped at end of scope (after the lock is released).
181        let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
182        inner.state = state;
183        let old = std::mem::replace(&mut inner.ports, new_ports);
184        drop(inner);
185        for h in old.values() {
186            h.abort();
187        }
188
189        accept_rx
190    }
191}
192
193/// Compute which ports must be added and removed to go from `current` to `next` (pure; the diff Go
194/// reconciles incrementally). The live [`ServeManager::set`] uses full-replace, but this captures
195/// the delta for tests/documentation: a port is *changed* iff its target differs, which counts as
196/// both a remove and an add.
197#[cfg_attr(not(test), allow(dead_code))]
198fn pure_reconcile(
199    current: &BTreeMap<u16, ServeTarget>,
200    next: &BTreeMap<u16, ServeTarget>,
201) -> (BTreeSet<u16>, BTreeSet<u16>) {
202    let mut to_add = BTreeSet::new();
203    let mut to_remove = BTreeSet::new();
204    for (port, target) in next {
205        match current.get(port) {
206            Some(cur) if cur == target => {}
207            _ => {
208                to_add.insert(*port);
209            }
210        }
211    }
212    for port in current.keys() {
213        match next.get(port) {
214            Some(target) if current.get(port) == Some(target) => {}
215            _ => {
216                to_remove.insert(*port);
217            }
218        }
219    }
220    (to_add, to_remove)
221}
222
223/// Accept loop for one serve port: bind the overlay listener on `(self_ipv4, port)` and dispatch
224/// each accepted connection per `rp.target`, capped at [`MAX_SERVE_CONNS_PER_PORT`] in flight.
225async fn run_port(
226    channel: Channel,
227    self_ipv4: Ipv4Addr,
228    port: u16,
229    rp: ResolvedPort,
230    accept_tx: mpsc::Sender<ServeAccepted>,
231) -> Result<(), netstack::netcore::Error> {
232    // Anti-leak: bind the OVERLAY netstack on this node's own tailnet IPv4, never a host socket.
233    let listen_addr = SocketAddr::new(self_ipv4.into(), port);
234    let listener = channel.tcp_listen(listen_addr).await?;
235    tracing::debug!(%port, "serve listener accepting");
236
237    let rp = Arc::new(rp);
238    let inflight = Arc::new(Semaphore::new(MAX_SERVE_CONNS_PER_PORT));
239
240    loop {
241        // Acquire a permit BEFORE accepting so the loop back-pressures at the cap.
242        let Ok(permit) = inflight.clone().acquire_owned().await else {
243            return Ok(());
244        };
245        let overlay = listener.accept().await?;
246
247        let rp = rp.clone();
248        let accept_tx = accept_tx.clone();
249        tokio::spawn(async move {
250            let _permit = permit; // released when this connection finishes
251            dispatch_conn(port, overlay, rp, accept_tx).await;
252        });
253    }
254}
255
256/// Dispatch one accepted overlay connection per the port's target. TLS is terminated here (once per
257/// connection) for TLS-terminating targets; failures drop the connection (fail-closed, logged).
258async fn dispatch_conn(
259    port: u16,
260    overlay: OverlayStream,
261    rp: Arc<ResolvedPort>,
262    accept_tx: mpsc::Sender<ServeAccepted>,
263) {
264    match &rp.target {
265        // Raw passthrough: NO TLS. Splice the raw overlay stream to the local backend.
266        ServeTarget::TcpForward { to } => {
267            forward_to_backend(port, overlay, to).await;
268        }
269        // TLS-terminating targets: terminate TLS once, then act on the decrypted stream.
270        _ => {
271            let Some(acceptor) = rp.acceptor.as_ref() else {
272                // The caller's contract guarantees a TLS acceptor for every TLS-terminating port;
273                // a missing one means we must never serve plaintext — drop, fail-closed.
274                tracing::warn!(%port, "serve: missing TLS acceptor for TLS port; dropping conn");
275                return;
276            };
277            let tls = match acceptor.accept(overlay).await {
278                Ok(s) => s,
279                Err(e) => {
280                    tracing::debug!(%port, error = %e, "serve: TLS handshake failed; dropping conn");
281                    return;
282                }
283            };
284            match &rp.target {
285                ServeTarget::Accept => {
286                    // Hand the TLS-terminated stream back to the embedder over the channel.
287                    let accepted = ServeAccepted {
288                        port,
289                        stream: Box::new(tls),
290                    };
291                    if accept_tx.send(accepted).await.is_err() {
292                        tracing::debug!(%port, "serve: accept receiver dropped; closing conn");
293                    }
294                }
295                // Reached DIRECTLY (no request head consumed off `tls`): a plain splice with no
296                // prefix replay — the backend sees the client's bytes verbatim.
297                ServeTarget::Proxy { to } => {
298                    proxy_to_backend(port, tls, to).await;
299                }
300                ServeTarget::Text { body } => {
301                    write_text(port, tls, body).await;
302                }
303                ServeTarget::Redirect { to, status } => {
304                    serve_redirect(port, tls, to, *status).await;
305                }
306                ServeTarget::Path { handlers } => {
307                    serve_path(port, tls, handlers).await;
308                }
309                // `TcpForward` is handled in the non-TLS arm above; nothing else terminates TLS.
310                // The wildcard covers `#[non_exhaustive]` future raw (non-TLS) variants: if one is
311                // added it must NOT silently terminate TLS here — drop it fail-closed until this
312                // dispatch is taught how to serve it.
313                other => {
314                    debug_assert!(
315                        !other.terminates_tls(),
316                        "TLS-terminating ServeTarget reached fall-through arm"
317                    );
318                    tracing::warn!(%port, "serve: unhandled ServeTarget on TLS port; dropping conn");
319                }
320            }
321        }
322    }
323}
324
325/// Reverse-proxy a TLS-terminated stream to a local host backend (Go `Proxy` handler). The backend
326/// dial is a LOCAL host socket to the embedder's own backend — never the forwarder egress path.
327///
328/// Reached DIRECTLY from [`dispatch_conn`] (no request head has been consumed off `tls`), so no
329/// prefix replay is needed — the backend sees the client's bytes verbatim via the bidirectional
330/// splice. The `Path`-nested case (where a head WAS consumed) uses [`proxy_to_backend_with_prefix`]
331/// instead.
332async fn proxy_to_backend<S>(port: u16, tls: S, to: &str)
333where
334    S: AsyncRead + AsyncWrite + Unpin,
335{
336    proxy_to_backend_with_prefix(port, tls, to, &[]).await;
337}
338
339/// Reverse-proxy a TLS-terminated stream to a local host backend, writing `prefix` to the backend
340/// FIRST (before the bidirectional splice). This replays an HTTP request head already consumed off
341/// `tls` (e.g. by [`serve_path`]'s [`read_http_head`]) so the backend sees the complete request: the
342/// consumed request line + headers, then the rest of the body/stream via the splice. An empty
343/// `prefix` is equivalent to a plain splice ([`proxy_to_backend`]). The backend dial is a LOCAL host
344/// socket — never the forwarder egress path; any failure (dial or prefix write) drops the conn
345/// fail-closed.
346async fn proxy_to_backend_with_prefix<S>(port: u16, mut tls: S, to: &str, prefix: &[u8])
347where
348    S: AsyncRead + AsyncWrite + Unpin,
349{
350    let mut backend = match tokio::net::TcpStream::connect(to).await {
351        Ok(b) => b,
352        Err(e) => {
353            tracing::debug!(%port, %to, error = %e, "serve proxy: backend dial failed; dropping conn");
354            return;
355        }
356    };
357    if !prefix.is_empty()
358        && let Err(e) = backend.write_all(prefix).await
359    {
360        tracing::debug!(%port, %to, error = %e, "serve proxy: prefix replay failed; dropping conn");
361        return;
362    }
363    if let Err(e) = tokio::io::copy_bidirectional(&mut tls, &mut backend).await {
364        tracing::debug!(%port, %to, error = %e, "serve proxy: splice ended");
365    }
366}
367
368/// Forward a RAW (non-TLS) overlay stream to a local host backend (Go `TCPForward` handler). The
369/// backend dial is a LOCAL host socket — never the forwarder egress path.
370async fn forward_to_backend(port: u16, mut overlay: OverlayStream, to: &str) {
371    let mut backend = match tokio::net::TcpStream::connect(to).await {
372        Ok(b) => b,
373        Err(e) => {
374            tracing::debug!(%port, %to, error = %e, "serve forward: backend dial failed; dropping conn");
375            return;
376        }
377    };
378    if let Err(e) = tokio::io::copy_bidirectional(&mut overlay, &mut backend).await {
379        tracing::debug!(%port, %to, error = %e, "serve forward: splice ended");
380    }
381}
382
383/// Write a fixed body to the TLS-terminated stream, flush, and close (Go `Text` handler).
384async fn write_text<S>(port: u16, mut tls: S, body: &str)
385where
386    S: AsyncRead + AsyncWrite + Unpin,
387{
388    if let Err(e) = tls.write_all(body.as_bytes()).await {
389        tracing::debug!(%port, error = %e, "serve text: write failed");
390        return;
391    }
392    if let Err(e) = tls.flush().await {
393        tracing::debug!(%port, error = %e, "serve text: flush failed");
394    }
395    drop(tls.shutdown().await);
396}
397
398/// Max bytes of an HTTP request head (request line + headers) we will buffer before giving up. A
399/// peer that never sends `\r\n\r\n` within this exact bound is dropped fail-closed (no unbounded
400/// read); the buffer is bound-checked AFTER each read, so it never exceeds this cap.
401const MAX_HTTP_HEAD: usize = 8 * 1024;
402
403/// Read the HTTP request head (up to and including `\r\n\r\n`) from a TLS-terminated stream into a
404/// buffer. Returns `(buf, header_end)` where `header_end` is the offset just past the terminator, or
405/// `None` if the peer closed early or the head exceeded [`MAX_HTTP_HEAD`]. Hand-rolled (no
406/// axum/hyper); mirrors the peerAPI router's head-read style.
407async fn read_http_head<S>(stream: &mut S) -> Option<(Vec<u8>, usize)>
408where
409    S: AsyncRead + AsyncWrite + Unpin,
410{
411    use tokio::io::AsyncReadExt;
412
413    let mut buf = Vec::with_capacity(1024);
414    let mut tmp = [0u8; 1024];
415    loop {
416        if let Some(end) = crate::peerapi_doh::find_header_end(&buf) {
417            return Some((buf, end));
418        }
419        match stream.read(&mut tmp).await {
420            Ok(0) => return None,
421            Ok(n) => {
422                buf.extend_from_slice(&tmp[..n]);
423                // Bound-check AFTER extending so the buffer never exceeds MAX_HTTP_HEAD. The
424                // terminator is re-checked at the top of the loop, so a head whose terminator lands
425                // exactly at the bound still succeeds; only a head with no terminator within
426                // MAX_HTTP_HEAD is dropped fail-closed.
427                if crate::peerapi_doh::find_header_end(&buf).is_none() && buf.len() >= MAX_HTTP_HEAD
428                {
429                    return None;
430                }
431            }
432            Err(_) => return None,
433        }
434    }
435}
436
437/// Parse an HTTP head into the URL path the serve mux routes on: the request-line target, run
438/// through [`request_target_path`]. Returns `None` if the head is malformed, or if the target is
439/// one Go's HTTP server refuses outright — dispatch answers 400 for both, which is Go's answer
440/// too. Hand-rolled; no HTTP library framing assumptions beyond the request line.
441///
442/// The path that comes back is **not** the bytes off the wire: it is percent-decoded and stripped
443/// of scheme, authority and query, exactly as Go's `r.URL.Path` is, because that is what
444/// `getServeHandler` looks up and cleans. Normalizing it further — the cleaning, and the refusal
445/// of a path that is not absolute — is [`match_path_handler`]'s job.
446fn request_path(buf: &[u8]) -> Option<String> {
447    let mut headers = [httparse::EMPTY_HEADER; 32];
448    let mut req = httparse::Request::new(&mut headers);
449    match req.parse(buf) {
450        Ok(_) => {}
451        Err(_) => return None,
452    }
453    request_target_path(req.method.unwrap_or(""), req.path?)
454}
455
456/// The URL path Go's `net/http` hands `getServeHandler` as `r.URL.Path`, for a request with this
457/// `method` and this request-line `target`. `None` means Go's server never reaches a handler at
458/// all and answers `400 Bad Request`.
459///
460/// ## Go behaviour this mirrors
461///
462/// Go does not route on the bytes off the request line. `net/http`'s `readRequest` (Go stdlib
463/// `net/http/request.go`, go1.25.1) runs the target through `url.ParseRequestURI` — `net/url`'s
464/// `parse(rawURL, viaRequest: true)` plus `setPath`'s `unescape` (Go stdlib `net/url/url.go`) —
465/// and `getServeHandler` (`ipn/ipnlocal/serve.go` @
466/// `9ea7cba44591e0cd840c6c94d23274dd222059bf`) then looks up, cleans and walks `r.URL.Path`. That
467/// path differs from the raw target in three ways that decide where a request goes:
468///
469/// * It is **percent-decoded**. `/api/%2e%2e/secret` is the path `/api/../secret`, which
470///   [`clean_path`] resolves to `/secret` — served by `/`, never by `/api`. Route on the still
471///   encoded bytes and the cleaning sees no dot-dot segment to resolve, so the `/api` mount claims
472///   a request it was never mounted for: the exact outcome the cleaning exists to prevent, spelled
473///   with two escapes. `%2f` likewise decodes to a real `/` and becomes a segment boundary, as it
474///   does in Go.
475/// * It has **no scheme, authority or query**. For the absolute-form target every server must
476///   accept (RFC 7230 §5.3.2) — `GET http://host/api HTTP/1.1` — the path is `/api`, and the
477///   `/api` mount serves it. Cleaning the raw target instead gives `http:/host/api`, which is not
478///   absolute and would 404 a request Go serves.
479/// * It is **empty** for a target that carries no path: an authority-form `CONNECT host:443`, or a
480///   rootless opaque target like `host:443`. `path.Clean("")` is `"."`, so those still reach no
481///   mount.
482///
483/// The `None` cases are Go's own parse errors, each of which makes `readRequest` fail before any
484/// handler runs: an empty target, a control byte in it, a `%` not followed by two hex digits
485/// (`/api/%zz`), and a target that is neither rooted nor scheme-prefixed (`api/v2`).
486///
487/// **Not ported:** `parseAuthority`'s validation of the authority, which this function (like Go)
488/// then throws away. A malformed authority in an absolute-form target (`http://ho%zzst/api`) is a
489/// 400 in Go and routes on its path here. The authority reaches neither the mux nor a backend, so
490/// this can only accept a request Go rejects, never route one somewhere Go would not.
491fn request_target_path(method: &str, target: &str) -> Option<String> {
492    // `readRequest`: a CONNECT target is authority-form, so Go re-parses it as `http://<target>`
493    // and then drops the scheme it added. What comes out has an empty path unless the authority
494    // itself carries one.
495    let connect_form;
496    let raw = if method == "CONNECT" && !target.starts_with('/') {
497        connect_form = format!("http://{target}");
498        connect_form.as_str()
499    } else {
500        target
501    };
502
503    // `parse(raw, viaRequest: true)`, in its order.
504    if raw.is_empty() {
505        return None;
506    }
507    if raw.bytes().any(|b| b < b' ' || b == 0x7f) {
508        return None;
509    }
510    if raw == "*" {
511        // Go's own special case, ahead of everything else: `GET *` has the path `*`, which is not
512        // absolute and so matches no mount.
513        return Some("*".to_string());
514    }
515    let (scheme, rest) = split_scheme(raw)?;
516    // The query is cut off before the path is ever looked at.
517    let rest = rest.split_once('?').map(|(p, _)| p).unwrap_or(rest);
518    let rest = if !rest.starts_with('/') {
519        // Rootless. With a scheme this is an opaque URI, which has no path at all; without one,
520        // Go's server refuses the request.
521        if scheme.is_empty() {
522            return None;
523        }
524        ""
525    } else if !scheme.is_empty() {
526        // Absolute-form: `//authority` is consumed, and the path is whatever follows it. Go only
527        // splits an authority off a *request* target when a scheme is present, so a schemeless
528        // `//foo/bar` stays a path (and cleans to `/foo/bar`).
529        match rest.strip_prefix("//") {
530            Some(authority_and_path) => match authority_and_path.find('/') {
531                Some(i) => &authority_and_path[i..],
532                None => "",
533            },
534            None => rest,
535        }
536    } else {
537        rest
538    };
539    unescape_path(rest)
540}
541
542/// Go's `getScheme` (Go stdlib `net/url/url.go`): split a leading `scheme:` off a URL. Returns
543/// `("", raw)` when there is no scheme — an origin-form target starts with `/`, which is not a
544/// legal scheme byte — and `None` for Go's "missing protocol scheme" error, a target starting
545/// with `:`.
546fn split_scheme(raw: &str) -> Option<(&str, &str)> {
547    for (i, c) in raw.bytes().enumerate() {
548        match c {
549            b'a'..=b'z' | b'A'..=b'Z' => {}
550            // Legal inside a scheme but not as its first byte; leading one ⇒ there is no scheme.
551            b'0'..=b'9' | b'+' | b'-' | b'.' => {
552                if i == 0 {
553                    return Some(("", raw));
554                }
555            }
556            b':' => {
557                if i == 0 {
558                    return None;
559                }
560                // `i` indexes an ASCII byte, so both halves split on a char boundary.
561                return Some((&raw[..i], &raw[i + 1..]));
562            }
563            _ => return Some(("", raw)),
564        }
565    }
566    Some(("", raw))
567}
568
569/// Go's `unescape(s, encodePath)` (Go stdlib `net/url/url.go`), the decoding `setPath` applies
570/// before `r.URL.Path` is ever routed on: `%XX` becomes the byte it names, `+` stays a `+` (only a
571/// query *component* decodes it as a space), and a `%` not followed by two hex digits is Go's
572/// `EscapeError` — `None` here, a 400 there.
573///
574/// Go's decoded path is a Go string, which need not be UTF-8; a Rust `String` must be, so any
575/// undecodable byte becomes U+FFFD. Routing compares a mount only against runs of bytes delimited
576/// by `/`, and the substitution replaces no ASCII byte and introduces no `/`, so the segment
577/// structure — and with it every mount decision — is the one Go reaches on the raw bytes. (The
578/// single case it could differ in is a mount point spelled with a literal U+FFFD in it, which the
579/// decoded bytes could then equal without being it.)
580fn unescape_path(s: &str) -> Option<String> {
581    let b = s.as_bytes();
582    let mut out: Vec<u8> = Vec::with_capacity(b.len());
583    let mut i = 0usize;
584    while i < b.len() {
585        if b[i] == b'%' {
586            let hi = unhex(*b.get(i + 1)?)?;
587            let lo = unhex(*b.get(i + 2)?)?;
588            out.push(hi << 4 | lo);
589            i += 3;
590        } else {
591            out.push(b[i]);
592            i += 1;
593        }
594    }
595    // A decoded byte sequence that is not UTF-8 is kept lossily rather than refused: Go routes it,
596    // and see above for why the replacement cannot move the request to another mount.
597    Some(
598        String::from_utf8(out)
599            .unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned()),
600    )
601}
602
603/// Go's `unhex` (Go stdlib `net/url/url.go`): the value of one hex digit, or `None` if it is not one.
604fn unhex(c: u8) -> Option<u8> {
605    match c {
606        b'0'..=b'9' => Some(c - b'0'),
607        b'a'..=b'f' => Some(c - b'a' + 10),
608        b'A'..=b'F' => Some(c - b'A' + 10),
609        _ => None,
610    }
611}
612
613/// Reason phrase for a redirect status (best-effort; falls back to "Redirect").
614fn redirect_reason(status: u16) -> &'static str {
615    match status {
616        301 => "Moved Permanently",
617        302 => "Found",
618        303 => "See Other",
619        307 => "Temporary Redirect",
620        308 => "Permanent Redirect",
621        _ => "Redirect",
622    }
623}
624
625/// Write a bodyless HTTP redirect (Go `HTTPHandler` redirect) on a TLS-terminated stream, then close.
626/// Fail-closed: any write error drops the conn. No request parsing is needed — every request on a
627/// `Redirect` target gets the same response.
628async fn serve_redirect<S>(port: u16, mut tls: S, to: &str, status: u16)
629where
630    S: AsyncRead + AsyncWrite + Unpin,
631{
632    let head = format!(
633        "HTTP/1.1 {status} {reason}\r\nLocation: {to}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
634        reason = redirect_reason(status),
635    );
636    if let Err(e) = tls.write_all(head.as_bytes()).await {
637        tracing::debug!(%port, error = %e, "serve redirect: write failed");
638        return;
639    }
640    if let Err(e) = tls.flush().await {
641        tracing::debug!(%port, error = %e, "serve redirect: flush failed");
642    }
643    drop(tls.shutdown().await);
644}
645
646/// Write a bodyless HTTP status response (e.g. `404 Not Found`) on a TLS-terminated stream, then
647/// close. Local mirror of `peerapi_doh::write_status` (which takes the concrete peerAPI stream type).
648async fn write_http_status<S>(port: u16, mut tls: S, status: &str)
649where
650    S: AsyncRead + AsyncWrite + Unpin,
651{
652    let head = format!("HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
653    if let Err(e) = tls.write_all(head.as_bytes()).await {
654        tracing::debug!(%port, error = %e, "serve path: status write failed");
655        return;
656    }
657    drop(tls.flush().await);
658    drop(tls.shutdown().await);
659}
660
661/// Go's `path.Clean` (Go stdlib `path/path.go`), transliterated. This is the lexical cleaning
662/// `getServeHandler` (`ipn/ipnlocal/serve.go` @ `49e148c4a30b4f8098f69468fd27a7021d85ea02`) applies
663/// to the request path *before* it walks the mounts, so it must be the same cleaning here: dot and
664/// dot-dot segments are resolved, repeated and trailing separators collapse, and a leading dot-dot
665/// on a rooted path is dropped (`/api/../secret` ⇒ `/secret`, `/../x` ⇒ `/x`, `//a//b/` ⇒ `/a/b`).
666///
667/// Purely lexical, exactly like Go's: it never touches a filesystem and never decodes percent
668/// escapes. Non-rooted inputs keep Go's answers too — `""` and `"."` clean to `"."`, and `"*"`
669/// cleans to `"*"` — which is what makes the "not absolute" refusal in [`match_path_handler`]
670/// catch the malformed request targets.
671fn clean_path(path: &str) -> String {
672    let s = path.as_bytes();
673    if s.is_empty() {
674        return ".".to_string();
675    }
676    let n = s.len();
677    let rooted = s[0] == b'/';
678
679    // `out` is Go's `lazybuf`: the cleaned bytes written so far. `dotdot` is the index past which
680    // a `..` may still eat an element (1 on a rooted path, so `..` can never eat the leading `/`).
681    let mut out: Vec<u8> = Vec::with_capacity(n);
682    let mut r = 0usize;
683    let mut dotdot = 0usize;
684    if rooted {
685        out.push(b'/');
686        r = 1;
687        dotdot = 1;
688    }
689
690    while r < n {
691        if s[r] == b'/' {
692            // Empty path element: drop it (this is what collapses `//` and a trailing `/`).
693            r += 1;
694        } else if s[r] == b'.' && (r + 1 == n || s[r + 1] == b'/') {
695            // `.` element: drop it.
696            r += 1;
697        } else if s[r] == b'.' && r + 1 < n && s[r + 1] == b'.' && (r + 2 == n || s[r + 2] == b'/')
698        {
699            // `..` element: back up over the previously written element, if there is one.
700            r += 2;
701            if out.len() > dotdot {
702                let mut w = out.len() - 1;
703                while w > dotdot && out[w] != b'/' {
704                    w -= 1;
705                }
706                out.truncate(w);
707            } else if !rooted {
708                // Nothing to back up over and no leading `/` to anchor to: the `..` is kept, as
709                // Go keeps it (`../..` cleans to itself). A rooted path drops it instead, which is
710                // why `/../secret` is `/secret` and can never escape above the root.
711                if !out.is_empty() {
712                    out.push(b'/');
713                }
714                out.extend_from_slice(b"..");
715                dotdot = out.len();
716            }
717        } else {
718            // A real path element: add the separator if one is needed, then copy the element.
719            if (rooted && out.len() != 1) || (!rooted && !out.is_empty()) {
720                out.push(b'/');
721            }
722            while r < n && s[r] != b'/' {
723                out.push(s[r]);
724                r += 1;
725            }
726        }
727    }
728
729    if out.is_empty() {
730        return ".".to_string();
731    }
732    // Every byte written is copied verbatim from `path` (valid UTF-8) and the buffer is only ever
733    // truncated at an ASCII `/`, so this cannot split a multi-byte character.
734    String::from_utf8(out).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned())
735}
736
737/// Whether a mount point in a [`ServeTarget::Path`] map claims `path`.
738///
739/// A mount at `P` claims exactly `P` itself and the paths **below** it — i.e. `path == P`, or `path`
740/// begins with `P` followed by `/`. It does **not** claim arbitrary strings that merely start with
741/// the same bytes: a `/api` mount does not claim `/apifoo`, `/apibar` or `/api-internal`, which fall
742/// through to whatever shorter mount (typically `/`) does claim them.
743///
744/// A mount written with a trailing slash means the same thing as one without: `/api/` is normalized
745/// to `/api`, so it claims `/api/v2` without needing the request to be `/api//v2`, and it also
746/// claims the bare `/api`. The root mount `/` normalizes to the empty prefix and therefore claims
747/// every path.
748///
749/// ## Go behaviour this mirrors
750///
751/// Go's `getServeHandler` (`ipn/ipnlocal/serve.go`) never does a raw byte-prefix test. It first
752/// looks the cleaned request path up in the handler map exactly, and only then walks *backwards*
753/// over the path's `/` separators, retrying the lookup on each successively shorter truncation of
754/// the path. Because every candidate it ever tries is the path cut at a `/`, a handler can only ever
755/// be reached at a path-segment boundary — `/apifoo` never reaches the `/api` handler there, and it
756/// must not here either.
757fn mount_claims_path(mount: &str, path: &str) -> bool {
758    // "/api/" and "/api" are the same mount; "/" becomes the empty prefix, which claims everything.
759    let base = mount.strip_suffix('/').unwrap_or(mount);
760    if base.is_empty() {
761        return true;
762    }
763    match path.strip_prefix(base) {
764        // Exactly the mount itself, or a path below it. Anything else (`/apifoo` for `/api`) is a
765        // different path that merely shares a byte prefix.
766        Some(rest) => rest.is_empty() || rest.starts_with('/'),
767        None => false,
768    }
769}
770
771/// Pick the [`ServeTarget`] a request `path` dispatches to in a [`ServeTarget::Path`] mux, given
772/// the request's URL path — what [`request_path`] returns, i.e. Go's `r.URL.Path`: percent-decoded,
773/// with any scheme, authority and query already stripped, and not yet cleaned.
774///
775/// Pure and total over `(handlers, path)` — the whole routing decision, with no I/O — so it is
776/// testable directly instead of only through a TLS-terminated socket. [`serve_path`] calls this; it
777/// is the single definition of the rule, and a test that re-implemented it would be testing its own
778/// copy rather than what dispatch does.
779///
780/// ## Go behaviour this mirrors
781///
782/// `getServeHandler` (`ipn/ipnlocal/serve.go` @ `49e148c4a30b4f8098f69468fd27a7021d85ea02`) resolves
783/// a request in three steps, and so does this:
784///
785/// 1. **Exact lookup of the path.** A mount spelled exactly as the request's path wins verbatim,
786///    before any cleaning (Go: `wsc.Handlers().GetOk(r.URL.Path)`).
787/// 2. **Clean, then match.** Otherwise the path is [`clean_path`]ed — Go's `path.Clean` — and only
788///    the *cleaned* path is offered to the mounts. Dot-dot is therefore resolved **before** any
789///    mount is consulted: with mounts at `/` and `/api`, `/api/../secret` is `/secret` and is served
790///    by `/`; it must never reach the `/api` backend, which was never mounted for it. Because the
791///    path arrives percent-decoded, `/api/%2e%2e/secret` is that same request and gets that same
792///    answer.
793/// 3. **Refuse a path that is not absolute.** A cleaned path not starting with `/` matches nothing.
794///    Go needs this guard because the malformed request targets — `*` (`GET *`) and the empty path
795///    of an authority-form target — clean to `*` and `.`, which are `path.Dir` fixed points that
796///    would spin its backwards walk forever. Here the walk cannot spin, but the guard still carries
797///    Go's *routing* answer: those targets match no mount. Without it a root mount claims them,
798///    because `/` normalizes to the empty prefix that claims every string. It fires on exactly what
799///    it fires on in Go — a parsed path, which can no longer carry a scheme — so an absolute-form
800///    `GET http://host/api` is routed on `/api` here as it is there, not refused.
801///
802/// Longest match wins among the mounts that claim the cleaned path (see [`mount_claims_path`]): the
803/// one with the most path bytes is chosen, so `/api/v2` beats `/api` beats `/`. This is the same
804/// answer as Go's backwards walk, which tries the path cut at each `/` from longest to shortest.
805/// Ties (only reachable between the same mount spelled with and without a trailing slash, e.g.
806/// `/api` and `/api/`) resolve to the last in `BTreeMap` order, deterministically. `None` means no
807/// mount claims the path, which dispatch turns into a fail-closed 404.
808fn match_path_handler<'h>(
809    handlers: &'h BTreeMap<String, ServeTarget>,
810    path: &str,
811) -> Option<&'h ServeTarget> {
812    // (1) The path, looked up exactly.
813    if let Some(target) = handlers.get(path) {
814        return Some(target);
815    }
816    // (2) Everything else routes on the cleaned path, never the uncleaned one.
817    let cleaned = clean_path(path);
818    // (3) Not an absolute path => no mount claims it.
819    if !cleaned.starts_with('/') {
820        return None;
821    }
822    handlers
823        .iter()
824        .filter(|(mount, _)| mount_claims_path(mount, &cleaned))
825        .max_by_key(|(mount, _)| mount.strip_suffix('/').unwrap_or(mount).len())
826        .map(|(_, target)| target)
827}
828
829/// Serve a [`ServeTarget::Path`] mux on a TLS-terminated stream: read the request head, pick the
830/// longest-matching mount in `handlers` (via [`match_path_handler`]), and dispatch the matched
831/// nested target on the already-decrypted stream. Fail-closed: a malformed head, no matching mount,
832/// or an un-dispatchable nested target ⇒ 404/drop. For a matched nested `Proxy`, the request head consumed
833/// here is replayed to the backend first (via [`proxy_to_backend_with_prefix`]) so the backend sees
834/// the complete request. Backend dial failures inside a nested `Proxy` drop the conn. Nested `Path`
835/// is rejected by target validation, so it is not expected here; it is dropped fail-closed if it
836/// ever reaches dispatch.
837async fn serve_path<S>(port: u16, mut tls: S, handlers: &BTreeMap<String, ServeTarget>)
838where
839    S: AsyncRead + AsyncWrite + Unpin,
840{
841    let Some((buf, _end)) = read_http_head(&mut tls).await else {
842        tracing::debug!(%port, "serve path: incomplete/oversized request head; dropping conn");
843        return;
844    };
845    let Some(path) = request_path(&buf) else {
846        write_http_status(port, tls, "400 Bad Request").await;
847        return;
848    };
849
850    let Some(target) = match_path_handler(handlers, &path) else {
851        write_http_status(port, tls, "404 Not Found").await;
852        return;
853    };
854
855    match target {
856        // The request head was already consumed off `tls` by `read_http_head`; replay it (`buf`) to
857        // the backend FIRST so the backend sees the complete request (head + remaining body/stream),
858        // not a request with its first request-line+headers missing.
859        ServeTarget::Proxy { to } => proxy_to_backend_with_prefix(port, tls, to, &buf).await,
860        ServeTarget::Text { body } => write_text(port, tls, body).await,
861        ServeTarget::Redirect { to, status } => serve_redirect(port, tls, to, *status).await,
862        // Accept (no hand-back channel here), TcpForward (raw, not on a TLS path), nested Path
863        // (rejected by validate), and any future `#[non_exhaustive]` variant are not servable as a
864        // Path leaf: drop fail-closed rather than guess.
865        _ => {
866            tracing::warn!(%port, "serve path: unsupported nested target; dropping conn");
867            write_http_status(port, tls, "404 Not Found").await;
868        }
869    }
870}
871
872#[cfg(test)]
873mod tests {
874    use super::*;
875
876    fn proxy(to: &str) -> ServeTarget {
877        ServeTarget::Proxy { to: to.into() }
878    }
879
880    #[test]
881    fn cap_is_bounded() {
882        assert_eq!(MAX_SERVE_CONNS_PER_PORT, 256);
883    }
884
885    #[test]
886    fn reconcile_adds_new_ports() {
887        let current = BTreeMap::new();
888        let mut next = BTreeMap::new();
889        next.insert(443u16, ServeTarget::Accept);
890        next.insert(8443u16, proxy("127.0.0.1:8080"));
891        let (add, remove) = pure_reconcile(&current, &next);
892        assert_eq!(add, BTreeSet::from([443, 8443]));
893        assert!(remove.is_empty());
894    }
895
896    #[test]
897    fn reconcile_removes_dropped_ports() {
898        let mut current = BTreeMap::new();
899        current.insert(443u16, ServeTarget::Accept);
900        current.insert(8443u16, proxy("127.0.0.1:8080"));
901        let mut next = BTreeMap::new();
902        next.insert(443u16, ServeTarget::Accept);
903        let (add, remove) = pure_reconcile(&current, &next);
904        assert!(add.is_empty());
905        assert_eq!(remove, BTreeSet::from([8443]));
906    }
907
908    #[test]
909    fn reconcile_changed_port_is_remove_and_add() {
910        // Same port, different target => counts as both (full-replace would respawn it anyway).
911        let mut current = BTreeMap::new();
912        current.insert(443u16, proxy("127.0.0.1:8080"));
913        let mut next = BTreeMap::new();
914        next.insert(443u16, proxy("127.0.0.1:9090"));
915        let (add, remove) = pure_reconcile(&current, &next);
916        assert_eq!(add, BTreeSet::from([443]));
917        assert_eq!(remove, BTreeSet::from([443]));
918    }
919
920    #[test]
921    fn reconcile_unchanged_port_is_noop() {
922        let mut current = BTreeMap::new();
923        current.insert(443u16, ServeTarget::Accept);
924        let next = current.clone();
925        let (add, remove) = pure_reconcile(&current, &next);
926        assert!(add.is_empty());
927        assert!(remove.is_empty());
928    }
929
930    #[test]
931    fn terminates_tls_matches_dispatch_arm() {
932        // The dispatch decision (TLS vs raw) must agree with the type's own `terminates_tls`: only
933        // TcpForward is raw; Accept/Proxy/Text/Path/Redirect all terminate TLS.
934        assert!(ServeTarget::Accept.terminates_tls());
935        assert!(proxy("127.0.0.1:8080").terminates_tls());
936        assert!(ServeTarget::Text { body: "ok".into() }.terminates_tls());
937        assert!(
938            ServeTarget::Redirect {
939                to: "/elsewhere".into(),
940                status: 302,
941            }
942            .terminates_tls()
943        );
944        let mut handlers = BTreeMap::new();
945        handlers.insert("/".to_string(), proxy("127.0.0.1:8080"));
946        assert!(ServeTarget::Path { handlers }.terminates_tls());
947        assert!(
948            !ServeTarget::TcpForward {
949                to: "127.0.0.1:5000".into()
950            }
951            .terminates_tls()
952        );
953    }
954
955    #[test]
956    fn find_header_end_shared_with_peerapi_doh() {
957        // The local mirror was removed; serve dispatch now uses the shared peerAPI helper. Keep one
958        // assertion that the shared fn behaves as serve dispatch relies on (peerapi_doh owns the
959        // exhaustive coverage).
960        assert_eq!(
961            crate::peerapi_doh::find_header_end(b"GET / HTTP/1.1\r\n\r\n"),
962            Some(18)
963        );
964        assert_eq!(
965            crate::peerapi_doh::find_header_end(b"GET / HTTP/1.1\r\n"),
966            None
967        );
968    }
969
970    #[test]
971    fn request_path_strips_query() {
972        assert_eq!(
973            request_path(b"GET /api/v1?x=1 HTTP/1.1\r\nHost: h\r\n\r\n").as_deref(),
974            Some("/api/v1")
975        );
976        assert_eq!(
977            request_path(b"GET / HTTP/1.1\r\n\r\n").as_deref(),
978            Some("/")
979        );
980        assert_eq!(request_path(b"not a request").as_deref(), None);
981    }
982
983    #[test]
984    fn request_path_none_on_malformed_request_line() {
985        // No method/version framing at all => httparse rejects => None.
986        assert_eq!(request_path(b"GARBAGE\r\n\r\n").as_deref(), None);
987        // Empty buffer => incomplete => None.
988        assert_eq!(request_path(b"").as_deref(), None);
989    }
990
991    #[test]
992    fn request_target_path_matches_go_url_path() {
993        // Each row is Go's `r.URL.Path` for that request line — the string `getServeHandler` looks
994        // up, cleans and walks. `None` is Go's parse error, which its server answers with a 400
995        // without ever reaching a handler.
996        for (method, target, want) in [
997            // Origin-form: the ordinary case, query cut off.
998            ("GET", "/", Some("/")),
999            ("GET", "/api/v1", Some("/api/v1")),
1000            ("GET", "/api/v1?x=1", Some("/api/v1")),
1001            ("GET", "/api?", Some("/api")),
1002            // Percent-decoded, which is the whole point: an encoded dot-dot is a dot-dot, so
1003            // `clean_path` can resolve it before any mount is consulted.
1004            ("GET", "/api/%2e%2e/secret", Some("/api/../secret")),
1005            ("GET", "/api/%2E%2E/secret", Some("/api/../secret")),
1006            ("GET", "/api/..%2fsecret", Some("/api/../secret")),
1007            // `%2f` is a real separator once decoded, exactly as it is in Go.
1008            ("GET", "/api%2Fv2", Some("/api/v2")),
1009            ("GET", "/%41", Some("/A")),
1010            // `+` is only a space in a query *component*; in a path it stays a `+`.
1011            ("GET", "/a+b", Some("/a+b")),
1012            // Absolute-form (RFC 7230 §5.3.2): scheme and authority are stripped, leaving the path.
1013            ("GET", "http://host/api", Some("/api")),
1014            ("GET", "https://host/api/v2?x=1", Some("/api/v2")),
1015            ("GET", "http://user@host/api", Some("/api")),
1016            ("GET", "http://host", Some("")),
1017            ("GET", "http://host/", Some("/")),
1018            // No scheme means no authority to split: `//foo/bar` is a path (it cleans to /foo/bar).
1019            ("GET", "//foo/bar", Some("//foo/bar")),
1020            // Targets with no path at all. All of these clean to "." or "*", so no mount claims them.
1021            ("GET", "*", Some("*")),
1022            ("GET", "host:443", Some("")),
1023            ("CONNECT", "host:443", Some("")),
1024            ("CONNECT", "192.0.2.1:443", Some("")),
1025            ("CONNECT", "*", Some("")),
1026            // Go's parse errors => 400 before any handler.
1027            ("GET", "", None),
1028            ("GET", "api/v2", None),
1029            ("GET", ":80/x", None),
1030            ("GET", "/api/%zz", None),
1031            ("GET", "/api/%2", None),
1032            ("GET", "/api/%", None),
1033            ("GET", "/api/\u{1}", None),
1034        ] {
1035            assert_eq!(
1036                request_target_path(method, target).as_deref(),
1037                want,
1038                "request_target_path({method:?}, {target:?})"
1039            );
1040        }
1041    }
1042
1043    #[test]
1044    fn request_path_returns_the_decoded_url_path() {
1045        // Straight off the wire through the production parse: what dispatch routes on is Go's
1046        // `r.URL.Path`, not the bytes of the request target.
1047        assert_eq!(
1048            request_path(b"GET /api/%2e%2e/secret HTTP/1.1\r\nHost: h\r\n\r\n").as_deref(),
1049            Some("/api/../secret")
1050        );
1051        assert_eq!(
1052            request_path(b"GET http://host/api?x=1 HTTP/1.1\r\nHost: host\r\n\r\n").as_deref(),
1053            Some("/api")
1054        );
1055        // A bad escape is not a path: Go's server answers 400, and so does dispatch.
1056        assert_eq!(
1057            request_path(b"GET /api/%zz HTTP/1.1\r\nHost: h\r\n\r\n").as_deref(),
1058            None
1059        );
1060    }
1061
1062    /// Route one request head the way dispatch does: the production parse ([`request_path`])
1063    /// feeding the production selection ([`match_path_handler`]). Nothing here re-implements
1064    /// either.
1065    fn route<'h>(
1066        head: &[u8],
1067        handlers: &'h BTreeMap<String, ServeTarget>,
1068    ) -> Option<&'h ServeTarget> {
1069        match_path_handler(handlers, &request_path(head)?)
1070    }
1071
1072    #[test]
1073    fn percent_encoded_dot_dot_does_not_reach_the_mount_it_climbed_out_of() {
1074        // The bug: routing on the raw target leaves `%2e%2e` an ordinary path segment, so the
1075        // cleaning finds no dot-dot to resolve and the `/api` mount claims a request for
1076        // `/secret`. Go decodes first, cleans `/api/../secret` to `/secret`, and serves it from `/`.
1077        let handlers = mux();
1078        let root = proxy("127.0.0.1:1");
1079        let api = proxy("127.0.0.1:2");
1080        let api_v2 = proxy("127.0.0.1:3");
1081        for target in [
1082            "/api/%2e%2e/secret",
1083            "/api/%2E%2E/secret",
1084            "/api/..%2fsecret",
1085            "/api%2f..%2fsecret",
1086            "/api/v2/%2e%2e/%2e%2e/secret",
1087        ] {
1088            let head = format!("GET {target} HTTP/1.1\r\nHost: h\r\n\r\n");
1089            let picked = route(head.as_bytes(), &handlers);
1090            assert_ne!(
1091                picked,
1092                Some(&api),
1093                "{target} must not reach the /api backend"
1094            );
1095            assert_ne!(
1096                picked,
1097                Some(&api_v2),
1098                "{target} must not reach the /api/v2 backend"
1099            );
1100            assert_eq!(
1101                picked,
1102                Some(&root),
1103                "{target} decodes and cleans to /secret, which only / claims"
1104            );
1105        }
1106        // Decoding cuts both ways: an encoded separator that lands inside a mount routes there.
1107        let head = b"GET /api%2fv2/x HTTP/1.1\r\nHost: h\r\n\r\n";
1108        assert_eq!(
1109            route(head, &handlers),
1110            Some(&api_v2),
1111            "/api%2fv2/x decodes to /api/v2/x"
1112        );
1113    }
1114
1115    #[test]
1116    fn absolute_form_request_target_routes_on_its_path() {
1117        // RFC 7230 §5.3.2 requires every server to accept absolute-form. Go's `r.URL.Path` is
1118        // `/api`, so the `/api` mount serves it; cleaning the raw target gives `http:/host/api`,
1119        // which is not absolute and would 404.
1120        let handlers = mux();
1121        let api = proxy("127.0.0.1:2");
1122        for target in [
1123            "http://host/api",
1124            "https://host/api",
1125            "http://host/api?x=1",
1126            "http://user@host/api/v1",
1127        ] {
1128            let head = format!("GET {target} HTTP/1.1\r\nHost: host\r\n\r\n");
1129            assert_eq!(
1130                route(head.as_bytes(), &handlers),
1131                Some(&api),
1132                "{target} is a request for /api"
1133            );
1134        }
1135        // ...and the cleaning still applies to the path it carries.
1136        let head = b"GET http://host/api/../secret HTTP/1.1\r\nHost: host\r\n\r\n";
1137        assert_eq!(route(head, &handlers), Some(&proxy("127.0.0.1:1")));
1138    }
1139
1140    /// The mux `serve_path` dispatch tests below route against: root, `/api`, `/api/v2`, each with a
1141    /// distinguishable backend so a test can assert which one a path did *not* reach.
1142    fn mux() -> BTreeMap<String, ServeTarget> {
1143        let mut handlers: BTreeMap<String, ServeTarget> = BTreeMap::new();
1144        handlers.insert("/".to_string(), proxy("127.0.0.1:1"));
1145        handlers.insert("/api".to_string(), proxy("127.0.0.1:2"));
1146        handlers.insert("/api/v2".to_string(), proxy("127.0.0.1:3"));
1147        handlers
1148    }
1149
1150    #[test]
1151    fn longest_matching_mount_wins() {
1152        // Calls the production selection (`serve_path` calls the same fn) — not a copy of it.
1153        let handlers = mux();
1154        assert_eq!(
1155            match_path_handler(&handlers, "/api/v2/x"),
1156            Some(&proxy("127.0.0.1:3")),
1157            "the longest mount claiming the path must win"
1158        );
1159        assert_eq!(
1160            match_path_handler(&handlers, "/api/v1"),
1161            Some(&proxy("127.0.0.1:2"))
1162        );
1163        assert_eq!(
1164            match_path_handler(&handlers, "/api"),
1165            Some(&proxy("127.0.0.1:2"))
1166        );
1167        assert_eq!(
1168            match_path_handler(&handlers, "/other"),
1169            Some(&proxy("127.0.0.1:1"))
1170        );
1171    }
1172
1173    #[test]
1174    fn mount_does_not_claim_a_longer_first_segment() {
1175        // The negative case, and the whole point: a `/api` mount must NOT swallow `/apifoo`. A raw
1176        // byte-prefix test routes these to the `/api` backend; Go's segment-boundary lookup does
1177        // not, and neither may we. Assert where they must *not* go, not only where they must.
1178        let handlers = mux();
1179        let api = proxy("127.0.0.1:2");
1180        let root = proxy("127.0.0.1:1");
1181        for path in ["/apifoo", "/apibar", "/api-internal", "/api_v2", "/apis/x"] {
1182            let picked = match_path_handler(&handlers, path);
1183            assert_ne!(picked, Some(&api), "{path} must not reach the /api backend");
1184            assert_eq!(
1185                picked,
1186                Some(&root),
1187                "{path} must fall through to the / mount"
1188            );
1189        }
1190        // Same shape one level down: `/api/v2` must not claim `/api/v20`.
1191        let picked = match_path_handler(&handlers, "/api/v20");
1192        assert_ne!(
1193            picked,
1194            Some(&proxy("127.0.0.1:3")),
1195            "/api/v20 must not reach the /api/v2 backend"
1196        );
1197        assert_eq!(picked, Some(&api));
1198    }
1199
1200    #[test]
1201    fn mount_claims_itself_and_paths_below_it() {
1202        assert!(mount_claims_path("/api", "/api"));
1203        assert!(mount_claims_path("/api", "/api/"));
1204        assert!(mount_claims_path("/api", "/api/v2/x"));
1205        assert!(!mount_claims_path("/api", "/apifoo"));
1206        assert!(!mount_claims_path("/api", "/ap"));
1207        assert!(!mount_claims_path("/api", "/"));
1208        // The root mount claims everything.
1209        assert!(mount_claims_path("/", "/"));
1210        assert!(mount_claims_path("/", "/anything/at/all"));
1211    }
1212
1213    #[test]
1214    fn trailing_slash_mount_needs_no_doubled_slash() {
1215        // `/api/` is the same mount as `/api`: it claims `/api/v2`, not only `/api//v2`.
1216        assert!(mount_claims_path("/api/", "/api/v2"));
1217        assert!(mount_claims_path("/api/", "/api/"));
1218        assert!(mount_claims_path("/api/", "/api"));
1219        assert!(!mount_claims_path("/api/", "/apifoo"));
1220
1221        let mut handlers: BTreeMap<String, ServeTarget> = BTreeMap::new();
1222        handlers.insert("/".to_string(), proxy("127.0.0.1:1"));
1223        handlers.insert("/api/".to_string(), proxy("127.0.0.1:2"));
1224        assert_eq!(
1225            match_path_handler(&handlers, "/api/v2"),
1226            Some(&proxy("127.0.0.1:2"))
1227        );
1228        assert_eq!(
1229            match_path_handler(&handlers, "/apifoo"),
1230            Some(&proxy("127.0.0.1:1")),
1231            "/apifoo must fall through to / even when the mount is spelled /api/"
1232        );
1233    }
1234
1235    #[test]
1236    fn clean_path_matches_go_path_clean() {
1237        // The table is Go's own `path.Clean` test table (Go stdlib `path/path_test.go`), which is
1238        // the cleaning `getServeHandler` applies before it consults the mounts.
1239        for (input, want) in [
1240            ("", "."),
1241            ("abc", "abc"),
1242            ("abc/def", "abc/def"),
1243            ("a/b/c", "a/b/c"),
1244            (".", "."),
1245            ("..", ".."),
1246            ("../..", "../.."),
1247            ("/abc", "/abc"),
1248            ("/", "/"),
1249            ("abc/", "abc"),
1250            ("abc/def/", "abc/def"),
1251            ("a/b/c/", "a/b/c"),
1252            ("./", "."),
1253            ("../", ".."),
1254            ("../../", "../.."),
1255            ("/abc/", "/abc"),
1256            ("abc//def//ghi", "abc/def/ghi"),
1257            ("//abc", "/abc"),
1258            ("///abc", "/abc"),
1259            ("//abc//", "/abc"),
1260            ("abc//", "abc"),
1261            ("abc/./def", "abc/def"),
1262            ("/./abc/def", "/abc/def"),
1263            ("abc/..", "."),
1264            ("abc/def/..", "abc"),
1265            ("abc/def/../ghi", "abc/ghi"),
1266            ("abc/def/../../ghi", "ghi"),
1267            ("abc/def/../../..", ".."),
1268            ("/abc/def/../../..", "/"),
1269            ("abc/./../def", "def"),
1270            ("abc//./../def", "def"),
1271            ("abc/../../././../def", "../../def"),
1272            // A rooted path can never climb above the root: the leading `..` is dropped.
1273            ("/../abc", "/abc"),
1274            ("/api/../secret", "/secret"),
1275            // The malformed request targets. Neither becomes absolute, which is what the
1276            // "not absolute" refusal keys off.
1277            ("*", "*"),
1278            ("host:443", "host:443"),
1279        ] {
1280            assert_eq!(clean_path(input), want, "clean_path({input:?})");
1281        }
1282    }
1283
1284    #[test]
1285    fn dot_dot_segment_is_cleaned_before_the_mounts_are_consulted() {
1286        // The bug: matching the RAW target means `/api/../secret` starts with `/api/`, so the `/api`
1287        // mount claims it and the request reaches a backend it was never mounted for. Go cleans
1288        // first — the path is `/secret`, which only the `/` mount claims.
1289        let handlers = mux();
1290        let root = proxy("127.0.0.1:1");
1291        let api = proxy("127.0.0.1:2");
1292        let api_v2 = proxy("127.0.0.1:3");
1293
1294        for path in [
1295            "/api/../secret",
1296            "/api/v2/../../secret",
1297            "/api/./../secret",
1298            "/api/..//secret",
1299            // Climbing above the root is dropped, not an escape: still `/secret`.
1300            "/../api/../secret",
1301        ] {
1302            let picked = match_path_handler(&handlers, path);
1303            assert_ne!(picked, Some(&api), "{path} must not reach the /api backend");
1304            assert_ne!(
1305                picked,
1306                Some(&api_v2),
1307                "{path} must not reach the /api/v2 backend"
1308            );
1309            assert_eq!(
1310                picked,
1311                Some(&root),
1312                "{path} cleans to /secret, which only / claims"
1313            );
1314        }
1315
1316        // Cleaning cuts both ways: a dot-dot that lands back inside a mount still routes there.
1317        assert_eq!(
1318            match_path_handler(&handlers, "/api/v2/../v2/x"),
1319            Some(&api_v2),
1320            "/api/v2/../v2/x cleans to /api/v2/x"
1321        );
1322        assert_eq!(
1323            match_path_handler(&handlers, "/api/v2/.."),
1324            Some(&api),
1325            "/api/v2/.. cleans to /api"
1326        );
1327        // Redundant separators and dot segments normalize away too.
1328        assert_eq!(match_path_handler(&handlers, "//api//v2//x"), Some(&api_v2));
1329        assert_eq!(match_path_handler(&handlers, "/api/./v2"), Some(&api_v2));
1330    }
1331
1332    #[test]
1333    fn malformed_request_target_matches_no_mount() {
1334        // `GET * HTTP/1.1` yields the target `*`, and an authority-form target has no path at all.
1335        // Go refuses both (they do not clean to an absolute path). A root mount normalizes to the
1336        // empty prefix that claims every string, so without the refusal `*` would be served by `/`.
1337        let handlers = mux();
1338        for target in ["*", "host:443", "example.com:443", "", ".", "..", "api/v2"] {
1339            assert_eq!(
1340                match_path_handler(&handlers, target),
1341                None,
1342                "{target:?} is not an absolute path and must match no mount, not even /"
1343            );
1344        }
1345        // A mount spelled exactly as the raw target still wins: that is Go's first lookup, which
1346        // happens before the cleaning and the absolute-path refusal.
1347        let mut odd: BTreeMap<String, ServeTarget> = BTreeMap::new();
1348        odd.insert("*".to_string(), proxy("127.0.0.1:9"));
1349        assert_eq!(match_path_handler(&odd, "*"), Some(&proxy("127.0.0.1:9")));
1350    }
1351
1352    #[test]
1353    fn unmatched_path_selects_nothing() {
1354        // No root mount => a path no mount claims is `None`, which dispatch turns into a 404.
1355        let mut handlers: BTreeMap<String, ServeTarget> = BTreeMap::new();
1356        handlers.insert("/api".to_string(), proxy("127.0.0.1:2"));
1357        assert_eq!(match_path_handler(&handlers, "/apifoo"), None);
1358        assert_eq!(match_path_handler(&handlers, "/other"), None);
1359        assert_eq!(
1360            match_path_handler(&handlers, "/api/v2"),
1361            Some(&proxy("127.0.0.1:2"))
1362        );
1363    }
1364
1365    #[test]
1366    fn redirect_reason_known_statuses() {
1367        assert_eq!(redirect_reason(301), "Moved Permanently");
1368        assert_eq!(redirect_reason(308), "Permanent Redirect");
1369        assert_eq!(redirect_reason(399), "Redirect");
1370    }
1371
1372    use tokio::io::{AsyncReadExt, AsyncWriteExt};
1373
1374    /// Read everything the server side wrote to the `client` half of a duplex until the server task
1375    /// closes its end (drop/shutdown), returning it as a `String`.
1376    async fn drain_to_string(mut client: tokio::io::DuplexStream) -> String {
1377        let mut out = Vec::new();
1378        drop(client.read_to_end(&mut out).await);
1379        String::from_utf8(out).expect("server emitted valid utf8")
1380    }
1381
1382    #[tokio::test]
1383    async fn serve_redirect_emits_exact_response() {
1384        let (client, server) = tokio::io::duplex(4096);
1385        let t = tokio::spawn(async move {
1386            serve_redirect(443, server, "/elsewhere", 302).await;
1387        });
1388        let got = drain_to_string(client).await;
1389        t.await.unwrap();
1390        assert_eq!(
1391            got,
1392            "HTTP/1.1 302 Found\r\nLocation: /elsewhere\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
1393        );
1394    }
1395
1396    #[tokio::test]
1397    async fn write_http_status_emits_status_line() {
1398        let (client, server) = tokio::io::duplex(4096);
1399        let t = tokio::spawn(async move {
1400            write_http_status(443, server, "404 Not Found").await;
1401        });
1402        let got = drain_to_string(client).await;
1403        t.await.unwrap();
1404        assert_eq!(
1405            got,
1406            "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
1407        );
1408
1409        let (client, server) = tokio::io::duplex(4096);
1410        let t = tokio::spawn(async move {
1411            write_http_status(443, server, "400 Bad Request").await;
1412        });
1413        let got = drain_to_string(client).await;
1414        t.await.unwrap();
1415        assert_eq!(
1416            got,
1417            "HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
1418        );
1419    }
1420
1421    #[tokio::test]
1422    async fn read_http_head_reads_terminated_head() {
1423        let (mut client, mut server) = tokio::io::duplex(4096);
1424        client
1425            .write_all(b"GET /api HTTP/1.1\r\nHost: h\r\n\r\nBODY")
1426            .await
1427            .unwrap();
1428        drop(client);
1429        let (buf, end) = read_http_head(&mut server).await.expect("complete head");
1430        // `end` points just past the terminator; the head + trailing body are both buffered.
1431        assert_eq!(&buf[..end], b"GET /api HTTP/1.1\r\nHost: h\r\n\r\n");
1432        assert_eq!(&buf[end..], b"BODY");
1433    }
1434
1435    #[tokio::test]
1436    async fn read_http_head_none_on_early_eof() {
1437        let (mut client, mut server) = tokio::io::duplex(4096);
1438        client.write_all(b"GET / HTTP/1.1\r\n").await.unwrap();
1439        drop(client); // EOF before the terminator
1440        assert!(read_http_head(&mut server).await.is_none());
1441    }
1442
1443    #[tokio::test]
1444    async fn read_http_head_none_on_oversized_head() {
1445        let (mut client, mut server) = tokio::io::duplex(64 * 1024);
1446        // A head that never terminates and exceeds MAX_HTTP_HEAD must be dropped fail-closed.
1447        let oversized = vec![b'a'; MAX_HTTP_HEAD + 1024];
1448        client.write_all(&oversized).await.unwrap();
1449        drop(client);
1450        assert!(read_http_head(&mut server).await.is_none());
1451    }
1452
1453    #[tokio::test]
1454    async fn read_http_head_never_exceeds_max_head() {
1455        // A terminator landing exactly at the bound still succeeds (the buffer never overshoots).
1456        let (mut client, mut server) = tokio::io::duplex(MAX_HTTP_HEAD + 16);
1457        let mut head = vec![b'a'; MAX_HTTP_HEAD - 4];
1458        head.extend_from_slice(b"\r\n\r\n");
1459        assert_eq!(head.len(), MAX_HTTP_HEAD);
1460        client.write_all(&head).await.unwrap();
1461        drop(client);
1462        let (buf, end) = read_http_head(&mut server).await.expect("head at bound");
1463        assert_eq!(end, MAX_HTTP_HEAD);
1464        assert!(buf.len() <= MAX_HTTP_HEAD);
1465    }
1466
1467    #[tokio::test]
1468    async fn proxy_with_prefix_writes_prefix_before_bidi_copy() {
1469        // Fix 1 regression guard: the consumed request head MUST hit the backend FIRST, before the
1470        // bidirectional splice forwards the rest of the client stream. The backend is a real
1471        // loopback TcpListener (the helper dials `to` via tokio TcpStream).
1472        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1473        let backend_addr = listener.local_addr().unwrap();
1474
1475        let prefix = b"GET /api HTTP/1.1\r\nHost: h\r\n\r\n";
1476        let body = b"trailing-body-bytes";
1477        let backend = tokio::spawn(async move {
1478            let (mut sock, _) = listener.accept().await.unwrap();
1479            let mut head = vec![0u8; prefix.len()];
1480            sock.read_exact(&mut head).await.unwrap();
1481            let mut rest = vec![0u8; body.len()];
1482            sock.read_exact(&mut rest).await.unwrap();
1483            (head, rest)
1484        });
1485
1486        // Client side of the duplex stands in for the TLS-terminated stream the helper splices.
1487        let (mut client, server) = tokio::io::duplex(4096);
1488        let to = backend_addr.to_string();
1489        let proxy_task = tokio::spawn(async move {
1490            proxy_to_backend_with_prefix(443, server, &to, prefix).await;
1491        });
1492
1493        // Feed the rest of the request body through the splice, then close.
1494        client.write_all(body).await.unwrap();
1495        drop(client);
1496
1497        let (head, rest) = backend.await.unwrap();
1498        proxy_task.await.unwrap();
1499        assert_eq!(
1500            head, prefix,
1501            "prefix (consumed head) replayed to backend first"
1502        );
1503        assert_eq!(rest, body, "remaining stream spliced after the prefix");
1504    }
1505
1506    #[tokio::test]
1507    async fn serve_path_proxy_replays_consumed_head_to_backend() {
1508        // End-to-end longest-prefix selection routing to a nested Proxy: the head consumed by
1509        // `read_http_head` must reach the backend, proving the request is not dropped (the bug).
1510        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1511        let backend_addr = listener.local_addr().unwrap();
1512        let request = b"GET /api/v2/x HTTP/1.1\r\nHost: h\r\n\r\n";
1513        let backend = tokio::spawn(async move {
1514            let (mut sock, _) = listener.accept().await.unwrap();
1515            let mut head = vec![0u8; request.len()];
1516            sock.read_exact(&mut head).await.unwrap();
1517            head
1518        });
1519
1520        let mut handlers: BTreeMap<String, ServeTarget> = BTreeMap::new();
1521        handlers.insert("/".to_string(), proxy("127.0.0.1:1")); // shorter prefix (not selected)
1522        handlers.insert("/api/v2".to_string(), proxy(&backend_addr.to_string())); // longest match
1523
1524        let (mut client, server) = tokio::io::duplex(4096);
1525        let path_task = tokio::spawn(async move {
1526            serve_path(443, server, &handlers).await;
1527        });
1528        client.write_all(request).await.unwrap();
1529        drop(client);
1530
1531        let head = backend.await.unwrap();
1532        path_task.await.unwrap();
1533        assert_eq!(
1534            head, request,
1535            "serve_path routed to the longest-prefix Proxy and replayed the consumed head"
1536        );
1537    }
1538
1539    #[tokio::test]
1540    async fn serve_path_text_target_emits_body() {
1541        // Longest-prefix selection routing to a nested Text target: the body is emitted verbatim.
1542        let mut handlers: BTreeMap<String, ServeTarget> = BTreeMap::new();
1543        handlers.insert(
1544            "/".to_string(),
1545            ServeTarget::Text {
1546                body: "root".into(),
1547            },
1548        );
1549        handlers.insert(
1550            "/hello".to_string(),
1551            ServeTarget::Text {
1552                body: "hello-body".into(),
1553            },
1554        );
1555
1556        let (mut client, server) = tokio::io::duplex(4096);
1557        let t = tokio::spawn(async move {
1558            serve_path(443, server, &handlers).await;
1559        });
1560        client
1561            .write_all(b"GET /hello/world HTTP/1.1\r\nHost: h\r\n\r\n")
1562            .await
1563            .unwrap();
1564        // Keep the client half open: `read_http_head` already saw the full head, and the Text target
1565        // neither reads further nor needs EOF. Drain the body the server writes + shuts down.
1566        let got = drain_to_string(client).await;
1567        t.await.unwrap();
1568        assert_eq!(got, "hello-body");
1569    }
1570
1571    #[tokio::test]
1572    async fn serve_path_does_not_route_a_longer_first_segment_to_the_shorter_mount() {
1573        // End to end through the real dispatch: with `/` and `/hello` mounted, `/hellofoo` is a
1574        // different path, not a path below `/hello`, so it must be served by the `/` mount.
1575        let mut handlers: BTreeMap<String, ServeTarget> = BTreeMap::new();
1576        handlers.insert(
1577            "/".to_string(),
1578            ServeTarget::Text {
1579                body: "root".into(),
1580            },
1581        );
1582        handlers.insert(
1583            "/hello".to_string(),
1584            ServeTarget::Text {
1585                body: "hello-body".into(),
1586            },
1587        );
1588
1589        let (mut client, server) = tokio::io::duplex(4096);
1590        let t = tokio::spawn(async move {
1591            serve_path(443, server, &handlers).await;
1592        });
1593        client
1594            .write_all(b"GET /hellofoo HTTP/1.1\r\nHost: h\r\n\r\n")
1595            .await
1596            .unwrap();
1597        let got = drain_to_string(client).await;
1598        t.await.unwrap();
1599        assert_ne!(
1600            got, "hello-body",
1601            "/hellofoo must not reach the /hello mount"
1602        );
1603        assert_eq!(got, "root");
1604    }
1605
1606    /// Text mux used by the dispatch tests below: `/` and `/api` with distinguishable bodies, so a
1607    /// test can assert which backend a request did *not* reach.
1608    fn text_mux() -> BTreeMap<String, ServeTarget> {
1609        let mut handlers: BTreeMap<String, ServeTarget> = BTreeMap::new();
1610        handlers.insert(
1611            "/".to_string(),
1612            ServeTarget::Text {
1613                body: "root".into(),
1614            },
1615        );
1616        handlers.insert(
1617            "/api".to_string(),
1618            ServeTarget::Text {
1619                body: "api-body".into(),
1620            },
1621        );
1622        handlers
1623    }
1624
1625    /// Run one raw request line through the real dispatch and return everything the server wrote.
1626    async fn serve_path_response(
1627        request: &[u8],
1628        handlers: BTreeMap<String, ServeTarget>,
1629    ) -> String {
1630        let (mut client, server) = tokio::io::duplex(4096);
1631        let t = tokio::spawn(async move {
1632            serve_path(443, server, &handlers).await;
1633        });
1634        client.write_all(request).await.unwrap();
1635        let got = drain_to_string(client).await;
1636        t.await.unwrap();
1637        got
1638    }
1639
1640    #[tokio::test]
1641    async fn serve_path_does_not_route_a_dot_dot_target_to_the_mount_it_climbed_out_of() {
1642        // End to end through the real dispatch: the request target names `/api`, but it climbs out
1643        // of it. Go cleans to `/secret` and serves it from `/`; the `/api` backend must never see
1644        // it — it was never mounted for `/secret`.
1645        let got = serve_path_response(
1646            b"GET /api/../secret HTTP/1.1\r\nHost: h\r\n\r\n",
1647            text_mux(),
1648        )
1649        .await;
1650        assert_ne!(
1651            got, "api-body",
1652            "/api/../secret must not reach the /api mount"
1653        );
1654        assert_eq!(got, "root", "/api/../secret cleans to /secret, served by /");
1655
1656        // The query string is stripped before cleaning, exactly as Go cleans `r.URL.Path`.
1657        let got = serve_path_response(
1658            b"GET /api/../secret?x=1 HTTP/1.1\r\nHost: h\r\n\r\n",
1659            text_mux(),
1660        )
1661        .await;
1662        assert_eq!(got, "root");
1663
1664        // And a target that stays inside the mount after cleaning still reaches it.
1665        let got =
1666            serve_path_response(b"GET /api/v2/../v2 HTTP/1.1\r\nHost: h\r\n\r\n", text_mux()).await;
1667        assert_eq!(got, "api-body");
1668    }
1669
1670    #[tokio::test]
1671    async fn serve_path_404s_a_malformed_request_target() {
1672        // `GET *` parses fine as a request line but is not an absolute path. Go matches no handler
1673        // for it; here the root mount would otherwise claim it, since `/` normalizes to the empty
1674        // prefix. Fail closed with a 404 instead of serving the root backend.
1675        let got = serve_path_response(b"GET * HTTP/1.1\r\nHost: h\r\n\r\n", text_mux()).await;
1676        assert_ne!(got, "root", "`GET *` must not be served by the / mount");
1677        assert!(
1678            got.starts_with("HTTP/1.1 404 Not Found\r\n"),
1679            "expected a 404, got {got:?}"
1680        );
1681
1682        // Authority-form (`CONNECT host:443`) likewise has no path to route on.
1683        let got =
1684            serve_path_response(b"CONNECT host:443 HTTP/1.1\r\nHost: h\r\n\r\n", text_mux()).await;
1685        assert!(
1686            got.starts_with("HTTP/1.1 404 Not Found\r\n"),
1687            "expected a 404, got {got:?}"
1688        );
1689    }
1690
1691    #[tokio::test]
1692    async fn serve_path_does_not_route_a_percent_encoded_dot_dot_to_the_mount_it_climbed_out_of() {
1693        // End to end through the real dispatch, with the two dots spelled as escapes: the request
1694        // target names `/api` but climbs out of it, and the `/api` backend must never see it.
1695        for target in [
1696            "/api/%2e%2e/secret",
1697            "/api/..%2fsecret",
1698            "/api%2f..%2fsecret",
1699        ] {
1700            let head = format!("GET {target} HTTP/1.1\r\nHost: h\r\n\r\n");
1701            let got = serve_path_response(head.as_bytes(), text_mux()).await;
1702            assert_ne!(got, "api-body", "{target} must not reach the /api mount");
1703            assert_eq!(
1704                got, "root",
1705                "{target} decodes and cleans to /secret, served by /"
1706            );
1707        }
1708    }
1709
1710    #[tokio::test]
1711    async fn serve_path_serves_an_absolute_form_request_target() {
1712        // `GET http://host/api HTTP/1.1` is a request for `/api` — absolute-form, which RFC 7230
1713        // §5.3.2 requires every server to accept. Routing on the raw target 404s it.
1714        let got = serve_path_response(
1715            b"GET http://host/api HTTP/1.1\r\nHost: host\r\n\r\n",
1716            text_mux(),
1717        )
1718        .await;
1719        assert_eq!(
1720            got, "api-body",
1721            "absolute-form /api must reach the /api mount"
1722        );
1723
1724        let got = serve_path_response(
1725            b"GET https://host/other?x=1 HTTP/1.1\r\nHost: host\r\n\r\n",
1726            text_mux(),
1727        )
1728        .await;
1729        assert_eq!(got, "root");
1730
1731        // The path it carries is cleaned like any other.
1732        let got = serve_path_response(
1733            b"GET http://host/api/../secret HTTP/1.1\r\nHost: host\r\n\r\n",
1734            text_mux(),
1735        )
1736        .await;
1737        assert_eq!(got, "root");
1738    }
1739
1740    #[tokio::test]
1741    async fn serve_path_400s_a_target_go_refuses_to_parse() {
1742        // Go's server fails these in `readRequest`, before any handler runs, and answers 400.
1743        for target in ["/api/%zz", "api/v2"] {
1744            let head = format!("GET {target} HTTP/1.1\r\nHost: h\r\n\r\n");
1745            let got = serve_path_response(head.as_bytes(), text_mux()).await;
1746            assert_ne!(got, "api-body", "{target} must reach no mount");
1747            assert_ne!(got, "root", "{target} must reach no mount");
1748            assert!(
1749                got.starts_with("HTTP/1.1 400 Bad Request\r\n"),
1750                "expected a 400 for {target}, got {got:?}"
1751            );
1752        }
1753    }
1754
1755    // NOTE: a live bind+accept test needs a running netstack channel + overlay; the existing
1756    // netstack-backed managers (fallback_tcp) likewise unit-test only the pure pieces (port diff,
1757    // dispatch decision) and leave the bind/accept path to integration coverage. The byte-emission
1758    // helpers above are exercised directly over `tokio::io::duplex` + loopback `TcpStream` backends;
1759    // the bind/accept/splice path is exercised via `Device::set_serve_config` against a real device.
1760}