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(contributions, root_dirs, &static_root, &storage, clear, false)
802}
803
804/// The storage-backed core collect routine. Writes every collected file
805/// *through* `storage` (the [`StaticStorage`] seam) instead of `std::fs`
806/// directly, so the same engine targets the local filesystem or a remote
807/// object store. [`collect_into`] is the convenience wrapper that
808/// constructs a [`LocalStorage`] and never hashes.
809///
810/// `static_root` is still passed alongside `storage` because it is the
811/// logical destination recorded in the [`CollectSummary`] (and where the
812/// manifest is written for [`LocalStorage`]); the bytes themselves go
813/// through `storage.put(rel_path, ..)`.
814///
815/// When `hashed` is true (the hashed static-file storage), each
816/// file is *also* written under a content-hashed name
817/// (`app.<hash>.css`), and a `<logical path> -> <hashed path>` mapping is
818/// recorded into a `staticfiles.json` manifest written at the
819/// `static_root` root. The original (un-hashed) copy is kept too, so an
820/// old deploy referencing the plain name still resolves.
821///
822/// No collision check: the contributions are pre-validated. The same
823/// guarantees as [`collect_static`] apply.
824///
825/// This is filesystem/asset infrastructure (copying asset files), so
826/// `std::fs` for the *source* read is the correct tool — the ORM-only
827/// rule governs database rows, not files. The *destination* write is the
828/// one routed through `storage`.
829pub fn collect_into_with(
830    contributions: &[StaticContribution],
831    root_dirs: &[PathBuf],
832    static_root: impl Into<PathBuf>,
833    storage: &dyn StaticStorage,
834    clear: bool,
835    hashed: bool,
836) -> Result<CollectSummary, CollectError> {
837    let static_root = static_root.into();
838
839    // `clear` only makes sense for the local filesystem (a remote bucket
840    // is cleared by its own lifecycle policy). When the local root
841    // exists, empty it before collecting. For non-local backends the dir
842    // simply doesn't exist and this is a no-op.
843    if clear && static_root.exists() {
844        std::fs::remove_dir_all(&static_root).map_err(|source| CollectError::Io {
845            path: static_root.clone(),
846            source,
847        })?;
848    }
849
850    let mut summary = CollectSummary {
851        static_root: static_root.clone(),
852        ..Default::default()
853    };
854
855    // Logical-path → hashed-path manifest, accumulated across every file
856    // when `hashed` is set. BTreeMap so the written JSON is deterministic
857    // (sorted keys) — easier to diff between collect runs.
858    let mut manifest: BTreeMap<String, String> = BTreeMap::new();
859
860    // Namespaced contributions → <static_root>/<namespace>/.
861    for contribution in contributions {
862        let StaticContribution {
863            namespace,
864            source_dir,
865            plugin,
866        } = contribution;
867
868        if !source_dir.exists() {
869            // A declared-but-absent source dir is a real
870            // misconfiguration. Record it so the CLI warns; don't
871            // swallow it silently (fix-don't-patch), and don't abort
872            // the whole run — the other contributions still collect.
873            summary.missing.push(MissingSourceDir {
874                namespace,
875                plugin,
876                source_dir: source_dir.clone(),
877            });
878            continue;
879        }
880
881        let files = copy_tree(
882            source_dir,
883            namespace,
884            storage,
885            hashed,
886            &mut manifest,
887        )?;
888
889        summary.collected.push(CollectedNamespace {
890            namespace,
891            plugin,
892            files,
893            destination: static_root.join(namespace),
894        });
895    }
896
897    // App/site root dirs → <static_root>/ root (no namespace). A root dir
898    // is a project convention dir (`./static`) that may legitimately not
899    // exist yet, so an absent one is skipped silently rather than warned
900    // — it is not the "plugin promised assets that aren't there"
901    // misconfiguration a namespaced source is.
902    for root in root_dirs {
903        if !root.exists() {
904            continue;
905        }
906        let files = copy_tree(root, "", storage, hashed, &mut manifest)?;
907        summary.root_files += files;
908        summary.root_dirs.push(root.clone());
909    }
910
911    // Write the manifest once, after every file is hashed. Keyed by the
912    // logical path the template uses (`css/app.css`), valued by the
913    // hashed path (`css/app.<hash>.css`) — exactly what
914    // `manifest_lookup` reads back.
915    if hashed {
916        let json = serde_json::to_vec_pretty(&manifest).map_err(|e| CollectError::Io {
917            path: static_root.join(MANIFEST_FILENAME),
918            source: std::io::Error::other(e),
919        })?;
920        storage
921            .put(MANIFEST_FILENAME, &json)
922            .map_err(CollectError::Static)?;
923    }
924
925    Ok(summary)
926}
927
928/// Recursively walk every file under `src`, writing each through
929/// `storage` at the logical path `<prefix>/<relative path>` (prefix is
930/// the namespace, or `""` for root dirs). Returns the count of files
931/// (not directories) written.
932///
933/// When `hashed` is set, each file is additionally written under its
934/// content-hashed name and the `<logical> -> <hashed>` pair recorded in
935/// `manifest`. Re-runs overwrite (storage `put` replaces), keeping the
936/// collect idempotent.
937fn copy_tree(
938    src: &Path,
939    prefix: &str,
940    storage: &dyn StaticStorage,
941    hashed: bool,
942    manifest: &mut BTreeMap<String, String>,
943) -> Result<usize, CollectError> {
944    let mut count = 0;
945    let entries = std::fs::read_dir(src).map_err(|source| CollectError::Io {
946        path: src.to_path_buf(),
947        source,
948    })?;
949
950    for entry in entries {
951        let entry = entry.map_err(|source| CollectError::Io {
952            path: src.to_path_buf(),
953            source,
954        })?;
955        let file_type = entry.file_type().map_err(|source| CollectError::Io {
956            path: entry.path(),
957            source,
958        })?;
959        let src_path = entry.path();
960        let name = entry.file_name().to_string_lossy().into_owned();
961        let child_prefix = if prefix.is_empty() {
962            name.clone()
963        } else {
964            format!("{prefix}/{name}")
965        };
966
967        if file_type.is_dir() {
968            count += copy_tree(&src_path, &child_prefix, storage, hashed, manifest)?;
969        } else {
970            // Covers regular files and symlinks-to-files alike:
971            // `std::fs::read` follows symlinks and reads the target
972            // bytes, which is what a collected asset should be.
973            let bytes = std::fs::read(&src_path).map_err(|source| CollectError::Io {
974                path: src_path.clone(),
975                source,
976            })?;
977            storage
978                .put(&child_prefix, &bytes)
979                .map_err(CollectError::Static)?;
980            count += 1;
981
982            if hashed {
983                let hash = content_hash(&bytes);
984                let hashed_path = hashed_name(&child_prefix, &hash);
985                // Write the hashed copy alongside the original. The
986                // manifest never points at itself, and the original is
987                // kept so an old deploy referencing the plain name still
988                // resolves.
989                storage
990                    .put(&hashed_path, &bytes)
991                    .map_err(CollectError::Static)?;
992                manifest.insert(child_prefix.clone(), hashed_path);
993            }
994        }
995    }
996
997    Ok(count)
998}
999
1000#[cfg(test)]
1001mod tests {
1002    use super::*;
1003    use axum::http::Request;
1004
1005    /// Build a `Request` whose path is `path` (already base-stripped),
1006    /// matching what `nest_service` hands the handler.
1007    fn req(path: &str) -> Request<Body> {
1008        Request::builder()
1009            .uri(path)
1010            .body(Body::empty())
1011            .expect("test request is valid")
1012    }
1013
1014    /// A minimal plugin that contributes a fixed set of static dirs.
1015    struct FakeStaticPlugin {
1016        name: &'static str,
1017        dirs: Vec<StaticDir>,
1018    }
1019
1020    impl Plugin for FakeStaticPlugin {
1021        fn name(&self) -> &'static str {
1022            self.name
1023        }
1024        fn static_dirs(&self) -> Vec<StaticDir> {
1025            self.dirs.clone()
1026        }
1027    }
1028
1029    /// A plugin with no static dirs — proves the trait default is empty
1030    /// and that it contributes nothing to the registry.
1031    struct NoStaticPlugin;
1032    impl Plugin for NoStaticPlugin {
1033        fn name(&self) -> &'static str {
1034            "no-static"
1035        }
1036    }
1037
1038    #[test]
1039    fn static_dirs_default_is_empty() {
1040        assert!(NoStaticPlugin.static_dirs().is_empty());
1041    }
1042
1043    #[test]
1044    fn registry_collects_static_dirs_from_plugins() {
1045        let plugins: Vec<Box<dyn Plugin>> = vec![
1046            Box::new(FakeStaticPlugin {
1047                name: "admin",
1048                dirs: vec![StaticDir::new("admin", "/src/admin/static")],
1049            }),
1050            Box::new(NoStaticPlugin),
1051            Box::new(FakeStaticPlugin {
1052                name: "playground",
1053                dirs: vec![StaticDir::new("playground", "/src/playground/static")],
1054            }),
1055        ];
1056        let registry = StaticRegistry::from_plugins(&plugins).expect("no collision");
1057        assert_eq!(
1058            registry.source_dir("admin"),
1059            Some(Path::new("/src/admin/static"))
1060        );
1061        assert_eq!(
1062            registry.source_dir("playground"),
1063            Some(Path::new("/src/playground/static"))
1064        );
1065        assert_eq!(registry.source_dir("nonexistent"), None);
1066    }
1067
1068    #[test]
1069    fn duplicate_namespace_fails_loudly_naming_both_plugins() {
1070        let plugins: Vec<Box<dyn Plugin>> = vec![
1071            Box::new(FakeStaticPlugin {
1072                name: "first",
1073                dirs: vec![StaticDir::new("shared", "/a")],
1074            }),
1075            Box::new(FakeStaticPlugin {
1076                name: "second",
1077                dirs: vec![StaticDir::new("shared", "/b")],
1078            }),
1079        ];
1080        let err = StaticRegistry::from_plugins(&plugins).expect_err("must collide");
1081        assert_eq!(err.namespace, "shared");
1082        assert_eq!(err.first_plugin, "first");
1083        assert_eq!(err.second_plugin, "second");
1084    }
1085
1086    #[test]
1087    fn split_namespace_splits_first_segment() {
1088        assert_eq!(
1089            split_namespace("admin/admin.css"),
1090            Some(("admin", "admin.css"))
1091        );
1092        assert_eq!(
1093            split_namespace("/admin/css/site.css"),
1094            Some(("admin", "css/site.css"))
1095        );
1096        // Bare namespace, no file -> nothing to serve.
1097        assert_eq!(split_namespace("admin"), None);
1098        assert_eq!(split_namespace("admin/"), None);
1099        assert_eq!(split_namespace(""), None);
1100    }
1101
1102    #[test]
1103    fn resolve_under_root_blocks_parent_traversal() {
1104        let dir = tempfile::tempdir().expect("tempdir");
1105        std::fs::write(dir.path().join("ok.css"), b"body{}").expect("write file");
1106
1107        // A legitimate file resolves.
1108        assert!(resolve_under_root(dir.path(), "ok.css").is_some());
1109
1110        // `..` escapes are refused lexically, before any FS access.
1111        assert!(resolve_under_root(dir.path(), "../../etc/passwd").is_none());
1112        assert!(resolve_under_root(dir.path(), "../secret").is_none());
1113        assert!(resolve_under_root(dir.path(), "a/../../b").is_none());
1114
1115        // Absolute paths are refused.
1116        assert!(resolve_under_root(dir.path(), "/etc/passwd").is_none());
1117    }
1118
1119    #[cfg(unix)]
1120    #[test]
1121    fn resolve_under_root_blocks_symlink_escape() {
1122        let root = tempfile::tempdir().expect("root tempdir");
1123        let outside = tempfile::tempdir().expect("outside tempdir");
1124        std::fs::write(outside.path().join("secret"), b"top secret").expect("write secret");
1125
1126        // A symlink *inside* root pointing to a file *outside* root.
1127        let link = root.path().join("escape");
1128        std::os::unix::fs::symlink(outside.path().join("secret"), &link).expect("symlink");
1129
1130        // Lexically clean ("escape" is a Normal component), but
1131        // canonicalisation + containment catches the escape.
1132        assert!(resolve_under_root(root.path(), "escape").is_none());
1133    }
1134
1135    #[tokio::test]
1136    async fn dev_serves_live_source_then_falls_back_to_static_root() {
1137        let source = tempfile::tempdir().expect("source dir");
1138        let static_root = tempfile::tempdir().expect("static root");
1139
1140        // Live source has admin.css; static_root has only legacy.css.
1141        std::fs::write(source.path().join("admin.css"), b"SOURCE").expect("write source");
1142        std::fs::create_dir_all(static_root.path().join("admin")).expect("mkdir ns");
1143        std::fs::write(
1144            static_root.path().join("admin").join("legacy.css"),
1145            b"COLLECTED",
1146        )
1147        .expect("write collected");
1148
1149        let mut by_namespace = HashMap::new();
1150        by_namespace.insert("admin", source.path().to_path_buf());
1151        let registry = StaticRegistry { by_namespace };
1152
1153        let state = StaticHandlerState {
1154            registry,
1155            static_root: static_root.path().to_path_buf(),
1156            root_dirs: Vec::new(),
1157            dev: true,
1158        };
1159
1160        // Live source wins for admin.css.
1161        let resp = static_handler(State(state.clone()), req("/admin/admin.css")).await;
1162        assert_eq!(resp.status(), StatusCode::OK);
1163        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1164            .await
1165            .expect("read body");
1166        assert_eq!(&body[..], b"SOURCE");
1167
1168        // legacy.css isn't in the live source -> dev falls back to
1169        // static_root/admin/legacy.css.
1170        let resp = static_handler(State(state.clone()), req("/admin/legacy.css")).await;
1171        assert_eq!(resp.status(), StatusCode::OK);
1172        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1173            .await
1174            .expect("read body");
1175        assert_eq!(&body[..], b"COLLECTED");
1176
1177        // A namespace with no registered source dir still serves from
1178        // static_root in dev.
1179        std::fs::create_dir_all(static_root.path().join("other")).expect("mkdir other");
1180        std::fs::write(static_root.path().join("other").join("x.js"), b"OTHER").expect("write");
1181        let resp = static_handler(State(state), req("/other/x.js")).await;
1182        assert_eq!(resp.status(), StatusCode::OK);
1183        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1184            .await
1185            .expect("read body");
1186        assert_eq!(&body[..], b"OTHER");
1187    }
1188
1189    #[tokio::test]
1190    async fn prod_serves_only_from_static_root() {
1191        let source = tempfile::tempdir().expect("source dir");
1192        let static_root = tempfile::tempdir().expect("static root");
1193
1194        // Source has a file that is NOT collected into static_root.
1195        std::fs::write(source.path().join("only-source.css"), b"SOURCE").expect("write source");
1196        std::fs::create_dir_all(static_root.path().join("admin")).expect("mkdir ns");
1197        std::fs::write(
1198            static_root.path().join("admin").join("admin.css"),
1199            b"COLLECTED",
1200        )
1201        .expect("write collected");
1202
1203        let mut by_namespace = HashMap::new();
1204        by_namespace.insert("admin", source.path().to_path_buf());
1205        let registry = StaticRegistry { by_namespace };
1206
1207        let state = StaticHandlerState {
1208            registry,
1209            static_root: static_root.path().to_path_buf(),
1210            root_dirs: Vec::new(),
1211            dev: false,
1212        };
1213
1214        // Collected file is served.
1215        let resp = static_handler(State(state.clone()), req("/admin/admin.css")).await;
1216        assert_eq!(resp.status(), StatusCode::OK);
1217        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1218            .await
1219            .expect("read body");
1220        assert_eq!(&body[..], b"COLLECTED");
1221
1222        // The source-only file is NOT reachable in prod (live serving is
1223        // dev-only).
1224        let resp = static_handler(State(state), req("/admin/only-source.css")).await;
1225        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1226    }
1227
1228    /// Write `bytes` to `dir/relpath`, creating parent dirs as needed.
1229    fn write_at(dir: &Path, relpath: &str, bytes: &[u8]) {
1230        let full = dir.join(relpath);
1231        if let Some(parent) = full.parent() {
1232            std::fs::create_dir_all(parent).expect("mkdir parents");
1233        }
1234        std::fs::write(full, bytes).expect("write file");
1235    }
1236
1237    /// Read `dir/relpath` as bytes, panicking if absent.
1238    fn read_at(dir: &Path, relpath: &str) -> Vec<u8> {
1239        std::fs::read(dir.join(relpath)).expect("read collected file")
1240    }
1241
1242    #[test]
1243    fn collect_copies_every_file_preserving_the_tree() {
1244        let admin_src = tempfile::tempdir().expect("admin src");
1245        let pg_src = tempfile::tempdir().expect("playground src");
1246        let static_root = tempfile::tempdir().expect("static root");
1247
1248        // Nested source trees: a top-level file and a file under assets/.
1249        write_at(admin_src.path(), "admin.css", b"ADMIN_CSS");
1250        write_at(admin_src.path(), "js/admin.js", b"ADMIN_JS");
1251        write_at(pg_src.path(), "dist/assets/index.js", b"PG_INDEX");
1252
1253        let plugins: Vec<Box<dyn Plugin>> = vec![
1254            Box::new(FakeStaticPlugin {
1255                name: "admin-plugin",
1256                dirs: vec![StaticDir::new("admin", admin_src.path())],
1257            }),
1258            Box::new(FakeStaticPlugin {
1259                name: "pg-plugin",
1260                dirs: vec![StaticDir::new("playground", pg_src.path())],
1261            }),
1262        ];
1263
1264        let summary =
1265            collect_static(&plugins, static_root.path(), false).expect("collect succeeds");
1266
1267        // Every file landed at <static_root>/<ns>/<relpath> with bytes intact.
1268        assert_eq!(read_at(static_root.path(), "admin/admin.css"), b"ADMIN_CSS");
1269        assert_eq!(
1270            read_at(static_root.path(), "admin/js/admin.js"),
1271            b"ADMIN_JS"
1272        );
1273        assert_eq!(
1274            read_at(static_root.path(), "playground/dist/assets/index.js"),
1275            b"PG_INDEX"
1276        );
1277
1278        // Summary reflects the per-namespace counts and the total.
1279        assert_eq!(summary.total_files(), 3);
1280        assert!(summary.missing.is_empty());
1281        let admin = summary
1282            .collected
1283            .iter()
1284            .find(|c| c.namespace == "admin")
1285            .expect("admin collected");
1286        assert_eq!(admin.files, 2);
1287        assert_eq!(admin.plugin, "admin-plugin");
1288        assert_eq!(admin.destination, static_root.path().join("admin"));
1289        let pg = summary
1290            .collected
1291            .iter()
1292            .find(|c| c.namespace == "playground")
1293            .expect("playground collected");
1294        assert_eq!(pg.files, 1);
1295    }
1296
1297    #[test]
1298    fn collect_is_idempotent_and_propagates_changed_bytes() {
1299        let src = tempfile::tempdir().expect("src");
1300        let static_root = tempfile::tempdir().expect("static root");
1301        write_at(src.path(), "app.js", b"V1");
1302
1303        let plugins: Vec<Box<dyn Plugin>> = vec![Box::new(FakeStaticPlugin {
1304            name: "p",
1305            dirs: vec![StaticDir::new("app", src.path())],
1306        })];
1307
1308        // First run.
1309        collect_static(&plugins, static_root.path(), false).expect("first collect");
1310        assert_eq!(read_at(static_root.path(), "app/app.js"), b"V1");
1311
1312        // Change the source bytes and re-run; the new bytes propagate.
1313        write_at(src.path(), "app.js", b"V2_CHANGED");
1314        let summary = collect_static(&plugins, static_root.path(), false).expect("second collect");
1315        assert_eq!(read_at(static_root.path(), "app/app.js"), b"V2_CHANGED");
1316        assert_eq!(summary.total_files(), 1);
1317    }
1318
1319    #[test]
1320    fn duplicate_namespace_aborts_and_copies_nothing() {
1321        let a = tempfile::tempdir().expect("a");
1322        let b = tempfile::tempdir().expect("b");
1323        let static_root = tempfile::tempdir().expect("static root");
1324        write_at(a.path(), "a.css", b"A");
1325        write_at(b.path(), "b.css", b"B");
1326
1327        let plugins: Vec<Box<dyn Plugin>> = vec![
1328            Box::new(FakeStaticPlugin {
1329                name: "first",
1330                dirs: vec![StaticDir::new("shared", a.path())],
1331            }),
1332            Box::new(FakeStaticPlugin {
1333                name: "second",
1334                dirs: vec![StaticDir::new("shared", b.path())],
1335            }),
1336        ];
1337
1338        let err =
1339            collect_static(&plugins, static_root.path(), false).expect_err("collision aborts");
1340        match err {
1341            CollectError::Collision(c) => {
1342                assert_eq!(c.namespace, "shared");
1343                assert_eq!(c.first_plugin, "first");
1344                assert_eq!(c.second_plugin, "second");
1345            }
1346            other => panic!("expected Collision, got {other:?}"),
1347        }
1348
1349        // Nothing was copied — the static_root has no namespace dirs.
1350        assert!(!static_root.path().join("shared").exists());
1351        let entries: Vec<_> = std::fs::read_dir(static_root.path())
1352            .expect("read static_root")
1353            .collect();
1354        assert!(
1355            entries.is_empty(),
1356            "static_root must be untouched on collision"
1357        );
1358    }
1359
1360    #[test]
1361    fn missing_source_dir_warns_but_others_still_collect() {
1362        let present = tempfile::tempdir().expect("present");
1363        let static_root = tempfile::tempdir().expect("static root");
1364        write_at(present.path(), "ok.css", b"OK");
1365
1366        // `missing_src` points at a path we never create.
1367        let missing_src = present.path().join("does-not-exist");
1368        assert!(!missing_src.exists());
1369
1370        let plugins: Vec<Box<dyn Plugin>> = vec![
1371            Box::new(FakeStaticPlugin {
1372                name: "broken",
1373                dirs: vec![StaticDir::new("ghost", missing_src.clone())],
1374            }),
1375            Box::new(FakeStaticPlugin {
1376                name: "good",
1377                dirs: vec![StaticDir::new("real", present.path())],
1378            }),
1379        ];
1380
1381        let summary =
1382            collect_static(&plugins, static_root.path(), false).expect("missing src is not fatal");
1383
1384        // The good plugin collected.
1385        assert_eq!(read_at(static_root.path(), "real/ok.css"), b"OK");
1386        // The broken plugin is recorded as missing, not collected.
1387        assert_eq!(summary.missing.len(), 1);
1388        assert_eq!(summary.missing[0].namespace, "ghost");
1389        assert_eq!(summary.missing[0].plugin, "broken");
1390        assert_eq!(summary.missing[0].source_dir, missing_src);
1391        // No ghost dir was created.
1392        assert!(!static_root.path().join("ghost").exists());
1393    }
1394
1395    #[test]
1396    fn clear_removes_stale_files_before_collect() {
1397        let src = tempfile::tempdir().expect("src");
1398        let static_root = tempfile::tempdir().expect("static root");
1399        write_at(src.path(), "current.css", b"CURRENT");
1400
1401        // Pre-seed static_root with a stale namespace no plugin ships.
1402        write_at(static_root.path(), "stale/old.css", b"STALE");
1403
1404        let plugins: Vec<Box<dyn Plugin>> = vec![Box::new(FakeStaticPlugin {
1405            name: "p",
1406            dirs: vec![StaticDir::new("app", src.path())],
1407        })];
1408
1409        // Without --clear, stale survives alongside the fresh collect.
1410        collect_static(&plugins, static_root.path(), false).expect("no-clear collect");
1411        assert!(static_root.path().join("stale/old.css").exists());
1412        assert_eq!(read_at(static_root.path(), "app/current.css"), b"CURRENT");
1413
1414        // With --clear, the stale file is gone and only fresh assets remain.
1415        collect_static(&plugins, static_root.path(), true).expect("clear collect");
1416        assert!(!static_root.path().join("stale").exists());
1417        assert_eq!(read_at(static_root.path(), "app/current.css"), b"CURRENT");
1418    }
1419
1420    #[test]
1421    fn collect_creates_static_root_when_absent() {
1422        let src = tempfile::tempdir().expect("src");
1423        let parent = tempfile::tempdir().expect("parent");
1424        // static_root doesn't exist yet — collect must create it.
1425        let static_root = parent.path().join("staticfiles");
1426        assert!(!static_root.exists());
1427        write_at(src.path(), "x.css", b"X");
1428
1429        let plugins: Vec<Box<dyn Plugin>> = vec![Box::new(FakeStaticPlugin {
1430            name: "p",
1431            dirs: vec![StaticDir::new("ns", src.path())],
1432        })];
1433
1434        collect_static(&plugins, &static_root, false).expect("collect creates root");
1435        assert_eq!(read_at(&static_root, "ns/x.css"), b"X");
1436    }
1437
1438    #[test]
1439    fn collect_into_copies_root_dirs_into_static_root_root() {
1440        let ns_src = tempfile::tempdir().expect("ns src");
1441        let root_a = tempfile::tempdir().expect("root a");
1442        let root_b = tempfile::tempdir().expect("root b");
1443        let static_root = tempfile::tempdir().expect("static root");
1444
1445        // A namespaced contribution lands under <root>/<ns>/...
1446        write_at(ns_src.path(), "admin.css", b"ADMIN");
1447        // Root dirs land at the bare <static_root>/... root, preserving
1448        // their tree shape.
1449        write_at(root_a.path(), "site.css", b"SITE_CSS");
1450        write_at(root_a.path(), "img/logo.png", b"LOGO");
1451        write_at(root_b.path(), "app.js", b"APP_JS");
1452
1453        let contributions = vec![StaticContribution {
1454            namespace: "admin",
1455            source_dir: ns_src.path().to_path_buf(),
1456            plugin: "admin-plugin",
1457        }];
1458        let root_dirs = vec![root_a.path().to_path_buf(), root_b.path().to_path_buf()];
1459
1460        let summary = collect_into(&contributions, &root_dirs, static_root.path(), false)
1461            .expect("collect_into succeeds");
1462
1463        // Namespaced file under its namespace.
1464        assert_eq!(read_at(static_root.path(), "admin/admin.css"), b"ADMIN");
1465        // Root-dir files at the bare root, bytes intact, tree preserved.
1466        assert_eq!(read_at(static_root.path(), "site.css"), b"SITE_CSS");
1467        assert_eq!(read_at(static_root.path(), "img/logo.png"), b"LOGO");
1468        assert_eq!(read_at(static_root.path(), "app.js"), b"APP_JS");
1469
1470        // Summary tracks both counts separately.
1471        assert_eq!(summary.total_files(), 1, "1 namespaced file");
1472        assert_eq!(summary.root_files, 3, "3 root-dir files across two dirs");
1473        assert_eq!(summary.root_dirs.len(), 2);
1474    }
1475
1476    #[test]
1477    fn collect_into_skips_absent_root_dir_silently() {
1478        let static_root = tempfile::tempdir().expect("static root");
1479        let present = tempfile::tempdir().expect("present root");
1480        write_at(present.path(), "x.css", b"X");
1481
1482        let absent = present.path().join("does-not-exist");
1483        assert!(!absent.exists());
1484
1485        let root_dirs = vec![present.path().to_path_buf(), absent];
1486        let summary = collect_into(&[], &root_dirs, static_root.path(), false)
1487            .expect("collect_into succeeds");
1488
1489        // Present root collected; absent one skipped, NOT recorded as a
1490        // missing-source warning (those are namespace-only).
1491        assert_eq!(read_at(static_root.path(), "x.css"), b"X");
1492        assert_eq!(summary.root_files, 1);
1493        assert_eq!(summary.root_dirs.len(), 1);
1494        assert!(summary.missing.is_empty());
1495    }
1496
1497    #[test]
1498    fn hashed_name_inserts_hash_before_extension() {
1499        assert_eq!(
1500            hashed_name("css/app.css", "abc123"),
1501            "css/app.abc123.css"
1502        );
1503        // No extension: hash appended.
1504        assert_eq!(hashed_name("js/bundle", "deadbe"), "js/bundle.deadbe");
1505        // Only the LAST dot is the extension.
1506        assert_eq!(
1507            hashed_name("a/b.min.css", "0f0f0f"),
1508            "a/b.min.0f0f0f.css"
1509        );
1510        // Top-level file, no directory.
1511        assert_eq!(hashed_name("favicon.ico", "112233"), "favicon.112233.ico");
1512    }
1513
1514    #[test]
1515    fn content_hash_is_stable_and_12_hex() {
1516        let h = content_hash(b"body{}");
1517        assert_eq!(h.len(), 12);
1518        assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
1519        // Same bytes -> same hash; different bytes -> different hash.
1520        assert_eq!(content_hash(b"body{}"), h);
1521        assert_ne!(content_hash(b"body{ }"), h);
1522    }
1523
1524    #[test]
1525    fn local_storage_put_writes_through_root() {
1526        let root = tempfile::tempdir().expect("root");
1527        let storage = LocalStorage::new(root.path());
1528        storage
1529            .put("css/app.css", b"BODY")
1530            .expect("put writes the file");
1531        assert_eq!(read_at(root.path(), "css/app.css"), b"BODY");
1532        assert!(storage.exists("css/app.css").expect("exists"));
1533        assert!(!storage.exists("css/missing.css").expect("exists"));
1534    }
1535
1536    #[test]
1537    fn collect_hashed_writes_copies_and_manifest() {
1538        let admin_src = tempfile::tempdir().expect("admin src");
1539        let root_src = tempfile::tempdir().expect("root src");
1540        let static_root = tempfile::tempdir().expect("static root");
1541
1542        write_at(admin_src.path(), "admin.css", b"ADMIN_CSS");
1543        write_at(root_src.path(), "css/app.css", b"APP_CSS");
1544
1545        let contributions = vec![StaticContribution {
1546            namespace: "admin",
1547            source_dir: admin_src.path().to_path_buf(),
1548            plugin: "admin-plugin",
1549        }];
1550        let root_dirs = vec![root_src.path().to_path_buf()];
1551
1552        let storage = LocalStorage::new(static_root.path());
1553        let summary = collect_into_with(
1554            &contributions,
1555            &root_dirs,
1556            static_root.path(),
1557            &storage,
1558            false,
1559            true,
1560        )
1561        .expect("hashed collect succeeds");
1562
1563        // Originals are kept.
1564        assert_eq!(read_at(static_root.path(), "admin/admin.css"), b"ADMIN_CSS");
1565        assert_eq!(read_at(static_root.path(), "css/app.css"), b"APP_CSS");
1566        assert_eq!(summary.total_files(), 1);
1567        assert_eq!(summary.root_files, 1);
1568
1569        // Manifest exists and maps logical -> hashed for BOTH the
1570        // namespaced and root-dir files, keyed by the template's logical
1571        // path.
1572        let manifest_bytes = read_at(static_root.path(), MANIFEST_FILENAME);
1573        let manifest: HashMap<String, String> =
1574            serde_json::from_slice(&manifest_bytes).expect("manifest parses");
1575
1576        let admin_hashed = manifest
1577            .get("admin/admin.css")
1578            .expect("admin entry present");
1579        let app_hashed = manifest.get("css/app.css").expect("app entry present");
1580
1581        // The hashed names carry the content hash and keep the extension.
1582        let admin_hash = content_hash(b"ADMIN_CSS");
1583        assert_eq!(admin_hashed, &format!("admin/admin.{admin_hash}.css"));
1584        let app_hash = content_hash(b"APP_CSS");
1585        assert_eq!(app_hashed, &format!("css/app.{app_hash}.css"));
1586
1587        // The hashed COPIES were actually written alongside the originals.
1588        assert_eq!(read_at(static_root.path(), admin_hashed), b"ADMIN_CSS");
1589        assert_eq!(read_at(static_root.path(), app_hashed), b"APP_CSS");
1590    }
1591
1592    #[test]
1593    fn collect_without_hashed_writes_no_manifest() {
1594        let src = tempfile::tempdir().expect("src");
1595        let static_root = tempfile::tempdir().expect("static root");
1596        write_at(src.path(), "x.css", b"X");
1597
1598        let contributions = vec![StaticContribution {
1599            namespace: "ns",
1600            source_dir: src.path().to_path_buf(),
1601            plugin: "p",
1602        }];
1603        let storage = LocalStorage::new(static_root.path());
1604        collect_into_with(&contributions, &[], static_root.path(), &storage, false, false)
1605            .expect("plain collect");
1606
1607        assert_eq!(read_at(static_root.path(), "ns/x.css"), b"X");
1608        // No manifest, no hashed copy.
1609        assert!(!static_root.path().join(MANIFEST_FILENAME).exists());
1610    }
1611
1612    #[tokio::test]
1613    async fn handler_blocks_path_traversal() {
1614        let static_root = tempfile::tempdir().expect("static root");
1615        std::fs::create_dir_all(static_root.path().join("admin")).expect("mkdir ns");
1616        std::fs::write(static_root.path().join("admin").join("ok.css"), b"OK").expect("write");
1617
1618        let state = StaticHandlerState {
1619            registry: StaticRegistry::default(),
1620            static_root: static_root.path().to_path_buf(),
1621            root_dirs: Vec::new(),
1622            dev: false,
1623        };
1624
1625        let resp = static_handler(State(state), req("/admin/../../etc/passwd")).await;
1626        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1627    }
1628}