rust_web_server/compression/
mod.rs1#[cfg(test)]
2mod tests;
3
4use flate2::Compression;
5use flate2::write::GzEncoder;
6use std::io::Write;
7use crate::header::Header;
8use crate::request::Request;
9use crate::response::Response;
10
11const COMPRESSIBLE: &[&str] = &[
13 "text/html",
14 "text/css",
15 "text/javascript",
16 "text/plain",
17 "text/xml",
18 "application/json",
19 "application/javascript",
20 "application/xml",
21 "application/xhtml+xml",
22 "image/svg+xml",
23];
24
25pub const MIN_COMPRESS_SIZE: usize = 512;
29
30pub fn apply_gzip(request: &Request, response: &mut Response) {
37 compress_with(request, response, &[Encoding::Gzip]);
38}
39
40pub fn apply_compression(request: &Request, response: &mut Response) {
44 compress_with(request, response, DYNAMIC_PREFERENCE);
45}
46
47fn compress_with(request: &Request, response: &mut Response, preference: &[Encoding]) {
48 if response.content_range_list.is_empty() {
49 return;
50 }
51 if response.status_code == 206 || response.content_range_list.len() > 1 {
55 return;
56 }
57 if response.headers.iter().any(|h| h.name.eq_ignore_ascii_case(Header::_CONTENT_ENCODING)) {
58 return;
59 }
60 let original_size: usize = response.content_range_list.iter().map(|cr| cr.body.len()).sum();
61 if original_size < MIN_COMPRESS_SIZE {
62 return;
63 }
64 let Some(encoding) = negotiate(request, preference) else { return };
65 let content_type = response
66 .content_range_list
67 .first()
68 .map(|cr| cr.content_type.as_str())
69 .unwrap_or_default();
70 if !is_compressible(content_type) {
71 return;
72 }
73
74 let mut compressed_bodies = Vec::with_capacity(response.content_range_list.len());
75 for cr in &response.content_range_list {
76 match encode(encoding, &cr.body, Level::Dynamic) {
77 Some(compressed) => compressed_bodies.push(compressed),
78 None => return,
79 }
80 }
81 let compressed_size: usize = compressed_bodies.iter().map(Vec::len).sum();
82 if compressed_size >= original_size {
83 return;
84 }
85 for (cr, compressed) in response.content_range_list.iter_mut().zip(compressed_bodies) {
86 let new_len = compressed.len() as u64;
87 cr.body = compressed;
88 cr.range.end = new_len;
89 cr.size = new_len.to_string();
90 }
91 mark_encoded(response, encoding);
92 tag_etag(response, encoding);
93}
94
95fn tag_etag(response: &mut Response, encoding: Encoding) {
98 for header in response.headers.iter_mut().filter(|h| h.name.eq_ignore_ascii_case(Header::_ETAG)) {
99 let value = header.value.trim();
100 if let Some(quoted) = value.strip_suffix('"') {
101 let mut tagged = String::with_capacity(value.len() + 6);
102 tagged.push_str(quoted);
103 tagged.push('-');
104 tagged.push_str(encoding.token());
105 tagged.push('"');
106 header.value = tagged.into();
107 }
108 }
109}
110
111#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
114pub enum Encoding {
115 Gzip,
116 Brotli,
117 Zstd,
118}
119
120impl Encoding {
121 pub fn token(self) -> &'static str {
123 match self {
124 Encoding::Gzip => "gzip",
125 Encoding::Brotli => "br",
126 Encoding::Zstd => "zstd",
127 }
128 }
129
130 pub fn available(self) -> bool {
132 match self {
133 Encoding::Gzip => true,
134 Encoding::Brotli => cfg!(feature = "brotli"),
135 Encoding::Zstd => cfg!(feature = "zstd"),
136 }
137 }
138}
139
140pub const DYNAMIC_PREFERENCE: &[Encoding] = &[Encoding::Zstd, Encoding::Brotli, Encoding::Gzip];
144
145pub const STATIC_PREFERENCE: &[Encoding] = &[Encoding::Brotli, Encoding::Zstd, Encoding::Gzip];
148
149#[derive(Clone, Copy, Debug, PartialEq, Eq)]
151pub enum Level {
152 Dynamic,
156 Static,
159}
160
161pub const BROTLI_QUALITY: [u32; 2] = [4, 6];
163pub const ZSTD_LEVEL: [i32; 2] = [3, 6];
165
166impl Level {
167 fn index(self) -> usize {
168 match self {
169 Level::Dynamic => 0,
170 Level::Static => 1,
171 }
172 }
173}
174
175pub fn negotiate(request: &Request, preference: &[Encoding]) -> Option<Encoding> {
180 let header = request.header(Header::_ACCEPT_ENCODING)?;
181 let mut best: Option<(Encoding, f32)> = None;
182 for &encoding in preference.iter().filter(|e| e.available()) {
183 let q = accepted_weight(&header.value, encoding.token());
184 if q > 0.0 && best.is_none_or(|(_, best_q)| q > best_q) {
185 best = Some((encoding, q));
186 }
187 }
188 best.map(|(encoding, _)| encoding)
189}
190
191fn accepted_weight(value: &str, token: &str) -> f32 {
194 let mut wildcard = None;
195 for coding in value.split(',') {
196 let mut parts = coding.split(';');
197 let name = parts.next().unwrap_or("").trim();
198 let q = parts
199 .find_map(|p| {
200 let p = p.trim();
201 p.as_bytes().get(..2).is_some_and(|b| b.eq_ignore_ascii_case(b"q=")).then(|| p[2..].trim().parse::<f32>().ok())
204 })
205 .flatten()
206 .filter(|q| (0.0..=1.0).contains(q))
207 .unwrap_or(1.0);
208 if name.eq_ignore_ascii_case(token) {
209 return q;
210 }
211 if name == "*" {
212 wildcard = Some(q);
213 }
214 }
215 wildcard.unwrap_or(0.0)
216}
217
218pub fn encode(encoding: Encoding, body: &[u8], level: Level) -> Option<Vec<u8>> {
221 match encoding {
222 Encoding::Gzip => gzip(body),
223 #[cfg(feature = "brotli")]
224 Encoding::Brotli => {
225 let mut out = Vec::with_capacity(body.len() / 3 + 64);
226 {
227 let mut writer = brotli::CompressorWriter::new(&mut out, 4096, BROTLI_QUALITY[level.index()], 22);
228 writer.write_all(body).ok()?;
229 writer.flush().ok()?;
230 }
231 Some(out)
232 }
233 #[cfg(feature = "zstd")]
234 Encoding::Zstd => zstd::bulk::compress(body, ZSTD_LEVEL[level.index()]).ok(),
235 #[allow(unreachable_patterns)]
236 _ => {
237 let _ = level;
238 None
239 }
240 }
241}
242
243pub fn mark_encoded(response: &mut Response, encoding: Encoding) {
246 response.headers.push(Header {
247 name: Header::_CONTENT_ENCODING.into(),
248 value: encoding.token().into(),
249 });
250 add_vary_accept_encoding(response);
251}
252
253pub(crate) fn is_compressible(content_type: &str) -> bool {
256 COMPRESSIBLE.iter().any(|mime| {
257 content_type.len() >= mime.len() && content_type.as_bytes()[..mime.len()].eq_ignore_ascii_case(mime.as_bytes())
258 })
259}
260
261pub(crate) fn gzip(body: &[u8]) -> Option<Vec<u8>> {
263 let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
264 encoder.write_all(body).ok()?;
265 encoder.finish().ok()
266}
267
268fn add_vary_accept_encoding(response: &mut Response) {
269 let vary_pos = response.headers.iter().position(|h| h.name == Header::_VARY);
270 if let Some(i) = vary_pos {
271 let current = response.headers[i].value.clone();
272 if !current.to_lowercase().contains("accept-encoding") {
273 response.headers[i].value = format!("{}, Accept-Encoding", current).into();
274 }
275 } else {
276 response.headers.push(Header {
277 name: Header::_VARY.into(),
278 value: "Accept-Encoding".into(),
279 });
280 }
281}