Skip to main content

umbral_core/
static_files.rs

1//! The unified static-asset pipeline's request → file resolution.
2//!
3//! This module is the runtime half of [`Plugin::static_dirs`]. At
4//! `App::build()` the framework walks every plugin's `static_dirs()`
5//! into a [`StaticRegistry`] (`namespace -> source_dir`) and mounts one
6//! handler at the configured `static_url` (default `/static/`). A
7//! request `/static/<namespace>/<rest>` resolves like so:
8//!
9//! - **Dev** ([`Environment::Dev`]) — try `<source_dir>/<rest>` from the
10//!   registry first (LIVE source serving: drop a rebuilt file, served on
11//!   the next request). If the namespace isn't registered OR the file is
12//!   missing, fall back to `<static_root>/<namespace>/<rest>`.
13//! - **Prod / Test** — serve `<static_root>/<namespace>/<rest>` only.
14//!
15//! Every resolution runs through [`resolve_under_root`], which rejects
16//! `..` escapes, absolute components, and symlink traversal by
17//! canonicalising the candidate and verifying it still lives under the
18//! intended root. A path that escapes is a 404 (never a 403 that would
19//! leak the attempted filename).
20//!
21//! ## One file-serving implementation
22//!
23//! [`serve_file`] is the single place the framework reads a file off
24//! disk and turns it into a response — Content-Type, ETag, range
25//! requests, and `If-Modified-Since` all come from `tower_http`'s
26//! `ServeFile`. The unified handler here routes every file response
27//! through it, so MIME / range / conditional-request handling lives in
28//! one spot. It is re-exported from the facade
29//! (`umbral::static_files::serve_file`) so a plugin that needs to serve
30//! a single file off disk can reuse it instead of hand-rolling the same
31//! logic; the standalone `umbral-storage` `StoragePlugin` static side keeps its own
32//! `ServeDir`/`include_dir` paths (a directory tree and an embedded
33//! tree are different shapes from a single-file serve) and is not
34//! rewired onto this primitive in this slice. The dev `max-age=0` /
35//! prod cache behaviour is applied here too.
36//!
37//! [`Plugin::static_dirs`]: crate::plugin::Plugin::static_dirs
38//! [`Environment::Dev`]: crate::settings::Environment
39
40use std::collections::BTreeMap;
41use std::collections::HashMap;
42use std::path::{Component, Path, PathBuf};
43use std::sync::OnceLock;
44
45use axum::body::Body;
46use axum::extract::State;
47use axum::http::{Request, Response, StatusCode, header};
48use sha2::{Digest, Sha256};
49use tower::ServiceExt;
50use tower_http::services::ServeFile;
51
52use crate::plugin::{Plugin, StaticDir};
53
54/// The on-disk name of the hashed-asset manifest written into
55/// `static_root` by `collectstatic --hashed`. The conventional name is
56/// `staticfiles.json`.
57pub const MANIFEST_FILENAME: &str = "staticfiles.json";
58
59/// Anything that can go wrong writing an asset through a
60/// [`StaticStorage`] backend. Backend-agnostic: a filesystem `put` and an
61/// S3 `put_object` both funnel their failure through this enum so
62/// `collect_into` has one error type regardless of where assets land.
63#[derive(Debug)]
64pub enum StaticError {
65    /// An IO error writing/reading an asset. Carries the logical path
66    /// that failed so the message names the culprit.
67    Io {
68        path: String,
69        source: std::io::Error,
70    },
71    /// A backend-specific failure (a remote upload rejected, credentials
72    /// missing, region unreachable). Carries a human-readable message.
73    Backend(String),
74}
75
76impl std::fmt::Display for StaticError {
77    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        match self {
79            StaticError::Io { path, source } => {
80                write!(f, "static storage io error at `{path}`: {source}")
81            }
82            StaticError::Backend(msg) => write!(f, "static storage backend error: {msg}"),
83        }
84    }
85}
86
87impl std::error::Error for StaticError {
88    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
89        match self {
90            StaticError::Io { source, .. } => Some(source),
91            StaticError::Backend(_) => None,
92        }
93    }
94}
95
96/// A swappable destination for collected static assets, the
97/// static-files storage backend. `collectstatic` writes every file *through* a
98/// `StaticStorage` rather than calling `std::fs` directly, so the same
99/// collect path targets the local filesystem ([`LocalStorage`], the
100/// default) or a remote object store (the feature-gated S3 backend in
101/// `umbral-storage`) without the collect engine knowing which.
102///
103/// `rel_path` is always the logical path RELATIVE to `static_root`
104/// (`"admin/admin.css"`, `"css/app.css"`), forward-slash separated. The
105/// backend maps it onto its own addressing (a filesystem join, an S3
106/// object key) — the engine never constructs an absolute on-disk path.
107pub trait StaticStorage: Send + Sync {
108    /// Write `bytes` at the logical `rel_path`, creating any intermediate
109    /// structure (directories, key prefixes) the backend needs.
110    /// Overwrites an existing object so re-running `collectstatic` is
111    /// idempotent.
112    fn put(&self, rel_path: &str, bytes: &[u8]) -> Result<(), StaticError>;
113
114    /// Whether an object already exists at `rel_path`.
115    fn exists(&self, rel_path: &str) -> Result<bool, StaticError>;
116}
117
118/// The default [`StaticStorage`]: writes collected assets onto the local
119/// filesystem under `root` (the resolved `static_root`). Reproduces the
120/// pre-storage-trait filesystem copy exactly — `put("a/b.css", bytes)`
121/// writes `<root>/a/b.css`, creating parent dirs as needed.
122#[derive(Debug, Clone)]
123pub struct LocalStorage {
124    /// The on-disk root every `rel_path` is joined onto.
125    pub root: PathBuf,
126}
127
128impl LocalStorage {
129    /// A filesystem storage rooted at `root` (the resolved `static_root`).
130    pub fn new(root: impl Into<PathBuf>) -> Self {
131        Self { root: root.into() }
132    }
133
134    /// Resolve a logical `rel_path` onto its on-disk path under `root`.
135    fn full_path(&self, rel_path: &str) -> PathBuf {
136        let mut p = self.root.clone();
137        for seg in rel_path.split('/') {
138            if !seg.is_empty() {
139                p.push(seg);
140            }
141        }
142        p
143    }
144}
145
146impl StaticStorage for LocalStorage {
147    fn put(&self, rel_path: &str, bytes: &[u8]) -> Result<(), StaticError> {
148        let dest = self.full_path(rel_path);
149        if let Some(parent) = dest.parent() {
150            std::fs::create_dir_all(parent).map_err(|source| StaticError::Io {
151                path: rel_path.to_string(),
152                source,
153            })?;
154        }
155        std::fs::write(&dest, bytes).map_err(|source| StaticError::Io {
156            path: rel_path.to_string(),
157            source,
158        })
159    }
160
161    fn exists(&self, rel_path: &str) -> Result<bool, StaticError> {
162        Ok(self.full_path(rel_path).exists())
163    }
164}
165
166/// Compute the content-hash filename fragment the hashed static-file
167/// storage uses: the first 12 hex chars of the
168/// SHA-256 of the file bytes. 48 bits is ample for cache-busting (a
169/// collision needs ~16M distinct versions of one asset) while keeping the
170/// hashed filename short.
171pub fn content_hash(bytes: &[u8]) -> String {
172    let mut hasher = Sha256::new();
173    hasher.update(bytes);
174    let digest = hasher.finalize();
175    digest[..6].iter().map(|b| format!("{b:02x}")).collect()
176}
177
178/// Insert the content hash before the final extension of a logical path:
179/// `"css/app.css"` → `"css/app.<hash>.css"`, `"js/x"` (no extension) →
180/// `"js/x.<hash>"`, `"a/b.min.css"` → `"a/b.min.<hash>.css"` (only the
181/// LAST `.` segment is treated as the extension).
182pub fn hashed_name(rel_path: &str, hash: &str) -> String {
183    // Split off the final path segment so a `.` in a directory name (rare
184    // but possible) never gets mistaken for the file extension.
185    let (dir, file) = match rel_path.rfind('/') {
186        Some(i) => (&rel_path[..=i], &rel_path[i + 1..]),
187        None => ("", rel_path),
188    };
189    match file.rfind('.') {
190        Some(dot) => format!("{dir}{}.{hash}.{}", &file[..dot], &file[dot + 1..]),
191        None => format!("{dir}{file}.{hash}"),
192    }
193}
194
195/// One plugin's namespaced static contribution, flattened so it can be
196/// published ambiently for a CLI command that has no access to the
197/// plugin list. Carries the plugin name too, so `collectstatic`'s
198/// summary and missing-source warnings can name the culprit exactly as
199/// the plugin-list path did.
200#[derive(Debug, Clone)]
201pub struct StaticContribution {
202    /// The static namespace this source dir collects under
203    /// (`<static_root>/<namespace>/`).
204    pub namespace: &'static str,
205    /// On-disk source dir whose tree is copied at collect time.
206    pub source_dir: PathBuf,
207    /// The plugin that declared this contribution (for summaries /
208    /// warnings).
209    pub plugin: &'static str,
210}
211
212impl StaticContribution {
213    /// Flatten every plugin's [`Plugin::static_dirs`] into a list of
214    /// contributions, capturing each plugin's `name()` so the collect
215    /// summary can attribute files and missing-source warnings.
216    ///
217    /// No collision check here — the caller that publishes this list
218    /// (`App::build`) has already run [`StaticRegistry::from_plugins`],
219    /// which fails the build on a duplicate namespace before anything is
220    /// published. The published list is therefore pre-validated.
221    pub fn collect(plugins: &[Box<dyn Plugin>]) -> Vec<StaticContribution> {
222        let mut out = Vec::new();
223        for plugin in plugins {
224            for dir in plugin.static_dirs() {
225                let StaticDir {
226                    namespace,
227                    source_dir,
228                } = dir;
229                out.push(StaticContribution {
230                    namespace,
231                    source_dir,
232                    plugin: plugin.name(),
233                });
234            }
235        }
236        out
237    }
238
239    /// Collect every plugin's [`Plugin::static_root_dirs`] into a flat
240    /// list of app/site root dirs, copied into `<static_root>/` root at
241    /// collect time (extra static-dirs collected to the root).
242    pub fn collect_root_dirs(plugins: &[Box<dyn Plugin>]) -> Vec<PathBuf> {
243        plugins.iter().flat_map(|p| p.static_root_dirs()).collect()
244    }
245}
246
247/// The static contributions published ambiently at `App::build` for CLI
248/// commands that can't take the plugin list as an argument.
249///
250/// This mirrors the `settings` ambient `OnceLock` (see
251/// [`crate::settings`]): read-only app config published exactly once at
252/// build time. It is NOT a mutable creeping global — nothing mutates it
253/// after `publish_static`, and the only reader is `collectstatic`, which
254/// runs after `App::build` and so needs every plugin's `static_dirs()`
255/// (namespaced) and `static_root_dirs()` (app/site) without the plugin
256/// list being threaded through `PluginCommand::run`.
257#[derive(Debug, Clone, Default)]
258pub struct PublishedStatic {
259    /// Every plugin's namespaced static contributions.
260    pub contributions: Vec<StaticContribution>,
261    /// Every plugin's app/site root dirs (no namespace), copied into the
262    /// `<static_root>/` root.
263    pub root_dirs: Vec<PathBuf>,
264}
265
266/// The one published-static slot, set once at `App::build`. Same family
267/// as `settings::SETTINGS` — the single intentional read-only ambient
268/// for CLI commands that run outside a request and can't be handed the
269/// plugin list directly.
270static PUBLISHED: OnceLock<PublishedStatic> = OnceLock::new();
271
272/// Publish the static contributions ambiently. Idempotent: a second
273/// call (e.g. a second `App::build` in one test process) is a no-op —
274/// the first publish wins, matching the `settings` OnceLock semantics.
275pub fn publish_static(p: PublishedStatic) {
276    let _ = PUBLISHED.set(p);
277}
278
279/// The static contributions published at `App::build`, or `None` if no
280/// `App` has been built in this process yet. `collectstatic` reads this
281/// to learn every plugin's source dirs without the plugin list.
282pub fn published_static() -> Option<&'static PublishedStatic> {
283    PUBLISHED.get()
284}
285
286/// The loaded hashed-asset manifest: logical path → hashed path
287/// (`"css/app.css" -> "css/app.<hash>.css"`). Loaded once from
288/// `<static_root>/staticfiles.json` and cached ambiently, the same
289/// read-only-at-boot family as `settings::SETTINGS` and [`PUBLISHED`].
290///
291/// `None` (the `OnceLock` unset, or set to `None`) means no manifest was
292/// found — `resolve_static_url` then falls back to today's plain
293/// `static_url + path` join. A present manifest means `collectstatic
294/// --hashed` ran, so prod serves the content-hashed filenames and can set
295/// far-future cache headers on them.
296static MANIFEST: OnceLock<Option<HashMap<String, String>>> = OnceLock::new();
297
298/// Load the hashed-asset manifest from `<static_root>/staticfiles.json`
299/// into the ambient slot, once. Idempotent: the first load wins (matching
300/// `settings`/`published_static`); a second call is a no-op.
301///
302/// Call at `App::build` after settings resolve. A missing or unparseable
303/// manifest is recorded as `None` (no hashing in effect) rather than an
304/// error — an app that never ran `collectstatic --hashed` legitimately
305/// has no manifest, and `resolve_static_url` must keep working.
306pub fn load_manifest(static_root: impl AsRef<Path>) {
307    let path = static_root.as_ref().join(MANIFEST_FILENAME);
308    let loaded = std::fs::read(&path)
309        .ok()
310        .and_then(|bytes| serde_json::from_slice::<HashMap<String, String>>(&bytes).ok());
311    let _ = MANIFEST.set(loaded);
312}
313
314/// Look up the hashed name for a logical asset path in the loaded
315/// manifest. Returns `None` when no manifest is loaded OR the path isn't
316/// in it (an asset not collected through `--hashed`); the caller then
317/// uses the path unchanged.
318///
319/// The lookup key is the logical path as the template wrote it
320/// (`"css/app.css"`), normalised to drop a leading slash so
321/// `static("/css/app.css")` and `static("css/app.css")` hit the same
322/// entry — matching `resolve_static_url`'s join, which also trims the
323/// leading slash.
324pub fn manifest_lookup(path: &str) -> Option<&'static str> {
325    let manifest = MANIFEST.get()?.as_ref()?;
326    let key = path.trim_start_matches('/');
327    manifest.get(key).map(String::as_str)
328}
329
330/// Whether a hashed-asset manifest is currently loaded. `resolve_static_url`
331/// uses this to decide between hashed and plain URLs.
332pub fn manifest_loaded() -> bool {
333    matches!(MANIFEST.get(), Some(Some(_)))
334}
335
336/// Test-only: install a manifest directly, bypassing the on-disk load.
337/// Used by `resolve_static_url` tests that need a known manifest without
338/// staging a `staticfiles.json` on disk.
339#[doc(hidden)]
340pub fn set_manifest_for_tests(manifest: Option<HashMap<String, String>>) {
341    let _ = MANIFEST.set(manifest);
342}
343
344/// Maps a plugin's static namespace to its on-disk source directory.
345///
346/// Built once at `App::build()` from every registered plugin's
347/// [`Plugin::static_dirs`]. Cloned into the static handler's axum state
348/// so per-request resolution is a cheap `HashMap` lookup.
349#[derive(Debug, Clone, Default)]
350pub struct StaticRegistry {
351    by_namespace: HashMap<&'static str, PathBuf>,
352}
353
354/// Two plugins declared the same static namespace. Carries the
355/// colliding namespace plus both plugin names so the boot-time error
356/// names exactly who collided.
357#[derive(Debug, Clone)]
358pub struct StaticNamespaceCollision {
359    /// The namespace both plugins claimed.
360    pub namespace: &'static str,
361    /// The plugin that registered the namespace first.
362    pub first_plugin: &'static str,
363    /// The plugin that tried to register it again.
364    pub second_plugin: &'static str,
365}
366
367impl StaticRegistry {
368    /// Walk every plugin's `static_dirs()` into a `namespace -> source_dir`
369    /// map. A namespace claimed by two plugins is a hard error — the
370    /// collision must fail the build loudly, never silently shadow one
371    /// plugin's assets with another's.
372    ///
373    /// `plugins` is borrowed in topological order; the first plugin to
374    /// claim a namespace owns it, and a later claimant surfaces as
375    /// [`StaticNamespaceCollision`] naming both sides.
376    pub fn from_plugins(plugins: &[Box<dyn Plugin>]) -> Result<Self, StaticNamespaceCollision> {
377        let mut by_namespace: HashMap<&'static str, PathBuf> = HashMap::new();
378        // Track which plugin claimed each namespace so a collision can
379        // name both sides, not just the loser.
380        let mut owner: HashMap<&'static str, &'static str> = HashMap::new();
381
382        for plugin in plugins {
383            for dir in plugin.static_dirs() {
384                let StaticDir {
385                    namespace,
386                    source_dir,
387                } = dir;
388                if let Some(&first_plugin) = owner.get(namespace) {
389                    return Err(StaticNamespaceCollision {
390                        namespace,
391                        first_plugin,
392                        second_plugin: plugin.name(),
393                    });
394                }
395                owner.insert(namespace, plugin.name());
396                by_namespace.insert(namespace, source_dir);
397            }
398        }
399
400        Ok(Self { by_namespace })
401    }
402
403    /// The source directory a namespace was registered with, if any.
404    pub fn source_dir(&self, namespace: &str) -> Option<&Path> {
405        self.by_namespace.get(namespace).map(PathBuf::as_path)
406    }
407
408    /// True when no plugin contributed a static dir. The handler is
409    /// still mounted (so `static_root` serving works in prod) but this
410    /// lets `App::build` skip the mount entirely when there's nothing
411    /// to serve AND no static_root convention is wanted.
412    pub fn is_empty(&self) -> bool {
413        self.by_namespace.is_empty()
414    }
415}
416
417/// Split a request path that has already had the `static_url` base
418/// stripped into `(namespace, rest)`.
419///
420/// `"admin/admin.css"` → `("admin", "admin.css")`.
421/// `"admin/css/site.css"` → `("admin", "css/site.css")`.
422/// A path with no `/` (just a namespace, no file) yields `None` — there
423/// is nothing to serve at a bare namespace root.
424fn split_namespace(rel: &str) -> Option<(&str, &str)> {
425    let rel = rel.trim_start_matches('/');
426    let (ns, rest) = rel.split_once('/')?;
427    if ns.is_empty() || rest.is_empty() {
428        return None;
429    }
430    Some((ns, rest))
431}
432
433/// Resolve `rel` against `root`, returning the on-disk path ONLY if it
434/// stays inside `root` after canonicalisation.
435///
436/// The defence is three-layered:
437///
438/// 1. **Lexical reject** — any `..` (`ParentDir`), absolute prefix
439///    (`RootDir` / `Prefix`), is refused before touching the filesystem.
440///    This blocks the `../../etc/passwd` family up front.
441/// 2. **Canonicalise** — resolve symlinks and `.` segments to a real
442///    absolute path. A symlink inside `root` pointing outside it is
443///    caught here, where a purely lexical check would miss it.
444/// 3. **Containment** — verify the canonical candidate is still prefixed
445///    by the canonical root. Anything escaping returns `None`.
446///
447/// Returns `None` (caller maps to 404) on any failure — a miss and an
448/// escape attempt are indistinguishable to the client, so a probe can't
449/// learn whether a path exists outside the root.
450pub fn resolve_under_root(root: &Path, rel: &str) -> Option<PathBuf> {
451    // Layer 1: lexical rejection. Reject before any filesystem access.
452    let rel_path = Path::new(rel);
453    for component in rel_path.components() {
454        match component {
455            Component::Normal(_) | Component::CurDir => {}
456            // ParentDir (`..`), RootDir (`/...`), Prefix (`C:\`) all
457            // escape or absolutise — refuse outright.
458            Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
459        }
460    }
461
462    let candidate = root.join(rel_path);
463
464    // Layer 2: canonicalise both sides. If the file doesn't exist,
465    // `canonicalize` errors -> None (a 404), which is exactly right.
466    let canonical_root = root.canonicalize().ok()?;
467    let canonical_candidate = candidate.canonicalize().ok()?;
468
469    // Layer 3: containment. The canonical candidate must live under the
470    // canonical root — this catches a symlink inside `root` that points
471    // out of it (lexical checks alone would let it through).
472    if canonical_candidate.starts_with(&canonical_root) {
473        Some(canonical_candidate)
474    } else {
475        None
476    }
477}
478
479/// Serve a single on-disk file as an HTTP response, reusing
480/// `tower_http::ServeFile` for Content-Type, ETag, range, and
481/// `If-Modified-Since` handling. This is the framework's ONE
482/// file-serving path — the unified static handler and `umbral-storage`
483/// both route through it.
484///
485/// `dev` forces `Cache-Control: no-cache` so a rebuilt asset is never
486/// masked by a stale cached copy during development; in prod the
487/// response carries whatever `ServeFile` set (typically none, leaving
488/// the caching decision to a reverse proxy or the browser).
489///
490/// `req` is forwarded so conditional and range headers (`If-None-Match`,
491/// `Range`, `If-Modified-Since`) reach `ServeFile` and produce `304` /
492/// `206` as appropriate.
493pub async fn serve_file(file_path: &Path, dev: bool, req: Request<Body>) -> Response<Body> {
494    // ServeFile is infallible at the Service level — a missing file is a
495    // 404 *response*, not an Err — so `oneshot` can't actually fail. The
496    // match keeps us honest if tower ever changes that contract.
497    let response = match ServeFile::new(file_path).oneshot(req).await {
498        Ok(resp) => resp,
499        Err(_unreachable) => {
500            return Response::builder()
501                .status(StatusCode::INTERNAL_SERVER_ERROR)
502                .body(Body::from("static file serving failed"))
503                .expect("static 500 response is always valid");
504        }
505    };
506
507    let mut response = response.map(Body::new);
508
509    if dev {
510        // Replace any cache header ServeFile may have set with an
511        // explicit no-cache so dev edits are always picked up.
512        response.headers_mut().insert(
513            header::CACHE_CONTROL,
514            header::HeaderValue::from_static("no-cache"),
515        );
516    }
517
518    response
519}
520
521/// The axum handler mounted at the `static_url` base. Resolves the
522/// request path (already stripped of the base prefix by the nested
523/// mount) per the dev/prod algorithm and serves the file via
524/// [`serve_file`].
525///
526/// State carries the [`StaticRegistry`], the resolved `static_root`
527/// (absolute or CWD-relative on-disk dir), and the `dev` flag captured
528/// at build time.
529pub async fn static_handler(
530    State(state): State<StaticHandlerState>,
531    req: Request<Body>,
532) -> Response<Body> {
533    // `nest_service` strips the mount prefix, so `req.uri().path()` is
534    // already relative to the static base: `/admin/admin.css`,
535    // `/css/site.css`.
536    let path = req.uri().path().to_string();
537    let rel = path.trim_start_matches('/');
538
539    // Step 1 — dev live source for a *registered* namespace. Lets a
540    // rebuilt plugin asset be served straight off its source dir without
541    // a recompile or a collect step. Only a namespace a plugin actually
542    // declared takes this path; an unregistered first segment (e.g.
543    // `css`) is not a namespace and flows to the steps below.
544    if state.dev {
545        if let Some((namespace, rest)) = split_namespace(rel) {
546            if let Some(source_dir) = state.registry.source_dir(namespace) {
547                if let Some(resolved) = resolve_under_root(source_dir, rest) {
548                    return serve_file(&resolved, true, req).await;
549                }
550            }
551        }
552    }
553
554    // Step 2 — the collected/prod tree: `<static_root>/<full path>`. This
555    // is the general path that serves every collected namespace
556    // (`<static_root>/admin/admin.css`) in prod, and the dev fallback when
557    // a live source missed. A missing `static_root` (no collect run yet)
558    // canonicalises to `None` here and flows on to the root dirs.
559    if let Some(resolved) = resolve_under_root(&state.static_root, rel) {
560        return serve_file(&resolved, state.dev, req).await;
561    }
562
563    // Step 3 — app/site root dirs (no namespace), the full request path.
564    // Real on-disk directories (a project's `./static`), served the same
565    // in dev and prod. This is what a `StoragePlugin` static side at `static_url`
566    // contributes, so site CSS / images live at the bare `/static/...`.
567    for root in &state.root_dirs {
568        if let Some(resolved) = resolve_under_root(root, rel) {
569            return serve_file(&resolved, state.dev, req).await;
570        }
571    }
572
573    not_found()
574}
575
576/// Immutable state the static handler closes over: the namespace
577/// registry, the on-disk collected-assets root, and the dev flag.
578#[derive(Debug, Clone)]
579pub struct StaticHandlerState {
580    /// `namespace -> source_dir`, built from plugins at boot.
581    pub registry: StaticRegistry,
582    /// On-disk root the collected/prod assets live under
583    /// (`settings.static_root`, e.g. `staticfiles/`).
584    pub static_root: PathBuf,
585    /// App/site-level static directories served at the bare
586    /// `static_url` root (no namespace), from every plugin's
587    /// [`Plugin::static_root_dirs`]. Tried after namespaces, with the
588    /// full request path. Typically a `StoragePlugin` static side pointed at
589    /// `static_url` contributes its directory here so the framework owns
590    /// `static_url` as one mount instead of a second catch-all colliding
591    /// with the pipeline.
592    ///
593    /// [`Plugin::static_root_dirs`]: crate::plugin::Plugin::static_root_dirs
594    pub root_dirs: Vec<PathBuf>,
595    /// Whether the app is running in `Environment::Dev`.
596    pub dev: bool,
597}
598
599fn not_found() -> Response<Body> {
600    Response::builder()
601        .status(StatusCode::NOT_FOUND)
602        .header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
603        .body(Body::from("not found"))
604        .expect("static 404 response is always valid")
605}
606
607/// Per-namespace result of a [`collect_static`] run: how many files were
608/// copied into `<static_root>/<namespace>/` and where they landed.
609#[derive(Debug, Clone)]
610pub struct CollectedNamespace {
611    /// The plugin namespace these files were collected under.
612    pub namespace: &'static str,
613    /// The plugin that contributed this namespace.
614    pub plugin: &'static str,
615    /// Count of files copied (not directories) for this namespace.
616    pub files: usize,
617    /// The destination directory (`<static_root>/<namespace>`).
618    pub destination: PathBuf,
619}
620
621/// A plugin declared a `source_dir` that doesn't exist on disk. Recorded
622/// (not fatal) so the CLI can surface the misconfiguration to the dev
623/// without aborting the whole collect — every other plugin still
624/// collects.
625#[derive(Debug, Clone)]
626pub struct MissingSourceDir {
627    /// The namespace whose source dir is missing.
628    pub namespace: &'static str,
629    /// The plugin that declared the missing dir.
630    pub plugin: &'static str,
631    /// The path that was declared but isn't present on disk.
632    pub source_dir: PathBuf,
633}
634
635/// The outcome of a [`collect_static`] run. Carries the per-namespace
636/// breakdown, any skipped (missing-source) namespaces, and the resolved
637/// `static_root` so the CLI can print a summary.
638#[derive(Debug, Clone, Default)]
639pub struct CollectSummary {
640    /// One entry per namespace that had an on-disk source dir.
641    pub collected: Vec<CollectedNamespace>,
642    /// Namespaces whose declared source dir was absent (warned, not
643    /// fatal).
644    pub missing: Vec<MissingSourceDir>,
645    /// The destination root every namespace was collected under.
646    pub static_root: PathBuf,
647    /// Count of files copied from app/site root dirs
648    /// ([`Plugin::static_root_dirs`]) into the `<static_root>/` ROOT
649    /// (no namespace) - extra static-dirs collected to the root. Counted
650    /// separately from namespaced files so the CLI can report both.
651    ///
652    /// [`Plugin::static_root_dirs`]: crate::plugin::Plugin::static_root_dirs
653    pub root_files: usize,
654    /// The app/site root dirs that were collected (those that existed on
655    /// disk). A declared-but-absent root dir is skipped silently — unlike
656    /// a namespaced source, a root dir is a project convention dir
657    /// (`./static`) that legitimately may not exist yet.
658    pub root_dirs: Vec<PathBuf>,
659}
660
661impl CollectSummary {
662    /// Total files copied across every namespace (not counting root-dir
663    /// files; use [`Self::root_files`] for those).
664    pub fn total_files(&self) -> usize {
665        self.collected.iter().map(|c| c.files).sum()
666    }
667}
668
669/// Anything that can go wrong collecting static assets. A namespace
670/// collision is detected up front (before any copying) so a misconfigured
671/// app never half-writes its `static_root`.
672#[derive(Debug)]
673pub enum CollectError {
674    /// Two plugins claimed the same namespace. Collected NOTHING — the
675    /// collision is detected before any file is touched.
676    Collision(StaticNamespaceCollision),
677    /// An IO error creating a directory or copying a file. Carries the
678    /// path that failed so the message names the culprit.
679    Io {
680        path: PathBuf,
681        source: std::io::Error,
682    },
683    /// A [`StaticStorage`] backend rejected a write (a failed S3 upload,
684    /// a permission error from the local filesystem put). Carries the
685    /// backend error so the CLI can surface which destination failed.
686    Static(StaticError),
687}
688
689impl std::fmt::Display for CollectError {
690    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
691        match self {
692            CollectError::Collision(c) => write!(
693                f,
694                "umbral collect_static: duplicate static namespace `{}` — claimed by both \
695                 `{}` and `{}`; nothing was copied. Rename one plugin's namespace.",
696                c.namespace, c.first_plugin, c.second_plugin
697            ),
698            CollectError::Io { path, source } => write!(
699                f,
700                "umbral collect_static: io error at `{}`: {source}",
701                path.display()
702            ),
703            CollectError::Static(e) => write!(f, "umbral collect_static: {e}"),
704        }
705    }
706}
707
708impl std::error::Error for CollectError {
709    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
710        match self {
711            CollectError::Io { source, .. } => Some(source),
712            CollectError::Static(e) => Some(e),
713            CollectError::Collision(_) => None,
714        }
715    }
716}
717
718/// Collect every registered plugin's `static_dirs()` into `static_root`.
719///
720/// The `collectstatic` operation. For each `StaticDir { namespace, source_dir }`,
721/// the entire `source_dir` tree is recursively copied into
722/// `<static_root>/<namespace>/`, preserving each file's path RELATIVE to
723/// its `source_dir`: `source_dir/assets/index.js` lands at
724/// `<static_root>/<namespace>/assets/index.js`.
725///
726/// Guarantees:
727///
728/// - **Collisions abort up front.** Namespace collisions are detected via
729///   [`StaticRegistry::from_plugins`] *before* any file is written, so a
730///   misconfigured app never leaves a half-populated `static_root`.
731/// - **Idempotent.** Re-running overwrites existing files (changed source
732///   bytes propagate). Destination dirs are created as needed.
733/// - **Missing source is warned, not fatal.** A plugin whose `source_dir`
734///   doesn't exist is recorded in [`CollectSummary::missing`] and skipped;
735///   every other plugin still collects. The caller surfaces the warning.
736/// - **`clear` empties `static_root` first.** When `true`, the destination
737///   root's contents are removed before collecting (the root dir itself is
738///   recreated). Use to drop stale assets that no plugin ships any more.
739///
740/// This is filesystem infrastructure (copying asset files), so `std::fs`
741/// is the correct tool — the ORM-only rule governs database rows, not
742/// files.
743pub fn collect_static(
744    plugins: &[Box<dyn Plugin>],
745    static_root: impl Into<PathBuf>,
746    clear: bool,
747) -> Result<CollectSummary, CollectError> {
748    // Detect collisions BEFORE writing anything. `from_plugins` is the
749    // single source of truth for the "namespace -> source_dir" map and
750    // the collision rule; running it here keeps collect_static and the
751    // runtime handler in lockstep. The flattened contributions below
752    // carry each plugin's `name()`, which the summary needs and the
753    // registry doesn't keep.
754    StaticRegistry::from_plugins(plugins).map_err(CollectError::Collision)?;
755
756    let contributions = StaticContribution::collect(plugins);
757    let root_dirs = StaticContribution::collect_root_dirs(plugins);
758    collect_into(&contributions, &root_dirs, static_root, clear)
759}
760
761/// The single core copy routine, shared by the plugin-list path
762/// ([`collect_static`]) and the published-contributions path (the
763/// `collectstatic` plugin command).
764///
765/// Copies each `StaticContribution`'s `source_dir` tree into
766/// `<static_root>/<namespace>/`, and each app/site `root_dir` into the
767/// `<static_root>/` ROOT (no namespace), so `static_root` is a complete
768/// CDN-servable tree.
769///
770/// No collision check: the contributions are pre-validated (either by
771/// [`collect_static`]'s `from_plugins` call, or at `App::build` before
772/// they were published). The same guarantees as [`collect_static`]
773/// apply: collisions never reach here, missing namespaced sources are
774/// warned-not-fatal, re-runs are idempotent, and `clear` empties
775/// `static_root` first.
776///
777/// This is filesystem infrastructure (copying asset files), so
778/// `std::fs` is the correct tool — the ORM-only rule governs database
779/// rows, not files.
780pub fn collect_into(
781    contributions: &[StaticContribution],
782    root_dirs: &[PathBuf],
783    static_root: impl Into<PathBuf>,
784    clear: bool,
785) -> Result<CollectSummary, CollectError> {
786    let static_root = static_root.into();
787    let storage = LocalStorage::new(static_root.clone());
788
789    // Local-filesystem convention: ensure the root dir exists even when
790    // nothing is collected (an app may point a reverse proxy at it
791    // regardless). The storage-backed path creates parents per-file, but
792    // an empty collect would otherwise leave no root dir at all. `clear`
793    // is handled inside `collect_into_with`.
794    if !(clear && static_root.exists()) {
795        std::fs::create_dir_all(&static_root).map_err(|source| CollectError::Io {
796            path: static_root.clone(),
797            source,
798        })?;
799    }
800
801    collect_into_with(
802        contributions,
803        root_dirs,
804        &static_root,
805        &storage,
806        clear,
807        false,
808    )
809}
810
811/// The storage-backed core collect routine. Writes every collected file
812/// *through* `storage` (the [`StaticStorage`] seam) instead of `std::fs`
813/// directly, so the same engine targets the local filesystem or a remote
814/// object store. [`collect_into`] is the convenience wrapper that
815/// constructs a [`LocalStorage`] and never hashes.
816///
817/// `static_root` is still passed alongside `storage` because it is the
818/// logical destination recorded in the [`CollectSummary`] (and where the
819/// manifest is written for [`LocalStorage`]); the bytes themselves go
820/// through `storage.put(rel_path, ..)`.
821///
822/// When `hashed` is true (the hashed static-file storage), each
823/// file is *also* written under a content-hashed name
824/// (`app.<hash>.css`), and a `<logical path> -> <hashed path>` mapping is
825/// recorded into a `staticfiles.json` manifest written at the
826/// `static_root` root. The original (un-hashed) copy is kept too, so an
827/// old deploy referencing the plain name still resolves.
828///
829/// No collision check: the contributions are pre-validated. The same
830/// guarantees as [`collect_static`] apply.
831///
832/// This is filesystem/asset infrastructure (copying asset files), so
833/// `std::fs` for the *source* read is the correct tool — the ORM-only
834/// rule governs database rows, not files. The *destination* write is the
835/// one routed through `storage`.
836pub fn collect_into_with(
837    contributions: &[StaticContribution],
838    root_dirs: &[PathBuf],
839    static_root: impl Into<PathBuf>,
840    storage: &dyn StaticStorage,
841    clear: bool,
842    hashed: bool,
843) -> Result<CollectSummary, CollectError> {
844    let static_root = static_root.into();
845
846    // `clear` only makes sense for the local filesystem (a remote bucket
847    // is cleared by its own lifecycle policy). When the local root
848    // exists, empty it before collecting. For non-local backends the dir
849    // simply doesn't exist and this is a no-op.
850    if clear && static_root.exists() {
851        std::fs::remove_dir_all(&static_root).map_err(|source| CollectError::Io {
852            path: static_root.clone(),
853            source,
854        })?;
855    }
856
857    let mut summary = CollectSummary {
858        static_root: static_root.clone(),
859        ..Default::default()
860    };
861
862    // Logical-path → hashed-path manifest, accumulated across every file
863    // when `hashed` is set. BTreeMap so the written JSON is deterministic
864    // (sorted keys) — easier to diff between collect runs.
865    let mut manifest: BTreeMap<String, String> = BTreeMap::new();
866
867    // Namespaced contributions → <static_root>/<namespace>/.
868    for contribution in contributions {
869        let StaticContribution {
870            namespace,
871            source_dir,
872            plugin,
873        } = contribution;
874
875        if !source_dir.exists() {
876            // A declared-but-absent source dir is a real
877            // misconfiguration. Record it so the CLI warns; don't
878            // swallow it silently (fix-don't-patch), and don't abort
879            // the whole run — the other contributions still collect.
880            summary.missing.push(MissingSourceDir {
881                namespace,
882                plugin,
883                source_dir: source_dir.clone(),
884            });
885            continue;
886        }
887
888        let files = copy_tree(
889            source_dir,
890            source_dir,
891            namespace,
892            storage,
893            hashed,
894            &mut manifest,
895        )?;
896
897        summary.collected.push(CollectedNamespace {
898            namespace,
899            plugin,
900            files,
901            destination: static_root.join(namespace),
902        });
903    }
904
905    // App/site root dirs → <static_root>/ root (no namespace). A root dir
906    // is a project convention dir (`./static`) that may legitimately not
907    // exist yet, so an absent one is skipped silently rather than warned
908    // — it is not the "plugin promised assets that aren't there"
909    // misconfiguration a namespaced source is.
910    for root in root_dirs {
911        if !root.exists() {
912            continue;
913        }
914        let files = copy_tree(root, root, "", storage, hashed, &mut manifest)?;
915        summary.root_files += files;
916        summary.root_dirs.push(root.clone());
917    }
918
919    // Write the manifest once, after every file is hashed. Keyed by the
920    // logical path the template uses (`css/app.css`), valued by the
921    // hashed path (`css/app.<hash>.css`) — exactly what
922    // `manifest_lookup` reads back.
923    if hashed {
924        let json = serde_json::to_vec_pretty(&manifest).map_err(|e| CollectError::Io {
925            path: static_root.join(MANIFEST_FILENAME),
926            source: std::io::Error::other(e),
927        })?;
928        storage
929            .put(MANIFEST_FILENAME, &json)
930            .map_err(CollectError::Static)?;
931    }
932
933    Ok(summary)
934}
935
936/// Recursively walk every file under `src`, writing each through
937/// `storage` at the logical path `<prefix>/<relative path>` (prefix is
938/// the namespace, or `""` for root dirs). Returns the count of files
939/// (not directories) written.
940///
941/// When `hashed` is set, each file is additionally written under its
942/// content-hashed name and the `<logical> -> <hashed>` pair recorded in
943/// `manifest`. Re-runs overwrite (storage `put` replaces), keeping the
944/// collect idempotent.
945fn copy_tree(
946    src: &Path,
947    root: &Path,
948    prefix: &str,
949    storage: &dyn StaticStorage,
950    hashed: bool,
951    manifest: &mut BTreeMap<String, String>,
952) -> Result<usize, CollectError> {
953    let mut count = 0;
954    let entries = std::fs::read_dir(src).map_err(|source| CollectError::Io {
955        path: src.to_path_buf(),
956        source,
957    })?;
958
959    for entry in entries {
960        let entry = entry.map_err(|source| CollectError::Io {
961            path: src.to_path_buf(),
962            source,
963        })?;
964        let file_type = entry.file_type().map_err(|source| CollectError::Io {
965            path: entry.path(),
966            source,
967        })?;
968        let src_path = entry.path();
969        let name = entry.file_name().to_string_lossy().into_owned();
970        let child_prefix = if prefix.is_empty() {
971            name.clone()
972        } else {
973            format!("{prefix}/{name}")
974        };
975
976        // audit_2 core-web #7: collect follows symlinks-to-files by design (see
977        // the `std::fs::read` note below), but a symlink whose target ESCAPES
978        // the source root would publish an arbitrary file (e.g. a link to
979        // `/etc/passwd`) into the world-readable static tree. Skip any symlink
980        // that doesn't canonicalise to a path under `root` — mirroring the
981        // serving-path containment in `resolve_under_root`.
982        if file_type.is_symlink() {
983            let contained = match (src_path.canonicalize(), root.canonicalize()) {
984                (Ok(target), Ok(root_c)) => target.starts_with(&root_c),
985                _ => false, // dangling / unresolvable → skip to be safe
986            };
987            if !contained {
988                continue;
989            }
990        }
991
992        if file_type.is_dir() {
993            count += copy_tree(&src_path, root, &child_prefix, storage, hashed, manifest)?;
994        } else {
995            // Covers regular files and symlinks-to-files alike:
996            // `std::fs::read` follows symlinks and reads the target
997            // bytes, which is what a collected asset should be.
998            let bytes = std::fs::read(&src_path).map_err(|source| CollectError::Io {
999                path: src_path.clone(),
1000                source,
1001            })?;
1002            storage
1003                .put(&child_prefix, &bytes)
1004                .map_err(CollectError::Static)?;
1005            count += 1;
1006
1007            if hashed {
1008                let hash = content_hash(&bytes);
1009                let hashed_path = hashed_name(&child_prefix, &hash);
1010                // Write the hashed copy alongside the original. The
1011                // manifest never points at itself, and the original is
1012                // kept so an old deploy referencing the plain name still
1013                // resolves.
1014                storage
1015                    .put(&hashed_path, &bytes)
1016                    .map_err(CollectError::Static)?;
1017                manifest.insert(child_prefix.clone(), hashed_path);
1018            }
1019        }
1020    }
1021
1022    Ok(count)
1023}
1024
1025#[cfg(test)]
1026mod tests {
1027    use super::*;
1028    use axum::http::Request;
1029
1030    /// Build a `Request` whose path is `path` (already base-stripped),
1031    /// matching what `nest_service` hands the handler.
1032    fn req(path: &str) -> Request<Body> {
1033        Request::builder()
1034            .uri(path)
1035            .body(Body::empty())
1036            .expect("test request is valid")
1037    }
1038
1039    /// A minimal plugin that contributes a fixed set of static dirs.
1040    struct FakeStaticPlugin {
1041        name: &'static str,
1042        dirs: Vec<StaticDir>,
1043    }
1044
1045    impl Plugin for FakeStaticPlugin {
1046        fn name(&self) -> &'static str {
1047            self.name
1048        }
1049        fn static_dirs(&self) -> Vec<StaticDir> {
1050            self.dirs.clone()
1051        }
1052    }
1053
1054    /// A plugin with no static dirs — proves the trait default is empty
1055    /// and that it contributes nothing to the registry.
1056    struct NoStaticPlugin;
1057    impl Plugin for NoStaticPlugin {
1058        fn name(&self) -> &'static str {
1059            "no-static"
1060        }
1061    }
1062
1063    #[test]
1064    fn static_dirs_default_is_empty() {
1065        assert!(NoStaticPlugin.static_dirs().is_empty());
1066    }
1067
1068    #[test]
1069    fn registry_collects_static_dirs_from_plugins() {
1070        let plugins: Vec<Box<dyn Plugin>> = vec![
1071            Box::new(FakeStaticPlugin {
1072                name: "admin",
1073                dirs: vec![StaticDir::new("admin", "/src/admin/static")],
1074            }),
1075            Box::new(NoStaticPlugin),
1076            Box::new(FakeStaticPlugin {
1077                name: "playground",
1078                dirs: vec![StaticDir::new("playground", "/src/playground/static")],
1079            }),
1080        ];
1081        let registry = StaticRegistry::from_plugins(&plugins).expect("no collision");
1082        assert_eq!(
1083            registry.source_dir("admin"),
1084            Some(Path::new("/src/admin/static"))
1085        );
1086        assert_eq!(
1087            registry.source_dir("playground"),
1088            Some(Path::new("/src/playground/static"))
1089        );
1090        assert_eq!(registry.source_dir("nonexistent"), None);
1091    }
1092
1093    #[test]
1094    fn duplicate_namespace_fails_loudly_naming_both_plugins() {
1095        let plugins: Vec<Box<dyn Plugin>> = vec![
1096            Box::new(FakeStaticPlugin {
1097                name: "first",
1098                dirs: vec![StaticDir::new("shared", "/a")],
1099            }),
1100            Box::new(FakeStaticPlugin {
1101                name: "second",
1102                dirs: vec![StaticDir::new("shared", "/b")],
1103            }),
1104        ];
1105        let err = StaticRegistry::from_plugins(&plugins).expect_err("must collide");
1106        assert_eq!(err.namespace, "shared");
1107        assert_eq!(err.first_plugin, "first");
1108        assert_eq!(err.second_plugin, "second");
1109    }
1110
1111    #[test]
1112    fn split_namespace_splits_first_segment() {
1113        assert_eq!(
1114            split_namespace("admin/admin.css"),
1115            Some(("admin", "admin.css"))
1116        );
1117        assert_eq!(
1118            split_namespace("/admin/css/site.css"),
1119            Some(("admin", "css/site.css"))
1120        );
1121        // Bare namespace, no file -> nothing to serve.
1122        assert_eq!(split_namespace("admin"), None);
1123        assert_eq!(split_namespace("admin/"), None);
1124        assert_eq!(split_namespace(""), None);
1125    }
1126
1127    #[test]
1128    fn resolve_under_root_blocks_parent_traversal() {
1129        let dir = tempfile::tempdir().expect("tempdir");
1130        std::fs::write(dir.path().join("ok.css"), b"body{}").expect("write file");
1131
1132        // A legitimate file resolves.
1133        assert!(resolve_under_root(dir.path(), "ok.css").is_some());
1134
1135        // `..` escapes are refused lexically, before any FS access.
1136        assert!(resolve_under_root(dir.path(), "../../etc/passwd").is_none());
1137        assert!(resolve_under_root(dir.path(), "../secret").is_none());
1138        assert!(resolve_under_root(dir.path(), "a/../../b").is_none());
1139
1140        // Absolute paths are refused.
1141        assert!(resolve_under_root(dir.path(), "/etc/passwd").is_none());
1142    }
1143
1144    #[cfg(unix)]
1145    #[test]
1146    fn resolve_under_root_blocks_symlink_escape() {
1147        let root = tempfile::tempdir().expect("root tempdir");
1148        let outside = tempfile::tempdir().expect("outside tempdir");
1149        std::fs::write(outside.path().join("secret"), b"top secret").expect("write secret");
1150
1151        // A symlink *inside* root pointing to a file *outside* root.
1152        let link = root.path().join("escape");
1153        std::os::unix::fs::symlink(outside.path().join("secret"), &link).expect("symlink");
1154
1155        // Lexically clean ("escape" is a Normal component), but
1156        // canonicalisation + containment catches the escape.
1157        assert!(resolve_under_root(root.path(), "escape").is_none());
1158    }
1159
1160    /// audit_2 core-web #7 — `collect_static` must not publish a file a symlink
1161    /// points to OUTSIDE the source tree (a link to `/etc/passwd` would land in
1162    /// the world-readable static root). In-tree files still collect.
1163    #[cfg(unix)]
1164    #[test]
1165    fn copy_tree_skips_symlinks_escaping_the_source_root() {
1166        let src = tempfile::tempdir().expect("src");
1167        let outside = tempfile::tempdir().expect("outside");
1168        std::fs::write(outside.path().join("secret.txt"), b"TOPSECRET").expect("secret");
1169        // A legit in-tree asset.
1170        std::fs::write(src.path().join("app.css"), b"body{}").expect("asset");
1171        // A symlink inside src pointing OUTSIDE it.
1172        std::os::unix::fs::symlink(
1173            outside.path().join("secret.txt"),
1174            src.path().join("leak.txt"),
1175        )
1176        .expect("symlink");
1177
1178        let dest = tempfile::tempdir().expect("dest");
1179        let storage = LocalStorage::new(dest.path().to_path_buf());
1180        let mut manifest = BTreeMap::new();
1181        let n = copy_tree(src.path(), src.path(), "", &storage, false, &mut manifest)
1182            .expect("copy_tree");
1183
1184        assert!(
1185            dest.path().join("app.css").exists(),
1186            "the in-tree asset is collected"
1187        );
1188        assert!(
1189            !dest.path().join("leak.txt").exists(),
1190            "the escaping symlink must NOT be collected"
1191        );
1192        assert_eq!(n, 1, "only the one in-tree file is counted");
1193    }
1194
1195    #[tokio::test]
1196    async fn dev_serves_live_source_then_falls_back_to_static_root() {
1197        let source = tempfile::tempdir().expect("source dir");
1198        let static_root = tempfile::tempdir().expect("static root");
1199
1200        // Live source has admin.css; static_root has only legacy.css.
1201        std::fs::write(source.path().join("admin.css"), b"SOURCE").expect("write source");
1202        std::fs::create_dir_all(static_root.path().join("admin")).expect("mkdir ns");
1203        std::fs::write(
1204            static_root.path().join("admin").join("legacy.css"),
1205            b"COLLECTED",
1206        )
1207        .expect("write collected");
1208
1209        let mut by_namespace = HashMap::new();
1210        by_namespace.insert("admin", source.path().to_path_buf());
1211        let registry = StaticRegistry { by_namespace };
1212
1213        let state = StaticHandlerState {
1214            registry,
1215            static_root: static_root.path().to_path_buf(),
1216            root_dirs: Vec::new(),
1217            dev: true,
1218        };
1219
1220        // Live source wins for admin.css.
1221        let resp = static_handler(State(state.clone()), req("/admin/admin.css")).await;
1222        assert_eq!(resp.status(), StatusCode::OK);
1223        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1224            .await
1225            .expect("read body");
1226        assert_eq!(&body[..], b"SOURCE");
1227
1228        // legacy.css isn't in the live source -> dev falls back to
1229        // static_root/admin/legacy.css.
1230        let resp = static_handler(State(state.clone()), req("/admin/legacy.css")).await;
1231        assert_eq!(resp.status(), StatusCode::OK);
1232        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1233            .await
1234            .expect("read body");
1235        assert_eq!(&body[..], b"COLLECTED");
1236
1237        // A namespace with no registered source dir still serves from
1238        // static_root in dev.
1239        std::fs::create_dir_all(static_root.path().join("other")).expect("mkdir other");
1240        std::fs::write(static_root.path().join("other").join("x.js"), b"OTHER").expect("write");
1241        let resp = static_handler(State(state), req("/other/x.js")).await;
1242        assert_eq!(resp.status(), StatusCode::OK);
1243        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1244            .await
1245            .expect("read body");
1246        assert_eq!(&body[..], b"OTHER");
1247    }
1248
1249    #[tokio::test]
1250    async fn prod_serves_only_from_static_root() {
1251        let source = tempfile::tempdir().expect("source dir");
1252        let static_root = tempfile::tempdir().expect("static root");
1253
1254        // Source has a file that is NOT collected into static_root.
1255        std::fs::write(source.path().join("only-source.css"), b"SOURCE").expect("write source");
1256        std::fs::create_dir_all(static_root.path().join("admin")).expect("mkdir ns");
1257        std::fs::write(
1258            static_root.path().join("admin").join("admin.css"),
1259            b"COLLECTED",
1260        )
1261        .expect("write collected");
1262
1263        let mut by_namespace = HashMap::new();
1264        by_namespace.insert("admin", source.path().to_path_buf());
1265        let registry = StaticRegistry { by_namespace };
1266
1267        let state = StaticHandlerState {
1268            registry,
1269            static_root: static_root.path().to_path_buf(),
1270            root_dirs: Vec::new(),
1271            dev: false,
1272        };
1273
1274        // Collected file is served.
1275        let resp = static_handler(State(state.clone()), req("/admin/admin.css")).await;
1276        assert_eq!(resp.status(), StatusCode::OK);
1277        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1278            .await
1279            .expect("read body");
1280        assert_eq!(&body[..], b"COLLECTED");
1281
1282        // The source-only file is NOT reachable in prod (live serving is
1283        // dev-only).
1284        let resp = static_handler(State(state), req("/admin/only-source.css")).await;
1285        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1286    }
1287
1288    /// Write `bytes` to `dir/relpath`, creating parent dirs as needed.
1289    fn write_at(dir: &Path, relpath: &str, bytes: &[u8]) {
1290        let full = dir.join(relpath);
1291        if let Some(parent) = full.parent() {
1292            std::fs::create_dir_all(parent).expect("mkdir parents");
1293        }
1294        std::fs::write(full, bytes).expect("write file");
1295    }
1296
1297    /// Read `dir/relpath` as bytes, panicking if absent.
1298    fn read_at(dir: &Path, relpath: &str) -> Vec<u8> {
1299        std::fs::read(dir.join(relpath)).expect("read collected file")
1300    }
1301
1302    #[test]
1303    fn collect_copies_every_file_preserving_the_tree() {
1304        let admin_src = tempfile::tempdir().expect("admin src");
1305        let pg_src = tempfile::tempdir().expect("playground src");
1306        let static_root = tempfile::tempdir().expect("static root");
1307
1308        // Nested source trees: a top-level file and a file under assets/.
1309        write_at(admin_src.path(), "admin.css", b"ADMIN_CSS");
1310        write_at(admin_src.path(), "js/admin.js", b"ADMIN_JS");
1311        write_at(pg_src.path(), "dist/assets/index.js", b"PG_INDEX");
1312
1313        let plugins: Vec<Box<dyn Plugin>> = vec![
1314            Box::new(FakeStaticPlugin {
1315                name: "admin-plugin",
1316                dirs: vec![StaticDir::new("admin", admin_src.path())],
1317            }),
1318            Box::new(FakeStaticPlugin {
1319                name: "pg-plugin",
1320                dirs: vec![StaticDir::new("playground", pg_src.path())],
1321            }),
1322        ];
1323
1324        let summary =
1325            collect_static(&plugins, static_root.path(), false).expect("collect succeeds");
1326
1327        // Every file landed at <static_root>/<ns>/<relpath> with bytes intact.
1328        assert_eq!(read_at(static_root.path(), "admin/admin.css"), b"ADMIN_CSS");
1329        assert_eq!(
1330            read_at(static_root.path(), "admin/js/admin.js"),
1331            b"ADMIN_JS"
1332        );
1333        assert_eq!(
1334            read_at(static_root.path(), "playground/dist/assets/index.js"),
1335            b"PG_INDEX"
1336        );
1337
1338        // Summary reflects the per-namespace counts and the total.
1339        assert_eq!(summary.total_files(), 3);
1340        assert!(summary.missing.is_empty());
1341        let admin = summary
1342            .collected
1343            .iter()
1344            .find(|c| c.namespace == "admin")
1345            .expect("admin collected");
1346        assert_eq!(admin.files, 2);
1347        assert_eq!(admin.plugin, "admin-plugin");
1348        assert_eq!(admin.destination, static_root.path().join("admin"));
1349        let pg = summary
1350            .collected
1351            .iter()
1352            .find(|c| c.namespace == "playground")
1353            .expect("playground collected");
1354        assert_eq!(pg.files, 1);
1355    }
1356
1357    #[test]
1358    fn collect_is_idempotent_and_propagates_changed_bytes() {
1359        let src = tempfile::tempdir().expect("src");
1360        let static_root = tempfile::tempdir().expect("static root");
1361        write_at(src.path(), "app.js", b"V1");
1362
1363        let plugins: Vec<Box<dyn Plugin>> = vec![Box::new(FakeStaticPlugin {
1364            name: "p",
1365            dirs: vec![StaticDir::new("app", src.path())],
1366        })];
1367
1368        // First run.
1369        collect_static(&plugins, static_root.path(), false).expect("first collect");
1370        assert_eq!(read_at(static_root.path(), "app/app.js"), b"V1");
1371
1372        // Change the source bytes and re-run; the new bytes propagate.
1373        write_at(src.path(), "app.js", b"V2_CHANGED");
1374        let summary = collect_static(&plugins, static_root.path(), false).expect("second collect");
1375        assert_eq!(read_at(static_root.path(), "app/app.js"), b"V2_CHANGED");
1376        assert_eq!(summary.total_files(), 1);
1377    }
1378
1379    #[test]
1380    fn duplicate_namespace_aborts_and_copies_nothing() {
1381        let a = tempfile::tempdir().expect("a");
1382        let b = tempfile::tempdir().expect("b");
1383        let static_root = tempfile::tempdir().expect("static root");
1384        write_at(a.path(), "a.css", b"A");
1385        write_at(b.path(), "b.css", b"B");
1386
1387        let plugins: Vec<Box<dyn Plugin>> = vec![
1388            Box::new(FakeStaticPlugin {
1389                name: "first",
1390                dirs: vec![StaticDir::new("shared", a.path())],
1391            }),
1392            Box::new(FakeStaticPlugin {
1393                name: "second",
1394                dirs: vec![StaticDir::new("shared", b.path())],
1395            }),
1396        ];
1397
1398        let err =
1399            collect_static(&plugins, static_root.path(), false).expect_err("collision aborts");
1400        match err {
1401            CollectError::Collision(c) => {
1402                assert_eq!(c.namespace, "shared");
1403                assert_eq!(c.first_plugin, "first");
1404                assert_eq!(c.second_plugin, "second");
1405            }
1406            other => panic!("expected Collision, got {other:?}"),
1407        }
1408
1409        // Nothing was copied — the static_root has no namespace dirs.
1410        assert!(!static_root.path().join("shared").exists());
1411        let entries: Vec<_> = std::fs::read_dir(static_root.path())
1412            .expect("read static_root")
1413            .collect();
1414        assert!(
1415            entries.is_empty(),
1416            "static_root must be untouched on collision"
1417        );
1418    }
1419
1420    #[test]
1421    fn missing_source_dir_warns_but_others_still_collect() {
1422        let present = tempfile::tempdir().expect("present");
1423        let static_root = tempfile::tempdir().expect("static root");
1424        write_at(present.path(), "ok.css", b"OK");
1425
1426        // `missing_src` points at a path we never create.
1427        let missing_src = present.path().join("does-not-exist");
1428        assert!(!missing_src.exists());
1429
1430        let plugins: Vec<Box<dyn Plugin>> = vec![
1431            Box::new(FakeStaticPlugin {
1432                name: "broken",
1433                dirs: vec![StaticDir::new("ghost", missing_src.clone())],
1434            }),
1435            Box::new(FakeStaticPlugin {
1436                name: "good",
1437                dirs: vec![StaticDir::new("real", present.path())],
1438            }),
1439        ];
1440
1441        let summary =
1442            collect_static(&plugins, static_root.path(), false).expect("missing src is not fatal");
1443
1444        // The good plugin collected.
1445        assert_eq!(read_at(static_root.path(), "real/ok.css"), b"OK");
1446        // The broken plugin is recorded as missing, not collected.
1447        assert_eq!(summary.missing.len(), 1);
1448        assert_eq!(summary.missing[0].namespace, "ghost");
1449        assert_eq!(summary.missing[0].plugin, "broken");
1450        assert_eq!(summary.missing[0].source_dir, missing_src);
1451        // No ghost dir was created.
1452        assert!(!static_root.path().join("ghost").exists());
1453    }
1454
1455    #[test]
1456    fn clear_removes_stale_files_before_collect() {
1457        let src = tempfile::tempdir().expect("src");
1458        let static_root = tempfile::tempdir().expect("static root");
1459        write_at(src.path(), "current.css", b"CURRENT");
1460
1461        // Pre-seed static_root with a stale namespace no plugin ships.
1462        write_at(static_root.path(), "stale/old.css", b"STALE");
1463
1464        let plugins: Vec<Box<dyn Plugin>> = vec![Box::new(FakeStaticPlugin {
1465            name: "p",
1466            dirs: vec![StaticDir::new("app", src.path())],
1467        })];
1468
1469        // Without --clear, stale survives alongside the fresh collect.
1470        collect_static(&plugins, static_root.path(), false).expect("no-clear collect");
1471        assert!(static_root.path().join("stale/old.css").exists());
1472        assert_eq!(read_at(static_root.path(), "app/current.css"), b"CURRENT");
1473
1474        // With --clear, the stale file is gone and only fresh assets remain.
1475        collect_static(&plugins, static_root.path(), true).expect("clear collect");
1476        assert!(!static_root.path().join("stale").exists());
1477        assert_eq!(read_at(static_root.path(), "app/current.css"), b"CURRENT");
1478    }
1479
1480    #[test]
1481    fn collect_creates_static_root_when_absent() {
1482        let src = tempfile::tempdir().expect("src");
1483        let parent = tempfile::tempdir().expect("parent");
1484        // static_root doesn't exist yet — collect must create it.
1485        let static_root = parent.path().join("staticfiles");
1486        assert!(!static_root.exists());
1487        write_at(src.path(), "x.css", b"X");
1488
1489        let plugins: Vec<Box<dyn Plugin>> = vec![Box::new(FakeStaticPlugin {
1490            name: "p",
1491            dirs: vec![StaticDir::new("ns", src.path())],
1492        })];
1493
1494        collect_static(&plugins, &static_root, false).expect("collect creates root");
1495        assert_eq!(read_at(&static_root, "ns/x.css"), b"X");
1496    }
1497
1498    #[test]
1499    fn collect_into_copies_root_dirs_into_static_root_root() {
1500        let ns_src = tempfile::tempdir().expect("ns src");
1501        let root_a = tempfile::tempdir().expect("root a");
1502        let root_b = tempfile::tempdir().expect("root b");
1503        let static_root = tempfile::tempdir().expect("static root");
1504
1505        // A namespaced contribution lands under <root>/<ns>/...
1506        write_at(ns_src.path(), "admin.css", b"ADMIN");
1507        // Root dirs land at the bare <static_root>/... root, preserving
1508        // their tree shape.
1509        write_at(root_a.path(), "site.css", b"SITE_CSS");
1510        write_at(root_a.path(), "img/logo.png", b"LOGO");
1511        write_at(root_b.path(), "app.js", b"APP_JS");
1512
1513        let contributions = vec![StaticContribution {
1514            namespace: "admin",
1515            source_dir: ns_src.path().to_path_buf(),
1516            plugin: "admin-plugin",
1517        }];
1518        let root_dirs = vec![root_a.path().to_path_buf(), root_b.path().to_path_buf()];
1519
1520        let summary = collect_into(&contributions, &root_dirs, static_root.path(), false)
1521            .expect("collect_into succeeds");
1522
1523        // Namespaced file under its namespace.
1524        assert_eq!(read_at(static_root.path(), "admin/admin.css"), b"ADMIN");
1525        // Root-dir files at the bare root, bytes intact, tree preserved.
1526        assert_eq!(read_at(static_root.path(), "site.css"), b"SITE_CSS");
1527        assert_eq!(read_at(static_root.path(), "img/logo.png"), b"LOGO");
1528        assert_eq!(read_at(static_root.path(), "app.js"), b"APP_JS");
1529
1530        // Summary tracks both counts separately.
1531        assert_eq!(summary.total_files(), 1, "1 namespaced file");
1532        assert_eq!(summary.root_files, 3, "3 root-dir files across two dirs");
1533        assert_eq!(summary.root_dirs.len(), 2);
1534    }
1535
1536    #[test]
1537    fn collect_into_skips_absent_root_dir_silently() {
1538        let static_root = tempfile::tempdir().expect("static root");
1539        let present = tempfile::tempdir().expect("present root");
1540        write_at(present.path(), "x.css", b"X");
1541
1542        let absent = present.path().join("does-not-exist");
1543        assert!(!absent.exists());
1544
1545        let root_dirs = vec![present.path().to_path_buf(), absent];
1546        let summary = collect_into(&[], &root_dirs, static_root.path(), false)
1547            .expect("collect_into succeeds");
1548
1549        // Present root collected; absent one skipped, NOT recorded as a
1550        // missing-source warning (those are namespace-only).
1551        assert_eq!(read_at(static_root.path(), "x.css"), b"X");
1552        assert_eq!(summary.root_files, 1);
1553        assert_eq!(summary.root_dirs.len(), 1);
1554        assert!(summary.missing.is_empty());
1555    }
1556
1557    #[test]
1558    fn hashed_name_inserts_hash_before_extension() {
1559        assert_eq!(hashed_name("css/app.css", "abc123"), "css/app.abc123.css");
1560        // No extension: hash appended.
1561        assert_eq!(hashed_name("js/bundle", "deadbe"), "js/bundle.deadbe");
1562        // Only the LAST dot is the extension.
1563        assert_eq!(hashed_name("a/b.min.css", "0f0f0f"), "a/b.min.0f0f0f.css");
1564        // Top-level file, no directory.
1565        assert_eq!(hashed_name("favicon.ico", "112233"), "favicon.112233.ico");
1566    }
1567
1568    #[test]
1569    fn content_hash_is_stable_and_12_hex() {
1570        let h = content_hash(b"body{}");
1571        assert_eq!(h.len(), 12);
1572        assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
1573        // Same bytes -> same hash; different bytes -> different hash.
1574        assert_eq!(content_hash(b"body{}"), h);
1575        assert_ne!(content_hash(b"body{ }"), h);
1576    }
1577
1578    #[test]
1579    fn local_storage_put_writes_through_root() {
1580        let root = tempfile::tempdir().expect("root");
1581        let storage = LocalStorage::new(root.path());
1582        storage
1583            .put("css/app.css", b"BODY")
1584            .expect("put writes the file");
1585        assert_eq!(read_at(root.path(), "css/app.css"), b"BODY");
1586        assert!(storage.exists("css/app.css").expect("exists"));
1587        assert!(!storage.exists("css/missing.css").expect("exists"));
1588    }
1589
1590    #[test]
1591    fn collect_hashed_writes_copies_and_manifest() {
1592        let admin_src = tempfile::tempdir().expect("admin src");
1593        let root_src = tempfile::tempdir().expect("root src");
1594        let static_root = tempfile::tempdir().expect("static root");
1595
1596        write_at(admin_src.path(), "admin.css", b"ADMIN_CSS");
1597        write_at(root_src.path(), "css/app.css", b"APP_CSS");
1598
1599        let contributions = vec![StaticContribution {
1600            namespace: "admin",
1601            source_dir: admin_src.path().to_path_buf(),
1602            plugin: "admin-plugin",
1603        }];
1604        let root_dirs = vec![root_src.path().to_path_buf()];
1605
1606        let storage = LocalStorage::new(static_root.path());
1607        let summary = collect_into_with(
1608            &contributions,
1609            &root_dirs,
1610            static_root.path(),
1611            &storage,
1612            false,
1613            true,
1614        )
1615        .expect("hashed collect succeeds");
1616
1617        // Originals are kept.
1618        assert_eq!(read_at(static_root.path(), "admin/admin.css"), b"ADMIN_CSS");
1619        assert_eq!(read_at(static_root.path(), "css/app.css"), b"APP_CSS");
1620        assert_eq!(summary.total_files(), 1);
1621        assert_eq!(summary.root_files, 1);
1622
1623        // Manifest exists and maps logical -> hashed for BOTH the
1624        // namespaced and root-dir files, keyed by the template's logical
1625        // path.
1626        let manifest_bytes = read_at(static_root.path(), MANIFEST_FILENAME);
1627        let manifest: HashMap<String, String> =
1628            serde_json::from_slice(&manifest_bytes).expect("manifest parses");
1629
1630        let admin_hashed = manifest
1631            .get("admin/admin.css")
1632            .expect("admin entry present");
1633        let app_hashed = manifest.get("css/app.css").expect("app entry present");
1634
1635        // The hashed names carry the content hash and keep the extension.
1636        let admin_hash = content_hash(b"ADMIN_CSS");
1637        assert_eq!(admin_hashed, &format!("admin/admin.{admin_hash}.css"));
1638        let app_hash = content_hash(b"APP_CSS");
1639        assert_eq!(app_hashed, &format!("css/app.{app_hash}.css"));
1640
1641        // The hashed COPIES were actually written alongside the originals.
1642        assert_eq!(read_at(static_root.path(), admin_hashed), b"ADMIN_CSS");
1643        assert_eq!(read_at(static_root.path(), app_hashed), b"APP_CSS");
1644    }
1645
1646    #[test]
1647    fn collect_without_hashed_writes_no_manifest() {
1648        let src = tempfile::tempdir().expect("src");
1649        let static_root = tempfile::tempdir().expect("static root");
1650        write_at(src.path(), "x.css", b"X");
1651
1652        let contributions = vec![StaticContribution {
1653            namespace: "ns",
1654            source_dir: src.path().to_path_buf(),
1655            plugin: "p",
1656        }];
1657        let storage = LocalStorage::new(static_root.path());
1658        collect_into_with(
1659            &contributions,
1660            &[],
1661            static_root.path(),
1662            &storage,
1663            false,
1664            false,
1665        )
1666        .expect("plain collect");
1667
1668        assert_eq!(read_at(static_root.path(), "ns/x.css"), b"X");
1669        // No manifest, no hashed copy.
1670        assert!(!static_root.path().join(MANIFEST_FILENAME).exists());
1671    }
1672
1673    #[tokio::test]
1674    async fn handler_blocks_path_traversal() {
1675        let static_root = tempfile::tempdir().expect("static root");
1676        std::fs::create_dir_all(static_root.path().join("admin")).expect("mkdir ns");
1677        std::fs::write(static_root.path().join("admin").join("ok.css"), b"OK").expect("write");
1678
1679        let state = StaticHandlerState {
1680            registry: StaticRegistry::default(),
1681            static_root: static_root.path().to_path_buf(),
1682            root_dirs: Vec::new(),
1683            dev: false,
1684        };
1685
1686        let resp = static_handler(State(state), req("/admin/../../etc/passwd")).await;
1687        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1688    }
1689}