go-http 0.1.3

rust native port of GO's http module
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
// SPDX-License-Identifier: Apache-2.0

use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use std::time::Duration;

use crate::error::HttpError;
use crate::request::Request;
use crate::response::ResponseWriter;

// ---------------------------------------------------------------------------
// Handler trait
// ---------------------------------------------------------------------------

/// The core handler interface.  Port of Go's `http.Handler`.
pub trait Handler: Send + Sync {
    fn serve_http(&self, w: &mut dyn ResponseWriter, r: &mut Request);
}

// ---------------------------------------------------------------------------
// HandlerFunc
// ---------------------------------------------------------------------------

/// Adapter that turns a function into a `Handler`.
/// Port of Go's `http.HandlerFunc`.
type HandlerFn = Box<dyn Fn(&mut dyn ResponseWriter, &mut Request) + Send + Sync>;
pub struct HandlerFunc(pub HandlerFn);

impl Handler for HandlerFunc {
    fn serve_http(&self, w: &mut dyn ResponseWriter, r: &mut Request) {
        (self.0)(w, r)
    }
}

/// Any `Arc<H>` where `H: Handler` is itself a `Handler`.  This lets you
/// wrap a mux or other handler in an `Arc` and pass it directly to middleware
/// functions like `timeout_handler`.
impl<H: Handler> Handler for Arc<H> {
    fn serve_http(&self, w: &mut dyn ResponseWriter, r: &mut Request) {
        (**self).serve_http(w, r)
    }
}

/// Convenience constructor.
pub fn handler_func<F>(f: F) -> HandlerFunc
where
    F: Fn(&mut dyn ResponseWriter, &mut Request) + Send + Sync + 'static,
{
    HandlerFunc(Box::new(f))
}

// ---------------------------------------------------------------------------
// ServeMux — Go 1.22-style routing
// ---------------------------------------------------------------------------

/// A single parsed segment of a URL pattern.
#[derive(Clone, Debug)]
enum Segment {
    Literal(String),
    /// `{name}` — matches exactly one path component.
    Wildcard { name: String },
    /// `{name...}` — matches all remaining path components (must be last).
    Tail { name: String },
}

/// A fully parsed mux pattern supporting method prefix, host prefix, and
/// wildcard path segments as introduced in Go 1.22.
///
/// Grammar: `[METHOD ][HOST]/PATH`
///   - `METHOD` is optional; any capitalized HTTP method word.
///   - `HOST` is optional; present when the pattern does not start with `/`.
///   - `PATH` may contain `{name}` (wildcard segment) and `{name...}` (tail).
#[derive(Clone, Debug)]
struct ParsedPattern {
    method:             Option<String>,
    host:               Option<String>,
    segments:           Vec<Segment>,
    has_trailing_slash: bool,
}

struct MuxEntry {
    raw:     String,
    parsed:  ParsedPattern,
    handler: Arc<dyn Handler>,
}

/// HTTP request multiplexer.  Port of Go's `http.ServeMux` (Go 1.22+).
///
/// Matching rules:
/// 1. More-specific patterns beat less-specific ones (method + host > host > path).
/// 2. Literal path segments beat wildcard segments; wildcards beat tail wildcards.
/// 3. Exact path beats trailing-slash subtree of the same length.
/// 4. Among equally-specific patterns, longer paths win.
/// 5. A pattern matching path but not method returns 405 Method Not Allowed.
pub struct ServeMux {
    entries: RwLock<Vec<MuxEntry>>,
}

impl ServeMux {
    pub fn new() -> Self {
        Self { entries: RwLock::new(Vec::new()) }
    }

