sentinel_proxy/static_files/
compression.rs

1//! Content compression for static file serving
2//!
3//! This module provides on-the-fly compression for static files,
4//! supporting both gzip and brotli encoding.
5
6use anyhow::Result;
7use bytes::Bytes;
8use flate2::write::GzEncoder;
9use flate2::Compression;
10use http::{header, Request};
11use std::io::Write;
12use tracing::trace;
13
14// ============================================================================
15// Content Encoding
16// ============================================================================
17
18/// Content encoding preference
19#[derive(Debug, Clone, Copy, PartialEq)]
20pub enum ContentEncoding {
21    Identity,
22    Gzip,
23    Brotli,
24}
25
26impl ContentEncoding {
27    /// Get the HTTP header value for this encoding
28    pub fn as_str(&self) -> &'static str {
29        match self {
30            ContentEncoding::Identity => "identity",
31            ContentEncoding::Gzip => "gzip",
32            ContentEncoding::Brotli => "br",
33        }
34    }
35}
36
37// ============================================================================
38// Compression Functions
39// ============================================================================
40
41/// Check if content type should be compressed
42pub fn should_compress(content_type: &str) -> bool {
43    content_type.starts_with("text/")
44        || content_type.contains("javascript")
45        || content_type.contains("json")
46        || content_type.contains("xml")
47        || content_type.contains("svg")
48        || content_type == "application/wasm"
49}
50
51/// Negotiate content encoding based on Accept-Encoding header
52pub fn negotiate_encoding<B>(req: &Request<B>) -> ContentEncoding {
53    if let Some(accept_encoding) = req.headers().get(header::ACCEPT_ENCODING) {
54        if let Ok(accept_str) = accept_encoding.to_str() {
55            // Check for brotli first (better compression)
56            if accept_str.contains("br") {
57                trace!(
58                    accept_encoding = %accept_str,
59                    selected = "brotli",
60                    "Negotiated content encoding"
61                );
62                return ContentEncoding::Brotli;
63            }
64            // Fall back to gzip
65            if accept_str.contains("gzip") {
66                trace!(
67                    accept_encoding = %accept_str,
68                    selected = "gzip",
69                    "Negotiated content encoding"
70                );
71                return ContentEncoding::Gzip;
72            }
73        }
74    }
75    trace!(selected = "identity", "No compression encoding accepted");
76    ContentEncoding::Identity
77}
78
79/// Compress content using the specified encoding
80pub fn compress_content(content: &Bytes, encoding: ContentEncoding) -> Result<Bytes> {
81    let original_size = content.len();
82
83    match encoding {
84        ContentEncoding::Gzip => {
85            let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
86            encoder.write_all(content)?;
87            let compressed = encoder.finish()?;
88            let compressed_size = compressed.len();
89
90            trace!(
91                encoding = "gzip",
92                original_size = original_size,
93                compressed_size = compressed_size,
94                ratio = format!(
95                    "{:.1}%",
96                    (compressed_size as f64 / original_size as f64) * 100.0
97                ),
98                "Compressed content"
99            );
100
101            Ok(Bytes::from(compressed))
102        }
103        ContentEncoding::Brotli => {
104            let mut compressed = Vec::new();
105            {
106                let mut encoder = brotli::CompressorWriter::new(&mut compressed, 4096, 4, 22);
107                encoder.write_all(content)?;
108            }
109            let compressed_size = compressed.len();
110
111            trace!(
112                encoding = "brotli",
113                original_size = original_size,
114                compressed_size = compressed_size,
115                ratio = format!(
116                    "{:.1}%",
117                    (compressed_size as f64 / original_size as f64) * 100.0
118                ),
119                "Compressed content"
120            );
121
122            Ok(Bytes::from(compressed))
123        }
124        ContentEncoding::Identity => {
125            trace!(
126                encoding = "identity",
127                size = original_size,
128                "No compression applied"
129            );
130            Ok(content.clone())
131        }
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn test_should_compress() {
141        assert!(should_compress("text/html"));
142        assert!(should_compress("text/css"));
143        assert!(should_compress("application/javascript"));
144        assert!(should_compress("application/json"));
145        assert!(should_compress("image/svg+xml"));
146        assert!(should_compress("application/wasm"));
147
148        assert!(!should_compress("image/png"));
149        assert!(!should_compress("image/jpeg"));
150        assert!(!should_compress("application/octet-stream"));
151    }
152
153    #[test]
154    fn test_content_encoding_as_str() {
155        assert_eq!(ContentEncoding::Identity.as_str(), "identity");
156        assert_eq!(ContentEncoding::Gzip.as_str(), "gzip");
157        assert_eq!(ContentEncoding::Brotli.as_str(), "br");
158    }
159
160    #[test]
161    fn test_compress_content_gzip() {
162        let content = Bytes::from("Hello, World!");
163        let compressed = compress_content(&content, ContentEncoding::Gzip).unwrap();
164
165        // Compressed content should be different (though might be larger for small inputs)
166        assert!(!compressed.is_empty());
167    }
168
169    #[test]
170    fn test_compress_content_identity() {
171        let content = Bytes::from("Hello, World!");
172        let result = compress_content(&content, ContentEncoding::Identity).unwrap();
173        assert_eq!(result, content);
174    }
175}