1use glob::Pattern;
24
25use crate::error::PackError;
26
27pub const DEFAULT_CACHE_DIRS: &[&str] = &[
34 "target",
35 "node_modules",
36 ".venv",
37 "venv",
38 "__pycache__",
39 ".pytest_cache",
40 ".mypy_cache",
41 ".ruff_cache",
42 ".turbo",
43 ".next",
44 ".nuxt",
45 ".parcel-cache",
46 ".gradle",
47];
48
49pub const DEFAULT_SECRET_GLOBS: &[&str] = &[
54 ".env",
56 ".env.*",
57 ".netrc",
59 ".npmrc",
60 ".pypirc",
61 ".dockercfg",
62 ".pgpass",
63 ".my.cnf",
64 ".htpasswd",
65 "credentials",
66 "credentials.toml",
67 "secret.toml",
69 "secrets.toml",
70 "secret.yaml",
71 "secrets.yaml",
72 "secret.yml",
73 "secrets.yml",
74 "secret.json",
75 "secrets.json",
76 "service-account*.json",
78 "terraform.tfvars",
79 "*.auto.tfvars",
80 "kubeconfig",
81 "id_rsa",
83 "id_dsa",
84 "id_ecdsa",
85 "id_ed25519",
86 "*.pem",
88 "*.key",
89 "*.p12",
90 "*.pfx",
91 "*.jks",
92 "*.keystore",
93 "*.p8",
94 "*.ppk",
95 "*.asc",
96 "*.gpg",
97];
98
99pub const DEFAULT_KEEP: &[&str] = &[
104 ".env.example",
105 ".env.sample",
106 ".env.template",
107 ".env.dist",
108 ".env.defaults",
109];
110
111#[derive(Debug, Clone, Default)]
113pub struct RuleOverrides {
114 pub secret_globs: Vec<String>,
116 pub cache_dirs: Vec<String>,
118 pub keep: Vec<String>,
120}
121
122impl RuleOverrides {
123 pub fn is_empty(&self) -> bool {
125 self.secret_globs.is_empty() && self.cache_dirs.is_empty() && self.keep.is_empty()
126 }
127}
128
129#[derive(Debug, Clone)]
131pub struct PackRules {
132 secret: Vec<Pattern>,
133 keep: Vec<Pattern>,
134 cache_dirs: Vec<String>,
135 pub custom_secret_count: usize,
137 pub custom_keep_count: usize,
139 pub custom_cache_count: usize,
141}
142
143impl Default for PackRules {
144 fn default() -> Self {
145 Self::new(&RuleOverrides::default()).expect("built-in globs must compile")
148 }
149}
150
151impl PackRules {
152 pub fn new(overrides: &RuleOverrides) -> Result<Self, PackError> {
168 let mut secret = compile_builtin(DEFAULT_SECRET_GLOBS);
169 for raw in &overrides.secret_globs {
170 secret.push(compile_custom(raw)?);
171 }
172
173 let mut keep = compile_builtin(DEFAULT_KEEP);
174 for raw in &overrides.keep {
175 keep.push(compile_custom(raw)?);
176 }
177
178 let mut cache_dirs: Vec<String> = DEFAULT_CACHE_DIRS
179 .iter()
180 .map(|s| (*s).to_string())
181 .collect();
182 cache_dirs.extend(overrides.cache_dirs.iter().cloned());
183
184 Ok(Self {
185 secret,
186 keep,
187 cache_dirs,
188 custom_secret_count: overrides.secret_globs.len(),
189 custom_keep_count: overrides.keep.len(),
190 custom_cache_count: overrides.cache_dirs.len(),
191 })
192 }
193
194 pub fn is_cache_dir(&self, name: &str) -> bool {
196 if self.is_kept(name) {
198 return false;
199 }
200 self.cache_dirs.iter().any(|d| d == name)
201 }
202
203 pub fn secret_reason(&self, name: &str) -> Option<String> {
213 if self.is_kept(name) {
214 return None;
215 }
216 let matched = self.secret.iter().find(|p| p.matches(name))?;
217 Some(format!("secret pattern: {}", matched.as_str()))
218 }
219
220 fn is_kept(&self, name: &str) -> bool {
222 self.keep.iter().any(|p| p.matches(name))
223 }
224
225 pub fn secret_pattern_count(&self) -> usize {
227 self.secret.len()
228 }
229
230 pub fn is_customized(&self) -> bool {
232 self.custom_secret_count + self.custom_keep_count + self.custom_cache_count > 0
233 }
234}
235
236fn compile_builtin(raw: &[&str]) -> Vec<Pattern> {
238 raw.iter()
239 .filter_map(|p| Pattern::new(p).ok())
240 .collect::<Vec<_>>()
241}
242
243fn compile_custom(raw: &str) -> Result<Pattern, PackError> {
245 Pattern::new(raw).map_err(|e| PackError::BadPattern {
246 pattern: raw.to_string(),
247 message: e.to_string(),
248 })
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 #[test]
261 fn test_all_builtin_globs_compile() {
262 for raw in DEFAULT_SECRET_GLOBS.iter().chain(DEFAULT_KEEP.iter()) {
263 assert!(
264 Pattern::new(raw).is_ok(),
265 "built-in glob is malformed: {raw}"
266 );
267 }
268 let rules = PackRules::new(&RuleOverrides::default()).expect("defaults compile");
269 assert_eq!(rules.secret_pattern_count(), DEFAULT_SECRET_GLOBS.len());
270 }
271
272 #[test]
274 fn test_builtin_covers_common_secret_names() {
275 let r = PackRules::default();
276 for name in [
277 ".env",
278 ".env.production",
279 "secret.toml",
280 "secrets.yaml",
281 "secrets.json",
282 "credentials.toml",
283 "terraform.tfvars",
284 "prod.auto.tfvars",
285 "service-account-prod.json",
286 "kubeconfig",
287 ".pgpass",
288 ".pypirc",
289 "id_ed25519",
290 "server.pem",
291 "signing.p8",
292 "putty.ppk",
293 "key.asc",
294 ] {
295 assert!(
296 r.secret_reason(name).is_some(),
297 "{name} should be treated as a secret"
298 );
299 }
300 }
301
302 #[test]
304 fn test_builtin_passes_ordinary_files() {
305 let r = PackRules::default();
306 for name in [
307 "main.rs",
308 "README.md",
309 ".mcp.json",
310 "Cargo.toml",
311 "id_rsa.pub",
312 ] {
313 assert!(r.secret_reason(name).is_none(), "{name} must travel");
314 }
315 }
316
317 #[test]
319 fn test_builtin_keep_rescues_templates() {
320 let r = PackRules::default();
321 for name in [".env.example", ".env.sample", ".env.template", ".env.dist"] {
322 assert!(r.secret_reason(name).is_none(), "{name} is a template");
323 }
324 assert!(r.secret_reason(".env.local").is_some());
325 }
326
327 #[test]
329 fn test_builtin_cache_dirs() {
330 let r = PackRules::default();
331 assert!(r.is_cache_dir("target"));
332 assert!(r.is_cache_dir("node_modules"));
333 assert!(!r.is_cache_dir("src"));
334 assert!(!r.is_cache_dir("dist"), "dist is source in many projects");
335 }
336
337 #[test]
343 fn test_custom_secret_glob_adds_without_replacing() {
344 let r = PackRules::new(&RuleOverrides {
345 secret_globs: vec!["my-app-keys.json".to_string(), "*.vault".to_string()],
346 ..Default::default()
347 })
348 .expect("compile");
349
350 assert!(r.secret_reason("my-app-keys.json").is_some());
351 assert!(r.secret_reason("prod.vault").is_some());
352 assert!(r.secret_reason(".env").is_some());
354 assert!(r.secret_reason("secret.toml").is_some());
355 assert_eq!(r.custom_secret_count, 2);
356 assert!(r.is_customized());
357 }
358
359 #[test]
361 fn test_keep_overrides_builtin_secret() {
362 let r = PackRules::new(&RuleOverrides {
363 keep: vec![".npmrc".to_string()],
364 ..Default::default()
365 })
366 .expect("compile");
367
368 assert!(
369 r.secret_reason(".npmrc").is_none(),
370 "keep must override the built-in secret rule"
371 );
372 assert!(r.secret_reason(".netrc").is_some(), "siblings unaffected");
373 }
374
375 #[test]
377 fn test_keep_overrides_cache_dir() {
378 let r = PackRules::new(&RuleOverrides {
379 keep: vec!["target".to_string()],
380 ..Default::default()
381 })
382 .expect("compile");
383 assert!(!r.is_cache_dir("target"));
384 }
385
386 #[test]
388 fn test_custom_cache_dir() {
389 let r = PackRules::new(&RuleOverrides {
390 cache_dirs: vec!["dist".to_string(), "build".to_string()],
391 ..Default::default()
392 })
393 .expect("compile");
394 assert!(r.is_cache_dir("dist"));
395 assert!(r.is_cache_dir("build"));
396 assert!(r.is_cache_dir("target"), "built-ins remain");
397 assert_eq!(r.custom_cache_count, 2);
398 }
399
400 #[test]
402 fn test_malformed_custom_glob_is_reported() {
403 let err = PackRules::new(&RuleOverrides {
404 secret_globs: vec!["broken[".to_string()],
405 ..Default::default()
406 })
407 .expect_err("malformed glob must fail");
408
409 match err {
410 PackError::BadPattern { pattern, .. } => assert_eq!(pattern, "broken["),
411 other => panic!("expected BadPattern, got {other:?}"),
412 }
413 }
414
415 #[test]
418 fn test_reason_names_the_matching_pattern() {
419 let r = PackRules::new(&RuleOverrides {
420 secret_globs: vec!["*.vault".to_string()],
421 ..Default::default()
422 })
423 .expect("compile");
424 assert_eq!(
425 r.secret_reason("prod.vault").as_deref(),
426 Some("secret pattern: *.vault")
427 );
428 }
429
430 #[test]
432 fn test_empty_overrides_are_defaults() {
433 let o = RuleOverrides::default();
434 assert!(o.is_empty());
435 let r = PackRules::new(&o).expect("compile");
436 assert!(!r.is_customized());
437 }
438}