lds_pack/rules.rs
1//! Classification rules: which file names are secrets, which directories are
2//! caches, and which of those the operator wants carried anyway.
3//!
4//! The built-in lists are the floor, not the ceiling. Secret file names are
5//! open-ended — every ecosystem invents its own (`credentials.toml`,
6//! `terraform.tfvars`, `service-account.json`), and a project can always have
7//! one nobody has heard of (`my-app-keys.json`). A fixed list is therefore
8//! guaranteed to be incomplete, so operators can extend it via
9//! `~/.config/lds/config.toml`:
10//!
11//! ```toml
12//! [pack]
13//! secret_globs = ["my-app-keys.json", "*.vault"]
14//! cache_dirs = ["frontend/dist"]
15//! keep = ["docs/samples/*.pem"]
16//! ```
17//!
18//! Extensions **add to** the built-ins rather than replacing them, so declaring
19//! one project-specific name cannot silently disable the rest of the
20//! protection. `keep` is the only subtractive list: it names files a built-in
21//! rule would exclude but that this project wants packed.
22//!
23//! # Scoping
24//!
25//! Every list here follows the convention `.gitignore` already established, so
26//! there is no second one to learn:
27//!
28//! | glob | matched against |
29//! |---|---|
30//! | no `/` (`*.pem`, `.env`) | the **file name**, at any depth |
31//! | contains `/` (`docs/samples/*.pem`) | the **path relative to the project root** |
32//!
33//! Every built-in is a bare name, so all of them keep reaching the whole tree.
34//! Scoping exists for the operator's own rules, where reaching the whole tree
35//! is the hazard: `keep = ["*.pem"]` written to carry one sample key carries
36//! every private key in the project, and `cache_dirs = ["dist"]` written for a
37//! build output drops any hand-written `dist/` that happens to share the name.
38//! Anchoring the rule to a path confines it to the case it was written for.
39
40use glob::Pattern;
41
42use crate::error::PackError;
43
44/// Directory names treated as regenerable caches.
45///
46/// `dist` and `build` are deliberately absent: both are common names for
47/// hand-written source in projects that do not use them as output directories,
48/// and wrongly dropping source is far worse than carrying a rebuildable tree.
49/// A project that does use them as output can add them via `[pack] cache_dirs`.
50pub const DEFAULT_CACHE_DIRS: &[&str] = &[
51 "target",
52 "node_modules",
53 ".venv",
54 "venv",
55 "__pycache__",
56 ".pytest_cache",
57 ".mypy_cache",
58 ".ruff_cache",
59 ".turbo",
60 ".next",
61 ".nuxt",
62 ".parcel-cache",
63 ".gradle",
64];
65
66/// File-name globs treated as secrets.
67///
68/// Grouped by what they are rather than alphabetically, so a gap is visible as
69/// a missing group rather than a missing line.
70pub const DEFAULT_SECRET_GLOBS: &[&str] = &[
71 // dotenv and friends — `.env.example` and co. are rescued by DEFAULT_KEEP
72 ".env",
73 ".env.*",
74 // per-tool credential files
75 ".netrc",
76 ".npmrc",
77 ".pypirc",
78 ".dockercfg",
79 ".pgpass",
80 ".my.cnf",
81 ".htpasswd",
82 "credentials",
83 "credentials.toml",
84 // generically named secret bundles
85 "secret.toml",
86 "secrets.toml",
87 "secret.yaml",
88 "secrets.yaml",
89 "secret.yml",
90 "secrets.yml",
91 "secret.json",
92 "secrets.json",
93 // cloud / infra
94 "service-account*.json",
95 "terraform.tfvars",
96 "*.auto.tfvars",
97 "kubeconfig",
98 // ssh private keys (the `.pub` counterparts are public and travel)
99 "id_rsa",
100 "id_dsa",
101 "id_ecdsa",
102 "id_ed25519",
103 // key / certificate containers
104 "*.pem",
105 "*.key",
106 "*.p12",
107 "*.pfx",
108 "*.jks",
109 "*.keystore",
110 "*.p8",
111 "*.ppk",
112 "*.asc",
113 "*.gpg",
114];
115
116/// File-name globs packed despite matching a secret rule.
117///
118/// These are the checked-in templates that exist precisely to be shared; they
119/// match `.env.*` but hold placeholders, not credentials.
120pub const DEFAULT_KEEP: &[&str] = &[
121 ".env.example",
122 ".env.sample",
123 ".env.template",
124 ".env.dist",
125 ".env.defaults",
126];
127
128/// Operator-supplied additions read from `[pack]` in `config.toml`.
129#[derive(Debug, Clone, Default)]
130pub struct RuleOverrides {
131 /// Extra secret globs, added to [`DEFAULT_SECRET_GLOBS`].
132 pub secret_globs: Vec<String>,
133 /// Extra cache directory names, added to [`DEFAULT_CACHE_DIRS`].
134 pub cache_dirs: Vec<String>,
135 /// Globs packed anyway, added to [`DEFAULT_KEEP`].
136 pub keep: Vec<String>,
137 /// Path globs whose symlinks are packed but left out of the link report.
138 ///
139 /// A symlink is a problem by default — it breaks when the project is
140 /// carried elsewhere — so every one is reported. This names the exception:
141 /// a directory that is *meant* to be links, such as a shared `.zsh/` tree.
142 /// Those are already known to the operator, so listing them is noise that
143 /// hides the links that do need attention.
144 ///
145 /// **No built-in counterpart**, deliberately: only the operator knows which
146 /// of their directories are link-by-design. Unset means every symlink is
147 /// reported.
148 pub no_link_report: Vec<String>,
149}
150
151impl RuleOverrides {
152 /// Whether the operator supplied anything at all.
153 pub fn is_empty(&self) -> bool {
154 self.secret_globs.is_empty()
155 && self.cache_dirs.is_empty()
156 && self.keep.is_empty()
157 && self.no_link_report.is_empty()
158 }
159}
160
161/// What the classification rules decided about one file name.
162///
163/// Spelled out as three cases rather than an `Option`, because the third one —
164/// a `keep` rule overriding a secret rule — used to be indistinguishable from
165/// "no rule matched". A file carried past the secret list is exactly the file a
166/// reader has to know about.
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub enum FileVerdict {
169 /// No rule applies. Packed as ordinary content.
170 Ordinary,
171 /// A secret rule matched and nothing overrode it. Not packed, reported.
172 Secret {
173 /// The secret glob that matched.
174 pattern: String,
175 },
176 /// A secret rule matched but a `keep` rule outranked it, so the file *is*
177 /// packed.
178 ///
179 /// The operator asked for this, so it is not an error and not a warning —
180 /// but the entire purpose of the secret list is that these files are
181 /// dangerous to carry, so the override is recorded instead of applied
182 /// silently.
183 KeptOverSecret {
184 /// The `keep` glob that rescued the file.
185 keep_pattern: String,
186 /// The secret glob it outranked.
187 secret_pattern: String,
188 },
189}
190
191/// What a compiled glob is matched against.
192///
193/// Decided from the glob itself, by the same convention `.gitignore` uses, so
194/// an operator does not have to learn a second one.
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196enum Scope {
197 /// No `/` in the glob: matched against the file name, at any depth.
198 Name,
199 /// The glob contains `/`: matched against the path relative to the project
200 /// root, so the rule reaches exactly one place in the tree.
201 Path,
202}
203
204/// One compiled classification glob and what it is matched against.
205#[derive(Debug, Clone)]
206struct Rule {
207 pattern: Pattern,
208 scope: Scope,
209}
210
211impl Rule {
212 /// Compile `raw`, taking its scope from whether it contains a separator.
213 ///
214 /// # Errors
215 ///
216 /// [`PackError::BadPattern`] when the glob is malformed.
217 fn compile(raw: &str) -> Result<Self, PackError> {
218 Ok(Self {
219 pattern: compile_custom(raw)?,
220 scope: if raw.contains('/') {
221 Scope::Path
222 } else {
223 Scope::Name
224 },
225 })
226 }
227
228 /// Compile a built-in literal, which is known-good at authoring time.
229 fn builtin(raw: &str) -> Option<Self> {
230 Self::compile(raw).ok()
231 }
232
233 /// # Arguments
234 ///
235 /// * `name` — File or directory name, no separators.
236 /// * `rel` — Path relative to the project root, `/`-separated.
237 fn matches(&self, name: &str, rel: &str) -> bool {
238 match self.scope {
239 Scope::Name => self.pattern.matches(name),
240 Scope::Path => self.pattern.matches(rel),
241 }
242 }
243
244 /// The glob as the operator wrote it.
245 fn as_str(&self) -> &str {
246 self.pattern.as_str()
247 }
248}
249
250/// Compiled classification rules used by the scan.
251#[derive(Debug, Clone)]
252pub struct PackRules {
253 secret: Vec<Rule>,
254 keep: Vec<Rule>,
255 cache_dirs: Vec<Rule>,
256 no_link_report: Vec<Rule>,
257 /// How many of the compiled patterns came from the operator, for reporting.
258 pub custom_secret_count: usize,
259 /// How many keep patterns came from the operator, for reporting.
260 pub custom_keep_count: usize,
261 /// How many cache directory names came from the operator, for reporting.
262 pub custom_cache_count: usize,
263}
264
265impl Default for PackRules {
266 fn default() -> Self {
267 // Compiling the built-in globs cannot fail; they are literals in this
268 // file and are covered by a test that compiles every one of them.
269 Self::new(&RuleOverrides::default()).expect("built-in globs must compile")
270 }
271}
272
273impl PackRules {
274 /// Compile the built-in rules plus the operator's additions.
275 ///
276 /// # Arguments
277 ///
278 /// * `overrides` — Extra globs and directory names from `[pack]`.
279 ///
280 /// # Returns
281 ///
282 /// Rules ready to classify paths.
283 ///
284 /// # Errors
285 ///
286 /// [`PackError::BadPattern`] when an operator-supplied glob is malformed,
287 /// naming the offending pattern. A typo in config must fail loudly rather
288 /// than silently classifying nothing.
289 pub fn new(overrides: &RuleOverrides) -> Result<Self, PackError> {
290 let mut secret = compile_builtin(DEFAULT_SECRET_GLOBS);
291 for raw in &overrides.secret_globs {
292 secret.push(Rule::compile(raw)?);
293 }
294
295 let mut keep = compile_builtin(DEFAULT_KEEP);
296 for raw in &overrides.keep {
297 keep.push(Rule::compile(raw)?);
298 }
299
300 let mut cache_dirs = compile_builtin(DEFAULT_CACHE_DIRS);
301 for raw in &overrides.cache_dirs {
302 cache_dirs.push(Rule::compile(raw)?);
303 }
304
305 // No built-in list to seed from: see `RuleOverrides::no_link_report`.
306 let mut no_link_report = Vec::new();
307 for raw in &overrides.no_link_report {
308 no_link_report.push(Rule::compile(raw)?);
309 }
310
311 Ok(Self {
312 secret,
313 keep,
314 cache_dirs,
315 no_link_report,
316 custom_secret_count: overrides.secret_globs.len(),
317 custom_keep_count: overrides.keep.len(),
318 custom_cache_count: overrides.cache_dirs.len(),
319 })
320 }
321
322 /// The `no_link_report` glob that covers this path, if the operator
323 /// declared one.
324 ///
325 /// # Arguments
326 ///
327 /// * `name` — Link name, no separators.
328 /// * `rel` — Path relative to the project root, `/`-separated.
329 ///
330 /// # Returns
331 ///
332 /// The glob exactly as configured, so a caller can record which rule
333 /// suppressed the report rather than only that something did.
334 ///
335 /// Always `None` when the operator configured nothing, which is the point:
336 /// this crate does not decide on its own that some directory's links are
337 /// expected.
338 pub fn no_link_report_match(&self, name: &str, rel: &str) -> Option<&str> {
339 self.no_link_report
340 .iter()
341 .find(|r| r.matches(name, rel))
342 .map(|r| r.as_str())
343 }
344
345 /// Whether a directory is a regenerable cache.
346 ///
347 /// # Arguments
348 ///
349 /// * `name` — Directory name, no separators.
350 /// * `rel` — Path relative to the project root, `/`-separated.
351 pub fn is_cache_dir(&self, name: &str, rel: &str) -> bool {
352 // `keep` outranks every exclusion, caches included.
353 if self.keep_match(name, rel).is_some() {
354 return false;
355 }
356 self.cache_dirs.iter().any(|r| r.matches(name, rel))
357 }
358
359 /// Classify a file against the secret and `keep` lists.
360 ///
361 /// # Arguments
362 ///
363 /// * `name` — File name, no separators.
364 /// * `rel` — Path relative to the project root, `/`-separated.
365 ///
366 /// # Returns
367 ///
368 /// Which of the three outcomes applies, naming every glob involved. A
369 /// `keep` rule outranking a secret rule yields
370 /// [`FileVerdict::KeptOverSecret`] rather than [`FileVerdict::Ordinary`],
371 /// so the caller can record that the file was carried past the secret list
372 /// on purpose.
373 pub fn classify(&self, name: &str, rel: &str) -> FileVerdict {
374 let Some(secret) = self.secret.iter().find(|r| r.matches(name, rel)) else {
375 return FileVerdict::Ordinary;
376 };
377 match self.keep_match(name, rel) {
378 Some(keep_pattern) => FileVerdict::KeptOverSecret {
379 keep_pattern: keep_pattern.to_string(),
380 secret_pattern: secret.as_str().to_string(),
381 },
382 None => FileVerdict::Secret {
383 pattern: secret.as_str().to_string(),
384 },
385 }
386 }
387
388 /// The `keep` glob that rescues this path from an exclusion, if any.
389 fn keep_match(&self, name: &str, rel: &str) -> Option<&str> {
390 self.keep
391 .iter()
392 .find(|r| r.matches(name, rel))
393 .map(|r| r.as_str())
394 }
395
396 /// Total number of secret patterns in force.
397 pub fn secret_pattern_count(&self) -> usize {
398 self.secret.len()
399 }
400
401 /// Whether the operator customized anything.
402 pub fn is_customized(&self) -> bool {
403 self.custom_secret_count + self.custom_keep_count + self.custom_cache_count > 0
404 }
405}
406
407/// Compile built-in literals, which are known-good at authoring time.
408fn compile_builtin(raw: &[&str]) -> Vec<Rule> {
409 raw.iter().filter_map(|p| Rule::builtin(p)).collect()
410}
411
412/// Compile an operator-supplied glob, reporting the pattern on failure.
413fn compile_custom(raw: &str) -> Result<Pattern, PackError> {
414 Pattern::new(raw).map_err(|e| PackError::BadPattern {
415 pattern: raw.to_string(),
416 message: e.to_string(),
417 })
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423
424 /// Whether a name is excluded as a secret — the outcome most of these
425 /// tests are about. A `keep` rule rescuing the file is a *different*
426 /// verdict and is asserted on directly where it matters.
427 ///
428 /// Passing the name as the path too puts the file at the project root,
429 /// where the two coincide. Path scoping is exercised separately.
430 fn is_secret(r: &PackRules, name: &str) -> bool {
431 matches!(r.classify(name, name), FileVerdict::Secret { .. })
432 }
433
434 /// A directory at the project root, where its name and path coincide.
435 fn is_cache(r: &PackRules, name: &str) -> bool {
436 r.is_cache_dir(name, name)
437 }
438
439 // ------------------------------------------------------------------
440 // built-in coverage
441 // ------------------------------------------------------------------
442
443 /// Every built-in glob compiles — `PackRules::default` relies on this.
444 #[test]
445 fn test_all_builtin_globs_compile() {
446 for raw in DEFAULT_SECRET_GLOBS.iter().chain(DEFAULT_KEEP.iter()) {
447 assert!(
448 Pattern::new(raw).is_ok(),
449 "built-in glob is malformed: {raw}"
450 );
451 }
452 let rules = PackRules::new(&RuleOverrides::default()).expect("defaults compile");
453 assert_eq!(rules.secret_pattern_count(), DEFAULT_SECRET_GLOBS.len());
454 }
455
456 /// The names that motivated making this configurable are covered by default.
457 #[test]
458 fn test_builtin_covers_common_secret_names() {
459 let r = PackRules::default();
460 for name in [
461 ".env",
462 ".env.production",
463 "secret.toml",
464 "secrets.yaml",
465 "secrets.json",
466 "credentials.toml",
467 "terraform.tfvars",
468 "prod.auto.tfvars",
469 "service-account-prod.json",
470 "kubeconfig",
471 ".pgpass",
472 ".pypirc",
473 "id_ed25519",
474 "server.pem",
475 "signing.p8",
476 "putty.ppk",
477 "key.asc",
478 ] {
479 assert!(is_secret(&r, name), "{name} should be treated as a secret");
480 }
481 }
482
483 /// Ordinary project files are not secrets.
484 #[test]
485 fn test_builtin_passes_ordinary_files() {
486 let r = PackRules::default();
487 for name in [
488 "main.rs",
489 "README.md",
490 ".mcp.json",
491 "Cargo.toml",
492 "id_rsa.pub",
493 ] {
494 assert!(!is_secret(&r, name), "{name} must travel");
495 }
496 }
497
498 /// Templates are rescued from the `.env.*` rule by the built-in keep list.
499 #[test]
500 fn test_builtin_keep_rescues_templates() {
501 let r = PackRules::default();
502 for name in [".env.example", ".env.sample", ".env.template", ".env.dist"] {
503 assert!(!is_secret(&r, name), "{name} is a template");
504 }
505 assert!(is_secret(&r, ".env.local"));
506 }
507
508 /// Built-in cache directories are recognized.
509 #[test]
510 fn test_builtin_cache_dirs() {
511 let r = PackRules::default();
512 assert!(is_cache(&r, "target"));
513 assert!(is_cache(&r, "node_modules"));
514 assert!(!is_cache(&r, "src"));
515 assert!(!is_cache(&r, "dist"), "dist is source in many projects");
516 }
517
518 // ------------------------------------------------------------------
519 // operator overrides
520 // ------------------------------------------------------------------
521
522 /// A project-specific secret name can be added without losing the built-ins.
523 #[test]
524 fn test_custom_secret_glob_adds_without_replacing() {
525 let r = PackRules::new(&RuleOverrides {
526 secret_globs: vec!["my-app-keys.json".to_string(), "*.vault".to_string()],
527 ..Default::default()
528 })
529 .expect("compile");
530
531 assert!(is_secret(&r, "my-app-keys.json"));
532 assert!(is_secret(&r, "prod.vault"));
533 // built-ins still in force
534 assert!(is_secret(&r, ".env"));
535 assert!(is_secret(&r, "secret.toml"));
536 assert_eq!(r.custom_secret_count, 2);
537 assert!(r.is_customized());
538 }
539
540 /// `keep` subtracts: a built-in exclusion can be overridden per project.
541 #[test]
542 fn test_keep_overrides_builtin_secret() {
543 let r = PackRules::new(&RuleOverrides {
544 keep: vec![".npmrc".to_string()],
545 ..Default::default()
546 })
547 .expect("compile");
548
549 assert!(
550 !is_secret(&r, ".npmrc"),
551 "keep must override the built-in secret rule"
552 );
553 assert!(is_secret(&r, ".netrc"), "siblings unaffected");
554 }
555
556 /// `keep` also outranks the cache rule.
557 #[test]
558 fn test_keep_overrides_cache_dir() {
559 let r = PackRules::new(&RuleOverrides {
560 keep: vec!["target".to_string()],
561 ..Default::default()
562 })
563 .expect("compile");
564 assert!(!is_cache(&r, "target"));
565 }
566
567 /// Extra cache directories are honored.
568 #[test]
569 fn test_custom_cache_dir() {
570 let r = PackRules::new(&RuleOverrides {
571 cache_dirs: vec!["dist".to_string(), "build".to_string()],
572 ..Default::default()
573 })
574 .expect("compile");
575 assert!(is_cache(&r, "dist"));
576 assert!(is_cache(&r, "build"));
577 assert!(is_cache(&r, "target"), "built-ins remain");
578 assert_eq!(r.custom_cache_count, 2);
579 }
580
581 /// A malformed operator glob fails loudly and names itself.
582 #[test]
583 fn test_malformed_custom_glob_is_reported() {
584 let err = PackRules::new(&RuleOverrides {
585 secret_globs: vec!["broken[".to_string()],
586 ..Default::default()
587 })
588 .expect_err("malformed glob must fail");
589
590 match err {
591 PackError::BadPattern { pattern, .. } => assert_eq!(pattern, "broken["),
592 other => panic!("expected BadPattern, got {other:?}"),
593 }
594 }
595
596 /// The reason string names the glob that matched, so a surprising exclusion
597 /// can be traced back to the rule responsible for it.
598 #[test]
599 fn test_reason_names_the_matching_pattern() {
600 let r = PackRules::new(&RuleOverrides {
601 secret_globs: vec!["*.vault".to_string()],
602 ..Default::default()
603 })
604 .expect("compile");
605 assert_eq!(
606 r.classify("prod.vault", "prod.vault"),
607 FileVerdict::Secret {
608 pattern: "*.vault".to_string()
609 }
610 );
611 }
612
613 /// An empty override set leaves the defaults untouched.
614 #[test]
615 fn test_empty_overrides_are_defaults() {
616 let o = RuleOverrides::default();
617 assert!(o.is_empty());
618 let r = PackRules::new(&o).expect("compile");
619 assert!(!r.is_customized());
620 }
621
622 // ------------------------------------------------------------------
623 // scoping: a glob with `/` names a path, one without names a file
624 // ------------------------------------------------------------------
625
626 /// A glob with no separator keeps matching at any depth, which is what
627 /// every built-in rule relies on.
628 #[test]
629 fn test_name_glob_matches_at_any_depth() {
630 let r = PackRules::default();
631 assert!(matches!(
632 r.classify("key.pem", "deep/nested/key.pem"),
633 FileVerdict::Secret { .. }
634 ));
635 assert!(matches!(
636 r.classify(".env", "services/api/.env"),
637 FileVerdict::Secret { .. }
638 ));
639 }
640
641 /// A glob with a separator is anchored to that path and nowhere else.
642 #[test]
643 fn test_path_glob_matches_only_its_own_path() {
644 let r = PackRules::new(&RuleOverrides {
645 secret_globs: vec!["deploy/*.token".to_string()],
646 ..Default::default()
647 })
648 .expect("compile");
649
650 assert!(matches!(
651 r.classify("prod.token", "deploy/prod.token"),
652 FileVerdict::Secret { .. }
653 ));
654 assert!(
655 matches!(
656 r.classify("prod.token", "docs/prod.token"),
657 FileVerdict::Ordinary
658 ),
659 "a path-scoped rule must not reach outside its path"
660 );
661 }
662
663 /// A path-scoped `keep` rescues its own directory without opening the rule
664 /// up everywhere — the reason scoping was worth having.
665 #[test]
666 fn test_path_scoped_keep_rescues_only_there() {
667 let r = PackRules::new(&RuleOverrides {
668 keep: vec!["docs/samples/*.pem".to_string()],
669 ..Default::default()
670 })
671 .expect("compile");
672
673 assert!(
674 matches!(
675 r.classify("demo.pem", "docs/samples/demo.pem"),
676 FileVerdict::KeptOverSecret { .. }
677 ),
678 "the sample is carried, and recorded as an override"
679 );
680 assert!(
681 matches!(
682 r.classify("server.pem", "deploy/server.pem"),
683 FileVerdict::Secret { .. }
684 ),
685 "a real key elsewhere stays excluded"
686 );
687 }
688
689 /// A path-scoped cache rule drops one `dist`, not every directory that
690 /// happens to share the name.
691 #[test]
692 fn test_path_scoped_cache_dir_does_not_catch_namesakes() {
693 let r = PackRules::new(&RuleOverrides {
694 cache_dirs: vec!["frontend/dist".to_string()],
695 ..Default::default()
696 })
697 .expect("compile");
698
699 assert!(r.is_cache_dir("dist", "frontend/dist"));
700 assert!(
701 !r.is_cache_dir("dist", "vendor/dist"),
702 "a namesake elsewhere may well be hand-written source"
703 );
704 }
705}