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