1use 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]
27pub 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]
45pub 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 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 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
469fn 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 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 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 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 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 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 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
785fn 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 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 output.ends_with("/index") {
836 output = output.strip_suffix("index").unwrap_or("/");
837 }
838
839 Ok(output)
840}