use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::SystemTime;
use bytes::Bytes;
use lightningcss::bundler::{Bundler, ResolveResult, SourceProvider};
use crate::resolve::canonicalize_within_roots;
use crate::error::StaticError;
pub(crate) const MAX_IMPORT_DEPTH: usize = 32;
pub(crate) const MAX_IMPORTED_FILES: usize = 512;
#[derive(Debug)]
pub(crate) enum BundleError {
Traversal(PathBuf),
Cycle(PathBuf),
DepthExceeded { path: PathBuf, depth: usize },
TooManyFiles { limit: usize },
MissingImport { path: PathBuf, io_error: String },
Css(String),
JoinError(String),
}
impl std::fmt::Display for BundleError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
BundleError::Traversal(p) => write!(f, "import traversal attempt: {}", p.display()),
BundleError::Cycle(p) => write!(f, "import cycle detected: {}", p.display()),
BundleError::DepthExceeded { path, depth } => {
write!(f, "import depth exceeded at {}: depth {}", path.display(), depth)
}
BundleError::TooManyFiles { limit } => write!(f, "too many imported files (limit {})", limit),
BundleError::MissingImport { path, io_error } => {
write!(f, "missing import {}: {}", path.display(), io_error)
}
BundleError::Css(msg) => write!(f, "CSS bundling failed: {msg}"),
BundleError::JoinError(msg) => write!(f, "bundling task failed: {msg}"),
}
}
}
impl std::error::Error for BundleError {}
struct RootBoundedProvider {
allowed_roots: Vec<PathBuf>,
depth_map: Mutex<HashMap<PathBuf, usize>>,
file_count: Mutex<usize>,
read_cache: Mutex<HashMap<PathBuf, String>>,
}
impl RootBoundedProvider {
fn new(allowed_roots: Vec<PathBuf>) -> Self {
Self {
allowed_roots,
depth_map: Mutex::new(HashMap::new()),
file_count: Mutex::new(0),
read_cache: Mutex::new(HashMap::new()),
}
}
}
impl SourceProvider for RootBoundedProvider {
type Error = BundleError;
fn read<'a>(&'a self, file: &Path) -> Result<&'a str, Self::Error> {
let mut cache = self.read_cache.lock().unwrap();
if let Some(content) = cache.get(file) {
let ptr = content.as_str() as *const str;
return Ok(unsafe { &*ptr });
}
let content = std::fs::read_to_string(file).map_err(|e| BundleError::MissingImport {
path: file.to_path_buf(),
io_error: e.to_string(),
})?;
let ptr = content.as_str() as *const str;
cache.insert(file.to_path_buf(), content);
Ok(unsafe { &*ptr })
}
fn resolve(
&self,
specifier: &str,
originating_file: &Path,
) -> Result<ResolveResult, Self::Error> {
if specifier.starts_with("http://") || specifier.starts_with("https://") || specifier.starts_with("//") {
return Ok(ResolveResult::External(specifier.to_string()));
}
let originating_dir = originating_file.parent().unwrap_or(Path::new("."));
let joined = originating_dir.join(specifier);
let canon = canonicalize_within_roots(&self.allowed_roots, &joined).map_err(|e| {
match e {
StaticError::Traversal(s) => BundleError::Traversal(PathBuf::from(s)),
StaticError::NotFound(s) => BundleError::MissingImport {
path: PathBuf::from(s),
io_error: "file not found".to_string(),
},
StaticError::Io(e) => BundleError::MissingImport {
path: joined,
io_error: e.to_string(),
},
}
})?;
debug_assert!(
self.allowed_roots.iter().any(|r| canon.starts_with(r)),
"canonicalize_within_roots should guarantee this"
);
let mut depth_map = self.depth_map.lock().unwrap();
let originating_depth = depth_map
.get(originating_file)
.copied()
.unwrap_or(0);
if originating_depth >= MAX_IMPORT_DEPTH {
return Err(BundleError::DepthExceeded {
path: canon.clone(),
depth: originating_depth + 1,
});
}
if let Some(&existing_depth) = depth_map.get(&canon) {
if existing_depth <= originating_depth {
return Err(BundleError::Cycle(canon));
}
} else {
let mut count = self.file_count.lock().unwrap();
if *count >= MAX_IMPORTED_FILES {
return Err(BundleError::TooManyFiles {
limit: MAX_IMPORTED_FILES,
});
}
*count += 1;
}
depth_map.insert(canon.clone(), originating_depth + 1);
Ok(ResolveResult::File(canon))
}
}
pub(crate) async fn bundle_and_minify_css(
allowed_roots: &[PathBuf],
entry: &Path,
) -> Result<(Bytes, Vec<(PathBuf, SystemTime)>), BundleError> {
let allowed_roots = allowed_roots.to_vec();
let entry = entry.to_path_buf();
tokio::task::spawn_blocking(move || {
let provider = RootBoundedProvider::new(allowed_roots.clone());
let mut bundler = Bundler::new(&provider, None, Default::default());
let mut bundled = bundler.bundle(&entry).map_err(|e| {
BundleError::Css(format!("{:?}", e))
})?;
bundled.minify(Default::default()).map_err(|e| {
BundleError::Css(format!("{:?}", e))
})?;
let minified_bytes = bundled
.to_css(Default::default())
.map_err(|e| BundleError::Css(format!("{:?}", e)))?
.code;
let dependencies = collect_dependencies(&entry, &allowed_roots)?;
Ok((Bytes::from(minified_bytes), dependencies))
})
.await
.map_err(|e| BundleError::JoinError(e.to_string()))?
}
fn collect_dependencies(entry: &Path, allowed_roots: &[PathBuf]) -> Result<Vec<(PathBuf, SystemTime)>, BundleError> {
let mut deps = Vec::new();
let mut to_walk = vec![entry.to_path_buf()];
let mut visited = HashSet::new();
while let Some(file) = to_walk.pop() {
if visited.contains(&file) {
continue;
}
visited.insert(file.clone());
let mtime = std::fs::metadata(&file)
.and_then(|m| m.modified())
.map_err(|e| BundleError::MissingImport {
path: file.clone(),
io_error: e.to_string(),
})?;
deps.push((file.clone(), mtime));
let content = std::fs::read_to_string(&file).map_err(|e| BundleError::MissingImport {
path: file.clone(),
io_error: e.to_string(),
})?;
for line in content.lines() {
if let Some(import_spec) = extract_import_spec(line) {
let file_dir = file.parent().unwrap_or(Path::new("."));
let joined = file_dir.join(&import_spec);
let canon = canonicalize_within_roots(allowed_roots, &joined).map_err(|e| {
match e {
StaticError::Traversal(s) => BundleError::Traversal(PathBuf::from(s)),
StaticError::NotFound(s) => BundleError::MissingImport {
path: PathBuf::from(s),
io_error: "not found".to_string(),
},
StaticError::Io(e) => BundleError::MissingImport {
path: joined,
io_error: e.to_string(),
},
}
})?;
if !visited.contains(&canon) {
to_walk.push(canon);
}
}
}
}
Ok(deps)
}
fn extract_import_spec(line: &str) -> Option<String> {
let trimmed = line.trim();
if !trimmed.starts_with("@import") {
return None;
}
let rest = trimmed.strip_prefix("@import")?.trim_start();
if let Some(quoted) = rest.strip_prefix('"') {
if let Some(end) = quoted.find('"') {
return Some(quoted[..end].to_string());
}
}
if let Some(quoted) = rest.strip_prefix('\'') {
if let Some(end) = quoted.find('\'') {
return Some(quoted[..end].to_string());
}
}
if let Some(url_str) = rest.strip_prefix("url(") {
for quote in &['"', '\''] {
if let Some(quoted) = url_str.strip_prefix(*quote) {
if let Some(end) = quoted.find(*quote) {
let spec = quoted[..end].to_string();
if !spec.starts_with("http") && !spec.starts_with("//") {
return Some(spec);
}
}
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use std::fs;
#[tokio::test]
async fn bundles_basic_file() {
let tmpdir = TempDir::new().unwrap();
let root = tmpdir.path();
let root_canon = root.canonicalize().unwrap();
fs::write(root.join("style.css"), "body { margin: 0; }").unwrap();
let result = bundle_and_minify_css(&[root_canon.clone()], &root.join("style.css")).await;
assert!(result.is_ok());
let (bytes, deps) = result.unwrap();
assert!(!bytes.is_empty());
assert!(deps.len() >= 1);
}
#[tokio::test]
async fn rejects_import_escaping_root() {
let tmpdir = TempDir::new().unwrap();
let root = tmpdir.path();
let root_canon = root.canonicalize().unwrap();
fs::write(root.join("evil.css"), r#"@import "../../etc/passwd";"#).unwrap();
let result = bundle_and_minify_css(&[root_canon.clone()], &root.join("evil.css")).await;
let is_guarded = matches!(result, Err(BundleError::Traversal(_)) | Err(BundleError::MissingImport { .. }) | Err(BundleError::Css(_)));
assert!(is_guarded, "expected traversal/missing import/css error, got {result:?}");
}
#[tokio::test]
async fn bundles_import_from_external_bundle_root_succeeds() {
let parent_tmpdir = TempDir::new().unwrap();
let parent = parent_tmpdir.path();
let served_root = parent.join("served").canonicalize().unwrap_or_else(|_| {
fs::create_dir_all(parent.join("served")).unwrap();
parent.join("served").canonicalize().unwrap()
});
let bundle_root = parent.join("bundle").canonicalize().unwrap_or_else(|_| {
fs::create_dir_all(parent.join("bundle")).unwrap();
parent.join("bundle").canonicalize().unwrap()
});
fs::write(served_root.join("entry.css"), r#"@import "../bundle/shared.css";"#).unwrap();
fs::write(bundle_root.join("shared.css"), ".shared { color: red; }").unwrap();
let result = bundle_and_minify_css(&[served_root.clone(), bundle_root.clone()], &served_root.join("entry.css")).await;
assert!(result.is_ok(), "expected bundling to succeed, got {result:?}");
let (bytes, deps) = result.unwrap();
assert!(!bytes.is_empty());
let bytes_str = String::from_utf8_lossy(&bytes);
assert!(bytes_str.contains("shared"), "expected shared CSS rule in output");
assert!(deps.iter().any(|(p, _)| p == &bundle_root.join("shared.css")), "expected shared.css in dependencies");
}
#[tokio::test]
async fn rejects_import_escaping_union_of_all_allowed_roots() {
let served_tmpdir = TempDir::new().unwrap();
let bundle_tmpdir = TempDir::new().unwrap();
let served_root = served_tmpdir.path().canonicalize().unwrap();
let bundle_root = bundle_tmpdir.path().canonicalize().unwrap();
fs::write(served_root.join("entry.css"), r#"@import "../../etc/passwd";"#).unwrap();
let result = bundle_and_minify_css(&[served_root.clone(), bundle_root.clone()], &served_root.join("entry.css")).await;
let is_guarded = matches!(result, Err(BundleError::Traversal(_)) | Err(BundleError::MissingImport { .. }) | Err(BundleError::Css(_)));
assert!(is_guarded, "expected traversal/missing import/css error, got {result:?}");
}
}