use std::path::{Component, Path};
const SAFE_ENV: [&str; 3] = [".env.example", ".env.sample", ".env.template"];
const DENIED_EXT: [&str; 8] = ["pem", "key", "p12", "pfx", "jks", "keystore", "kdbx", "ppk"];
const KEY_PREFIXES: [&str; 4] = ["id_rsa", "id_dsa", "id_ecdsa", "id_ed25519"];
const DENIED_NAMES: [&str; 12] = [
".npmrc",
".pypirc",
".netrc",
".git-credentials",
".htpasswd",
".envrc",
"credentials",
"credentials.json",
"secrets.json",
"secrets.yaml",
"secrets.yml",
".env",
];
const BACKUP_SUFFIXES: [&str; 11] = [
".bak", ".old", ".orig", ".save", ".backup", ".tmp", ".swp", ".swo", ".swn", ".copy", ".dist",
];
const SWAP_SUFFIXES: [&str; 3] = [".swp", ".swo", ".swn"];
const SOURCE_EXT: [&str; 18] = [
"ts", "tsx", "js", "mjs", "rs", "py", "go", "swift", "kt", "java", "c", "h", "cpp", "md",
"txt", "toml", "yaml", "yml",
];
const DENIED_PAIRS: [(&str, &str); 1] = [(".docker", "config.json")];
const DENIED_DIRS: [&str; 4] = [".ssh", ".gnupg", ".aws", ".kube"];
const SENSITIVE_ROOTS: [&str; 5] = [".ssh", ".gnupg", ".aws", ".docker", ".kube"];
fn norm(s: &std::ffi::OsStr) -> String {
s.to_string_lossy()
.to_lowercase()
.trim_end_matches(['.', ' '])
.to_owned()
}
fn lower_components(p: &Path) -> Vec<String> {
p.components()
.filter_map(|c| match c {
Component::Normal(s) => Some(norm(s)),
_ => None,
})
.collect()
}
fn dir_denied(parts: &[String]) -> bool {
parts.iter().any(|c| DENIED_DIRS.contains(&c.as_str()))
}
fn normalize_name(n: &str) -> String {
let mut s = n;
loop {
if s.len() >= 2 && s.starts_with('#') && s.ends_with('#') {
s = &s[1..s.len() - 1];
} else if let Some(t) = s.strip_suffix('~') {
s = t;
} else if let Some(t) = BACKUP_SUFFIXES.iter().find_map(|x| s.strip_suffix(x)) {
s = t;
} else if let Some((t, d)) = s.rsplit_once('.') {
if t.is_empty()
|| d.is_empty()
|| d.len() > 14
|| !d.bytes().all(|b| b.is_ascii_digit())
{
return s.to_owned();
}
s = t;
} else {
return s.to_owned();
}
}
}
fn variants(n: &str) -> Vec<String> {
let mut v = vec![normalize_name(n)];
if n.starts_with('.') && SWAP_SUFFIXES.iter().any(|x| n.ends_with(x)) {
v.push(normalize_name(&n[1..]));
}
v
}
fn env_segment_denied(segs: &[&str]) -> bool {
let Some(i) = segs.iter().skip(1).position(|s| *s == "env").map(|i| i + 1) else {
return false;
};
!(i + 2 == segs.len() && ["example", "sample", "template"].contains(&segs[i + 1]))
}
fn name_denied(n: &str) -> bool {
if SAFE_ENV.contains(&n) {
return false;
}
if DENIED_NAMES.contains(&n)
|| ((n.starts_with("service-account") || n.starts_with("service_account"))
&& n.ends_with(".json"))
{
return true;
}
let segs: Vec<&str> = n.split('.').collect();
let last = if segs.len() > 1 {
segs[segs.len() - 1]
} else {
""
};
let pubkey = n.ends_with(".pub") && KEY_PREFIXES.iter().any(|p| n.starts_with(p));
if n.starts_with(".env.")
|| n.ends_with(".env")
|| DENIED_EXT.contains(&last)
|| env_segment_denied(&segs)
|| segs
.iter()
.skip(1)
.any(|seg| *seg == "tfvars" || seg.starts_with("tfstate"))
|| (!pubkey
&& KEY_PREFIXES
.iter()
.any(|p| n == *p || n.starts_with(&format!("{p}."))))
{
return true;
}
if SOURCE_EXT.contains(&last) {
return false;
}
(!pubkey && KEY_PREFIXES.iter().any(|p| n.starts_with(p)))
|| (segs.len() > 2
&& segs[1..segs.len() - 1]
.iter()
.any(|s| DENIED_EXT.contains(s)))
}
pub fn is_denied(rel_path: &Path) -> bool {
let parts = lower_components(rel_path);
if dir_denied(&parts) {
return true;
}
if parts.windows(2).any(|w| {
variants(&w[1])
.iter()
.any(|f| DENIED_PAIRS.iter().any(|(d, df)| w[0] == *d && f == df))
}) {
return true;
}
parts
.last()
.is_some_and(|n| variants(n).iter().any(|v| name_denied(v)))
}
pub(super) fn is_denied_dir(rel_path: &Path) -> bool {
dir_denied(&lower_components(rel_path))
}
pub(super) fn is_denied_root(canonical_root: &Path) -> bool {
lower_components(canonical_root)
.iter()
.any(|c| SENSITIVE_ROOTS.contains(&c.as_str()))
}
pub(super) fn is_db_file(name: &str) -> bool {
let n = name.to_lowercase();
n == ".pith" || n == ".pith.lock" || n.starts_with(".pith.tmp")
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
const DECOR: [&str; 14] = [
".bak", ".old", ".orig", ".save", ".backup", ".tmp", ".swp", ".swo", ".swn", ".copy",
".dist", ".1", ".12", "~",
];
const SECRET_NAMES: [&str; 14] = [
".npmrc",
".pypirc",
".netrc",
".git-credentials",
".htpasswd",
".envrc",
"credentials",
"credentials.json",
"secrets.json",
"secrets.yaml",
"secrets.yml",
".env",
"service-account.json",
"service_account_x.json",
];
const SOURCE_NAMES: [&str; 8] = [
"credentials.rs",
"env.ts",
"api.key.ts",
"id_rsa_helper.ts",
"config.v2.json",
"config.rs",
"config.json.schema.ts",
"secrets.rs",
];
fn decorate(name: &str, picks: &[usize], wrap: u8) -> String {
let mut s = name.to_owned();
for &i in picks {
s.push_str(DECOR[i % DECOR.len()]);
}
match wrap % 4 {
1 => format!(".{s}.swp"),
2 => format!("#{s}#"),
3 => format!("{s}~"),
_ => s,
}
}
proptest! {
#[test]
fn strong_rules_ignore_source_extensions(
ni in 0usize..6,
ei in 0usize..SOURCE_EXT.len(),
) {
let base = [".env", ".env.local", "x.tfvars", "x.tfstate", "id_rsa", "id_ed25519"][ni];
let f = format!("{base}.{}", SOURCE_EXT[ei]);
prop_assert!(is_denied(Path::new(&f)), "{f}");
}
#[test]
fn decorated_secret_names_are_denied(
ni in 0usize..SECRET_NAMES.len(),
picks in prop::collection::vec(0usize..DECOR.len(), 0..=3),
wrap in 0u8..4,
) {
let f = decorate(SECRET_NAMES[ni], &picks, wrap);
prop_assert!(is_denied(Path::new(&f)), "{f}");
prop_assert!(is_denied(&Path::new("sub").join(&f)), "sub/{f}");
}
#[test]
fn decorated_pairs_are_denied(
pi in 0usize..DENIED_PAIRS.len(),
picks in prop::collection::vec(0usize..DECOR.len(), 0..=3),
wrap in 0u8..4,
) {
let (d, f) = DENIED_PAIRS[pi];
let p = format!("{d}/{}", decorate(f, &picks, wrap));
prop_assert!(is_denied(Path::new(&p)), "{p}");
prop_assert!(is_denied(Path::new(&format!("x/{p}"))), "x/{p}");
}
}
#[test]
fn whole_aws_and_kube_dirs_are_denied() {
for p in [
".aws/config",
".aws/sso/cache/x.json",
".kube/config.yaml",
".kube/config.rs",
] {
assert!(is_denied(Path::new(p)), "{p}");
}
assert!(is_denied_dir(Path::new("a/.kube")));
}
#[test]
fn aws_and_kube_dirs_deny_even_ordinary_source_names() {
for n in SOURCE_NAMES {
for d in [".aws", ".kube"] {
assert!(is_denied(&Path::new(d).join(n)), "{d}/{n}");
}
}
}
#[test]
fn known_source_names_stay_allowed() {
for n in SOURCE_NAMES {
assert!(!is_denied(Path::new(n)), "{n}");
assert!(!is_denied(&Path::new(".docker").join(n)), ".docker/{n}");
}
}
}