    /// Register a handler for the given pattern.
    ///
    /// Pattern syntax: `[METHOD ][HOST]/PATH`
    ///
    /// | Example                  | Meaning                                      |
    /// |--------------------------|----------------------------------------------|
    /// | `/`                      | subtree catch-all (old-style)                |
    /// | `/api/v2/`               | subtree prefix                               |
    /// | `/items/{id}`            | single wildcard segment                      |
    /// | `/files/{path...}`       | tail wildcard (matches rest of path)         |
    /// | `GET /api/users`         | method-restricted exact route                |
    /// | `example.com/`           | host-restricted subtree                      |
    /// | `GET example.com/{id}`   | method + host + wildcard                     |
    pub fn handle(&self, pattern: &str, handler: impl Handler + 'static) {
        self.handle_arc(pattern, Arc::new(handler));
    }

    /// Register a function as a handler.
    pub fn handle_func<F>(&self, pattern: &str, f: F)
    where
        F: Fn(&mut dyn ResponseWriter, &mut Request) + Send + Sync + 'static,
    {
        self.handle(pattern, handler_func(f));
    }

    fn handle_arc(&self, pattern: &str, handler: Arc<dyn Handler>) {
        let parsed = parse_pattern(pattern);
        let mut entries = self.entries.write().unwrap();
        if let Some(e) = entries.iter_mut().find(|e| e.raw == pattern) {
            e.handler = handler;
            return;
        }
        entries.push(MuxEntry { raw: pattern.to_owned(), parsed, handler });
    }

    /// Find the best matching handler for a bare `path` (no method/host
    /// filtering).  Retained for backward compatibility; prefer the full
    /// `serve_http` path for new code.
    pub fn match_handler(&self, path: &str) -> Option<Arc<dyn Handler>> {
        let entries = self.entries.read().unwrap();
        let (h, _, _) = match_with_params(&entries, "", "", path);
        h
    }
}

impl Handler for ServeMux {
    fn serve_http(&self, w: &mut dyn ResponseWriter, r: &mut Request) {
        let entries = self.entries.read().unwrap();
        let (handler, params, method_not_allowed) =
            match_with_params(&entries, &r.method, &r.host, r.url.path());
        drop(entries);

        match handler {
            Some(h) => {
                r.path_params = params;
                h.serve_http(w, r);
            }
            None if method_not_allowed => {
                w.write_header(crate::status::METHOD_NOT_ALLOWED);
                let _ = w.write(b"405 method not allowed\n");
            }
            None => not_found_handler().serve_http(w, r),
        }
    }
}

impl Default for ServeMux {
    fn default() -> Self {
        Self::new()
    }
}

// ---------------------------------------------------------------------------
// Pattern parsing
// ---------------------------------------------------------------------------

/// HTTP methods recognised as pattern prefixes.
const KNOWN_METHODS: &[&str] = &[
    "GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "CONNECT", "OPTIONS", "TRACE",
];

fn parse_pattern(raw: &str) -> ParsedPattern {
    let mut s = raw;

    // 1. Optional method prefix: "GET /path" or "POST example.com/path"
    let method = {
        let upper = s.to_ascii_uppercase();
        let mut found = None;
        for &m in KNOWN_METHODS {
            if upper.starts_with(m)
                && s[m.len()..].starts_with(' ')
            {
                found = Some(m.to_owned());
                s = s[m.len() + 1..].trim_start();
                break;
            }
        }
        found
    };

    // 2. Optional host prefix: present when the remainder doesn't start with '/'
    let host = if !s.starts_with('/') {
        let slash = s.find('/').unwrap_or(s.len());
        let h = s[..slash].to_owned();
        s = if slash < s.len() { &s[slash..] } else { "/" };
        Some(h)
    } else {
        None
    };

    // 3. Parse path segments
    let has_trailing_slash = s.len() > 1 && s.ends_with('/');
    let path_body = if has_trailing_slash { &s[..s.len() - 1] } else { s };

    let segments: Vec<Segment> = path_body
        .split('/')
        .filter(|p| !p.is_empty())
        .map(parse_segment)
        .collect();

    ParsedPattern { method, host, segments, has_trailing_slash }
}

