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, or that
396/// the application reads through [`EmbeddedAssets::read`].
397pub(crate) fn decoded_once(asset: &'static EmbeddedAsset) -> Option<&'static [u8]> {
398    static DECODED: OnceLock<Mutex<HashMap<&'static str, &'static [u8]>>> = OnceLock::new();
399
400    let cache = DECODED.get_or_init(|| Mutex::new(HashMap::new()));
401    let mut cache = cache.lock().ok()?;
402    if let Some(bytes) = cache.get(asset.path) {
403        return Some(bytes);
404    }
405    let bytes: &'static [u8] = Box::leak(asset.decode()?.into_boxed_slice());
406    cache.insert(asset.path, bytes);
407    Some(bytes)
408}
409
410impl SpaMiddleware {
411    /// Resolve a request path to a filesystem path, preventing traversal.
412    /// Resolve a request path to the file it names under the root.
413    ///
414    /// `None` means the path escapes the root, so it is treated as a miss.
415    /// A returned path may still not exist: the caller tries disk first, then
416    /// the compiled-in copy, before falling back to the index for SPA routing.
417    /// Resolving a *missing* file straight to the index here would hide every
418    /// embedded file that has no disk counterpart, so this method never
419    /// substitutes the index.
420    fn resolve_file(&self, request_path: &str) -> Option<PathBuf> {
421        let relative = alias_static_path(request_path.trim_start_matches('/'));
422        if relative.is_empty() {
423            return Some(self.root.join(&self.index));
424        }
425
426        let candidate = self.root.join(&relative);
427
428        // Reject anything that escapes the root before touching the filesystem.
429        if !is_safe_subpath(&self.root, &candidate) {
430            return None;
431        }
432
433        // Follow symlinks only when the result still stays inside the root.
434        match candidate.canonicalize() {
435            Ok(resolved) if is_canonical_subpath(&self.root, &resolved) => Some(resolved),
436            Ok(_) => None,
437            Err(_) => Some(candidate),
438        }
439    }
440}
441
442/// Map vendor paths that include a redundant `dist/` segment to on-disk layout.
443///
444/// Vditor resolves assets as `{cdn}/dist/js/...` while our vendored tree keeps
445/// `js/`, `css/`, and bundle files at the package root.
446fn alias_static_path(relative: &str) -> String {
447    const VDITOR_DIST: &str = "assets/vendor/vditor-dist/dist/";
448    if let Some(rest) = relative.strip_prefix(VDITOR_DIST) {
449        format!("assets/vendor/vditor-dist/{rest}")
450    } else {
451        relative.to_string()
452    }
453}
454
455/// Whether embedded files should be served, per [`EMBED_ENV`].
456fn embed_enabled_from_env() -> bool {
457    embed_enabled(std::env::var(EMBED_ENV).ok().as_deref())
458}
459
460/// Interpret the [`EMBED_ENV`] value. Unset, or anything not recognised as
461/// "off", leaves embedding enabled.
462fn embed_enabled(value: Option<&str>) -> bool {
463    match value {
464        Some(value) => !matches!(
465            value.trim().to_ascii_lowercase().as_str(),
466            "off" | "0" | "false" | "no"
467        ),
468        None => true,
469    }
470}
471
472/// Check that `candidate` is a sub-path of `root` without requiring the
473/// candidate to exist on disk (canonicalize requires the file exists).
474/// Whether `candidate` stays inside `root`, compared lexically.
475///
476/// Deliberately avoids `canonicalize` on the candidate: on Windows it returns a
477/// `\\?\`-prefixed verbatim path, which never compares equal to a plainly
478/// joined one, so every missing file would look like a traversal attempt.
479/// `..` segments are removed without consulting the filesystem, which is what
480/// the traversal defence actually needs.
481fn is_safe_subpath(root: &Path, candidate: &Path) -> bool {
482    let root_abs = normalize_path(&absolute(root));
483    let candidate_abs = normalize_path(&absolute(candidate));
484    candidate_abs.starts_with(&root_abs)
485}
486
487/// Whether an already-canonicalized path stays inside the root, following
488/// symlinks on both sides.
489fn is_canonical_subpath(root: &Path, resolved: &Path) -> bool {
490    match root.canonicalize() {
491        Ok(root) => resolved.starts_with(&root),
492        // The root itself is missing, so there is nothing to compare against.
493        Err(_) => is_safe_subpath(root, resolved),
494    }
495}
496
497/// Make a path absolute against the working directory without touching the
498/// filesystem, so the result stays in the same form as `root`.
499fn absolute(path: &Path) -> PathBuf {
500    if path.is_absolute() {
501        return path.to_path_buf();
502    }
503    match std::env::current_dir() {
504        Ok(cwd) => cwd.join(path),
505        Err(_) => path.to_path_buf(),
506    }
507}
508
509/// Remove "." and ".." segments from a path without consulting the filesystem.
510fn normalize_path(path: &Path) -> PathBuf {
511    let mut parts: Vec<&std::ffi::OsStr> = Vec::new();
512    for component in path.components() {
513        match component {
514            std::path::Component::ParentDir => {
515                parts.pop();
516            }
517            std::path::Component::CurDir => {}
518            other => {
519                parts.push(other.as_os_str());
520            }
521        }
522    }
523    let mut result = PathBuf::new();
524    for part in parts {
525        result.push(part);
526    }
527    result
528}
529
530/// Resolve a SPA root path by first checking the application base directory
531/// (as resolved by `webx_core::paths::app_base`), then as-is.
532///
533/// This mirrors the strategy used by `config::load_appsettings` so that
534/// `use_spa("wwwroot")` works whether the user runs from `demo/`, from
535/// the workspace root (`rust-webx/`), or from a deployment directory
536/// (exe alongside `wwwroot/`).
537fn resolve_spa_root(root: PathBuf) -> PathBuf {
538    // If the path is already absolute or exists, use it directly.
539    if root.is_absolute() || root.exists() {
540        return root;
541    }
542
543    // 应用基准目录(exe 同级 / cwd / 上溯统一由 app_base 处理)。
544    let candidate = webx_core::paths::app_base().join(&root);
545    if candidate.exists() {
546        return candidate;
547    }
548
549    root
550}
551
552#[cfg(test)]
553mod tests {
554    use super::{alias_static_path, header_allows, normalize_path, SpaSource};
555    use crate::embed::{ContentEncoding, EmbeddedAsset, EmbeddedAssets};
556    use std::path::Path;
557
558    const FILES: &[EmbeddedAsset] = &[EmbeddedAsset {
559        path: "index.html",
560        bytes: b"index",
561        raw_len: 5,
562        encoding: ContentEncoding::Identity,
563        content_type: "text/html",
564        etag: "\"sha256-1\"",
565    }];
566
567    fn embedded() -> SpaSource {
568        SpaSource::Embedded {
569            assets: EmbeddedAssets::new("wwwroot", FILES),
570            overlay_root: "wwwroot".to_string(),
571        }
572    }
573
574    #[test]
575    fn alias_static_path_strips_vditor_dist_segment() {
576        let out = alias_static_path("assets/vendor/vditor-dist/dist/js/foo.js");
577        assert_eq!(out, "assets/vendor/vditor-dist/js/foo.js");
578    }
579
580    #[test]
581    fn normalize_path_removes_dot_dot() {
582        let p = normalize_path(Path::new("a/../b/./c"));
583        assert_eq!(p, Path::new("b/c"));
584    }
585
586    /// Regression: a missing file must resolve to itself, not to `index.html`.
587    ///
588    /// Rewriting misses to the index hid every compiled-in file that had no disk
589    /// counterpart, because the caller then served the index instead of trying
590    /// the embedded table. It only showed up on Windows, where the old
591    /// containment check compared a `\\?\`-prefixed canonical root against a
592    /// plainly joined candidate and so reported a miss as a traversal attempt.
593    #[test]
594    fn missing_file_resolves_to_itself_not_the_index() {
595        let dir = tempfile::tempdir().unwrap();
596        std::fs::write(dir.path().join("index.html"), b"shell").unwrap();
597        let source = embedded().with_overlay_root(dir.path().to_string_lossy().into_owned());
598        let mw = super::SpaMiddleware::from_source(source);
599
600        let resolved = mw.resolve_file("/app.js").expect("inside the root");
601        assert_eq!(resolved, dir.path().join("app.js"));
602        assert!(!resolved.is_file(), "this is the miss case");
603
604        // The index is still reachable for SPA routing, via the fallback the
605        // caller applies once both the disk and embedded lookups miss.
606        let index = mw.resolve_file("/").expect("inside the root");
607        assert!(index.is_file());
608
609        // Escaping the root stays rejected.
610        assert_eq!(mw.resolve_file("/../secrets.txt"), None);
611        assert_eq!(mw.resolve_file("/assets/../../app.db"), None);
612    }
613
614    #[test]
615    fn skips_api_paths_for_spa_fallback() {
616        // API paths are passed through to the router; no static/index.html response.
617        // Full integration is covered by host tests; this documents the guard.
618        assert!("/api/docs".starts_with("/api/"));
619        assert!(!"/works/foo".starts_with("/api/"));
620    }
621
622    #[test]
623    fn embedded_key_maps_root_to_index_and_applies_aliases() {
624        let mw = super::SpaMiddleware::new(embedded());
625        assert_eq!(mw.embedded_key("/").as_deref(), Some("index.html"));
626        assert_eq!(
627            mw.embedded_key("/assets/vendor/vditor-dist/dist/js/foo.js")
628                .as_deref(),
629            Some("assets/vendor/vditor-dist/js/foo.js")
630        );
631        assert_eq!(mw.embedded_key("some/page").as_deref(), Some("some/page"));
632    }
633
634    #[test]
635    fn embedded_key_rejects_traversal() {
636        let mw = super::SpaMiddleware::new(embedded());
637        assert_eq!(mw.embedded_key("/../secrets.txt"), None);
638        assert_eq!(mw.embedded_key("/assets/../../app.db"), None);
639    }
640
641    #[test]
642    fn embedded_is_served_only_for_the_embedded_source() {
643        let disk = super::SpaMiddleware::new(SpaSource::Disk("wwwroot".to_string()));
644        assert!(disk.embedded().is_none());
645        assert!(disk.embedded_asset("index.html").is_none());
646
647        let with_embedded = super::SpaMiddleware::new(embedded());
648        assert!(with_embedded.embedded().is_some());
649        assert!(with_embedded.embedded_asset("index.html").is_some());
650        assert!(with_embedded.embedded_asset("missing.html").is_none());
651    }
652
653    #[test]
654    fn disk_root_and_build_root_are_independent() {
655        // `use_spa("wwwroot")` then `embed_assets("frontend/dist")` in build.rs:
656        // overrides come from wwwroot, the baseline is compiled from dist.
657        let source = SpaSource::from(EmbeddedAssets::new("frontend/dist", FILES))
658            .with_overlay_root("wwwroot");
659        assert_eq!(source.disk_root(), "wwwroot");
660        assert_eq!(source.embedded().unwrap().root(), "frontend/dist");
661        assert_eq!(source.embedded().unwrap().len(), 1);
662    }
663
664    #[test]
665    fn overlay_root_defaults_to_the_build_root() {
666        let source = SpaSource::from(EmbeddedAssets::new("wwwroot", FILES));
667        assert_eq!(source.disk_root(), "wwwroot");
668    }
669
670    #[test]
671    fn with_overlay_root_replaces_a_disk_source() {
672        let source = SpaSource::from("static").with_overlay_root("wwwroot");
673        assert_eq!(source.disk_root(), "wwwroot");
674        assert!(source.embedded().is_none());
675    }
676
677    #[test]
678    fn embed_env_value_is_parsed() {
679        use super::embed_enabled;
680        // Unset, or anything not recognised as "off", keeps embedding on.
681        assert!(embed_enabled(None));
682        assert!(embed_enabled(Some("on")));
683        assert!(embed_enabled(Some("embed")));
684        // "off" is matched case-insensitively and after trimming whitespace.
685        assert!(!embed_enabled(Some("off")));
686        assert!(!embed_enabled(Some(" OFF  ")));
687        assert!(!embed_enabled(Some("false")));
688        assert!(!embed_enabled(Some("0")));
689        assert!(!embed_enabled(Some("no")));
690    }
691
692    #[test]
693    fn accept_encoding_recognises_brotli() {
694        assert!(header_allows("gzip, br", "br"));
695        assert!(header_allows("br", "br"));
696        assert!(header_allows("gzip, deflate, br, zstd", "br"));
697        assert!(header_allows("BR", "br"));
698        assert!(header_allows("gzip, br;q=1.0", "br"));
699    }
700
701    #[test]
702    fn accept_encoding_honours_a_zero_quality() {
703        assert!(!header_allows("br;q=0", "br"));
704        assert!(!header_allows("gzip, br;q=0.0", "br"));
705        assert!(!header_allows("gzip", "br"));
706        assert!(!header_allows("", "br"));
707        // A different encoding listed as zero does not affect brotli.
708        assert!(header_allows("gzip;q=0, br", "br"));
709    }
710
711    #[test]
712    fn accept_encoding_understands_the_wildcard() {
713        // Some clients and CDNs advertise only `*`.
714        assert!(header_allows("*", "br"));
715        assert!(header_allows("gzip, *", "br"));
716        assert!(header_allows("*;q=0.5", "br"));
717        // `*;q=0` means "nothing else is acceptable".
718        assert!(!header_allows("*;q=0", "br"));
719        assert!(!header_allows("gzip, *;q=0", "br"));
720        // An explicit token wins over the wildcard, in both directions.
721        assert!(header_allows("*;q=0, br", "br"));
722        assert!(!header_allows("*;q=1, br;q=0", "br"));
723    }
724
725    #[test]
726    fn decoded_payloads_are_cached_per_path() {
727        let source = "x".repeat(2048);
728        let payload = {
729            use std::io::Write as _;
730            let mut out = Vec::new();
731            let mut encoder = brotli::CompressorWriter::new(&mut out, 4096, 11, 22);
732            encoder.write_all(source.as_bytes()).unwrap();
733            encoder.flush().unwrap();
734            drop(encoder);
735            out
736        };
737        let asset: &'static EmbeddedAsset = Box::leak(Box::new(EmbeddedAsset {
738            path: "cached.txt",
739            bytes: Box::leak(payload.into_boxed_slice()),
740            raw_len: source.len(),
741            encoding: ContentEncoding::Brotli,
742            content_type: "text/plain",
743            etag: "\"sha256-c\"",
744        }));
745
746        let first = super::decoded_once(asset).expect("decodes");
747        let second = super::decoded_once(asset).expect("cached");
748        assert_eq!(first, source.as_bytes());
749        // Same allocation on the second call, so nothing is decoded twice.
750        assert!(std::ptr::eq(first.as_ptr(), second.as_ptr()));
751    }
752}