Skip to main content

rightkit_qa/
mock.rs

1//! Local HTTP mock for PAID providers: paid-tier scenarios assert the exact
2//! requests the product would send, and never make a billed call.
3//!
4//! Two ways to answer:
5//! * [`MockServer::start`]: static [`Route`]s with JSON bodies (the declarative `mock_start` step).
6//! * [`MockServer::start_with`]: a handler closure over the request and the mock's own base URL,
7//!   returning a [`Reply`] with any status, content type, headers and binary body. Use it for
8//!   stateful flows (poll until done, fail the Nth call) and generated media (PNG/MP3/MP4).
9//!
10//! * [`MockServer::start_static`]: static routes answered with full [`Reply`]s (binary files).
11//!
12//! **HTTP `Range`.** A `200` reply to a `GET`/`HEAD` honours a single byte range
13//! (`bytes=a-b`, `bytes=a-`, suffix `bytes=-n`): `206` with `Content-Range`, or `416` with
14//! `Content-Range: bytes */<len>` when unsatisfiable. A missing, malformed or multi-range
15//! `Range`, or an `If-Range` that does not match the reply's `ETag`/`Last-Modified`, gets the
16//! full `200` body. [`Reply::ignore_range`] always sends the full body (a server without
17//! resume); [`Reply::cut_after`] drops the connection after N body bytes (a dropped download).
18//!
19//! Every connection is served on its own thread, so a slow handler or a client holding a
20//! connection open never blocks the next request. Every request is recorded ([`MockServer::seen`]).
21use crate::util::Result;
22use serde_json::{json, Value};
23use std::io::{Read, Write};
24use std::net::{SocketAddr, TcpListener, TcpStream};
25use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
26use std::sync::{Arc, Mutex};
27use std::time::Duration;
28
29/// Static route: first route whose method (if any) and path (query ignored) match answers.
30#[derive(Debug, Clone)]
31pub struct Route {
32    pub method: Option<String>,
33    pub path: String,
34    pub status: u16,
35    pub body: Value,
36    pub content_type: Option<String>,
37}
38
39/// One recorded request.
40#[derive(Debug, Clone)]
41pub struct Seen {
42    pub method: String,
43    /// Request target as sent, including any query string.
44    pub path: String,
45    /// Header names as sent; use [`Seen::header`] for case-insensitive lookup.
46    pub headers: Vec<(String, String)>,
47    /// Body as (lossy) UTF-8 text.
48    pub body: String,
49    /// Raw body bytes (multipart uploads, binary posts).
50    pub body_bytes: Vec<u8>,
51}
52
53/// The request a [`MockServer::start_with`] handler receives.
54pub type Request = Seen;
55
56impl Seen {
57    /// Path without the query string.
58    pub fn route(&self) -> &str {
59        self.path.split('?').next().unwrap_or("")
60    }
61    /// The query string, without `?`.
62    pub fn query(&self) -> Option<&str> {
63        self.path.split_once('?').map(|(_, q)| q)
64    }
65    /// One query parameter (no percent-decoding).
66    pub fn query_param(&self, name: &str) -> Option<&str> {
67        self.query()?
68            .split('&')
69            .filter_map(|kv| kv.split_once('=').or(Some((kv, ""))))
70            .find(|(k, _)| *k == name)
71            .map(|(_, v)| v)
72    }
73    /// Case-insensitive header value.
74    pub fn header(&self, name: &str) -> Option<&str> {
75        self.headers
76            .iter()
77            .find(|(k, _)| k.eq_ignore_ascii_case(name))
78            .map(|(_, v)| v.as_str())
79    }
80    /// Body parsed as JSON, or `Null`.
81    pub fn json(&self) -> Value {
82        serde_json::from_slice(&self.body_bytes).unwrap_or(Value::Null)
83    }
84    pub fn is(&self, method: &str, route: &str) -> bool {
85        self.method.eq_ignore_ascii_case(method) && self.route() == route
86    }
87    pub fn to_value(&self) -> Value {
88        let headers: serde_json::Map<String, Value> = self
89            .headers
90            .iter()
91            .map(|(k, v)| (k.to_ascii_lowercase(), json!(v)))
92            .collect();
93        json!({"method": self.method, "path": self.path, "headers": headers, "body": self.body,
94               "json": self.json()})
95    }
96}
97
98/// A handler's answer: any status, content type, extra headers and binary body.
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct Reply {
101    pub status: u16,
102    pub content_type: String,
103    pub headers: Vec<(String, String)>,
104    pub body: Vec<u8>,
105}
106
107impl Reply {
108    /// 200 with a JSON body.
109    pub fn json(v: Value) -> Self {
110        Self::status(200, v)
111    }
112    /// Any status with a JSON body.
113    pub fn status(status: u16, v: Value) -> Self {
114        Self {
115            status,
116            content_type: "application/json".into(),
117            headers: vec![],
118            body: v.to_string().into_bytes(),
119        }
120    }
121    /// 200 with a binary body (generated media: `image/png`, `audio/mpeg`, `video/mp4`, ...).
122    pub fn bytes(content_type: &str, body: impl Into<Vec<u8>>) -> Self {
123        Self {
124            status: 200,
125            content_type: content_type.into(),
126            headers: vec![],
127            body: body.into(),
128        }
129    }
130    /// 200 with a file's bytes; an unreadable file is a 500 naming it.
131    pub fn file(content_type: &str, path: &std::path::Path) -> Self {
132        match std::fs::read(path) {
133            Ok(b) => Self::bytes(content_type, b),
134            Err(e) => Self::status(
135                500,
136                json!({"error": format!("mock could not read {}: {e}", path.display())}),
137            ),
138        }
139    }
140    /// 200 `text/plain`.
141    pub fn text(body: &str) -> Self {
142        Self::bytes("text/plain; charset=utf-8", body.as_bytes().to_vec())
143    }
144    /// 404 `{"error":"unmocked"}` (what an unmatched request gets).
145    pub fn unmocked() -> Self {
146        Self::status(404, json!({"error": "unmocked"}))
147    }
148    pub fn with_status(mut self, status: u16) -> Self {
149        self.status = status;
150        self
151    }
152    pub fn with_header(mut self, name: &str, value: &str) -> Self {
153        self.headers.push((name.into(), value.into()));
154        self
155    }
156    /// Answer every request with the full body, ignoring any `Range` (a server
157    /// that cannot resume). Recorded as the reserved [`IGNORE_RANGE_HEADER`],
158    /// which the mock consumes and never sends.
159    pub fn ignore_range(self) -> Self {
160        self.with_header(IGNORE_RANGE_HEADER, "1")
161    }
162    /// Close the connection without writing any response: a half-accepted request (the
163    /// client sent it, the server never answered). The request is still recorded and logged
164    /// (status `0` in [`RequestLog`]). Recorded as the reserved [`DROP_HEADER`], which the
165    /// mock consumes and never sends.
166    pub fn drop_connection() -> Self {
167        Self::bytes("application/octet-stream", Vec::new()).with_header(DROP_HEADER, "1")
168    }
169    /// True for a [`Reply::drop_connection`] reply.
170    pub fn is_drop(&self) -> bool {
171        self.headers
172            .iter()
173            .any(|(k, _)| k.eq_ignore_ascii_case(DROP_HEADER))
174    }
175    /// Send the response head (with the full `Content-Length`), then only the first
176    /// `bytes` body bytes, then close the connection: a download dropped mid-transfer.
177    /// Applies after any `Range` slicing. Recorded as the reserved [`CUT_AFTER_HEADER`],
178    /// which the mock consumes and never sends.
179    pub fn cut_after(self, bytes: u64) -> Self {
180        self.with_header(CUT_AFTER_HEADER, &bytes.to_string())
181    }
182}
183
184/// Reserved reply header behind [`Reply::drop_connection`]; consumed, never sent.
185pub const DROP_HEADER: &str = "X-RightKit-Mock-Drop";
186/// Reserved reply header behind [`Reply::ignore_range`]; consumed, never sent.
187pub const IGNORE_RANGE_HEADER: &str = "X-RightKit-Mock-Ignore-Range";
188/// Reserved reply header behind [`Reply::cut_after`]; consumed, never sent.
189pub const CUT_AFTER_HEADER: &str = "X-RightKit-Mock-Cut-After";
190
191/// How a reply's body is sent for one request.
192#[derive(Debug, Clone, PartialEq, Eq)]
193enum BodyPlan {
194    Full,
195    /// Inclusive byte range of the body.
196    Partial(u64, u64),
197    Unsatisfiable,
198}
199
200/// Parse a single `bytes=` range against a body of `len` bytes. `None` means
201/// "ignore the header" (absent, malformed or multi-range: serve the full body).
202fn plan_range(range: Option<&str>, len: u64) -> Option<BodyPlan> {
203    let spec = range?.trim().strip_prefix("bytes=")?.trim();
204    if spec.contains(',') {
205        return None;
206    }
207    let (a, b) = spec.split_once('-')?;
208    let (a, b) = (a.trim(), b.trim());
209    let digits = |s: &str| !s.is_empty() && s.bytes().all(|c| c.is_ascii_digit());
210    if a.is_empty() {
211        if !digits(b) {
212            return None;
213        }
214        let n: u64 = b.parse().ok()?;
215        if n == 0 || len == 0 {
216            return Some(BodyPlan::Unsatisfiable);
217        }
218        return Some(BodyPlan::Partial(len.saturating_sub(n), len - 1));
219    }
220    if !digits(a) || !(b.is_empty() || digits(b)) {
221        return None;
222    }
223    let start: u64 = a.parse().ok()?;
224    let end = if b.is_empty() {
225        None
226    } else {
227        Some(b.parse::<u64>().ok()?)
228    };
229    if end.is_some_and(|e| e < start) {
230        return None;
231    }
232    if start >= len {
233        return Some(BodyPlan::Unsatisfiable);
234    }
235    Some(BodyPlan::Partial(
236        start,
237        end.map_or(len - 1, |e| e.min(len - 1)),
238    ))
239}
240
241/// `If-Range` holds when it equals the reply's strong `ETag` or its `Last-Modified`.
242fn if_range_holds(if_range: &str, headers: &[(String, String)]) -> bool {
243    let v = if_range.trim();
244    let get = |n: &str| {
245        headers
246            .iter()
247            .find(|(k, _)| k.eq_ignore_ascii_case(n))
248            .map(|(_, v)| v.trim())
249    };
250    if v.starts_with('"') || v.starts_with("W/") {
251        !v.starts_with("W/") && get("etag").is_some_and(|e| e == v && !e.starts_with("W/"))
252    } else {
253        get("last-modified").is_some_and(|m| m == v)
254    }
255}
256
257/// `None` from a handler means "not mocked" (404).
258impl From<Option<Reply>> for Reply {
259    fn from(r: Option<Reply>) -> Self {
260        r.unwrap_or_else(Reply::unmocked)
261    }
262}
263
264type Handler = dyn Fn(&Request, &str) -> Reply + Send + Sync;
265
266/// One request as the per-request log hook ([`MockOptions::on_request`]) sees it.
267#[derive(Debug, Clone, PartialEq, Eq)]
268pub struct RequestLog {
269    /// Empty when the request could not be read (limit hit, malformed, closed early).
270    pub method: String,
271    /// Request target as sent; empty when the request could not be read.
272    pub path: String,
273    /// Status written; `0` when the connection was dropped with no response.
274    pub status: u16,
275    /// Why the mock itself rejected the request (limit, `Transfer-Encoding`, connection cap),
276    /// else `None`.
277    pub rejected: Option<&'static str>,
278}
279
280type LogHook = dyn Fn(&RequestLog) + Send + Sync;
281
282/// Extra behaviour for [`MockServer::start_opts`]. `MockOptions::default()` behaves exactly
283/// like [`MockServer::start_with`]: ephemeral port, no limits, no cap, no linger.
284#[derive(Clone, Default)]
285pub struct MockOptions {
286    /// Bind this port on `127.0.0.1` instead of an ephemeral one (`serve --port n`).
287    pub port: Option<u16>,
288    /// Request head larger than this gets `431`.
289    pub max_header_bytes: Option<usize>,
290    /// A `Content-Length` larger than this gets `413`.
291    pub max_body_bytes: Option<usize>,
292    /// Any `Transfer-Encoding` request header gets `501` (only `Content-Length` bodies are read).
293    pub reject_transfer_encoding: bool,
294    /// At most this many connections are served at once; extra ones get `503` and are closed.
295    pub max_connections: Option<usize>,
296    /// After a reply, shut down the write side, then read and discard the peer's remaining
297    /// bytes for up to this long before closing, so a close with unread input cannot reset
298    /// the connection and discard a reply the client has not read.
299    pub linger: Option<Duration>,
300    /// Called once per request, after it is answered (or dropped, or rejected).
301    pub on_request: Option<Arc<LogHook>>,
302}
303
304impl MockOptions {
305    pub fn port(mut self, port: u16) -> Self {
306        self.port = Some(port);
307        self
308    }
309    pub fn max_header_bytes(mut self, n: usize) -> Self {
310        self.max_header_bytes = Some(n);
311        self
312    }
313    pub fn max_body_bytes(mut self, n: usize) -> Self {
314        self.max_body_bytes = Some(n);
315        self
316    }
317    pub fn reject_transfer_encoding(mut self) -> Self {
318        self.reject_transfer_encoding = true;
319        self
320    }
321    pub fn max_connections(mut self, n: usize) -> Self {
322        self.max_connections = Some(n);
323        self
324    }
325    pub fn linger(mut self, d: Duration) -> Self {
326        self.linger = Some(d);
327        self
328    }
329    pub fn on_request(mut self, f: impl Fn(&RequestLog) + Send + Sync + 'static) -> Self {
330        self.on_request = Some(Arc::new(f));
331        self
332    }
333}
334
335/// Cloneable, thread-safe handle that stops a [`MockServer`] (from a signal handler thread,
336/// a stdin-close watcher, or a `/shutdown` route) and lets another thread wait for it.
337#[derive(Clone)]
338pub struct ShutdownHandle {
339    addr: SocketAddr,
340    stop: Arc<AtomicBool>,
341}
342
343impl ShutdownHandle {
344    /// Stop accepting; in-flight connections finish.
345    pub fn shutdown(&self) {
346        self.stop.store(true, Ordering::SeqCst);
347        let _ = TcpStream::connect_timeout(&self.addr, Duration::from_millis(200));
348    }
349    pub fn is_shutdown(&self) -> bool {
350        self.stop.load(Ordering::SeqCst)
351    }
352    /// Block until [`ShutdownHandle::shutdown`] is called (the main thread of a standalone mock).
353    pub fn wait(&self) {
354        while !self.is_shutdown() {
355            std::thread::sleep(Duration::from_millis(5));
356        }
357    }
358}
359
360struct Limits {
361    opts: MockOptions,
362}
363
364pub struct MockServer {
365    pub base: String,
366    addr: SocketAddr,
367    seen: Arc<Mutex<Vec<Seen>>>,
368    stop: Arc<AtomicBool>,
369}
370
371impl MockServer {
372    /// Static JSON routes; unmatched requests get 404 `{"error":"unmocked"}`.
373    pub fn start(routes: Vec<Route>) -> Result<Self> {
374        Self::start_with(move |req: &Request, _base: &str| {
375            routes
376                .iter()
377                .find(|r| {
378                    r.path == req.route()
379                        && r.method
380                            .as_deref()
381                            .map(|m| m.eq_ignore_ascii_case(&req.method))
382                            .unwrap_or(true)
383                })
384                .map(|r| Reply {
385                    status: r.status,
386                    content_type: r
387                        .content_type
388                        .clone()
389                        .unwrap_or_else(|| "application/json".into()),
390                    headers: vec![],
391                    body: r.body.to_string().into_bytes(),
392                })
393        })
394    }
395
396    /// Static routes answered with full [`Reply`]s (binary bodies, extra headers such as a
397    /// strong `ETag`, [`Reply::ignore_range`], [`Reply::cut_after`]): `(method, path, reply)`,
398    /// first match wins, `None` method matches any, query ignored; unmatched requests get 404.
399    pub fn start_static(routes: Vec<(Option<String>, String, Reply)>) -> Result<Self> {
400        Self::start_with(move |req: &Request, _base: &str| {
401            routes
402                .iter()
403                .find(|(m, p, _)| {
404                    *p == req.route()
405                        && m.as_deref()
406                            .is_none_or(|m| m.eq_ignore_ascii_case(&req.method))
407                })
408                .map(|(_, _, r)| r.clone())
409        })
410    }
411
412    /// Dynamic handler: `handler(request, base_url)` answers every request. `base_url`
413    /// (`http://127.0.0.1:<port>`) lets a reply point the client back at the mock (a job's
414    /// result URL). Return a [`Reply`], or an `Option<Reply>` where `None` means 404. A
415    /// panicking handler yields a 500 instead of killing the server.
416    pub fn start_with<R: Into<Reply>>(
417        handler: impl Fn(&Request, &str) -> R + Send + Sync + 'static,
418    ) -> Result<Self> {
419        Self::start_opts(MockOptions::default(), handler)
420    }
421
422    /// [`MockServer::start_with`] plus [`MockOptions`]: a chosen port, request limits
423    /// (`431`/`413`/`501`), a connection cap (`503`), linger/drain, and a per-request log hook.
424    /// Rejections are written by the mock; the handler never sees those requests.
425    pub fn start_opts<R: Into<Reply>>(
426        opts: MockOptions,
427        handler: impl Fn(&Request, &str) -> R + Send + Sync + 'static,
428    ) -> Result<Self> {
429        let listener = TcpListener::bind(("127.0.0.1", opts.port.unwrap_or(0)))?;
430        let addr = listener.local_addr()?;
431        let base = format!("http://{addr}");
432        let seen = Arc::new(Mutex::new(Vec::new()));
433        let stop = Arc::new(AtomicBool::new(false));
434        let handler: Arc<Handler> = Arc::new(move |r: &Request, b: &str| handler(r, b).into());
435        let (s2, st2, b2) = (seen.clone(), stop.clone(), base.clone());
436        let limits = Arc::new(Limits { opts });
437        let active = Arc::new(AtomicUsize::new(0));
438        std::thread::spawn(move || {
439            for mut conn in listener.incoming().flatten() {
440                if st2.load(Ordering::SeqCst) {
441                    break;
442                }
443                if limits
444                    .opts
445                    .max_connections
446                    .is_some_and(|cap| active.load(Ordering::SeqCst) >= cap)
447                {
448                    reject(&mut conn, &limits, 503, "too many connections");
449                    continue;
450                }
451                active.fetch_add(1, Ordering::SeqCst);
452                let (s3, h, b, l, a) = (
453                    s2.clone(),
454                    handler.clone(),
455                    b2.clone(),
456                    limits.clone(),
457                    active.clone(),
458                );
459                let spawned = std::thread::Builder::new().spawn(move || {
460                    let _ = serve(conn, &s3, &b, &*h, &l);
461                    a.fetch_sub(1, Ordering::SeqCst);
462                });
463                if spawned.is_err() {
464                    active.fetch_sub(1, Ordering::SeqCst);
465                }
466            }
467        });
468        Ok(Self {
469            base,
470            addr,
471            seen,
472            stop,
473        })
474    }
475
476    /// A handle that stops this server from any thread.
477    pub fn shutdown_handle(&self) -> ShutdownHandle {
478        ShutdownHandle {
479            addr: self.addr,
480            stop: self.stop.clone(),
481        }
482    }
483
484    pub fn port(&self) -> u16 {
485        self.addr.port()
486    }
487    /// `base + path`.
488    pub fn url(&self, path: &str) -> String {
489        format!("{}{path}", self.base)
490    }
491    pub fn seen(&self) -> Vec<Seen> {
492        self.seen.lock().unwrap().clone()
493    }
494    pub fn close(&self) {
495        self.stop.store(true, Ordering::SeqCst);
496        let _ = TcpStream::connect_timeout(&self.addr, Duration::from_millis(200));
497    }
498}
499
500impl Drop for MockServer {
501    fn drop(&mut self) {
502        self.close();
503    }
504}
505
506fn serve(
507    mut s: TcpStream,
508    seen: &Mutex<Vec<Seen>>,
509    base: &str,
510    handler: &Handler,
511    limits: &Limits,
512) -> std::io::Result<()> {
513    s.set_read_timeout(Some(Duration::from_secs(10)))?;
514    let o = &limits.opts;
515    let mut buf = Vec::new();
516    let mut chunk = [0u8; 16384];
517    let (head_end, content_len) = loop {
518        let n = s.read(&mut chunk)?;
519        if n == 0 {
520            return Ok(());
521        }
522        buf.extend_from_slice(&chunk[..n]);
523        let end = buf.windows(4).position(|w| w == b"\r\n\r\n");
524        if let Some(max) = o.max_header_bytes {
525            if end.unwrap_or(buf.len()) > max {
526                reject(&mut s, limits, 431, "request headers are too large");
527                return Ok(());
528            }
529        }
530        if let Some(p) = end {
531            let head = String::from_utf8_lossy(&buf[..p]).to_ascii_lowercase();
532            if o.reject_transfer_encoding
533                && head.lines().skip(1).any(|l| {
534                    l.split_once(':')
535                        .is_some_and(|(k, _)| k.trim() == "transfer-encoding")
536                })
537            {
538                reject(
539                    &mut s,
540                    limits,
541                    501,
542                    "Transfer-Encoding is not supported; send Content-Length",
543                );
544                return Ok(());
545            }
546            let mut lens = head.lines().skip(1).filter_map(|l| {
547                l.split_once(':')
548                    .filter(|(k, _)| k.trim() == "content-length")
549                    .map(|(_, v)| v.trim().to_string())
550            });
551            let len = match (lens.next(), lens.next()) {
552                (None, _) => 0,
553                (Some(v), None) if !v.is_empty() && v.bytes().all(|b| b.is_ascii_digit()) => {
554                    match v.parse::<usize>() {
555                        Ok(n) => n,
556                        Err(_) => {
557                            reject(&mut s, limits, 400, "invalid Content-Length");
558                            return Ok(());
559                        }
560                    }
561                }
562                _ => {
563                    reject(&mut s, limits, 400, "invalid or duplicate Content-Length");
564                    return Ok(());
565                }
566            };
567            if o.max_body_bytes.is_some_and(|max| len > max) {
568                reject(&mut s, limits, 413, "request body is too large");
569                return Ok(());
570            }
571            break (p + 4, len);
572        }
573    };
574    let Some(total) = head_end.checked_add(content_len) else {
575        reject(&mut s, limits, 400, "invalid Content-Length");
576        return Ok(());
577    };
578    while buf.len() < total {
579        let n = s.read(&mut chunk)?;
580        if n == 0 {
581            reject(
582                &mut s,
583                limits,
584                400,
585                "request body ended before Content-Length",
586            );
587            return Ok(());
588        }
589        buf.extend_from_slice(&chunk[..n]);
590    }
591    let head = String::from_utf8_lossy(&buf[..head_end]).to_string();
592    let mut lines = head.lines();
593    let mut first = lines.next().unwrap_or("").split_whitespace();
594    let (method, path) = (
595        first.next().unwrap_or("").to_string(),
596        first.next().unwrap_or("").to_string(),
597    );
598    let headers = lines
599        .filter_map(|l| l.split_once(':'))
600        .map(|(k, v)| (k.trim().to_string(), v.trim().to_string()))
601        .collect();
602    let body_bytes = buf[head_end..total].to_vec();
603    let req = Seen {
604        method,
605        path,
606        headers,
607        body: String::from_utf8_lossy(&body_bytes).into_owned(),
608        body_bytes,
609    };
610    seen.lock().unwrap().push(req.clone());
611    let reply = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| handler(&req, base)))
612        .unwrap_or_else(|_| Reply::status(500, json!({"error": "mock handler panicked"})));
613    let log = |status: u16| {
614        if let Some(f) = &o.on_request {
615            f(&RequestLog {
616                method: req.method.clone(),
617                path: req.path.clone(),
618                status,
619                rejected: None,
620            });
621        }
622    };
623    if reply.is_drop() {
624        log(0);
625        return Ok(());
626    }
627    let status = reply.status;
628    let r = write_reply(&mut s, &req, reply);
629    log(status);
630    linger(&mut s, o);
631    r
632}
633
634/// Write a mock-generated JSON error, log it, and close.
635fn reject(s: &mut TcpStream, limits: &Limits, status: u16, why: &'static str) {
636    let _ = s.set_write_timeout(Some(Duration::from_secs(10)));
637    let req = Seen {
638        method: String::new(),
639        path: String::new(),
640        headers: vec![],
641        body: String::new(),
642        body_bytes: vec![],
643    };
644    let body = json!({"error": {"code": "bad_request", "message": why}});
645    let _ = write_reply(s, &req, Reply::status(status, body));
646    if let Some(f) = &limits.opts.on_request {
647        f(&RequestLog {
648            method: String::new(),
649            path: String::new(),
650            status,
651            rejected: Some(why),
652        });
653    }
654    linger(s, &limits.opts);
655}
656
657/// Stop sending, then drain the peer's last bytes so the close is not a reset.
658fn linger(s: &mut TcpStream, o: &MockOptions) {
659    let Some(d) = o.linger else { return };
660    let _ = s.shutdown(std::net::Shutdown::Write);
661    let _ = s.set_read_timeout(Some(d));
662    let mut sink = [0u8; 1024];
663    let deadline = std::time::Instant::now() + d;
664    while std::time::Instant::now() < deadline && matches!(s.read(&mut sink), Ok(n) if n > 0) {}
665}
666
667fn write_reply(s: &mut TcpStream, req: &Request, mut reply: Reply) -> std::io::Result<()> {
668    let take = |reply: &mut Reply, name: &str| -> Option<String> {
669        let i = reply
670            .headers
671            .iter()
672            .position(|(k, _)| k.eq_ignore_ascii_case(name))?;
673        Some(reply.headers.remove(i).1)
674    };
675    let ignore_range = take(&mut reply, IGNORE_RANGE_HEADER).is_some();
676    let cut_after = take(&mut reply, CUT_AFTER_HEADER).and_then(|v| v.trim().parse::<u64>().ok());
677    let len = reply.body.len() as u64;
678    let ranged_method =
679        req.method.eq_ignore_ascii_case("GET") || req.method.eq_ignore_ascii_case("HEAD");
680    let plan = if reply.status == 200 && ranged_method && !ignore_range {
681        let honoured = req
682            .header("if-range")
683            .is_none_or(|v| if_range_holds(v, &reply.headers));
684        honoured
685            .then(|| plan_range(req.header("range"), len))
686            .flatten()
687            .unwrap_or(BodyPlan::Full)
688    } else {
689        BodyPlan::Full
690    };
691    let (status, reason, body, extra): (u16, &str, &[u8], Option<String>) = match plan {
692        BodyPlan::Full => (reply.status, "X", &reply.body[..], None),
693        BodyPlan::Partial(a, b) => (
694            206,
695            "Partial Content",
696            &reply.body[a as usize..=b as usize],
697            Some(format!(
698                "Content-Range: bytes {a}-{b}/{len}\r\nAccept-Ranges: bytes\r\n"
699            )),
700        ),
701        BodyPlan::Unsatisfiable => (
702            416,
703            "Range Not Satisfiable",
704            &[][..],
705            Some(format!(
706                "Content-Range: bytes */{len}\r\nAccept-Ranges: bytes\r\n"
707            )),
708        ),
709    };
710    let mut head = format!(
711        "HTTP/1.1 {status} {reason}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n",
712        reply.content_type,
713        body.len()
714    );
715    head.push_str(extra.as_deref().unwrap_or(""));
716    for (k, v) in &reply.headers {
717        head.push_str(&format!("{k}: {v}\r\n"));
718    }
719    head.push_str("\r\n");
720    s.write_all(head.as_bytes())?;
721    if !req.method.eq_ignore_ascii_case("HEAD") {
722        let n = cut_after.map_or(body.len(), |c| (c as usize).min(body.len()));
723        s.write_all(&body[..n])?;
724        if n < body.len() {
725            s.flush()?;
726            return s.shutdown(std::net::Shutdown::Both);
727        }
728    }
729    s.flush()
730}
731
732#[cfg(test)]
733mod tests {
734    use super::*;
735    use crate::http::request;
736    use std::sync::atomic::AtomicUsize;
737
738    fn get(m: &MockServer, method: &str, path: &str, body: Option<&str>) -> crate::http::Response {
739        request(
740            m.addr,
741            method,
742            path,
743            Some("Bearer k"),
744            body,
745            Duration::from_secs(5),
746        )
747        .unwrap()
748    }
749
750    #[test]
751    fn static_routes_still_answer_json_and_404() {
752        let m = MockServer::start(vec![Route {
753            method: Some("POST".into()),
754            path: "/v1/gen".into(),
755            status: 201,
756            body: json!({"id": "j1"}),
757            content_type: None,
758        }])
759        .unwrap();
760        let r = get(&m, "POST", "/v1/gen?x=1", Some(r#"{"prompt":"p"}"#));
761        assert_eq!(r.status, 201);
762        assert_eq!(r.content_type, "application/json");
763        assert_eq!(
764            serde_json::from_slice::<Value>(&r.body).unwrap()["id"],
765            "j1"
766        );
767        assert_eq!(get(&m, "GET", "/v1/gen", None).status, 404);
768        let seen = m.seen();
769        assert_eq!(seen.len(), 2);
770        assert_eq!(seen[0].json()["prompt"], "p");
771        assert_eq!(seen[0].header("authorization"), Some("Bearer k"));
772        assert_eq!(seen[0].query_param("x"), Some("1"));
773        assert_eq!(seen[0].to_value()["json"]["prompt"], "p");
774    }
775
776    #[test]
777    fn dynamic_handler_is_stateful_binary_and_knows_its_base() {
778        let png: Vec<u8> = (0..=255u8).cycle().take(70_000).collect();
779        let polls = Arc::new(AtomicUsize::new(0));
780        let (p2, png2) = (polls.clone(), png.clone());
781        let m = MockServer::start_with(move |req: &Request, base: &str| {
782            if req.is("POST", "/jobs") {
783                Some(Reply::json(json!({"poll": format!("{base}/jobs/1")})))
784            } else if req.is("GET", "/jobs/1") {
785                let n = p2.fetch_add(1, Ordering::SeqCst);
786                Some(if n < 2 {
787                    Reply::json(json!({"status": "running"}))
788                } else {
789                    Reply::json(json!({"status": "done", "url": format!("{base}/out.png")}))
790                })
791            } else if req.is("GET", "/out.png") {
792                Some(Reply::bytes("image/png", png2.clone()).with_header("X-Mock", "1"))
793            } else if req.is("GET", "/boom") {
794                panic!("handler bug")
795            } else {
796                None
797            }
798        })
799        .unwrap();
800        let first: Value =
801            serde_json::from_slice(&get(&m, "POST", "/jobs", Some("{}")).body).unwrap();
802        assert_eq!(first["poll"], m.url("/jobs/1"));
803        let states: Vec<String> = (0..3)
804            .map(|_| {
805                serde_json::from_slice::<Value>(&get(&m, "GET", "/jobs/1", None).body).unwrap()
806                    ["status"]
807                    .as_str()
808                    .unwrap()
809                    .to_string()
810            })
811            .collect();
812        assert_eq!(states, ["running", "running", "done"]);
813        let media = get(&m, "GET", "/out.png", None);
814        assert_eq!(media.status, 200);
815        assert_eq!(media.content_type, "image/png");
816        assert_eq!(media.body, png, "binary body survives byte-for-byte");
817        assert_eq!(get(&m, "GET", "/boom", None).status, 500);
818        assert_eq!(get(&m, "GET", "/nope", None).status, 404);
819        assert_eq!(m.seen().len(), 7);
820    }
821
822    #[test]
823    fn a_held_connection_does_not_block_other_requests() {
824        let m = MockServer::start_with(|_: &Request, _: &str| Reply::text("ok")).unwrap();
825        // A client that connects and never sends a request.
826        let _idle = TcpStream::connect(m.addr).unwrap();
827        let started = std::time::Instant::now();
828        let r = get(&m, "GET", "/", None);
829        assert_eq!(r.text(), "ok");
830        assert!(started.elapsed() < Duration::from_secs(3));
831    }
832
833    /// Raw GET with extra headers: (status, lowercase headers, body, eof_reached_cleanly).
834    fn raw(
835        m: &MockServer,
836        method: &str,
837        path: &str,
838        extra: &[(&str, &str)],
839    ) -> (u16, Vec<(String, String)>, Vec<u8>) {
840        let mut s = TcpStream::connect(m.addr).unwrap();
841        s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
842        let mut head = format!("{method} {path} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n");
843        for (k, v) in extra {
844            head.push_str(&format!("{k}: {v}\r\n"));
845        }
846        head.push_str("\r\n");
847        s.write_all(head.as_bytes()).unwrap();
848        let mut buf = Vec::new();
849        let _ = s.read_to_end(&mut buf);
850        let split = buf.windows(4).position(|w| w == b"\r\n\r\n").unwrap();
851        let text = String::from_utf8_lossy(&buf[..split]).to_string();
852        let mut lines = text.lines();
853        let status = lines
854            .next()
855            .unwrap()
856            .split_whitespace()
857            .nth(1)
858            .unwrap()
859            .parse()
860            .unwrap();
861        let headers = lines
862            .filter_map(|l| l.split_once(':'))
863            .map(|(k, v)| (k.trim().to_ascii_lowercase(), v.trim().to_string()))
864            .collect();
865        (status, headers, buf[split + 4..].to_vec())
866    }
867
868    fn hdr<'a>(h: &'a [(String, String)], name: &str) -> Option<&'a str> {
869        h.iter().find(|(k, _)| k == name).map(|(_, v)| v.as_str())
870    }
871
872    fn data() -> Vec<u8> {
873        (0..1000u32).map(|i| (i * 7 % 251) as u8).collect()
874    }
875
876    #[test]
877    fn static_and_dynamic_binary_replies_serve_byte_ranges() {
878        let body = data();
879        let file =
880            Reply::bytes("application/octet-stream", body.clone()).with_header("ETag", "\"abc\"");
881        let st = MockServer::start_static(vec![
882            (Some("GET".into()), "/f.bin".into(), file.clone()),
883            (Some("HEAD".into()), "/f.bin".into(), file.clone()),
884        ])
885        .unwrap();
886        let f2 = file.clone();
887        let dy = MockServer::start_with(move |_: &Request, _: &str| f2.clone()).unwrap();
888        for m in [&st, &dy] {
889            let (s, h, b) = raw(m, "GET", "/f.bin", &[]);
890            assert_eq!((s, b.as_slice()), (200, &body[..]), "no Range: full 200");
891            assert_eq!(hdr(&h, "content-range"), None);
892
893            let (s, h, b) = raw(m, "GET", "/f.bin", &[("Range", "bytes=10-19")]);
894            assert_eq!(s, 206);
895            assert_eq!(b, &body[10..20]);
896            assert_eq!(hdr(&h, "content-range"), Some("bytes 10-19/1000"));
897            assert_eq!(hdr(&h, "content-length"), Some("10"));
898            assert_eq!(hdr(&h, "etag"), Some("\"abc\""));
899
900            let (s, h, b) = raw(m, "GET", "/f.bin", &[("Range", "bytes=990-")]);
901            assert_eq!((s, b.as_slice()), (206, &body[990..]));
902            assert_eq!(hdr(&h, "content-range"), Some("bytes 990-999/1000"));
903
904            let (s, h, b) = raw(m, "GET", "/f.bin", &[("Range", "bytes=-5")]);
905            assert_eq!((s, b.as_slice()), (206, &body[995..]));
906            assert_eq!(hdr(&h, "content-range"), Some("bytes 995-999/1000"));
907
908            let (s, h, b) = raw(m, "GET", "/f.bin", &[("Range", "bytes=-5000")]);
909            assert_eq!(
910                (s, b.as_slice()),
911                (206, &body[..]),
912                "suffix longer than the body"
913            );
914            assert_eq!(hdr(&h, "content-range"), Some("bytes 0-999/1000"));
915
916            let (s, h, b) = raw(m, "GET", "/f.bin", &[("Range", "bytes=995-5000")]);
917            assert_eq!((s, b.as_slice()), (206, &body[995..]), "end clamped");
918            assert_eq!(hdr(&h, "content-range"), Some("bytes 995-999/1000"));
919
920            for unsat in ["bytes=1000-", "bytes=5000-6000", "bytes=-0"] {
921                let (s, h, b) = raw(m, "GET", "/f.bin", &[("Range", unsat)]);
922                assert_eq!(s, 416, "{unsat}");
923                assert!(b.is_empty());
924                assert_eq!(hdr(&h, "content-range"), Some("bytes */1000"));
925            }
926            for ignored in ["bytes=5-1", "bytes=0-1,5-6", "items=0-1", "bytes=x-"] {
927                let (s, _, b) = raw(m, "GET", "/f.bin", &[("Range", ignored)]);
928                assert_eq!((s, b.as_slice()), (200, &body[..]), "{ignored}");
929            }
930            // If-Range: matching ETag resumes, anything else gets the full body.
931            let (s, _, b) = raw(
932                m,
933                "GET",
934                "/f.bin",
935                &[("Range", "bytes=10-"), ("If-Range", "\"abc\"")],
936            );
937            assert_eq!((s, b.as_slice()), (206, &body[10..]));
938            let (s, _, b) = raw(
939                m,
940                "GET",
941                "/f.bin",
942                &[("Range", "bytes=10-"), ("If-Range", "\"old\"")],
943            );
944            assert_eq!((s, b.as_slice()), (200, &body[..]));
945            // HEAD: the range head, no body.
946            let (s, h, b) = raw(m, "HEAD", "/f.bin", &[("Range", "bytes=0-9")]);
947            assert_eq!(s, 206);
948            assert!(b.is_empty());
949            assert_eq!(hdr(&h, "content-length"), Some("10"));
950        }
951        assert_eq!(raw(&st, "POST", "/f.bin", &[]).0, 404, "method filter");
952        // Non-200 replies are never sliced.
953        let err =
954            MockServer::start_with(|_: &Request, _: &str| Reply::text("nope").with_status(500))
955                .unwrap();
956        let (s, _, b) = raw(&err, "GET", "/", &[("Range", "bytes=0-1")]);
957        assert_eq!((s, b.as_slice()), (500, &b"nope"[..]));
958    }
959
960    #[test]
961    fn ignore_range_and_cut_after_simulate_no_resume_and_dropped_connections() {
962        let body = data();
963        let b2 = body.clone();
964        let m = MockServer::start_with(move |req: &Request, _: &str| {
965            let r = Reply::bytes("application/octet-stream", b2.clone());
966            match req.route() {
967                "/norange" => r.ignore_range(),
968                "/drop" => r.cut_after(100),
969                _ => r,
970            }
971        })
972        .unwrap();
973        let (s, h, b) = raw(&m, "GET", "/norange", &[("Range", "bytes=10-")]);
974        assert_eq!((s, b.as_slice()), (200, &body[..]));
975        assert_eq!(hdr(&h, "content-range"), None);
976        assert!(
977            h.iter().all(|(k, _)| !k.starts_with("x-rightkit-mock")),
978            "reserved headers are never sent: {h:?}"
979        );
980
981        let (s, h, b) = raw(&m, "GET", "/drop", &[]);
982        assert_eq!(s, 200);
983        assert_eq!(
984            hdr(&h, "content-length"),
985            Some("1000"),
986            "head promises the full body"
987        );
988        assert_eq!(b, &body[..100], "then the connection drops");
989        let (s, h, b) = raw(&m, "GET", "/drop", &[("Range", "bytes=500-")]);
990        assert_eq!(s, 206);
991        assert_eq!(hdr(&h, "content-length"), Some("500"));
992        assert_eq!(b, &body[500..600], "cut applies after range slicing");
993        // The reserved headers are consumed even on static replies.
994        let st =
995            MockServer::start_static(vec![(None, "/x".into(), Reply::text("hello").cut_after(2))])
996                .unwrap();
997        let (_, h, b) = raw(&st, "GET", "/x", &[]);
998        assert_eq!(b, b"he");
999        assert!(h.iter().all(|(k, _)| !k.starts_with("x-rightkit-mock")));
1000    }
1001
1002    fn exchange(addr: SocketAddr, req: &[u8]) -> String {
1003        let mut st = TcpStream::connect(addr).unwrap();
1004        st.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
1005        let _ = st.write_all(req);
1006        let mut out = Vec::new();
1007        let _ = st.read_to_end(&mut out);
1008        String::from_utf8_lossy(&out).into_owned()
1009    }
1010
1011    #[test]
1012    fn opts_bind_chosen_port_and_drop_connection_and_log() {
1013        let free = TcpListener::bind(("127.0.0.1", 0))
1014            .unwrap()
1015            .local_addr()
1016            .unwrap()
1017            .port();
1018        let log = Arc::new(Mutex::new(Vec::<RequestLog>::new()));
1019        let l2 = log.clone();
1020        let m = MockServer::start_opts(
1021            MockOptions::default()
1022                .port(free)
1023                .on_request(move |r| l2.lock().unwrap().push(r.clone())),
1024            |req: &Request, _| {
1025                if req.is("POST", "/half") {
1026                    Reply::drop_connection()
1027                } else {
1028                    Reply::text("ok")
1029                }
1030            },
1031        )
1032        .unwrap();
1033        assert_eq!(m.port(), free);
1034        assert!(exchange(m.addr, b"POST /half HTTP/1.1\r\nContent-Length: 0\r\n\r\n").is_empty());
1035        assert!(exchange(m.addr, b"GET /a HTTP/1.1\r\n\r\n").starts_with("HTTP/1.1 200"));
1036        assert_eq!(m.seen().len(), 2);
1037        let l = log.lock().unwrap();
1038        assert_eq!((l[0].path.as_str(), l[0].status), ("/half", 0));
1039        assert_eq!((l[1].path.as_str(), l[1].status), ("/a", 200));
1040    }
1041
1042    #[test]
1043    fn malformed_framing_is_rejected_before_the_handler() {
1044        let calls = Arc::new(AtomicUsize::new(0));
1045        let c2 = calls.clone();
1046        let m = MockServer::start_opts(
1047            MockOptions::default().linger(Duration::from_millis(50)),
1048            move |_: &Request, _| {
1049                c2.fetch_add(1, Ordering::SeqCst);
1050                Reply::text("ok")
1051            },
1052        )
1053        .unwrap();
1054        for bad in [
1055            "POST /x HTTP/1.1\r\nContent-Length: abc\r\n\r\n",
1056            "POST /x HTTP/1.1\r\nContent-Length: -1\r\n\r\n",
1057            "POST /x HTTP/1.1\r\nContent-Length: 99999999999999999999999\r\n\r\n",
1058            "POST /x HTTP/1.1\r\nContent-Length: 1\r\nContent-Length: 1\r\n\r\nA",
1059        ] {
1060            assert!(
1061                exchange(m.addr, bad.as_bytes()).starts_with("HTTP/1.1 400"),
1062                "{bad}"
1063            );
1064        }
1065        // truncated body: the client half-closes before sending Content-Length bytes
1066        let mut st = TcpStream::connect(m.addr).unwrap();
1067        st.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
1068        st.write_all(b"POST /x HTTP/1.1\r\nContent-Length: 10\r\n\r\nabc")
1069            .unwrap();
1070        st.shutdown(std::net::Shutdown::Write).unwrap();
1071        let mut out = Vec::new();
1072        let _ = st.read_to_end(&mut out);
1073        assert!(String::from_utf8_lossy(&out).starts_with("HTTP/1.1 400"));
1074        assert_eq!(calls.load(Ordering::SeqCst), 0);
1075        assert!(m.seen().is_empty());
1076        assert!(
1077            exchange(m.addr, b"POST /x HTTP/1.1\r\nContent-Length: 2\r\n\r\nab")
1078                .starts_with("HTTP/1.1 200")
1079        );
1080    }
1081
1082    #[test]
1083    fn opts_limits_answer_431_413_501_without_calling_the_handler() {
1084        let calls = Arc::new(AtomicUsize::new(0));
1085        let c2 = calls.clone();
1086        let m = MockServer::start_opts(
1087            MockOptions::default()
1088                .max_header_bytes(256)
1089                .max_body_bytes(10)
1090                .reject_transfer_encoding()
1091                .linger(Duration::from_millis(50)),
1092            move |_: &Request, _| {
1093                c2.fetch_add(1, Ordering::SeqCst);
1094                Reply::text("ok")
1095            },
1096        )
1097        .unwrap();
1098        let big = format!("GET / HTTP/1.1\r\nX: {}\r\n\r\n", "a".repeat(400));
1099        assert!(exchange(m.addr, big.as_bytes()).starts_with("HTTP/1.1 431"));
1100        let r = exchange(
1101            m.addr,
1102            b"POST / HTTP/1.1\r\nContent-Length: 11\r\n\r\nhello world",
1103        );
1104        assert!(r.starts_with("HTTP/1.1 413"), "{r}");
1105        let r = exchange(
1106            m.addr,
1107            b"POST / HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n",
1108        );
1109        assert!(r.starts_with("HTTP/1.1 501"), "{r}");
1110        assert!(
1111            exchange(m.addr, b"POST / HTTP/1.1\r\nContent-Length: 5\r\n\r\nhello")
1112                .starts_with("HTTP/1.1 200")
1113        );
1114        assert_eq!(calls.load(Ordering::SeqCst), 1);
1115    }
1116
1117    #[test]
1118    fn opts_connection_cap_answers_503_and_shutdown_handle_stops() {
1119        let m = MockServer::start_opts(
1120            MockOptions::default().max_connections(1),
1121            |_: &Request, _| {
1122                std::thread::sleep(Duration::from_millis(400));
1123                Reply::text("slow")
1124            },
1125        )
1126        .unwrap();
1127        let addr = m.addr;
1128        let t = std::thread::spawn(move || exchange(addr, b"GET /s HTTP/1.1\r\n\r\n"));
1129        std::thread::sleep(Duration::from_millis(150));
1130        assert!(exchange(addr, b"GET /s HTTP/1.1\r\n\r\n").starts_with("HTTP/1.1 503"));
1131        assert!(t.join().unwrap().starts_with("HTTP/1.1 200"));
1132        let h = m.shutdown_handle();
1133        let h2 = h.clone();
1134        let w = std::thread::spawn(move || h2.wait());
1135        h.shutdown();
1136        w.join().unwrap();
1137        assert!(h.is_shutdown());
1138    }
1139}