fn parse_segment(seg: &str) -> Segment {
    if seg.starts_with('{') && seg.ends_with('}') {
        let inner = &seg[1..seg.len() - 1];
        if let Some(name) = inner.strip_suffix("...") {
            return Segment::Tail { name: name.to_owned() };
        }
        return Segment::Wildcard { name: inner.to_owned() };
    }
    Segment::Literal(seg.to_owned())
}

// ---------------------------------------------------------------------------
// Match logic
// ---------------------------------------------------------------------------

/// Specificity score: higher = more specific = wins when multiple patterns match.
fn specificity(p: &ParsedPattern) -> i64 {
    let mut score: i64 = 0;
    if p.method.is_some() { score += 10_000_000; }
    if p.host.is_some()   { score +=  1_000_000; }
    for seg in &p.segments {
        score += match seg {
            Segment::Literal(_)      => 10_000,
            Segment::Wildcard { .. } =>  1_000,
            Segment::Tail { .. }     =>    100,
        };
    }
    // Exact paths (no trailing slash, no tail) beat same-length subtree patterns.
    let has_tail = p.segments.iter().any(|s| matches!(s, Segment::Tail { .. }));
    if !p.has_trailing_slash && !has_tail { score += 1; }
    score
}

/// Try to match `parts` against `segments`.  Returns captured params on success.
fn try_match_path(
    segments:           &[Segment],
    parts:              &[&str],
    has_trailing_slash: bool,
) -> Option<HashMap<String, String>> {
    let mut params = HashMap::new();

    for (i, seg) in segments.iter().enumerate() {
        match seg {
            Segment::Tail { name } => {
                // Captures all remaining parts, joined.
                let tail = parts[i..].join("/");
                if !name.is_empty() {
                    params.insert(name.clone(), tail);
                }
                return Some(params);
            }
            Segment::Wildcard { name } => {
                let part = parts.get(i)?;
                if !name.is_empty() {
                    params.insert(name.clone(), (*part).to_owned());
                }
            }
            Segment::Literal(lit) => {
                if parts.get(i)? != lit { return None; }
            }
        }
    }

    if parts.len() == segments.len() {
        return Some(params);
    }
    // Trailing-slash subtree: path may extend beyond the pattern's segments.
    if has_trailing_slash && parts.len() > segments.len() {
        return Some(params);
    }
    None
}

/// Find the best handler for a request, returning captured path params and a
/// `method_not_allowed` flag.  An empty `method` or `host` skips those filters
/// (used by the backward-compat `match_handler` shim).
fn match_with_params(
    entries: &[MuxEntry],
    method:  &str,
    host:    &str,
    path:    &str,
) -> (Option<Arc<dyn Handler>>, HashMap<String, String>, bool) {
    let parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();

    let mut best_handler: Option<Arc<dyn Handler>> = None;
    let mut best_params  = HashMap::new();
    let mut best_score:  i64 = i64::MIN;
    let mut method_not_allowed = false;

    for entry in entries {
        let pat = &entry.parsed;

        let Some(params) = try_match_path(&pat.segments, &parts, pat.has_trailing_slash)
        else { continue };

        // Host filter (only when caller supplies a host).
        if !host.is_empty()
            && let Some(ref ph) = pat.host
            && !host.eq_ignore_ascii_case(ph)
        {
            continue;
        }

        // Method filter (only when caller supplies a method).
        if !method.is_empty()
            && let Some(ref pm) = pat.method
            && !method.eq_ignore_ascii_case(pm)
        {
            method_not_allowed = true;
            continue;
        }

        let score = specificity(pat);
        if score > best_score {
            best_score   = score;
            best_handler = Some(Arc::clone(&entry.handler));
            best_params  = params;
        }
    }

    // Only surface 405 if there was no method-agnostic match.
    let mna = method_not_allowed && best_handler.is_none();
    (best_handler, best_params, mna)
}

// ---------------------------------------------------------------------------
// Default mux — global DefaultServeMux
// ---------------------------------------------------------------------------

