httpsd 0.1.1

A pure-Rust HTTP/HTTPS server — usable as a sans-I/O library with pluggable runtimes (thread pool, tokio, mio) or as a CLI that serves a directory or a TOML config.
Documentation
//! Response body compression, negotiated from the request's `Accept-Encoding`.
//!
//! Built on [`compcol`]. Buffered response bodies are compressed in one shot
//! with [`compcol::vec`], which keeps the integration simple and lets us skip
//! compression when it would not actually shrink the body.

use compcol::deflate::Deflate;
use compcol::gzip::Gzip;
use compcol::zlib::Zlib;

use crate::proto::{Request, Response};

/// Knobs controlling response compression.
#[derive(Debug, Clone, Copy)]
pub struct Options {
    /// Master switch.
    pub enabled: bool,
    /// Bodies smaller than this are never compressed (the framing overhead and
    /// CPU rarely pay off for tiny payloads).
    pub min_size: usize,
}

impl Default for Options {
    fn default() -> Options {
        Options {
            enabled: true,
            min_size: 256,
        }
    }
}

/// A content-coding this server can produce.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Coding {
    Gzip,
    /// HTTP `deflate` is, by spec, the zlib format (RFC 1950).
    Deflate,
    /// Raw RFC 1951 deflate. Not negotiated by default but kept available for
    /// callers that build their own coding selection.
    #[allow(dead_code)]
    Raw,
}

impl Coding {
    fn token(self) -> &'static str {
        match self {
            Coding::Gzip => "gzip",
            Coding::Deflate => "deflate",
            Coding::Raw => "deflate",
        }
    }
}

/// Compress `resp`'s body in place if the client accepts a coding we support
/// and the body is a worthwhile candidate. Returns the (possibly unchanged)
/// response.
pub fn compress_response(req: &Request, resp: Response, opts: &Options) -> Response {
    if !opts.enabled {
        return resp;
    }
    // Never double-encode or compress a partial/conditional body we didn't
    // produce here.
    if resp.headers().contains("content-encoding") {
        return resp;
    }
    // Never compress partial-content responses: re-encoding a byte range would
    // corrupt the `Content-Range` semantics (and can poison shared caches).
    if resp.status_code().code() == 206 || resp.headers().contains("content-range") {
        return resp;
    }
    // Only buffered byte bodies are compressed; a streamed file body passes
    // through untouched (streaming + compression together is out of scope).
    if resp.body_ref().is_file() {
        return resp;
    }
    if resp.body_ref().len() < opts.min_size {
        return resp;
    }
    // Skip already-compressed media types.
    if let Some(ct) = resp.headers().get("content-type")
        && crate::mime::is_precompressed(ct)
    {
        return resp;
    }

    let accept = req.headers().get("accept-encoding").unwrap_or("");
    let Some(coding) = choose_coding(accept) else {
        return resp;
    };

    let (status, mut headers, body) = resp.into_parts();
    // Safe: the non-file guard above means this is the buffered bytes, not a
    // file read.
    let body = body.into_bytes();
    let compressed = match coding {
        Coding::Gzip => compcol::vec::compress_to_vec::<Gzip>(&body),
        Coding::Deflate => compcol::vec::compress_to_vec::<Zlib>(&body),
        Coding::Raw => compcol::vec::compress_to_vec::<Deflate>(&body),
    };

    match compressed {
        // Only adopt the compressed form if it is actually smaller.
        Ok(out) if out.len() < body.len() => {
            headers.set("Content-Encoding", coding.token());
            append_vary(&mut headers, "Accept-Encoding");
            Response::from_parts(status, headers, out.into())
        }
        _ => {
            append_vary(&mut headers, "Accept-Encoding");
            Response::from_parts(status, headers, body.into())
        }
    }
}

/// Pick the best coding the client will accept, honoring `q=0` opt-outs.
/// Preference order: gzip, then deflate.
fn choose_coding(accept_encoding: &str) -> Option<Coding> {
    let acceptable = |name: &str| accepts(accept_encoding, name);
    if acceptable("gzip") {
        Some(Coding::Gzip)
    } else if acceptable("deflate") {
        Some(Coding::Deflate)
    } else {
        None
    }
}

