//! `Content-Encoding` we know how to decode, plus the dispatch helpers and //! the `Accept-Encoding` header value we send. use async_compression::tokio::bufread::{BrotliDecoder, DeflateDecoder, GzipDecoder, ZstdDecoder}; use reqwest::header::{self, HeaderMap, ToStrError}; use tokio::io::{AsyncRead, BufReader}; /// Encodings we ask PDSes to consider, in preference order. Sent as the /// `Accept-Encoding` header on `getRepo` requests. pub const ACCEPT_ENCODING: &str = "zstd, br, gzip, deflate"; #[derive(Debug, thiserror::Error)] pub enum RequestEncodingError { #[error("non-ASCII Content-Encoding header: {0}")] NonAsciiName(#[from] ToStrError), #[error("unsupported content-encoding: {0:?}")] Unsupported(String), } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum KnownEncoding { Brotli, Deflate, Gzip, Zstd, } impl std::str::FromStr for KnownEncoding { type Err = RequestEncodingError; fn from_str(s: &str) -> Result { match s.trim().to_ascii_lowercase().as_str() { "br" => Ok(Self::Brotli), "deflate" => Ok(Self::Deflate), "gzip" | "x-gzip" => Ok(Self::Gzip), "zstd" => Ok(Self::Zstd), _ => Err(RequestEncodingError::Unsupported(s.to_string())), } } } impl KnownEncoding { /// Read the `Content-Encoding` header (if present) and parse it. /// Returns `Ok(None)` when the header is absent. /// Errors on non-ASCII header bytes or an unrecognized encoding name. pub fn from_response_headers( headers: &HeaderMap, ) -> Result, RequestEncodingError> { match headers.get(header::CONTENT_ENCODING) { None => Ok(None), Some(v) => { let s = v.to_str()?; Ok(Some(s.parse()?)) } } } pub fn as_str(self) -> &'static str { match self { Self::Brotli => "br", Self::Deflate => "deflate", Self::Gzip => "gzip", Self::Zstd => "zstd", } } /// Wrap `src` in this encoding's decoder. The decoder needs `AsyncBufRead`, /// so a `BufReader` is added internally — callers just pass the raw /// `AsyncRead`. pub fn decode(self, src: R) -> Box where R: AsyncRead + Unpin + Send + 'static, { let buf = BufReader::new(src); match self { Self::Brotli => Box::new(BrotliDecoder::new(buf)), Self::Deflate => Box::new(DeflateDecoder::new(buf)), Self::Gzip => Box::new(GzipDecoder::new(buf)), Self::Zstd => Box::new(ZstdDecoder::new(buf)), } } } #[cfg(test)] mod tests { use super::*; use reqwest::header::HeaderValue; #[test] fn from_response_headers_absent() { let headers = HeaderMap::new(); assert!(matches!( KnownEncoding::from_response_headers(&headers), Ok(None) )); } #[test] fn from_response_headers_recognized() { for (raw, expected) in [ ("gzip", KnownEncoding::Gzip), ("x-gzip", KnownEncoding::Gzip), ("br", KnownEncoding::Brotli), ("zstd", KnownEncoding::Zstd), ("deflate", KnownEncoding::Deflate), ("GZIP", KnownEncoding::Gzip), (" br ", KnownEncoding::Brotli), ] { let mut headers = HeaderMap::new(); headers.insert(header::CONTENT_ENCODING, HeaderValue::from_static(raw)); assert_eq!( KnownEncoding::from_response_headers(&headers).unwrap(), Some(expected), "raw={raw:?}", ); } } #[test] fn from_response_headers_unsupported_name() { for raw in ["identity", "snappy", "gzip, br"] { let mut headers = HeaderMap::new(); headers.insert(header::CONTENT_ENCODING, HeaderValue::from_static(raw)); let err = KnownEncoding::from_response_headers(&headers).unwrap_err(); assert!( matches!(err, RequestEncodingError::Unsupported(_)), "raw={raw:?} got {err:?}", ); } } #[test] fn from_response_headers_non_ascii() { let mut headers = HeaderMap::new(); let v = HeaderValue::from_bytes(&[0xff, 0xfe]) .expect("obs-text bytes are valid in HeaderValue"); headers.insert(header::CONTENT_ENCODING, v); let err = KnownEncoding::from_response_headers(&headers).unwrap_err(); assert!( matches!(err, RequestEncodingError::NonAsciiName(_)), "got {err:?}" ); } }