Skip to main content

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}