use std::sync::OnceLock;

static DEFAULT_SERVE_MUX: OnceLock<Arc<ServeMux>> = OnceLock::new();

fn default_mux() -> &'static Arc<ServeMux> {
    DEFAULT_SERVE_MUX.get_or_init(|| Arc::new(ServeMux::new()))
}

/// Register `handler` on the `DefaultServeMux`.  Port of Go's `http.Handle`.
pub fn handle(pattern: &str, handler: impl Handler + 'static) {
    default_mux().handle(pattern, handler);
}

/// Register a function on the `DefaultServeMux`.  Port of Go's `http.HandleFunc`.
pub fn handle_func<F>(pattern: &str, f: F)
where
    F: Fn(&mut dyn ResponseWriter, &mut Request) + Send + Sync + 'static,
{
    default_mux().handle_func(pattern, f);
}

/// Return a reference to the global `DefaultServeMux`.
pub fn default_serve_mux() -> Arc<ServeMux> {
    Arc::clone(default_mux())
}

// ---------------------------------------------------------------------------
// Built-in handler helpers
// ---------------------------------------------------------------------------

/// Returns a `Handler` that always replies 404.
pub fn not_found_handler() -> impl Handler {
    handler_func(|w, _r| {
        w.write_header(crate::status::NOT_FOUND);
        let _ = w.write(b"404 page not found\n");
    })
}

/// Strips `prefix` from the request path before forwarding to `handler`.
/// Port of Go's `http.StripPrefix`.
///
/// If the path does not start with `prefix` the request is answered with 404.
/// The forwarded request has its URL path rewritten to the stripped path so
/// the inner handler sees the correct path.
pub fn strip_prefix(prefix: String, handler: impl Handler + 'static) -> impl Handler {
    let handler = Arc::new(handler);
    handler_func(move |w, r| {
        let path = r.url.path();
        match path.strip_prefix(prefix.as_str()) {
            None => not_found_handler().serve_http(w, r),
            Some(stripped) => {
                // Build a new Request with the stripped path.
                let mut new_url = r.url.clone();
                new_url.set_path(if stripped.is_empty() { "/" } else { stripped });
                match rebuild_request(r, new_url) {
                    Err(_) => crate::util::error(w, "internal error", 500),
                    Ok(mut req) => handler.serve_http(w, &mut req),
                }
            }
        }
    })
}

/// Serve files from the filesystem rooted at `root`.
/// Port of Go's `http.FileServer`.
///
/// The URL path is joined to `root` to form the filesystem path.  Directory
/// listings are not supported — a 403 is returned for directories.  File reads
/// are performed synchronously within the goroutine (no extra goroutine spawn
/// needed since each connection already has its own goroutine).
pub fn file_server(root: String) -> impl Handler {
    handler_func(move |w, r| {
        use std::io::Read;
        use std::path::Path;

        let url_path = r.url.path();

        // Strip the leading `/` and join with root.
        let rel = url_path.trim_start_matches('/');
        let fs_path = if rel.is_empty() {
            Path::new(&root).to_path_buf()
        } else {
            Path::new(&root).join(rel)
        };

        // Guard against path traversal: the canonical path must remain under root.
        // We canonicalize the root once and compare prefixes.
        let root_canon = match std::fs::canonicalize(&root) {
            Ok(p)  => p,
            Err(_) => {
                crate::util::error(w, "500 Internal Server Error", crate::status::INTERNAL_SERVER_ERROR);
                return;
            }
        };
        // For the candidate path, canonicalize if it exists; otherwise check the
        // parent chain — if the parent is outside root, deny.
        let candidate_canon = std::fs::canonicalize(&fs_path)
            .or_else(|_| std::fs::canonicalize(fs_path.parent().unwrap_or(&fs_path)))
            .unwrap_or_else(|_| fs_path.clone());
        if !candidate_canon.starts_with(&root_canon) {
            crate::util::error(w, "403 Forbidden", crate::status::FORBIDDEN);
            return;
        }

        // Disallow directory access.
        match fs_path.metadata() {
            Err(_) => {
                crate::util::error(w, "404 Not Found", crate::status::NOT_FOUND);
                return;
            }
            Ok(meta) if meta.is_dir() => {
                crate::util::error(w, "403 Forbidden", crate::status::FORBIDDEN);
                return;
            }
            Ok(_) => {}
        }

        // Detect content type from the first 512 bytes.
        let ct = {
            let mut probe = [0u8; 512];
            let n = std::fs::File::open(&fs_path)
                .and_then(|mut f| f.read(&mut probe))
                .unwrap_or(0);
            crate::mime::detect_content_type(&probe[..n]).to_owned()
        };

        // Read and serve the file.
        match std::fs::read(&fs_path) {
            Err(_) => crate::util::error(w, "500 Internal Server Error", crate::status::INTERNAL_SERVER_ERROR),
            Ok(data) => {
                w.header().set("Content-Type", &ct);
                w.header().set("Content-Length", data.len().to_string());
                w.write_header(crate::status::OK);
                let _ = w.write(&data);
            }
        }
    })
}

