use std::collections::BTreeMap;
use std::collections::HashMap;
use std::path::{Component, Path, PathBuf};
use std::sync::OnceLock;
use axum::body::Body;
use axum::extract::State;
use axum::http::{Request, Response, StatusCode, header};
use sha2::{Digest, Sha256};
use tower::ServiceExt;
use tower_http::services::ServeFile;
use crate::plugin::{Plugin, StaticDir};
pub const MANIFEST_FILENAME: &str = "staticfiles.json";
#[derive(Debug)]
pub enum StaticError {
Io {
path: String,
source: std::io::Error,
},
Backend(String),
}
impl std::fmt::Display for StaticError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
StaticError::Io { path, source } => {
write!(f, "static storage io error at `{path}`: {source}")
}
StaticError::Backend(msg) => write!(f, "static storage backend error: {msg}"),
}
}
}
impl std::error::Error for StaticError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
StaticError::Io { source, .. } => Some(source),
StaticError::Backend(_) => None,
}
}
}
pub trait StaticStorage: Send + Sync {
fn put(&self, rel_path: &str, bytes: &[u8]) -> Result<(), StaticError>;
fn exists(&self, rel_path: &str) -> Result<bool, StaticError>;
}
#[derive(Debug, Clone)]
pub struct LocalStorage {
pub root: PathBuf,
}
impl LocalStorage {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
fn full_path(&self, rel_path: &str) -> PathBuf {
let mut p = self.root.clone();
for seg in rel_path.split('/') {
if !seg.is_empty() {
p.push(seg);
}
}
p
}
}
impl StaticStorage for LocalStorage {
fn put(&self, rel_path: &str, bytes: &[u8]) -> Result<(), StaticError> {
let dest = self.full_path(rel_path);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent).map_err(|source| StaticError::Io {
path: rel_path.to_string(),
source,
})?;
}
std::fs::write(&dest, bytes).map_err(|source| StaticError::Io {
path: rel_path.to_string(),
source,
})
}
fn exists(&self, rel_path: &str) -> Result<bool, StaticError> {
Ok(self.full_path(rel_path).exists())
}
}
pub fn content_hash(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
let digest = hasher.finalize();
digest[..6].iter().map(|b| format!("{b:02x}")).collect()
}
pub fn hashed_name(rel_path: &str, hash: &str) -> String {
let (dir, file) = match rel_path.rfind('/') {
Some(i) => (&rel_path[..=i], &rel_path[i + 1..]),
None => ("", rel_path),
};
match file.rfind('.') {
Some(dot) => format!("{dir}{}.{hash}.{}", &file[..dot], &file[dot + 1..]),
None => format!("{dir}{file}.{hash}"),
}
}
#[derive(Debug, Clone)]
pub struct StaticContribution {
pub namespace: &'static str,
pub source_dir: PathBuf,
pub plugin: &'static str,
}
impl StaticContribution {
pub fn collect(plugins: &[Box<dyn Plugin>]) -> Vec<StaticContribution> {
let mut out = Vec::new();
for plugin in plugins {
for dir in plugin.static_dirs() {
let StaticDir {
namespace,
source_dir,
} = dir;
out.push(StaticContribution {
namespace,
source_dir,
plugin: plugin.name(),
});
}
}
out
}
pub fn collect_root_dirs(plugins: &[Box<dyn Plugin>]) -> Vec<PathBuf> {
plugins.iter().flat_map(|p| p.static_root_dirs()).collect()
}
}
#[derive(Debug, Clone, Default)]
pub struct PublishedStatic {
pub contributions: Vec<StaticContribution>,
pub root_dirs: Vec<PathBuf>,
}
static PUBLISHED: OnceLock<PublishedStatic> = OnceLock::new();
pub fn publish_static(p: PublishedStatic) {
let _ = PUBLISHED.set(p);
}
pub fn published_static() -> Option<&'static PublishedStatic> {
PUBLISHED.get()
}
static MANIFEST: OnceLock<Option<HashMap<String, String>>> = OnceLock::new();
pub fn load_manifest(static_root: impl AsRef<Path>) {
let path = static_root.as_ref().join(MANIFEST_FILENAME);
let loaded = std::fs::read(&path)
.ok()
.and_then(|bytes| serde_json::from_slice::<HashMap<String, String>>(&bytes).ok());
let _ = MANIFEST.set(loaded);
}
pub fn manifest_lookup(path: &str) -> Option<&'static str> {
let manifest = MANIFEST.get()?.as_ref()?;
let key = path.trim_start_matches('/');
manifest.get(key).map(String::as_str)
}
pub fn manifest_loaded() -> bool {
matches!(MANIFEST.get(), Some(Some(_)))
}
#[doc(hidden)]
pub fn set_manifest_for_tests(manifest: Option<HashMap<String, String>>) {
let _ = MANIFEST.set(manifest);
}
#[derive(Debug, Clone, Default)]
pub struct StaticRegistry {
by_namespace: HashMap<&'static str, PathBuf>,
}
#[derive(Debug, Clone)]
pub struct StaticNamespaceCollision {
pub namespace: &'static str,
pub first_plugin: &'static str,
pub second_plugin: &'static str,
}
impl StaticRegistry {
pub fn from_plugins(plugins: &[Box<dyn Plugin>]) -> Result<Self, StaticNamespaceCollision> {
let mut by_namespace: HashMap<&'static str, PathBuf> = HashMap::new();
let mut owner: HashMap<&'static str, &'static str> = HashMap::new();
for plugin in plugins {
for dir in plugin.static_dirs() {
let StaticDir {
namespace,
source_dir,
} = dir;
if let Some(&first_plugin) = owner.get(namespace) {
return Err(StaticNamespaceCollision {
namespace,
first_plugin,
second_plugin: plugin.name(),
});
}
owner.insert(namespace, plugin.name());
by_namespace.insert(namespace, source_dir);
}
}
Ok(Self { by_namespace })
}
pub fn source_dir(&self, namespace: &str) -> Option<&Path> {
self.by_namespace.get(namespace).map(PathBuf::as_path)
}
pub fn is_empty(&self) -> bool {
self.by_namespace.is_empty()
}
}
fn split_namespace(rel: &str) -> Option<(&str, &str)> {
let rel = rel.trim_start_matches('/');
let (ns, rest) = rel.split_once('/')?;
if ns.is_empty() || rest.is_empty() {
return None;
}
Some((ns, rest))
}
pub fn resolve_under_root(root: &Path, rel: &str) -> Option<PathBuf> {
let rel_path = Path::new(rel);
for component in rel_path.components() {
match component {
Component::Normal(_) | Component::CurDir => {}
Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
}
}
let candidate = root.join(rel_path);
let canonical_root = root.canonicalize().ok()?;
let canonical_candidate = candidate.canonicalize().ok()?;
if canonical_candidate.starts_with(&canonical_root) {
Some(canonical_candidate)
} else {
None
}
}
pub async fn serve_file(file_path: &Path, dev: bool, req: Request<Body>) -> Response<Body> {
let response = match ServeFile::new(file_path).oneshot(req).await {
Ok(resp) => resp,
Err(_unreachable) => {
return Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(Body::from("static file serving failed"))
.expect("static 500 response is always valid");
}
};
let mut response = response.map(Body::new);
if dev {
response.headers_mut().insert(
header::CACHE_CONTROL,
header::HeaderValue::from_static("no-cache"),
);
}
response
}
pub async fn static_handler(
State(state): State<StaticHandlerState>,
req: Request<Body>,
) -> Response<Body> {
let path = req.uri().path().to_string();
let rel = path.trim_start_matches('/');
if state.dev {
if let Some((namespace, rest)) = split_namespace(rel) {
if let Some(source_dir) = state.registry.source_dir(namespace) {
if let Some(resolved) = resolve_under_root(source_dir, rest) {
return serve_file(&resolved, true, req).await;
}
}
}
}
if let Some(resolved) = resolve_under_root(&state.static_root, rel) {
return serve_file(&resolved, state.dev, req).await;
}
for root in &state.root_dirs {
if let Some(resolved) = resolve_under_root(root, rel) {
return serve_file(&resolved, state.dev, req).await;
}
}
not_found()
}
#[derive(Debug, Clone)]
pub struct StaticHandlerState {
pub registry: StaticRegistry,
pub static_root: PathBuf,
pub root_dirs: Vec<PathBuf>,
pub dev: bool,
}
fn not_found() -> Response<Body> {
Response::builder()
.status(StatusCode::NOT_FOUND)
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(Body::from("not found"))
.expect("static 404 response is always valid")
}
#[derive(Debug, Clone)]
pub struct CollectedNamespace {
pub namespace: &'static str,
pub plugin: &'static str,
pub files: usize,
pub destination: PathBuf,
}
#[derive(Debug, Clone)]
pub struct MissingSourceDir {
pub namespace: &'static str,
pub plugin: &'static str,
pub source_dir: PathBuf,
}
#[derive(Debug, Clone, Default)]
pub struct CollectSummary {
pub collected: Vec<CollectedNamespace>,
pub missing: Vec<MissingSourceDir>,
pub static_root: PathBuf,
pub root_files: usize,
pub root_dirs: Vec<PathBuf>,
}
impl CollectSummary {
pub fn total_files(&self) -> usize {
self.collected.iter().map(|c| c.files).sum()
}
}
#[derive(Debug)]
pub enum CollectError {
Collision(StaticNamespaceCollision),
Io {
path: PathBuf,
source: std::io::Error,
},
Static(StaticError),
}
impl std::fmt::Display for CollectError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CollectError::Collision(c) => write!(
f,
"umbral collect_static: duplicate static namespace `{}` — claimed by both \
`{}` and `{}`; nothing was copied. Rename one plugin's namespace.",
c.namespace, c.first_plugin, c.second_plugin
),
CollectError::Io { path, source } => write!(
f,
"umbral collect_static: io error at `{}`: {source}",
path.display()
),
CollectError::Static(e) => write!(f, "umbral collect_static: {e}"),
}
}
}
impl std::error::Error for CollectError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
CollectError::Io { source, .. } => Some(source),
CollectError::Static(e) => Some(e),
CollectError::Collision(_) => None,
}
}
}
pub fn collect_static(
plugins: &[Box<dyn Plugin>],
static_root: impl Into<PathBuf>,
clear: bool,
) -> Result<CollectSummary, CollectError> {
StaticRegistry::from_plugins(plugins).map_err(CollectError::Collision)?;
let contributions = StaticContribution::collect(plugins);
let root_dirs = StaticContribution::collect_root_dirs(plugins);
collect_into(&contributions, &root_dirs, static_root, clear)
}
pub fn collect_into(
contributions: &[StaticContribution],
root_dirs: &[PathBuf],
static_root: impl Into<PathBuf>,
clear: bool,
) -> Result<CollectSummary, CollectError> {
let static_root = static_root.into();
let storage = LocalStorage::new(static_root.clone());
if !(clear && static_root.exists()) {
std::fs::create_dir_all(&static_root).map_err(|source| CollectError::Io {
path: static_root.clone(),
source,
})?;
}
collect_into_with(contributions, root_dirs, &static_root, &storage, clear, false)
}
pub fn collect_into_with(
contributions: &[StaticContribution],
root_dirs: &[PathBuf],
static_root: impl Into<PathBuf>,
storage: &dyn StaticStorage,
clear: bool,
hashed: bool,
) -> Result<CollectSummary, CollectError> {
let static_root = static_root.into();
if clear && static_root.exists() {
std::fs::remove_dir_all(&static_root).map_err(|source| CollectError::Io {
path: static_root.clone(),
source,
})?;
}
let mut summary = CollectSummary {
static_root: static_root.clone(),
..Default::default()
};
let mut manifest: BTreeMap<String, String> = BTreeMap::new();
for contribution in contributions {
let StaticContribution {
namespace,
source_dir,
plugin,
} = contribution;
if !source_dir.exists() {
summary.missing.push(MissingSourceDir {
namespace,
plugin,
source_dir: source_dir.clone(),
});
continue;
}
let files = copy_tree(
source_dir,
namespace,
storage,
hashed,
&mut manifest,
)?;
summary.collected.push(CollectedNamespace {
namespace,
plugin,
files,
destination: static_root.join(namespace),
});
}
for root in root_dirs {
if !root.exists() {
continue;
}
let files = copy_tree(root, "", storage, hashed, &mut manifest)?;
summary.root_files += files;
summary.root_dirs.push(root.clone());
}
if hashed {
let json = serde_json::to_vec_pretty(&manifest).map_err(|e| CollectError::Io {
path: static_root.join(MANIFEST_FILENAME),
source: std::io::Error::other(e),
})?;
storage
.put(MANIFEST_FILENAME, &json)
.map_err(CollectError::Static)?;
}
Ok(summary)
}
fn copy_tree(
src: &Path,
prefix: &str,
storage: &dyn StaticStorage,
hashed: bool,
manifest: &mut BTreeMap<String, String>,
) -> Result<usize, CollectError> {
let mut count = 0;
let entries = std::fs::read_dir(src).map_err(|source| CollectError::Io {
path: src.to_path_buf(),
source,
})?;
for entry in entries {
let entry = entry.map_err(|source| CollectError::Io {
path: src.to_path_buf(),
source,
})?;
let file_type = entry.file_type().map_err(|source| CollectError::Io {
path: entry.path(),
source,
})?;
let src_path = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
let child_prefix = if prefix.is_empty() {
name.clone()
} else {
format!("{prefix}/{name}")
};
if file_type.is_dir() {
count += copy_tree(&src_path, &child_prefix, storage, hashed, manifest)?;
} else {
let bytes = std::fs::read(&src_path).map_err(|source| CollectError::Io {
path: src_path.clone(),
source,
})?;
storage
.put(&child_prefix, &bytes)
.map_err(CollectError::Static)?;
count += 1;
if hashed {
let hash = content_hash(&bytes);
let hashed_path = hashed_name(&child_prefix, &hash);
storage
.put(&hashed_path, &bytes)
.map_err(CollectError::Static)?;
manifest.insert(child_prefix.clone(), hashed_path);
}
}
}
Ok(count)
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::Request;
fn req(path: &str) -> Request<Body> {
Request::builder()
.uri(path)
.body(Body::empty())
.expect("test request is valid")
}
struct FakeStaticPlugin {
name: &'static str,
dirs: Vec<StaticDir>,
}
impl Plugin for FakeStaticPlugin {
fn name(&self) -> &'static str {
self.name
}
fn static_dirs(&self) -> Vec<StaticDir> {
self.dirs.clone()
}
}
struct NoStaticPlugin;
impl Plugin for NoStaticPlugin {
fn name(&self) -> &'static str {
"no-static"
}
}
#[test]
fn static_dirs_default_is_empty() {
assert!(NoStaticPlugin.static_dirs().is_empty());
}
#[test]
fn registry_collects_static_dirs_from_plugins() {
let plugins: Vec<Box<dyn Plugin>> = vec![
Box::new(FakeStaticPlugin {
name: "admin",
dirs: vec![StaticDir::new("admin", "/src/admin/static")],
}),
Box::new(NoStaticPlugin),
Box::new(FakeStaticPlugin {
name: "playground",
dirs: vec![StaticDir::new("playground", "/src/playground/static")],
}),
];
let registry = StaticRegistry::from_plugins(&plugins).expect("no collision");
assert_eq!(
registry.source_dir("admin"),
Some(Path::new("/src/admin/static"))
);
assert_eq!(
registry.source_dir("playground"),
Some(Path::new("/src/playground/static"))
);
assert_eq!(registry.source_dir("nonexistent"), None);
}
#[test]
fn duplicate_namespace_fails_loudly_naming_both_plugins() {
let plugins: Vec<Box<dyn Plugin>> = vec![
Box::new(FakeStaticPlugin {
name: "first",
dirs: vec![StaticDir::new("shared", "/a")],
}),
Box::new(FakeStaticPlugin {
name: "second",
dirs: vec![StaticDir::new("shared", "/b")],
}),
];
let err = StaticRegistry::from_plugins(&plugins).expect_err("must collide");
assert_eq!(err.namespace, "shared");
assert_eq!(err.first_plugin, "first");
assert_eq!(err.second_plugin, "second");
}
#[test]
fn split_namespace_splits_first_segment() {
assert_eq!(
split_namespace("admin/admin.css"),
Some(("admin", "admin.css"))
);
assert_eq!(
split_namespace("/admin/css/site.css"),
Some(("admin", "css/site.css"))
);
assert_eq!(split_namespace("admin"), None);
assert_eq!(split_namespace("admin/"), None);
assert_eq!(split_namespace(""), None);
}
#[test]
fn resolve_under_root_blocks_parent_traversal() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("ok.css"), b"body{}").expect("write file");
assert!(resolve_under_root(dir.path(), "ok.css").is_some());
assert!(resolve_under_root(dir.path(), "../../etc/passwd").is_none());
assert!(resolve_under_root(dir.path(), "../secret").is_none());
assert!(resolve_under_root(dir.path(), "a/../../b").is_none());
assert!(resolve_under_root(dir.path(), "/etc/passwd").is_none());
}
#[cfg(unix)]
#[test]
fn resolve_under_root_blocks_symlink_escape() {
let root = tempfile::tempdir().expect("root tempdir");
let outside = tempfile::tempdir().expect("outside tempdir");
std::fs::write(outside.path().join("secret"), b"top secret").expect("write secret");
let link = root.path().join("escape");
std::os::unix::fs::symlink(outside.path().join("secret"), &link).expect("symlink");
assert!(resolve_under_root(root.path(), "escape").is_none());
}
#[tokio::test]
async fn dev_serves_live_source_then_falls_back_to_static_root() {
let source = tempfile::tempdir().expect("source dir");
let static_root = tempfile::tempdir().expect("static root");
std::fs::write(source.path().join("admin.css"), b"SOURCE").expect("write source");
std::fs::create_dir_all(static_root.path().join("admin")).expect("mkdir ns");
std::fs::write(
static_root.path().join("admin").join("legacy.css"),
b"COLLECTED",
)
.expect("write collected");
let mut by_namespace = HashMap::new();
by_namespace.insert("admin", source.path().to_path_buf());
let registry = StaticRegistry { by_namespace };
let state = StaticHandlerState {
registry,
static_root: static_root.path().to_path_buf(),
root_dirs: Vec::new(),
dev: true,
};
let resp = static_handler(State(state.clone()), req("/admin/admin.css")).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.expect("read body");
assert_eq!(&body[..], b"SOURCE");
let resp = static_handler(State(state.clone()), req("/admin/legacy.css")).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.expect("read body");
assert_eq!(&body[..], b"COLLECTED");
std::fs::create_dir_all(static_root.path().join("other")).expect("mkdir other");
std::fs::write(static_root.path().join("other").join("x.js"), b"OTHER").expect("write");
let resp = static_handler(State(state), req("/other/x.js")).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.expect("read body");
assert_eq!(&body[..], b"OTHER");
}
#[tokio::test]
async fn prod_serves_only_from_static_root() {
let source = tempfile::tempdir().expect("source dir");
let static_root = tempfile::tempdir().expect("static root");
std::fs::write(source.path().join("only-source.css"), b"SOURCE").expect("write source");
std::fs::create_dir_all(static_root.path().join("admin")).expect("mkdir ns");
std::fs::write(
static_root.path().join("admin").join("admin.css"),
b"COLLECTED",
)
.expect("write collected");
let mut by_namespace = HashMap::new();
by_namespace.insert("admin", source.path().to_path_buf());
let registry = StaticRegistry { by_namespace };
let state = StaticHandlerState {
registry,
static_root: static_root.path().to_path_buf(),
root_dirs: Vec::new(),
dev: false,
};
let resp = static_handler(State(state.clone()), req("/admin/admin.css")).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.expect("read body");
assert_eq!(&body[..], b"COLLECTED");
let resp = static_handler(State(state), req("/admin/only-source.css")).await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
fn write_at(dir: &Path, relpath: &str, bytes: &[u8]) {
let full = dir.join(relpath);
if let Some(parent) = full.parent() {
std::fs::create_dir_all(parent).expect("mkdir parents");
}
std::fs::write(full, bytes).expect("write file");
}
fn read_at(dir: &Path, relpath: &str) -> Vec<u8> {
std::fs::read(dir.join(relpath)).expect("read collected file")
}
#[test]
fn collect_copies_every_file_preserving_the_tree() {
let admin_src = tempfile::tempdir().expect("admin src");
let pg_src = tempfile::tempdir().expect("playground src");
let static_root = tempfile::tempdir().expect("static root");
write_at(admin_src.path(), "admin.css", b"ADMIN_CSS");
write_at(admin_src.path(), "js/admin.js", b"ADMIN_JS");
write_at(pg_src.path(), "dist/assets/index.js", b"PG_INDEX");
let plugins: Vec<Box<dyn Plugin>> = vec![
Box::new(FakeStaticPlugin {
name: "admin-plugin",
dirs: vec![StaticDir::new("admin", admin_src.path())],
}),
Box::new(FakeStaticPlugin {
name: "pg-plugin",
dirs: vec![StaticDir::new("playground", pg_src.path())],
}),
];
let summary =
collect_static(&plugins, static_root.path(), false).expect("collect succeeds");
assert_eq!(read_at(static_root.path(), "admin/admin.css"), b"ADMIN_CSS");
assert_eq!(
read_at(static_root.path(), "admin/js/admin.js"),
b"ADMIN_JS"
);
assert_eq!(
read_at(static_root.path(), "playground/dist/assets/index.js"),
b"PG_INDEX"
);
assert_eq!(summary.total_files(), 3);
assert!(summary.missing.is_empty());
let admin = summary
.collected
.iter()
.find(|c| c.namespace == "admin")
.expect("admin collected");
assert_eq!(admin.files, 2);
assert_eq!(admin.plugin, "admin-plugin");
assert_eq!(admin.destination, static_root.path().join("admin"));
let pg = summary
.collected
.iter()
.find(|c| c.namespace == "playground")
.expect("playground collected");
assert_eq!(pg.files, 1);
}
#[test]
fn collect_is_idempotent_and_propagates_changed_bytes() {
let src = tempfile::tempdir().expect("src");
let static_root = tempfile::tempdir().expect("static root");
write_at(src.path(), "app.js", b"V1");
let plugins: Vec<Box<dyn Plugin>> = vec![Box::new(FakeStaticPlugin {
name: "p",
dirs: vec![StaticDir::new("app", src.path())],
})];
collect_static(&plugins, static_root.path(), false).expect("first collect");
assert_eq!(read_at(static_root.path(), "app/app.js"), b"V1");
write_at(src.path(), "app.js", b"V2_CHANGED");
let summary = collect_static(&plugins, static_root.path(), false).expect("second collect");
assert_eq!(read_at(static_root.path(), "app/app.js"), b"V2_CHANGED");
assert_eq!(summary.total_files(), 1);
}
#[test]
fn duplicate_namespace_aborts_and_copies_nothing() {
let a = tempfile::tempdir().expect("a");
let b = tempfile::tempdir().expect("b");
let static_root = tempfile::tempdir().expect("static root");
write_at(a.path(), "a.css", b"A");
write_at(b.path(), "b.css", b"B");
let plugins: Vec<Box<dyn Plugin>> = vec![
Box::new(FakeStaticPlugin {
name: "first",
dirs: vec![StaticDir::new("shared", a.path())],
}),
Box::new(FakeStaticPlugin {
name: "second",
dirs: vec![StaticDir::new("shared", b.path())],
}),
];
let err =
collect_static(&plugins, static_root.path(), false).expect_err("collision aborts");
match err {
CollectError::Collision(c) => {
assert_eq!(c.namespace, "shared");
assert_eq!(c.first_plugin, "first");
assert_eq!(c.second_plugin, "second");
}
other => panic!("expected Collision, got {other:?}"),
}
assert!(!static_root.path().join("shared").exists());
let entries: Vec<_> = std::fs::read_dir(static_root.path())
.expect("read static_root")
.collect();
assert!(
entries.is_empty(),
"static_root must be untouched on collision"
);
}
#[test]
fn missing_source_dir_warns_but_others_still_collect() {
let present = tempfile::tempdir().expect("present");
let static_root = tempfile::tempdir().expect("static root");
write_at(present.path(), "ok.css", b"OK");
let missing_src = present.path().join("does-not-exist");
assert!(!missing_src.exists());
let plugins: Vec<Box<dyn Plugin>> = vec![
Box::new(FakeStaticPlugin {
name: "broken",
dirs: vec![StaticDir::new("ghost", missing_src.clone())],
}),
Box::new(FakeStaticPlugin {
name: "good",
dirs: vec![StaticDir::new("real", present.path())],
}),
];
let summary =
collect_static(&plugins, static_root.path(), false).expect("missing src is not fatal");
assert_eq!(read_at(static_root.path(), "real/ok.css"), b"OK");
assert_eq!(summary.missing.len(), 1);
assert_eq!(summary.missing[0].namespace, "ghost");
assert_eq!(summary.missing[0].plugin, "broken");
assert_eq!(summary.missing[0].source_dir, missing_src);
assert!(!static_root.path().join("ghost").exists());
}
#[test]
fn clear_removes_stale_files_before_collect() {
let src = tempfile::tempdir().expect("src");
let static_root = tempfile::tempdir().expect("static root");
write_at(src.path(), "current.css", b"CURRENT");
write_at(static_root.path(), "stale/old.css", b"STALE");
let plugins: Vec<Box<dyn Plugin>> = vec![Box::new(FakeStaticPlugin {
name: "p",
dirs: vec![StaticDir::new("app", src.path())],
})];
collect_static(&plugins, static_root.path(), false).expect("no-clear collect");
assert!(static_root.path().join("stale/old.css").exists());
assert_eq!(read_at(static_root.path(), "app/current.css"), b"CURRENT");
collect_static(&plugins, static_root.path(), true).expect("clear collect");
assert!(!static_root.path().join("stale").exists());
assert_eq!(read_at(static_root.path(), "app/current.css"), b"CURRENT");
}
#[test]
fn collect_creates_static_root_when_absent() {
let src = tempfile::tempdir().expect("src");
let parent = tempfile::tempdir().expect("parent");
let static_root = parent.path().join("staticfiles");
assert!(!static_root.exists());
write_at(src.path(), "x.css", b"X");
let plugins: Vec<Box<dyn Plugin>> = vec![Box::new(FakeStaticPlugin {
name: "p",
dirs: vec![StaticDir::new("ns", src.path())],
})];
collect_static(&plugins, &static_root, false).expect("collect creates root");
assert_eq!(read_at(&static_root, "ns/x.css"), b"X");
}
#[test]
fn collect_into_copies_root_dirs_into_static_root_root() {
let ns_src = tempfile::tempdir().expect("ns src");
let root_a = tempfile::tempdir().expect("root a");
let root_b = tempfile::tempdir().expect("root b");
let static_root = tempfile::tempdir().expect("static root");
write_at(ns_src.path(), "admin.css", b"ADMIN");
write_at(root_a.path(), "site.css", b"SITE_CSS");
write_at(root_a.path(), "img/logo.png", b"LOGO");
write_at(root_b.path(), "app.js", b"APP_JS");
let contributions = vec![StaticContribution {
namespace: "admin",
source_dir: ns_src.path().to_path_buf(),
plugin: "admin-plugin",
}];
let root_dirs = vec![root_a.path().to_path_buf(), root_b.path().to_path_buf()];
let summary = collect_into(&contributions, &root_dirs, static_root.path(), false)
.expect("collect_into succeeds");
assert_eq!(read_at(static_root.path(), "admin/admin.css"), b"ADMIN");
assert_eq!(read_at(static_root.path(), "site.css"), b"SITE_CSS");
assert_eq!(read_at(static_root.path(), "img/logo.png"), b"LOGO");
assert_eq!(read_at(static_root.path(), "app.js"), b"APP_JS");
assert_eq!(summary.total_files(), 1, "1 namespaced file");
assert_eq!(summary.root_files, 3, "3 root-dir files across two dirs");
assert_eq!(summary.root_dirs.len(), 2);
}
#[test]
fn collect_into_skips_absent_root_dir_silently() {
let static_root = tempfile::tempdir().expect("static root");
let present = tempfile::tempdir().expect("present root");
write_at(present.path(), "x.css", b"X");
let absent = present.path().join("does-not-exist");
assert!(!absent.exists());
let root_dirs = vec![present.path().to_path_buf(), absent];
let summary = collect_into(&[], &root_dirs, static_root.path(), false)
.expect("collect_into succeeds");
assert_eq!(read_at(static_root.path(), "x.css"), b"X");
assert_eq!(summary.root_files, 1);
assert_eq!(summary.root_dirs.len(), 1);
assert!(summary.missing.is_empty());
}
#[test]
fn hashed_name_inserts_hash_before_extension() {
assert_eq!(
hashed_name("css/app.css", "abc123"),
"css/app.abc123.css"
);
assert_eq!(hashed_name("js/bundle", "deadbe"), "js/bundle.deadbe");
assert_eq!(
hashed_name("a/b.min.css", "0f0f0f"),
"a/b.min.0f0f0f.css"
);
assert_eq!(hashed_name("favicon.ico", "112233"), "favicon.112233.ico");
}
#[test]
fn content_hash_is_stable_and_12_hex() {
let h = content_hash(b"body{}");
assert_eq!(h.len(), 12);
assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
assert_eq!(content_hash(b"body{}"), h);
assert_ne!(content_hash(b"body{ }"), h);
}
#[test]
fn local_storage_put_writes_through_root() {
let root = tempfile::tempdir().expect("root");
let storage = LocalStorage::new(root.path());
storage
.put("css/app.css", b"BODY")
.expect("put writes the file");
assert_eq!(read_at(root.path(), "css/app.css"), b"BODY");
assert!(storage.exists("css/app.css").expect("exists"));
assert!(!storage.exists("css/missing.css").expect("exists"));
}
#[test]
fn collect_hashed_writes_copies_and_manifest() {
let admin_src = tempfile::tempdir().expect("admin src");
let root_src = tempfile::tempdir().expect("root src");
let static_root = tempfile::tempdir().expect("static root");
write_at(admin_src.path(), "admin.css", b"ADMIN_CSS");
write_at(root_src.path(), "css/app.css", b"APP_CSS");
let contributions = vec![StaticContribution {
namespace: "admin",
source_dir: admin_src.path().to_path_buf(),
plugin: "admin-plugin",
}];
let root_dirs = vec![root_src.path().to_path_buf()];
let storage = LocalStorage::new(static_root.path());
let summary = collect_into_with(
&contributions,
&root_dirs,
static_root.path(),
&storage,
false,
true,
)
.expect("hashed collect succeeds");
assert_eq!(read_at(static_root.path(), "admin/admin.css"), b"ADMIN_CSS");
assert_eq!(read_at(static_root.path(), "css/app.css"), b"APP_CSS");
assert_eq!(summary.total_files(), 1);
assert_eq!(summary.root_files, 1);
let manifest_bytes = read_at(static_root.path(), MANIFEST_FILENAME);
let manifest: HashMap<String, String> =
serde_json::from_slice(&manifest_bytes).expect("manifest parses");
let admin_hashed = manifest
.get("admin/admin.css")
.expect("admin entry present");
let app_hashed = manifest.get("css/app.css").expect("app entry present");
let admin_hash = content_hash(b"ADMIN_CSS");
assert_eq!(admin_hashed, &format!("admin/admin.{admin_hash}.css"));
let app_hash = content_hash(b"APP_CSS");
assert_eq!(app_hashed, &format!("css/app.{app_hash}.css"));
assert_eq!(read_at(static_root.path(), admin_hashed), b"ADMIN_CSS");
assert_eq!(read_at(static_root.path(), app_hashed), b"APP_CSS");
}
#[test]
fn collect_without_hashed_writes_no_manifest() {
let src = tempfile::tempdir().expect("src");
let static_root = tempfile::tempdir().expect("static root");
write_at(src.path(), "x.css", b"X");
let contributions = vec![StaticContribution {
namespace: "ns",
source_dir: src.path().to_path_buf(),
plugin: "p",
}];
let storage = LocalStorage::new(static_root.path());
collect_into_with(&contributions, &[], static_root.path(), &storage, false, false)
.expect("plain collect");
assert_eq!(read_at(static_root.path(), "ns/x.css"), b"X");
assert!(!static_root.path().join(MANIFEST_FILENAME).exists());
}
#[tokio::test]
async fn handler_blocks_path_traversal() {
let static_root = tempfile::tempdir().expect("static root");
std::fs::create_dir_all(static_root.path().join("admin")).expect("mkdir ns");
std::fs::write(static_root.path().join("admin").join("ok.css"), b"OK").expect("write");
let state = StaticHandlerState {
registry: StaticRegistry::default(),
static_root: static_root.path().to_path_buf(),
root_dirs: Vec::new(),
dev: false,
};
let resp = static_handler(State(state), req("/admin/../../etc/passwd")).await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
}