Skip to main content

go_http/
handler.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use std::collections::HashMap;
4use std::sync::{Arc, RwLock};
5use std::time::Duration;
6
7use crate::error::HttpError;
8use crate::request::Request;
9use crate::response::ResponseWriter;
10
11// ---------------------------------------------------------------------------
12// Handler trait
13// ---------------------------------------------------------------------------
14
15/// The core handler interface.  Port of Go's `http.Handler`.
16pub trait Handler: Send + Sync {
17    fn serve_http(&self, w: &mut dyn ResponseWriter, r: &mut Request);
18}
19
20// ---------------------------------------------------------------------------
21// HandlerFunc
22// ---------------------------------------------------------------------------
23
24/// Adapter that turns a function into a `Handler`.
25/// Port of Go's `http.HandlerFunc`.
26type HandlerFn = Box<dyn Fn(&mut dyn ResponseWriter, &mut Request) + Send + Sync>;
27pub struct HandlerFunc(pub HandlerFn);
28
29impl Handler for HandlerFunc {
30    fn serve_http(&self, w: &mut dyn ResponseWriter, r: &mut Request) {
31        (self.0)(w, r)
32    }
33}
34
35/// Any `Arc<H>` where `H: Handler` is itself a `Handler`.  This lets you
36/// wrap a mux or other handler in an `Arc` and pass it directly to middleware
37/// functions like `timeout_handler`.
38impl<H: Handler> Handler for Arc<H> {
39    fn serve_http(&self, w: &mut dyn ResponseWriter, r: &mut Request) {
40        (**self).serve_http(w, r)
41    }
42}
43
44/// Convenience constructor.
45pub fn handler_func<F>(f: F) -> HandlerFunc
46where
47    F: Fn(&mut dyn ResponseWriter, &mut Request) + Send + Sync + 'static,
48{
49    HandlerFunc(Box::new(f))
50}
51
52// ---------------------------------------------------------------------------
53// ServeMux — Go 1.22-style routing
54// ---------------------------------------------------------------------------
55
56/// A single parsed segment of a URL pattern.
57#[derive(Clone, Debug)]
58enum Segment {
59    Literal(String),
60    /// `{name}` — matches exactly one path component.
61    Wildcard { name: String },
62    /// `{name...}` — matches all remaining path components (must be last).
63    Tail { name: String },
64}
65
66/// A fully parsed mux pattern supporting method prefix, host prefix, and
67/// wildcard path segments as introduced in Go 1.22.
68///
69/// Grammar: `[METHOD ][HOST]/PATH`
70///   - `METHOD` is optional; any capitalized HTTP method word.
71///   - `HOST` is optional; present when the pattern does not start with `/`.
72///   - `PATH` may contain `{name}` (wildcard segment) and `{name...}` (tail).
73#[derive(Clone, Debug)]
74struct ParsedPattern {
75    method:             Option<String>,
76    host:               Option<String>,
77    segments:           Vec<Segment>,
78    has_trailing_slash: bool,
79}
80
81struct MuxEntry {
82    raw:     String,
83    parsed:  ParsedPattern,
84    handler: Arc<dyn Handler>,
85}
86
87/// HTTP request multiplexer.  Port of Go's `http.ServeMux` (Go 1.22+).
88///
89/// Matching rules:
90/// 1. More-specific patterns beat less-specific ones (method + host > host > path).
91/// 2. Literal path segments beat wildcard segments; wildcards beat tail wildcards.
92/// 3. Exact path beats trailing-slash subtree of the same length.
93/// 4. Among equally-specific patterns, longer paths win.
94/// 5. A pattern matching path but not method returns 405 Method Not Allowed.
95pub struct ServeMux {
96    entries: RwLock<Vec<MuxEntry>>,
97}
98
99impl ServeMux {
100    pub fn new() -> Self {
101        Self { entries: RwLock::new(Vec::new()) }
102    }
103
104    /// Register a handler for the given pattern.
105    ///
106    /// Pattern syntax: `[METHOD ][HOST]/PATH`
107    ///
108    /// | Example                  | Meaning                                      |
109    /// |--------------------------|----------------------------------------------|
110    /// | `/`                      | subtree catch-all (old-style)                |
111    /// | `/api/v2/`               | subtree prefix                               |
112    /// | `/items/{id}`            | single wildcard segment                      |
113    /// | `/files/{path...}`       | tail wildcard (matches rest of path)         |
114    /// | `GET /api/users`         | method-restricted exact route                |
115    /// | `example.com/`           | host-restricted subtree                      |
116    /// | `GET example.com/{id}`   | method + host + wildcard                     |
117    pub fn handle(&self, pattern: &str, handler: impl Handler + 'static) {
118        self.handle_arc(pattern, Arc::new(handler));
119    }
120
121    /// Register a function as a handler.
122    pub fn handle_func<F>(&self, pattern: &str, f: F)
123    where
124        F: Fn(&mut dyn ResponseWriter, &mut Request) + Send + Sync + 'static,
125    {
126        self.handle(pattern, handler_func(f));
127    }
128
129    fn handle_arc(&self, pattern: &str, handler: Arc<dyn Handler>) {
130        let parsed = parse_pattern(pattern);
131        let mut entries = self.entries.write().unwrap();
132        if let Some(e) = entries.iter_mut().find(|e| e.raw == pattern) {
133            e.handler = handler;
134            return;
135        }
136        entries.push(MuxEntry { raw: pattern.to_owned(), parsed, handler });
137    }
138
139    /// Find the best matching handler for a bare `path` (no method/host
140    /// filtering).  Retained for backward compatibility; prefer the full
141    /// `serve_http` path for new code.
142    pub fn match_handler(&self, path: &str) -> Option<Arc<dyn Handler>> {
143        let entries = self.entries.read().unwrap();
144        let (h, _, _) = match_with_params(&entries, "", "", path);
145        h
146    }
147}
148
149impl Handler for ServeMux {
150    fn serve_http(&self, w: &mut dyn ResponseWriter, r: &mut Request) {
151        let entries = self.entries.read().unwrap();
152        let (handler, params, method_not_allowed) =
153            match_with_params(&entries, &r.method, &r.host, r.url.path());
154        drop(entries);
155
156        match handler {
157            Some(h) => {
158                r.path_params = params;
159                h.serve_http(w, r);
160            }
161            None if method_not_allowed => {
162                w.write_header(crate::status::METHOD_NOT_ALLOWED);
163                let _ = w.write(b"405 method not allowed\n");
164            }
165            None => not_found_handler().serve_http(w, r),
166        }
167    }
168}
169
170impl Default for ServeMux {
171    fn default() -> Self {
172        Self::new()
173    }
174}
175
176// ---------------------------------------------------------------------------
177// Pattern parsing
178// ---------------------------------------------------------------------------
179
180/// HTTP methods recognised as pattern prefixes.
181const KNOWN_METHODS: &[&str] = &[
182    "GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "CONNECT", "OPTIONS", "TRACE",
183];
184
185fn parse_pattern(raw: &str) -> ParsedPattern {
186    let mut s = raw;
187
188    // 1. Optional method prefix: "GET /path" or "POST example.com/path"
189    let method = {
190        let upper = s.to_ascii_uppercase();
191        let mut found = None;
192        for &m in KNOWN_METHODS {
193            if upper.starts_with(m)
194                && s[m.len()..].starts_with(' ')
195            {
196                found = Some(m.to_owned());
197                s = s[m.len() + 1..].trim_start();
198                break;
199            }
200        }
201        found
202    };
203
204    // 2. Optional host prefix: present when the remainder doesn't start with '/'
205    let host = if !s.starts_with('/') {
206        let slash = s.find('/').unwrap_or(s.len());
207        let h = s[..slash].to_owned();
208        s = if slash < s.len() { &s[slash..] } else { "/" };
209        Some(h)
210    } else {
211        None
212    };
213
214    // 3. Parse path segments
215    let has_trailing_slash = s.len() > 1 && s.ends_with('/');
216    let path_body = if has_trailing_slash { &s[..s.len() - 1] } else { s };
217
218    let segments: Vec<Segment> = path_body
219        .split('/')
220        .filter(|p| !p.is_empty())
221        .map(parse_segment)
222        .collect();
223
224    ParsedPattern { method, host, segments, has_trailing_slash }
225}
226
227fn parse_segment(seg: &str) -> Segment {
228    if seg.starts_with('{') && seg.ends_with('}') {
229        let inner = &seg[1..seg.len() - 1];
230        if let Some(name) = inner.strip_suffix("...") {
231            return Segment::Tail { name: name.to_owned() };
232        }
233        return Segment::Wildcard { name: inner.to_owned() };
234    }
235    Segment::Literal(seg.to_owned())
236}
237
238// ---------------------------------------------------------------------------
239// Match logic
240// ---------------------------------------------------------------------------
241
242/// Specificity score: higher = more specific = wins when multiple patterns match.
243fn specificity(p: &ParsedPattern) -> i64 {
244    let mut score: i64 = 0;
245    if p.method.is_some() { score += 10_000_000; }
246    if p.host.is_some()   { score +=  1_000_000; }
247    for seg in &p.segments {
248        score += match seg {
249            Segment::Literal(_)      => 10_000,
250            Segment::Wildcard { .. } =>  1_000,
251            Segment::Tail { .. }     =>    100,
252        };
253    }
254    // Exact paths (no trailing slash, no tail) beat same-length subtree patterns.
255    let has_tail = p.segments.iter().any(|s| matches!(s, Segment::Tail { .. }));
256    if !p.has_trailing_slash && !has_tail { score += 1; }
257    score
258}
259
260/// Try to match `parts` against `segments`.  Returns captured params on success.
261fn try_match_path(
262    segments:           &[Segment],
263    parts:              &[&str],
264    has_trailing_slash: bool,
265) -> Option<HashMap<String, String>> {
266    let mut params = HashMap::new();
267
268    for (i, seg) in segments.iter().enumerate() {
269        match seg {
270            Segment::Tail { name } => {
271                // Captures all remaining parts, joined.
272                let tail = parts[i..].join("/");
273                if !name.is_empty() {
274                    params.insert(name.clone(), tail);
275                }
276                return Some(params);
277            }
278            Segment::Wildcard { name } => {
279                let part = parts.get(i)?;
280                if !name.is_empty() {
281                    params.insert(name.clone(), (*part).to_owned());
282                }
283            }
284            Segment::Literal(lit) => {
285                if parts.get(i)? != lit { return None; }
286            }
287        }
288    }
289
290    if parts.len() == segments.len() {
291        return Some(params);
292    }
293    // Trailing-slash subtree: path may extend beyond the pattern's segments.
294    if has_trailing_slash && parts.len() > segments.len() {
295        return Some(params);
296    }
297    None
298}
299
300/// Find the best handler for a request, returning captured path params and a
301/// `method_not_allowed` flag.  An empty `method` or `host` skips those filters
302/// (used by the backward-compat `match_handler` shim).
303fn match_with_params(
304    entries: &[MuxEntry],
305    method:  &str,
306    host:    &str,
307    path:    &str,
308) -> (Option<Arc<dyn Handler>>, HashMap<String, String>, bool) {
309    let parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
310
311    let mut best_handler: Option<Arc<dyn Handler>> = None;
312    let mut best_params  = HashMap::new();
313    let mut best_score:  i64 = i64::MIN;
314    let mut method_not_allowed = false;
315
316    for entry in entries {
317        let pat = &entry.parsed;
318
319        let Some(params) = try_match_path(&pat.segments, &parts, pat.has_trailing_slash)
320        else { continue };
321
322        // Host filter (only when caller supplies a host).
323        if !host.is_empty()
324            && let Some(ref ph) = pat.host
325            && !host.eq_ignore_ascii_case(ph)
326        {
327            continue;
328        }
329
330        // Method filter (only when caller supplies a method).
331        if !method.is_empty()
332            && let Some(ref pm) = pat.method
333            && !method.eq_ignore_ascii_case(pm)
334        {
335            method_not_allowed = true;
336            continue;
337        }
338
339        let score = specificity(pat);
340        if score > best_score {
341            best_score   = score;
342            best_handler = Some(Arc::clone(&entry.handler));
343            best_params  = params;
344        }
345    }
346
347    // Only surface 405 if there was no method-agnostic match.
348    let mna = method_not_allowed && best_handler.is_none();
349    (best_handler, best_params, mna)
350}
351
352// ---------------------------------------------------------------------------
353// Default mux — global DefaultServeMux
354// ---------------------------------------------------------------------------
355
356use std::sync::OnceLock;
357
358static DEFAULT_SERVE_MUX: OnceLock<Arc<ServeMux>> = OnceLock::new();
359
360fn default_mux() -> &'static Arc<ServeMux> {
361    DEFAULT_SERVE_MUX.get_or_init(|| Arc::new(ServeMux::new()))
362}
363
364/// Register `handler` on the `DefaultServeMux`.  Port of Go's `http.Handle`.
365pub fn handle(pattern: &str, handler: impl Handler + 'static) {
366    default_mux().handle(pattern, handler);
367}
368
369/// Register a function on the `DefaultServeMux`.  Port of Go's `http.HandleFunc`.
370pub fn handle_func<F>(pattern: &str, f: F)
371where
372    F: Fn(&mut dyn ResponseWriter, &mut Request) + Send + Sync + 'static,
373{
374    default_mux().handle_func(pattern, f);
375}
376
377/// Return a reference to the global `DefaultServeMux`.
378pub fn default_serve_mux() -> Arc<ServeMux> {
379    Arc::clone(default_mux())
380}
381
382// ---------------------------------------------------------------------------
383// Built-in handler helpers
384// ---------------------------------------------------------------------------
385
386/// Returns a `Handler` that always replies 404.
387pub fn not_found_handler() -> impl Handler {
388    handler_func(|w, _r| {
389        w.write_header(crate::status::NOT_FOUND);
390        let _ = w.write(b"404 page not found\n");
391    })
392}
393
394/// Strips `prefix` from the request path before forwarding to `handler`.
395/// Port of Go's `http.StripPrefix`.
396///
397/// If the path does not start with `prefix` the request is answered with 404.
398/// The forwarded request has its URL path rewritten to the stripped path so
399/// the inner handler sees the correct path.
400pub fn strip_prefix(prefix: String, handler: impl Handler + 'static) -> impl Handler {
401    let handler = Arc::new(handler);
402    handler_func(move |w, r| {
403        let path = r.url.path();
404        match path.strip_prefix(prefix.as_str()) {
405            None => not_found_handler().serve_http(w, r),
406            Some(stripped) => {
407                // Build a new Request with the stripped path.
408                let mut new_url = r.url.clone();
409                new_url.set_path(if stripped.is_empty() { "/" } else { stripped });
410                match rebuild_request(r, new_url) {
411                    Err(_) => crate::util::error(w, "internal error", 500),
412                    Ok(mut req) => handler.serve_http(w, &mut req),
413                }
414            }
415        }
416    })
417}
418
419/// Serve files from the filesystem rooted at `root`.
420/// Port of Go's `http.FileServer`.
421///
422/// The URL path is joined to `root` to form the filesystem path.  Directory
423/// listings are not supported — a 403 is returned for directories.  File reads
424/// are performed synchronously within the goroutine (no extra goroutine spawn
425/// needed since each connection already has its own goroutine).
426pub fn file_server(root: String) -> impl Handler {
427    handler_func(move |w, r| {
428        use std::io::Read;
429        use std::path::Path;
430
431        let url_path = r.url.path();
432
433        // Strip the leading `/` and join with root.
434        let rel = url_path.trim_start_matches('/');
435        let fs_path = if rel.is_empty() {
436            Path::new(&root).to_path_buf()
437        } else {
438            Path::new(&root).join(rel)
439        };
440
441        // Guard against path traversal: the canonical path must remain under root.
442        // We canonicalize the root once and compare prefixes.
443        let root_canon = match std::fs::canonicalize(&root) {
444            Ok(p)  => p,
445            Err(_) => {
446                crate::util::error(w, "500 Internal Server Error", crate::status::INTERNAL_SERVER_ERROR);
447                return;
448            }
449        };
450        // For the candidate path, canonicalize if it exists; otherwise check the
451        // parent chain — if the parent is outside root, deny.
452        let candidate_canon = std::fs::canonicalize(&fs_path)
453            .or_else(|_| std::fs::canonicalize(fs_path.parent().unwrap_or(&fs_path)))
454            .unwrap_or_else(|_| fs_path.clone());
455        if !candidate_canon.starts_with(&root_canon) {
456            crate::util::error(w, "403 Forbidden", crate::status::FORBIDDEN);
457            return;
458        }
459
460        // Disallow directory access.
461        match fs_path.metadata() {
462            Err(_) => {
463                crate::util::error(w, "404 Not Found", crate::status::NOT_FOUND);
464                return;
465            }
466            Ok(meta) if meta.is_dir() => {
467                crate::util::error(w, "403 Forbidden", crate::status::FORBIDDEN);
468                return;
469            }
470            Ok(_) => {}
471        }
472
473        // Detect content type from the first 512 bytes.
474        let ct = {
475            let mut probe = [0u8; 512];
476            let n = std::fs::File::open(&fs_path)
477                .and_then(|mut f| f.read(&mut probe))
478                .unwrap_or(0);
479            crate::mime::detect_content_type(&probe[..n]).to_owned()
480        };
481
482        // Read and serve the file.
483        match std::fs::read(&fs_path) {
484            Err(_) => crate::util::error(w, "500 Internal Server Error", crate::status::INTERNAL_SERVER_ERROR),
485            Ok(data) => {
486                w.header().set("Content-Type", &ct);
487                w.header().set("Content-Length", data.len().to_string());
488                w.write_header(crate::status::OK);
489                let _ = w.write(&data);
490            }
491        }
492    })
493}
494
495/// Wraps `handler` with a per-request deadline.
496///
497/// If the handler does not complete within `timeout` the connection receives
498/// `body` with status 503.  The handler runs in a spawned goroutine; the
499/// caller goroutine selects on a done channel vs a timeout context.
500///
501/// Port of Go's `http.TimeoutHandler`.
502pub fn timeout_handler(
503    handler: impl Handler + 'static,
504    timeout: Duration,
505    body:    &'static str,
506) -> impl Handler {
507    let handler = Arc::new(handler);
508    handler_func(move |w, r| {
509        use go_lib::chan::chan;
510        use go_lib::context::with_timeout;
511
512        // BodyCapture collects response data without HTTP framing so we can
513        // replay it through the outer ResponseWriter cleanly.
514        let (done_tx, done_rx) = chan::<BodyCapture>(1);
515
516        let inner_handler = Arc::clone(&handler);
517        let req_url    = r.url.clone();
518        let method     = r.method.clone();
519        let req_header = r.header.clone();
520        let host       = r.host.clone();
521        let remote     = r.remote_addr.clone();
522
523        let (ctx, cancel) = with_timeout(&go_lib::context::background(), timeout);
524
525        go_lib::go!(move || {
526            let mut inner_req = match Request::new(&method, req_url.as_str(), None) {
527                Ok(r)  => r,
528                Err(_) => { done_tx.send(BodyCapture::default()); return; }
529            };
530            inner_req.header      = req_header;
531            inner_req.host        = host;
532            inner_req.remote_addr = remote;
533
534            let mut capture = BodyCapture::default();
535            inner_handler.serve_http(&mut capture, &mut inner_req);
536            done_tx.send(capture);
537        });
538
539        // Select: timeout fires → 503; inner handler done → replay captured response.
540        go_lib::select! {
541            recv(ctx.done()) -> _v => {
542                cancel.cancel();
543                w.write_header(crate::status::SERVICE_UNAVAILABLE);
544                let _ = w.write(body.as_bytes());
545            }
546            recv(done_rx) -> result => {
547                cancel.cancel();
548                if let Some(capture) = result {
549                    // Replay captured headers.
550                    for (name, values) in capture.header.iter() {
551                        for val in values {
552                            w.header().add(name, val.as_str());
553                        }
554                    }
555                    let status = if capture.status == 0 { 200 } else { capture.status };
556                    w.write_header(status);
557                    let _ = w.write(&capture.body);
558                }
559            }
560        }
561    })
562}
563
564// ---------------------------------------------------------------------------
565// BodyCapture — a ResponseWriter that buffers status + headers + raw body
566// without adding HTTP framing.  Used by timeout_handler.
567// ---------------------------------------------------------------------------
568
569#[derive(Default)]
570struct BodyCapture {
571    status: u16,
572    header: crate::header::Header,
573    body:   Vec<u8>,
574}
575
576impl ResponseWriter for BodyCapture {
577    fn header(&mut self) -> &mut crate::header::Header { &mut self.header }
578    fn write(&mut self, buf: &[u8]) -> Result<usize, crate::error::HttpError> {
579        self.body.extend_from_slice(buf);
580        Ok(buf.len())
581    }
582    fn write_header(&mut self, code: u16) {
583        if self.status == 0 { self.status = code; }
584    }
585}
586
587// BodyCapture must be Send to cross a goroutine boundary through a channel.
588// It only holds Vec<u8> and Header (both Send).
589unsafe impl Send for BodyCapture {}
590
591// ---------------------------------------------------------------------------
592// Internal: rebuild a Request with a new URL (used by strip_prefix)
593// ---------------------------------------------------------------------------
594
595fn rebuild_request(r: &Request, new_url: url::Url) -> Result<Request, HttpError> {
596    let mut req = Request::new_with_context(
597        &r.method,
598        new_url.as_str(),
599        None, // body is not forwarded — it may be consumed; handlers should read from original
600        r.context().clone(),
601    )?;
602    req.proto             = r.proto.clone();
603    req.proto_major       = r.proto_major;
604    req.proto_minor       = r.proto_minor;
605    req.header            = r.header.clone();
606    req.host              = r.host.clone();
607    req.content_length    = r.content_length;
608    req.transfer_encoding = r.transfer_encoding.clone();
609    req.remote_addr       = r.remote_addr.clone();
610    req.trailer           = r.trailer.clone();
611    req.path_params       = r.path_params.clone();
612    Ok(req)
613}
614
615#[cfg(test)]
616mod tests {
617    use super::*;
618    use crate::response::ConnResponseWriter;
619    use crate::request::Request;
620
621    fn dummy_request(path: &str) -> Request {
622        Request::new("GET", &format!("http://example.com{path}"), None).unwrap()
623    }
624
625    struct RecordingWriter {
626        inner: ConnResponseWriter<Vec<u8>>,
627    }
628    impl RecordingWriter {
629        fn new() -> Self { Self { inner: ConnResponseWriter::new(Vec::new()) } }
630        fn bytes(mut self) -> Vec<u8> {
631            let _ = self.inner.finish();
632            self.inner.inner
633        }
634    }
635    impl ResponseWriter for RecordingWriter {
636        fn header(&mut self) -> &mut crate::header::Header { self.inner.header() }
637        fn write(&mut self, buf: &[u8]) -> Result<usize, crate::error::HttpError> { self.inner.write(buf) }
638        fn write_header(&mut self, code: u16) { self.inner.write_header(code) }
639    }
640
641    #[test]
642    fn exact_match() {
643        let mux = ServeMux::new();
644        mux.handle_func("/hello", |w, _| { let _ = w.write(b"hi"); });
645        let mut r = dummy_request("/hello");
646        let mut w = RecordingWriter::new();
647        mux.serve_http(&mut w, &mut r);
648        let out = w.bytes();
649        assert!(out.windows(2).any(|w| w == b"hi"), "body should contain 'hi'");
650    }
651
652    #[test]
653    fn prefix_match() {
654        let mux = ServeMux::new();
655        mux.handle_func("/static/", |w, _| { let _ = w.write(b"file"); });
656        let mut r = dummy_request("/static/foo.js");
657        let mut w = RecordingWriter::new();
658        mux.serve_http(&mut w, &mut r);
659        let out = w.bytes();
660        assert!(out.windows(4).any(|s| s == b"file"));
661    }
662
663    #[test]
664    fn not_found_fallback() {
665        let mux = ServeMux::new();
666        let mut r = dummy_request("/nowhere");
667        let mut w = RecordingWriter::new();
668        mux.serve_http(&mut w, &mut r);
669        let out = String::from_utf8(w.bytes()).unwrap();
670        assert!(out.contains("404"));
671    }
672
673    #[test]
674    fn longer_prefix_wins() {
675        let mux = ServeMux::new();
676        mux.handle_func("/api/", |w, _| { let _ = w.write(b"short"); });
677        mux.handle_func("/api/v2/", |w, _| { let _ = w.write(b"long"); });
678        let mut r = dummy_request("/api/v2/users");
679        let mut w = RecordingWriter::new();
680        mux.serve_http(&mut w, &mut r);
681        let out = w.bytes();
682        assert!(out.windows(4).any(|s| s == b"long"));
683    }
684
685    // ── strip_prefix ─────────────────────────────────────────────────────────
686
687    #[test]
688    fn strip_prefix_rewrites_path() {
689        // Inner handler sees the stripped path in the request URL.
690        let inner = handler_func(|w, r| {
691            let _ = w.write(r.url.path().as_bytes());
692        });
693        let h = strip_prefix("/api".to_owned(), inner);
694        let mut r = dummy_request("/api/users");
695        let mut w = RecordingWriter::new();
696        h.serve_http(&mut w, &mut r);
697        let body = String::from_utf8(w.bytes()).unwrap();
698        // The body is the raw bytes written; find /users in them.
699        assert!(body.contains("/users"), "stripped path should be /users, got: {body:?}");
700    }
701
702    #[test]
703    fn strip_prefix_no_match_returns_404() {
704        let inner = handler_func(|w, _| { let _ = w.write(b"ok"); });
705        let h = strip_prefix("/api".to_owned(), inner);
706        let mut r = dummy_request("/other/path");
707        let mut w = RecordingWriter::new();
708        h.serve_http(&mut w, &mut r);
709        let out = String::from_utf8(w.bytes()).unwrap();
710        assert!(out.contains("404"));
711    }
712
713    // ── file_server ──────────────────────────────────────────────────────────
714
715    #[test]
716    fn file_server_serves_existing_file() {
717        // Write a temp file.
718        let dir  = std::env::temp_dir();
719        let path = dir.join("go_http_test_file.txt");
720        std::fs::write(&path, b"hello file").unwrap();
721
722        let h = file_server(dir.to_str().unwrap().to_owned());
723        let mut r = dummy_request("/go_http_test_file.txt");
724        let mut w = RecordingWriter::new();
725        h.serve_http(&mut w, &mut r);
726        let out = w.bytes();
727        assert!(out.windows(10).any(|s| s == b"hello file"), "file content not found");
728
729        let _ = std::fs::remove_file(path);
730    }
731
732    #[test]
733    fn file_server_missing_file_returns_404() {
734        let dir = std::env::temp_dir();
735        let h = file_server(dir.to_str().unwrap().to_owned());
736        let mut r = dummy_request("/this_file_does_not_exist_xyz.bin");
737        let mut w = RecordingWriter::new();
738        h.serve_http(&mut w, &mut r);
739        let out = String::from_utf8(w.bytes()).unwrap();
740        assert!(out.contains("404"), "expected 404 in response, got: {}", out);
741    }
742
743    #[test]
744    fn file_server_rejects_path_traversal() {
745        // Create a subdirectory and serve only from it.
746        let root = std::env::temp_dir().join("go_http_test_root");
747        std::fs::create_dir_all(&root).unwrap();
748
749        // Write a sentinel file *outside* the root (in its parent).
750        let outside = std::env::temp_dir().join("go_http_outside.txt");
751        std::fs::write(&outside, b"secret").unwrap();
752
753        // Request a path that resolves to the parent directory's file.
754        // We serve from .../go_http_test_root/ and request ../go_http_outside.txt
755        // which on the filesystem becomes .../go_http_outside.txt (outside root).
756        let h = file_server(root.to_str().unwrap().to_owned());
757        let mut r = dummy_request("/../go_http_outside.txt");
758        let mut w = RecordingWriter::new();
759        h.serve_http(&mut w, &mut r);
760        let out = String::from_utf8(w.bytes()).unwrap();
761
762        let _ = std::fs::remove_file(outside);
763        let _ = std::fs::remove_dir(root);
764
765        // URL normalises /../foo to /foo so the path is just the filename —
766        // which doesn't exist in our empty root → 404.  Either 403 or 404 is
767        // acceptable; the important thing is we don't serve secret content.
768        assert!(
769            out.contains("403") || out.contains("404"),
770            "expected 403 or 404, got: {out:?}"
771        );
772        assert!(!out.contains("secret"), "traversal should not expose file content");
773    }
774
775    // timeout_handler is covered by tests/middleware.rs integration tests
776    // (timeout_handler_fast_passes, timeout_handler_slow_503), which carry
777    // `#[go_lib::main]` so each test body runs as the first goroutine on the
778    // shared process-wide scheduler.
779}