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