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}