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