use std::collections::HashMap;
use std::sync::{OnceLock, RwLock};
pub const PLUGIN_ASSETS_PREFIX: &str = "/static/_plugins";
const PLUGIN_ASSETS_REL_PREFIX: &str = "_plugins/";
pub const PLUGIN_ASSETS_ROUTE_MARKER: &str = "plugin-assets";
const IMMUTABLE: &str = "public, max-age=31536000, immutable";
const REVALIDATE: &str = "public, max-age=0, must-revalidate";
#[derive(Clone, Copy)]
enum Source {
Files(&'static [(&'static str, &'static [u8])]),
#[cfg(feature = "embed-assets")]
Dir(&'static include_dir::Dir<'static>),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Mount {
Plugin,
StaticRoot,
}
pub struct PluginAssets {
namespace: &'static str,
source: Source,
mount: Mount,
index: OnceLock<Index>,
}
impl std::fmt::Debug for PluginAssets {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PluginAssets")
.field("namespace", &self.namespace)
.field("mount", &self.mount_path())
.field("files", &self.index().assets.len())
.finish_non_exhaustive()
}
}
#[derive(Debug)]
pub struct PluginAsset {
logical_path: String,
url: String,
plain_url: String,
bytes: &'static [u8],
integrity: String,
etag: crate::etag::ETag,
content_type: &'static str,
}
impl PluginAsset {
#[must_use]
pub fn logical_path(&self) -> &str {
&self.logical_path
}
#[must_use]
pub fn url(&self) -> &str {
&self.url
}
#[must_use]
pub fn plain_url(&self) -> &str {
&self.plain_url
}
#[must_use]
pub fn integrity(&self) -> &str {
&self.integrity
}
#[must_use]
pub const fn bytes(&self) -> &'static [u8] {
self.bytes
}
#[must_use]
pub const fn content_type(&self) -> &'static str {
self.content_type
}
}
struct Index {
assets: Vec<PluginAsset>,
by_logical: HashMap<String, usize>,
by_fingerprinted_rel: HashMap<String, usize>,
}
impl PluginAssets {
#[must_use]
pub const fn from_files(
namespace: &'static str,
files: &'static [(&'static str, &'static [u8])],
) -> Self {
assert_valid_namespace(namespace);
Self {
namespace,
source: Source::Files(files),
mount: Mount::Plugin,
index: OnceLock::new(),
}
}
#[cfg(feature = "embed-assets")]
#[must_use]
pub const fn from_dir(
namespace: &'static str,
dir: &'static include_dir::Dir<'static>,
) -> Self {
assert_valid_namespace(namespace);
Self {
namespace,
source: Source::Dir(dir),
mount: Mount::Plugin,
index: OnceLock::new(),
}
}
#[cfg_attr(not(feature = "htmx"), allow(dead_code))]
pub(crate) const fn framework(files: &'static [(&'static str, &'static [u8])]) -> Self {
Self {
namespace: "autumn",
source: Source::Files(files),
mount: Mount::StaticRoot,
index: OnceLock::new(),
}
}
#[must_use]
pub const fn namespace(&self) -> &'static str {
self.namespace
}
#[must_use]
pub fn mount_path(&self) -> String {
match self.mount {
Mount::Plugin => format!("{PLUGIN_ASSETS_PREFIX}/{}", self.namespace),
Mount::StaticRoot => "/static".to_owned(),
}
}
#[must_use]
pub fn get(&self, logical_path: &str) -> Option<&PluginAsset> {
let index = self.index();
index
.by_logical
.get(logical_path)
.map(|&i| &index.assets[i])
}
pub fn iter(&self) -> impl Iterator<Item = &PluginAsset> {
self.index().assets.iter()
}
#[must_use]
pub fn url(&self, logical_path: &str) -> String {
if let Some(asset) = self.get(logical_path) {
return asset.url.clone();
}
tracing::warn!(
namespace = self.namespace,
asset = logical_path,
"PluginAssets::url: no such file in the bundle"
);
format!("{}/{logical_path}", self.mount_path())
}
#[must_use]
pub fn integrity(&self, logical_path: &str) -> Option<&str> {
self.get(logical_path).map(PluginAsset::integrity)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn script_tag(&self, logical_path: &str) -> maud::Markup {
self.get(logical_path).map_or_else(
|| self.missing_markup(logical_path),
|asset| {
maud::html! {
script src=(asset.url) integrity=(asset.integrity) crossorigin="anonymous" {}
}
},
)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn deferred_script_tag(&self, logical_path: &str) -> maud::Markup {
self.get(logical_path).map_or_else(
|| self.missing_markup(logical_path),
|asset| {
maud::html! {
script src=(asset.url) integrity=(asset.integrity) crossorigin="anonymous" defer {}
}
},
)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn stylesheet_tag(&self, logical_path: &str) -> maud::Markup {
self.get(logical_path).map_or_else(
|| self.missing_markup(logical_path),
|asset| {
maud::html! {
link rel="stylesheet" href=(asset.url) integrity=(asset.integrity) crossorigin="anonymous";
}
},
)
}
#[cfg(feature = "maud")]
fn missing_markup(&self, logical_path: &str) -> maud::Markup {
tracing::warn!(
namespace = self.namespace,
asset = logical_path,
"PluginAssets: no such file in the bundle"
);
let safe = logical_path
.replace('&', "&")
.replace('<', "<")
.replace('>', ">");
maud::html! {
(maud::PreEscaped(format!(
"<!-- autumn: asset '{safe}' not found in plugin bundle '{}' -->",
self.namespace
)))
}
}
pub fn router<S>(&'static self) -> axum::Router<S>
where
S: Clone + Send + Sync + 'static,
{
self.router_with_base(&self.mount_path())
}
pub(crate) fn nested_router<S>(&'static self) -> axum::Router<S>
where
S: Clone + Send + Sync + 'static,
{
self.router_with_base("")
}
fn router_with_base<S>(&'static self, base: &str) -> axum::Router<S>
where
S: Clone + Send + Sync + 'static,
{
routes_for(self.iter(), &self.mount_path(), base)
}
#[must_use]
pub fn route_infos(&self) -> Vec<crate::route_listing::RouteInfo> {
self.iter()
.flat_map(|asset| [asset.plain_url.clone(), asset.url.clone()])
.map(|path| crate::route_listing::RouteInfo {
method: "GET".to_owned(),
path,
handler: format!("{}::assets", self.namespace),
middleware: vec![PLUGIN_ASSETS_ROUTE_MARKER.to_owned()],
classification: crate::route_listing::RouteClassification::Public,
..Default::default()
})
.collect()
}
pub(crate) fn is_fingerprinted_rel(&self, rel: &str) -> bool {
self.index().by_fingerprinted_rel.contains_key(rel)
}
fn index(&self) -> &Index {
self.index.get_or_init(|| self.build_index())
}
fn build_index(&self) -> Index {
let mut files: Vec<(String, &'static [u8])> = Vec::new();
match self.source {
Source::Files(list) => {
files.extend(list.iter().map(|&(path, bytes)| (path.to_owned(), bytes)));
}
#[cfg(feature = "embed-assets")]
Source::Dir(dir) => collect_dir(dir, &mut files),
}
files.retain(|(path, _)| {
let valid = is_valid_logical_path(path);
if !valid && !is_dotfile(path) {
tracing::warn!(
namespace = self.namespace,
asset = %path,
"PluginAssets: skipping a file whose path is not a relative `/`-separated path"
);
}
valid
});
files.sort_by(|a, b| a.0.cmp(&b.0));
files.dedup_by(|a, b| a.0 == b.0);
let hashed: Vec<(String, &'static [u8], Hashes)> = files
.into_iter()
.map(|(path, bytes)| {
let hashes = Hashes::of(bytes);
(path, bytes, hashes)
})
.collect();
let fingerprinted_names: std::collections::HashSet<String> = hashed
.iter()
.map(|(path, _, hashes)| fingerprinted_name(path, &hashes.short))
.collect();
let hashed: Vec<(String, &'static [u8], Hashes)> = hashed
.into_iter()
.filter(|(path, _, _)| {
let collides = fingerprinted_names.contains(path);
if collides {
tracing::warn!(
namespace = self.namespace,
asset = %path,
"PluginAssets: skipping a file whose path is another file's \
fingerprinted URL"
);
}
!collides
})
.collect();
let mount = self.mount_path();
let rel_mount = mount.strip_prefix("/static").unwrap_or("");
let rel_mount = rel_mount.strip_prefix('/').unwrap_or(rel_mount);
let mut index = Index {
assets: Vec::with_capacity(hashed.len()),
by_logical: HashMap::with_capacity(hashed.len()),
by_fingerprinted_rel: HashMap::with_capacity(hashed.len()),
};
for (logical_path, bytes, hashes) in hashed {
let fingerprinted = fingerprinted_name(&logical_path, &hashes.short);
let fingerprinted_rel = if rel_mount.is_empty() {
fingerprinted.clone()
} else {
format!("{rel_mount}/{fingerprinted}")
};
let position = index.assets.len();
index.by_logical.insert(logical_path.clone(), position);
index
.by_fingerprinted_rel
.insert(fingerprinted_rel, position);
index.assets.push(PluginAsset {
url: format!("{mount}/{fingerprinted}"),
plain_url: format!("{mount}/{logical_path}"),
content_type: crate::assets::content_type_for(&logical_path),
logical_path,
bytes,
integrity: hashes.integrity,
etag: hashes.etag,
});
}
index
}
}
pub(crate) fn routes_for<S>(
assets: impl Iterator<Item = &'static PluginAsset>,
mount: &str,
base: &str,
) -> axum::Router<S>
where
S: Clone + Send + Sync + 'static,
{
let mut router = axum::Router::new();
for asset in assets {
for (url, immutable) in [(&asset.plain_url, false), (&asset.url, true)] {
let relative = url.strip_prefix(mount).unwrap_or(url);
router = router.route(
&format!("{base}{relative}"),
axum::routing::get(move |headers: http::HeaderMap| async move {
asset_response(asset, immutable, &headers)
}),
);
}
}
router
}
struct Hashes {
short: String,
integrity: String,
etag: crate::etag::ETag,
}
impl Hashes {
fn of(bytes: &[u8]) -> Self {
use base64::Engine as _;
use sha2::{Digest as _, Sha256, Sha384};
let sha256 = hex::encode(Sha256::digest(bytes));
let integrity = format!(
"sha384-{}",
base64::engine::general_purpose::STANDARD.encode(Sha384::digest(bytes))
);
Self {
short: sha256[..8].to_owned(),
integrity,
etag: crate::etag::ETag::weak(sha256),
}
}
}
fn fingerprinted_name(logical_path: &str, hash: &str) -> String {
let (dir, file) = logical_path
.rsplit_once('/')
.map_or(("", logical_path), |(d, f)| (d, f));
let renamed = match file.rsplit_once('.') {
Some((stem, ext)) if !stem.is_empty() => format!("{stem}.{hash}.{ext}"),
_ => format!("{file}.{hash}"),
};
if dir.is_empty() {
renamed
} else {
format!("{dir}/{renamed}")
}
}
fn is_valid_logical_path(path: &str) -> bool {
!path.is_empty()
&& path.split('/').all(|segment| {
!segment.is_empty()
&& !segment.starts_with('.')
&& segment
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~'))
})
}
fn is_dotfile(path: &str) -> bool {
path.split(['/', '\\'])
.any(|segment| segment.starts_with('.') && segment.len() > 1 && segment != "..")
}
#[cfg(feature = "embed-assets")]
fn collect_dir(dir: &'static include_dir::Dir<'static>, out: &mut Vec<(String, &'static [u8])>) {
for entry in dir.entries() {
match entry {
include_dir::DirEntry::Dir(child) => collect_dir(child, out),
include_dir::DirEntry::File(file) => {
let path = file.path().to_string_lossy().replace('\\', "/");
out.push((path, file.contents()));
}
}
}
}
const fn assert_valid_namespace(namespace: &str) {
let bytes = namespace.as_bytes();
assert!(
!bytes.is_empty() && bytes.len() <= 64,
"PluginAssets namespace must be 1 to 64 bytes long"
);
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
assert!(
b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_',
"PluginAssets namespace may only contain a-z, 0-9, '-' and '_'"
);
i += 1;
}
}
fn asset_response(
asset: &'static PluginAsset,
immutable: bool,
request_headers: &http::HeaderMap,
) -> axum::response::Response {
use axum::response::IntoResponse as _;
let body = bytes::Bytes::from_static(asset.bytes);
let resolution = crate::range::resolve(request_headers, body.len() as u64, None);
let mut response = crate::range::partial_bytes_response(&resolution, body);
let headers = response.headers_mut();
headers.insert(
http::header::CONTENT_TYPE,
http::HeaderValue::from_static(asset.content_type),
);
headers.insert(
http::header::CACHE_CONTROL,
http::HeaderValue::from_static(if immutable { IMMUTABLE } else { REVALIDATE }),
);
crate::etag::fresh_when(request_headers, asset.etag.clone())
.or(response)
.into_response()
}
static REGISTRY: RwLock<Vec<&'static PluginAssets>> = RwLock::new(Vec::new());
pub(crate) fn register(bundle: &'static PluginAssets) {
let mut registry = REGISTRY
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(existing) = registry
.iter()
.find(|existing| existing.namespace == bundle.namespace)
{
let same = std::ptr::eq(*existing, bundle);
drop(registry);
assert!(
same,
"two different PluginAssets bundles use the namespace `{}` in one process; \
`asset_url` resolves a namespace to one bundle, so each needs its own",
bundle.namespace,
);
return;
}
registry.push(bundle);
}
fn registered(namespace: &str) -> Option<&'static PluginAssets> {
REGISTRY
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.iter()
.find(|bundle| bundle.namespace == namespace)
.copied()
}
fn split_plugin_rel(rel: &str) -> Option<(&'static PluginAssets, &str)> {
let rest = rel.strip_prefix(PLUGIN_ASSETS_REL_PREFIX)?;
let (namespace, path) = rest.split_once('/')?;
Some((registered(namespace)?, path))
}
pub(crate) fn is_framework_asset_route(route: &crate::route_listing::RouteInfo) -> bool {
route.method.eq_ignore_ascii_case("GET")
&& route
.path
.strip_prefix(PLUGIN_ASSETS_PREFIX)
.is_some_and(|rest| rest.starts_with('/'))
&& route
.middleware
.iter()
.any(|label| label == PLUGIN_ASSETS_ROUTE_MARKER)
}
pub(crate) fn resolve_registered_url(rel: &str) -> Option<String> {
let (bundle, path) = split_plugin_rel(rel)?;
bundle.get(path).map(|asset| asset.url.clone())
}
pub(crate) fn is_registered_fingerprint(rel: &str) -> bool {
split_plugin_rel(rel).is_some_and(|(bundle, _)| bundle.is_fingerprinted_rel(rel))
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use http::{Request, StatusCode, header};
use tower::ServiceExt as _;
static BUNDLE: PluginAssets = PluginAssets::from_files(
"unit-test",
&[
("init.js", b"console.log('init');"),
("css/theme.min.css", b"body{color:red}"),
("LICENSE", b"MIT"),
(".gitkeep", b""),
("../escape.js", b"nope"),
("/abs.js", b"nope"),
("{name}.js", b"capture"),
("{*rest}", b"wildcard"),
("a b.js", b"space"),
("50%.js", b"percent"),
],
);
fn expected_short(bytes: &[u8]) -> String {
use sha2::{Digest as _, Sha256};
hex::encode(&Sha256::digest(bytes)[..4])
}
async fn get(path: &str, headers: &[(&str, &str)]) -> axum::response::Response {
let mut request = Request::builder().uri(path);
for (name, value) in headers {
request = request.header(*name, *value);
}
BUNDLE
.router::<()>()
.oneshot(request.body(Body::empty()).unwrap())
.await
.unwrap()
}
#[test]
fn fingerprinted_name_matches_autumn_build_naming() {
assert_eq!(
fingerprinted_name("init.js", "abcd1234"),
"init.abcd1234.js"
);
assert_eq!(
fingerprinted_name("motion.min.js", "abcd1234"),
"motion.min.abcd1234.js"
);
assert_eq!(
fingerprinted_name("css/theme.css", "abcd1234"),
"css/theme.abcd1234.css"
);
assert_eq!(
fingerprinted_name("LICENSE", "abcd1234"),
"LICENSE.abcd1234"
);
assert_eq!(
fingerprinted_name("dir.v1/LICENSE", "abcd1234"),
"dir.v1/LICENSE.abcd1234"
);
}
#[test]
fn url_is_a_function_of_the_bytes() {
let hash = expected_short(b"console.log('init');");
assert_eq!(
BUNDLE.url("init.js"),
format!("/static/_plugins/unit-test/init.{hash}.js")
);
let css = BUNDLE.get("css/theme.min.css").unwrap();
assert_eq!(
css.url(),
format!(
"/static/_plugins/unit-test/css/theme.min.{}.css",
expected_short(b"body{color:red}")
)
);
assert_eq!(
css.plain_url(),
"/static/_plugins/unit-test/css/theme.min.css"
);
assert_eq!(css.content_type(), "text/css; charset=utf-8");
}
#[test]
fn integrity_is_sha384_of_the_bytes() {
use base64::Engine as _;
use sha2::{Digest as _, Sha384};
let expected = format!(
"sha384-{}",
base64::engine::general_purpose::STANDARD.encode(Sha384::digest(b"MIT"))
);
assert_eq!(BUNDLE.integrity("LICENSE"), Some(expected.as_str()));
}
#[test]
fn unsafe_paths_and_dotfiles_are_not_part_of_the_bundle() {
let paths: Vec<&str> = BUNDLE.iter().map(PluginAsset::logical_path).collect();
assert_eq!(paths, ["LICENSE", "css/theme.min.css", "init.js"]);
assert!(BUNDLE.get("../escape.js").is_none());
assert!(BUNDLE.get("/abs.js").is_none());
assert!(BUNDLE.get(".gitkeep").is_none());
assert!(BUNDLE.get("{name}.js").is_none());
assert!(BUNDLE.get("{*rest}").is_none());
assert!(BUNDLE.get("a b.js").is_none());
assert!(BUNDLE.get("50%.js").is_none());
}
#[tokio::test]
async fn a_route_pattern_filename_does_not_match_other_paths() {
assert_eq!(
get("/static/_plugins/unit-test/anything.js", &[])
.await
.status(),
StatusCode::NOT_FOUND
);
}
#[tokio::test]
async fn a_file_named_like_another_files_hashed_url_is_dropped() {
static COLLIDING: OnceLock<PluginAssets> = OnceLock::new();
static FILES: OnceLock<Vec<(&'static str, &'static [u8])>> = OnceLock::new();
let hashed_name: &'static str =
Box::leak(fingerprinted_name("app.js", &expected_short(b"real app")).into_boxed_str());
let files = FILES.get_or_init(|| {
vec![
("app.js", b"real app".as_slice()),
(hashed_name, b"imposter".as_slice()),
]
});
let bundle = COLLIDING.get_or_init(|| PluginAssets::from_files("unit-test-collide", files));
let paths: Vec<&str> = bundle.iter().map(PluginAsset::logical_path).collect();
assert_eq!(paths, ["app.js"]);
let response = bundle
.router::<()>()
.oneshot(
Request::builder()
.uri(bundle.url("app.js"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(&body[..], b"real app");
}
#[test]
fn missing_file_url_falls_back_to_the_plain_path() {
assert_eq!(BUNDLE.url("nope.js"), "/static/_plugins/unit-test/nope.js");
assert_eq!(BUNDLE.integrity("nope.js"), None);
}
#[test]
fn route_infos_list_both_urls_as_public() {
let infos = BUNDLE.route_infos();
assert_eq!(infos.len(), 6, "three files, two URLs each");
assert!(infos.iter().all(|info| info.method == "GET"
&& info.classification == crate::route_listing::RouteClassification::Public));
assert!(infos.iter().any(|info| info.path == BUNDLE.url("init.js")));
assert!(
infos
.iter()
.any(|info| info.path == "/static/_plugins/unit-test/init.js")
);
}
#[cfg(feature = "maud")]
#[test]
fn tags_carry_hashed_url_and_integrity() {
let asset = BUNDLE.get("init.js").unwrap();
let script = BUNDLE.script_tag("init.js").into_string();
assert!(
script.contains(&format!(r#"src="{}""#, asset.url())),
"{script}"
);
assert!(
script.contains(&format!(r#"integrity="{}""#, asset.integrity())),
"{script}"
);
assert!(script.contains(r#"crossorigin="anonymous""#), "{script}");
assert!(!script.contains("defer"), "{script}");
let deferred = BUNDLE.deferred_script_tag("init.js").into_string();
assert!(deferred.contains("defer"), "{deferred}");
let link = BUNDLE.stylesheet_tag("css/theme.min.css").into_string();
assert!(link.starts_with(r#"<link rel="stylesheet""#), "{link}");
assert!(
link.contains(&format!(
r#"href="{}""#,
BUNDLE.get("css/theme.min.css").unwrap().url()
)),
"{link}"
);
}
#[cfg(feature = "maud")]
#[test]
fn missing_tag_is_an_html_comment_that_cannot_be_broken_out_of() {
let markup = BUNDLE
.script_tag("x--><script>alert(1)</script>")
.into_string();
assert!(markup.starts_with("<!--"), "{markup}");
assert_eq!(markup.matches("-->").count(), 1, "{markup}");
assert!(!markup.contains("<script"), "{markup}");
}
#[tokio::test]
async fn fingerprinted_url_is_immutable() {
let response = get(&BUNDLE.url("init.js"), &[]).await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers()[header::CACHE_CONTROL], IMMUTABLE);
assert_eq!(
response.headers()[header::CONTENT_TYPE],
"text/javascript; charset=utf-8"
);
assert!(response.headers().contains_key(header::ETAG));
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(&body[..], b"console.log('init');");
}
#[tokio::test]
async fn plain_url_revalidates() {
let response = get("/static/_plugins/unit-test/init.js", &[]).await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers()[header::CACHE_CONTROL], REVALIDATE);
}
#[tokio::test]
async fn matching_if_none_match_is_not_modified() {
let first = get("/static/_plugins/unit-test/init.js", &[]).await;
let etag = first.headers()[header::ETAG].to_str().unwrap().to_owned();
let second = get(
"/static/_plugins/unit-test/init.js",
&[("if-none-match", etag.as_str())],
)
.await;
assert_eq!(second.status(), StatusCode::NOT_MODIFIED);
assert_eq!(second.headers()[header::CACHE_CONTROL], REVALIDATE);
let body = axum::body::to_bytes(second.into_body(), usize::MAX)
.await
.unwrap();
assert!(body.is_empty());
}
#[tokio::test]
async fn range_request_is_partial() {
let response = get(&BUNDLE.url("LICENSE"), &[("range", "bytes=0-1")]).await;
assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(&body[..], b"MI");
}
#[tokio::test]
async fn unknown_and_stale_paths_are_not_served() {
assert_eq!(
get("/static/_plugins/unit-test/nope.js", &[])
.await
.status(),
StatusCode::NOT_FOUND
);
assert_eq!(
get("/static/_plugins/unit-test/init.00000000.js", &[])
.await
.status(),
StatusCode::NOT_FOUND
);
assert_eq!(
get("/static/_plugins/unit-test/.gitkeep", &[])
.await
.status(),
StatusCode::NOT_FOUND
);
}
#[test]
fn registry_resolves_plugin_paths_after_registration() {
static REGISTERED: PluginAssets =
PluginAssets::from_files("unit-test-registry", &[("a.js", b"a")]);
assert_eq!(
resolve_registered_url("_plugins/unit-test-registry/a.js"),
None
);
register(®ISTERED);
register(®ISTERED);
let url = REGISTERED.url("a.js");
assert_eq!(
resolve_registered_url("_plugins/unit-test-registry/a.js"),
Some(url.clone())
);
assert_eq!(
resolve_registered_url("_plugins/unit-test-registry/b.js"),
None
);
let rel = url.strip_prefix("/static/").unwrap();
assert!(is_registered_fingerprint(rel));
assert!(!is_registered_fingerprint(
"_plugins/unit-test-registry/a.js"
));
assert!(!is_registered_fingerprint("js/a.js"));
}
#[test]
#[should_panic(
expected = "two different PluginAssets bundles use the namespace `unit-test-takeover` in one process"
)]
fn a_second_bundle_cannot_take_over_a_registered_namespace() {
static FIRST: PluginAssets =
PluginAssets::from_files("unit-test-takeover", &[("a.js", b"first")]);
static SECOND: PluginAssets =
PluginAssets::from_files("unit-test-takeover", &[("a.js", b"second")]);
register(&FIRST);
register(&SECOND);
}
#[test]
fn a_refused_takeover_leaves_the_first_bundle_resolving() {
static FIRST: PluginAssets =
PluginAssets::from_files("unit-test-takeover-kept", &[("a.js", b"first")]);
static SECOND: PluginAssets =
PluginAssets::from_files("unit-test-takeover-kept", &[("a.js", b"second")]);
register(&FIRST);
let refused = std::panic::catch_unwind(|| register(&SECOND));
assert!(refused.is_err());
assert_eq!(
resolve_registered_url("_plugins/unit-test-takeover-kept/a.js"),
Some(FIRST.url("a.js"))
);
}
#[test]
fn only_marked_get_asset_routes_count_as_framework_asset_routes() {
let route = |method: &str, path: &str, marked: bool| crate::route_listing::RouteInfo {
method: method.to_owned(),
path: path.to_owned(),
middleware: if marked {
vec![PLUGIN_ASSETS_ROUTE_MARKER.to_owned()]
} else {
Vec::new()
},
..Default::default()
};
assert!(is_framework_asset_route(&route(
"GET",
"/static/_plugins/ns/a.js",
true
)));
assert!(!is_framework_asset_route(&route(
"GET",
"/static/_plugins/ns/a.js",
false
)));
assert!(!is_framework_asset_route(&route(
"POST",
"/static/_plugins/ns/a.js",
true
)));
assert!(!is_framework_asset_route(&route(
"GET",
"/static/app.js",
true
)));
assert!(!is_framework_asset_route(&route(
"GET",
"/static/_pluginsx/a.js",
true
)));
}
#[test]
fn framework_bundle_mounts_at_static_root() {
static FRAMEWORK: PluginAssets = PluginAssets::framework(&[("js/x.js", b"x")]);
let asset = FRAMEWORK.get("js/x.js").unwrap();
assert_eq!(asset.plain_url(), "/static/js/x.js");
assert_eq!(
asset.url(),
format!("/static/js/x.{}.js", expected_short(b"x"))
);
let rel = asset.url().strip_prefix("/static/").unwrap();
assert!(FRAMEWORK.is_fingerprinted_rel(rel));
assert!(!FRAMEWORK.is_fingerprinted_rel("js/x.js"));
}
#[test]
#[should_panic(expected = "namespace may only contain")]
fn namespace_with_a_slash_is_rejected() {
let _ = PluginAssets::from_files("bad/ns", &[]);
}
#[test]
#[should_panic(expected = "1 to 64 bytes")]
fn empty_namespace_is_rejected() {
let _ = PluginAssets::from_files("", &[]);
}
}