Skip to main content

pingora_core/protocols/http/compression/
mod.rs

1// Copyright 2026 Cloudflare, Inc.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! HTTP response (de)compression libraries
16//!
17//! Gzip, Brotli, Zstd, and dictionary-compressed Zstd (dcz, [RFC 9842](https://datatracker.ietf.org/doc/html/rfc9842)) are supported.
18
19use super::HttpTask;
20
21use bytes::Bytes;
22use log::{debug, warn};
23use pingora_error::{ErrorType, Result};
24use pingora_http::{RequestHeader, ResponseHeader};
25use std::time::Duration;
26
27use strum::EnumCount;
28use strum_macros::EnumCount as EnumCountMacro;
29
30mod brotli;
31mod gzip;
32mod zstd;
33
34/// Re-export [RFC 9842](https://datatracker.ietf.org/doc/html/rfc9842) constants for external use.
35pub use zstd::{DCZ_HEADER_SIZE, DCZ_MAGIC};
36
37/// The type of error to return when (de)compression fails
38pub const COMPRESSION_ERROR: ErrorType = ErrorType::new("CompressionError");
39
40/// The trait for both compress and decompress because the interface and syntax are the same:
41/// encode some bytes to other bytes
42pub trait Encode {
43    /// Encode the input bytes. The `end` flag signals the end of the entire input. The `end` flag
44    /// helps the encoder to flush out the remaining buffered encoded data because certain compression
45    /// algorithms prefer to collect large enough data to compress all together.
46    fn encode(&mut self, input: &[u8], end: bool) -> Result<Bytes>;
47    /// Return the Encoder's name, the total input bytes, the total output bytes and the total
48    /// duration spent on encoding the data.
49    fn stat(&self) -> (&'static str, usize, usize, Duration);
50}
51
52/// The response compression object. Currently support gzip compression and brotli decompression.
53///
54/// To use it, the caller should create a [`ResponseCompressionCtx`] per HTTP session.
55/// The caller should call the corresponding filters for the request header, response header and
56/// response body. If the algorithms are supported, the output response body will be encoded.
57/// The response header will be adjusted accordingly as well. If the algorithm is not supported
58/// or no encoding is needed, the response is untouched.
59///
60/// If configured and if the request's `accept-encoding` header contains the algorithm supported and the
61/// incoming response doesn't have that encoding, the filter will compress the response.
62/// If configured and supported, and if the incoming response's `content-encoding` isn't one of the
63/// request's `accept-encoding` supported algorithm, the ctx will decompress the response.
64///
65/// # Currently supported algorithms and actions
66/// - Brotli decompression: if the response is br compressed, this ctx can decompress it
67/// - Gzip compression: if the response is uncompressed, this ctx can compress it with gzip
68pub struct ResponseCompressionCtx(CtxInner);
69
70/// Dictionary data for [RFC 9842](https://datatracker.ietf.org/doc/html/rfc9842) shared dictionary compression.
71#[derive(Clone, Debug)]
72pub struct DictionaryData {
73    pub bytes: Bytes,
74    pub hash: [u8; 32],
75}
76
77enum CtxInner {
78    HeaderPhase {
79        // Store the preferred list to compare with content-encoding
80        accept_encoding: Vec<Algorithm>,
81        encoding_levels: [u32; Algorithm::COUNT],
82        decompress_enable: [bool; Algorithm::COUNT],
83        preserve_etag: [bool; Algorithm::COUNT],
84        // Optional dictionary for dcz compression (RFC 9842).
85        dictionary: Option<DictionaryData>,
86    },
87    BodyPhase(Option<Box<dyn Encode + Send + Sync>>),
88}
89
90impl ResponseCompressionCtx {
91    /// Create a new [`ResponseCompressionCtx`] with the expected compression level. `0` will disable
92    /// the compression. The compression level is applied across all algorithms.
93    /// The `decompress_enable` flag will tell the ctx to decompress if needed.
94    /// The `preserve_etag` flag indicates whether the ctx should avoid modifying the etag,
95    /// which will otherwise be weakened if the flag is false and (de)compression is applied.
96    pub fn new(compression_level: u32, decompress_enable: bool, preserve_etag: bool) -> Self {
97        Self(CtxInner::HeaderPhase {
98            accept_encoding: Vec::new(),
99            encoding_levels: [compression_level; Algorithm::COUNT],
100            decompress_enable: [decompress_enable; Algorithm::COUNT],
101            preserve_etag: [preserve_etag; Algorithm::COUNT],
102            dictionary: None,
103        })
104    }
105
106    /// Whether the encoder is enabled.
107    /// The enablement will change according to the request and response filter by this ctx.
108    pub fn is_enabled(&self) -> bool {
109        match &self.0 {
110            CtxInner::HeaderPhase {
111                decompress_enable,
112                encoding_levels: levels,
113                ..
114            } => levels.iter().any(|l| *l != 0) || decompress_enable.iter().any(|d| *d),
115            CtxInner::BodyPhase(c) => c.is_some(),
116        }
117    }
118
119    /// Return the stat of this ctx:
120    /// algorithm name, in bytes, out bytes, time took for the compression
121    pub fn get_info(&self) -> Option<(&'static str, usize, usize, Duration)> {
122        match &self.0 {
123            CtxInner::HeaderPhase { .. } => None,
124            CtxInner::BodyPhase(c) => c.as_ref().map(|c| c.stat()),
125        }
126    }
127
128    /// Adjust the compression level for all compression algorithms.
129    /// # Panic
130    /// This function will panic if it has already started encoding the response body.
131    pub fn adjust_level(&mut self, new_level: u32) {
132        match &mut self.0 {
133            CtxInner::HeaderPhase {
134                encoding_levels: levels,
135                ..
136            } => {
137                *levels = [new_level; Algorithm::COUNT];
138            }
139            CtxInner::BodyPhase(_) => panic!("Wrong phase: BodyPhase"),
140        }
141    }
142
143    /// Adjust the compression level for a specific algorithm.
144    /// # Panic
145    /// This function will panic if it has already started encoding the response body.
146    pub fn adjust_algorithm_level(&mut self, algorithm: Algorithm, new_level: u32) {
147        match &mut self.0 {
148            CtxInner::HeaderPhase {
149                encoding_levels: levels,
150                ..
151            } => {
152                levels[algorithm.index()] = new_level;
153            }
154            CtxInner::BodyPhase(_) => panic!("Wrong phase: BodyPhase"),
155        }
156    }
157
158    /// Adjust the decompression flag for all compression algorithms.
159    /// # Panic
160    /// This function will panic if it has already started encoding the response body.
161    pub fn adjust_decompression(&mut self, enabled: bool) {
162        match &mut self.0 {
163            CtxInner::HeaderPhase {
164                decompress_enable, ..
165            } => {
166                *decompress_enable = [enabled; Algorithm::COUNT];
167            }
168            CtxInner::BodyPhase(_) => panic!("Wrong phase: BodyPhase"),
169        }
170    }
171
172    /// Adjust the decompression flag for a specific algorithm.
173    /// # Panic
174    /// This function will panic if it has already started encoding the response body.
175    pub fn adjust_algorithm_decompression(&mut self, algorithm: Algorithm, enabled: bool) {
176        match &mut self.0 {
177            CtxInner::HeaderPhase {
178                decompress_enable, ..
179            } => {
180                decompress_enable[algorithm.index()] = enabled;
181            }
182            CtxInner::BodyPhase(_) => panic!("Wrong phase: BodyPhase"),
183        }
184    }
185
186    /// Adjust preserve etag setting.
187    /// # Panic
188    /// This function will panic if it has already started encoding the response body.
189    pub fn adjust_preserve_etag(&mut self, enabled: bool) {
190        match &mut self.0 {
191            CtxInner::HeaderPhase { preserve_etag, .. } => {
192                *preserve_etag = [enabled; Algorithm::COUNT];
193            }
194            CtxInner::BodyPhase(_) => panic!("Wrong phase: BodyPhase"),
195        }
196    }
197
198    /// Adjust preserve etag setting for a specific algorithm.
199    /// # Panic
200    /// This function will panic if it has already started encoding the response body.
201    pub fn adjust_algorithm_preserve_etag(&mut self, algorithm: Algorithm, enabled: bool) {
202        match &mut self.0 {
203            CtxInner::HeaderPhase { preserve_etag, .. } => {
204                preserve_etag[algorithm.index()] = enabled;
205            }
206            CtxInner::BodyPhase(_) => panic!("Wrong phase: BodyPhase"),
207        }
208    }
209
210    /// Set the dictionary for [RFC 9842](https://datatracker.ietf.org/doc/html/rfc9842) dictionary compression.
211    /// # Panic
212    /// This function will panic if it has already started encoding the response body.
213    pub fn set_dictionary(&mut self, dictionary_bytes: Bytes, dictionary_hash: [u8; 32]) {
214        match &mut self.0 {
215            CtxInner::HeaderPhase { dictionary, .. } => {
216                *dictionary = Some(DictionaryData {
217                    bytes: dictionary_bytes,
218                    hash: dictionary_hash,
219                });
220            }
221            CtxInner::BodyPhase(_) => panic!("Wrong phase: BodyPhase"),
222        }
223    }
224
225    /// Check if a dictionary has been set.
226    pub fn has_dictionary(&self) -> bool {
227        match &self.0 {
228            CtxInner::HeaderPhase { dictionary, .. } => dictionary.is_some(),
229            CtxInner::BodyPhase(_) => false,
230        }
231    }
232
233    /// Clear any previously set dictionary.
234    /// # Panic
235    /// This function will panic if it has already started encoding the response body.
236    pub fn clear_dictionary(&mut self) {
237        match &mut self.0 {
238            CtxInner::HeaderPhase { dictionary, .. } => {
239                *dictionary = None;
240            }
241            CtxInner::BodyPhase(_) => panic!("Wrong phase: BodyPhase"),
242        }
243    }
244
245    /// Feed the request header into this ctx.
246    pub fn request_filter(&mut self, req: &RequestHeader) {
247        if !self.is_enabled() {
248            return;
249        }
250        match &mut self.0 {
251            CtxInner::HeaderPhase {
252                accept_encoding, ..
253            } => parse_accept_encoding(
254                req.headers.get(http::header::ACCEPT_ENCODING),
255                accept_encoding,
256            ),
257            CtxInner::BodyPhase(_) => panic!("Wrong phase: BodyPhase"),
258        }
259    }
260
261    /// Feed the response header into this ctx
262    pub fn response_header_filter(&mut self, resp: &mut ResponseHeader, end: bool) {
263        if !self.is_enabled() {
264            return;
265        }
266        match &self.0 {
267            CtxInner::HeaderPhase {
268                decompress_enable,
269                preserve_etag,
270                accept_encoding,
271                encoding_levels: levels,
272                dictionary,
273            } => {
274                if resp.status.is_informational() {
275                    if resp.status == http::status::StatusCode::SWITCHING_PROTOCOLS {
276                        // no transformation for websocket (TODO: cite RFC)
277                        self.0 = CtxInner::BodyPhase(None);
278                    }
279                    // else, wait for the final response header for decision
280                    return;
281                }
282                // do nothing if no body
283                if end {
284                    self.0 = CtxInner::BodyPhase(None);
285                    return;
286                }
287
288                if depends_on_accept_encoding(
289                    resp,
290                    levels.iter().any(|level| *level != 0),
291                    decompress_enable,
292                ) {
293                    // The response depends on the Accept-Encoding header, make sure to indicate it
294                    // in the Vary response header.
295                    // https://www.rfc-editor.org/rfc/rfc9110#name-vary
296                    add_vary_header(resp, &http::header::ACCEPT_ENCODING);
297                }
298
299                let action = decide_action(resp, accept_encoding);
300                debug!("compression action: {action:?}");
301                let (encoder, preserve_etag) = match action {
302                    Action::Noop => (None, false),
303                    Action::Compress(algorithm) => {
304                        let idx = algorithm.index();
305                        let compressor = match algorithm {
306                            Algorithm::Dcz => {
307                                // RFC 9842: dictionary-compressed responses vary on
308                                // Available-Dictionary so caches don't serve this variant
309                                // to clients with a different or missing dictionary.
310                                let enc = dictionary.as_ref().and_then(|d| {
311                                    algorithm.maybe_compressor_with_dictionary(levels[idx], d)
312                                });
313                                if enc.is_some() {
314                                    add_vary_header(resp, &AVAILABLE_DICTIONARY);
315                                }
316                                enc
317                            }
318                            _ => algorithm.compressor(levels[idx]),
319                        };
320                        (compressor, preserve_etag[idx])
321                    }
322                    Action::Decompress(algorithm) => {
323                        let idx = algorithm.index();
324                        (
325                            algorithm.decompressor(decompress_enable[idx]),
326                            preserve_etag[idx],
327                        )
328                    }
329                };
330                if encoder.is_some() {
331                    adjust_response_header(resp, &action, preserve_etag);
332                }
333                self.0 = CtxInner::BodyPhase(encoder);
334            }
335            CtxInner::BodyPhase(_) => panic!("Wrong phase: BodyPhase"),
336        }
337    }
338
339    /// Stream the response body chunks into this ctx. The return value will be the compressed
340    /// data.
341    ///
342    /// Return None if compression is not enabled.
343    pub fn response_body_filter(&mut self, data: Option<&Bytes>, end: bool) -> Option<Bytes> {
344        match &mut self.0 {
345            CtxInner::HeaderPhase { .. } => panic!("Wrong phase: HeaderPhase"),
346            CtxInner::BodyPhase(compressor) => {
347                let result = compressor
348                    .as_mut()
349                    .map(|c| {
350                        // Feed even empty slice to compressor because it might yield data
351                        // when `end` is true
352                        let data = if let Some(b) = data { b.as_ref() } else { &[] };
353                        c.encode(data, end)
354                    })
355                    .transpose();
356                result.unwrap_or_else(|e| {
357                    warn!("Failed to compress, compression disabled, {}", e);
358                    // no point to transcode further data because bad data is already seen
359                    self.0 = CtxInner::BodyPhase(None);
360                    None
361                })
362            }
363        }
364    }
365
366    // TODO: retire this function, replace it with the two functions above
367    /// Feed the response into this ctx.
368    /// This filter will mutate the response accordingly if encoding is needed.
369    pub fn response_filter(&mut self, t: &mut HttpTask) {
370        if !self.is_enabled() {
371            return;
372        }
373        match t {
374            HttpTask::Header(resp, end) => self.response_header_filter(resp, *end),
375            HttpTask::Body(data, end) => {
376                let compressed = self.response_body_filter(data.as_ref(), *end);
377                if compressed.is_some() {
378                    *t = HttpTask::Body(compressed, *end);
379                }
380            }
381            HttpTask::Done => {
382                // try to finish/flush compression
383                let compressed = self.response_body_filter(None, true);
384                if compressed.is_some() {
385                    // compressor has more data to flush
386                    *t = HttpTask::Body(compressed, true);
387                }
388            }
389            _ => { /* Trailer, Failed: do nothing? */ }
390        }
391    }
392}
393
394#[derive(Debug, PartialEq, Eq, Clone, Copy, EnumCountMacro)]
395pub enum Algorithm {
396    Any, // the "*"
397    Gzip,
398    Brotli,
399    Zstd,
400    Dcb,
401    Dcz,
402    // TODO: Identity,
403    // TODO: Deflate
404    Other, // anything unknown
405}
406
407impl Algorithm {
408    pub fn as_str(&self) -> &'static str {
409        match self {
410            Algorithm::Gzip => "gzip",
411            Algorithm::Brotli => "br",
412            Algorithm::Zstd => "zstd",
413            Algorithm::Dcb => "dcb",
414            Algorithm::Dcz => "dcz",
415            Algorithm::Any => "*",
416            Algorithm::Other => "other",
417        }
418    }
419
420    pub fn compressor(&self, level: u32) -> Option<Box<dyn Encode + Send + Sync>> {
421        if level == 0 {
422            None
423        } else {
424            match self {
425                Self::Gzip => Some(Box::new(gzip::Compressor::new(level))),
426                Self::Brotli => Some(Box::new(brotli::Compressor::new(level))),
427                Self::Zstd => Some(Box::new(zstd::Compressor::new(level))),
428                _ => None, // not implemented
429            }
430        }
431    }
432
433    pub fn maybe_compressor_with_dictionary(
434        &self,
435        level: u32,
436        dictionary: &DictionaryData,
437    ) -> Option<Box<dyn Encode + Send + Sync>> {
438        if level == 0 {
439            None
440        } else {
441            match self {
442                Self::Dcz => {
443                    match zstd::DictionaryCompressor::new(level, &dictionary.bytes, dictionary.hash)
444                    {
445                        Ok(c) => Some(Box::new(c)),
446                        Err(e) => {
447                            warn!("Failed to create DCZ compressor: {e}");
448                            None
449                        }
450                    }
451                }
452                _ => None,
453            }
454        }
455    }
456
457    pub fn decompressor(&self, enabled: bool) -> Option<Box<dyn Encode + Send + Sync>> {
458        if !enabled {
459            None
460        } else {
461            match self {
462                Self::Gzip => Some(Box::new(gzip::Decompressor::new())),
463                Self::Brotli => Some(Box::new(brotli::Decompressor::new())),
464                _ => None, // not implemented
465            }
466        }
467    }
468
469    pub fn index(&self) -> usize {
470        *self as usize
471    }
472}
473
474impl From<&str> for Algorithm {
475    fn from(s: &str) -> Self {
476        use unicase::UniCase;
477
478        let coding = UniCase::new(s);
479        if coding == UniCase::ascii("gzip") {
480            Algorithm::Gzip
481        } else if coding == UniCase::ascii("br") {
482            Algorithm::Brotli
483        } else if coding == UniCase::ascii("zstd") {
484            Algorithm::Zstd
485        } else if coding == UniCase::ascii("dcb") {
486            Algorithm::Dcb
487        } else if coding == UniCase::ascii("dcz") {
488            Algorithm::Dcz
489        } else if s.is_empty() {
490            Algorithm::Any
491        } else {
492            Algorithm::Other
493        }
494    }
495}
496
497#[derive(Debug, PartialEq, Eq, Clone, Copy)]
498enum Action {
499    Noop, // do nothing, e.g. when the input is already gzip
500    Compress(Algorithm),
501    Decompress(Algorithm),
502}
503
504// parse Accept-Encoding header and put it to the list
505fn parse_accept_encoding(accept_encoding: Option<&http::HeaderValue>, list: &mut Vec<Algorithm>) {
506    // https://www.rfc-editor.org/rfc/rfc9110#name-accept-encoding
507    if let Some(ac) = accept_encoding {
508        // fast path
509        if ac.as_bytes() == b"gzip" {
510            list.push(Algorithm::Gzip);
511            return;
512        }
513        // properly parse AC header
514        match sfv::Parser::parse_list(ac.as_bytes()) {
515            Ok(parsed) => {
516                for item in parsed {
517                    if let sfv::ListEntry::Item(i) = item {
518                        if let Some(s) = i.bare_item.as_token() {
519                            // TODO: support q value
520                            let algorithm = Algorithm::from(s);
521                            // ignore algorithms that we don't understand ignore
522                            if algorithm != Algorithm::Other {
523                                list.push(Algorithm::from(s));
524                            }
525                        }
526                    }
527                }
528            }
529            Err(e) => {
530                warn!("Failed to parse accept-encoding {ac:?}, {e}")
531            }
532        }
533    } else {
534        // "If no Accept-Encoding header, any content coding is acceptable"
535        // keep the list empty
536    }
537}
538
539#[test]
540fn test_accept_encoding_req_header() {
541    let mut header = RequestHeader::build("GET", b"/", None).unwrap();
542    let mut ac_list = Vec::new();
543    parse_accept_encoding(
544        header.headers.get(http::header::ACCEPT_ENCODING),
545        &mut ac_list,
546    );
547    assert!(ac_list.is_empty());
548
549    let mut ac_list = Vec::new();
550    header.insert_header("accept-encoding", "gzip").unwrap();
551    parse_accept_encoding(
552        header.headers.get(http::header::ACCEPT_ENCODING),
553        &mut ac_list,
554    );
555    assert_eq!(ac_list[0], Algorithm::Gzip);
556
557    let mut ac_list = Vec::new();
558    header
559        .insert_header("accept-encoding", "what, br, gzip")
560        .unwrap();
561    parse_accept_encoding(
562        header.headers.get(http::header::ACCEPT_ENCODING),
563        &mut ac_list,
564    );
565    assert_eq!(ac_list[0], Algorithm::Brotli);
566    assert_eq!(ac_list[1], Algorithm::Gzip);
567}
568
569// test whether the response depends on Accept-Encoding header
570fn depends_on_accept_encoding(
571    resp: &ResponseHeader,
572    compress_enabled: bool,
573    decompress_enabled: &[bool],
574) -> bool {
575    use http::header::CONTENT_ENCODING;
576
577    (decompress_enabled.iter().any(|enabled| *enabled)
578        && resp.headers.get(CONTENT_ENCODING).is_some())
579        || (compress_enabled && compressible(resp))
580}
581
582#[test]
583fn test_decide_on_accept_encoding() {
584    let mut resp = ResponseHeader::build(200, None).unwrap();
585    resp.insert_header("content-length", "50").unwrap();
586    resp.insert_header("content-type", "text/html").unwrap();
587    resp.insert_header("content-encoding", "gzip").unwrap();
588
589    // enabled
590    assert!(depends_on_accept_encoding(&resp, false, &[true]));
591
592    // decompress disabled => disabled
593    assert!(!depends_on_accept_encoding(&resp, false, &[false]));
594
595    // no content-encoding => disabled
596    resp.remove_header("content-encoding");
597    assert!(!depends_on_accept_encoding(&resp, false, &[true]));
598
599    // compress enabled and compressible response => enabled
600    assert!(depends_on_accept_encoding(&resp, true, &[false]));
601
602    // compress disabled and compressible response => disabled
603    assert!(!depends_on_accept_encoding(&resp, false, &[false]));
604
605    // compress enabled and not compressible response => disabled
606    resp.insert_header("content-type", "text/html+zip").unwrap();
607    assert!(!depends_on_accept_encoding(&resp, true, &[false]));
608}
609
610// filter response header to see if (de)compression is needed
611fn decide_action(resp: &ResponseHeader, accept_encoding: &[Algorithm]) -> Action {
612    use http::header::CONTENT_ENCODING;
613
614    let content_encoding = if let Some(ce) = resp.headers.get(CONTENT_ENCODING) {
615        // https://www.rfc-editor.org/rfc/rfc9110#name-content-encoding
616        if let Ok(ce_str) = std::str::from_utf8(ce.as_bytes()) {
617            Some(Algorithm::from(ce_str))
618        } else {
619            // not utf-8, treat it as unknown encoding to leave it untouched
620            Some(Algorithm::Other)
621        }
622    } else {
623        // no Accept-encoding
624        None
625    };
626
627    if let Some(ce) = content_encoding {
628        if accept_encoding.contains(&ce) {
629            // downstream can accept this encoding, nothing to do
630            Action::Noop
631        } else {
632            // always decompress because uncompressed is always acceptable
633            // https://www.rfc-editor.org/rfc/rfc9110#field.accept-encoding
634            // "If the representation has no content coding, then it is acceptable by default
635            // unless specifically excluded..." TODO: check the exclude case
636            // TODO: we could also transcode it to a preferred encoding, e.g. br->gzip
637            Action::Decompress(ce)
638        }
639    } else if accept_encoding.is_empty() // both CE and AE are empty
640        || !compressible(resp) // the type is not compressible
641        || accept_encoding[0] == Algorithm::Any
642    {
643        Action::Noop
644    } else {
645        // try to compress with the first AC
646        // TODO: support to configure preferred encoding
647        Action::Compress(accept_encoding[0])
648    }
649}
650
651#[test]
652fn test_decide_action() {
653    use Action::*;
654    use Algorithm::*;
655
656    let header = ResponseHeader::build(200, None).unwrap();
657    // no compression asked, no compression needed
658    assert_eq!(decide_action(&header, &[]), Noop);
659
660    // already gzip, no compression needed
661    let mut header = ResponseHeader::build(200, None).unwrap();
662    header.insert_header("content-type", "text/html").unwrap();
663    header.insert_header("content-encoding", "gzip").unwrap();
664    assert_eq!(decide_action(&header, &[Gzip]), Noop);
665
666    // already gzip, no compression needed, upper case
667    let mut header = ResponseHeader::build(200, None).unwrap();
668    header.insert_header("content-encoding", "GzIp").unwrap();
669    header.insert_header("content-type", "text/html").unwrap();
670    assert_eq!(decide_action(&header, &[Gzip]), Noop);
671
672    // no encoding, compression needed, accepted content-type, large enough
673    // Will compress
674    let mut header = ResponseHeader::build(200, None).unwrap();
675    header.insert_header("content-length", "20").unwrap();
676    header.insert_header("content-type", "text/html").unwrap();
677    assert_eq!(decide_action(&header, &[Gzip]), Compress(Gzip));
678
679    // too small
680    let mut header = ResponseHeader::build(200, None).unwrap();
681    header.insert_header("content-length", "19").unwrap();
682    header.insert_header("content-type", "text/html").unwrap();
683    assert_eq!(decide_action(&header, &[Gzip]), Noop);
684
685    // already compressed MIME
686    let mut header = ResponseHeader::build(200, None).unwrap();
687    header.insert_header("content-length", "20").unwrap();
688    header
689        .insert_header("content-type", "text/html+zip")
690        .unwrap();
691    assert_eq!(decide_action(&header, &[Gzip]), Noop);
692
693    // unsupported MIME
694    let mut header = ResponseHeader::build(200, None).unwrap();
695    header.insert_header("content-length", "20").unwrap();
696    header.insert_header("content-type", "image/jpg").unwrap();
697    assert_eq!(decide_action(&header, &[Gzip]), Noop);
698
699    // compressed, need decompress
700    let mut header = ResponseHeader::build(200, None).unwrap();
701    header.insert_header("content-encoding", "gzip").unwrap();
702    assert_eq!(decide_action(&header, &[]), Decompress(Gzip));
703
704    // accept-encoding different, need decompress
705    let mut header = ResponseHeader::build(200, None).unwrap();
706    header.insert_header("content-encoding", "gzip").unwrap();
707    assert_eq!(decide_action(&header, &[Brotli]), Decompress(Gzip));
708
709    // less preferred but no need to decompress
710    let mut header = ResponseHeader::build(200, None).unwrap();
711    header.insert_header("content-encoding", "gzip").unwrap();
712    assert_eq!(decide_action(&header, &[Brotli, Gzip]), Noop);
713
714    // dcb passthrough: client accepts dcb, response has dcb
715    let mut header = ResponseHeader::build(200, None).unwrap();
716    header.insert_header("content-encoding", "dcb").unwrap();
717    assert_eq!(decide_action(&header, &[Dcb, Brotli]), Noop);
718
719    // dcz passthrough: client accepts dcz, response has dcz
720    let mut header = ResponseHeader::build(200, None).unwrap();
721    header.insert_header("content-encoding", "dcz").unwrap();
722    assert_eq!(decide_action(&header, &[Dcz, Zstd]), Noop);
723
724    // Client wants dcz but response has brotli, decompress brotli
725    let mut header = ResponseHeader::build(200, None).unwrap();
726    header.insert_header("content-encoding", "br").unwrap();
727    assert_eq!(decide_action(&header, &[Dcz]), Decompress(Brotli));
728
729    // Client wants dcz but response has zstd, decompress zstd
730    let mut header = ResponseHeader::build(200, None).unwrap();
731    header.insert_header("content-encoding", "zstd").unwrap();
732    assert_eq!(decide_action(&header, &[Dcz]), Decompress(Zstd));
733
734    // Client wants dcb but response has gzip, decompress gzip
735    let mut header = ResponseHeader::build(200, None).unwrap();
736    header.insert_header("content-encoding", "gzip").unwrap();
737    assert_eq!(decide_action(&header, &[Dcb]), Decompress(Gzip));
738
739    // Client wants dcb but response has brotli, decompress brotli
740    let mut header = ResponseHeader::build(200, None).unwrap();
741    header.insert_header("content-encoding", "br").unwrap();
742    assert_eq!(decide_action(&header, &[Dcb]), Decompress(Brotli));
743}
744
745use once_cell::sync::Lazy;
746use regex::Regex;
747
748// Allow text, application, font, a few image/ MIME types and binary/octet-stream
749// TODO: fine tune this list
750static MIME_CHECK: Lazy<Regex> = Lazy::new(|| {
751    Regex::new(r"^(?:text/|application/|font/|image/(?:x-icon|svg\+xml|nd\.microsoft\.icon)|binary/octet-stream)")
752        .unwrap()
753});
754
755// check if the response mime type is compressible
756fn compressible(resp: &ResponseHeader) -> bool {
757    // arbitrary size limit, things to consider
758    // 1. too short body may have little redundancy to compress
759    // 2. gzip header and footer overhead
760    // 3. latency is the same as long as data fits in a TCP congestion window regardless of size
761    const MIN_COMPRESS_LEN: usize = 20;
762
763    // check if response is too small to compress
764    if let Some(cl) = resp.headers.get(http::header::CONTENT_LENGTH) {
765        if let Some(cl_num) = std::str::from_utf8(cl.as_bytes())
766            .ok()
767            .and_then(|v| v.parse::<usize>().ok())
768        {
769            if cl_num < MIN_COMPRESS_LEN {
770                return false;
771            }
772        }
773    }
774    // no Content-Length or large enough, check content-type next
775    if let Some(ct) = resp.headers.get(http::header::CONTENT_TYPE) {
776        if let Ok(ct_str) = std::str::from_utf8(ct.as_bytes()) {
777            if ct_str.contains("zip") {
778                // heuristic: don't compress mime type that has zip in it
779                false
780            } else {
781                // check if mime type in allow list
782                MIME_CHECK.find(ct_str).is_some()
783            }
784        } else {
785            false // invalid CT header, don't compress
786        }
787    } else {
788        false // don't compress empty content-type
789    }
790}
791
792/// Header name for the Available-Dictionary request header ([RFC 9842]).
793/// TODO: Replace with http::header when available.
794///
795/// [RFC 9842]: https://datatracker.ietf.org/doc/html/rfc9842
796static AVAILABLE_DICTIONARY: http::HeaderName =
797    http::HeaderName::from_static("available-dictionary");
798
799// add Vary header with the specified value or extend an existing Vary header value
800fn add_vary_header(resp: &mut ResponseHeader, value: &http::header::HeaderName) {
801    use http::header::{HeaderValue, VARY};
802
803    let already_present = resp.headers.get_all(VARY).iter().any(|existing| {
804        existing
805            .as_bytes()
806            .split(|b| *b == b',')
807            .map(|mut v| {
808                // This is equivalent to slice.trim_ascii() which is unstable
809                while let [first, rest @ ..] = v {
810                    if first.is_ascii_whitespace() {
811                        v = rest;
812                    } else {
813                        break;
814                    }
815                }
816                while let [rest @ .., last] = v {
817                    if last.is_ascii_whitespace() {
818                        v = rest;
819                    } else {
820                        break;
821                    }
822                }
823                v
824            })
825            .any(|v| v == b"*" || v.eq_ignore_ascii_case(value.as_ref()))
826    });
827
828    if !already_present {
829        resp.append_header(&VARY, HeaderValue::from_name(value.clone()))
830            .unwrap();
831    }
832}
833
834#[test]
835fn test_add_vary_header() {
836    let mut header = ResponseHeader::build(200, None).unwrap();
837    add_vary_header(&mut header, &http::header::ACCEPT_ENCODING);
838    assert_eq!(
839        header
840            .headers
841            .get_all("Vary")
842            .into_iter()
843            .collect::<Vec<_>>(),
844        vec!["accept-encoding"]
845    );
846
847    let mut header = ResponseHeader::build(200, None).unwrap();
848    header.insert_header("Vary", "Accept-Language").unwrap();
849    add_vary_header(&mut header, &http::header::ACCEPT_ENCODING);
850    assert_eq!(
851        header
852            .headers
853            .get_all("Vary")
854            .into_iter()
855            .collect::<Vec<_>>(),
856        vec!["Accept-Language", "accept-encoding"]
857    );
858
859    let mut header = ResponseHeader::build(200, None).unwrap();
860    header
861        .insert_header("Vary", "Accept-Language, Accept-Encoding")
862        .unwrap();
863    add_vary_header(&mut header, &http::header::ACCEPT_ENCODING);
864    assert_eq!(
865        header
866            .headers
867            .get_all("Vary")
868            .into_iter()
869            .collect::<Vec<_>>(),
870        vec!["Accept-Language, Accept-Encoding"]
871    );
872
873    let mut header = ResponseHeader::build(200, None).unwrap();
874    header.insert_header("Vary", "*").unwrap();
875    add_vary_header(&mut header, &http::header::ACCEPT_ENCODING);
876    assert_eq!(
877        header
878            .headers
879            .get_all("Vary")
880            .into_iter()
881            .collect::<Vec<_>>(),
882        vec!["*"]
883    );
884}
885
886fn adjust_response_header(resp: &mut ResponseHeader, action: &Action, preserve_etag: bool) {
887    use http::header::{
888        HeaderValue, ACCEPT_RANGES, CONTENT_ENCODING, CONTENT_LENGTH, ETAG, TRANSFER_ENCODING,
889    };
890
891    fn set_stream_headers(resp: &mut ResponseHeader) {
892        // because the transcoding is streamed, content length is not known ahead
893        resp.remove_header(&CONTENT_LENGTH);
894        // remove Accept-Ranges header because range requests will no longer work
895        resp.remove_header(&ACCEPT_RANGES);
896
897        // we stream body now TODO: chunked is for h1 only
898        resp.insert_header(&TRANSFER_ENCODING, HeaderValue::from_static("chunked"))
899            .unwrap();
900    }
901
902    fn weaken_or_clear_etag(resp: &mut ResponseHeader) {
903        // RFC9110: https://datatracker.ietf.org/doc/html/rfc9110#section-8.8.1
904        // "a validator is weak if it is shared by two or more representations
905        // of a given resource at the same time, unless those representations
906        // have identical representation data"
907        // Follow nginx gzip filter's example when changing content encoding:
908        // - if the ETag is not a valid strong ETag, clear it (i.e. does not start with `"`)
909        // - else, weaken it
910        if let Some(etag) = resp.headers.get(&ETAG) {
911            let etag_bytes = etag.as_bytes();
912            if etag_bytes.starts_with(b"W/") {
913                // this is already a weak ETag, noop
914            } else if etag_bytes.starts_with(b"\"") {
915                // strong ETag, weaken since we are changing the byte representation
916                let weakened_etag = HeaderValue::from_bytes(&[b"W/", etag_bytes].concat())
917                    .expect("valid header value prefixed with \"W/\" should remain valid");
918                resp.insert_header(&ETAG, weakened_etag)
919                    .expect("can insert weakened etag when etag was already valid");
920            } else {
921                // invalid strong ETag, just clear it
922                // https://datatracker.ietf.org/doc/html/rfc9110#section-8.8.3
923                // says the opaque-tag section needs to be a quoted string
924                resp.remove_header(&ETAG);
925            }
926        }
927    }
928
929    match action {
930        Action::Noop => { /* do nothing */ }
931        Action::Decompress(_) => {
932            resp.remove_header(&CONTENT_ENCODING);
933            set_stream_headers(resp);
934            if !preserve_etag {
935                weaken_or_clear_etag(resp);
936            }
937        }
938        Action::Compress(a) => {
939            resp.insert_header(&CONTENT_ENCODING, HeaderValue::from_static(a.as_str()))
940                .unwrap();
941            set_stream_headers(resp);
942            if !preserve_etag {
943                weaken_or_clear_etag(resp);
944            }
945        }
946    }
947}
948
949#[test]
950fn test_adjust_response_header() {
951    use Action::*;
952    use Algorithm::*;
953
954    // noop
955    let mut header = ResponseHeader::build(200, None).unwrap();
956    header.insert_header("content-length", "20").unwrap();
957    header.insert_header("content-encoding", "gzip").unwrap();
958    header.insert_header("accept-ranges", "bytes").unwrap();
959    header.insert_header("etag", "\"abc123\"").unwrap();
960    adjust_response_header(&mut header, &Noop, false);
961    assert_eq!(
962        header.headers.get("content-encoding").unwrap().as_bytes(),
963        b"gzip"
964    );
965    assert_eq!(
966        header.headers.get("content-length").unwrap().as_bytes(),
967        b"20"
968    );
969    assert_eq!(
970        header.headers.get("etag").unwrap().as_bytes(),
971        b"\"abc123\""
972    );
973    assert!(header.headers.get("transfer-encoding").is_none());
974
975    // decompress gzip
976    let mut header = ResponseHeader::build(200, None).unwrap();
977    header.insert_header("content-length", "20").unwrap();
978    header.insert_header("content-encoding", "gzip").unwrap();
979    header.insert_header("accept-ranges", "bytes").unwrap();
980    header.insert_header("etag", "\"abc123\"").unwrap();
981    adjust_response_header(&mut header, &Decompress(Gzip), false);
982    assert!(header.headers.get("content-encoding").is_none());
983    assert!(header.headers.get("content-length").is_none());
984    assert_eq!(
985        header.headers.get("transfer-encoding").unwrap().as_bytes(),
986        b"chunked"
987    );
988    assert!(header.headers.get("accept-ranges").is_none());
989    assert_eq!(
990        header.headers.get("etag").unwrap().as_bytes(),
991        b"W/\"abc123\""
992    );
993    // when preserve_etag on, strong etag is kept
994    header.insert_header("etag", "\"abc123\"").unwrap();
995    adjust_response_header(&mut header, &Decompress(Gzip), true);
996    assert_eq!(
997        header.headers.get("etag").unwrap().as_bytes(),
998        b"\"abc123\""
999    );
1000
1001    // compress
1002    let mut header = ResponseHeader::build(200, None).unwrap();
1003    header.insert_header("content-length", "20").unwrap();
1004    header.insert_header("accept-ranges", "bytes").unwrap();
1005    // try invalid etag, should be cleared
1006    header.insert_header("etag", "abc123").unwrap();
1007    adjust_response_header(&mut header, &Compress(Gzip), false);
1008    assert_eq!(
1009        header.headers.get("content-encoding").unwrap().as_bytes(),
1010        b"gzip"
1011    );
1012    assert!(header.headers.get("content-length").is_none());
1013    assert!(header.headers.get("accept-ranges").is_none());
1014    assert_eq!(
1015        header.headers.get("transfer-encoding").unwrap().as_bytes(),
1016        b"chunked"
1017    );
1018    assert!(header.headers.get("etag").is_none());
1019    // when preserve_etag on, etag is kept
1020    header.insert_header("etag", "abc123").unwrap();
1021    adjust_response_header(&mut header, &Compress(Gzip), true);
1022    assert_eq!(header.headers.get("etag").unwrap().as_bytes(), b"abc123");
1023}
1024
1025#[cfg(test)]
1026mod tests_dictionary_compression {
1027    use super::*;
1028
1029    const TEST_DICTIONARY: &[u8] = b"The quick brown fox jumps over the lazy dog. \
1030        Common HTTP headers: Content-Type, Accept-Encoding, Cache-Control. \
1031        JSON patterns: {\"key\": \"value\"}, [\"array\", \"items\"].";
1032
1033    fn test_dictionary_hash() -> [u8; 32] {
1034        let mut hash = [0u8; 32];
1035        for (i, byte) in TEST_DICTIONARY.iter().take(32).enumerate() {
1036            hash[i] = *byte;
1037        }
1038        hash
1039    }
1040
1041    #[test]
1042    fn set_and_clear_dictionary() {
1043        let mut ctx = ResponseCompressionCtx::new(3, false, false);
1044        assert!(!ctx.has_dictionary());
1045
1046        ctx.set_dictionary(Bytes::from_static(TEST_DICTIONARY), test_dictionary_hash());
1047        assert!(ctx.has_dictionary());
1048
1049        ctx.clear_dictionary();
1050        assert!(!ctx.has_dictionary());
1051    }
1052
1053    #[test]
1054    fn dcz_compression_with_dictionary() {
1055        let mut ctx = ResponseCompressionCtx::new(3, false, false);
1056        let hash = test_dictionary_hash();
1057        ctx.set_dictionary(Bytes::from_static(TEST_DICTIONARY), hash);
1058
1059        let mut req = RequestHeader::build("GET", b"/test.js", None).unwrap();
1060        req.insert_header("accept-encoding", "dcz, br, gzip")
1061            .unwrap();
1062        ctx.request_filter(&req);
1063
1064        let mut resp = ResponseHeader::build(200, None).unwrap();
1065        resp.insert_header("content-type", "application/javascript")
1066            .unwrap();
1067        resp.insert_header("content-length", "1000").unwrap();
1068        ctx.response_header_filter(&mut resp, false);
1069
1070        assert_eq!(
1071            resp.headers.get("content-encoding").unwrap().as_bytes(),
1072            b"dcz"
1073        );
1074        // RFC 9842: DCZ responses must vary on Available-Dictionary.
1075        assert!(resp.headers.get_all("vary").iter().any(|v| v
1076            .as_bytes()
1077            .split(|b| *b == b',')
1078            .any(|t| t.trim_ascii().eq_ignore_ascii_case(b"available-dictionary"))));
1079
1080        let input = Bytes::from_static(b"The quick brown fox jumps over the lazy dog again.");
1081        let compressed = ctx.response_body_filter(Some(&input), true).unwrap();
1082
1083        assert!(compressed.len() >= 40);
1084        assert_eq!(&compressed[..8], &zstd::DCZ_MAGIC);
1085        assert_eq!(&compressed[8..40], &hash);
1086    }
1087
1088    #[test]
1089    fn dcz_without_dictionary_no_compression() {
1090        let mut ctx = ResponseCompressionCtx::new(3, false, false);
1091
1092        let mut req = RequestHeader::build("GET", b"/test.js", None).unwrap();
1093        req.insert_header("accept-encoding", "dcz").unwrap();
1094        ctx.request_filter(&req);
1095
1096        let mut resp = ResponseHeader::build(200, None).unwrap();
1097        resp.insert_header("content-type", "application/javascript")
1098            .unwrap();
1099        resp.insert_header("content-length", "1000").unwrap();
1100        ctx.response_header_filter(&mut resp, false);
1101
1102        // no dictionary set, no compression applied
1103        assert!(resp.headers.get("content-encoding").is_none());
1104        // No compression → no Vary: available-dictionary.
1105        assert!(!resp.headers.get_all("vary").iter().any(|v| v
1106            .as_bytes()
1107            .split(|b| *b == b',')
1108            .any(|t| t.trim_ascii().eq_ignore_ascii_case(b"available-dictionary"))));
1109    }
1110
1111    #[test]
1112    fn dcz_no_fallback_without_dictionary() {
1113        let mut ctx = ResponseCompressionCtx::new(3, false, false);
1114
1115        let mut req = RequestHeader::build("GET", b"/test.js", None).unwrap();
1116        req.insert_header("accept-encoding", "dcz, br, gzip")
1117            .unwrap();
1118        ctx.request_filter(&req);
1119
1120        let mut resp = ResponseHeader::build(200, None).unwrap();
1121        resp.insert_header("content-type", "application/javascript")
1122            .unwrap();
1123        resp.insert_header("content-length", "1000").unwrap();
1124        ctx.response_header_filter(&mut resp, false);
1125
1126        // dcz first but no dictionary, no automatic fallback
1127        assert!(resp.headers.get("content-encoding").is_none());
1128        // No compression → no Vary: available-dictionary.
1129        assert!(!resp.headers.get_all("vary").iter().any(|v| v
1130            .as_bytes()
1131            .split(|b| *b == b',')
1132            .any(|t| t.trim_ascii().eq_ignore_ascii_case(b"available-dictionary"))));
1133    }
1134
1135    #[test]
1136    fn maybe_compressor_with_dictionary_dcz_only() {
1137        let dict_data = DictionaryData {
1138            bytes: Bytes::from_static(TEST_DICTIONARY),
1139            hash: test_dictionary_hash(),
1140        };
1141
1142        // only Dcz returns a compressor
1143        assert!(Algorithm::Dcz
1144            .maybe_compressor_with_dictionary(3, &dict_data)
1145            .is_some());
1146        assert!(Algorithm::Gzip
1147            .maybe_compressor_with_dictionary(3, &dict_data)
1148            .is_none());
1149        assert!(Algorithm::Brotli
1150            .maybe_compressor_with_dictionary(3, &dict_data)
1151            .is_none());
1152        assert!(Algorithm::Zstd
1153            .maybe_compressor_with_dictionary(3, &dict_data)
1154            .is_none());
1155        // level 0 disables
1156        assert!(Algorithm::Dcz
1157            .maybe_compressor_with_dictionary(0, &dict_data)
1158            .is_none());
1159    }
1160
1161    #[test]
1162    fn dcz_full_flow() {
1163        let mut ctx = ResponseCompressionCtx::new(3, false, false);
1164        let hash = test_dictionary_hash();
1165        ctx.set_dictionary(Bytes::from_static(TEST_DICTIONARY), hash);
1166
1167        let mut req = RequestHeader::build("GET", b"/app.js", None).unwrap();
1168        req.insert_header("accept-encoding", "dcz").unwrap();
1169        ctx.request_filter(&req);
1170
1171        let mut resp = ResponseHeader::build(200, None).unwrap();
1172        resp.insert_header("content-type", "application/javascript")
1173            .unwrap();
1174        resp.insert_header("content-length", "500").unwrap();
1175        ctx.response_header_filter(&mut resp, false);
1176
1177        assert_eq!(
1178            resp.headers.get("content-encoding").unwrap().as_bytes(),
1179            b"dcz"
1180        );
1181        assert!(resp.headers.get("content-length").is_none());
1182        assert_eq!(
1183            resp.headers.get("transfer-encoding").unwrap().as_bytes(),
1184            b"chunked"
1185        );
1186        // RFC 9842: DCZ responses must vary on Available-Dictionary.
1187        assert!(resp.headers.get_all("vary").iter().any(|v| v
1188            .as_bytes()
1189            .split(|b| *b == b',')
1190            .any(|t| t.trim_ascii().eq_ignore_ascii_case(b"available-dictionary"))));
1191
1192        let chunk1 = Bytes::from_static(b"First chunk. ");
1193        let output1 = ctx.response_body_filter(Some(&chunk1), false);
1194        assert!(output1.is_some());
1195
1196        let chunk2 = Bytes::from_static(b"Second chunk.");
1197        let output2 = ctx.response_body_filter(Some(&chunk2), true);
1198        assert!(output2.is_some());
1199
1200        let (name, total_in, total_out, _) = ctx.get_info().unwrap();
1201        assert_eq!(name, "dcz");
1202        assert_eq!(total_in, chunk1.len() + chunk2.len());
1203        assert!(total_out > 0);
1204    }
1205
1206    #[test]
1207    fn regular_compression_no_available_dictionary_vary() {
1208        // Gzip compression should produce Vary: Accept-Encoding but NOT
1209        // Vary: available-dictionary.
1210        let mut ctx = ResponseCompressionCtx::new(3, false, false);
1211
1212        let mut req = RequestHeader::build("GET", b"/page.html", None).unwrap();
1213        req.insert_header("accept-encoding", "gzip").unwrap();
1214        ctx.request_filter(&req);
1215
1216        let mut resp = ResponseHeader::build(200, None).unwrap();
1217        resp.insert_header("content-type", "text/html").unwrap();
1218        resp.insert_header("content-length", "1000").unwrap();
1219        ctx.response_header_filter(&mut resp, false);
1220
1221        assert_eq!(
1222            resp.headers.get("content-encoding").unwrap().as_bytes(),
1223            b"gzip"
1224        );
1225        assert!(resp.headers.get_all("vary").iter().any(|v| v
1226            .as_bytes()
1227            .split(|b| *b == b',')
1228            .any(|t| t.trim_ascii().eq_ignore_ascii_case(b"accept-encoding"))));
1229        assert!(!resp.headers.get_all("vary").iter().any(|v| v
1230            .as_bytes()
1231            .split(|b| *b == b',')
1232            .any(|t| t.trim_ascii().eq_ignore_ascii_case(b"available-dictionary"))));
1233    }
1234}