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!("{:.1}%", (compressed_size as f64 / original_size as f64) * 100.0),
95                "Compressed content"
96            );
97
98            Ok(Bytes::from(compressed))
99        }
100        ContentEncoding::Brotli => {
101            let mut compressed = Vec::new();
102            {
103                let mut encoder = brotli::CompressorWriter::new(&mut compressed, 4096, 4, 22);
104                encoder.write_all(content)?;
105            }
106            let compressed_size = compressed.len();
107
108            trace!(
109                encoding = "brotli",
110                original_size = original_size,
111                compressed_size = compressed_size,
112                ratio = format!("{:.1}%", (compressed_size as f64 / original_size as f64) * 100.0),
113                "Compressed content"
114            );
115
116            Ok(Bytes::from(compressed))
117        }
118        ContentEncoding::Identity => {
119            trace!(
120                encoding = "identity",
121                size = original_size,
122                "No compression applied"
123            );
124            Ok(content.clone())
125        }
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn test_should_compress() {
135        assert!(should_compress("text/html"));
136        assert!(should_compress("text/css"));
137        assert!(should_compress("application/javascript"));
138        assert!(should_compress("application/json"));
139        assert!(should_compress("image/svg+xml"));
140        assert!(should_compress("application/wasm"));
141
142        assert!(!should_compress("image/png"));
143        assert!(!should_compress("image/jpeg"));
144        assert!(!should_compress("application/octet-stream"));
145    }
146
147    #[test]
148    fn test_content_encoding_as_str() {
149        assert_eq!(ContentEncoding::Identity.as_str(), "identity");
150        assert_eq!(ContentEncoding::Gzip.as_str(), "gzip");
151        assert_eq!(ContentEncoding::Brotli.as_str(), "br");
152    }
153
154    #[test]
155    fn test_compress_content_gzip() {
156        let content = Bytes::from("Hello, World!");
157        let compressed = compress_content(&content, ContentEncoding::Gzip).unwrap();
158
159        // Compressed content should be different (though might be larger for small inputs)
160        assert!(!compressed.is_empty());
161    }
162
163    #[test]
164    fn test_compress_content_identity() {
165        let content = Bytes::from("Hello, World!");
166        let result = compress_content(&content, ContentEncoding::Identity).unwrap();
167        assert_eq!(result, content);
168    }
169}