/// Wraps `handler` with a per-request deadline.
///
/// If the handler does not complete within `timeout` the connection receives
/// `body` with status 503.  The handler runs in a spawned goroutine; the
/// caller goroutine selects on a done channel vs a timeout context.
///
/// Port of Go's `http.TimeoutHandler`.
pub fn timeout_handler(
    handler: impl Handler + 'static,
    timeout: Duration,
    body:    &'static str,
) -> impl Handler {
    let handler = Arc::new(handler);
    handler_func(move |w, r| {
        use go_lib::chan::chan;
        use go_lib::context::with_timeout;

        // BodyCapture collects response data without HTTP framing so we can
        // replay it through the outer ResponseWriter cleanly.
        let (done_tx, done_rx) = chan::<BodyCapture>(1);

        let inner_handler = Arc::clone(&handler);
        let req_url    = r.url.clone();
        let method     = r.method.clone();
        let req_header = r.header.clone();
        let host       = r.host.clone();
        let remote     = r.remote_addr.clone();

        let (ctx, cancel) = with_timeout(&go_lib::context::background(), timeout);

        go_lib::go!(move || {
            let mut inner_req = match Request::new(&method, req_url.as_str(), None) {
                Ok(r)  => r,
                Err(_) => { done_tx.send(BodyCapture::default()); return; }
            };
            inner_req.header      = req_header;
            inner_req.host        = host;
            inner_req.remote_addr = remote;

            let mut capture = BodyCapture::default();
            inner_handler.serve_http(&mut capture, &mut inner_req);
            done_tx.send(capture);
        });

        // Select: timeout fires → 503; inner handler done → replay captured response.
        go_lib::select! {
            recv(ctx.done()) -> _v => {
                cancel.cancel();
                w.write_header(crate::status::SERVICE_UNAVAILABLE);
                let _ = w.write(body.as_bytes());
            }
            recv(done_rx) -> result => {
                cancel.cancel();
                if let Some(capture) = result {
                    // Replay captured headers.
                    for (name, values) in capture.header.iter() {
                        for val in values {
                            w.header().add(name, val.as_str());
                        }
                    }
                    let status = if capture.status == 0 { 200 } else { capture.status };
                    w.write_header(status);
                    let _ = w.write(&capture.body);
                }
            }
        }
    })
}

// ---------------------------------------------------------------------------
// BodyCapture — a ResponseWriter that buffers status + headers + raw body
// without adding HTTP framing.  Used by timeout_handler.
// ---------------------------------------------------------------------------

#[derive(Default)]
struct BodyCapture {
    status: u16,
    header: crate::header::Header,
    body:   Vec<u8>,
}

