use std::path::Path;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Presets(u8);
impl Presets {
pub const SOURCE: Presets = Presets(0b0001);
pub const HIDDEN: Presets = Presets(0b0010);
pub const CONFIG: Presets = Presets(0b0100);
pub const SECRETS: Presets = Presets(0b1000);
pub const ALL: Presets = Presets(0b1111);
pub const NONE: Presets = Presets(0);
pub fn contains(self, other: Presets) -> bool {
self.0 & other.0 == other.0
}
fn parse_one(name: &str) -> Option<Presets> {
match name {
"source" => Some(Presets::SOURCE),
"hidden" => Some(Presets::HIDDEN),
"config" => Some(Presets::CONFIG),
"secrets" => Some(Presets::SECRETS),
_ => None,
}
}
}
impl std::ops::BitOr for Presets {
type Output = Presets;
fn bitor(self, rhs: Presets) -> Presets {
Presets(self.0 | rhs.0)
}
}
impl std::ops::BitAnd for Presets {
type Output = Presets;
fn bitand(self, rhs: Presets) -> Presets {
Presets(self.0 & rhs.0)
}
}
impl std::ops::Not for Presets {
type Output = Presets;
fn not(self) -> Presets {
Presets(!self.0 & Presets::ALL.0)
}
}
const SOURCE_EXTS: &[&str] = &[
"php", "php5", "phtml", "ts", "tsx", "cts", "mts", "scss", "sass", "tera",
];
const CONFIG_NAMES: &[&str] = &[
"package.json",
"package-lock.json",
"npm-shrinkwrap.json",
"yarn.lock",
"pnpm-lock.yaml",
"bun.lockb",
"tsconfig.json",
"jsconfig.json",
];
const SECRET_EXTS: &[&str] = &[
"pem", "key", "crt", "cer", "der", "p12", "pfx", "sql", "sqlite", "sqlite3", "db", "dump", ];
const WELL_KNOWN: &str = ".well-known";
#[derive(Clone, Debug)]
pub struct Reject {
exts: Vec<String>,
names: Vec<String>,
hidden: bool,
}
impl Default for Reject {
fn default() -> Self {
Self::all()
}
}
impl From<Presets> for Reject {
fn from(presets: Presets) -> Self {
let mut r = Self::empty();
if presets.contains(Presets::SOURCE) {
r.exts.extend(SOURCE_EXTS.iter().map(|s| s.to_string()));
}
if presets.contains(Presets::SECRETS) {
r.exts.extend(SECRET_EXTS.iter().map(|s| s.to_string()));
}
if presets.contains(Presets::CONFIG) {
r.names.extend(CONFIG_NAMES.iter().map(|s| s.to_string()));
}
r.hidden = presets.contains(Presets::HIDDEN);
r
}
}
impl Reject {
fn empty() -> Self {
Self {
exts: Vec::new(),
names: Vec::new(),
hidden: false,
}
}
pub fn all() -> Self {
Presets::ALL.into()
}
pub fn none() -> Self {
Self::empty()
}
pub fn add(&mut self, pattern: impl AsRef<str>) {
let pat = pattern.as_ref().trim();
if pat.is_empty() {
return;
}
if let Some(ext) = pat.strip_prefix("*.") {
if !ext.is_empty() {
self.exts.push(ext.to_ascii_lowercase());
return;
}
}
let name = pat.strip_suffix('/').unwrap_or(pat);
self.names.push(name.to_ascii_lowercase());
}
pub fn parse_presets(expr: &str) -> Result<Self, String> {
let mut active = Presets::NONE;
for tok in expr.split(',').map(str::trim).filter(|t| !t.is_empty()) {
match tok {
"all" => active = Presets::ALL,
"none" => active = Presets::NONE,
_ => {
let (remove, name) = tok
.strip_prefix('!')
.map_or((false, tok), |rest| (true, rest));
let preset = Presets::parse_one(name)
.ok_or_else(|| format!("unknown reject preset `{name}` (in `{expr}`)"))?;
active = if remove {
active & !preset
} else {
active | preset
};
}
}
}
Ok(active.into())
}
pub fn from_list<I, S>(patterns: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut r = Self::empty();
for pat in patterns {
r.add(pat);
}
r
}
pub fn rejects(&self, rel: &str) -> bool {
rel.split(['/', '\\']).any(|c| self.component_rejected(c))
}
pub fn rejects_path(&self, path: &Path) -> bool {
path.components().any(|c| match c {
std::path::Component::Normal(os) => {
os.to_str().is_some_and(|n| self.component_rejected(n))
}
_ => false,
})
}
pub fn is_empty(&self) -> bool {
self.exts.is_empty() && self.names.is_empty() && !self.hidden
}
fn component_rejected(&self, comp: &str) -> bool {
if comp.is_empty() {
return false;
}
let lower = comp.to_ascii_lowercase();
if self.hidden && lower.starts_with('.') && lower != WELL_KNOWN {
return true;
}
if self.names.contains(&lower) {
return true;
}
if let Some(ext) = Path::new(&lower).extension().and_then(|e| e.to_str()) {
if self.exts.iter().any(|e| e == ext) {
return true;
}
}
false
}
}
#[cfg(feature = "tracing")]
pub(crate) fn warn_rejected(path: &str) {
tracing::warn!(target: "web_modules", path, "dropped by the reject list");
}
#[cfg(not(feature = "tracing"))]
pub(crate) fn warn_rejected(_path: &str) {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn all_presets_reject_config_secrets_and_source() {
let r = Reject::all();
assert!(r.rejects("package.json"));
assert!(r.rejects(".env"));
assert!(r.rejects("web/.git/config"));
assert!(r.rejects("api/secret.php"));
assert!(r.rejects("app.ts"));
assert!(r.rejects("mod.cts"));
assert!(r.rejects("styles.sass"));
assert!(r.rejects("jsconfig.json"));
assert!(r.rejects("yarn.lock"));
assert!(r.rejects("pnpm-lock.yaml"));
assert!(r.rejects("bun.lockb"));
assert!(r.rejects("server.key"));
assert!(r.rejects("tls/cert.pem"));
assert!(r.rejects("backup.sql"));
assert!(r.rejects("data.sqlite"));
assert!(!r.rejects("app.js"));
assert!(!r.rejects("styles.css"));
assert!(!r.rejects("logo.svg"));
assert!(!r.rejects("index.html"));
}
#[test]
fn well_known_is_allowed_even_under_hidden() {
let r = Reject::all();
assert!(!r.rejects(".well-known/security.txt"));
assert!(!r.rejects(".well-known/acme-challenge/abc"));
assert!(r.rejects(".ssh/id_rsa"));
}
#[test]
fn matching_is_case_insensitive() {
let r = Reject::all();
assert!(r.rejects(".ENV"));
assert!(r.rejects("web/.Git/config"));
assert!(r.rejects("API/SECRET.PHP"));
assert!(r.rejects("Package.JSON"));
assert!(r.rejects("SERVER.KEY"));
assert!(r.rejects("DUMP.SQL"));
assert!(!r.rejects(".WELL-KNOWN/x"));
}
#[test]
fn presets_compose_with_all_and_negation() {
let no_config = Reject::parse_presets("all,!config").unwrap();
assert!(!no_config.rejects("package.json")); assert!(no_config.rejects(".env")); assert!(no_config.rejects("x.php")); assert!(no_config.rejects("server.key"));
let only = Reject::parse_presets("source,hidden").unwrap();
assert!(only.rejects("x.php") && only.rejects(".env"));
assert!(!only.rejects("package.json"));
assert!(!only.rejects("server.key"));
let none = Reject::parse_presets("none").unwrap();
assert!(none.is_empty());
assert!(!none.rejects(".env") && !none.rejects("package.json"));
assert!(Reject::parse_presets("all,!bogus").is_err());
}
#[test]
fn secrets_preset_selects_keys_certs_and_dumps() {
let secrets = Reject::parse_presets("secrets").unwrap();
assert!(secrets.rejects("id.pem"));
assert!(secrets.rejects("server.key"));
assert!(secrets.rejects("db.sqlite"));
assert!(secrets.rejects("backup.sql"));
assert!(!secrets.rejects(".env"));
assert!(!secrets.rejects("package.json"));
assert!(!secrets.rejects("app.ts"));
let no_secrets = Reject::parse_presets("all,!secrets").unwrap();
assert!(!no_secrets.rejects("server.key"));
assert!(no_secrets.rejects(".env") && no_secrets.rejects("package.json"));
}
#[test]
fn from_list_is_a_full_replace() {
let r = Reject::from_list(["*.php", ".git/", "package.json", ".env"]);
assert!(r.rejects("x.php"));
assert!(r.rejects("a/.git/b"));
assert!(r.rejects("package.json"));
assert!(r.rejects(".env"));
assert!(!r.rejects("app.ts"));
assert!(!r.rejects(".sops"));
}
#[test]
fn rejects_path_matches_resolved_components() {
let r = Reject::all();
assert!(r.rejects_path(Path::new("/abs/web/.git/config")));
assert!(r.rejects_path(Path::new("/abs/web/secret.PHP")));
assert!(!r.rejects_path(Path::new("/abs/web/app.js")));
assert!(!r.rejects_path(Path::new("/abs/web/.well-known/security.txt")));
}
#[test]
fn presets_bitwise_ops_select_rules() {
let all_but_config: Reject = (Presets::ALL & !Presets::CONFIG).into();
assert!(all_but_config.rejects("x.php")); assert!(all_but_config.rejects(".env")); assert!(all_but_config.rejects("server.key")); assert!(!all_but_config.rejects("package.json"));
let cfg_or_src: Reject = (Presets::CONFIG | Presets::SOURCE).into();
assert!(cfg_or_src.rejects("package.json") && cfg_or_src.rejects("app.ts"));
assert!(!cfg_or_src.rejects(".env")); assert!(!cfg_or_src.rejects("server.key"));
let sel = Presets::ALL & !Presets::CONFIG;
assert!(sel.contains(Presets::SOURCE) && sel.contains(Presets::HIDDEN));
assert!(sel.contains(Presets::SECRETS));
assert!(!sel.contains(Presets::CONFIG));
assert!(Presets::ALL.contains(Presets::CONFIG));
assert_eq!(Presets::NONE, Presets::ALL & !Presets::ALL);
}
#[test]
fn add_layers_extra_patterns_onto_a_selection() {
let mut r: Reject = Presets::NONE.into();
r.add(".htpasswd");
r.add("*.bak");
assert!(r.rejects(".htpasswd"));
assert!(r.rejects("dir/notes.BAK")); assert!(!r.rejects("index.html"));
}
}