Skip to main content

static_serve_macro/
lib.rs

1//! Proc macro crate for compressing and embedding static assets
2//! in a web server
3
4use std::{
5    convert::Into,
6    fs,
7    io::{self, Write},
8    path::{Path, PathBuf},
9};
10
11use display_full_error::DisplayFullError;
12use flate2::write::GzEncoder;
13use glob::glob;
14use proc_macro2::{Span, TokenStream};
15use quote::{ToTokens, quote};
16use sha1::{Digest as _, Sha1};
17use syn::{
18    Ident, LitBool, LitByteStr, LitStr, Token, bracketed,
19    parse::{Parse, ParseStream},
20    parse_macro_input,
21};
22
23mod error;
24use error::{Error, GzipType, ZstdType};
25
26#[proc_macro]
27/// Embed and optionally compress static assets for a web server
28///
29/// ```compile_fail,hidden
30/// # // The corresponding successful test is in static-serve/tests/tests.rs,
31/// # // where tests usually belong. It's called serves_unknown_attributes.
32/// # // But only doctests support the `compile_fail` attribute so the failing
33/// # // test is placed here.
34/// embed_assets!(
35///     "../static-serve/test_unknown_extensions",
36///     allow_unknown_extensions = false
37/// );
38/// ```
39pub fn embed_assets(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
40    let parsed = parse_macro_input!(input as EmbedAssets);
41    quote! { #parsed }.into()
42}
43
44#[proc_macro]
45/// Embed and optionally compress a single static asset for a web server
46pub fn embed_asset(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
47    let parsed = parse_macro_input!(input as EmbedAsset);
48    quote! { #parsed }.into()
49}
50
51struct EmbedAsset {
52    asset_file: AssetFile,
53    should_compress: ShouldCompress,
54    cache_busted: IsCacheBusted,
55    allow_unknown_extensions: LitBool,
56}
57
58struct AssetFile(LitStr);
59
60impl Parse for EmbedAsset {
61    fn parse(input: ParseStream) -> syn::Result<Self> {
62        let asset_file: AssetFile = input.parse()?;
63
64        // Default to no compression, no cache-busting
65        let mut maybe_should_compress = None;
66        let mut maybe_is_cache_busted = None;
67        let mut maybe_allow_unknown_extensions = None;
68
69        while !input.is_empty() {
70            input.parse::<Token![,]>()?;
71            let key: Ident = input.parse()?;
72            input.parse::<Token![=]>()?;
73
74            match key.to_string().as_str() {
75                "compress" => {
76                    let value = input.parse()?;
77                    maybe_should_compress = Some(value);
78                }
79                "cache_bust" => {
80                    let value = input.parse()?;
81                    maybe_is_cache_busted = Some(value);
82                }
83                "allow_unknown_extensions" => {
84                    let value = input.parse()?;
85                    maybe_allow_unknown_extensions = Some(value);
86                }
87                _ => {
88                    return Err(syn::Error::new(
89                        key.span(),
90                        format!(
91                            "Unknown key in `embed_asset!` macro. Expected `compress`, `cache_bust`, or `allow_unknown_extensions` but got {key}"
92                        ),
93                    ));
94                }
95            }
96        }
97        let should_compress = maybe_should_compress.unwrap_or_else(|| {
98            ShouldCompress(LitBool {
99                value: false,
100                span: Span::call_site(),
101            })
102        });
103        let cache_busted = maybe_is_cache_busted.unwrap_or_else(|| {
104            IsCacheBusted(LitBool {
105                value: false,
106                span: Span::call_site(),
107            })
108        });
109        let allow_unknown_extensions = maybe_allow_unknown_extensions.unwrap_or(LitBool {
110            value: false,
111            span: Span::call_site(),
112        });
113
114        Ok(Self {
115            asset_file,
116            should_compress,
117            cache_busted,
118            allow_unknown_extensions,
119        })
120    }
121}
122
123impl Parse for AssetFile {
124    fn parse(input: ParseStream) -> syn::Result<Self> {
125        let input_span = input.span();
126        let asset_file: LitStr = input.parse()?;
127        let literal = asset_file.value();
128        let path = Path::new(&literal);
129        let metadata = match fs::metadata(path) {
130            Ok(meta) => meta,
131            Err(e) if matches!(e.kind(), std::io::ErrorKind::NotFound) => {
132                return Err(syn::Error::new(
133                    input_span,
134                    format!("The specified asset file ({literal}) does not exist."),
135                ));
136            }
137            Err(e) => {
138                return Err(syn::Error::new(
139                    input_span,
140                    format!("Error reading file {literal}: {}", DisplayFullError(&e)),
141                ));
142            }
143        };
144
145        if metadata.is_dir() {
146            return Err(syn::Error::new(
147                input_span,
148                "The specified asset is a directory, not a file. Did you mean to call `embed_assets!` instead?",
149            ));
150        }
151
152        Ok(AssetFile(asset_file))
153    }
154}
155
156impl ToTokens for EmbedAsset {
157    fn to_tokens(&self, tokens: &mut TokenStream) {
158        let AssetFile(asset_file) = &self.asset_file;
159        let ShouldCompress(should_compress) = &self.should_compress;
160        let IsCacheBusted(cache_busted) = &self.cache_busted;
161        let allow_unknown_extensions = &self.allow_unknown_extensions;
162
163        let result = generate_static_handler(
164            asset_file,
165            should_compress,
166            cache_busted,
167            allow_unknown_extensions,
168        );
169
170        match result {
171            Ok(value) => {
172                tokens.extend(quote! {
173                    #value
174                });
175            }
176            Err(err_message) => {
177                let error = syn::Error::new(Span::call_site(), err_message);
178                tokens.extend(error.to_compile_error());
179            }
180        }
181    }
182}
183
184struct EmbedAssets {
185    assets_dir: AssetsDir,
186    validated_ignore_paths: IgnorePaths,
187    should_compress: ShouldCompress,
188    should_strip_html_ext: ShouldStripHtmlExt,
189    cache_busted_paths: CacheBustedPaths,
190    allow_unknown_extensions: LitBool,
191}
192
193impl Parse for EmbedAssets {
194    fn parse(input: ParseStream) -> syn::Result<Self> {
195        let assets_dir: AssetsDir = input.parse()?;
196
197        // Default to no compression
198        let mut maybe_should_compress = None;
199        let mut maybe_ignore_paths = None;
200        let mut maybe_should_strip_html_ext = None;
201        let mut maybe_cache_busted_paths = None;
202        let mut maybe_allow_unknown_extensions = None;
203
204        while !input.is_empty() {
205            input.parse::<Token![,]>()?;
206            let key: Ident = input.parse()?;
207            input.parse::<Token![=]>()?;
208
209            match key.to_string().as_str() {
210                "compress" => {
211                    let value = input.parse()?;
212                    maybe_should_compress = Some(value);
213                }
214                "ignore_paths" => {
215                    let value = input.parse()?;
216                    maybe_ignore_paths = Some(value);
217                }
218                "strip_html_ext" => {
219                    let value = input.parse()?;
220                    maybe_should_strip_html_ext = Some(value);
221                }
222                "cache_busted_paths" => {
223                    let value = input.parse()?;
224                    maybe_cache_busted_paths = Some(value);
225                }
226                "allow_unknown_extensions" => {
227                    let value = input.parse()?;
228                    maybe_allow_unknown_extensions = Some(value);
229                }
230                _ => {
231                    return Err(syn::Error::new(
232                        key.span(),
233                        "Unknown key in embed_assets! macro. Expected `compress`, `ignore_paths`, `strip_html_ext`, `cache_busted_paths`, or `allow_unknown_extensions`",
234                    ));
235                }
236            }
237        }
238
239        let should_compress = maybe_should_compress.unwrap_or_else(|| {
240            ShouldCompress(LitBool {
241                value: false,
242                span: Span::call_site(),
243            })
244        });
245
246        let should_strip_html_ext = maybe_should_strip_html_ext.unwrap_or_else(|| {
247            ShouldStripHtmlExt(LitBool {
248                value: false,
249                span: Span::call_site(),
250            })
251        });
252
253        let ignore_paths_with_span = maybe_ignore_paths.unwrap_or(IgnorePathsWithSpan(vec![]));
254        let validated_ignore_paths = validate_ignore_paths(ignore_paths_with_span, &assets_dir.0)?;
255
256        let maybe_cache_busted_paths =
257            maybe_cache_busted_paths.unwrap_or(CacheBustedPathsWithSpan(vec![]));
258        let cache_busted_paths =
259            validate_cache_busted_paths(maybe_cache_busted_paths, &assets_dir.0)?;
260
261        let allow_unknown_extensions = maybe_allow_unknown_extensions.unwrap_or(LitBool {
262            value: false,
263            span: Span::call_site(),
264        });
265
266        Ok(Self {
267            assets_dir,
268            validated_ignore_paths,
269            should_compress,
270            should_strip_html_ext,
271            cache_busted_paths,
272            allow_unknown_extensions,
273        })
274    }
275}
276
277impl ToTokens for EmbedAssets {
278    fn to_tokens(&self, tokens: &mut TokenStream) {
279        let AssetsDir(assets_dir) = &self.assets_dir;
280        let ignore_paths = &self.validated_ignore_paths;
281        let ShouldCompress(should_compress) = &self.should_compress;
282        let ShouldStripHtmlExt(should_strip_html_ext) = &self.should_strip_html_ext;
283        let cache_busted_paths = &self.cache_busted_paths;
284        let allow_unknown_extensions = &self.allow_unknown_extensions;
285
286        let result = generate_static_routes(
287            assets_dir,
288            ignore_paths,
289            should_compress,
290            should_strip_html_ext,
291            cache_busted_paths,
292            allow_unknown_extensions.value,
293        );
294
295        match result {
296            Ok(value) => {
297                tokens.extend(quote! {
298                    #value
299                });
300            }
301            Err(err_message) => {
302                let error = syn::Error::new(Span::call_site(), err_message);
303                tokens.extend(error.to_compile_error());
304            }
305        }
306    }
307}
308
309struct AssetsDir(LitStr);
310
311impl Parse for AssetsDir {
312    fn parse(input: ParseStream) -> syn::Result<Self> {
313        let input_span = input.span();
314        let assets_dir: LitStr = input.parse()?;
315        let literal = assets_dir.value();
316        let path = Path::new(&literal);
317        let metadata = match fs::metadata(path) {
318            Ok(meta) => meta,
319            Err(e) if matches!(e.kind(), std::io::ErrorKind::NotFound) => {
320                return Err(syn::Error::new(
321                    input_span,
322                    "The specified assets directory does not exist",
323                ));
324            }
325            Err(e) => {
326                return Err(syn::Error::new(
327                    input_span,
328                    format!(
329                        "Error reading directory {literal}: {}",
330                        DisplayFullError(&e)
331                    ),
332                ));
333            }
334        };
335
336        if !metadata.is_dir() {
337            return Err(syn::Error::new(
338                input_span,
339                "The specified assets directory is not a directory",
340            ));
341        }
342
343        Ok(AssetsDir(assets_dir))
344    }
345}
346
347struct IgnorePaths(Vec<PathBuf>);
348
349struct IgnorePathsWithSpan(Vec<(PathBuf, Span)>);
350
351impl Parse for IgnorePathsWithSpan {
352    fn parse(input: ParseStream) -> syn::Result<Self> {
353        let dirs = parse_dirs(input)?;
354
355        Ok(IgnorePathsWithSpan(dirs))
356    }
357}
358
359fn validate_ignore_paths(
360    ignore_paths: IgnorePathsWithSpan,
361    assets_dir: &LitStr,
362) -> syn::Result<IgnorePaths> {
363    let mut valid_ignore_paths = Vec::new();
364    for (dir, span) in ignore_paths.0 {
365        let full_path = PathBuf::from(assets_dir.value()).join(&dir);
366        match fs::metadata(&full_path) {
367            Ok(_) => valid_ignore_paths.push(full_path),
368            Err(e) if matches!(e.kind(), std::io::ErrorKind::NotFound) => {
369                return Err(syn::Error::new(
370                    span,
371                    "The specified ignored path does not exist",
372                ));
373            }
374            Err(e) => {
375                return Err(syn::Error::new(
376                    span,
377                    format!(
378                        "Error reading ignored path {}: {}",
379                        dir.to_string_lossy(),
380                        DisplayFullError(&e)
381                    ),
382                ));
383            }
384        }
385    }
386    Ok(IgnorePaths(valid_ignore_paths))
387}
388
389struct ShouldCompress(LitBool);
390
391impl Parse for ShouldCompress {
392    fn parse(input: ParseStream) -> syn::Result<Self> {
393        let lit = input.parse()?;
394        Ok(ShouldCompress(lit))
395    }
396}
397
398struct ShouldStripHtmlExt(LitBool);
399
400impl Parse for ShouldStripHtmlExt {
401    fn parse(input: ParseStream) -> syn::Result<Self> {
402        let lit = input.parse()?;
403        Ok(ShouldStripHtmlExt(lit))
404    }
405}
406
407struct IsCacheBusted(LitBool);
408
409impl Parse for IsCacheBusted {
410    fn parse(input: ParseStream) -> syn::Result<Self> {
411        let lit = input.parse()?;
412        Ok(IsCacheBusted(lit))
413    }
414}
415
416struct CacheBustedPaths {
417    dirs: Vec<PathBuf>,
418    files: Vec<PathBuf>,
419}
420struct CacheBustedPathsWithSpan(Vec<(PathBuf, Span)>);
421
422impl Parse for CacheBustedPathsWithSpan {
423    fn parse(input: ParseStream) -> syn::Result<Self> {
424        let dirs = parse_dirs(input)?;
425        Ok(CacheBustedPathsWithSpan(dirs))
426    }
427}
428
429fn validate_cache_busted_paths(
430    tuples: CacheBustedPathsWithSpan,
431    assets_dir: &LitStr,
432) -> syn::Result<CacheBustedPaths> {
433    let mut valid_dirs = Vec::new();
434    let mut valid_files = Vec::new();
435    for (dir, span) in tuples.0 {
436        let full_path = PathBuf::from(assets_dir.value()).join(&dir);
437        match fs::metadata(&full_path) {
438            Ok(meta) => {
439                if meta.is_dir() {
440                    valid_dirs.push(full_path);
441                } else {
442                    valid_files.push(full_path);
443                }
444            }
445            Err(e) if matches!(e.kind(), std::io::ErrorKind::NotFound) => {
446                return Err(syn::Error::new(
447                    span,
448                    "The specified directory for cache busting does not exist",
449                ));
450            }
451            Err(e) => {
452                return Err(syn::Error::new(
453                    span,
454                    format!(
455                        "Error reading path {}: {}",
456                        dir.to_string_lossy(),
457                        DisplayFullError(&e)
458                    ),
459                ));
460            }
461        }
462    }
463    Ok(CacheBustedPaths {
464        dirs: valid_dirs,
465        files: valid_files,
466    })
467}
468
469/// Helper function for turning an array of strs representing paths into
470/// a `Vec` containing tuples of each `PathBuf` and its `Span` in the `ParseStream`
471fn parse_dirs(input: ParseStream) -> syn::Result<Vec<(PathBuf, Span)>> {
472    let inner_content;
473    bracketed!(inner_content in input);
474
475    let mut dirs = Vec::new();
476    while !inner_content.is_empty() {
477        let directory_span = inner_content.span();
478        let directory_str = inner_content.parse::<LitStr>()?;
479        let path = PathBuf::from(directory_str.value());
480        dirs.push((path, directory_span));
481
482        if !inner_content.is_empty() {
483            inner_content.parse::<Token![,]>()?;
484        }
485    }
486    Ok(dirs)
487}
488
489fn generate_static_routes(
490    assets_dir: &LitStr,
491    ignore_paths: &IgnorePaths,
492    should_compress: &LitBool,
493    should_strip_html_ext: &LitBool,
494    cache_busted_paths: &CacheBustedPaths,
495    allow_unknown_extensions: bool,
496) -> Result<TokenStream, error::Error> {
497    let assets_dir_abs = Path::new(&assets_dir.value())
498        .canonicalize()
499        .map_err(Error::CannotCanonicalizeDirectory)?;
500    let assets_dir_abs_str = assets_dir_abs
501        .to_str()
502        .ok_or(Error::InvalidUnicodeInDirectoryName)?;
503    let canon_ignore_paths = ignore_paths
504        .0
505        .iter()
506        .map(|d| {
507            d.canonicalize()
508                .map_err(Error::CannotCanonicalizeIgnorePath)
509        })
510        .collect::<Result<Vec<_>, _>>()?;
511    let canon_cache_busted_dirs = cache_busted_paths
512        .dirs
513        .iter()
514        .map(|d| {
515            d.canonicalize()
516                .map_err(Error::CannotCanonicalizeCacheBustedDir)
517        })
518        .collect::<Result<Vec<_>, _>>()?;
519    let canon_cache_busted_files = cache_busted_paths
520        .files
521        .iter()
522        .map(|file| file.canonicalize().map_err(Error::CannotCanonicalizeFile))
523        .collect::<Result<Vec<_>, _>>()?;
524
525    let mut routes = Vec::new();
526    for entry in glob(&format!("{assets_dir_abs_str}/**/*")).map_err(Error::Pattern)? {
527        let entry = entry.map_err(Error::Glob)?;
528        let metadata = entry.metadata().map_err(Error::CannotGetMetadata)?;
529        if metadata.is_dir() {
530            continue;
531        }
532
533        // Skip `entry`s which are located in ignored paths
534        if canon_ignore_paths
535            .iter()
536            .any(|ignore_path| entry.starts_with(ignore_path))
537        {
538            continue;
539        }
540
541        let mut is_entry_cache_busted = false;
542        if canon_cache_busted_dirs
543            .iter()
544            .any(|dir| entry.starts_with(dir))
545            || canon_cache_busted_files.contains(&entry)
546        {
547            is_entry_cache_busted = true;
548        }
549
550        let entry = entry
551            .canonicalize()
552            .map_err(Error::CannotCanonicalizeFile)?;
553        let entry_str = entry.to_str().ok_or(Error::FilePathIsNotUtf8)?;
554        let EmbeddedFileInfo {
555            entry_path,
556            content_type,
557            etag_str,
558            lit_byte_str_contents,
559            maybe_gzip,
560            maybe_zstd,
561            cache_busted,
562        } = EmbeddedFileInfo::from_path(
563            &entry,
564            Some(assets_dir_abs_str),
565            should_compress,
566            should_strip_html_ext,
567            is_entry_cache_busted,
568            allow_unknown_extensions,
569        )?;
570
571        routes.push(quote! {
572            router = ::static_serve::static_route(
573                router,
574                #entry_path,
575                #content_type,
576                #etag_str,
577                {
578                    // Poor man's `tracked_path`
579                    // https://github.com/rust-lang/rust/issues/99515
580                    const _: &[u8] = include_bytes!(#entry_str);
581                        #lit_byte_str_contents
582                },
583                #maybe_gzip,
584                #maybe_zstd,
585                #cache_busted
586            );
587        });
588    }
589
590    Ok(quote! {
591    pub fn static_router<S>() -> ::axum::Router<S>
592        where S: ::std::clone::Clone + ::std::marker::Send + ::std::marker::Sync + 'static {
593            let mut router = ::axum::Router::<S>::new();
594            #(#routes)*
595            router
596        }
597    })
598}
599
600fn generate_static_handler(
601    asset_file: &LitStr,
602    should_compress: &LitBool,
603    cache_busted: &LitBool,
604    allow_unknown_extensions: &LitBool,
605) -> Result<TokenStream, error::Error> {
606    let asset_file_abs = Path::new(&asset_file.value())
607        .canonicalize()
608        .map_err(Error::CannotCanonicalizeFile)?;
609    let asset_file_abs_str = asset_file_abs.to_str().ok_or(Error::FilePathIsNotUtf8)?;
610
611    let EmbeddedFileInfo {
612        entry_path: _,
613        content_type,
614        etag_str,
615        lit_byte_str_contents,
616        maybe_gzip,
617        maybe_zstd,
618        cache_busted,
619    } = EmbeddedFileInfo::from_path(
620        &asset_file_abs,
621        None,
622        should_compress,
623        &LitBool {
624            value: false,
625            span: Span::call_site(),
626        },
627        cache_busted.value(),
628        allow_unknown_extensions.value(),
629    )?;
630
631    let route = quote! {
632        ::static_serve::static_method_router(
633            #content_type,
634            #etag_str,
635            {
636                // Poor man's `tracked_path`
637                // https://github.com/rust-lang/rust/issues/99515
638                const _: &[u8] = include_bytes!(#asset_file_abs_str);
639                #lit_byte_str_contents
640            },
641            #maybe_gzip,
642            #maybe_zstd,
643            #cache_busted
644        )
645    };
646
647    Ok(route)
648}
649
650struct OptionBytesSlice(Option<LitByteStr>);
651impl ToTokens for OptionBytesSlice {
652    fn to_tokens(&self, tokens: &mut TokenStream) {
653        tokens.extend(if let Some(inner) = &self.0.as_ref() {
654            quote! { ::std::option::Option::Some(#inner) }
655        } else {
656            quote! { ::std::option::Option::None }
657        });
658    }
659}
660
661struct EmbeddedFileInfo<'a> {
662    /// When creating a `Router`, we need the API path/route to the
663    /// target file. If creating a `Handler`, this is not needed since
664    /// the router is responsible for the file's path on the server.
665    entry_path: Option<&'a str>,
666    content_type: String,
667    etag_str: String,
668    lit_byte_str_contents: LitByteStr,
669    maybe_gzip: OptionBytesSlice,
670    maybe_zstd: OptionBytesSlice,
671    cache_busted: bool,
672}
673
674impl<'a> EmbeddedFileInfo<'a> {
675    fn from_path(
676        pathbuf: &'a PathBuf,
677        assets_dir_abs_str: Option<&str>,
678        should_compress: &LitBool,
679        should_strip_html_ext: &LitBool,
680        cache_busted: bool,
681        allow_unknown_extensions: bool,
682    ) -> Result<Self, Error> {
683        let contents = fs::read(pathbuf).map_err(Error::CannotReadEntryContents)?;
684
685        // Optionally compress files
686        let (maybe_gzip, maybe_zstd) = if should_compress.value {
687            let gzip = gzip_compress(&contents)?;
688            let zstd = zstd_compress(&contents)?;
689            (gzip, zstd)
690        } else {
691            (None, None)
692        };
693
694        let content_type = file_content_type(pathbuf, allow_unknown_extensions)?;
695
696        // entry_path is only needed for the router (embed_assets!)
697        let entry_path = if let Some(dir) = assets_dir_abs_str {
698            if should_strip_html_ext.value && content_type == "text/html" {
699                Some(
700                    strip_html_ext(pathbuf)?
701                        .strip_prefix(dir)
702                        .unwrap_or_default(),
703                )
704            } else {
705                pathbuf
706                    .to_str()
707                    .ok_or(Error::InvalidUnicodeInEntryName)?
708                    .strip_prefix(dir)
709            }
710        } else {
711            None
712        };
713
714        let etag_str = etag(&contents);
715        let lit_byte_str_contents = LitByteStr::new(&contents, Span::call_site());
716        let maybe_gzip = OptionBytesSlice(maybe_gzip);
717        let maybe_zstd = OptionBytesSlice(maybe_zstd);
718
719        Ok(Self {
720            entry_path,
721            content_type,
722            etag_str,
723            lit_byte_str_contents,
724            maybe_gzip,
725            maybe_zstd,
726            cache_busted,
727        })
728    }
729}
730
731fn gzip_compress(contents: &[u8]) -> Result<Option<LitByteStr>, Error> {
732    let mut compressor = GzEncoder::new(Vec::new(), flate2::Compression::best());
733    compressor
734        .write_all(contents)
735        .map_err(|e| Error::Gzip(GzipType::CompressorWrite(e)))?;
736    let compressed = compressor
737        .finish()
738        .map_err(|e| Error::Gzip(GzipType::EncoderFinish(e)))?;
739
740    Ok(maybe_get_compressed(&compressed, contents))
741}
742
743fn zstd_compress(contents: &[u8]) -> Result<Option<LitByteStr>, Error> {
744    let level = *zstd::compression_level_range().end();
745    let mut encoder = zstd::Encoder::new(Vec::new(), level).unwrap();
746    write_to_zstd_encoder(&mut encoder, contents)
747        .map_err(|e| Error::Zstd(ZstdType::EncoderWrite(e)))?;
748
749    let compressed = encoder
750        .finish()
751        .map_err(|e| Error::Zstd(ZstdType::EncoderFinish(e)))?;
752
753    Ok(maybe_get_compressed(&compressed, contents))
754}
755
756fn write_to_zstd_encoder(
757    encoder: &mut zstd::Encoder<'static, Vec<u8>>,
758    contents: &[u8],
759) -> io::Result<()> {
760    encoder.set_pledged_src_size(Some(
761        contents
762            .len()
763            .try_into()
764            .expect("contents size should fit into u64"),
765    ))?;
766    encoder.window_log(23)?;
767    encoder.include_checksum(false)?;
768    encoder.include_contentsize(false)?;
769    encoder.long_distance_matching(false)?;
770    encoder.write_all(contents)?;
771
772    Ok(())
773}
774
775fn is_compression_significant(compressed_len: usize, contents_len: usize) -> bool {
776    let ninety_pct_original = contents_len / 10 * 9;
777    compressed_len < ninety_pct_original
778}
779
780fn maybe_get_compressed(compressed: &[u8], contents: &[u8]) -> Option<LitByteStr> {
781    is_compression_significant(compressed.len(), contents.len())
782        .then(|| LitByteStr::new(compressed, Span::call_site()))
783}
784
785/// Use `mime_guess` to get the best guess of the file's MIME type
786/// by looking at its extension, or return an error if unable.
787///
788/// If the `allow_unknown_extensions` parameter is true, an unknown ext
789/// will not produce an error, but application/octet-stream.
790///
791/// We accept the first guess because [`mime_guess` updates the order
792/// according to the latest IETF RTC](https://docs.rs/mime_guess/2.0.5/mime_guess/struct.MimeGuess.html#note-ordering)
793fn file_content_type(path: &Path, allow_unknown_extensions: bool) -> Result<String, error::Error> {
794    let ext = path.extension().ok_or(if allow_unknown_extensions {
795        return Ok(mime_guess::mime::APPLICATION_OCTET_STREAM.to_string());
796    } else {
797        error::Error::UnknownFileExtension(None)
798    })?;
799
800    let ext = ext
801        .to_str()
802        .ok_or(error::Error::InvalidFileExtension(path.into()))?;
803
804    let guess = mime_guess::MimeGuess::from_ext(ext);
805
806    if allow_unknown_extensions {
807        return Ok(guess.first_or_octet_stream().to_string());
808    }
809
810    guess
811        .first_raw()
812        .map(ToOwned::to_owned)
813        .ok_or(error::Error::UnknownFileExtension(Some(ext.into())))
814}
815
816fn etag(contents: &[u8]) -> String {
817    let sha256 = Sha1::digest(contents);
818    let hash = u64::from_le_bytes(sha256[..8].try_into().unwrap())
819        ^ u64::from_le_bytes(sha256[8..16].try_into().unwrap());
820    format!("\"{hash:016x}\"")
821}
822
823fn strip_html_ext(entry: &Path) -> Result<&str, Error> {
824    let entry_str = entry.to_str().ok_or(Error::InvalidUnicodeInEntryName)?;
825    let mut output = entry_str;
826
827    // Strip the extension
828    if let Some(prefix) = output.strip_suffix(".html") {
829        output = prefix;
830    } else if let Some(prefix) = output.strip_suffix(".htm") {
831        output = prefix;
832    }
833
834    // If it was `/index.html` or `/index.htm`, also remove `index`
835    if output.ends_with("/index") {
836        output = output.strip_suffix("index").unwrap_or("/");
837    }
838
839    Ok(output)
840}