Skip to main content

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