1use std::collections::HashMap;
46use std::sync::{OnceLock, RwLock};
47
48pub const PLUGIN_ASSETS_PREFIX: &str = "/static/_plugins";
51
52const PLUGIN_ASSETS_REL_PREFIX: &str = "_plugins/";
56
57pub const PLUGIN_ASSETS_ROUTE_MARKER: &str = "plugin-assets";
65
66const IMMUTABLE: &str = "public, max-age=31536000, immutable";
68
69const REVALIDATE: &str = "public, max-age=0, must-revalidate";
71
72#[derive(Clone, Copy)]
74enum Source {
75 Files(&'static [(&'static str, &'static [u8])]),
77 #[cfg(feature = "embed-assets")]
79 Dir(&'static include_dir::Dir<'static>),
80}
81
82#[derive(Clone, Copy, Debug, PartialEq, Eq)]
84enum Mount {
85 Plugin,
87 StaticRoot,
90}
91
92pub struct PluginAssets {
102 namespace: &'static str,
103 source: Source,
104 mount: Mount,
105 index: OnceLock<Index>,
106}
107
108impl std::fmt::Debug for PluginAssets {
109 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110 f.debug_struct("PluginAssets")
111 .field("namespace", &self.namespace)
112 .field("mount", &self.mount_path())
113 .field("files", &self.index().assets.len())
114 .finish_non_exhaustive()
115 }
116}
117
118#[derive(Debug)]
120pub struct PluginAsset {
121 logical_path: String,
122 url: String,
123 plain_url: String,
124 bytes: &'static [u8],
125 integrity: String,
126 etag: crate::etag::ETag,
127 content_type: &'static str,
128}
129
130impl PluginAsset {
131 #[must_use]
133 pub fn logical_path(&self) -> &str {
134 &self.logical_path
135 }
136
137 #[must_use]
140 pub fn url(&self) -> &str {
141 &self.url
142 }
143
144 #[must_use]
147 pub fn plain_url(&self) -> &str {
148 &self.plain_url
149 }
150
151 #[must_use]
153 pub fn integrity(&self) -> &str {
154 &self.integrity
155 }
156
157 #[must_use]
159 pub const fn bytes(&self) -> &'static [u8] {
160 self.bytes
161 }
162
163 #[must_use]
165 pub const fn content_type(&self) -> &'static str {
166 self.content_type
167 }
168}
169
170struct Index {
172 assets: Vec<PluginAsset>,
173 by_logical: HashMap<String, usize>,
175 by_fingerprinted_rel: HashMap<String, usize>,
177}
178
179impl PluginAssets {
180 #[must_use]
205 pub const fn from_files(
206 namespace: &'static str,
207 files: &'static [(&'static str, &'static [u8])],
208 ) -> Self {
209 assert_valid_namespace(namespace);
210 Self {
211 namespace,
212 source: Source::Files(files),
213 mount: Mount::Plugin,
214 index: OnceLock::new(),
215 }
216 }
217
218 #[cfg(feature = "embed-assets")]
227 #[must_use]
228 pub const fn from_dir(
229 namespace: &'static str,
230 dir: &'static include_dir::Dir<'static>,
231 ) -> Self {
232 assert_valid_namespace(namespace);
233 Self {
234 namespace,
235 source: Source::Dir(dir),
236 mount: Mount::Plugin,
237 index: OnceLock::new(),
238 }
239 }
240
241 #[cfg_attr(not(feature = "htmx"), allow(dead_code))]
244 pub(crate) const fn framework(files: &'static [(&'static str, &'static [u8])]) -> Self {
245 Self {
246 namespace: "autumn",
247 source: Source::Files(files),
248 mount: Mount::StaticRoot,
249 index: OnceLock::new(),
250 }
251 }
252
253 #[must_use]
255 pub const fn namespace(&self) -> &'static str {
256 self.namespace
257 }
258
259 #[must_use]
261 pub fn mount_path(&self) -> String {
262 match self.mount {
263 Mount::Plugin => format!("{PLUGIN_ASSETS_PREFIX}/{}", self.namespace),
264 Mount::StaticRoot => "/static".to_owned(),
265 }
266 }
267
268 #[must_use]
270 pub fn get(&self, logical_path: &str) -> Option<&PluginAsset> {
271 let index = self.index();
272 index
273 .by_logical
274 .get(logical_path)
275 .map(|&i| &index.assets[i])
276 }
277
278 pub fn iter(&self) -> impl Iterator<Item = &PluginAsset> {
280 self.index().assets.iter()
281 }
282
283 #[must_use]
289 pub fn url(&self, logical_path: &str) -> String {
290 if let Some(asset) = self.get(logical_path) {
291 return asset.url.clone();
292 }
293 tracing::warn!(
294 namespace = self.namespace,
295 asset = logical_path,
296 "PluginAssets::url: no such file in the bundle"
297 );
298 format!("{}/{logical_path}", self.mount_path())
299 }
300
301 #[must_use]
303 pub fn integrity(&self, logical_path: &str) -> Option<&str> {
304 self.get(logical_path).map(PluginAsset::integrity)
305 }
306
307 #[cfg(feature = "maud")]
313 #[must_use]
314 pub fn script_tag(&self, logical_path: &str) -> maud::Markup {
315 self.get(logical_path).map_or_else(
316 || self.missing_markup(logical_path),
317 |asset| {
318 maud::html! {
319 script src=(asset.url) integrity=(asset.integrity) crossorigin="anonymous" {}
320 }
321 },
322 )
323 }
324
325 #[cfg(feature = "maud")]
327 #[must_use]
328 pub fn deferred_script_tag(&self, logical_path: &str) -> maud::Markup {
329 self.get(logical_path).map_or_else(
330 || self.missing_markup(logical_path),
331 |asset| {
332 maud::html! {
333 script src=(asset.url) integrity=(asset.integrity) crossorigin="anonymous" defer {}
334 }
335 },
336 )
337 }
338
339 #[cfg(feature = "maud")]
344 #[must_use]
345 pub fn stylesheet_tag(&self, logical_path: &str) -> maud::Markup {
346 self.get(logical_path).map_or_else(
347 || self.missing_markup(logical_path),
348 |asset| {
349 maud::html! {
350 link rel="stylesheet" href=(asset.url) integrity=(asset.integrity) crossorigin="anonymous";
351 }
352 },
353 )
354 }
355
356 #[cfg(feature = "maud")]
357 fn missing_markup(&self, logical_path: &str) -> maud::Markup {
358 tracing::warn!(
359 namespace = self.namespace,
360 asset = logical_path,
361 "PluginAssets: no such file in the bundle"
362 );
363 let safe = logical_path
366 .replace('&', "&")
367 .replace('<', "<")
368 .replace('>', ">");
369 maud::html! {
370 (maud::PreEscaped(format!(
371 "<!-- autumn: asset '{safe}' not found in plugin bundle '{}' -->",
372 self.namespace
373 )))
374 }
375 }
376
377 pub fn router<S>(&'static self) -> axum::Router<S>
382 where
383 S: Clone + Send + Sync + 'static,
384 {
385 self.router_with_base(&self.mount_path())
386 }
387
388 pub(crate) fn nested_router<S>(&'static self) -> axum::Router<S>
394 where
395 S: Clone + Send + Sync + 'static,
396 {
397 self.router_with_base("")
398 }
399
400 fn router_with_base<S>(&'static self, base: &str) -> axum::Router<S>
401 where
402 S: Clone + Send + Sync + 'static,
403 {
404 routes_for(self.iter(), &self.mount_path(), base)
405 }
406
407 #[must_use]
412 pub fn route_infos(&self) -> Vec<crate::route_listing::RouteInfo> {
413 self.iter()
414 .flat_map(|asset| [asset.plain_url.clone(), asset.url.clone()])
415 .map(|path| crate::route_listing::RouteInfo {
416 method: "GET".to_owned(),
417 path,
418 handler: format!("{}::assets", self.namespace),
419 middleware: vec![PLUGIN_ASSETS_ROUTE_MARKER.to_owned()],
420 classification: crate::route_listing::RouteClassification::Public,
421 ..Default::default()
422 })
423 .collect()
424 }
425
426 pub(crate) fn is_fingerprinted_rel(&self, rel: &str) -> bool {
429 self.index().by_fingerprinted_rel.contains_key(rel)
430 }
431
432 fn index(&self) -> &Index {
433 self.index.get_or_init(|| self.build_index())
434 }
435
436 fn build_index(&self) -> Index {
437 let mut files: Vec<(String, &'static [u8])> = Vec::new();
438 match self.source {
439 Source::Files(list) => {
440 files.extend(list.iter().map(|&(path, bytes)| (path.to_owned(), bytes)));
441 }
442 #[cfg(feature = "embed-assets")]
443 Source::Dir(dir) => collect_dir(dir, &mut files),
444 }
445 files.retain(|(path, _)| {
446 let valid = is_valid_logical_path(path);
447 if !valid && !is_dotfile(path) {
448 tracing::warn!(
449 namespace = self.namespace,
450 asset = %path,
451 "PluginAssets: skipping a file whose path is not a relative `/`-separated path"
452 );
453 }
454 valid
455 });
456 files.sort_by(|a, b| a.0.cmp(&b.0));
457 files.dedup_by(|a, b| a.0 == b.0);
458
459 let hashed: Vec<(String, &'static [u8], Hashes)> = files
464 .into_iter()
465 .map(|(path, bytes)| {
466 let hashes = Hashes::of(bytes);
467 (path, bytes, hashes)
468 })
469 .collect();
470 let fingerprinted_names: std::collections::HashSet<String> = hashed
471 .iter()
472 .map(|(path, _, hashes)| fingerprinted_name(path, &hashes.short))
473 .collect();
474 let hashed: Vec<(String, &'static [u8], Hashes)> = hashed
475 .into_iter()
476 .filter(|(path, _, _)| {
477 let collides = fingerprinted_names.contains(path);
478 if collides {
479 tracing::warn!(
480 namespace = self.namespace,
481 asset = %path,
482 "PluginAssets: skipping a file whose path is another file's \
483 fingerprinted URL"
484 );
485 }
486 !collides
487 })
488 .collect();
489
490 let mount = self.mount_path();
491 let rel_mount = mount.strip_prefix("/static").unwrap_or("");
492 let rel_mount = rel_mount.strip_prefix('/').unwrap_or(rel_mount);
493 let mut index = Index {
494 assets: Vec::with_capacity(hashed.len()),
495 by_logical: HashMap::with_capacity(hashed.len()),
496 by_fingerprinted_rel: HashMap::with_capacity(hashed.len()),
497 };
498 for (logical_path, bytes, hashes) in hashed {
499 let fingerprinted = fingerprinted_name(&logical_path, &hashes.short);
500 let fingerprinted_rel = if rel_mount.is_empty() {
501 fingerprinted.clone()
502 } else {
503 format!("{rel_mount}/{fingerprinted}")
504 };
505 let position = index.assets.len();
506 index.by_logical.insert(logical_path.clone(), position);
507 index
508 .by_fingerprinted_rel
509 .insert(fingerprinted_rel, position);
510 index.assets.push(PluginAsset {
511 url: format!("{mount}/{fingerprinted}"),
512 plain_url: format!("{mount}/{logical_path}"),
513 content_type: crate::assets::content_type_for(&logical_path),
514 logical_path,
515 bytes,
516 integrity: hashes.integrity,
517 etag: hashes.etag,
518 });
519 }
520 index
521 }
522}
523
524pub(crate) fn routes_for<S>(
527 assets: impl Iterator<Item = &'static PluginAsset>,
528 mount: &str,
529 base: &str,
530) -> axum::Router<S>
531where
532 S: Clone + Send + Sync + 'static,
533{
534 let mut router = axum::Router::new();
535 for asset in assets {
536 for (url, immutable) in [(&asset.plain_url, false), (&asset.url, true)] {
537 let relative = url.strip_prefix(mount).unwrap_or(url);
538 router = router.route(
539 &format!("{base}{relative}"),
540 axum::routing::get(move |headers: http::HeaderMap| async move {
541 asset_response(asset, immutable, &headers)
542 }),
543 );
544 }
545 }
546 router
547}
548
549struct Hashes {
551 short: String,
553 integrity: String,
555 etag: crate::etag::ETag,
559}
560
561impl Hashes {
562 fn of(bytes: &[u8]) -> Self {
563 use base64::Engine as _;
564 use sha2::{Digest as _, Sha256, Sha384};
565
566 let sha256 = hex::encode(Sha256::digest(bytes));
567 let integrity = format!(
568 "sha384-{}",
569 base64::engine::general_purpose::STANDARD.encode(Sha384::digest(bytes))
570 );
571 Self {
572 short: sha256[..8].to_owned(),
573 integrity,
574 etag: crate::etag::ETag::weak(sha256),
575 }
576 }
577}
578
579fn fingerprinted_name(logical_path: &str, hash: &str) -> String {
583 let (dir, file) = logical_path
584 .rsplit_once('/')
585 .map_or(("", logical_path), |(d, f)| (d, f));
586 let renamed = match file.rsplit_once('.') {
587 Some((stem, ext)) if !stem.is_empty() => format!("{stem}.{hash}.{ext}"),
588 _ => format!("{file}.{hash}"),
589 };
590 if dir.is_empty() {
591 renamed
592 } else {
593 format!("{dir}/{renamed}")
594 }
595}
596
597fn is_valid_logical_path(path: &str) -> bool {
604 !path.is_empty()
605 && path.split('/').all(|segment| {
606 !segment.is_empty()
607 && !segment.starts_with('.')
608 && segment
609 .bytes()
610 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~'))
611 })
612}
613
614fn is_dotfile(path: &str) -> bool {
617 path.split(['/', '\\'])
618 .any(|segment| segment.starts_with('.') && segment.len() > 1 && segment != "..")
619}
620
621#[cfg(feature = "embed-assets")]
623fn collect_dir(dir: &'static include_dir::Dir<'static>, out: &mut Vec<(String, &'static [u8])>) {
624 for entry in dir.entries() {
625 match entry {
626 include_dir::DirEntry::Dir(child) => collect_dir(child, out),
627 include_dir::DirEntry::File(file) => {
628 let path = file.path().to_string_lossy().replace('\\', "/");
631 out.push((path, file.contents()));
632 }
633 }
634 }
635}
636
637const fn assert_valid_namespace(namespace: &str) {
640 let bytes = namespace.as_bytes();
641 assert!(
642 !bytes.is_empty() && bytes.len() <= 64,
643 "PluginAssets namespace must be 1 to 64 bytes long"
644 );
645 let mut i = 0;
646 while i < bytes.len() {
647 let b = bytes[i];
648 assert!(
649 b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_',
650 "PluginAssets namespace may only contain a-z, 0-9, '-' and '_'"
651 );
652 i += 1;
653 }
654}
655
656fn asset_response(
659 asset: &'static PluginAsset,
660 immutable: bool,
661 request_headers: &http::HeaderMap,
662) -> axum::response::Response {
663 use axum::response::IntoResponse as _;
664
665 let body = bytes::Bytes::from_static(asset.bytes);
666 let resolution = crate::range::resolve(request_headers, body.len() as u64, None);
669 let mut response = crate::range::partial_bytes_response(&resolution, body);
670 let headers = response.headers_mut();
671 headers.insert(
672 http::header::CONTENT_TYPE,
673 http::HeaderValue::from_static(asset.content_type),
674 );
675 headers.insert(
676 http::header::CACHE_CONTROL,
677 http::HeaderValue::from_static(if immutable { IMMUTABLE } else { REVALIDATE }),
678 );
679 crate::etag::fresh_when(request_headers, asset.etag.clone())
680 .or(response)
681 .into_response()
682}
683
684static REGISTRY: RwLock<Vec<&'static PluginAssets>> = RwLock::new(Vec::new());
694
695pub(crate) fn register(bundle: &'static PluginAssets) {
706 let mut registry = REGISTRY
707 .write()
708 .unwrap_or_else(std::sync::PoisonError::into_inner);
709 if let Some(existing) = registry
710 .iter()
711 .find(|existing| existing.namespace == bundle.namespace)
712 {
713 let same = std::ptr::eq(*existing, bundle);
714 drop(registry);
717 assert!(
718 same,
719 "two different PluginAssets bundles use the namespace `{}` in one process; \
720 `asset_url` resolves a namespace to one bundle, so each needs its own",
721 bundle.namespace,
722 );
723 return;
724 }
725 registry.push(bundle);
726}
727
728fn registered(namespace: &str) -> Option<&'static PluginAssets> {
729 REGISTRY
730 .read()
731 .unwrap_or_else(std::sync::PoisonError::into_inner)
732 .iter()
733 .find(|bundle| bundle.namespace == namespace)
734 .copied()
735}
736
737fn split_plugin_rel(rel: &str) -> Option<(&'static PluginAssets, &str)> {
739 let rest = rel.strip_prefix(PLUGIN_ASSETS_REL_PREFIX)?;
740 let (namespace, path) = rest.split_once('/')?;
741 Some((registered(namespace)?, path))
742}
743
744pub(crate) fn is_framework_asset_route(route: &crate::route_listing::RouteInfo) -> bool {
752 route.method.eq_ignore_ascii_case("GET")
753 && route
754 .path
755 .strip_prefix(PLUGIN_ASSETS_PREFIX)
756 .is_some_and(|rest| rest.starts_with('/'))
757 && route
758 .middleware
759 .iter()
760 .any(|label| label == PLUGIN_ASSETS_ROUTE_MARKER)
761}
762
763pub(crate) fn resolve_registered_url(rel: &str) -> Option<String> {
766 let (bundle, path) = split_plugin_rel(rel)?;
767 bundle.get(path).map(|asset| asset.url.clone())
768}
769
770pub(crate) fn is_registered_fingerprint(rel: &str) -> bool {
773 split_plugin_rel(rel).is_some_and(|(bundle, _)| bundle.is_fingerprinted_rel(rel))
774}
775
776#[cfg(test)]
777mod tests {
778 use super::*;
779 use axum::body::Body;
780 use http::{Request, StatusCode, header};
781 use tower::ServiceExt as _;
782
783 static BUNDLE: PluginAssets = PluginAssets::from_files(
784 "unit-test",
785 &[
786 ("init.js", b"console.log('init');"),
787 ("css/theme.min.css", b"body{color:red}"),
788 ("LICENSE", b"MIT"),
789 (".gitkeep", b""),
790 ("../escape.js", b"nope"),
791 ("/abs.js", b"nope"),
792 ("{name}.js", b"capture"),
793 ("{*rest}", b"wildcard"),
794 ("a b.js", b"space"),
795 ("50%.js", b"percent"),
796 ],
797 );
798
799 fn expected_short(bytes: &[u8]) -> String {
800 use sha2::{Digest as _, Sha256};
801 hex::encode(&Sha256::digest(bytes)[..4])
802 }
803
804 async fn get(path: &str, headers: &[(&str, &str)]) -> axum::response::Response {
805 let mut request = Request::builder().uri(path);
806 for (name, value) in headers {
807 request = request.header(*name, *value);
808 }
809 BUNDLE
810 .router::<()>()
811 .oneshot(request.body(Body::empty()).unwrap())
812 .await
813 .unwrap()
814 }
815
816 #[test]
817 fn fingerprinted_name_matches_autumn_build_naming() {
818 assert_eq!(
819 fingerprinted_name("init.js", "abcd1234"),
820 "init.abcd1234.js"
821 );
822 assert_eq!(
823 fingerprinted_name("motion.min.js", "abcd1234"),
824 "motion.min.abcd1234.js"
825 );
826 assert_eq!(
827 fingerprinted_name("css/theme.css", "abcd1234"),
828 "css/theme.abcd1234.css"
829 );
830 assert_eq!(
831 fingerprinted_name("LICENSE", "abcd1234"),
832 "LICENSE.abcd1234"
833 );
834 assert_eq!(
835 fingerprinted_name("dir.v1/LICENSE", "abcd1234"),
836 "dir.v1/LICENSE.abcd1234"
837 );
838 }
839
840 #[test]
841 fn url_is_a_function_of_the_bytes() {
842 let hash = expected_short(b"console.log('init');");
843 assert_eq!(
844 BUNDLE.url("init.js"),
845 format!("/static/_plugins/unit-test/init.{hash}.js")
846 );
847 let css = BUNDLE.get("css/theme.min.css").unwrap();
848 assert_eq!(
849 css.url(),
850 format!(
851 "/static/_plugins/unit-test/css/theme.min.{}.css",
852 expected_short(b"body{color:red}")
853 )
854 );
855 assert_eq!(
856 css.plain_url(),
857 "/static/_plugins/unit-test/css/theme.min.css"
858 );
859 assert_eq!(css.content_type(), "text/css; charset=utf-8");
860 }
861
862 #[test]
863 fn integrity_is_sha384_of_the_bytes() {
864 use base64::Engine as _;
865 use sha2::{Digest as _, Sha384};
866 let expected = format!(
867 "sha384-{}",
868 base64::engine::general_purpose::STANDARD.encode(Sha384::digest(b"MIT"))
869 );
870 assert_eq!(BUNDLE.integrity("LICENSE"), Some(expected.as_str()));
871 }
872
873 #[test]
874 fn unsafe_paths_and_dotfiles_are_not_part_of_the_bundle() {
875 let paths: Vec<&str> = BUNDLE.iter().map(PluginAsset::logical_path).collect();
876 assert_eq!(paths, ["LICENSE", "css/theme.min.css", "init.js"]);
877 assert!(BUNDLE.get("../escape.js").is_none());
878 assert!(BUNDLE.get("/abs.js").is_none());
879 assert!(BUNDLE.get(".gitkeep").is_none());
880 assert!(BUNDLE.get("{name}.js").is_none());
881 assert!(BUNDLE.get("{*rest}").is_none());
882 assert!(BUNDLE.get("a b.js").is_none());
883 assert!(BUNDLE.get("50%.js").is_none());
884 }
885
886 #[tokio::test]
889 async fn a_route_pattern_filename_does_not_match_other_paths() {
890 assert_eq!(
891 get("/static/_plugins/unit-test/anything.js", &[])
892 .await
893 .status(),
894 StatusCode::NOT_FOUND
895 );
896 }
897
898 #[tokio::test]
901 async fn a_file_named_like_another_files_hashed_url_is_dropped() {
902 static COLLIDING: OnceLock<PluginAssets> = OnceLock::new();
903 static FILES: OnceLock<Vec<(&'static str, &'static [u8])>> = OnceLock::new();
904 let hashed_name: &'static str =
905 Box::leak(fingerprinted_name("app.js", &expected_short(b"real app")).into_boxed_str());
906 let files = FILES.get_or_init(|| {
907 vec![
908 ("app.js", b"real app".as_slice()),
909 (hashed_name, b"imposter".as_slice()),
910 ]
911 });
912 let bundle = COLLIDING.get_or_init(|| PluginAssets::from_files("unit-test-collide", files));
913
914 let paths: Vec<&str> = bundle.iter().map(PluginAsset::logical_path).collect();
915 assert_eq!(paths, ["app.js"]);
916 let response = bundle
917 .router::<()>()
918 .oneshot(
919 Request::builder()
920 .uri(bundle.url("app.js"))
921 .body(Body::empty())
922 .unwrap(),
923 )
924 .await
925 .unwrap();
926 assert_eq!(response.status(), StatusCode::OK);
927 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
928 .await
929 .unwrap();
930 assert_eq!(&body[..], b"real app");
931 }
932
933 #[test]
934 fn missing_file_url_falls_back_to_the_plain_path() {
935 assert_eq!(BUNDLE.url("nope.js"), "/static/_plugins/unit-test/nope.js");
936 assert_eq!(BUNDLE.integrity("nope.js"), None);
937 }
938
939 #[test]
940 fn route_infos_list_both_urls_as_public() {
941 let infos = BUNDLE.route_infos();
942 assert_eq!(infos.len(), 6, "three files, two URLs each");
943 assert!(infos.iter().all(|info| info.method == "GET"
944 && info.classification == crate::route_listing::RouteClassification::Public));
945 assert!(infos.iter().any(|info| info.path == BUNDLE.url("init.js")));
946 assert!(
947 infos
948 .iter()
949 .any(|info| info.path == "/static/_plugins/unit-test/init.js")
950 );
951 }
952
953 #[cfg(feature = "maud")]
954 #[test]
955 fn tags_carry_hashed_url_and_integrity() {
956 let asset = BUNDLE.get("init.js").unwrap();
957 let script = BUNDLE.script_tag("init.js").into_string();
958 assert!(
959 script.contains(&format!(r#"src="{}""#, asset.url())),
960 "{script}"
961 );
962 assert!(
963 script.contains(&format!(r#"integrity="{}""#, asset.integrity())),
964 "{script}"
965 );
966 assert!(script.contains(r#"crossorigin="anonymous""#), "{script}");
967 assert!(!script.contains("defer"), "{script}");
968
969 let deferred = BUNDLE.deferred_script_tag("init.js").into_string();
970 assert!(deferred.contains("defer"), "{deferred}");
971
972 let link = BUNDLE.stylesheet_tag("css/theme.min.css").into_string();
973 assert!(link.starts_with(r#"<link rel="stylesheet""#), "{link}");
974 assert!(
975 link.contains(&format!(
976 r#"href="{}""#,
977 BUNDLE.get("css/theme.min.css").unwrap().url()
978 )),
979 "{link}"
980 );
981 }
982
983 #[cfg(feature = "maud")]
984 #[test]
985 fn missing_tag_is_an_html_comment_that_cannot_be_broken_out_of() {
986 let markup = BUNDLE
987 .script_tag("x--><script>alert(1)</script>")
988 .into_string();
989 assert!(markup.starts_with("<!--"), "{markup}");
990 assert_eq!(markup.matches("-->").count(), 1, "{markup}");
991 assert!(!markup.contains("<script"), "{markup}");
992 }
993
994 #[tokio::test]
995 async fn fingerprinted_url_is_immutable() {
996 let response = get(&BUNDLE.url("init.js"), &[]).await;
997 assert_eq!(response.status(), StatusCode::OK);
998 assert_eq!(response.headers()[header::CACHE_CONTROL], IMMUTABLE);
999 assert_eq!(
1000 response.headers()[header::CONTENT_TYPE],
1001 "text/javascript; charset=utf-8"
1002 );
1003 assert!(response.headers().contains_key(header::ETAG));
1004 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
1005 .await
1006 .unwrap();
1007 assert_eq!(&body[..], b"console.log('init');");
1008 }
1009
1010 #[tokio::test]
1011 async fn plain_url_revalidates() {
1012 let response = get("/static/_plugins/unit-test/init.js", &[]).await;
1013 assert_eq!(response.status(), StatusCode::OK);
1014 assert_eq!(response.headers()[header::CACHE_CONTROL], REVALIDATE);
1015 }
1016
1017 #[tokio::test]
1018 async fn matching_if_none_match_is_not_modified() {
1019 let first = get("/static/_plugins/unit-test/init.js", &[]).await;
1020 let etag = first.headers()[header::ETAG].to_str().unwrap().to_owned();
1021 let second = get(
1022 "/static/_plugins/unit-test/init.js",
1023 &[("if-none-match", etag.as_str())],
1024 )
1025 .await;
1026 assert_eq!(second.status(), StatusCode::NOT_MODIFIED);
1027 assert_eq!(second.headers()[header::CACHE_CONTROL], REVALIDATE);
1028 let body = axum::body::to_bytes(second.into_body(), usize::MAX)
1029 .await
1030 .unwrap();
1031 assert!(body.is_empty());
1032 }
1033
1034 #[tokio::test]
1035 async fn range_request_is_partial() {
1036 let response = get(&BUNDLE.url("LICENSE"), &[("range", "bytes=0-1")]).await;
1037 assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
1038 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
1039 .await
1040 .unwrap();
1041 assert_eq!(&body[..], b"MI");
1042 }
1043
1044 #[tokio::test]
1045 async fn unknown_and_stale_paths_are_not_served() {
1046 assert_eq!(
1047 get("/static/_plugins/unit-test/nope.js", &[])
1048 .await
1049 .status(),
1050 StatusCode::NOT_FOUND
1051 );
1052 assert_eq!(
1053 get("/static/_plugins/unit-test/init.00000000.js", &[])
1054 .await
1055 .status(),
1056 StatusCode::NOT_FOUND
1057 );
1058 assert_eq!(
1059 get("/static/_plugins/unit-test/.gitkeep", &[])
1060 .await
1061 .status(),
1062 StatusCode::NOT_FOUND
1063 );
1064 }
1065
1066 #[test]
1067 fn registry_resolves_plugin_paths_after_registration() {
1068 static REGISTERED: PluginAssets =
1069 PluginAssets::from_files("unit-test-registry", &[("a.js", b"a")]);
1070 assert_eq!(
1071 resolve_registered_url("_plugins/unit-test-registry/a.js"),
1072 None
1073 );
1074 register(®ISTERED);
1075 register(®ISTERED);
1076 let url = REGISTERED.url("a.js");
1077 assert_eq!(
1078 resolve_registered_url("_plugins/unit-test-registry/a.js"),
1079 Some(url.clone())
1080 );
1081 assert_eq!(
1082 resolve_registered_url("_plugins/unit-test-registry/b.js"),
1083 None
1084 );
1085 let rel = url.strip_prefix("/static/").unwrap();
1086 assert!(is_registered_fingerprint(rel));
1087 assert!(!is_registered_fingerprint(
1088 "_plugins/unit-test-registry/a.js"
1089 ));
1090 assert!(!is_registered_fingerprint("js/a.js"));
1091 }
1092
1093 #[test]
1094 #[should_panic(
1095 expected = "two different PluginAssets bundles use the namespace `unit-test-takeover` in one process"
1096 )]
1097 fn a_second_bundle_cannot_take_over_a_registered_namespace() {
1098 static FIRST: PluginAssets =
1099 PluginAssets::from_files("unit-test-takeover", &[("a.js", b"first")]);
1100 static SECOND: PluginAssets =
1101 PluginAssets::from_files("unit-test-takeover", &[("a.js", b"second")]);
1102 register(&FIRST);
1103 register(&SECOND);
1104 }
1105
1106 #[test]
1107 fn a_refused_takeover_leaves_the_first_bundle_resolving() {
1108 static FIRST: PluginAssets =
1109 PluginAssets::from_files("unit-test-takeover-kept", &[("a.js", b"first")]);
1110 static SECOND: PluginAssets =
1111 PluginAssets::from_files("unit-test-takeover-kept", &[("a.js", b"second")]);
1112 register(&FIRST);
1113 let refused = std::panic::catch_unwind(|| register(&SECOND));
1114 assert!(refused.is_err());
1115 assert_eq!(
1116 resolve_registered_url("_plugins/unit-test-takeover-kept/a.js"),
1117 Some(FIRST.url("a.js"))
1118 );
1119 }
1120
1121 #[test]
1122 fn only_marked_get_asset_routes_count_as_framework_asset_routes() {
1123 let route = |method: &str, path: &str, marked: bool| crate::route_listing::RouteInfo {
1124 method: method.to_owned(),
1125 path: path.to_owned(),
1126 middleware: if marked {
1127 vec![PLUGIN_ASSETS_ROUTE_MARKER.to_owned()]
1128 } else {
1129 Vec::new()
1130 },
1131 ..Default::default()
1132 };
1133 assert!(is_framework_asset_route(&route(
1134 "GET",
1135 "/static/_plugins/ns/a.js",
1136 true
1137 )));
1138 assert!(!is_framework_asset_route(&route(
1140 "GET",
1141 "/static/_plugins/ns/a.js",
1142 false
1143 )));
1144 assert!(!is_framework_asset_route(&route(
1146 "POST",
1147 "/static/_plugins/ns/a.js",
1148 true
1149 )));
1150 assert!(!is_framework_asset_route(&route(
1152 "GET",
1153 "/static/app.js",
1154 true
1155 )));
1156 assert!(!is_framework_asset_route(&route(
1157 "GET",
1158 "/static/_pluginsx/a.js",
1159 true
1160 )));
1161 }
1162
1163 #[test]
1164 fn framework_bundle_mounts_at_static_root() {
1165 static FRAMEWORK: PluginAssets = PluginAssets::framework(&[("js/x.js", b"x")]);
1166 let asset = FRAMEWORK.get("js/x.js").unwrap();
1167 assert_eq!(asset.plain_url(), "/static/js/x.js");
1168 assert_eq!(
1169 asset.url(),
1170 format!("/static/js/x.{}.js", expected_short(b"x"))
1171 );
1172 let rel = asset.url().strip_prefix("/static/").unwrap();
1173 assert!(FRAMEWORK.is_fingerprinted_rel(rel));
1174 assert!(!FRAMEWORK.is_fingerprinted_rel("js/x.js"));
1175 }
1176
1177 #[test]
1178 #[should_panic(expected = "namespace may only contain")]
1179 fn namespace_with_a_slash_is_rejected() {
1180 let _ = PluginAssets::from_files("bad/ns", &[]);
1181 }
1182
1183 #[test]
1184 #[should_panic(expected = "1 to 64 bytes")]
1185 fn empty_namespace_is_rejected() {
1186 let _ = PluginAssets::from_files("", &[]);
1187 }
1188}