use glob::Pattern;
use crate::error::PackError;
pub const DEFAULT_CACHE_DIRS: &[&str] = &[
"target",
"node_modules",
".venv",
"venv",
"__pycache__",
".pytest_cache",
".mypy_cache",
".ruff_cache",
".turbo",
".next",
".nuxt",
".parcel-cache",
".gradle",
];
pub const DEFAULT_SECRET_GLOBS: &[&str] = &[
".env",
".env.*",
".netrc",
".npmrc",
".pypirc",
".dockercfg",
".pgpass",
".my.cnf",
".htpasswd",
"credentials",
"credentials.toml",
"secret.toml",
"secrets.toml",
"secret.yaml",
"secrets.yaml",
"secret.yml",
"secrets.yml",
"secret.json",
"secrets.json",
"service-account*.json",
"terraform.tfvars",
"*.auto.tfvars",
"kubeconfig",
"id_rsa",
"id_dsa",
"id_ecdsa",
"id_ed25519",
"*.pem",
"*.key",
"*.p12",
"*.pfx",
"*.jks",
"*.keystore",
"*.p8",
"*.ppk",
"*.asc",
"*.gpg",
];
pub const DEFAULT_KEEP: &[&str] = &[
".env.example",
".env.sample",
".env.template",
".env.dist",
".env.defaults",
];
#[derive(Debug, Clone, Default)]
pub struct RuleOverrides {
pub secret_globs: Vec<String>,
pub cache_dirs: Vec<String>,
pub keep: Vec<String>,
pub no_link_report: Vec<String>,
}
impl RuleOverrides {
pub fn is_empty(&self) -> bool {
self.secret_globs.is_empty()
&& self.cache_dirs.is_empty()
&& self.keep.is_empty()
&& self.no_link_report.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileVerdict {
Ordinary,
Secret {
pattern: String,
},
KeptOverSecret {
keep_pattern: String,
secret_pattern: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Scope {
Name,
Path,
}
#[derive(Debug, Clone)]
struct Rule {
pattern: Pattern,
scope: Scope,
}
impl Rule {
fn compile(raw: &str) -> Result<Self, PackError> {
Ok(Self {
pattern: compile_custom(raw)?,
scope: if raw.contains('/') {
Scope::Path
} else {
Scope::Name
},
})
}
fn builtin(raw: &str) -> Option<Self> {
Self::compile(raw).ok()
}
fn matches(&self, name: &str, rel: &str) -> bool {
match self.scope {
Scope::Name => self.pattern.matches(name),
Scope::Path => self.pattern.matches(rel),
}
}
fn as_str(&self) -> &str {
self.pattern.as_str()
}
}
#[derive(Debug, Clone)]
pub struct PackRules {
secret: Vec<Rule>,
keep: Vec<Rule>,
cache_dirs: Vec<Rule>,
no_link_report: Vec<Rule>,
pub custom_secret_count: usize,
pub custom_keep_count: usize,
pub custom_cache_count: usize,
}
impl Default for PackRules {
fn default() -> Self {
Self::new(&RuleOverrides::default()).expect("built-in globs must compile")
}
}
impl PackRules {
pub fn new(overrides: &RuleOverrides) -> Result<Self, PackError> {
let mut secret = compile_builtin(DEFAULT_SECRET_GLOBS);
for raw in &overrides.secret_globs {
secret.push(Rule::compile(raw)?);
}
let mut keep = compile_builtin(DEFAULT_KEEP);
for raw in &overrides.keep {
keep.push(Rule::compile(raw)?);
}
let mut cache_dirs = compile_builtin(DEFAULT_CACHE_DIRS);
for raw in &overrides.cache_dirs {
cache_dirs.push(Rule::compile(raw)?);
}
let mut no_link_report = Vec::new();
for raw in &overrides.no_link_report {
no_link_report.push(Rule::compile(raw)?);
}
Ok(Self {
secret,
keep,
cache_dirs,
no_link_report,
custom_secret_count: overrides.secret_globs.len(),
custom_keep_count: overrides.keep.len(),
custom_cache_count: overrides.cache_dirs.len(),
})
}
pub fn no_link_report_match(&self, name: &str, rel: &str) -> Option<&str> {
self.no_link_report
.iter()
.find(|r| r.matches(name, rel))
.map(|r| r.as_str())
}
pub fn is_cache_dir(&self, name: &str, rel: &str) -> bool {
if self.keep_match(name, rel).is_some() {
return false;
}
self.cache_dirs.iter().any(|r| r.matches(name, rel))
}
pub fn classify(&self, name: &str, rel: &str) -> FileVerdict {
let Some(secret) = self.secret.iter().find(|r| r.matches(name, rel)) else {
return FileVerdict::Ordinary;
};
match self.keep_match(name, rel) {
Some(keep_pattern) => FileVerdict::KeptOverSecret {
keep_pattern: keep_pattern.to_string(),
secret_pattern: secret.as_str().to_string(),
},
None => FileVerdict::Secret {
pattern: secret.as_str().to_string(),
},
}
}
fn keep_match(&self, name: &str, rel: &str) -> Option<&str> {
self.keep
.iter()
.find(|r| r.matches(name, rel))
.map(|r| r.as_str())
}
pub fn secret_pattern_count(&self) -> usize {
self.secret.len()
}
pub fn is_customized(&self) -> bool {
self.custom_secret_count + self.custom_keep_count + self.custom_cache_count > 0
}
}
fn compile_builtin(raw: &[&str]) -> Vec<Rule> {
raw.iter().filter_map(|p| Rule::builtin(p)).collect()
}
fn compile_custom(raw: &str) -> Result<Pattern, PackError> {
Pattern::new(raw).map_err(|e| PackError::BadPattern {
pattern: raw.to_string(),
message: e.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn is_secret(r: &PackRules, name: &str) -> bool {
matches!(r.classify(name, name), FileVerdict::Secret { .. })
}
fn is_cache(r: &PackRules, name: &str) -> bool {
r.is_cache_dir(name, name)
}
#[test]
fn test_all_builtin_globs_compile() {
for raw in DEFAULT_SECRET_GLOBS.iter().chain(DEFAULT_KEEP.iter()) {
assert!(
Pattern::new(raw).is_ok(),
"built-in glob is malformed: {raw}"
);
}
let rules = PackRules::new(&RuleOverrides::default()).expect("defaults compile");
assert_eq!(rules.secret_pattern_count(), DEFAULT_SECRET_GLOBS.len());
}
#[test]
fn test_builtin_covers_common_secret_names() {
let r = PackRules::default();
for name in [
".env",
".env.production",
"secret.toml",
"secrets.yaml",
"secrets.json",
"credentials.toml",
"terraform.tfvars",
"prod.auto.tfvars",
"service-account-prod.json",
"kubeconfig",
".pgpass",
".pypirc",
"id_ed25519",
"server.pem",
"signing.p8",
"putty.ppk",
"key.asc",
] {
assert!(is_secret(&r, name), "{name} should be treated as a secret");
}
}
#[test]
fn test_builtin_passes_ordinary_files() {
let r = PackRules::default();
for name in [
"main.rs",
"README.md",
".mcp.json",
"Cargo.toml",
"id_rsa.pub",
] {
assert!(!is_secret(&r, name), "{name} must travel");
}
}
#[test]
fn test_builtin_keep_rescues_templates() {
let r = PackRules::default();
for name in [".env.example", ".env.sample", ".env.template", ".env.dist"] {
assert!(!is_secret(&r, name), "{name} is a template");
}
assert!(is_secret(&r, ".env.local"));
}
#[test]
fn test_builtin_cache_dirs() {
let r = PackRules::default();
assert!(is_cache(&r, "target"));
assert!(is_cache(&r, "node_modules"));
assert!(!is_cache(&r, "src"));
assert!(!is_cache(&r, "dist"), "dist is source in many projects");
}
#[test]
fn test_custom_secret_glob_adds_without_replacing() {
let r = PackRules::new(&RuleOverrides {
secret_globs: vec!["my-app-keys.json".to_string(), "*.vault".to_string()],
..Default::default()
})
.expect("compile");
assert!(is_secret(&r, "my-app-keys.json"));
assert!(is_secret(&r, "prod.vault"));
assert!(is_secret(&r, ".env"));
assert!(is_secret(&r, "secret.toml"));
assert_eq!(r.custom_secret_count, 2);
assert!(r.is_customized());
}
#[test]
fn test_keep_overrides_builtin_secret() {
let r = PackRules::new(&RuleOverrides {
keep: vec![".npmrc".to_string()],
..Default::default()
})
.expect("compile");
assert!(
!is_secret(&r, ".npmrc"),
"keep must override the built-in secret rule"
);
assert!(is_secret(&r, ".netrc"), "siblings unaffected");
}
#[test]
fn test_keep_overrides_cache_dir() {
let r = PackRules::new(&RuleOverrides {
keep: vec!["target".to_string()],
..Default::default()
})
.expect("compile");
assert!(!is_cache(&r, "target"));
}
#[test]
fn test_custom_cache_dir() {
let r = PackRules::new(&RuleOverrides {
cache_dirs: vec!["dist".to_string(), "build".to_string()],
..Default::default()
})
.expect("compile");
assert!(is_cache(&r, "dist"));
assert!(is_cache(&r, "build"));
assert!(is_cache(&r, "target"), "built-ins remain");
assert_eq!(r.custom_cache_count, 2);
}
#[test]
fn test_malformed_custom_glob_is_reported() {
let err = PackRules::new(&RuleOverrides {
secret_globs: vec!["broken[".to_string()],
..Default::default()
})
.expect_err("malformed glob must fail");
match err {
PackError::BadPattern { pattern, .. } => assert_eq!(pattern, "broken["),
other => panic!("expected BadPattern, got {other:?}"),
}
}
#[test]
fn test_reason_names_the_matching_pattern() {
let r = PackRules::new(&RuleOverrides {
secret_globs: vec!["*.vault".to_string()],
..Default::default()
})
.expect("compile");
assert_eq!(
r.classify("prod.vault", "prod.vault"),
FileVerdict::Secret {
pattern: "*.vault".to_string()
}
);
}
#[test]
fn test_empty_overrides_are_defaults() {
let o = RuleOverrides::default();
assert!(o.is_empty());
let r = PackRules::new(&o).expect("compile");
assert!(!r.is_customized());
}
#[test]
fn test_name_glob_matches_at_any_depth() {
let r = PackRules::default();
assert!(matches!(
r.classify("key.pem", "deep/nested/key.pem"),
FileVerdict::Secret { .. }
));
assert!(matches!(
r.classify(".env", "services/api/.env"),
FileVerdict::Secret { .. }
));
}
#[test]
fn test_path_glob_matches_only_its_own_path() {
let r = PackRules::new(&RuleOverrides {
secret_globs: vec!["deploy/*.token".to_string()],
..Default::default()
})
.expect("compile");
assert!(matches!(
r.classify("prod.token", "deploy/prod.token"),
FileVerdict::Secret { .. }
));
assert!(
matches!(
r.classify("prod.token", "docs/prod.token"),
FileVerdict::Ordinary
),
"a path-scoped rule must not reach outside its path"
);
}
#[test]
fn test_path_scoped_keep_rescues_only_there() {
let r = PackRules::new(&RuleOverrides {
keep: vec!["docs/samples/*.pem".to_string()],
..Default::default()
})
.expect("compile");
assert!(
matches!(
r.classify("demo.pem", "docs/samples/demo.pem"),
FileVerdict::KeptOverSecret { .. }
),
"the sample is carried, and recorded as an override"
);
assert!(
matches!(
r.classify("server.pem", "deploy/server.pem"),
FileVerdict::Secret { .. }
),
"a real key elsewhere stays excluded"
);
}
#[test]
fn test_path_scoped_cache_dir_does_not_catch_namesakes() {
let r = PackRules::new(&RuleOverrides {
cache_dirs: vec!["frontend/dist".to_string()],
..Default::default()
})
.expect("compile");
assert!(r.is_cache_dir("dist", "frontend/dist"));
assert!(
!r.is_cache_dir("dist", "vendor/dist"),
"a namesake elsewhere may well be hand-written source"
);
}
}