impl ResponseWriter for BodyCapture {
    fn header(&mut self) -> &mut crate::header::Header { &mut self.header }
    fn write(&mut self, buf: &[u8]) -> Result<usize, crate::error::HttpError> {
        self.body.extend_from_slice(buf);
        Ok(buf.len())
    }
    fn write_header(&mut self, code: u16) {
        if self.status == 0 { self.status = code; }
    }
}

// BodyCapture must be Send to cross a goroutine boundary through a channel.
// It only holds Vec<u8> and Header (both Send).
unsafe impl Send for BodyCapture {}

// ---------------------------------------------------------------------------
// Internal: rebuild a Request with a new URL (used by strip_prefix)
// ---------------------------------------------------------------------------

fn rebuild_request(r: &Request, new_url: url::Url) -> Result<Request, HttpError> {
    let mut req = Request::new_with_context(
        &r.method,
        new_url.as_str(),
        None, // body is not forwarded — it may be consumed; handlers should read from original
        r.context().clone(),
    )?;
    req.proto             = r.proto.clone();
    req.proto_major       = r.proto_major;
    req.proto_minor       = r.proto_minor;
    req.header            = r.header.clone();
    req.host              = r.host.clone();
    req.content_length    = r.content_length;
    req.transfer_encoding = r.transfer_encoding.clone();
    req.remote_addr       = r.remote_addr.clone();
    req.trailer           = r.trailer.clone();
    req.path_params       = r.path_params.clone();
    Ok(req)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::response::ConnResponseWriter;
    use crate::request::Request;

    fn dummy_request(path: &str) -> Request {
        Request::new("GET", &format!("http://example.com{path}"), None).unwrap()
    }

    struct RecordingWriter {
        inner: ConnResponseWriter<Vec<u8>>,
    }
    impl RecordingWriter {
        fn new() -> Self { Self { inner: ConnResponseWriter::new(Vec::new()) } }
        fn bytes(mut self) -> Vec<u8> {
            let _ = self.inner.finish();
            self.inner.inner
        }
    }
    impl ResponseWriter for RecordingWriter {
        fn header(&mut self) -> &mut crate::header::Header { self.inner.header() }
        fn write(&mut self, buf: &[u8]) -> Result<usize, crate::error::HttpError> { self.inner.write(buf) }
        fn write_header(&mut self, code: u16) { self.inner.write_header(code) }
    }

    #[test]
    fn exact_match() {
        let mux = ServeMux::new();
        mux.handle_func("/hello", |w, _| { let _ = w.write(b"hi"); });
        let mut r = dummy_request("/hello");
        let mut w = RecordingWriter::new();
        mux.serve_http(&mut w, &mut r);
        let out = w.bytes();
        assert!(out.windows(2).any(|w| w == b"hi"), "body should contain 'hi'");
    }

    #[test]
    fn prefix_match() {
        let mux = ServeMux::new();
        mux.handle_func("/static/", |w, _| { let _ = w.write(b"file"); });
        let mut r = dummy_request("/static/foo.js");
        let mut w = RecordingWriter::new();
        mux.serve_http(&mut w, &mut r);
        let out = w.bytes();
        assert!(out.windows(4).any(|s| s == b"file"));
    }

    #[test]
    fn not_found_fallback() {
        let mux = ServeMux::new();
        let mut r = dummy_request("/nowhere");
        let mut w = RecordingWriter::new();
        mux.serve_http(&mut w, &mut r);
        let out = String::from_utf8(w.bytes()).unwrap();
        assert!(out.contains("404"));
    }

    #[test]
    fn longer_prefix_wins() {
        let mux = ServeMux::new();
        mux.handle_func("/api/", |w, _| { let _ = w.write(b"short"); });
        mux.handle_func("/api/v2/", |w, _| { let _ = w.write(b"long"); });
        let mut r = dummy_request("/api/v2/users");
        let mut w = RecordingWriter::new();
        mux.serve_http(&mut w, &mut r);
        let out = w.bytes();
        assert!(out.windows(4).any(|s| s == b"long"));
    }

    // ── strip_prefix ─────────────────────────────────────────────────────────

    #[test]
    fn strip_prefix_rewrites_path() {
        // Inner handler sees the stripped path in the request URL.
        let inner = handler_func(|w, r| {
            let _ = w.write(r.url.path().as_bytes());
        });
        let h = strip_prefix("/api".to_owned(), inner);
        let mut r = dummy_request("/api/users");
        let mut w = RecordingWriter::new();
        h.serve_http(&mut w, &mut r);
        let body = String::from_utf8(w.bytes()).unwrap();
        // The body is the raw bytes written; find /users in them.
        assert!(body.contains("/users"), "stripped path should be /users, got: {body:?}");
    }

    #[test]
    fn strip_prefix_no_match_returns_404() {
        let inner = handler_func(|w, _| { let _ = w.write(b"ok"); });
        let h = strip_prefix("/api".to_owned(), inner);
        let mut r = dummy_request("/other/path");
        let mut w = RecordingWriter::new();
        h.serve_http(&mut w, &mut r);
        let out = String::from_utf8(w.bytes()).unwrap();
        assert!(out.contains("404"));
    }

    // ── file_server ──────────────────────────────────────────────────────────

    #[test]
    fn file_server_serves_existing_file() {
        // Write a temp file.
        let dir  = std::env::temp_dir();
        let path = dir.join("go_http_test_file.txt");
        std::fs::write(&path, b"hello file").unwrap();

        let h = file_server(dir.to_str().unwrap().to_owned());
        let mut r = dummy_request("/go_http_test_file.txt");
        let mut w = RecordingWriter::new();
        h.serve_http(&mut w, &mut r);
        let out = w.bytes();
        assert!(out.windows(10).any(|s| s == b"hello file"), "file content not found");

        let _ = std::fs::remove_file(path);
    }

    #[test]
    fn file_server_missing_file_returns_404() {
        let dir = std::env::temp_dir();
        let h = file_server(dir.to_str().unwrap().to_owned());
        let mut r = dummy_request("/this_file_does_not_exist_xyz.bin");
        let mut w = RecordingWriter::new();
        h.serve_http(&mut w, &mut r);
        let out = String::from_utf8(w.bytes()).unwrap();
        assert!(out.contains("404"), "expected 404 in response, got: {}", out);
    }

    #[test]
    fn file_server_rejects_path_traversal() {
        // Create a subdirectory and serve only from it.
        let root = std::env::temp_dir().join("go_http_test_root");
        std::fs::create_dir_all(&root).unwrap();

        // Write a sentinel file *outside* the root (in its parent).
        let outside = std::env::temp_dir().join("go_http_outside.txt");
        std::fs::write(&outside, b"secret").unwrap();

        // Request a path that resolves to the parent directory's file.
        // We serve from .../go_http_test_root/ and request ../go_http_outside.txt
        // which on the filesystem becomes .../go_http_outside.txt (outside root).
        let h = file_server(root.to_str().unwrap().to_owned());
        let mut r = dummy_request("/../go_http_outside.txt");
        let mut w = RecordingWriter::new();
        h.serve_http(&mut w, &mut r);
        let out = String::from_utf8(w.bytes()).unwrap();

        let _ = std::fs::remove_file(outside);
        let _ = std::fs::remove_dir(root);

        // URL normalises /../foo to /foo so the path is just the filename —
        // which doesn't exist in our empty root → 404.  Either 403 or 404 is
        // acceptable; the important thing is we don't serve secret content.
        assert!(
            out.contains("403") || out.contains("404"),
            "expected 403 or 404, got: {out:?}"
        );
        assert!(!out.contains("secret"), "traversal should not expose file content");
    }

    // timeout_handler is covered by tests/middleware.rs integration tests
    // (timeout_handler_fast_passes, timeout_handler_slow_503), which carry
    // `#[go_lib::main]` so each test body runs as the first goroutine on the
    // shared process-wide scheduler.
}