1use 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]
28pub 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]
46pub 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 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 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
470fn 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 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 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
627fn 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 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 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 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 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 * 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
842fn 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
884fn 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
901fn 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 assert!(!is_compression_significant(90, 100));
926 assert!(is_compression_significant(89, 100));
927
928 assert!(is_compression_significant(17, 19));
931 assert!(!is_compression_significant(18, 19));
932 }
933}