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//!
7//! For SPA routing, a request the router did not match falls back to
8//! `index.html` via [`SpaMiddleware::try_index_fallback`], which the host calls
9//! *after* routing. The middleware itself never answers a request before the
10//! router has seen it: doing so turned unmatched requests (including API paths
11//! outside `/api/`) into `200 text/html` and leaked the file cache policy into
12//! handler responses.
13//!
14//! Files are handed to the host as [`FileBody`] values, so they are streamed
15//! rather than read into memory, and every response carries an `ETag` and
16//! `Last-Modified` and honours `Range` requests. That means a 2 GB video in
17//! `wwwroot` is served with a bounded memory footprint and can be seeked.
18//!
19//! Embedded entries may be stored brotli-compressed. Those are sent as-is to
20//! clients that accept `br` and decoded once (then cached) for clients that do
21//! not, so a smaller binary does not cost a decode per request.
22
23use std::collections::HashMap;
24use std::ops::ControlFlow;
25use std::path::{Path, PathBuf};
26use std::sync::{Mutex, OnceLock};
27use webx_core::error::Result;
28use webx_core::http::{FileBody, HttpStatus, IHttpContext};
29use webx_core::middleware::IMiddleware;
30
31use crate::embed::{EmbeddedAsset, EmbeddedAssets};
32
33/// How long fingerprints-addressed files may be cached.
34///
35/// Files under `/assets/` are treated as content-addressed and cached for a
36/// year; everything else must revalidate with the `ETag` the host sends.
37const IMMUTABLE_CACHE: &str = "public, max-age=31536000, immutable";
38const REVALIDATE_CACHE: &str = "public, max-age=0, must-revalidate";
39
40/// Embedded files may be answered with a brotli payload or the plain file
41/// depending on the request, so every embedded response varies on this.
42const VARY: &str = "accept-encoding";
43
44/// Environment variable that turns embedded assets off at runtime.
45///
46/// Set it to `off` (or `0` / `false` / `no`) to ignore the compiled-in files
47/// and serve only from disk — handy for bisecting an override problem without
48/// rebuilding.
49pub const EMBED_ENV: &str = "WEBX_EMBED";
50
51/// Where a [`SpaMiddleware`] takes its files from.
52///
53/// Built by `Host::builder()`: `use_spa(...)` names the disk directory;
54/// a registered `embed_assets!()` table is layered underneath at build time.
55#[derive(Debug, Clone)]
56pub enum SpaSource {
57    /// A directory, resolved the same way as `config::load_appsettings` does.
58    Disk(String),
59    /// Files compiled into the executable, served for any name that is not
60    /// present under `overlay_root`.
61    Embedded {
62        /// The compiled-in baseline.
63        assets: EmbeddedAssets,
64        /// The disk directory consulted first.
65        overlay_root: String,
66    },
67}
68
69impl SpaSource {
70    /// The disk directory consulted first; it may not exist.
71    pub fn disk_root(&self) -> &str {
72        match self {
73            SpaSource::Disk(root) => root,
74            SpaSource::Embedded { overlay_root, .. } => overlay_root,
75        }
76    }
77
78    /// The embedded baseline, if this source has one.
79    pub fn embedded(&self) -> Option<&EmbeddedAssets> {
80        match self {
81            SpaSource::Disk(_) => None,
82            SpaSource::Embedded { assets, .. } => Some(assets),
83        }
84    }
85
86    /// Use `dir` on disk, replacing whatever root was configured before.
87    ///
88    /// This is how `use_spa("wwwroot")` states where operators may drop
89    /// overrides, independently of the directory the assets were compiled from.
90    pub fn with_overlay_root(self, dir: impl Into<String>) -> Self {
91        let dir = dir.into();
92        match self {
93            SpaSource::Disk(_) => SpaSource::Disk(dir),
94            SpaSource::Embedded { assets, .. } => SpaSource::Embedded {
95                assets,
96                overlay_root: dir,
97            },
98        }
99    }
100}
101
102impl From<&str> for SpaSource {
103    fn from(root: &str) -> Self {
104        SpaSource::Disk(root.to_string())
105    }
106}
107
108impl From<String> for SpaSource {
109    fn from(root: String) -> Self {
110        SpaSource::Disk(root)
111    }
112}
113
114impl From<PathBuf> for SpaSource {
115    fn from(root: PathBuf) -> Self {
116        SpaSource::Disk(root.to_string_lossy().into_owned())
117    }
118}
119
120impl From<&Path> for SpaSource {
121    fn from(root: &Path) -> Self {
122        SpaSource::Disk(root.to_string_lossy().into_owned())
123    }
124}
125
126impl From<&String> for SpaSource {
127    fn from(root: &String) -> Self {
128        SpaSource::Disk(root.clone())
129    }
130}
131
132impl From<EmbeddedAssets> for SpaSource {
133    fn from(assets: EmbeddedAssets) -> Self {
134        SpaSource::Embedded {
135            overlay_root: assets.root().to_string(),
136            assets,
137        }
138    }
139}
140
141/// SPA static file middleware.
142///
143/// - Matches `/{filename}` against the disk root, then against embedded files
144/// - Serves files with MIME types inferred by the host
145/// - Falls back to `index.html` for unknown paths (SPA routing)
146/// - Handles GET and HEAD; other methods pass through silently
147pub struct SpaMiddleware {
148    root: PathBuf,
149    index: String,
150    embedded: Option<EmbeddedAssets>,
151    /// Embedded files are served only when this is on; see [`EMBED_ENV`].
152    embed_enabled: bool,
153}
154
155impl SpaMiddleware {
156    /// Create a new SPA middleware with default index "index.html".
157    ///
158    /// The disk root is resolved relative to the current working directory.
159    /// If the directory doesn't exist at that path, the middleware searches
160    /// upward through ancestor directories and their immediate subdirectories,
161    /// matching the strategy used by `config::load_appsettings`.
162    pub fn new(source: impl Into<SpaSource>) -> Self {
163        Self::build(source.into(), "index.html".to_string())
164    }
165
166    /// Create a new SPA middleware with a custom index file name.
167    pub fn with_index(source: impl Into<SpaSource>, index: impl Into<String>) -> Self {
168        Self::build(source.into(), index.into())
169    }
170
171    /// Create a SPA middleware from an already-built source.
172    pub fn from_source(source: SpaSource) -> Self {
173        Self::build(source, "index.html".to_string())
174    }
175
176    fn build(source: SpaSource, index: String) -> Self {
177        let embedded = source.embedded().copied();
178        if let Some(assets) = embedded {
179            assets.debug_assert_sorted();
180        }
181        Self {
182            root: resolve_spa_root(PathBuf::from(source.disk_root())),
183            index,
184            embedded,
185            embed_enabled: embedded.is_some() && embed_enabled_from_env(),
186        }
187    }
188
189    /// The disk directory actually consulted, after resolution.
190    pub fn resolved_root(&self) -> &Path {
191        &self.root
192    }
193
194    /// The embedded files this middleware may serve, if any.
195    ///
196    /// Present even when [`EMBED_ENV`] disabled them at runtime.
197    pub fn embedded(&self) -> Option<&EmbeddedAssets> {
198        self.embedded.as_ref()
199    }
200
201    /// The embedded file for a normalized key, when embedding is active.
202    fn embedded_asset(&self, key: &str) -> Option<&'static EmbeddedAsset> {
203        if !self.embed_enabled {
204            return None;
205        }
206        self.embedded.as_ref()?.get(key)
207    }
208
209    /// Normalize a request path into an embedded-table key.
210    ///
211    /// Returns `None` for paths that try to leave the root; the embedded table
212    /// is an exact-match set, so those must never be resolved loosely.
213    fn embedded_key(&self, request_path: &str) -> Option<String> {
214        let relative = alias_static_path(request_path.trim_start_matches('/'));
215        if relative.contains("..") {
216            return None;
217        }
218        Some(if relative.is_empty() {
219            self.index.clone()
220        } else {
221            relative
222        })
223    }
224}
225
226#[async_trait::async_trait]
227impl IMiddleware for SpaMiddleware {
228    async fn invoke(&self, ctx: &mut dyn IHttpContext) -> Result<ControlFlow<()>> {
229        let method = ctx.request().method().to_uppercase();
230        if method != "GET" && method != "HEAD" {
231            return Ok(ControlFlow::Continue(()));
232        }
233
234        let request_path = ctx.request().path();
235
236        // API routes must reach the router — never serve index.html for /api/*.
237        if request_path.starts_with("/api/") {
238            return Ok(ControlFlow::Continue(()));
239        }
240
241        if request_path.starts_with("/assets/") {
242            let relative = alias_static_path(request_path.trim_start_matches('/'));
243            if relative.is_empty() || relative.contains("..") {
244                ctx.response_mut().set_status(HttpStatus::NOT_FOUND);
245                return Ok(ControlFlow::Continue(()));
246            }
247            let candidate = self.root.join(&relative);
248            if candidate.is_file() {
249                return serve_file(ctx, &candidate, IMMUTABLE_CACHE).await;
250            }
251            if let Some(asset) = self.embedded_asset(&relative) {
252                return serve_embedded(ctx, asset, IMMUTABLE_CACHE).await;
253            }
254            ctx.response_mut().set_status(HttpStatus::NOT_FOUND);
255            return Ok(ControlFlow::Continue(()));
256        }
257
258        // A file of this exact name: the disk copy wins, the embedded copy is
259        // the fallback. A request for "/" resolves to the index file, so
260        // "index.html" gets the same precedence.
261        if let Some(file_path) = self.resolve_file(request_path) {
262            if file_path.is_file() {
263                return serve_file(ctx, &file_path, REVALIDATE_CACHE).await;
264            }
265        }
266        if let Some(key) = self.embedded_key(request_path) {
267            if let Some(asset) = self.embedded_asset(&key) {
268                return serve_embedded(ctx, asset, REVALIDATE_CACHE).await;
269            }
270        }
271
272        // Static lookups missed. The index fallback for client-side routes is
273        // applied by the host after the router also misses (see
274        // `try_index_fallback`): serving it here would answer unmatched and
275        // API requests with 200 HTML before routing ever ran.
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    /// Serve `index.html` for a client-side route that did not match the router.
442    ///
443    /// Called by the host *after* routing misses, so:
444    /// * an API path (`/api/*`) is never answered with the app shell;
445    /// * a request that asks for data (`Accept: application/json`, `*/*`, or no
446    ///   `Accept`) gets a proper 404 instead of `200 text/html`;
447    /// * the fallback cannot leak its cache policy into a handler response,
448    ///   because no handler ran.
449    ///
450    /// Returns `true` when the fallback was written into `ctx`.
451    pub async fn try_index_fallback(&self, ctx: &mut dyn IHttpContext) -> Result<bool> {
452        if ctx.response().has_body() {
453            return Ok(false);
454        }
455        let method = ctx.request().method().to_uppercase();
456        if method != "GET" && method != "HEAD" {
457            return Ok(false);
458        }
459        if ctx.request().path().starts_with("/api/") {
460            return Ok(false);
461        }
462        if !accepts_html(ctx.request().header("accept")) {
463            return Ok(false);
464        }
465
466        let index_path = self.root.join(&self.index);
467        if index_path.is_file() {
468            let _ = serve_file(ctx, &index_path, REVALIDATE_CACHE).await?;
469            return Ok(true);
470        }
471        if let Some(asset) = self.embedded_asset(&self.index) {
472            let _ = serve_embedded(ctx, asset, REVALIDATE_CACHE).await?;
473            return Ok(true);
474        }
475        Ok(false)
476    }
477}
478
479/// Whether the client explicitly asked for an HTML document.
480///
481/// The SPA index is only served when `Accept` names `text/html`: a caller that
482/// sends `application/json`, `*/*`, or no `Accept` at all expected data, and
483/// answering with the app shell would turn its 404 into a parse error.
484fn accepts_html(accept: Option<&str>) -> bool {
485    accept
486        .map(|value| value.to_ascii_lowercase().contains("text/html"))
487        .unwrap_or(false)
488}
489
490/// Map vendor paths that include a redundant `dist/` segment to on-disk layout.
491///
492/// Vditor resolves assets as `{cdn}/dist/js/...` while our vendored tree keeps
493/// `js/`, `css/`, and bundle files at the package root.
494fn alias_static_path(relative: &str) -> String {
495    const VDITOR_DIST: &str = "assets/vendor/vditor-dist/dist/";
496    if let Some(rest) = relative.strip_prefix(VDITOR_DIST) {
497        format!("assets/vendor/vditor-dist/{rest}")
498    } else {
499        relative.to_string()
500    }
501}
502
503/// Whether embedded files should be served, per [`EMBED_ENV`].
504fn embed_enabled_from_env() -> bool {
505    embed_enabled(std::env::var(EMBED_ENV).ok().as_deref())
506}
507
508/// Interpret the [`EMBED_ENV`] value. Unset, or anything not recognised as
509/// "off", leaves embedding enabled.
510fn embed_enabled(value: Option<&str>) -> bool {
511    match value {
512        Some(value) => !matches!(
513            value.trim().to_ascii_lowercase().as_str(),
514            "off" | "0" | "false" | "no"
515        ),
516        None => true,
517    }
518}
519
520/// Check that `candidate` is a sub-path of `root` without requiring the
521/// candidate to exist on disk (canonicalize requires the file exists).
522/// Whether `candidate` stays inside `root`, compared lexically.
523///
524/// Deliberately avoids `canonicalize` on the candidate: on Windows it returns a
525/// `\\?\`-prefixed verbatim path, which never compares equal to a plainly
526/// joined one, so every missing file would look like a traversal attempt.
527/// `..` segments are removed without consulting the filesystem, which is what
528/// the traversal defence actually needs.
529fn is_safe_subpath(root: &Path, candidate: &Path) -> bool {
530    let root_abs = normalize_path(&absolute(root));
531    let candidate_abs = normalize_path(&absolute(candidate));
532    candidate_abs.starts_with(&root_abs)
533}
534
535/// Whether an already-canonicalized path stays inside the root, following
536/// symlinks on both sides.
537fn is_canonical_subpath(root: &Path, resolved: &Path) -> bool {
538    match root.canonicalize() {
539        Ok(root) => resolved.starts_with(&root),
540        // The root itself is missing, so there is nothing to compare against.
541        Err(_) => is_safe_subpath(root, resolved),
542    }
543}
544
545/// Make a path absolute against the working directory without touching the
546/// filesystem, so the result stays in the same form as `root`.
547fn absolute(path: &Path) -> PathBuf {
548    if path.is_absolute() {
549        return path.to_path_buf();
550    }
551    match std::env::current_dir() {
552        Ok(cwd) => cwd.join(path),
553        Err(_) => path.to_path_buf(),
554    }
555}
556
557/// Remove "." and ".." segments from a path without consulting the filesystem.
558fn normalize_path(path: &Path) -> PathBuf {
559    let mut parts: Vec<&std::ffi::OsStr> = Vec::new();
560    for component in path.components() {
561        match component {
562            std::path::Component::ParentDir => {
563                parts.pop();
564            }
565            std::path::Component::CurDir => {}
566            other => {
567                parts.push(other.as_os_str());
568            }
569        }
570    }
571    let mut result = PathBuf::new();
572    for part in parts {
573        result.push(part);
574    }
575    result
576}
577
578/// Resolve a SPA root path by first checking the application base directory
579/// (as resolved by `webx_core::paths::app_base`), then as-is.
580///
581/// This mirrors the strategy used by `config::load_appsettings` so that
582/// `use_spa("wwwroot")` works whether the user runs from `demo/`, from
583/// the workspace root (`rust-webx/`), or from a deployment directory
584/// (exe alongside `wwwroot/`).
585fn resolve_spa_root(root: PathBuf) -> PathBuf {
586    // If the path is already absolute or exists, use it directly.
587    if root.is_absolute() || root.exists() {
588        return root;
589    }
590
591    // 应用基准目录(exe 同级 / cwd / 上溯统一由 app_base 处理)。
592    let candidate = webx_core::paths::app_base().join(&root);
593    if candidate.exists() {
594        return candidate;
595    }
596
597    root
598}
599
600#[cfg(test)]
601mod tests {
602    use super::{alias_static_path, header_allows, normalize_path, SpaSource};
603    use crate::embed::{ContentEncoding, EmbeddedAsset, EmbeddedAssets};
604    use std::path::Path;
605
606    const FILES: &[EmbeddedAsset] = &[EmbeddedAsset {
607        path: "index.html",
608        bytes: b"index",
609        raw_len: 5,
610        encoding: ContentEncoding::Identity,
611        content_type: "text/html",
612        etag: "\"sha256-1\"",
613    }];
614
615    fn embedded() -> SpaSource {
616        SpaSource::Embedded {
617            assets: EmbeddedAssets::new("wwwroot", FILES),
618            overlay_root: "wwwroot".to_string(),
619        }
620    }
621
622    #[test]
623    fn alias_static_path_strips_vditor_dist_segment() {
624        let out = alias_static_path("assets/vendor/vditor-dist/dist/js/foo.js");
625        assert_eq!(out, "assets/vendor/vditor-dist/js/foo.js");
626    }
627
628    #[test]
629    fn normalize_path_removes_dot_dot() {
630        let p = normalize_path(Path::new("a/../b/./c"));
631        assert_eq!(p, Path::new("b/c"));
632    }
633
634    /// Regression: a missing file must resolve to itself, not to `index.html`.
635    ///
636    /// Rewriting misses to the index hid every compiled-in file that had no disk
637    /// counterpart, because the caller then served the index instead of trying
638    /// the embedded table. It only showed up on Windows, where the old
639    /// containment check compared a `\\?\`-prefixed canonical root against a
640    /// plainly joined candidate and so reported a miss as a traversal attempt.
641    #[test]
642    fn missing_file_resolves_to_itself_not_the_index() {
643        let dir = tempfile::tempdir().unwrap();
644        std::fs::write(dir.path().join("index.html"), b"shell").unwrap();
645        let source = embedded().with_overlay_root(dir.path().to_string_lossy().into_owned());
646        let mw = super::SpaMiddleware::from_source(source);
647
648        let resolved = mw.resolve_file("/app.js").expect("inside the root");
649        assert_eq!(resolved, dir.path().join("app.js"));
650        assert!(!resolved.is_file(), "this is the miss case");
651
652        // The index is still reachable for SPA routing, via the fallback the
653        // caller applies once both the disk and embedded lookups miss.
654        let index = mw.resolve_file("/").expect("inside the root");
655        assert!(index.is_file());
656
657        // Escaping the root stays rejected.
658        assert_eq!(mw.resolve_file("/../secrets.txt"), None);
659        assert_eq!(mw.resolve_file("/assets/../../app.db"), None);
660    }
661
662    #[test]
663    fn skips_api_paths_for_spa_fallback() {
664        // API paths are passed through to the router; no static/index.html response.
665        // Full integration is covered by host tests; this documents the guard.
666        assert!("/api/docs".starts_with("/api/"));
667        assert!(!"/works/foo".starts_with("/api/"));
668    }
669
670    #[test]
671    fn embedded_key_maps_root_to_index_and_applies_aliases() {
672        let mw = super::SpaMiddleware::new(embedded());
673        assert_eq!(mw.embedded_key("/").as_deref(), Some("index.html"));
674        assert_eq!(
675            mw.embedded_key("/assets/vendor/vditor-dist/dist/js/foo.js")
676                .as_deref(),
677            Some("assets/vendor/vditor-dist/js/foo.js")
678        );
679        assert_eq!(mw.embedded_key("some/page").as_deref(), Some("some/page"));
680    }
681
682    #[test]
683    fn embedded_key_rejects_traversal() {
684        let mw = super::SpaMiddleware::new(embedded());
685        assert_eq!(mw.embedded_key("/../secrets.txt"), None);
686        assert_eq!(mw.embedded_key("/assets/../../app.db"), None);
687    }
688
689    #[test]
690    fn embedded_is_served_only_for_the_embedded_source() {
691        let disk = super::SpaMiddleware::new(SpaSource::Disk("wwwroot".to_string()));
692        assert!(disk.embedded().is_none());
693        assert!(disk.embedded_asset("index.html").is_none());
694
695        let with_embedded = super::SpaMiddleware::new(embedded());
696        assert!(with_embedded.embedded().is_some());
697        assert!(with_embedded.embedded_asset("index.html").is_some());
698        assert!(with_embedded.embedded_asset("missing.html").is_none());
699    }
700
701    #[test]
702    fn disk_root_and_build_root_are_independent() {
703        // `use_spa("wwwroot")` then `embed_assets("frontend/dist")` in build.rs:
704        // overrides come from wwwroot, the baseline is compiled from dist.
705        let source = SpaSource::from(EmbeddedAssets::new("frontend/dist", FILES))
706            .with_overlay_root("wwwroot");
707        assert_eq!(source.disk_root(), "wwwroot");
708        assert_eq!(source.embedded().unwrap().root(), "frontend/dist");
709        assert_eq!(source.embedded().unwrap().len(), 1);
710    }
711
712    #[test]
713    fn overlay_root_defaults_to_the_build_root() {
714        let source = SpaSource::from(EmbeddedAssets::new("wwwroot", FILES));
715        assert_eq!(source.disk_root(), "wwwroot");
716    }
717
718    #[test]
719    fn with_overlay_root_replaces_a_disk_source() {
720        let source = SpaSource::from("static").with_overlay_root("wwwroot");
721        assert_eq!(source.disk_root(), "wwwroot");
722        assert!(source.embedded().is_none());
723    }
724
725    #[test]
726    fn embed_env_value_is_parsed() {
727        use super::embed_enabled;
728        // Unset, or anything not recognised as "off", keeps embedding on.
729        assert!(embed_enabled(None));
730        assert!(embed_enabled(Some("on")));
731        assert!(embed_enabled(Some("embed")));
732        // "off" is matched case-insensitively and after trimming whitespace.
733        assert!(!embed_enabled(Some("off")));
734        assert!(!embed_enabled(Some(" OFF  ")));
735        assert!(!embed_enabled(Some("false")));
736        assert!(!embed_enabled(Some("0")));
737        assert!(!embed_enabled(Some("no")));
738    }
739
740    #[test]
741    fn accept_encoding_recognises_brotli() {
742        assert!(header_allows("gzip, br", "br"));
743        assert!(header_allows("br", "br"));
744        assert!(header_allows("gzip, deflate, br, zstd", "br"));
745        assert!(header_allows("BR", "br"));
746        assert!(header_allows("gzip, br;q=1.0", "br"));
747    }
748
749    #[test]
750    fn accept_encoding_honours_a_zero_quality() {
751        assert!(!header_allows("br;q=0", "br"));
752        assert!(!header_allows("gzip, br;q=0.0", "br"));
753        assert!(!header_allows("gzip", "br"));
754        assert!(!header_allows("", "br"));
755        // A different encoding listed as zero does not affect brotli.
756        assert!(header_allows("gzip;q=0, br", "br"));
757    }
758
759    #[test]
760    fn accept_encoding_understands_the_wildcard() {
761        // Some clients and CDNs advertise only `*`.
762        assert!(header_allows("*", "br"));
763        assert!(header_allows("gzip, *", "br"));
764        assert!(header_allows("*;q=0.5", "br"));
765        // `*;q=0` means "nothing else is acceptable".
766        assert!(!header_allows("*;q=0", "br"));
767        assert!(!header_allows("gzip, *;q=0", "br"));
768        // An explicit token wins over the wildcard, in both directions.
769        assert!(header_allows("*;q=0, br", "br"));
770        assert!(!header_allows("*;q=1, br;q=0", "br"));
771    }
772
773    #[test]
774    fn decoded_payloads_are_cached_per_path() {
775        let source = "x".repeat(2048);
776        let payload = {
777            use std::io::Write as _;
778            let mut out = Vec::new();
779            let mut encoder = brotli::CompressorWriter::new(&mut out, 4096, 11, 22);
780            encoder.write_all(source.as_bytes()).unwrap();
781            encoder.flush().unwrap();
782            drop(encoder);
783            out
784        };
785        let asset: &'static EmbeddedAsset = Box::leak(Box::new(EmbeddedAsset {
786            path: "cached.txt",
787            bytes: Box::leak(payload.into_boxed_slice()),
788            raw_len: source.len(),
789            encoding: ContentEncoding::Brotli,
790            content_type: "text/plain",
791            etag: "\"sha256-c\"",
792        }));
793
794        let first = super::decoded_once(asset).expect("decodes");
795        let second = super::decoded_once(asset).expect("cached");
796        assert_eq!(first, source.as_bytes());
797        // Same allocation on the second call, so nothing is decoded twice.
798        assert!(std::ptr::eq(first.as_ptr(), second.as_ptr()));
799    }
800}