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