web_static_pack_packer/
file.rs1use crate::common::{cache_control::CacheControl, file::File};
5use anyhow::Error;
6use base16ct::HexDisplay;
7use brotli::enc::BrotliEncoderParams;
8use flate2::{Compression, write::GzEncoder};
9use sha3::{Digest, Sha3_256};
10use std::{
11 fs,
12 io::{Cursor, Write},
13 path::Path,
14};
15
16#[derive(Debug)]
20pub struct BuildFromPathOptions {
21 pub use_gzip: bool,
24 pub use_brotli: bool,
27
28 pub content_type_override: Option<String>,
30 pub cache_control_override: Option<CacheControl>,
32}
33impl Default for BuildFromPathOptions {
34 fn default() -> Self {
35 Self {
36 use_gzip: true,
37 use_brotli: true,
38 content_type_override: None,
39 cache_control_override: None,
40 }
41 }
42}
43
44pub fn build_from_path(
74 path: &Path,
75 options: &BuildFromPathOptions,
76) -> Result<File, Error> {
77 let content = content_from_path(path)?;
79
80 let content_type = if let Some(content_type) = &options.content_type_override {
82 content_type.clone()
83 } else {
84 content_type_from_path(path)
85 };
86
87 let file = build_from_content(
89 content,
90 content_type,
91 &BuildFromContentOptions {
92 use_gzip: options.use_gzip,
93 use_brotli: options.use_brotli,
94 cache_control_override: options.cache_control_override,
95 },
96 );
97
98 Ok(file)
99}
100
101#[derive(Debug)]
105pub struct BuildFromContentOptions {
106 pub use_gzip: bool,
109 pub use_brotli: bool,
112
113 pub cache_control_override: Option<CacheControl>,
115}
116impl Default for BuildFromContentOptions {
117 fn default() -> Self {
118 Self {
119 use_gzip: true,
120 use_brotli: true,
121 cache_control_override: None,
122 }
123 }
124}
125
126pub fn build_from_content(
157 content: Box<[u8]>,
158 content_type: String,
159 options: &BuildFromContentOptions,
160) -> File {
161 let content_gzip = if options.use_gzip {
162 content_gzip_from_content(&content)
163 } else {
164 None
165 };
166 let content_brotli = if options.use_brotli {
167 content_brotli_from_content(&content)
168 } else {
169 None
170 };
171
172 let etag = etag_from_content(&content);
173 let cache_control = if let Some(cache_control) = &options.cache_control_override {
174 *cache_control
175 } else {
176 CacheControl::MaxCache
178 };
179
180 File {
181 content,
182 content_gzip,
183 content_brotli,
184 content_type,
185 etag,
186 cache_control,
187 }
188}
189
190fn content_from_path(path: &Path) -> Result<Box<[u8]>, Error> {
192 let content = fs::read(path)?.into_boxed_slice();
193
194 Ok(content)
195}
196fn content_gzip_from_content(content: &[u8]) -> Option<Box<[u8]>> {
201 if content.is_empty() {
203 return None;
204 }
205
206 let mut content_gzip = GzEncoder::new(Vec::new(), Compression::best());
207 content_gzip.write_all(content).unwrap();
208 let content_gzip = content_gzip.finish().unwrap().into_boxed_slice();
209
210 if content_gzip.len() >= content.len() {
212 return None;
213 }
214
215 Some(content_gzip)
216}
217fn content_brotli_from_content(content: &[u8]) -> Option<Box<[u8]>> {
222 if content.is_empty() {
224 return None;
225 }
226
227 let mut content_cursor = Cursor::new(content);
228 let mut content_brotli = Vec::new();
229 let content_brotli_length = brotli::BrotliCompress(
230 &mut content_cursor,
231 &mut content_brotli,
232 &BrotliEncoderParams::default(),
233 )
234 .unwrap();
235 let content_brotli = content_brotli.into_boxed_slice();
236 assert!(content_brotli.len() == content_brotli_length);
237
238 if content_brotli.len() >= content.len() {
240 return None;
241 }
242
243 Some(content_brotli)
244}
245
246fn content_type_from_path(path: &Path) -> String {
252 let mut content_type = mime_guess::from_path(path)
253 .first_or_octet_stream()
254 .as_ref()
255 .to_owned();
256
257 if content_type.starts_with("text/") {
258 content_type.push_str("; charset=utf-8");
259 }
260 content_type
261}
262fn etag_from_content(content: &[u8]) -> String {
264 let mut etag = Sha3_256::new();
265 etag.update(content);
266 let etag = etag.finalize();
267 let etag = format!("\"{:x}\"", HexDisplay(etag.as_slice())); etag
269}
270
271#[cfg(test)]
272mod test {
273 use super::{
274 BuildFromContentOptions, build_from_content, content_brotli_from_content,
275 content_gzip_from_content, content_type_from_path, etag_from_content,
276 };
277 use crate::common::file::File;
278 use std::path::{Path, PathBuf};
279 use test_case::test_case;
280
281 #[test]
282 fn build_from_content_returns_expected() {
283 let content_original = b"lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum";
284 let content_type_original = "text/plain; charset=utf-8";
285
286 let file = build_from_content(
287 Box::new(*content_original),
288 content_type_original.to_owned(),
289 &BuildFromContentOptions::default(),
290 );
291
292 let File {
293 content,
294 content_gzip,
295 content_brotli,
296 content_type,
297 ..
301 } = file;
302 assert_eq!(&*content, content_original);
303 assert_eq!(&*content_gzip.unwrap(), b"\x1f\x8b\x08\x00\x00\x00\x00\x00\x02\xff\x95\xc6\x41\x09\x00\x00\x08\x03\xc0\x2a\x2b\xe7\x43\xd8\x50\x14\xfb\x9b\x61\xbf\x63\x4d\x08\xd9\x7b\x02\x3d\x3f\x1e\x08\x7c\xb8\x3b\x00\x00\x00");
304 assert_eq!(&*content_brotli.unwrap(), b"\x1b\x3a\x00\xf8\x1d\xa9\x53\x9f\xbb\x70\x9d\xc6\xf6\x06\xa7\xda\xe4\x1a\xa4\x6c\xae\x4e\x18\x15\x0b\x98\x56\x70\x03");
305 assert_eq!(content_type, content_type_original);
306
307 }
311
312 #[test]
313 fn empty_should_not_be_compressed() {
314 assert!(content_gzip_from_content(&[]).is_none());
315 assert!(content_brotli_from_content(&[]).is_none());
316 }
317
318 #[test]
319 fn content_gzip_from_content_returns_expected() {
320 assert_eq!(
321 content_gzip_from_content(b"lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum").as_deref(),
322 Some(b"\x1f\x8b\x08\x00\x00\x00\x00\x00\x02\xff\x95\xc6\x41\x09\x00\x00\x08\x03\xc0\x2a\x2b\xe7\x43\xd8\x50\x14\xfb\x9b\x61\xbf\x63\x4d\x08\xd9\x7b\x02\x3d\x3f\x1e\x08\x7c\xb8\x3b\x00\x00\x00".as_slice())
323 );
324 }
325
326 #[test]
327 fn content_brotli_from_content_returns_expected() {
328 assert_eq!(
329 content_brotli_from_content(b"lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum").as_deref(),
330 Some(b"\x1b\x3a\x00\xf8\x1d\xa9\x53\x9f\xbb\x70\x9d\xc6\xf6\x06\xa7\xda\xe4\x1a\xa4\x6c\xae\x4e\x18\x15\x0b\x98\x56\x70\x03".as_slice())
331 );
332 }
333
334 #[test]
335 fn etag_from_content_returns_expected() {
336 assert_eq!(
340 etag_from_content(b"lorem ipsum"),
341 etag_from_content(b"lorem ipsum")
342 );
343 assert_ne!(
344 etag_from_content(b"lorem ipsum"),
345 etag_from_content(b"ipsum lorem")
346 );
347 }
348
349 #[test_case(
350 &PathBuf::from("a.html"),
351 "text/html; charset=utf-8";
352 "html file"
353 )]
354 #[test_case(
355 &PathBuf::from("directory/styles.css"),
356 "text/css; charset=utf-8";
357 "css file in directory"
358 )]
359 #[test_case(
360 &PathBuf::from("/root/dir/script.00ff00.js"),
361 "text/javascript; charset=utf-8";
362 "js file, full path, with some hex in stem"
363 )]
364 #[test_case(
365 &PathBuf::from("C:\\Users\\example\\Images\\SomeImage.webp"),
366 "image/webp";
367 "webp image in windows style path format"
368 )]
369 fn content_type_from_path_returns_expected(
370 path: &Path,
371 expected: &str,
372 ) {
373 assert_eq!(content_type_from_path(path), expected);
374 }
375}