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