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
490#[expect(clippy::too_many_lines)]
491fn generate_static_routes(
492    assets_dir: &LitStr,
493    ignore_paths: &IgnorePaths,
494    should_compress: &LitBool,
495    should_strip_html_ext: &LitBool,
496    cache_busted_paths: &CacheBustedPaths,
497    allow_unknown_extensions: bool,
498) -> Result<TokenStream, error::Error> {
499    let assets_dir_abs = Path::new(&assets_dir.value())
500        .canonicalize()
501        .map_err(|error| Error::CannotCanonicalizeDirectory {
502            dir: assets_dir.value(),
503            error,
504        })?;
505    let assets_dir_abs_str = assets_dir_abs
506        .to_str()
507        .ok_or_else(|| Error::InvalidUnicodeInDirectoryName(assets_dir_abs.clone()))?;
508    let canon_ignore_paths = ignore_paths
509        .0
510        .iter()
511        .map(|d| {
512            d.canonicalize()
513                .map_err(|error| Error::CannotCanonicalizeIgnorePath {
514                    path: d.clone(),
515                    error,
516                })
517        })
518        .collect::<Result<Vec<_>, _>>()?;
519    let canon_cache_busted_dirs = cache_busted_paths
520        .dirs
521        .iter()
522        .map(|d| {
523            d.canonicalize()
524                .map_err(|error| Error::CannotCanonicalizeCacheBustedDir {
525                    dir: d.clone(),
526                    error,
527                })
528        })
529        .collect::<Result<Vec<_>, _>>()?;
530    let canon_cache_busted_files = cache_busted_paths
531        .files
532        .iter()
533        .map(|file| {
534            file.canonicalize()
535                .map_err(|error| Error::CannotCanonicalizeFile {
536                    entry: file.clone(),
537                    error,
538                })
539        })
540        .collect::<Result<Vec<_>, _>>()?;
541
542    let mut routes = Vec::new();
543    let mut seen_web_paths = HashMap::new();
544    for entry in glob(&format!("{assets_dir_abs_str}/**/*")).map_err(Error::Pattern)? {
545        let entry = entry.map_err(Error::Glob)?;
546        let metadata = entry.metadata().map_err(|error| Error::CannotGetMetadata {
547            entry: entry.clone(),
548            error,
549        })?;
550        if metadata.is_dir() {
551            continue;
552        }
553
554        // Skip `entry`s which are located in ignored paths
555        if canon_ignore_paths
556            .iter()
557            .any(|ignore_path| entry.starts_with(ignore_path))
558        {
559            continue;
560        }
561
562        let mut is_entry_cache_busted = false;
563        if canon_cache_busted_dirs
564            .iter()
565            .any(|dir| entry.starts_with(dir))
566            || canon_cache_busted_files.contains(&entry)
567        {
568            is_entry_cache_busted = true;
569        }
570
571        let entry = entry
572            .canonicalize()
573            .map_err(|error| Error::CannotCanonicalizeFile { entry, error })?;
574        let entry_str = entry
575            .to_str()
576            .ok_or_else(|| Error::FilePathIsNotUtf8(entry.clone()))?;
577        let EmbeddedFileInfo {
578            entry_path,
579            content_type,
580            etag_str,
581            lit_byte_str_contents,
582            maybe_gzip,
583            maybe_zstd,
584            cache_busted,
585        } = EmbeddedFileInfo::from_path(
586            &entry,
587            Some(assets_dir_abs_str),
588            should_compress,
589            should_strip_html_ext,
590            is_entry_cache_busted,
591            allow_unknown_extensions,
592        )?;
593
594        if let Some(web_path) = &entry_path {
595            check_duplicate_web_path(&mut seen_web_paths, web_path, entry_str)?;
596        }
597
598        routes.push(quote! {
599            router = ::static_serve::static_route(
600                router,
601                #entry_path,
602                #content_type,
603                #etag_str,
604                {
605                    // Poor man's `tracked_path`
606                    // https://github.com/rust-lang/rust/issues/99515
607                    const _: &[u8] = include_bytes!(#entry_str);
608                        #lit_byte_str_contents
609                },
610                #maybe_gzip,
611                #maybe_zstd,
612                #cache_busted
613            );
614        });
615    }
616
617    Ok(quote! {
618    pub fn static_router<S>() -> ::axum::Router<S>
619        where S: ::std::clone::Clone + ::std::marker::Send + ::std::marker::Sync + 'static {
620            let mut router = ::axum::Router::<S>::new();
621            #(#routes)*
622            router
623        }
624    })
625}
626
627/// Record the web path claimed by `entry_str`, erroring if another file
628/// already claimed it.
629///
630/// Registering the same path twice would panic at runtime when the `Router`
631/// is built. This can happen with `strip_html_ext = true`, where files like
632/// `foo.html` and `foo.htm` both map to `/foo`.
633fn check_duplicate_web_path(
634    seen_web_paths: &mut HashMap<String, String>,
635    web_path: &str,
636    entry_str: &str,
637) -> Result<(), Error> {
638    match seen_web_paths.insert(web_path.to_owned(), entry_str.to_owned()) {
639        Some(first_file) => Err(Error::DuplicateWebPath {
640            web_path: web_path.to_owned(),
641            first_file,
642            second_file: entry_str.to_owned(),
643        }),
644        None => Ok(()),
645    }
646}
647
648fn generate_static_handler(
649    asset_file: &LitStr,
650    should_compress: &LitBool,
651    cache_busted: &LitBool,
652    allow_unknown_extensions: &LitBool,
653) -> Result<TokenStream, error::Error> {
654    let asset_file_abs = Path::new(&asset_file.value())
655        .canonicalize()
656        .map_err(|error| Error::CannotCanonicalizeFile {
657            entry: Path::new(&asset_file.value()).to_path_buf(),
658            error,
659        })?;
660    let asset_file_abs_str = asset_file_abs
661        .to_str()
662        .ok_or_else(|| Error::FilePathIsNotUtf8(asset_file_abs.clone()))?;
663
664    let EmbeddedFileInfo {
665        entry_path: _,
666        content_type,
667        etag_str,
668        lit_byte_str_contents,
669        maybe_gzip,
670        maybe_zstd,
671        cache_busted,
672    } = EmbeddedFileInfo::from_path(
673        &asset_file_abs,
674        None,
675        should_compress,
676        &LitBool {
677            value: false,
678            span: Span::call_site(),
679        },
680        cache_busted.value(),
681        allow_unknown_extensions.value(),
682    )?;
683
684    let route = quote! {
685        ::static_serve::static_method_router(
686            #content_type,
687            #etag_str,
688            {
689                // Poor man's `tracked_path`
690                // https://github.com/rust-lang/rust/issues/99515
691                const _: &[u8] = include_bytes!(#asset_file_abs_str);
692                #lit_byte_str_contents
693            },
694            #maybe_gzip,
695            #maybe_zstd,
696            #cache_busted
697        )
698    };
699
700    Ok(route)
701}
702
703struct OptionBytesSlice(Option<LitByteStr>);
704impl ToTokens for OptionBytesSlice {
705    fn to_tokens(&self, tokens: &mut TokenStream) {
706        tokens.extend(if let Some(inner) = &self.0.as_ref() {
707            quote! { ::std::option::Option::Some(#inner) }
708        } else {
709            quote! { ::std::option::Option::None }
710        });
711    }
712}
713
714struct EmbeddedFileInfo {
715    /// When creating a `Router`, we need the API path/route to the
716    /// target file. If creating a `Handler`, this is not needed since
717    /// the router is responsible for the file's path on the server.
718    entry_path: Option<String>,
719    content_type: String,
720    etag_str: String,
721    lit_byte_str_contents: LitByteStr,
722    maybe_gzip: OptionBytesSlice,
723    maybe_zstd: OptionBytesSlice,
724    cache_busted: bool,
725}
726
727impl EmbeddedFileInfo {
728    fn from_path(
729        pathbuf: &PathBuf,
730        assets_dir_abs_str: Option<&str>,
731        should_compress: &LitBool,
732        should_strip_html_ext: &LitBool,
733        cache_busted: bool,
734        allow_unknown_extensions: bool,
735    ) -> Result<Self, Error> {
736        let contents = fs::read(pathbuf).map_err(|error| Error::CannotReadEntryContents {
737            entry: pathbuf.clone(),
738            error,
739        })?;
740
741        // Optionally compress files
742        let (maybe_gzip, maybe_zstd) = if should_compress.value {
743            let gzip = gzip_compress(&contents, pathbuf)?;
744            let zstd = zstd_compress(&contents, pathbuf)?;
745            (gzip, zstd)
746        } else {
747            (None, None)
748        };
749
750        let content_type = file_content_type(pathbuf, allow_unknown_extensions)?;
751
752        // entry_path is only needed for the router (embed_assets!)
753        let entry_path = if let Some(dir) = assets_dir_abs_str {
754            let relative_entry = pathdiff::diff_paths(pathbuf, dir)
755                .ok_or_else(|| Error::CannotMakeFileRelative(pathbuf.clone()))?;
756            let mut web_path = normalize_web_path(&relative_entry);
757            if should_strip_html_ext.value && content_type == "text/html" {
758                strip_html_ext(&mut web_path);
759            }
760
761            Some(web_path)
762        } else {
763            None
764        };
765
766        let etag_str = etag(&contents);
767        let lit_byte_str_contents = LitByteStr::new(&contents, Span::call_site());
768        let maybe_gzip = OptionBytesSlice(maybe_gzip);
769        let maybe_zstd = OptionBytesSlice(maybe_zstd);
770
771        Ok(Self {
772            entry_path,
773            content_type,
774            etag_str,
775            lit_byte_str_contents,
776            maybe_gzip,
777            maybe_zstd,
778            cache_busted,
779        })
780    }
781}
782
783fn gzip_compress(contents: &[u8], entry: &Path) -> Result<Option<LitByteStr>, Error> {
784    let mut compressor = GzEncoder::new(Vec::new(), flate2::Compression::best());
785    compressor.write_all(contents).map_err(|e| Error::Gzip {
786        entry: entry.to_path_buf(),
787        error: GzipType::CompressorWrite(e),
788    })?;
789    let compressed = compressor.finish().map_err(|e| Error::Gzip {
790        entry: entry.to_path_buf(),
791        error: GzipType::EncoderFinish(e),
792    })?;
793
794    Ok(maybe_get_compressed(&compressed, contents))
795}
796
797fn zstd_compress(contents: &[u8], entry: &Path) -> Result<Option<LitByteStr>, Error> {
798    let level = *zstd::compression_level_range().end();
799    let mut encoder = zstd::Encoder::new(Vec::new(), level).unwrap();
800    write_to_zstd_encoder(&mut encoder, contents).map_err(|e| Error::Zstd {
801        entry: entry.to_path_buf(),
802        error: ZstdType::EncoderWrite(e),
803    })?;
804
805    let compressed = encoder.finish().map_err(|e| Error::Zstd {
806        entry: entry.to_path_buf(),
807        error: ZstdType::EncoderFinish(e),
808    })?;
809
810    Ok(maybe_get_compressed(&compressed, contents))
811}
812
813fn write_to_zstd_encoder(
814    encoder: &mut zstd::Encoder<'static, Vec<u8>>,
815    contents: &[u8],
816) -> io::Result<()> {
817    encoder.set_pledged_src_size(Some(
818        contents
819            .len()
820            .try_into()
821            .expect("contents size should fit into u64"),
822    ))?;
823    encoder.window_log(23)?;
824    encoder.include_checksum(false)?;
825    encoder.include_contentsize(false)?;
826    encoder.long_distance_matching(false)?;
827    encoder.write_all(contents)?;
828
829    Ok(())
830}
831
832fn is_compression_significant(compressed_len: usize, contents_len: usize) -> bool {
833    // `compressed_len < contents_len * 0.9`, computed exactly in integers
834    compressed_len * 10 < contents_len * 9
835}
836
837fn maybe_get_compressed(compressed: &[u8], contents: &[u8]) -> Option<LitByteStr> {
838    is_compression_significant(compressed.len(), contents.len())
839        .then(|| LitByteStr::new(compressed, Span::call_site()))
840}
841
842/// Use `mime_guess` to get the best guess of the file's MIME type
843/// by looking at its extension, or return an error if unable.
844///
845/// If the `allow_unknown_extensions` parameter is true, an unknown ext
846/// will not produce an error, but application/octet-stream.
847///
848/// We accept the first guess because [`mime_guess` updates the order
849/// according to the latest IETF RTC](https://docs.rs/mime_guess/2.0.5/mime_guess/struct.MimeGuess.html#note-ordering)
850fn file_content_type(path: &Path, allow_unknown_extensions: bool) -> Result<String, error::Error> {
851    let Some(ext) = path.extension() else {
852        return if allow_unknown_extensions {
853            Ok(mime_guess::mime::APPLICATION_OCTET_STREAM.to_string())
854        } else {
855            Err(error::Error::UnknownFileExtension(None))
856        };
857    };
858
859    let ext = ext
860        .to_str()
861        .ok_or(error::Error::InvalidFileExtension(path.into()))?;
862
863    let guess = mime_guess::MimeGuess::from_ext(ext);
864
865    if allow_unknown_extensions {
866        return Ok(guess.first_or_octet_stream().to_string());
867    }
868
869    guess
870        .first_raw()
871        .map(ToOwned::to_owned)
872        .ok_or(error::Error::UnknownFileExtension(Some(ext.into())))
873}
874
875fn etag(contents: &[u8]) -> String {
876    let sha256 = Sha256::digest(contents);
877    let hash = u64::from_le_bytes(sha256[..8].try_into().unwrap())
878        ^ u64::from_le_bytes(sha256[8..16].try_into().unwrap())
879        ^ u64::from_le_bytes(sha256[16..24].try_into().unwrap())
880        ^ u64::from_le_bytes(sha256[24..32].try_into().unwrap());
881    format!("\"{hash:016x}\"")
882}
883
884/// Convert a relative filesystem-style path into a rooted web route.
885///
886/// Path segments are normalized via [`Path::components`] so separator
887/// style differences across platforms do not affect route generation.
888/// The returned route is always absolute (starts with `/`).
889fn normalize_web_path(relative_path: &Path) -> String {
890    let normalized = relative_path
891        .components()
892        .filter_map(|component| match component {
893            std::path::Component::Normal(segment) => segment.to_str(),
894            _ => None,
895        })
896        .collect::<Vec<_>>()
897        .join("/");
898    format!("/{normalized}")
899}
900
901/// Strip `.html`/`.htm` from an already-normalized web path in-place,
902/// and map `/index` to its parent directory route.
903fn strip_html_ext(path: &mut String) {
904    let ext = path.rsplit_once('.').map(|(_, ext)| ext);
905    if ext.is_some_and(|ext| ext.eq_ignore_ascii_case("html")) {
906        path.truncate(path.len() - ".html".len());
907    } else if ext.is_some_and(|ext| ext.eq_ignore_ascii_case("htm")) {
908        path.truncate(path.len() - ".htm".len());
909    }
910
911    if path.ends_with("/index") {
912        path.truncate(path.len() - "index".len());
913    } else if path == "/index" {
914        path.truncate(1);
915    }
916}
917
918#[cfg(test)]
919mod tests {
920    use super::is_compression_significant;
921
922    #[test]
923    fn compression_significance_threshold() {
924        // Exactly 90% of the original size is not significant
925        assert!(!is_compression_significant(90, 100));
926        assert!(is_compression_significant(89, 100));
927
928        // Sizes not divisible by 10 must not floor the threshold down:
929        // 90% of 19 is 17.1, so 17 is significant and 18 is not
930        assert!(is_compression_significant(17, 19));
931        assert!(!is_compression_significant(18, 19));
932    }
933}