use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::module_graph::ModuleImport;
use crate::Result;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum Rank {
Tera = 0,
Static = 1,
Transform = 2,
}
#[derive(Clone, Debug)]
pub(crate) struct Claim {
pub out_rel: PathBuf,
pub tiebreak: u8,
}
pub(crate) trait Preflight {
fn name(&self) -> &'static str;
fn rank(&self) -> Rank;
fn claim(&self, rel: &Path) -> Option<Claim>;
}
pub(crate) trait Step: Preflight {
fn emit(&self, cx: &EmitCx<'_>, src: &Path, rel: &Path, dest: &Path) -> Result<Emitted>;
}
pub(crate) struct EmitCx<'a> {
pub importmap: &'a crate::importmap::Importmap,
}
#[derive(Debug, Default)]
pub(crate) struct Emitted {
pub imports: Option<Vec<ModuleImport>>,
}
#[derive(Default)]
pub(crate) struct StepConfig {
#[cfg(feature = "typescript")]
pub transpile: crate::typescript::TranspileOptions,
#[cfg(feature = "scss")]
pub scss_load_paths: Vec<PathBuf>,
}
pub(crate) fn enabled_steps(
processors: &crate::build::Processors,
config: StepConfig,
) -> Vec<Box<dyn Step>> {
let mut steps: Vec<Box<dyn Step>> = Vec::new();
#[cfg(feature = "tera")]
if processors.tera {
steps.push(Box::new(TeraStep));
}
steps.push(Box::new(crate::static_files::StaticStep::new(
processors.reject.clone(),
)));
#[cfg(feature = "typescript")]
if processors.typescript {
steps.push(Box::new(crate::typescript::TypeScriptStep::new(
config.transpile,
)));
}
#[cfg(feature = "scss")]
if processors.scss {
steps.push(Box::new(crate::scss::ScssStep::new(config.scss_load_paths)));
}
steps
}
#[cfg(feature = "tera")]
pub(crate) struct TeraStep;
#[cfg(feature = "tera")]
impl Preflight for TeraStep {
fn name(&self) -> &'static str {
"Tera template"
}
fn rank(&self) -> Rank {
Rank::Tera
}
fn claim(&self, rel: &Path) -> Option<Claim> {
let name = rel.file_name()?.to_str()?;
let ext = rel.extension()?.to_str()?;
if !ext.eq_ignore_ascii_case("tera") || name.starts_with('_') {
return None;
}
Some(Claim {
out_rel: rel.with_extension(""),
tiebreak: 0,
})
}
}
#[cfg(feature = "tera")]
impl Step for TeraStep {
fn emit(&self, cx: &EmitCx<'_>, src: &Path, rel: &Path, dest: &Path) -> Result<Emitted> {
let ctx = crate::templates::importmap_context(cx.importmap);
let rendered = crate::templates::render_file(src, &ctx)?;
let ext = dest.extension().and_then(|x| x.to_str()).unwrap_or("");
let imports = crate::module_graph::imports_for_emitted_js(&rendered, ext, rel)?;
std::fs::write(dest, rendered)?;
Ok(Emitted { imports })
}
}
#[derive(Debug)]
pub(crate) struct ClaimRecord {
pub root: usize,
pub rel: PathBuf,
pub step: usize,
pub rank: Rank,
pub tiebreak: u8,
pub out_rel: PathBuf,
}
impl ClaimRecord {
fn precedence(&self) -> (usize, Rank, u8, &Path) {
(self.root, self.rank, self.tiebreak, &self.rel)
}
}
pub(crate) struct PreflightReport {
claims: Vec<ClaimRecord>,
walked: Vec<PathBuf>,
escaping_sources: Vec<EscapingSource>,
walk_errors: Vec<String>,
skipped_symlinks: Vec<SkippedSymlink>,
rejected: Vec<RejectedClaim>,
npm_assets: Vec<NpmAsset>,
}
pub(crate) struct WalkPolicy<'a> {
pub reject: &'a crate::reject::Reject,
pub symlinks: crate::SymlinkMode,
}
#[derive(Debug)]
pub(crate) struct SkippedSymlink {
pub root: usize,
pub rel: PathBuf,
}
#[derive(Debug)]
pub(crate) struct NpmAsset {
pub root: usize,
pub rel: PathBuf,
pub target: String,
}
#[derive(Debug)]
pub(crate) struct EscapingSource {
pub root: usize,
pub rel: PathBuf,
pub target: PathBuf,
}
#[derive(Debug)]
pub(crate) struct RejectedClaim {
pub root: usize,
pub rel: PathBuf,
pub out_rel: PathBuf,
}
impl RejectedClaim {
pub(crate) fn describe(&self, roots: &[PathBuf]) -> String {
let source = roots[self.root].join(&self.rel);
if self.rel == self.out_rel {
format!("{} dropped by the reject list", source.display())
} else {
format!(
"{} would emit {} - dropped by the reject list",
source.display(),
self.out_rel.display()
)
}
}
}
pub(crate) fn preflight(
roots: &[PathBuf],
steps: &[&dyn Preflight],
policy: WalkPolicy<'_>,
) -> PreflightReport {
let mut report = PreflightReport {
claims: Vec::new(),
walked: Vec::new(),
escaping_sources: Vec::new(),
walk_errors: Vec::new(),
skipped_symlinks: Vec::new(),
rejected: Vec::new(),
npm_assets: Vec::new(),
};
let follow = matches!(
policy.symlinks,
crate::SymlinkMode::Follow | crate::SymlinkMode::FollowUnsafe
);
for (root_index, root) in roots.iter().enumerate() {
let canonical_root = match policy.symlinks {
crate::SymlinkMode::Follow => match std::fs::canonicalize(root) {
Ok(canonical) => Some(canonical),
Err(e) => {
report.walk_errors.push(format!("{}: {e}", root.display()));
continue;
}
},
_ => None,
};
for entry in walkdir::WalkDir::new(root).follow_links(follow) {
let entry = match entry {
Ok(entry) => entry,
Err(e) => {
if let Some(path) = e.path() {
if let Some(target) = crate::npm_link::link_target(path) {
if let Ok(rel) = path.strip_prefix(root) {
report.npm_assets.push(NpmAsset {
root: root_index,
rel: rel.to_path_buf(),
target,
});
}
continue;
}
}
report.walk_errors.push(e.to_string());
continue;
}
};
let path = entry.path();
report.walked.push(path.to_path_buf());
if !follow && entry.path_is_symlink() {
if let Some(target) = crate::npm_link::link_target(path) {
if let Ok(rel) = path.strip_prefix(root) {
report.npm_assets.push(NpmAsset {
root: root_index,
rel: rel.to_path_buf(),
target,
});
}
continue;
}
if let Ok(rel) = path.strip_prefix(root) {
report.skipped_symlinks.push(SkippedSymlink {
root: root_index,
rel: rel.to_path_buf(),
});
}
continue;
}
if !path.is_file() {
continue;
}
let Ok(rel) = path.strip_prefix(root) else {
continue;
};
if let Some(canonical_root) = &canonical_root {
let canonical = match std::fs::canonicalize(path) {
Ok(canonical) => canonical,
Err(e) => {
report.walk_errors.push(format!("{}: {e}", path.display()));
continue;
}
};
if !canonical.starts_with(canonical_root) {
report.escaping_sources.push(EscapingSource {
root: root_index,
rel: rel.to_path_buf(),
target: canonical,
});
continue;
}
}
for (step_index, step) in steps.iter().enumerate() {
if let Some(claim) = step.claim(rel) {
if policy.reject.rejects_path(&claim.out_rel) {
crate::reject::warn_rejected(&claim.out_rel.display().to_string());
report.rejected.push(RejectedClaim {
root: root_index,
rel: rel.to_path_buf(),
out_rel: claim.out_rel,
});
continue;
}
report.claims.push(ClaimRecord {
root: root_index,
rel: rel.to_path_buf(),
step: step_index,
rank: step.rank(),
tiebreak: claim.tiebreak,
out_rel: claim.out_rel,
});
}
}
}
}
report
}
pub(crate) struct Conflict<'a> {
pub out_rel: &'a Path,
pub claimants: Vec<&'a ClaimRecord>,
}
impl PreflightReport {
fn grouped(&self) -> BTreeMap<&Path, Vec<&ClaimRecord>> {
let mut groups: BTreeMap<&Path, Vec<&ClaimRecord>> = BTreeMap::new();
for claim in &self.claims {
groups.entry(&claim.out_rel).or_default().push(claim);
}
for group in groups.values_mut() {
group.sort_by_key(|c| c.precedence());
}
groups
}
pub(crate) fn conflicts(&self) -> Vec<Conflict<'_>> {
self.grouped()
.into_iter()
.filter(|(_, claimants)| claimants.len() > 1)
.map(|(out_rel, claimants)| Conflict { out_rel, claimants })
.collect()
}
pub(crate) fn escaping(&self) -> Vec<&ClaimRecord> {
self.claims
.iter()
.filter(|claim| {
!claim
.out_rel
.components()
.all(|c| matches!(c, std::path::Component::Normal(_)))
|| claim.out_rel.as_os_str().is_empty()
})
.collect()
}
pub(crate) fn winners(&self) -> Vec<&ClaimRecord> {
self.grouped().into_values().map(|group| group[0]).collect()
}
pub(crate) fn claims_target(&self, out_rel: impl AsRef<Path>) -> bool {
let out_rel = out_rel.as_ref();
self.claims.iter().any(|c| c.out_rel == out_rel)
}
pub(crate) fn walked_paths(&self) -> &[PathBuf] {
&self.walked
}
pub(crate) fn escaping_sources(&self) -> &[EscapingSource] {
&self.escaping_sources
}
pub(crate) fn walk_errors(&self) -> &[String] {
&self.walk_errors
}
pub(crate) fn skipped_symlinks(&self) -> &[SkippedSymlink] {
&self.skipped_symlinks
}
pub(crate) fn rejected_claims(&self) -> &[RejectedClaim] {
&self.rejected
}
pub(crate) fn npm_assets(&self) -> &[NpmAsset] {
&self.npm_assets
}
}
#[cfg(test)]
mod tests {
use super::*;
struct CopyLike;
impl Preflight for CopyLike {
fn name(&self) -> &'static str {
"copy"
}
fn rank(&self) -> Rank {
Rank::Static
}
fn claim(&self, rel: &Path) -> Option<Claim> {
let ext = rel.extension().and_then(|x| x.to_str()).unwrap_or("");
(ext != "src").then(|| Claim {
out_rel: rel.to_path_buf(),
tiebreak: 0,
})
}
}
struct TransformLike;
impl Preflight for TransformLike {
fn name(&self) -> &'static str {
"transform"
}
fn rank(&self) -> Rank {
Rank::Transform
}
fn claim(&self, rel: &Path) -> Option<Claim> {
let ext = rel.extension().and_then(|x| x.to_str()).unwrap_or("");
(ext == "src").then(|| Claim {
out_rel: rel.with_extension("txt"),
tiebreak: 0,
})
}
}
fn scan(roots: &[PathBuf]) -> PreflightReport {
scan_with(roots, crate::SymlinkMode::Follow)
}
fn scan_with(roots: &[PathBuf], symlinks: crate::SymlinkMode) -> PreflightReport {
let reject = crate::reject::Reject::none();
preflight(
roots,
&[&CopyLike, &TransformLike],
WalkPolicy {
reject: &reject,
symlinks,
},
)
}
#[cfg(unix)]
#[test]
fn preflight_records_an_npm_link_as_an_asset_not_an_error() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("web");
std::fs::create_dir_all(root.join("icons/bi")).unwrap();
symlink(
"npm://bootstrap-icons/icons/eye.svg",
root.join("icons/bi/eye.svg"),
)
.unwrap();
std::fs::write(root.join("app.js"), "export {};").unwrap();
let report = scan(std::slice::from_ref(&root));
assert!(
report.walk_errors().is_empty(),
"an npm:// link is not a walk error"
);
assert!(report.escaping_sources().is_empty());
let assets = report.npm_assets();
assert_eq!(assets.len(), 1);
assert_eq!(assets[0].rel, Path::new("icons/bi/eye.svg"));
assert_eq!(assets[0].target, "npm://bootstrap-icons/icons/eye.svg");
assert!(report
.winners()
.iter()
.any(|w| w.out_rel == Path::new("app.js")));
}
#[test]
fn preflight_detects_a_within_root_conflict_and_ranks_the_winner() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("root");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("a.txt"), "literal").unwrap();
std::fs::write(root.join("a.src"), "source").unwrap();
std::fs::write(root.join("b.md"), "fine").unwrap();
let report = scan(std::slice::from_ref(&root));
let conflicts = report.conflicts();
assert_eq!(conflicts.len(), 1, "one contested target");
assert_eq!(conflicts[0].out_rel, Path::new("a.txt"));
assert_eq!(conflicts[0].claimants.len(), 2);
assert_eq!(
conflicts[0].claimants[0].rel,
Path::new("a.txt"),
"the literal file wins"
);
let winners = report.winners();
assert_eq!(winners.len(), 2, "one winner per target: a.txt and b.md");
assert!(winners.iter().any(|w| w.out_rel == Path::new("b.md")));
}
#[test]
fn preflight_detects_a_cross_root_conflict_and_the_first_root_wins() {
let dir = tempfile::tempdir().unwrap();
let first = dir.path().join("first");
let second = dir.path().join("second");
std::fs::create_dir_all(&first).unwrap();
std::fs::create_dir_all(&second).unwrap();
std::fs::write(first.join("app.txt"), "first").unwrap();
std::fs::write(second.join("app.txt"), "second").unwrap();
let roots = vec![first, second];
let report = scan(&roots);
let conflicts = report.conflicts();
assert_eq!(conflicts.len(), 1);
assert_eq!(
conflicts[0].claimants[0].root, 0,
"the earlier root outranks the later one"
);
assert_eq!(report.winners().len(), 1);
}
#[test]
fn preflight_records_a_rejected_claim_instead_of_claiming() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("root");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join(".env"), "S=1").unwrap();
std::fs::write(root.join("ok.md"), "fine").unwrap();
let reject = crate::reject::Reject::default();
let report = preflight(
std::slice::from_ref(&root),
&[&CopyLike, &TransformLike],
WalkPolicy {
reject: &reject,
symlinks: crate::SymlinkMode::Follow,
},
);
assert!(
report.claims.iter().all(|c| c.out_rel != Path::new(".env")),
"a rejected target never claims"
);
let rejected = report.rejected_claims();
assert_eq!(rejected.len(), 1, "got {rejected:?}");
assert_eq!(rejected[0].rel, Path::new(".env"));
assert_eq!(rejected[0].out_rel, Path::new(".env"));
assert_eq!(
rejected[0].describe(std::slice::from_ref(&root)),
format!("{} dropped by the reject list", root.join(".env").display())
);
}
#[test]
fn a_rejected_transform_target_names_both_paths() {
let rejected = RejectedClaim {
root: 0,
rel: PathBuf::from(".env.tera"),
out_rel: PathBuf::from(".env"),
};
let line = rejected.describe(&[PathBuf::from("web")]);
assert!(
line.contains(".env.tera")
&& line.contains("would emit .env - dropped by the reject list"),
"got: {line}"
);
}
#[test]
fn tiebreak_orders_same_rank_claims() {
struct Multi;
impl Preflight for Multi {
fn name(&self) -> &'static str {
"multi"
}
fn rank(&self) -> Rank {
Rank::Transform
}
fn claim(&self, rel: &Path) -> Option<Claim> {
let ext = rel.extension()?.to_str()?;
let tiebreak = ["one", "two"].iter().position(|e| *e == ext)? as u8;
Some(Claim {
out_rel: rel.with_extension("out"),
tiebreak,
})
}
}
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("root");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("x.two"), "later").unwrap();
std::fs::write(root.join("x.one"), "earlier").unwrap();
let reject = crate::reject::Reject::none();
let report = preflight(
std::slice::from_ref(&root),
&[&Multi],
WalkPolicy {
reject: &reject,
symlinks: crate::SymlinkMode::Follow,
},
);
let conflicts = report.conflicts();
assert_eq!(conflicts.len(), 1);
assert_eq!(
conflicts[0].claimants[0].rel,
Path::new("x.one"),
"the lower tiebreak wins"
);
}
#[test]
fn escaping_claims_are_flagged_and_honest_ones_are_not() {
struct Hostile;
impl Preflight for Hostile {
fn name(&self) -> &'static str {
"hostile"
}
fn rank(&self) -> Rank {
Rank::Static
}
fn claim(&self, rel: &Path) -> Option<Claim> {
let ext = rel.extension().and_then(|x| x.to_str()).unwrap_or("");
match ext {
"up" => Some(Claim {
out_rel: PathBuf::from("../evil.txt"),
tiebreak: 0,
}),
"abs" => Some(Claim {
out_rel: PathBuf::from("/etc/evil.txt"),
tiebreak: 0,
}),
_ => None,
}
}
}
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("root");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("a.up"), "x").unwrap();
std::fs::write(root.join("b.abs"), "x").unwrap();
std::fs::write(root.join("fine.txt"), "x").unwrap();
let reject = crate::reject::Reject::none();
let report = preflight(
std::slice::from_ref(&root),
&[&Hostile, &CopyLike],
WalkPolicy {
reject: &reject,
symlinks: crate::SymlinkMode::Follow,
},
);
let escaping = report.escaping();
assert_eq!(escaping.len(), 2, "got {escaping:?}");
assert!(escaping
.iter()
.all(|claim| claim.out_rel.starts_with("..") || claim.out_rel.is_absolute()));
let honest = scan(std::slice::from_ref(&root));
assert!(honest.escaping().is_empty());
}
#[test]
fn claims_target_gates_on_any_claim_and_walk_records_every_entry() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("root");
std::fs::create_dir_all(root.join("sub")).unwrap();
std::fs::write(root.join("sub/page.src"), "src").unwrap();
let report = scan(std::slice::from_ref(&root));
assert!(
report.claims_target("sub/page.txt"),
"the transform's target counts as claimed"
);
assert!(!report.claims_target("index.html"));
assert!(report.walked_paths().len() >= 3);
}
#[test]
fn preflight_drops_claims_with_rejected_targets() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("root");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("a.src"), "source").unwrap();
std::fs::write(root.join("b.md"), "fine").unwrap();
let reject = crate::reject::Reject::from_list(["*.txt"]);
let report = preflight(
std::slice::from_ref(&root),
&[&CopyLike, &TransformLike],
WalkPolicy {
reject: &reject,
symlinks: crate::SymlinkMode::Follow,
},
);
assert!(
!report.claims_target("a.txt"),
"the rejected target is not claimed"
);
assert!(report.claims_target("b.md"), "unrejected claims survive");
}
#[cfg(unix)]
#[test]
fn preflight_flags_files_resolving_outside_the_root() {
let dir = tempfile::tempdir().unwrap();
let private = dir.path().join("private");
std::fs::create_dir_all(&private).unwrap();
std::fs::write(private.join("credentials.txt"), "secret").unwrap();
let root = dir.path().join("root");
std::fs::create_dir_all(&root).unwrap();
std::os::unix::fs::symlink(&private, root.join("exposed")).unwrap();
let report = scan(std::slice::from_ref(&root));
let escaping = report.escaping_sources();
assert_eq!(escaping.len(), 1, "one file resolves outside the root");
assert_eq!(escaping[0].rel, Path::new("exposed/credentials.txt"));
assert!(escaping[0].target.ends_with("private/credentials.txt"));
assert!(
!report.claims_target("exposed/credentials.txt"),
"no step may claim an escaping file"
);
}
#[cfg(unix)]
#[test]
fn preflight_allows_symlinks_resolving_inside_the_root() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("root");
std::fs::create_dir_all(root.join("real")).unwrap();
std::fs::write(root.join("real/a.txt"), "data").unwrap();
std::os::unix::fs::symlink(root.join("real/a.txt"), root.join("alias.txt")).unwrap();
std::os::unix::fs::symlink(root.join("real"), root.join("linked")).unwrap();
let report = scan(std::slice::from_ref(&root));
assert!(report.escaping_sources().is_empty(), "nothing escapes");
assert!(report.walk_errors().is_empty(), "no walk problems");
assert!(report.claims_target("alias.txt"), "linked file claims");
assert!(
report.claims_target("linked/a.txt"),
"file in a linked dir claims"
);
}
#[cfg(unix)]
#[test]
fn preflight_follow_unsafe_claims_escaping_files() {
let dir = tempfile::tempdir().unwrap();
let private = dir.path().join("private");
std::fs::create_dir_all(&private).unwrap();
std::fs::write(private.join("credentials.txt"), "secret").unwrap();
let root = dir.path().join("root");
std::fs::create_dir_all(&root).unwrap();
std::os::unix::fs::symlink(&private, root.join("exposed")).unwrap();
let report = scan_with(
std::slice::from_ref(&root),
crate::SymlinkMode::FollowUnsafe,
);
assert!(
report.escaping_sources().is_empty(),
"escapes are permitted"
);
assert!(
report.claims_target("exposed/credentials.txt"),
"the escaping file claims like any other"
);
}
#[cfg(all(unix, feature = "symlink-move"))]
#[test]
fn preflight_redirect_skips_symlinks_and_claims_plain_files() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("root");
std::fs::create_dir_all(root.join("real")).unwrap();
std::fs::write(root.join("real/a.txt"), "data").unwrap();
std::fs::write(root.join("plain.txt"), "plain").unwrap();
std::os::unix::fs::symlink(root.join("plain.txt"), root.join("link.txt")).unwrap();
std::os::unix::fs::symlink(root.join("real"), root.join("linked")).unwrap();
std::os::unix::fs::symlink(root.join("missing"), root.join("dangling")).unwrap();
let report = scan_with(std::slice::from_ref(&root), crate::SymlinkMode::Redirect);
let mut skipped: Vec<_> = report
.skipped_symlinks()
.iter()
.map(|s| s.rel.display().to_string())
.collect();
skipped.sort();
assert_eq!(skipped, ["dangling", "link.txt", "linked"]);
assert!(report.walk_errors().is_empty(), "a skip is not an error");
assert!(report.claims_target("plain.txt"), "plain files still claim");
assert!(report.claims_target("real/a.txt"));
assert!(
!report.claims_target("linked/a.txt"),
"nothing beneath a directory link is walked"
);
}
#[cfg(unix)]
#[test]
fn preflight_surfaces_a_dangling_link_as_a_walk_error() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("root");
std::fs::create_dir_all(&root).unwrap();
std::os::unix::fs::symlink(root.join("missing"), root.join("dangling")).unwrap();
let report = scan(std::slice::from_ref(&root));
assert_eq!(
report.walk_errors().len(),
1,
"got {:?}",
report.walk_errors()
);
assert!(report.escaping_sources().is_empty());
assert!(report.conflicts().is_empty(), "the tree still preflights");
}
}