Skip to main content

web_static_pack_packer/
file.rs

1//! File helpers. Contains [build_from_path] and [build_from_content] functions
2//! to create a [File] from fs / memory content.
3
4use 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/// Options when preparing file in [build_from_path].
17///
18/// If not sure what to set here, use [Default].
19#[derive(Debug)]
20pub struct BuildFromPathOptions {
21    /// Try adding gzipped version of file. If set to true, it may still not be
22    /// added (ex. in case gzipped version is larger than raw).
23    pub use_gzip: bool,
24    /// Try adding brotli version of file. If set to true, it may still not be
25    /// added (ex. in case gzipped version is larger than raw).
26    pub use_brotli: bool,
27
28    /// Override `content-type` header for this file.
29    pub content_type_override: Option<String>,
30    /// Override [CacheControl] for this file.
31    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
44/// Creates a [File] by reading file from fs, specified by `path`.
45///
46/// Inside file will be read, `content-type` determined
47/// from extension and then passed to [build_from_content].
48///
49/// # Examples
50///
51/// ```
52/// # use anyhow::{anyhow, Error};
53/// # use std::path::PathBuf;
54/// # use web_static_pack_packer::file::{build_from_path, BuildFromPathOptions};
55/// #
56/// # fn main() -> Result<(), Error> {
57/// #
58/// let file = build_from_path(
59///     &PathBuf::from(env!("CARGO_MANIFEST_DIR"))
60///         .parent()
61///         .ok_or_else(|| anyhow!("missing parent"))?
62///         .join("tests")
63///         .join("data")
64///         .join("vcard-personal-portfolio")
65///         .join("index.html"),
66///     &BuildFromPathOptions::default(),
67/// )?;
68/// assert_eq!(file.content_type, "text/html; charset=utf-8");
69/// #
70/// # Ok(())
71/// # }
72/// ```
73pub fn build_from_path(
74    path: &Path,
75    options: &BuildFromPathOptions,
76) -> Result<File, Error> {
77    // read content
78    let content = content_from_path(path)?;
79
80    // use user provided content type if set, otherwise guess from path
81    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    // pass to inner builder
88    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/// Options when preparing file in [build_from_content].
102///
103/// If not sure what to set here, use [Default].
104#[derive(Debug)]
105pub struct BuildFromContentOptions {
106    /// Try adding gzipped version of content. If set to true, it may still not
107    /// be added (ex. in case gzipped version is larger than raw).
108    pub use_gzip: bool,
109    /// Try adding brotli version of content. If set to true, it may still not
110    /// be added (ex. in case gzipped version is larger than raw).
111    pub use_brotli: bool,
112
113    /// Override [CacheControl] for this file.
114    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
126/// Creates a [File] from provided raw content and `content-type`.
127///
128/// Inside compressed versions will be created (according to options), `ETag`
129/// calculated and [CacheControl] set.
130///
131/// When setting `content_type` remember to set charset for text files, eg.
132/// `text/plain; charset=utf-8`.
133///
134/// # Examples
135///
136/// ```
137/// # use anyhow::Error;
138/// # use std::path::PathBuf;
139/// # use web_static_pack_packer::file::{build_from_content, BuildFromContentOptions};
140/// #
141/// # fn main() -> Result<(), Error> {
142/// #
143/// let file = build_from_content(
144///     Box::new(*b"<html>Hello World!</html>"),
145///     "text/html; charset=utf-8".to_owned(),
146///     &BuildFromContentOptions::default(),
147/// );
148/// assert!(file.content_gzip.is_none()); // too short for gzip
149/// assert!(file.content_brotli.is_none()); // too short for gzip
150/// assert_eq!(&*file.content, b"<html>Hello World!</html>");
151/// assert_eq!(file.content_type, "text/html; charset=utf-8");
152/// #
153/// # Ok(())
154/// # }
155/// ```
156pub 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        // we assume, that content is "static" and provide max caching opportunity
177        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
190/// Builds content by reading given file.
191fn content_from_path(path: &Path) -> Result<Box<[u8]>, Error> {
192    let content = fs::read(path)?.into_boxed_slice();
193
194    Ok(content)
195}
196/// Builds gzip compressed version of `content`.
197///
198/// Returns [None] if there is no sense in having compressed version in `pack`
199/// (eg. compressed is larger than raw).
200fn content_gzip_from_content(content: &[u8]) -> Option<Box<[u8]>> {
201    // no sense in compressing empty files
202    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 gzip is longer then original value - it makes no sense to store it
211    if content_gzip.len() >= content.len() {
212        return None;
213    }
214
215    Some(content_gzip)
216}
217/// Builds brotli compressed version of `content`.
218///
219/// Returns [None] if there is no sense in having compressed version in `pack`
220/// (eg. compressed is larger than raw).
221fn content_brotli_from_content(content: &[u8]) -> Option<Box<[u8]>> {
222    // no sense in compressing empty files
223    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 brotli is longer then original value - it makes no sense to store it
239    if content_brotli.len() >= content.len() {
240        return None;
241    }
242
243    Some(content_brotli)
244}
245
246/// Guesses `content-type` from file path.
247///
248/// Only path is used, file content is not read. If file type cannot be guessed,
249/// returns "application/octet-stream". For text files (eg. plain, html, css,
250/// js, etc) it assumes utf-8 encoding.
251fn 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}
262/// Calculates `ETag` header from file contents.
263fn 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` as "quoted" hex sha3. Quote is required by standard
268    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            // implementation dependant
298            // etag,
299            // cache_control,
300            ..
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        // implementation dependant
308        // assert_eq!(etag, "");
309        // assert_eq!(cache_control, CacheControl::MaxCache);
310    }
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        // two identical payloads should produce identical `ETag`
337        // two different payloads should produce different `ETag`
338
339        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}