Skip to main content

rust_webx_host/
context.rs

1//! HttpContext — runtime implementation of IHttpContext, IHttpRequest, IHttpResponse.
2//!
3//! # Request body
4//!
5//! The body is *not* read eagerly. It stays as the live hyper stream until
6//! something asks for it:
7//!
8//! * [`IHttpRequest::body_bytes`] buffers the whole body, enforcing
9//!   `App.MaxBodySize`.
10//! * [`IHttpRequest::multipart`] streams the body through a multipart parser,
11//!   enforcing `Form.MaxRequestSize`, `Form.MaxFileSize` and
12//!   `Form.MaxFieldSize`, and spooling large file parts to disk.
13//!
14//! Either way the body can only be consumed once; asking for it twice reports a
15//! clear error rather than silently returning an empty body.
16//!
17//! # Response body
18//!
19//! Responses carry either in-memory bytes or a [`FileBody`]. File bodies are
20//! streamed by the host with `Content-Length`, `ETag`, `Last-Modified`,
21//! `Accept-Ranges` and `Content-Disposition`, and satisfy `Range`,
22//! `If-Range`, `If-None-Match` and `If-Modified-Since` requests, so downloads
23//! can be cached and resumed.
24
25use http_body_util::{combinators::UnsyncBoxBody, BodyExt, Full};
26use hyper::body::{Bytes, Frame, Incoming};
27use hyper::Request;
28use rust_webx_core::auth::IClaims;
29use rust_webx_core::config::AppOptions;
30use rust_webx_core::error::{Error, Result};
31use rust_webx_core::form::{FormFileBuilder, MultipartForm};
32use rust_webx_core::http::{
33    content_disposition_value, ByteRange, FileBody, FileSource, IClaimsExt, IHttpContext,
34    IHttpRequest, IHttpResponse, ResponseBody, SeekableReader,
35};
36use futures_util::{StreamExt, TryStreamExt};
37use std::collections::HashMap;
38use std::path::{Path, PathBuf};
39use std::sync::Arc;
40use std::time::{SystemTime, UNIX_EPOCH};
41use tokio::io::{AsyncRead, AsyncReadExt, AsyncSeekExt, SeekFrom};
42use tokio::sync::Mutex;
43
44use crate::problem_response::{build_problem, problem_to_bytes};
45
46/// Response body produced by the HTTP host.
47///
48/// Deliberately the *unsync* box: a streamed body only ever needs to be `Send`,
49/// and requiring `Sync` would rule out readers that are not shared between
50/// threads (most streaming sources).
51pub type RespBody = UnsyncBoxBody<Bytes, std::io::Error>;
52
53/// Limits applied while reading a request body.
54///
55/// Built from `App` and `Form` configuration; see [`BodyLimits::from_options`].
56#[derive(Debug, Clone)]
57pub struct BodyLimits {
58    /// Limit for non-multipart bodies (`App.MaxBodySize`).
59    pub body: usize,
60    /// Limit for the whole multipart body (`Form.MaxRequestSize`).
61    pub multipart_body: u64,
62    /// Limit for one uploaded file (`Form.MaxFileSize`).
63    pub file: u64,
64    /// Limit for one text field (`Form.MaxFieldSize`).
65    pub field: usize,
66    /// Bytes buffered in memory per file before spooling (`Form.MemoryThreshold`).
67    pub memory_threshold: usize,
68    /// Directory for spooled uploads (`Form.TempDir`).
69    pub spool_dir: PathBuf,
70}
71
72impl Default for BodyLimits {
73    fn default() -> Self {
74        Self {
75            body: 10 * 1024 * 1024,
76            multipart_body: 256 * 1024 * 1024,
77            file: 128 * 1024 * 1024,
78            field: 1024 * 1024,
79            memory_threshold: 1024 * 1024,
80            spool_dir: rust_webx_core::form::spool_root(),
81        }
82    }
83}
84
85impl BodyLimits {
86    /// Derive limits from the merged application configuration.
87    ///
88    /// A relative `Form.TempDir` is resolved against the application base
89    /// directory, matching how `wwwroot` and `appsettings.json` are located.
90    pub fn from_options(options: &AppOptions) -> Self {
91        let spool_dir = match options.form.temp_dir.as_deref() {
92            Some(dir) if !dir.trim().is_empty() => {
93                let path = PathBuf::from(dir);
94                if path.is_absolute() {
95                    path
96                } else {
97                    rust_webx_core::paths::app_base().join(path)
98                }
99            }
100            _ => rust_webx_core::form::spool_root(),
101        };
102
103        Self {
104            body: options.app.max_body_size,
105            multipart_body: options.form.max_request_size as u64,
106            file: options.form.max_file_size,
107            field: options.form.max_field_size,
108            memory_threshold: options.form.memory_threshold,
109            spool_dir,
110        }
111    }
112
113    /// Publish the spool directory so `FormFile` values can be validated
114    /// wherever they are bound.
115    pub fn apply_spool_root(&self) {
116        rust_webx_core::form::set_spool_root(self.spool_dir.clone());
117    }
118}
119
120/// Concrete implementation of IHttpContext wrapping a hyper request and response.
121pub struct HttpContext {
122    req: HttpRequest,
123    resp: HttpResponse,
124    /// Authentication claims set by authentication middleware.
125    claims: Option<Box<dyn IClaims>>,
126}
127
128impl HttpContext {
129    /// Create a context around `req`.
130    ///
131    /// No I/O happens here: the body stays a live stream until the endpoint
132    /// asks for it.
133    pub fn new(req: Request<Incoming>, limits: BodyLimits) -> Self {
134        let (parts, body) = req.into_parts();
135
136        let mut headers = HashMap::new();
137        for (name, value) in parts.headers.iter() {
138            if let Ok(value) = value.to_str() {
139                headers.insert(name.to_string(), value.to_string());
140            }
141        }
142
143        let query_params = parts
144            .uri
145            .query()
146            .map(parse_query_string)
147            .unwrap_or_default();
148
149        let mut resp = HttpResponse::new(200);
150
151        // Refuse an oversized upload from its declared length, without reading
152        // it. Chunked bodies have no `Content-Length` and are bounded while
153        // streaming instead.
154        let declared_limit = if is_multipart_content_type(headers.get("content-type")) {
155            limits.multipart_body
156        } else {
157            limits.body as u64
158        };
159        if let Some(declared) = headers
160            .get("content-length")
161            .and_then(|value| value.parse::<u64>().ok())
162        {
163            if declared > declared_limit {
164                let problem = build_problem(
165                    413,
166                    format!("request body exceeds the {declared_limit}-byte limit"),
167                );
168                resp.set_status(413);
169                resp.set_header("content-type", "application/problem+json");
170                resp.body = Some(ResponseBody::Bytes(problem_to_bytes(&problem)));
171            }
172        }
173
174        let req = HttpRequest {
175            method: parts.method.to_string(),
176            path: parts.uri.path().to_string(),
177            headers,
178            query_params,
179            route_params: HashMap::new(),
180            route_pattern: None,
181            body: Mutex::new(RequestBody::Pending(Some(body))),
182            limits,
183        };
184
185        Self { req, resp, claims: None }
186    }
187
188    /// Finish the request and produce the hyper response.
189    pub async fn into_response(self) -> hyper::Response<RespBody> {
190        let HttpContext { req, resp, .. } = self;
191        finalize_response(&req, resp).await
192    }
193}
194
195impl IClaimsExt for HttpContext {
196    fn set_claims(&mut self, claims: Box<dyn IClaims>) {
197        self.claims = Some(claims);
198    }
199
200    fn claims(&self) -> Option<&dyn IClaims> {
201        self.claims.as_deref()
202    }
203}
204
205impl IHttpContext for HttpContext {
206    fn request(&self) -> &dyn IHttpRequest {
207        &self.req
208    }
209
210    fn request_mut(&mut self) -> &mut dyn IHttpRequest {
211        &mut self.req
212    }
213
214    fn response(&self) -> &dyn IHttpResponse {
215        &self.resp
216    }
217
218    fn response_mut(&mut self) -> &mut dyn IHttpResponse {
219        &mut self.resp
220    }
221}
222
223// ---------------------------------------------------------------------------
224// Request
225// ---------------------------------------------------------------------------
226
227/// State of the request body.
228enum RequestBody {
229    /// Still streaming from the connection.
230    Pending(Option<Incoming>),
231    /// Fully buffered, replayable.
232    Buffered(Vec<u8>),
233    /// Parsed as a multipart form.
234    Form(Arc<MultipartForm>),
235}
236
237/// Concrete implementation of IHttpRequest.
238pub struct HttpRequest {
239    method: String,
240    path: String,
241    headers: HashMap<String, String>,
242    query_params: HashMap<String, String>,
243    route_params: HashMap<String, String>,
244    route_pattern: Option<String>,
245    body: Mutex<RequestBody>,
246    limits: BodyLimits,
247}
248
249#[async_trait::async_trait]
250impl IHttpRequest for HttpRequest {
251    fn method(&self) -> &str {
252        &self.method
253    }
254
255    fn path(&self) -> &str {
256        &self.path
257    }
258
259    fn header(&self, name: &str) -> Option<&str> {
260        self.headers.get(name).map(|s| s.as_str())
261    }
262
263    fn query(&self) -> &HashMap<String, String> {
264        &self.query_params
265    }
266
267    fn route_params(&self) -> &HashMap<String, String> {
268        &self.route_params
269    }
270
271    fn route_params_mut(&mut self) -> &mut HashMap<String, String> {
272        &mut self.route_params
273    }
274
275    fn route_pattern(&self) -> Option<&str> {
276        self.route_pattern.as_deref()
277    }
278
279    fn route_pattern_mut(&mut self) -> &mut Option<String> {
280        &mut self.route_pattern
281    }
282
283    async fn body_bytes(&mut self) -> Result<Vec<u8>> {
284        let mut guard = self.body.lock().await;
285        let incoming = match &mut *guard {
286            RequestBody::Buffered(bytes) => return Ok(bytes.clone()),
287            RequestBody::Form(_) => {
288                return Err(Error::Http(
289                    "request body was already read as multipart/form-data".to_string(),
290                ))
291            }
292            RequestBody::Pending(slot) => slot.take(),
293        };
294
295        let Some(incoming) = incoming else {
296            return Err(Error::Http(
297                "request body has already been consumed".to_string(),
298            ));
299        };
300
301        let bytes = read_body(incoming, self.limits.body).await?;
302        *guard = RequestBody::Buffered(bytes.clone());
303        Ok(bytes)
304    }
305
306    async fn body_text(&mut self) -> Result<String> {
307        let bytes = self.body_bytes().await?;
308        String::from_utf8(bytes).map_err(|e| Error::Http(e.to_string()))
309    }
310
311    async fn multipart(&mut self) -> Result<Arc<MultipartForm>> {
312        let mut guard = self.body.lock().await;
313
314        match &*guard {
315            RequestBody::Form(form) => return Ok(Arc::clone(form)),
316            RequestBody::Buffered(_) => {
317                return Err(Error::UnsupportedMediaType(
318                    "request body was already read as a non-multipart body".to_string(),
319                ))
320            }
321            RequestBody::Pending(_) => {}
322        }
323
324        if !self.is_multipart() {
325            return Err(Error::UnsupportedMediaType(
326                "expected Content-Type: multipart/form-data".to_string(),
327            ));
328        }
329
330        let raw_content_type = self.header("content-type").unwrap_or("");
331        let boundary = multipart_boundary(raw_content_type).ok_or_else(|| {
332            Error::Validation(
333                "multipart/form-data request is missing its boundary parameter".to_string(),
334            )
335        })?;
336
337        let incoming = match &mut *guard {
338            RequestBody::Pending(slot) => slot.take(),
339            _ => None,
340        };
341
342        let Some(body) = incoming else {
343            return Err(Error::Http(
344                "request body has already been consumed".to_string(),
345            ));
346        };
347
348        let form = Arc::new(parse_multipart(body, &boundary, &self.limits).await?);
349        *guard = RequestBody::Form(Arc::clone(&form));
350        Ok(form)
351    }
352}
353
354/// Drain a body stream into memory, enforcing `limit`.
355async fn read_body(incoming: Incoming, limit: usize) -> Result<Vec<u8>> {
356    let mut out = Vec::new();
357    let mut total = 0usize;
358    let mut stream = incoming.into_data_stream();
359
360    while let Some(chunk) = stream.next().await {
361        let chunk = chunk.map_err(|err| Error::Http(format!("failed to read request body: {err}")))?;
362        total = total.saturating_add(chunk.len());
363        if total > limit {
364            return Err(Error::PayloadTooLarge(format!(
365                "request body exceeds the {limit}-byte limit"
366            )));
367        }
368        out.extend_from_slice(&chunk);
369    }
370
371    Ok(out)
372}
373
374/// Whether a raw `Content-Type` header value declares a multipart form.
375fn is_multipart_content_type(content_type: Option<&String>) -> bool {
376    content_type
377        .map(|value| {
378            value
379                .split(';')
380                .next()
381                .unwrap_or("")
382                .trim()
383                .eq_ignore_ascii_case("multipart/form-data")
384        })
385        .unwrap_or(false)
386}
387
388/// Extract the `boundary` parameter of a `multipart/form-data` content type.
389fn multipart_boundary(content_type: &str) -> Option<String> {
390    for part in content_type.split(';').skip(1) {
391        let part = part.trim();
392        let Some((key, value)) = part.split_once('=') else {
393            continue;
394        };
395        if key.trim().eq_ignore_ascii_case("boundary") {
396            let value = value.trim().trim_matches('"');
397            if !value.is_empty() {
398                return Some(value.to_string());
399            }
400        }
401    }
402    None
403}
404
405/// Stream a multipart body into a [`MultipartForm`].
406///
407/// Text fields are buffered (bounded by `limits.field`); file parts stream into
408/// a [`FormFileBuilder`], which keeps small files in memory and spools large
409/// ones to disk. Nothing is copied twice, so peak memory stays bounded no
410/// matter how large the upload is.
411async fn parse_multipart(
412    incoming: Incoming,
413    boundary: &str,
414    limits: &BodyLimits,
415) -> Result<MultipartForm> {
416    let stream = incoming.into_data_stream();
417    let mut multipart = multer::Multipart::new(stream, boundary);
418    let mut form = MultipartForm::new();
419    let mut total: u64 = 0;
420
421    while let Some(field) = multipart.next_field().await.map_err(multer_error)? {
422        let field_name = field.name().unwrap_or_default().to_string();
423        if field_name.is_empty() {
424            continue;
425        }
426        let file_name = field.file_name().map(str::to_string);
427        let content_type = field.content_type().map(|m| m.to_string());
428
429        match file_name {
430            Some(file_name) => {
431                let mut builder = FormFileBuilder::new(field_name.clone(), file_name, content_type)
432                    .memory_threshold(limits.memory_threshold)
433                    .max_size(limits.file)
434                    .spool_dir(limits.spool_dir.clone());
435
436                let mut field = field;
437                while let Some(chunk) = field.chunk().await.map_err(multer_error)? {
438                    total = charge(&mut total, chunk.len(), limits.multipart_body)?;
439                    builder.write(&chunk).await?;
440                }
441                form.push_file(builder.finish().await?);
442            }
443            None => {
444                let mut value: Vec<u8> = Vec::new();
445                let mut field = field;
446                while let Some(chunk) = field.chunk().await.map_err(multer_error)? {
447                    total = charge(&mut total, chunk.len(), limits.multipart_body)?;
448                    if value.len() + chunk.len() > limits.field {
449                        return Err(Error::PayloadTooLarge(format!(
450                            "form field `{field_name}` exceeds the {}-byte limit",
451                            limits.field
452                        )));
453                    }
454                    value.extend_from_slice(&chunk);
455                }
456                let text = String::from_utf8(value).map_err(|_| {
457                    Error::Validation(format!("form field `{field_name}` is not valid UTF-8"))
458                })?;
459                form.push_field(field_name, text);
460            }
461        }
462    }
463
464    Ok(form)
465}
466
467/// Add `len` to the running total, failing when the request limit is exceeded.
468fn charge(total: &mut u64, len: usize, limit: u64) -> Result<u64> {
469    *total = total.saturating_add(len as u64);
470    if *total > limit {
471        return Err(Error::PayloadTooLarge(format!(
472            "multipart request exceeds the {limit}-byte limit"
473        )));
474    }
475    Ok(*total)
476}
477
478fn multer_error(err: multer::Error) -> Error {
479    match err {
480        multer::Error::FieldSizeExceeded { limit, .. } => Error::PayloadTooLarge(format!(
481            "multipart field exceeds the {limit}-byte limit"
482        )),
483        multer::Error::StreamSizeExceeded { limit } => Error::PayloadTooLarge(format!(
484            "multipart request exceeds the {limit}-byte limit"
485        )),
486        multer::Error::IncompleteFieldData { .. } | multer::Error::IncompleteStream => {
487            Error::Validation("multipart body ended unexpectedly".to_string())
488        }
489        other => Error::Validation(format!("invalid multipart body: {other}")),
490    }
491}
492
493// ---------------------------------------------------------------------------
494// Response
495// ---------------------------------------------------------------------------
496
497/// Concrete implementation of IHttpResponse.
498pub struct HttpResponse {
499    status: u16,
500    headers: HashMap<String, String>,
501    body: Option<ResponseBody>,
502}
503
504impl HttpResponse {
505    pub fn new(status: u16) -> Self {
506        Self {
507            status,
508            headers: HashMap::new(),
509            body: None,
510        }
511    }
512}
513
514#[async_trait::async_trait]
515impl IHttpResponse for HttpResponse {
516    fn status(&self) -> u16 {
517        self.status
518    }
519
520    fn has_body(&self) -> bool {
521        self.body.is_some()
522    }
523
524    fn set_status(&mut self, code: u16) {
525        self.status = code;
526    }
527
528    fn set_header(&mut self, key: &str, value: &str) {
529        self.headers.insert(key.to_string(), value.to_string());
530    }
531
532    fn remove_header(&mut self, key: &str) {
533        self.headers.remove(key);
534    }
535
536    async fn write_bytes(&mut self, data: Vec<u8>) -> Result<()> {
537        self.body = Some(ResponseBody::Bytes(data));
538        Ok(())
539    }
540
541    async fn write_text(&mut self, text: &str) -> Result<()> {
542        self.body = Some(ResponseBody::Bytes(text.as_bytes().to_vec()));
543        Ok(())
544    }
545
546    async fn write_body(&mut self, body: ResponseBody) -> Result<()> {
547        self.body = Some(body);
548        Ok(())
549    }
550
551    fn body_bytes(&self) -> Vec<u8> {
552        self.body
553            .as_ref()
554            .and_then(ResponseBody::as_bytes)
555            .map(<[u8]>::to_vec)
556            .unwrap_or_default()
557    }
558
559    fn header(&self, key: &str) -> Option<&str> {
560        self.headers.get(key).map(|s| s.as_str())
561    }
562}
563
564// ---------------------------------------------------------------------------
565// Response finalisation
566// ---------------------------------------------------------------------------
567
568async fn finalize_response(req: &HttpRequest, resp: HttpResponse) -> hyper::Response<RespBody> {
569    let HttpResponse {
570        status,
571        mut headers,
572        body,
573    } = resp;
574
575    match body.unwrap_or(ResponseBody::Bytes(Vec::new())) {
576        ResponseBody::Bytes(bytes) => {
577            default_cache_control(&mut headers, false);
578            bytes_response(status, headers, bytes)
579        }
580        ResponseBody::File(file) => {
581            default_cache_control(&mut headers, true);
582            file_response(req, status, headers, file).await
583        }
584    }
585}
586
587/// Apply the framework's default `Cache-Control` when the response has none.
588///
589/// Ordinary API responses must not be stored; a file response with validators is
590/// exactly the case where `no-store` would defeat the `ETag` we just sent, so it
591/// revalidates instead. Anything the application set explicitly wins.
592fn default_cache_control(headers: &mut HashMap<String, String>, is_file: bool) {
593    if headers.contains_key("cache-control") {
594        return;
595    }
596    headers.insert(
597        "cache-control".to_string(),
598        if is_file {
599            "public, max-age=0, must-revalidate".to_string()
600        } else {
601            "no-store".to_string()
602        },
603    );
604}
605
606/// A file body whose source has been resolved to something streamable.
607enum PreparedSource {
608    /// An open file handle; seeks to serve ranges.
609    Path(tokio::fs::File),
610    /// A reader that can seek, so ranges are served by seeking.
611    Seekable(Box<dyn SeekableReader>),
612    /// A reader of unknown length; chunked, no ranges.
613    Stream(Box<dyn AsyncRead + Send + Unpin>),
614}
615
616impl PreparedSource {
617    fn is_seekable(&self) -> bool {
618        matches!(self, PreparedSource::Path(_) | PreparedSource::Seekable(_))
619    }
620}
621
622/// Serve a file response with validators, conditional handling and byte ranges.
623///
624/// Works the same way for a path, a seekable stream and a plain stream; the only
625/// differences are which HTTP features the source can support.
626async fn file_response(
627    req: &HttpRequest,
628    status: u16,
629    mut headers: HashMap<String, String>,
630    file: FileBody,
631) -> hyper::Response<RespBody> {
632    let FileBody {
633        source,
634        content_type,
635        file_name,
636        disposition,
637        enable_range_processing,
638        last_modified,
639        entity_tag,
640    } = file;
641
642    // ── Resolve the source, its length and its validators ──
643    let mut modified = last_modified;
644    let mut etag = entity_tag;
645    let mut source_path: Option<PathBuf> = None;
646    let known_len;
647
648    let prepared = match source {
649        FileSource::Path(path) => {
650            // Open once and take length/mtime from the same handle: one syscall
651            // pair instead of `metadata()` followed by `open()`.
652            let handle = match tokio::fs::File::open(&path).await {
653                Ok(handle) => handle,
654                Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
655                    return problem_bytes(404, headers, "Not Found")
656                }
657                Err(err) => {
658                    return problem_bytes(
659                        500,
660                        headers,
661                        &format!("cannot open `{}`: {err}", path.display()),
662                    )
663                }
664            };
665            let metadata = match handle.metadata().await {
666                Ok(metadata) => metadata,
667                Err(err) => {
668                    return problem_bytes(
669                        500,
670                        headers,
671                        &format!("cannot stat `{}`: {err}", path.display()),
672                    )
673                }
674            };
675            if !metadata.is_file() {
676                return problem_bytes(404, headers, "Not Found");
677            }
678
679            let len = metadata.len();
680            if modified.is_none() {
681                modified = metadata.modified().ok();
682            }
683            if etag.is_none() {
684                etag = Some(etag_for(len, modified));
685            }
686            known_len = Some(len);
687            source_path = Some(path);
688            PreparedSource::Path(handle)
689        }
690        FileSource::SeekableStream { reader, len } => {
691            known_len = Some(len);
692            PreparedSource::Seekable(reader)
693        }
694        FileSource::Stream(reader) => {
695            known_len = None;
696            PreparedSource::Stream(reader)
697        }
698    };
699
700    // RFC 7232 §2.2.2: an origin server must not send a `Last-Modified` later
701    // than the response generation time, and must not treat such a date as a
702    // precondition. Restored archives and clock skew produce future mtimes, and
703    // they would otherwise make `If-Modified-Since` and date-based `If-Range`
704    // answer nonsense. The ETag above was already derived from the real mtime,
705    // so content changes are still detected.
706    let modified = modified.filter(|value| *value <= SystemTime::now());
707
708    // ── Representation metadata ──
709    if !headers.contains_key("content-type") {
710        let resolved = content_type
711            .or_else(|| source_path.as_deref().and_then(guess_content_type))
712            .unwrap_or_else(|| "application/octet-stream".to_string());
713        headers.insert("content-type".to_string(), resolved);
714    }
715    if !headers.contains_key("content-disposition") {
716        if let Some(name) = &file_name {
717            headers.insert(
718                "content-disposition".to_string(),
719                content_disposition_value(disposition, name),
720            );
721        }
722    }
723    if let Some(tag) = &etag {
724        headers.insert("etag".to_string(), tag.clone());
725    }
726    if let Some(modified) = modified {
727        headers.insert(
728            "last-modified".to_string(),
729            httpdate::fmt_http_date(modified),
730        );
731    }
732
733    // `Accept-Ranges: none` tells download managers not to bother trying, which
734    // is more useful than the header's absence (absence means "unknown").
735    let range_capable = enable_range_processing && prepared.is_seekable() && known_len.is_some();
736    headers.insert(
737        "accept-ranges".to_string(),
738        if range_capable { "bytes" } else { "none" }.to_string(),
739    );
740
741    // ── Conditional GET: the representation is unchanged ──
742    if status == 200 && is_not_modified(req, etag.as_deref(), modified) {
743        headers.remove("content-length");
744        return bytes_response(304, headers, Vec::new());
745    }
746
747    // ── Byte ranges ──
748    let mut ranges: Vec<ByteRange> = Vec::new();
749    if status == 200 && range_capable {
750        let len = known_len.expect("range-capable sources know their length");
751        if let Some(raw) = req.header("range") {
752            // `If-Range` gates the whole range request: if the client's copy is
753            // stale, the correct answer is the *full* representation, not a
754            // range from a different version.
755            if if_range_matches(req, etag.as_deref(), modified) {
756                match parse_ranges(raw, len) {
757                    RangeOutcome::Ignored => {}
758                    RangeOutcome::Unsatisfiable => {
759                        headers.insert("content-range".to_string(), format!("bytes */{len}"));
760                        return problem_bytes(416, headers, "Range Not Satisfiable");
761                    }
762                    RangeOutcome::Satisfiable(parsed) => ranges = parsed,
763                }
764            }
765        }
766    }
767
768    // ── Body ──
769    //
770    // A multi-range request needs to re-read the same source from several
771    // offsets. Only a file handle can do that; a range-capable *stream* cannot
772    // be restarted, so those requests are answered with the full representation
773    // (RFC 7233 §3.1 lets a server ignore `Range`).
774    if ranges.len() > 1 && !matches!(prepared, PreparedSource::Path(_)) {
775        ranges.clear();
776    }
777    if ranges.len() > 1 {
778        return multipart_byteranges_response(prepared, headers, ranges, known_len);
779    }
780
781    let (status, start, count) = match ranges.first() {
782        Some(range) => {
783            let len = known_len.expect("range-capable sources know their length");
784            headers.insert(
785                "content-range".to_string(),
786                format!("bytes {}-{}/{len}", range.start, range.end),
787            );
788            (206, range.start, range.len())
789        }
790        None => (status, 0, known_len.unwrap_or(0)),
791    };
792
793    match prepared {
794        PreparedSource::Path(mut handle) => {
795            headers.insert("content-length".to_string(), count.to_string());
796            if count == 0 {
797                return bytes_response(status, headers, Vec::new());
798            }
799            if start > 0 {
800                if let Err(err) = handle.seek(SeekFrom::Start(start)).await {
801                    return problem_bytes(500, headers, &format!("cannot seek: {err}"));
802                }
803            }
804            build_response(status, headers, reader_body(handle.take(count)))
805        }
806        PreparedSource::Seekable(mut reader) => {
807            headers.insert("content-length".to_string(), count.to_string());
808            if count == 0 {
809                return bytes_response(status, headers, Vec::new());
810            }
811            if start > 0 {
812                if let Err(err) = reader.seek(SeekFrom::Start(start)).await {
813                    return problem_bytes(500, headers, &format!("cannot seek: {err}"));
814                }
815            }
816            build_response(status, headers, reader_body(reader.take(count)))
817        }
818        PreparedSource::Stream(reader) => {
819            // Unknown length: chunked transfer encoding, no `Content-Length`.
820            // Nothing is buffered.
821            build_response(status, headers, reader_body(reader))
822        }
823    }
824}
825
826/// The media type of the parts inside a `multipart/byteranges` response.
827///
828/// The response-level `Content-Type` is about to be replaced, so the original
829/// representation type has to be captured first.
830fn representation_content_type(headers: &HashMap<String, String>) -> String {
831    headers
832        .get("content-type")
833        .filter(|value| !value.starts_with("multipart/byteranges"))
834        .cloned()
835        .unwrap_or_else(|| "application/octet-stream".to_string())
836}
837
838/// Serve several disjoint ranges as one `multipart/byteranges` response.
839///
840/// Ranges are coalesced before this point, so the parts are disjoint and the
841/// total payload never exceeds the file size — no traffic amplification. The
842/// response length is computed exactly rather than falling back to chunked,
843/// because every part's size is known up front.
844fn multipart_byteranges_response(
845    prepared: PreparedSource,
846    mut headers: HashMap<String, String>,
847    ranges: Vec<ByteRange>,
848    known_len: Option<u64>,
849) -> hyper::Response<RespBody> {
850    let PreparedSource::Path(handle) = prepared else {
851        unreachable!("multi-range is only routed here for path sources");
852    };
853
854    let total_len = known_len.unwrap_or(0);
855    let media_type = representation_content_type(&headers);
856    let boundary = format!("webx-{}", uuid::Uuid::new_v4().simple());
857
858    // Pre-render each part's header block so the total length is exact by
859    // construction and the stream only has to emit bytes.
860    let parts: Vec<(Bytes, ByteRange)> = ranges
861        .iter()
862        .map(|range| {
863            let header = format!(
864                "--{boundary}\r\nContent-Type: {media_type}\r\nContent-Range: bytes {}-{}/{total_len}\r\n\r\n",
865                range.start, range.end
866            );
867            (Bytes::from(header), *range)
868        })
869        .collect();
870    let closing = Bytes::from(format!("--{boundary}--\r\n"));
871
872    let content_length: u64 = parts
873        .iter()
874        .map(|(header, range)| header.len() as u64 + range.len() + 2)
875        .sum::<u64>()
876        + closing.len() as u64;
877
878    headers.insert(
879        "content-type".to_string(),
880        format!("multipart/byteranges; boundary={boundary}"),
881    );
882    headers.insert("content-length".to_string(), content_length.to_string());
883    // `Content-Range` is per part here, so the message must not carry one.
884    headers.remove("content-range");
885
886    let stream = multipart_stream(handle, parts, closing);
887    build_response(
888        206,
889        headers,
890        BodyExt::boxed_unsync(http_body_util::StreamBody::new(stream)),
891    )
892}
893
894/// Chunk size for each `multipart/byteranges` part read.
895const MULTIPART_CHUNK: usize = STREAM_BUF_SIZE;
896
897/// State carried between polls of the `multipart/byteranges` stream.
898struct MultipartState {
899    handle: tokio::fs::File,
900    parts: std::vec::IntoIter<(Bytes, ByteRange)>,
901    closing: Bytes,
902    /// Header bytes still to emit for the current part.
903    pending_header: Bytes,
904    phase: MultipartPhase,
905    buffer: bytes::BytesMut,
906}
907
908enum MultipartPhase {
909    /// Move to the next part's header, or close the body.
910    NextPart,
911    /// Stream the current part's payload.
912    Payload(u64),
913    /// Emit the `\r\n` that terminates a part.
914    Terminator,
915    /// Emit the closing delimiter.
916    Closing,
917    Done,
918}
919
920/// Build the `multipart/byteranges` body as a stream of frames.
921///
922/// Written as a `Stream` rather than a hand-rolled `AsyncRead`: the byte layout
923/// is a sequence of independent chunks, so `unfold` expresses it directly and
924/// there is no `poll_*` plumbing to get wrong.
925fn multipart_stream(
926    handle: tokio::fs::File,
927    parts: Vec<(Bytes, ByteRange)>,
928    closing: Bytes,
929) -> impl futures_util::Stream<Item = std::io::Result<Frame<Bytes>>> + Send {
930    let state = MultipartState {
931        handle,
932        parts: parts.into_iter(),
933        closing,
934        pending_header: Bytes::new(),
935        phase: MultipartPhase::NextPart,
936        buffer: bytes::BytesMut::new(),
937    };
938
939    futures_util::stream::unfold(state, |mut state| async move {
940        loop {
941            match std::mem::replace(&mut state.phase, MultipartPhase::Done) {
942                MultipartPhase::NextPart => match state.parts.next() {
943                    Some((header, range)) => {
944                        if let Err(err) = state.handle.seek(SeekFrom::Start(range.start)).await {
945                            return Some((Err(err), state));
946                        }
947                        state.pending_header = header;
948                        state.phase = MultipartPhase::Payload(range.len());
949                    }
950                    None => state.phase = MultipartPhase::Closing,
951                },
952                MultipartPhase::Payload(remaining) => {
953                    if remaining == 0 {
954                        state.phase = MultipartPhase::Terminator;
955                        continue;
956                    }
957
958                    // Read at most one chunk, never past the part boundary.
959                    let limit = MULTIPART_CHUNK.min(remaining as usize);
960                    state.buffer.clear();
961                    state.buffer.reserve(limit);
962                    let read = match state.handle.read_buf(&mut state.buffer).await {
963                        Ok(read) => read,
964                        Err(err) => return Some((Err(err), state)),
965                    };
966                    if read == 0 {
967                        return Some((
968                            Err(std::io::Error::new(
969                                std::io::ErrorKind::UnexpectedEof,
970                                "file changed size while serving byte ranges",
971                            )),
972                            state,
973                        ));
974                    }
975                    let keep = read.min(limit);
976                    state.buffer.truncate(keep);
977                    let chunk = state.buffer.split().freeze();
978
979                    // The part header is emitted immediately before its first
980                    // payload byte, so a part is one contiguous run of frames.
981                    let header = std::mem::take(&mut state.pending_header);
982                    let frame = if header.is_empty() {
983                        chunk
984                    } else {
985                        let mut combined = bytes::BytesMut::with_capacity(header.len() + chunk.len());
986                        combined.extend_from_slice(&header);
987                        combined.extend_from_slice(&chunk);
988                        combined.freeze()
989                    };
990
991                    state.phase = MultipartPhase::Payload(remaining - keep as u64);
992                    return Some((Ok(Frame::data(frame)), state));
993                }
994                MultipartPhase::Terminator => {
995                    state.phase = MultipartPhase::NextPart;
996                    return Some((Ok(Frame::data(Bytes::from_static(b"\r\n"))), state));
997                }
998                MultipartPhase::Closing => {
999                    state.phase = MultipartPhase::Done;
1000                    let closing = std::mem::take(&mut state.closing);
1001                    return Some((Ok(Frame::data(closing)), state));
1002                }
1003                MultipartPhase::Done => return None,
1004            }
1005        }
1006    })
1007}
1008
1009/// Read buffer used for every streamed response body.
1010///
1011/// Shared with the upload path so both sides of a transfer use the same size.
1012const STREAM_BUF_SIZE: usize = rust_webx_core::http::STREAM_BUF_SIZE;
1013
1014/// Turn any async reader into a streaming response body.
1015fn reader_body<R>(reader: R) -> RespBody
1016where
1017    R: tokio::io::AsyncRead + Send + 'static,
1018{
1019    let stream = tokio_util::io::ReaderStream::with_capacity(reader, STREAM_BUF_SIZE);
1020    let body = http_body_util::StreamBody::new(stream.map_ok(Frame::data));
1021    BodyExt::boxed_unsync(body)
1022}
1023
1024fn bytes_response(
1025    status: u16,
1026    headers: HashMap<String, String>,
1027    bytes: Vec<u8>,
1028) -> hyper::Response<RespBody> {
1029    build_response(status, headers, full_body(bytes))
1030}
1031
1032fn full_body(bytes: Vec<u8>) -> RespBody {
1033    Full::new(Bytes::from(bytes))
1034        .map_err(|never: std::convert::Infallible| match never {})
1035        .boxed_unsync()
1036}
1037
1038/// Build an RFC 7807 `application/problem+json` response for a file error.
1039fn problem_bytes(
1040    status: u16,
1041    mut headers: HashMap<String, String>,
1042    detail: &str,
1043) -> hyper::Response<RespBody> {
1044    headers.insert(
1045        "content-type".to_string(),
1046        "application/problem+json".to_string(),
1047    );
1048    bytes_response(status, headers, problem_to_bytes(&build_problem(status, detail)))
1049}
1050
1051fn build_response(
1052    status: u16,
1053    headers: HashMap<String, String>,
1054    body: RespBody,
1055) -> hyper::Response<RespBody> {
1056    let mut builder = hyper::Response::builder().status(status);
1057    for (key, value) in &headers {
1058        builder = builder.header(key.as_str(), value.as_str());
1059    }
1060    builder.body(body).unwrap_or_else(|_| {
1061        hyper::Response::builder()
1062            .status(hyper::StatusCode::INTERNAL_SERVER_ERROR)
1063            .body(full_body(b"Internal Server Error".to_vec()))
1064            .expect("static error response is valid")
1065    })
1066}
1067
1068/// Result of parsing a `Range` header.
1069#[derive(Debug, PartialEq, Eq)]
1070enum RangeOutcome {
1071    /// Syntactically invalid: the whole header must be ignored (RFC 7233 §2.1).
1072    Ignored,
1073    /// Valid but nothing overlaps the representation: answer `416`.
1074    Unsatisfiable,
1075    /// One or more disjoint, coalesced ranges, in ascending order.
1076    Satisfiable(Vec<ByteRange>),
1077}
1078
1079/// Maximum number of parts to answer in one `multipart/byteranges` response.
1080///
1081/// Ranges are coalesced first, so the parts are disjoint and the total payload
1082/// can never exceed the file size; this cap only bounds the *framing* overhead
1083/// and the per-part seeks. A client asking for more gets the full
1084/// representation, which RFC 7233 §3.1 permits.
1085const MAX_RANGE_PARTS: usize = 8;
1086
1087/// Parse a byte-range-set (RFC 7233 §2.1) and coalesce it.
1088///
1089/// Coalescing matters for more than tidiness: without it a client could ask for
1090/// the same range eight times and make the server send the file eight times
1091/// over. Merging overlapping and adjacent ranges guarantees the parts are
1092/// disjoint, so the response can never be larger than the file.
1093fn parse_ranges(value: &str, len: u64) -> RangeOutcome {
1094    let value = value.trim();
1095    let Some(spec) = strip_prefix_ignore_ascii_case(value, "bytes=") else {
1096        return RangeOutcome::Ignored;
1097    };
1098
1099    let mut ranges: Vec<ByteRange> = Vec::new();
1100    let mut saw_valid_spec = false;
1101
1102    for part in spec.split(',') {
1103        let part = part.trim();
1104        if part.is_empty() {
1105            continue;
1106        }
1107        match parse_single_range(part, len) {
1108            // One malformed member invalidates the whole header.
1109            SingleRange::Malformed => return RangeOutcome::Ignored,
1110            SingleRange::Unsatisfiable => saw_valid_spec = true,
1111            SingleRange::Satisfiable(range) => {
1112                saw_valid_spec = true;
1113                ranges.push(range);
1114            }
1115        }
1116    }
1117
1118    if ranges.is_empty() {
1119        return if saw_valid_spec {
1120            RangeOutcome::Unsatisfiable
1121        } else {
1122            RangeOutcome::Ignored
1123        };
1124    }
1125
1126    ranges.sort_by_key(|range| range.start);
1127    let mut merged: Vec<ByteRange> = Vec::with_capacity(ranges.len());
1128    for range in ranges {
1129        match merged.last_mut() {
1130            // `+ 1` merges adjacent ranges too (`0-9,10-19` is one read).
1131            Some(last) if range.start <= last.end.saturating_add(1) => {
1132                last.end = last.end.max(range.end);
1133            }
1134            _ => merged.push(range),
1135        }
1136    }
1137
1138    if merged.len() > MAX_RANGE_PARTS {
1139        return RangeOutcome::Ignored;
1140    }
1141
1142    RangeOutcome::Satisfiable(merged)
1143}
1144
1145enum SingleRange {
1146    /// Not a valid `byte-range-spec`.
1147    Malformed,
1148    /// Valid syntax, but outside the representation.
1149    Unsatisfiable,
1150    Satisfiable(ByteRange),
1151}
1152
1153/// Parse one `first-byte-pos "-" [last-byte-pos]` or `"-" suffix-length`.
1154fn parse_single_range(spec: &str, len: u64) -> SingleRange {
1155    let Some((start_text, end_text)) = spec.split_once('-') else {
1156        return SingleRange::Malformed;
1157    };
1158    let (start_text, end_text) = (start_text.trim(), end_text.trim());
1159
1160    if start_text.is_empty() {
1161        // Suffix range: the last N bytes.
1162        let Ok(suffix) = end_text.parse::<u64>() else {
1163            return SingleRange::Malformed;
1164        };
1165        if suffix == 0 || len == 0 {
1166            return SingleRange::Unsatisfiable;
1167        }
1168        return SingleRange::Satisfiable(ByteRange {
1169            start: len.saturating_sub(suffix),
1170            end: len - 1,
1171        });
1172    }
1173
1174    let Ok(start) = start_text.parse::<u64>() else {
1175        return SingleRange::Malformed;
1176    };
1177    if start >= len {
1178        return SingleRange::Unsatisfiable;
1179    }
1180
1181    let end = if end_text.is_empty() {
1182        len - 1
1183    } else {
1184        match end_text.parse::<u64>() {
1185            Ok(end) => end.min(len - 1),
1186            Err(_) => return SingleRange::Malformed,
1187        }
1188    };
1189
1190    if end < start {
1191        return SingleRange::Unsatisfiable;
1192    }
1193
1194    SingleRange::Satisfiable(ByteRange { start, end })
1195}
1196
1197fn strip_prefix_ignore_ascii_case<'a>(value: &'a str, prefix: &str) -> Option<&'a str> {
1198    if value.len() >= prefix.len() && value[..prefix.len()].eq_ignore_ascii_case(prefix) {
1199        Some(&value[prefix.len()..])
1200    } else {
1201        None
1202    }
1203}
1204
1205/// A weak validator derived from size and modification time.
1206///
1207/// This is the same scheme nginx and most static servers use: free to compute,
1208/// and it changes whenever the file does. Full `SystemTime` precision is used so
1209/// an in-place edit that keeps the size is still detected within the same
1210/// second — `Last-Modified` cannot express that, but an `ETag` can.
1211///
1212/// It is emitted as a **strong** tag because download resumption needs one:
1213/// RFC 7233 §3.2 requires a strong validator for `If-Range`, so a weak tag would
1214/// silently disable resumable downloads. Callers that need a byte-exact
1215/// validator should set one explicitly with `FileBody::entity_tag`.
1216fn etag_for(len: u64, modified: Option<SystemTime>) -> String {
1217    match modified {
1218        Some(modified) => format!("\"{:x}-{:x}\"", len, epoch_nanos(modified)),
1219        None => format!("\"{len:x}\""),
1220    }
1221}
1222
1223fn epoch_secs(time: SystemTime) -> u64 {
1224    time.duration_since(UNIX_EPOCH)
1225        .map(|d| d.as_secs())
1226        .unwrap_or(0)
1227}
1228
1229fn epoch_nanos(time: SystemTime) -> u128 {
1230    time.duration_since(UNIX_EPOCH)
1231        .map(|d| d.as_nanos())
1232        .unwrap_or(0)
1233}
1234
1235/// Whether an entity-tag is weak (`W/"..."`).
1236fn is_weak_etag(tag: &str) -> bool {
1237    tag.trim_start().starts_with("W/")
1238}
1239
1240/// Strip the weakness marker so two tags can be compared by value.
1241fn etag_value(tag: &str) -> &str {
1242    tag.trim().trim_start_matches("W/")
1243}
1244
1245/// Whether the client's cached representation is still current.
1246///
1247/// Uses **weak** comparison, per RFC 7232 §3.2: `If-None-Match` only asks
1248/// whether the content is semantically the same, so a weak tag is enough. A
1249/// response without validators can never answer `304`.
1250fn is_not_modified(req: &HttpRequest, etag: Option<&str>, modified: Option<SystemTime>) -> bool {
1251    if let Some(if_none_match) = req.header("if-none-match") {
1252        let Some(etag) = etag else {
1253            return false;
1254        };
1255        let expected = etag_value(etag);
1256        return if_none_match.split(',').any(|candidate| {
1257            let candidate = candidate.trim();
1258            candidate == "*" || etag_value(candidate) == expected
1259        });
1260    }
1261
1262    if let Some(if_modified_since) = req.header("if-modified-since") {
1263        if let Some(modified) = modified {
1264            if let Ok(since) = httpdate::parse_http_date(if_modified_since) {
1265                return epoch_secs(modified) <= epoch_secs(since);
1266            }
1267        }
1268    }
1269
1270    false
1271}
1272
1273/// Whether a `Range` header should be honoured, per RFC 7233 §3.2.
1274///
1275/// `If-Range` is the precondition that makes resumption safe, so it is strict:
1276///
1277/// * an entity-tag must match by **strong** comparison — both tags must be
1278///   strong and byte-equal. A weak tag (ours or the client's) can never match,
1279///   because a weak validator does not promise byte-for-byte identity;
1280/// * a date must be at least as new as `Last-Modified`.
1281///
1282/// Returning `false` means "ignore `Range` and send the full representation".
1283fn if_range_matches(req: &HttpRequest, etag: Option<&str>, modified: Option<SystemTime>) -> bool {
1284    let Some(value) = req.header("if-range") else {
1285        return true;
1286    };
1287    let value = value.trim();
1288
1289    if value.starts_with('"') || value.starts_with("W/") {
1290        let Some(etag) = etag else {
1291            return false;
1292        };
1293        if is_weak_etag(value) || is_weak_etag(etag) {
1294            return false;
1295        }
1296        return value == etag.trim();
1297    }
1298
1299    match (httpdate::parse_http_date(value), modified) {
1300        (Ok(instant), Some(modified)) => epoch_secs(instant) >= epoch_secs(modified),
1301        _ => false,
1302    }
1303}
1304
1305fn guess_content_type(path: &Path) -> Option<String> {
1306    mime_guess::from_path(path)
1307        .first()
1308        .map(|mime| mime.essence_str().to_string())
1309}
1310
1311// ---------------------------------------------------------------------------
1312// Query string helpers
1313// ---------------------------------------------------------------------------
1314
1315/// Parse a query string like "key1=val1&key2=val2" into a HashMap.
1316fn parse_query_string(query: &str) -> HashMap<String, String> {
1317    let mut params = HashMap::new();
1318    for pair in query.split('&') {
1319        if pair.is_empty() {
1320            continue;
1321        }
1322        let mut parts = pair.splitn(2, '=');
1323        if let (Some(key), Some(value)) = (parts.next(), parts.next()) {
1324            params.insert(percent_decode(key), percent_decode(value));
1325        }
1326    }
1327    params
1328}
1329
1330fn percent_decode(s: &str) -> String {
1331    let mut result = String::with_capacity(s.len());
1332    let mut chars = s.chars();
1333    while let Some(c) = chars.next() {
1334        if c == '%' {
1335            let hex: String = chars.by_ref().take(2).collect();
1336            if let Ok(byte) = u8::from_str_radix(&hex, 16) {
1337                result.push(byte as char);
1338            }
1339        } else if c == '+' {
1340            result.push(' ');
1341        } else {
1342            result.push(c);
1343        }
1344    }
1345    result
1346}
1347
1348#[cfg(test)]
1349mod tests {
1350    use super::*;
1351
1352    #[test]
1353    fn parses_the_boundary_parameter() {
1354        assert_eq!(
1355            multipart_boundary("multipart/form-data; boundary=----abc123").as_deref(),
1356            Some("----abc123")
1357        );
1358        assert_eq!(
1359            multipart_boundary("multipart/form-data; charset=utf-8; boundary=\"quoted\"").as_deref(),
1360            Some("quoted")
1361        );
1362        assert_eq!(multipart_boundary("multipart/form-data"), None);
1363    }
1364
1365    fn ranges(start: u64, end: u64) -> RangeOutcome {
1366        RangeOutcome::Satisfiable(vec![ByteRange { start, end }])
1367    }
1368
1369    #[test]
1370    fn parses_byte_ranges() {
1371        assert_eq!(parse_ranges("bytes=0-99", 1000), ranges(0, 99));
1372        assert_eq!(parse_ranges("bytes=500-", 1000), ranges(500, 999));
1373        assert_eq!(parse_ranges("bytes=-200", 1000), ranges(800, 999));
1374        // Clamped to the end of the representation.
1375        assert_eq!(parse_ranges("bytes=0-99999", 1000), ranges(0, 999));
1376        // Case-insensitive unit, and whitespace around members is tolerated.
1377        assert_eq!(parse_ranges("BYTES=0-9", 1000), ranges(0, 9));
1378        assert_eq!(parse_ranges("bytes= 0-9 ", 1000), ranges(0, 9));
1379    }
1380
1381    #[test]
1382    fn rejects_unsatisfiable_and_malformed_ranges() {
1383        assert_eq!(parse_ranges("bytes=1000-", 1000), RangeOutcome::Unsatisfiable);
1384        assert_eq!(parse_ranges("bytes=-0", 1000), RangeOutcome::Unsatisfiable);
1385        assert_eq!(
1386            parse_ranges("bytes=500-100", 1000),
1387            RangeOutcome::Unsatisfiable
1388        );
1389        assert_eq!(parse_ranges("items=0-10", 1000), RangeOutcome::Ignored);
1390        assert_eq!(parse_ranges("bytes=", 1000), RangeOutcome::Ignored);
1391        // RFC 7233 §2.1: one malformed member invalidates the whole header.
1392        assert_eq!(parse_ranges("bytes=abc-def", 1000), RangeOutcome::Ignored);
1393        assert_eq!(parse_ranges("bytes=0-9,abc-def", 1000), RangeOutcome::Ignored);
1394    }
1395
1396    #[test]
1397    fn suffix_range_on_empty_file_is_unsatisfiable() {
1398        assert_eq!(parse_ranges("bytes=-10", 0), RangeOutcome::Unsatisfiable);
1399    }
1400
1401    #[test]
1402    fn multi_range_is_coalesced_into_disjoint_parts() {
1403        // Disjoint ranges stay separate, in ascending order.
1404        assert_eq!(
1405            parse_ranges("bytes=20-30,0-10", 1000),
1406            RangeOutcome::Satisfiable(vec![
1407                ByteRange { start: 0, end: 10 },
1408                ByteRange { start: 20, end: 30 },
1409            ])
1410        );
1411
1412        // Overlapping ranges merge, so a client cannot ask for the same bytes
1413        // twice and make the server send them twice.
1414        assert_eq!(
1415            parse_ranges("bytes=0-99,50-149", 1000),
1416            ranges(0, 149)
1417        );
1418
1419        // Adjacent ranges merge too — one read instead of two.
1420        assert_eq!(parse_ranges("bytes=0-9,10-19", 1000), ranges(0, 19));
1421
1422        // Duplicates collapse completely: no traffic amplification.
1423        assert_eq!(
1424            parse_ranges("bytes=0-,0-,0-,0-,0-,0-,0-,0-", 1000),
1425            ranges(0, 999)
1426        );
1427
1428        // An unsatisfiable member next to a satisfiable one is dropped, not fatal.
1429        assert_eq!(parse_ranges("bytes=0-9,5000-6000", 1000), ranges(0, 9));
1430    }
1431
1432    #[test]
1433    fn too_many_parts_falls_back_to_the_full_representation() {
1434        let mut spec = String::from("bytes=");
1435        for index in 0..(MAX_RANGE_PARTS + 1) {
1436            if index > 0 {
1437                spec.push(',');
1438            }
1439            spec.push_str(&format!("{}-{}", index * 4, index * 4 + 1));
1440        }
1441        assert_eq!(parse_ranges(&spec, 1000), RangeOutcome::Ignored);
1442    }
1443
1444    #[test]
1445    fn etag_changes_with_size_and_mtime() {
1446        let base = UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000);
1447        assert_eq!(etag_for(10, Some(base)), etag_for(10, Some(base)));
1448        assert_ne!(etag_for(10, Some(base)), etag_for(11, Some(base)));
1449        assert_ne!(
1450            etag_for(10, Some(base)),
1451            etag_for(10, Some(base + std::time::Duration::from_secs(1)))
1452        );
1453        // Sub-second precision matters: an in-place edit that keeps the size is
1454        // invisible to `Last-Modified`, but must change the ETag. (1 µs rather
1455        // than 1 ns, because Windows filesystem timestamps are 100 ns ticks.)
1456        assert_ne!(
1457            etag_for(10, Some(base)),
1458            etag_for(10, Some(base + std::time::Duration::from_micros(1)))
1459        );
1460    }
1461
1462    #[test]
1463    fn etag_strength_rules() {
1464        assert!(!is_weak_etag("\"abc\""));
1465        assert!(is_weak_etag("W/\"abc\""));
1466        assert_eq!(etag_value("W/\"abc\""), "\"abc\"");
1467        assert_eq!(etag_value("\"abc\""), "\"abc\"");
1468    }
1469
1470    #[test]
1471    fn parses_query_strings() {
1472        let params = parse_query_string("a=1&b=hello+world&c=%2Ftmp");
1473        assert_eq!(params.get("a").map(String::as_str), Some("1"));
1474        assert_eq!(params.get("b").map(String::as_str), Some("hello world"));
1475        assert_eq!(params.get("c").map(String::as_str), Some("/tmp"));
1476    }
1477}