/// Whether `name` (or `*`) is acceptable per an `Accept-Encoding` header,
/// treating an explicit `q=0` as a refusal.
fn accepts(header: &str, name: &str) -> bool {
    let mut star: Option<bool> = None;
    for part in header.split(',') {
        let part = part.trim();
        if part.is_empty() {
            continue;
        }
        let (token, q) = match part.split_once(';') {
            Some((t, params)) => (t.trim(), parse_q(params)),
            None => (part, 1.0),
        };
        let usable = q > 0.0;
        if token.eq_ignore_ascii_case(name) {
            return usable;
        }
        if token == "*" {
            star = Some(usable);
        }
    }
    star.unwrap_or(false)
}

/// Extract the `q=` value from an `Accept-Encoding` parameter list.
fn parse_q(params: &str) -> f32 {
    for p in params.split(';') {
        let p = p.trim();
        if let Some(v) = p.strip_prefix("q=").or_else(|| p.strip_prefix("Q=")) {
            return v.trim().parse().unwrap_or(1.0);
        }
    }
    1.0
}

/// Add `value` to the `Vary` header without duplicating it.
fn append_vary(headers: &mut crate::proto::Headers, value: &str) {
    if headers.contains_token("vary", value) || headers.contains_token("vary", "*") {
        return;
    }
    match headers.get("vary").map(|s| s.to_owned()) {
        Some(existing) => headers.set("Vary", format!("{existing}, {value}")),
        None => headers.set("Vary", value),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn accept_encoding_parsing() {
        assert!(accepts("gzip, deflate", "gzip"));
        assert!(accepts("gzip;q=0.8, deflate", "deflate"));
        assert!(!accepts("gzip;q=0", "gzip"));
        assert!(accepts("*", "gzip"));
        assert!(!accepts("*;q=0", "gzip"));
        assert!(!accepts("identity", "gzip"));
    }

    #[test]
    fn chooses_gzip_first() {
        assert_eq!(choose_coding("deflate, gzip"), Some(Coding::Gzip));
        assert_eq!(choose_coding("deflate"), Some(Coding::Deflate));
        assert_eq!(choose_coding("br"), None);
    }

    fn gzip_request() -> Request {
        let mut headers = crate::proto::Headers::new();
        headers.set("Accept-Encoding", "gzip");
        Request::new(
            crate::proto::Method::Get,
            "/x".to_owned(),
            crate::proto::Version::Http11,
            headers,
            Vec::new(),
        )
    }

    #[test]
    fn partial_content_is_not_compressed() {
        let req = gzip_request();
        // A highly compressible body well over `min_size`.
        let body = vec![b'a'; 4096];
        let resp = Response::new(crate::proto::StatusCode::PARTIAL_CONTENT)
            .header("Content-Type", "text/plain")
            .header("Content-Range", "bytes 0-4095/8192")
            .body(body.clone());
        let out = compress_response(&req, resp, &Options::default());
        assert!(!out.headers().contains("content-encoding"));
        assert_eq!(out.body_ref().len(), body.len());
    }

    #[test]
    fn content_range_is_not_compressed() {
        let req = gzip_request();
        let body = vec![b'a'; 4096];
        // Even a 200 carrying a Content-Range must be left alone.
        let resp = Response::new(crate::proto::StatusCode::OK)
            .header("Content-Type", "text/plain")
            .header("Content-Range", "bytes 0-4095/8192")
            .body(body.clone());
        let out = compress_response(&req, resp, &Options::default());
        assert!(!out.headers().contains("content-encoding"));
    }

    #[test]
    fn ordinary_body_is_compressed() {
        let req = gzip_request();
        let body = vec![b'a'; 4096];
        let resp = Response::new(crate::proto::StatusCode::OK)
            .header("Content-Type", "text/plain")
            .body(body);
        let out = compress_response(&req, resp, &Options::default());
        assert!(out.headers().contains("content-encoding"));
    }
}