Skip to main content

webx_spa/
spa.rs

1//! SPA static file serving middleware.
2//!
3//! Serves files from a configured disk directory, from a table of files
4//! compiled into the executable, or both — in which case the disk copy of a
5//! given file wins and the embedded copy is the fallback.
6//! For SPA routing, non-file requests fall back to index.html.
7//!
8//! Files are handed to the host as [`FileBody`] values, so they are streamed
9//! rather than read into memory, and every response carries an `ETag` and
10//! `Last-Modified` and honours `Range` requests. That means a 2 GB video in
11//! `wwwroot` is served with a bounded memory footprint and can be seeked.
12//!
13//! Embedded entries may be stored brotli-compressed. Those are sent as-is to
14//! clients that accept `br` and decoded once (then cached) for clients that do
15//! not, so a smaller binary does not cost a decode per request.
16
17use std::collections::HashMap;
18use std::ops::ControlFlow;
19use std::path::{Path, PathBuf};
20use std::sync::{Mutex, OnceLock};
21use webx_core::error::Result;
22use webx_core::http::{FileBody, HttpStatus, IHttpContext};
23use webx_core::middleware::IMiddleware;
24
25use crate::embed::{EmbeddedAsset, EmbeddedAssets};
26
27/// How long fingerprints-addressed files may be cached.
28///
29/// Files under `/assets/` are treated as content-addressed and cached for a
30/// year; everything else must revalidate with the `ETag` the host sends.
31const IMMUTABLE_CACHE: &str = "public, max-age=31536000, immutable";
32const REVALIDATE_CACHE: &str = "public, max-age=0, must-revalidate";
33
34/// Embedded files may be answered with a brotli payload or the plain file
35/// depending on the request, so every embedded response varies on this.
36const VARY: &str = "accept-encoding";
37
38/// Environment variable that turns embedded assets off at runtime.
39///
40/// Set it to `off` (or `0` / `false` / `no`) to ignore the compiled-in files
41/// and serve only from disk — handy for bisecting an override problem without
42/// rebuilding.
43pub const EMBED_ENV: &str = "WEBX_EMBED";
44
45/// Where a [`SpaMiddleware`] takes its files from.
46///
47/// Built by `Host::builder()`: `use_spa(...)` names the disk directory;
48/// a registered `embed_assets!()` table is layered underneath at build time.
49#[derive(Debug, Clone)]
50pub enum SpaSource {
51    /// A directory, resolved the same way as `config::load_appsettings` does.
52    Disk(String),
53    /// Files compiled into the executable, served for any name that is not
54    /// present under `overlay_root`.
55    Embedded {
56        /// The compiled-in baseline.
57        assets: EmbeddedAssets,
58        /// The disk directory consulted first.
59        overlay_root: String,
60    },
61}
62
63impl SpaSource {
64    /// The disk directory consulted first; it may not exist.
65    pub fn disk_root(&self) -> &str {
66        match self {
67            SpaSource::Disk(root) => root,
68            SpaSource::Embedded { overlay_root, .. } => overlay_root,
69        }
70    }
71
72    /// The embedded baseline, if this source has one.
73    pub fn embedded(&self) -> Option<&EmbeddedAssets> {
74        match self {
75            SpaSource::Disk(_) => None,
76            SpaSource::Embedded { assets, .. } => Some(assets),
77        }
78    }
79
80    /// Use `dir` on disk, replacing whatever root was configured before.
81    ///
82    /// This is how `use_spa("wwwroot")` states where operators may drop
83    /// overrides, independently of the directory the assets were compiled from.
84    pub fn with_overlay_root(self, dir: impl Into<String>) -> Self {
85        let dir = dir.into();
86        match self {
87            SpaSource::Disk(_) => SpaSource::Disk(dir),
88            SpaSource::Embedded { assets, .. } => SpaSource::Embedded {
89                assets,
90                overlay_root: dir,
91            },
92        }
93    }
94}
95
96impl From<&str> for SpaSource {
97    fn from(root: &str) -> Self {
98        SpaSource::Disk(root.to_string())
99    }
100}
101
102impl From<String> for SpaSource {
103    fn from(root: String) -> Self {
104        SpaSource::Disk(root)
105    }
106}
107
108impl From<PathBuf> for SpaSource {
109    fn from(root: PathBuf) -> Self {
110        SpaSource::Disk(root.to_string_lossy().into_owned())
111    }
112}
113
114impl From<&Path> for SpaSource {
115    fn from(root: &Path) -> Self {
116        SpaSource::Disk(root.to_string_lossy().into_owned())
117    }
118}
119
120impl From<&String> for SpaSource {
121    fn from(root: &String) -> Self {
122        SpaSource::Disk(root.clone())
123    }
124}
125
126impl From<EmbeddedAssets> for SpaSource {
127    fn from(assets: EmbeddedAssets) -> Self {
128        SpaSource::Embedded {
129            overlay_root: assets.root().to_string(),
130            assets,
131        }
132    }
133}
134
135/// SPA static file middleware.
136///
137/// - Matches `/{filename}` against the disk root, then against embedded files
138/// - Serves files with MIME types inferred by the host
139/// - Falls back to `index.html` for unknown paths (SPA routing)
140/// - Handles GET and HEAD; other methods pass through silently
141pub struct SpaMiddleware {
142    root: PathBuf,
143    index: String,
144    embedded: Option<EmbeddedAssets>,
145    /// Embedded files are served only when this is on; see [`EMBED_ENV`].
146    embed_enabled: bool,
147}
148
149impl SpaMiddleware {
150    /// Create a new SPA middleware with default index "index.html".
151    ///
152    /// The disk root is resolved relative to the current working directory.
153    /// If the directory doesn't exist at that path, the middleware searches
154    /// upward through ancestor directories and their immediate subdirectories,
155    /// matching the strategy used by `config::load_appsettings`.
156    pub fn new(source: impl Into<SpaSource>) -> Self {
157        Self::build(source.into(), "index.html".to_string())
158    }
159
160    /// Create a new SPA middleware with a custom index file name.
161    pub fn with_index(source: impl Into<SpaSource>, index: impl Into<String>) -> Self {
162        Self::build(source.into(), index.into())
163    }
164
165    /// Create a SPA middleware from an already-built source.
166    pub fn from_source(source: SpaSource) -> Self {
167        Self::build(source, "index.html".to_string())
168    }
169
170    fn build(source: SpaSource, index: String) -> Self {
171        let embedded = source.embedded().copied();
172        if let Some(assets) = embedded {
173            assets.debug_assert_sorted();
174        }
175        Self {
176            root: resolve_spa_root(PathBuf::from(source.disk_root())),
177            index,
178            embedded,
179            embed_enabled: embedded.is_some() && embed_enabled_from_env(),
180        }
181    }
182
183    /// The disk directory actually consulted, after resolution.
184    pub fn resolved_root(&self) -> &Path {
185        &self.root
186    }
187
188    /// The embedded files this middleware may serve, if any.
189    ///
190    /// Present even when [`EMBED_ENV`] disabled them at runtime.
191    pub fn embedded(&self) -> Option<&EmbeddedAssets> {
192        self.embedded.as_ref()
193    }
194
195    /// The embedded file for a normalized key, when embedding is active.
196    fn embedded_asset(&self, key: &str) -> Option<&'static EmbeddedAsset> {
197        if !self.embed_enabled {
198            return None;
199        }
200        self.embedded.as_ref()?.get(key)
201    }
202
203    /// Normalize a request path into an embedded-table key.
204    ///
205    /// Returns `None` for paths that try to leave the root; the embedded table
206    /// is an exact-match set, so those must never be resolved loosely.
207    fn embedded_key(&self, request_path: &str) -> Option<String> {
208        let relative = alias_static_path(request_path.trim_start_matches('/'));
209        if relative.contains("..") {
210            return None;
211        }
212        Some(if relative.is_empty() {
213            self.index.clone()
214        } else {
215            relative
216        })
217    }
218}
219
220#[async_trait::async_trait]
221impl IMiddleware for SpaMiddleware {
222    async fn invoke(&self, ctx: &mut dyn IHttpContext) -> Result<ControlFlow<()>> {
223        let method = ctx.request().method().to_uppercase();
224        if method != "GET" && method != "HEAD" {
225            return Ok(ControlFlow::Continue(()));
226        }
227
228        let request_path = ctx.request().path();
229
230        // API routes must reach the router — never serve index.html for /api/*.
231        if request_path.starts_with("/api/") {
232            return Ok(ControlFlow::Continue(()));
233        }
234
235        if request_path.starts_with("/assets/") {
236            let relative = alias_static_path(request_path.trim_start_matches('/'));
237            if relative.is_empty() || relative.contains("..") {
238                ctx.response_mut().set_status(HttpStatus::NOT_FOUND);
239                return Ok(ControlFlow::Continue(()));
240            }
241            let candidate = self.root.join(&relative);
242            if candidate.is_file() {
243                return serve_file(ctx, &candidate, IMMUTABLE_CACHE).await;
244            }
245            if let Some(asset) = self.embedded_asset(&relative) {
246                return serve_embedded(ctx, asset, IMMUTABLE_CACHE).await;
247            }
248            ctx.response_mut().set_status(HttpStatus::NOT_FOUND);
249            return Ok(ControlFlow::Continue(()));
250        }
251
252        // A file of this exact name: the disk copy wins, the embedded copy is
253        // the fallback. A request for "/" resolves to the index file, so
254        // "index.html" gets the same precedence.
255        if let Some(file_path) = self.resolve_file(request_path) {
256            if file_path.is_file() {
257                return serve_file(ctx, &file_path, REVALIDATE_CACHE).await;
258            }
259        }
260        if let Some(key) = self.embedded_key(request_path) {
261            if let Some(asset) = self.embedded_asset(&key) {
262                return serve_embedded(ctx, asset, REVALIDATE_CACHE).await;
263            }
264        }
265
266        // File not found — fall back to index.html for SPA routing.
267        let index_path = self.root.join(&self.index);
268        if index_path.is_file() {
269            return serve_file(ctx, &index_path, REVALIDATE_CACHE).await;
270        }
271        if let Some(asset) = self.embedded_asset(&self.index) {
272            return serve_embedded(ctx, asset, REVALIDATE_CACHE).await;
273        }
274
275        // Neither the file nor index.html exists — let the router decide.
276        Ok(ControlFlow::Continue(()))
277    }
278}
279
280/// Hand a file to the host for streaming.
281async fn serve_file(
282    ctx: &mut dyn IHttpContext,
283    path: &Path,
284    cache_control: &str,
285) -> Result<ControlFlow<()>> {
286    ctx.response_mut().set_status(HttpStatus::OK);
287    ctx.response_mut()
288        .set_header("cache-control", cache_control);
289    ctx.response_mut()
290        .write_body(FileBody::path(path).into())
291        .await?;
292    Ok(ControlFlow::Continue(()))
293}
294
295/// Hand a compiled-in file to the host.
296///
297/// The source is seekable and its length is known, so the response carries an
298/// exact `Content-Length` and supports `Range`, `If-None-Match` and `HEAD` just
299/// like a file on disk. The `ETag` comes from the table because there is no
300/// mtime to derive one from.
301///
302/// A stored brotli payload is handed over as-is when the client accepts `br`,
303/// which costs neither a decode nor an allocation. A client that cannot read it
304/// gets the decoded bytes instead — decoded once, then reused for the life of
305/// the process.
306async fn serve_embedded(
307    ctx: &mut dyn IHttpContext,
308    asset: &'static EmbeddedAsset,
309    cache_control: &str,
310) -> Result<ControlFlow<()>> {
311    ctx.response_mut().set_status(HttpStatus::OK);
312    ctx.response_mut()
313        .set_header("cache-control", cache_control);
314
315    let token = asset.encoding.token();
316    if let Some(token) = token {
317        // Only an encoded payload can vary by `Accept-Encoding`; a verbatim one
318        // is the same bytes either way, so it is left without `Vary` rather
319        // than fragmenting a cache for no reason.
320        ctx.response_mut().set_header("vary", VARY);
321        if accepts_encoding(ctx, token) {
322            return send_embedded(ctx, asset, asset.bytes, Some(token)).await;
323        }
324        if let Some(decoded) = decoded_once(asset) {
325            return send_embedded(ctx, asset, decoded, None).await;
326        }
327    }
328    // Either the payload is stored verbatim, or an encoded one failed to decode
329    // and the stored bytes remain the best answer available.
330    send_embedded(ctx, asset, asset.bytes, token).await
331}
332
333/// Write `bytes` as the response body for `asset`.
334///
335/// `token` is the encoding `bytes` is in, so it drives both the
336/// `Content-Encoding` header and which `ETag` variant is sent.
337async fn send_embedded(
338    ctx: &mut dyn IHttpContext,
339    asset: &'static EmbeddedAsset,
340    bytes: &'static [u8],
341    token: Option<&'static str>,
342) -> Result<ControlFlow<()>> {
343    if let Some(token) = token {
344        ctx.response_mut().set_header("content-encoding", token);
345    }
346    ctx.response_mut()
347        .write_body(
348            FileBody::static_bytes(bytes)
349                .content_type(asset.content_type)
350                .entity_tag(asset.etag_for(token.is_some()))
351                .into(),
352        )
353        .await?;
354    Ok(ControlFlow::Continue(()))
355}
356
357/// Whether the request's `Accept-Encoding` allows `token`.
358fn accepts_encoding(ctx: &dyn IHttpContext, token: &str) -> bool {
359    ctx.request()
360        .header("accept-encoding")
361        .is_some_and(|value| header_allows(value, token))
362}
363
364/// Parse one `Accept-Encoding` header, honouring an explicit `q=0`.
365///
366/// Listing the token is enough to allow it, and `*` stands in for every coding
367/// the client did not name — some clients and CDNs send only `*`. An explicit
368/// `q=0` rejects the token, which is how a client asks for the identity
369/// representation while still advertising support.
370fn header_allows(value: &str, token: &str) -> bool {
371    let mut wildcard = None;
372    for entry in value.split(',') {
373        let mut params = entry.split(';');
374        let name = params.next().unwrap_or("").trim();
375        // Quality defaults to 1 when omitted; only an explicit 0 rejects.
376        let quality = params
377            .filter_map(|param| param.trim().strip_prefix("q="))
378            .find_map(|quality| quality.trim().parse::<f32>().ok())
379            .unwrap_or(1.0);
380
381        if name.eq_ignore_ascii_case(token) {
382            return quality > 0.0;
383        }
384        if name == "*" {
385            wildcard = Some(quality);
386        }
387    }
388    wildcard.is_some_and(|quality| quality > 0.0)
389}
390
391/// Decoded payload for `asset`, decoded at most once per process.
392///
393/// The bytes are leaked so they can be handed to the response as a
394/// `&'static [u8]`. The cost is bounded by the size of the embedded tree and is
395/// only paid for assets that a client without `br` actually requests.
396fn decoded_once(asset: &'static EmbeddedAsset) -> Option<&'static [u8]> {
397    static DECODED: OnceLock<Mutex<HashMap<&'static str, &'static [u8]>>> = OnceLock::new();
398
399    let cache = DECODED.get_or_init(|| Mutex::new(HashMap::new()));
400    let mut cache = cache.lock().ok()?;
401    if let Some(bytes) = cache.get(asset.path) {
402        return Some(bytes);
403    }
404    let bytes: &'static [u8] = Box::leak(asset.decode()?.into_boxed_slice());
405    cache.insert(asset.path, bytes);
406    Some(bytes)
407}
408
409impl SpaMiddleware {
410    /// Resolve a request path to a filesystem path, preventing traversal.
411    /// Resolve a request path to the file it names under the root.
412    ///
413    /// `None` means the path escapes the root, so it is treated as a miss.
414    /// A returned path may still not exist: the caller tries disk first, then
415    /// the compiled-in copy, before falling back to the index for SPA routing.
416    /// Resolving a *missing* file straight to the index here would hide every
417    /// embedded file that has no disk counterpart, so this method never
418    /// substitutes the index.
419    fn resolve_file(&self, request_path: &str) -> Option<PathBuf> {
420        let relative = alias_static_path(request_path.trim_start_matches('/'));
421        if relative.is_empty() {
422            return Some(self.root.join(&self.index));
423        }
424
425        let candidate = self.root.join(&relative);
426
427        // Reject anything that escapes the root before touching the filesystem.
428        if !is_safe_subpath(&self.root, &candidate) {
429            return None;
430        }
431
432        // Follow symlinks only when the result still stays inside the root.
433        match candidate.canonicalize() {
434            Ok(resolved) if is_canonical_subpath(&self.root, &resolved) => Some(resolved),
435            Ok(_) => None,
436            Err(_) => Some(candidate),
437        }
438    }
439}
440
441/// Map vendor paths that include a redundant `dist/` segment to on-disk layout.
442///
443/// Vditor resolves assets as `{cdn}/dist/js/...` while our vendored tree keeps
444/// `js/`, `css/`, and bundle files at the package root.
445fn alias_static_path(relative: &str) -> String {
446    const VDITOR_DIST: &str = "assets/vendor/vditor-dist/dist/";
447    if let Some(rest) = relative.strip_prefix(VDITOR_DIST) {
448        format!("assets/vendor/vditor-dist/{rest}")
449    } else {
450        relative.to_string()
451    }
452}
453
454/// Whether embedded files should be served, per [`EMBED_ENV`].
455fn embed_enabled_from_env() -> bool {
456    embed_enabled(std::env::var(EMBED_ENV).ok().as_deref())
457}
458
459/// Interpret the [`EMBED_ENV`] value. Unset, or anything not recognised as
460/// "off", leaves embedding enabled.
461fn embed_enabled(value: Option<&str>) -> bool {
462    match value {
463        Some(value) => !matches!(
464            value.trim().to_ascii_lowercase().as_str(),
465            "off" | "0" | "false" | "no"
466        ),
467        None => true,
468    }
469}
470
471/// Check that `candidate` is a sub-path of `root` without requiring the
472/// candidate to exist on disk (canonicalize requires the file exists).
473/// Whether `candidate` stays inside `root`, compared lexically.
474///
475/// Deliberately avoids `canonicalize` on the candidate: on Windows it returns a
476/// `\\?\`-prefixed verbatim path, which never compares equal to a plainly
477/// joined one, so every missing file would look like a traversal attempt.
478/// `..` segments are removed without consulting the filesystem, which is what
479/// the traversal defence actually needs.
480fn is_safe_subpath(root: &Path, candidate: &Path) -> bool {
481    let root_abs = normalize_path(&absolute(root));
482    let candidate_abs = normalize_path(&absolute(candidate));
483    candidate_abs.starts_with(&root_abs)
484}
485
486/// Whether an already-canonicalized path stays inside the root, following
487/// symlinks on both sides.
488fn is_canonical_subpath(root: &Path, resolved: &Path) -> bool {
489    match root.canonicalize() {
490        Ok(root) => resolved.starts_with(&root),
491        // The root itself is missing, so there is nothing to compare against.
492        Err(_) => is_safe_subpath(root, resolved),
493    }
494}
495
496/// Make a path absolute against the working directory without touching the
497/// filesystem, so the result stays in the same form as `root`.
498fn absolute(path: &Path) -> PathBuf {
499    if path.is_absolute() {
500        return path.to_path_buf();
501    }
502    match std::env::current_dir() {
503        Ok(cwd) => cwd.join(path),
504        Err(_) => path.to_path_buf(),
505    }
506}
507
508/// Remove "." and ".." segments from a path without consulting the filesystem.
509fn normalize_path(path: &Path) -> PathBuf {
510    let mut parts: Vec<&std::ffi::OsStr> = Vec::new();
511    for component in path.components() {
512        match component {
513            std::path::Component::ParentDir => {
514                parts.pop();
515            }
516            std::path::Component::CurDir => {}
517            other => {
518                parts.push(other.as_os_str());
519            }
520        }
521    }
522    let mut result = PathBuf::new();
523    for part in parts {
524        result.push(part);
525    }
526    result
527}
528
529/// Resolve a SPA root path by first checking the application base directory
530/// (as resolved by `webx_core::paths::app_base`), then as-is.
531///
532/// This mirrors the strategy used by `config::load_appsettings` so that
533/// `use_spa("wwwroot")` works whether the user runs from `demo/`, from
534/// the workspace root (`rust-webx/`), or from a deployment directory
535/// (exe alongside `wwwroot/`).
536fn resolve_spa_root(root: PathBuf) -> PathBuf {
537    // If the path is already absolute or exists, use it directly.
538    if root.is_absolute() || root.exists() {
539        return root;
540    }
541
542    // 应用基准目录(exe 同级 / cwd / 上溯统一由 app_base 处理)。
543    let candidate = webx_core::paths::app_base().join(&root);
544    if candidate.exists() {
545        return candidate;
546    }
547
548    root
549}
550
551#[cfg(test)]
552mod tests {
553    use super::{alias_static_path, header_allows, normalize_path, SpaSource};
554    use crate::embed::{ContentEncoding, EmbeddedAsset, EmbeddedAssets};
555    use std::path::Path;
556
557    const FILES: &[EmbeddedAsset] = &[EmbeddedAsset {
558        path: "index.html",
559        bytes: b"index",
560        raw_len: 5,
561        encoding: ContentEncoding::Identity,
562        content_type: "text/html",
563        etag: "\"sha256-1\"",
564    }];
565
566    fn embedded() -> SpaSource {
567        SpaSource::Embedded {
568            assets: EmbeddedAssets::new("wwwroot", FILES),
569            overlay_root: "wwwroot".to_string(),
570        }
571    }
572
573    #[test]
574    fn alias_static_path_strips_vditor_dist_segment() {
575        let out = alias_static_path("assets/vendor/vditor-dist/dist/js/foo.js");
576        assert_eq!(out, "assets/vendor/vditor-dist/js/foo.js");
577    }
578
579    #[test]
580    fn normalize_path_removes_dot_dot() {
581        let p = normalize_path(Path::new("a/../b/./c"));
582        assert_eq!(p, Path::new("b/c"));
583    }
584
585    /// Regression: a missing file must resolve to itself, not to `index.html`.
586    ///
587    /// Rewriting misses to the index hid every compiled-in file that had no disk
588    /// counterpart, because the caller then served the index instead of trying
589    /// the embedded table. It only showed up on Windows, where the old
590    /// containment check compared a `\\?\`-prefixed canonical root against a
591    /// plainly joined candidate and so reported a miss as a traversal attempt.
592    #[test]
593    fn missing_file_resolves_to_itself_not_the_index() {
594        let dir = tempfile::tempdir().unwrap();
595        std::fs::write(dir.path().join("index.html"), b"shell").unwrap();
596        let source = embedded().with_overlay_root(dir.path().to_string_lossy().into_owned());
597        let mw = super::SpaMiddleware::from_source(source);
598
599        let resolved = mw.resolve_file("/app.js").expect("inside the root");
600        assert_eq!(resolved, dir.path().join("app.js"));
601        assert!(!resolved.is_file(), "this is the miss case");
602
603        // The index is still reachable for SPA routing, via the fallback the
604        // caller applies once both the disk and embedded lookups miss.
605        let index = mw.resolve_file("/").expect("inside the root");
606        assert!(index.is_file());
607
608        // Escaping the root stays rejected.
609        assert_eq!(mw.resolve_file("/../secrets.txt"), None);
610        assert_eq!(mw.resolve_file("/assets/../../app.db"), None);
611    }
612
613    #[test]
614    fn skips_api_paths_for_spa_fallback() {
615        // API paths are passed through to the router; no static/index.html response.
616        // Full integration is covered by host tests; this documents the guard.
617        assert!("/api/docs".starts_with("/api/"));
618        assert!(!"/works/foo".starts_with("/api/"));
619    }
620
621    #[test]
622    fn embedded_key_maps_root_to_index_and_applies_aliases() {
623        let mw = super::SpaMiddleware::new(embedded());
624        assert_eq!(mw.embedded_key("/").as_deref(), Some("index.html"));
625        assert_eq!(
626            mw.embedded_key("/assets/vendor/vditor-dist/dist/js/foo.js")
627                .as_deref(),
628            Some("assets/vendor/vditor-dist/js/foo.js")
629        );
630        assert_eq!(mw.embedded_key("some/page").as_deref(), Some("some/page"));
631    }
632
633    #[test]
634    fn embedded_key_rejects_traversal() {
635        let mw = super::SpaMiddleware::new(embedded());
636        assert_eq!(mw.embedded_key("/../secrets.txt"), None);
637        assert_eq!(mw.embedded_key("/assets/../../app.db"), None);
638    }
639
640    #[test]
641    fn embedded_is_served_only_for_the_embedded_source() {
642        let disk = super::SpaMiddleware::new(SpaSource::Disk("wwwroot".to_string()));
643        assert!(disk.embedded().is_none());
644        assert!(disk.embedded_asset("index.html").is_none());
645
646        let with_embedded = super::SpaMiddleware::new(embedded());
647        assert!(with_embedded.embedded().is_some());
648        assert!(with_embedded.embedded_asset("index.html").is_some());
649        assert!(with_embedded.embedded_asset("missing.html").is_none());
650    }
651
652    #[test]
653    fn disk_root_and_build_root_are_independent() {
654        // `use_spa("wwwroot")` then `embed_assets("frontend/dist")` in build.rs:
655        // overrides come from wwwroot, the baseline is compiled from dist.
656        let source = SpaSource::from(EmbeddedAssets::new("frontend/dist", FILES))
657            .with_overlay_root("wwwroot");
658        assert_eq!(source.disk_root(), "wwwroot");
659        assert_eq!(source.embedded().unwrap().root(), "frontend/dist");
660        assert_eq!(source.embedded().unwrap().len(), 1);
661    }
662
663    #[test]
664    fn overlay_root_defaults_to_the_build_root() {
665        let source = SpaSource::from(EmbeddedAssets::new("wwwroot", FILES));
666        assert_eq!(source.disk_root(), "wwwroot");
667    }
668
669    #[test]
670    fn with_overlay_root_replaces_a_disk_source() {
671        let source = SpaSource::from("static").with_overlay_root("wwwroot");
672        assert_eq!(source.disk_root(), "wwwroot");
673        assert!(source.embedded().is_none());
674    }
675
676    #[test]
677    fn embed_env_value_is_parsed() {
678        use super::embed_enabled;
679        // Unset, or anything not recognised as "off", keeps embedding on.
680        assert!(embed_enabled(None));
681        assert!(embed_enabled(Some("on")));
682        assert!(embed_enabled(Some("embed")));
683        // "off" is matched case-insensitively and after trimming whitespace.
684        assert!(!embed_enabled(Some("off")));
685        assert!(!embed_enabled(Some(" OFF  ")));
686        assert!(!embed_enabled(Some("false")));
687        assert!(!embed_enabled(Some("0")));
688        assert!(!embed_enabled(Some("no")));
689    }
690
691    #[test]
692    fn accept_encoding_recognises_brotli() {
693        assert!(header_allows("gzip, br", "br"));
694        assert!(header_allows("br", "br"));
695        assert!(header_allows("gzip, deflate, br, zstd", "br"));
696        assert!(header_allows("BR", "br"));
697        assert!(header_allows("gzip, br;q=1.0", "br"));
698    }
699
700    #[test]
701    fn accept_encoding_honours_a_zero_quality() {
702        assert!(!header_allows("br;q=0", "br"));
703        assert!(!header_allows("gzip, br;q=0.0", "br"));
704        assert!(!header_allows("gzip", "br"));
705        assert!(!header_allows("", "br"));
706        // A different encoding listed as zero does not affect brotli.
707        assert!(header_allows("gzip;q=0, br", "br"));
708    }
709
710    #[test]
711    fn accept_encoding_understands_the_wildcard() {
712        // Some clients and CDNs advertise only `*`.
713        assert!(header_allows("*", "br"));
714        assert!(header_allows("gzip, *", "br"));
715        assert!(header_allows("*;q=0.5", "br"));
716        // `*;q=0` means "nothing else is acceptable".
717        assert!(!header_allows("*;q=0", "br"));
718        assert!(!header_allows("gzip, *;q=0", "br"));
719        // An explicit token wins over the wildcard, in both directions.
720        assert!(header_allows("*;q=0, br", "br"));
721        assert!(!header_allows("*;q=1, br;q=0", "br"));
722    }
723
724    #[test]
725    fn decoded_payloads_are_cached_per_path() {
726        let source = "x".repeat(2048);
727        let payload = {
728            use std::io::Write as _;
729            let mut out = Vec::new();
730            let mut encoder = brotli::CompressorWriter::new(&mut out, 4096, 11, 22);
731            encoder.write_all(source.as_bytes()).unwrap();
732            encoder.flush().unwrap();
733            drop(encoder);
734            out
735        };
736        let asset: &'static EmbeddedAsset = Box::leak(Box::new(EmbeddedAsset {
737            path: "cached.txt",
738            bytes: Box::leak(payload.into_boxed_slice()),
739            raw_len: source.len(),
740            encoding: ContentEncoding::Brotli,
741            content_type: "text/plain",
742            etag: "\"sha256-c\"",
743        }));
744
745        let first = super::decoded_once(asset).expect("decodes");
746        let second = super::decoded_once(asset).expect("cached");
747        assert_eq!(first, source.as_bytes());
748        // Same allocation on the second call, so nothing is decoded twice.
749        assert!(std::ptr::eq(first.as_ptr(), second.as_ptr()));
750    }
751}