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   = ["dist"]
15//! keep         = [".npmrc"]
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
23use glob::Pattern;
24
25use crate::error::PackError;
26
27/// Directory names treated as regenerable caches.
28///
29/// `dist` and `build` are deliberately absent: both are common names for
30/// hand-written source in projects that do not use them as output directories,
31/// and wrongly dropping source is far worse than carrying a rebuildable tree.
32/// A project that does use them as output can add them via `[pack] cache_dirs`.
33pub 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
49/// File-name globs treated as secrets.
50///
51/// Grouped by what they are rather than alphabetically, so a gap is visible as
52/// a missing group rather than a missing line.
53pub const DEFAULT_SECRET_GLOBS: &[&str] = &[
54    // dotenv and friends — `.env.example` and co. are rescued by DEFAULT_KEEP
55    ".env",
56    ".env.*",
57    // per-tool credential files
58    ".netrc",
59    ".npmrc",
60    ".pypirc",
61    ".dockercfg",
62    ".pgpass",
63    ".my.cnf",
64    ".htpasswd",
65    "credentials",
66    "credentials.toml",
67    // generically named secret bundles
68    "secret.toml",
69    "secrets.toml",
70    "secret.yaml",
71    "secrets.yaml",
72    "secret.yml",
73    "secrets.yml",
74    "secret.json",
75    "secrets.json",
76    // cloud / infra
77    "service-account*.json",
78    "terraform.tfvars",
79    "*.auto.tfvars",
80    "kubeconfig",
81    // ssh private keys (the `.pub` counterparts are public and travel)
82    "id_rsa",
83    "id_dsa",
84    "id_ecdsa",
85    "id_ed25519",
86    // key / certificate containers
87    "*.pem",
88    "*.key",
89    "*.p12",
90    "*.pfx",
91    "*.jks",
92    "*.keystore",
93    "*.p8",
94    "*.ppk",
95    "*.asc",
96    "*.gpg",
97];
98
99/// File-name globs packed despite matching a secret rule.
100///
101/// These are the checked-in templates that exist precisely to be shared; they
102/// match `.env.*` but hold placeholders, not credentials.
103pub const DEFAULT_KEEP: &[&str] = &[
104    ".env.example",
105    ".env.sample",
106    ".env.template",
107    ".env.dist",
108    ".env.defaults",
109];
110
111/// Operator-supplied additions read from `[pack]` in `config.toml`.
112#[derive(Debug, Clone, Default)]
113pub struct RuleOverrides {
114    /// Extra secret globs, added to [`DEFAULT_SECRET_GLOBS`].
115    pub secret_globs: Vec<String>,
116    /// Extra cache directory names, added to [`DEFAULT_CACHE_DIRS`].
117    pub cache_dirs: Vec<String>,
118    /// Globs packed anyway, added to [`DEFAULT_KEEP`].
119    pub keep: Vec<String>,
120}
121
122impl RuleOverrides {
123    /// Whether the operator supplied anything at all.
124    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/// Compiled classification rules used by the scan.
130#[derive(Debug, Clone)]
131pub struct PackRules {
132    secret: Vec<Pattern>,
133    keep: Vec<Pattern>,
134    cache_dirs: Vec<String>,
135    /// How many of the compiled patterns came from the operator, for reporting.
136    pub custom_secret_count: usize,
137    /// How many keep patterns came from the operator, for reporting.
138    pub custom_keep_count: usize,
139    /// How many cache directory names came from the operator, for reporting.
140    pub custom_cache_count: usize,
141}
142
143impl Default for PackRules {
144    fn default() -> Self {
145        // Compiling the built-in globs cannot fail; they are literals in this
146        // file and are covered by a test that compiles every one of them.
147        Self::new(&RuleOverrides::default()).expect("built-in globs must compile")
148    }
149}
150
151impl PackRules {
152    /// Compile the built-in rules plus the operator's additions.
153    ///
154    /// # Arguments
155    ///
156    /// * `overrides` — Extra globs and directory names from `[pack]`.
157    ///
158    /// # Returns
159    ///
160    /// Rules ready to classify file names.
161    ///
162    /// # Errors
163    ///
164    /// [`PackError::BadPattern`] when an operator-supplied glob is malformed,
165    /// naming the offending pattern. A typo in config must fail loudly rather
166    /// than silently classifying nothing.
167    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    /// Whether a directory name is a regenerable cache.
195    pub fn is_cache_dir(&self, name: &str) -> bool {
196        // `keep` outranks every exclusion, caches included.
197        if self.is_kept(name) {
198            return false;
199        }
200        self.cache_dirs.iter().any(|d| d == name)
201    }
202
203    /// Whether a file name must be treated as a secret, and which rule said so.
204    ///
205    /// # Arguments
206    ///
207    /// * `name` — File name, not a path.
208    ///
209    /// # Returns
210    ///
211    /// `Some(reason)` naming the matched glob when the file must not be packed.
212    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    /// Whether a name is explicitly kept despite matching an exclusion.
221    fn is_kept(&self, name: &str) -> bool {
222        self.keep.iter().any(|p| p.matches(name))
223    }
224
225    /// Total number of secret patterns in force.
226    pub fn secret_pattern_count(&self) -> usize {
227        self.secret.len()
228    }
229
230    /// Whether the operator customized anything.
231    pub fn is_customized(&self) -> bool {
232        self.custom_secret_count + self.custom_keep_count + self.custom_cache_count > 0
233    }
234}
235
236/// Compile built-in literals, which are known-good at authoring time.
237fn compile_builtin(raw: &[&str]) -> Vec<Pattern> {
238    raw.iter()
239        .filter_map(|p| Pattern::new(p).ok())
240        .collect::<Vec<_>>()
241}
242
243/// Compile an operator-supplied glob, reporting the pattern on failure.
244fn 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    // ------------------------------------------------------------------
256    // built-in coverage
257    // ------------------------------------------------------------------
258
259    /// Every built-in glob compiles — `PackRules::default` relies on this.
260    #[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    /// The names that motivated making this configurable are covered by default.
273    #[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    /// Ordinary project files are not secrets.
303    #[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    /// Templates are rescued from the `.env.*` rule by the built-in keep list.
318    #[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    /// Built-in cache directories are recognized.
328    #[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    // ------------------------------------------------------------------
338    // operator overrides
339    // ------------------------------------------------------------------
340
341    /// A project-specific secret name can be added without losing the built-ins.
342    #[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        // built-ins still in force
353        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    /// `keep` subtracts: a built-in exclusion can be overridden per project.
360    #[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    /// `keep` also outranks the cache rule.
376    #[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    /// Extra cache directories are honored.
387    #[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    /// A malformed operator glob fails loudly and names itself.
401    #[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    /// The reason string names the glob that matched, so a surprising exclusion
416    /// can be traced back to the rule responsible for it.
417    #[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    /// An empty override set leaves the defaults untouched.
431    #[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}