Skip to main content

autumn_web/assets/
plugin.rs

1//! Fingerprinted asset bundles compiled into a crate.
2//!
3//! The `autumn build` pipeline fingerprints the files in an **app's**
4//! `static/` directory. A plugin crate's JavaScript, CSS, fonts and images
5//! never sit in that directory: they are compiled into the plugin with
6//! `include_bytes!`/`include_dir!`. A [`PluginAssets`] bundle gives those
7//! files the same treatment without a build step. The bytes are fixed at
8//! compile time, so each file's fingerprint comes from the bytes themselves.
9//!
10//! - Each file gets a content-hashed URL under `/static/_plugins/<namespace>/`
11//!   (`init.js` → `/static/_plugins/motion/init.3f9a12c0.js`). The hash is
12//!   the first 8 hex digits of the file's SHA-256, the same naming that
13//!   `autumn build` uses for `static/`.
14//! - The fingerprinted URL is served `immutable` for a year. The plain URL
15//!   (`/static/_plugins/motion/init.js`) also works, with `must-revalidate`.
16//!   Both carry an `ETag`, answer `If-None-Match` with `304`, and support
17//!   `Range`.
18//! - Each file also gets a `sha384` Subresource Integrity hash, so the
19//!   `<script>`/`<link>` helpers emit `integrity=` with no hand-kept
20//!   constants.
21//!
22//! A plugin declares the bundle once as a `static` and installs it from
23//! [`Plugin::build`](crate::plugin::Plugin::build):
24//!
25//! ```rust,ignore
26//! use autumn_web::assets::PluginAssets;
27//!
28//! pub static ASSETS: PluginAssets =
29//!     autumn_web::plugin_assets!("motion", "$CARGO_MANIFEST_DIR/assets");
30//!
31//! impl Plugin for MotionPlugin {
32//!     fn build(self, app: AppBuilder) -> AppBuilder {
33//!         app.plugin_assets(&ASSETS)
34//!     }
35//! }
36//!
37//! // In a layout:
38//! html! { head { (ASSETS.deferred_script_tag("init.js")) } }
39//! ```
40//!
41//! Templates can also go through [`asset_url`](crate::assets::asset_url),
42//! with the bundle path under `_plugins/`:
43//! `asset_url("_plugins/motion/init.js")`.
44
45use std::collections::HashMap;
46use std::sync::{OnceLock, RwLock};
47
48/// URL prefix that every plugin bundle mounts under, followed by its
49/// namespace: `/static/_plugins/<namespace>/…`.
50pub const PLUGIN_ASSETS_PREFIX: &str = "/static/_plugins";
51
52/// Path of a bundle under `/static/`, without the leading `/static/`. Used
53/// to recognise plugin paths in [`asset_url`](crate::assets::asset_url) and
54/// the cache-control policy.
55const PLUGIN_ASSETS_REL_PREFIX: &str = "_plugins/";
56
57/// The `middleware` label on every route a bundle declares for the listing.
58///
59/// Only [`AppBuilder::plugin_assets`](crate::app::AppBuilder::plugin_assets)
60/// can attach it: `declare_plugin_routes` strips it from anything a plugin
61/// declares. `autumn plugin-check` exempts a route under `/static/_plugins/`
62/// from its prefix and sensitive-name checks only when it carries this label,
63/// so a hand-declared route at such a path gets no free pass.
64pub const PLUGIN_ASSETS_ROUTE_MARKER: &str = "plugin-assets";
65
66/// `Cache-Control` for a fingerprinted URL. Its bytes can never change.
67const IMMUTABLE: &str = "public, max-age=31536000, immutable";
68
69/// `Cache-Control` for a plain URL. Its bytes change when the crate does.
70const REVALIDATE: &str = "public, max-age=0, must-revalidate";
71
72/// Where a bundle's files come from.
73#[derive(Clone, Copy)]
74enum Source {
75    /// An explicit `(logical path, bytes)` list.
76    Files(&'static [(&'static str, &'static [u8])]),
77    /// A directory embedded with `include_dir!`.
78    #[cfg(feature = "embed-assets")]
79    Dir(&'static include_dir::Dir<'static>),
80}
81
82/// Where a bundle is mounted.
83#[derive(Clone, Copy, Debug, PartialEq, Eq)]
84enum Mount {
85    /// `/static/_plugins/<namespace>/` — every plugin bundle.
86    Plugin,
87    /// `/static/` itself — the framework's own scripts, which keep their
88    /// historical `/static/js/…` paths.
89    StaticRoot,
90}
91
92/// A set of files compiled into a crate and served with content-hashed URLs.
93///
94/// Build one with `plugin_assets!` (a directory; `embed-assets` feature) or
95/// [`PluginAssets::from_files`] (an explicit list), store it in a `static`,
96/// and install it with
97/// [`AppBuilder::plugin_assets`](crate::app::AppBuilder::plugin_assets). See
98/// the [module docs](self) for the URL scheme and caching policy.
99///
100/// Hashes are computed once, the first time the bundle is used.
101pub 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/// One file in a [`PluginAssets`] bundle, with its derived URLs and hashes.
119#[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    /// The path inside the bundle, e.g. `"init.js"` or `"css/theme.css"`.
132    #[must_use]
133    pub fn logical_path(&self) -> &str {
134        &self.logical_path
135    }
136
137    /// The content-hashed URL, e.g. `/static/_plugins/motion/init.3f9a12c0.js`.
138    /// Served with a year-long `immutable` cache.
139    #[must_use]
140    pub fn url(&self) -> &str {
141        &self.url
142    }
143
144    /// The plain URL, e.g. `/static/_plugins/motion/init.js`. Served with
145    /// `must-revalidate`, for references that cannot use [`url`](Self::url).
146    #[must_use]
147    pub fn plain_url(&self) -> &str {
148        &self.plain_url
149    }
150
151    /// The `sha384-<base64>` Subresource Integrity hash of the bytes.
152    #[must_use]
153    pub fn integrity(&self) -> &str {
154        &self.integrity
155    }
156
157    /// The file's bytes.
158    #[must_use]
159    pub const fn bytes(&self) -> &'static [u8] {
160        self.bytes
161    }
162
163    /// The `Content-Type` the file is served with.
164    #[must_use]
165    pub const fn content_type(&self) -> &'static str {
166        self.content_type
167    }
168}
169
170/// The hashed view of a bundle, built on first use.
171struct Index {
172    assets: Vec<PluginAsset>,
173    /// Logical path → position in `assets`.
174    by_logical: HashMap<String, usize>,
175    /// Fingerprinted path relative to `/static/` → position in `assets`.
176    by_fingerprinted_rel: HashMap<String, usize>,
177}
178
179impl PluginAssets {
180    /// A bundle from an explicit list of `(logical path, bytes)` pairs.
181    ///
182    /// Use this when the files are already `include_bytes!`/`include_str!`
183    /// constants, or when the crate does not enable `embed-assets`:
184    ///
185    /// ```rust
186    /// use autumn_web::assets::PluginAssets;
187    ///
188    /// static ASSETS: PluginAssets = PluginAssets::from_files(
189    ///     "charts",
190    ///     &[("charts.js", b"console.log('charts');")],
191    /// );
192    /// assert!(ASSETS.url("charts.js").starts_with("/static/_plugins/charts/charts."));
193    /// ```
194    ///
195    /// A logical path is relative (no leading `/`), uses `/` separators, and
196    /// has no `..` or empty segments. Entries that break these rules, and
197    /// dotfiles, are skipped with a warning when the bundle is first used.
198    ///
199    /// # Panics
200    ///
201    /// Panics if `namespace` is empty, longer than 64 bytes, or contains
202    /// anything other than ASCII lowercase letters, digits, `-` and `_`. In a
203    /// `static` this is a compile-time error.
204    #[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    /// A bundle from a directory embedded with `include_dir!`. Every file in
219    /// the tree is included, except dotfiles. Prefer the
220    /// [`plugin_assets!`](crate::plugin_assets) macro, which embeds the
221    /// directory for you.
222    ///
223    /// # Panics
224    ///
225    /// Same namespace rules as [`from_files`](Self::from_files).
226    #[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    /// The framework's own scripts, mounted at `/static/` so they keep their
242    /// historical paths (`/static/js/htmx.min.js`).
243    #[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    /// The bundle's namespace, e.g. `"motion"`.
254    #[must_use]
255    pub const fn namespace(&self) -> &'static str {
256        self.namespace
257    }
258
259    /// The URL path the bundle is served under, e.g. `/static/_plugins/motion`.
260    #[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    /// The file at `logical_path`, or `None` when the bundle has no such file.
269    #[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    /// Every file in the bundle, sorted by logical path.
279    pub fn iter(&self) -> impl Iterator<Item = &PluginAsset> {
280        self.index().assets.iter()
281    }
282
283    /// The content-hashed URL of `logical_path`.
284    ///
285    /// When the bundle has no such file this logs a warning and returns the
286    /// plain URL, which will `404`. Use [`get`](Self::get) to handle a
287    /// missing file yourself.
288    #[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    /// The `sha384-<base64>` Subresource Integrity hash of `logical_path`.
302    #[must_use]
303    pub fn integrity(&self, logical_path: &str) -> Option<&str> {
304        self.get(logical_path).map(PluginAsset::integrity)
305    }
306
307    /// A `<script>` tag for `logical_path`, with the hashed `src`, `integrity`
308    /// and `crossorigin="anonymous"`.
309    ///
310    /// When the bundle has no such file this logs a warning and returns an
311    /// HTML comment naming the file, so the gap shows in View Source.
312    #[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    /// Like [`script_tag`](Self::script_tag), with the `defer` attribute.
326    #[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    /// A `<link rel="stylesheet">` tag for `logical_path`, with the hashed
340    /// `href`, `integrity` and `crossorigin="anonymous"`.
341    ///
342    /// A missing file behaves as in [`script_tag`](Self::script_tag).
343    #[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        // The path ends up inside an HTML comment. With no `>` left in it,
364        // nothing in it can close the comment early.
365        let safe = logical_path
366            .replace('&', "&amp;")
367            .replace('<', "&lt;")
368            .replace('>', "&gt;");
369        maud::html! {
370            (maud::PreEscaped(format!(
371                "<!-- autumn: asset '{safe}' not found in plugin bundle '{}' -->",
372                self.namespace
373            )))
374        }
375    }
376
377    /// A router serving every file at both its plain and its fingerprinted
378    /// URL, using absolute paths. Merge it yourself only for a custom setup;
379    /// [`AppBuilder::plugin_assets`](crate::app::AppBuilder::plugin_assets)
380    /// mounts the bundle for you.
381    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    /// The router [`AppBuilder::plugin_assets`] nests at
389    /// [`mount_path`](Self::mount_path): the same routes as
390    /// [`router`](Self::router), relative to the mount.
391    ///
392    /// [`AppBuilder::plugin_assets`]: crate::app::AppBuilder::plugin_assets
393    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    /// The bundle's routes in the shape `autumn routes` lists them. Static
408    /// files are public, so every entry is classified
409    /// [`Public`](crate::route_listing::RouteClassification::Public) and
410    /// does not trip `autumn routes audit`.
411    #[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    /// `true` when `rel` (a path relative to `/static/`) is one of this
427    /// bundle's fingerprinted paths.
428    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        // Hash once, then drop any file whose own path is another file's
460        // fingerprinted path (`app.js` hashing to `2d711642` next to a file
461        // literally named `app.2d711642.js`): both would claim one URL, and
462        // axum refuses to route a path twice.
463        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
524/// A router serving each of `assets` at its plain and its fingerprinted URL,
525/// with `mount` (the bundle's mount path) replaced by `base` in each route.
526pub(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
549/// The hashes derived from one file's bytes.
550struct Hashes {
551    /// First 8 hex digits of the SHA-256: the fingerprint in the filename.
552    short: String,
553    /// `sha384-<base64>` Subresource Integrity value.
554    integrity: String,
555    /// Weak `ETag` over the full SHA-256. Weak, because a compression layer
556    /// may re-encode the body after the handler sets it, and a strong tag
557    /// would then claim byte equality that does not hold.
558    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
579/// `dir/name.ext` → `dir/name.<hash>.ext`; `dir/name` → `dir/name.<hash>`.
580/// Matches the naming `autumn build` uses for the app's `static/` tree, so a
581/// `motion.min.js` becomes `motion.min.<hash>.js`.
582fn 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
597/// A relative, `/`-separated path whose segments are non-empty, not dotfiles
598/// (so never `.` or `..`), and spelled only in URL-unreserved characters
599/// (`A-Z a-z 0-9 - . _ ~`). The path becomes both a URL and an axum route
600/// pattern, so anything with routing meaning (`{name}`, `*`, `:`) or that
601/// would need percent-encoding (spaces, `%`, `?`, `#`) is refused rather than
602/// turned into a capture or a route no request can match.
603fn 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
614/// `true` when any segment of `path` is a dotfile. Those are skipped quietly:
615/// an embedded directory routinely holds a `.gitkeep` or `.DS_Store`.
616fn is_dotfile(path: &str) -> bool {
617    path.split(['/', '\\'])
618        .any(|segment| segment.starts_with('.') && segment.len() > 1 && segment != "..")
619}
620
621/// Every file under `dir`, with paths relative to the embedded root.
622#[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                // `include_dir` records paths with the host's separator; URLs
629                // need `/`.
630                let path = file.path().to_string_lossy().replace('\\', "/");
631                out.push((path, file.contents()));
632            }
633        }
634    }
635}
636
637/// Compile-time check behind [`PluginAssets::from_files`]: the namespace
638/// becomes a URL segment, so keep it to a safe, unambiguous alphabet.
639const 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
656/// Serve one bundle file: conditional `GET`, `Range`, content type and the
657/// cache policy for the URL form that was requested.
658fn 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    // The tag is weak, so it can never satisfy `If-Range`; a conditional
667    // range request gets the full body, as RFC 7233 requires.
668    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
684// ── Process-wide registry, for `asset_url` ──────────────────────────────────
685//
686// `asset_url` is a free function that templates call with no app handle, so
687// resolving `_plugins/<ns>/…` needs a process-wide lookup. The registry only
688// ever maps a namespace to a `&'static` bundle, and a bundle's URLs are a
689// pure function of its compiled bytes, so two apps in one process (tests)
690// registering the same bundle see identical answers. Serving stays per app:
691// each `AppBuilder` mounts only the bundles it was given.
692
693static REGISTRY: RwLock<Vec<&'static PluginAssets>> = RwLock::new(Vec::new());
694
695/// Record `bundle` so [`asset_url`](crate::assets::asset_url) can resolve
696/// its paths. Registering the same bundle again is a no-op.
697///
698/// # Panics
699///
700/// Panics when a *different* bundle already registered this namespace in the
701/// process. `asset_url` has no app handle, so it could only answer for one of
702/// them, and templates of the other app would get URLs its router does not
703/// serve. [`AppBuilder::plugin_assets`](crate::app::AppBuilder::plugin_assets)
704/// refuses the same clash within one app, with a message naming the URL path.
705pub(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        // Release the lock before panicking so a caught panic (a test) does
715        // not poison the registry for everyone else.
716        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
737/// Split `_plugins/<ns>/<path>` into the registered bundle and `<path>`.
738fn 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
744/// `true` for a route `AppBuilder::plugin_assets` declared: a `GET` under
745/// `/static/_plugins/` carrying [`PLUGIN_ASSETS_ROUTE_MARKER`].
746///
747/// The router's duplicate-route preflight refuses every declared route under
748/// `/static`; these are the framework's own mounts, so it lets them through.
749/// The marker cannot be forged: `declare_plugin_routes` (and with it every
750/// sandbox manifest) strips it.
751pub(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
763/// The fingerprinted URL for `rel` (a path relative to `/static/`) when it
764/// names a file in a registered plugin bundle.
765pub(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
770/// `true` when `rel` (a path relative to `/static/`) is a fingerprinted path
771/// of a registered plugin bundle.
772pub(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    /// A route-pattern filename must not become a capture that answers for
887    /// arbitrary paths.
888    #[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    /// A file named like another file's fingerprinted URL would claim the same
899    /// route; it is dropped rather than letting router construction panic.
900    #[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(&REGISTERED);
1075        register(&REGISTERED);
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        // No marker: a plugin declared it by hand.
1139        assert!(!is_framework_asset_route(&route(
1140            "GET",
1141            "/static/_plugins/ns/a.js",
1142            false
1143        )));
1144        // Bundles only ever mount GETs.
1145        assert!(!is_framework_asset_route(&route(
1146            "POST",
1147            "/static/_plugins/ns/a.js",
1148            true
1149        )));
1150        // Outside the bundle prefix, the marker means nothing.
1151        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}