lds-pack 0.15.0

Pack module for local-develop-server (lds) — bundle a whole project (.git plus untracked local assets) into a single portable archive
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
//! Classification rules: which file names are secrets, which directories are
//! caches, and which of those the operator wants carried anyway.
//!
//! The built-in lists are the floor, not the ceiling. Secret file names are
//! open-ended — every ecosystem invents its own (`credentials.toml`,
//! `terraform.tfvars`, `service-account.json`), and a project can always have
//! one nobody has heard of (`my-app-keys.json`). A fixed list is therefore
//! guaranteed to be incomplete, so operators can extend it via
//! `~/.config/lds/config.toml`:
//!
//! ```toml
//! [pack]
//! secret_globs = ["my-app-keys.json", "*.vault"]
//! cache_dirs   = ["frontend/dist"]
//! keep         = ["docs/samples/*.pem"]
//! ```
//!
//! Extensions **add to** the built-ins rather than replacing them, so declaring
//! one project-specific name cannot silently disable the rest of the
//! protection. `keep` is the only subtractive list: it names files a built-in
//! rule would exclude but that this project wants packed.
//!
//! # Scoping
//!
//! Every list here follows the convention `.gitignore` already established, so
//! there is no second one to learn:
//!
//! | glob | matched against |
//! |---|---|
//! | no `/` (`*.pem`, `.env`) | the **file name**, at any depth |
//! | contains `/` (`docs/samples/*.pem`) | the **path relative to the project root** |
//!
//! Every built-in is a bare name, so all of them keep reaching the whole tree.
//! Scoping exists for the operator's own rules, where reaching the whole tree
//! is the hazard: `keep = ["*.pem"]` written to carry one sample key carries
//! every private key in the project, and `cache_dirs = ["dist"]` written for a
//! build output drops any hand-written `dist/` that happens to share the name.
//! Anchoring the rule to a path confines it to the case it was written for.

use glob::Pattern;

use crate::error::PackError;

/// Directory names treated as regenerable caches.
///
/// `dist` and `build` are deliberately absent: both are common names for
/// hand-written source in projects that do not use them as output directories,
/// and wrongly dropping source is far worse than carrying a rebuildable tree.
/// A project that does use them as output can add them via `[pack] cache_dirs`.
pub const DEFAULT_CACHE_DIRS: &[&str] = &[
    "target",
    "node_modules",
    ".venv",
    "venv",
    "__pycache__",
    ".pytest_cache",
    ".mypy_cache",
    ".ruff_cache",
    ".turbo",
    ".next",
    ".nuxt",
    ".parcel-cache",
    ".gradle",
];

/// File-name globs treated as secrets.
///
/// Grouped by what they are rather than alphabetically, so a gap is visible as
/// a missing group rather than a missing line.
pub const DEFAULT_SECRET_GLOBS: &[&str] = &[
    // dotenv and friends — `.env.example` and co. are rescued by DEFAULT_KEEP
    ".env",
    ".env.*",
    // per-tool credential files
    ".netrc",
    ".npmrc",
    ".pypirc",
    ".dockercfg",
    ".pgpass",
    ".my.cnf",
    ".htpasswd",
    "credentials",
    "credentials.toml",
    // generically named secret bundles
    "secret.toml",
    "secrets.toml",
    "secret.yaml",
    "secrets.yaml",
    "secret.yml",
    "secrets.yml",
    "secret.json",
    "secrets.json",
    // cloud / infra
    "service-account*.json",
    "terraform.tfvars",
    "*.auto.tfvars",
    "kubeconfig",
    // ssh private keys (the `.pub` counterparts are public and travel)
    "id_rsa",
    "id_dsa",
    "id_ecdsa",
    "id_ed25519",
    // key / certificate containers
    "*.pem",
    "*.key",
    "*.p12",
    "*.pfx",
    "*.jks",
    "*.keystore",
    "*.p8",
    "*.ppk",
    "*.asc",
    "*.gpg",
];

/// File-name globs packed despite matching a secret rule.
///
/// These are the checked-in templates that exist precisely to be shared; they
/// match `.env.*` but hold placeholders, not credentials.
pub const DEFAULT_KEEP: &[&str] = &[
    ".env.example",
    ".env.sample",
    ".env.template",
    ".env.dist",
    ".env.defaults",
];

/// Operator-supplied additions read from `[pack]` in `config.toml`.
#[derive(Debug, Clone, Default)]
pub struct RuleOverrides {
    /// Extra secret globs, added to [`DEFAULT_SECRET_GLOBS`].
    pub secret_globs: Vec<String>,
    /// Extra cache directory names, added to [`DEFAULT_CACHE_DIRS`].
    pub cache_dirs: Vec<String>,
    /// Globs packed anyway, added to [`DEFAULT_KEEP`].
    pub keep: Vec<String>,
    /// Path globs whose symlinks are packed but left out of the link report.
    ///
    /// A symlink is a problem by default — it breaks when the project is
    /// carried elsewhere — so every one is reported. This names the exception:
    /// a directory that is *meant* to be links, such as a shared `.zsh/` tree.
    /// Those are already known to the operator, so listing them is noise that
    /// hides the links that do need attention.
    ///
    /// **No built-in counterpart**, deliberately: only the operator knows which
    /// of their directories are link-by-design. Unset means every symlink is
    /// reported.
    pub no_link_report: Vec<String>,
}

impl RuleOverrides {
    /// Whether the operator supplied anything at all.
    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()
    }
}

/// What the classification rules decided about one file name.
///
/// Spelled out as three cases rather than an `Option`, because the third one —
/// a `keep` rule overriding a secret rule — used to be indistinguishable from
/// "no rule matched". A file carried past the secret list is exactly the file a
/// reader has to know about.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileVerdict {
    /// No rule applies. Packed as ordinary content.
    Ordinary,
    /// A secret rule matched and nothing overrode it. Not packed, reported.
    Secret {
        /// The secret glob that matched.
        pattern: String,
    },
    /// A secret rule matched but a `keep` rule outranked it, so the file *is*
    /// packed.
    ///
    /// The operator asked for this, so it is not an error and not a warning —
    /// but the entire purpose of the secret list is that these files are
    /// dangerous to carry, so the override is recorded instead of applied
    /// silently.
    KeptOverSecret {
        /// The `keep` glob that rescued the file.
        keep_pattern: String,
        /// The secret glob it outranked.
        secret_pattern: String,
    },
}

/// What a compiled glob is matched against.
///
/// Decided from the glob itself, by the same convention `.gitignore` uses, so
/// an operator does not have to learn a second one.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Scope {
    /// No `/` in the glob: matched against the file name, at any depth.
    Name,
    /// The glob contains `/`: matched against the path relative to the project
    /// root, so the rule reaches exactly one place in the tree.
    Path,
}

/// One compiled classification glob and what it is matched against.
#[derive(Debug, Clone)]
struct Rule {
    pattern: Pattern,
    scope: Scope,
}

impl Rule {
    /// Compile `raw`, taking its scope from whether it contains a separator.
    ///
    /// # Errors
    ///
    /// [`PackError::BadPattern`] when the glob is malformed.
    fn compile(raw: &str) -> Result<Self, PackError> {
        Ok(Self {
            pattern: compile_custom(raw)?,
            scope: if raw.contains('/') {
                Scope::Path
            } else {
                Scope::Name
            },
        })
    }

    /// Compile a built-in literal, which is known-good at authoring time.
    fn builtin(raw: &str) -> Option<Self> {
        Self::compile(raw).ok()
    }

    /// # Arguments
    ///
    /// * `name` — File or directory name, no separators.
    /// * `rel` — Path relative to the project root, `/`-separated.
    fn matches(&self, name: &str, rel: &str) -> bool {
        match self.scope {
            Scope::Name => self.pattern.matches(name),
            Scope::Path => self.pattern.matches(rel),
        }
    }

    /// The glob as the operator wrote it.
    fn as_str(&self) -> &str {
        self.pattern.as_str()
    }
}

/// Compiled classification rules used by the scan.
#[derive(Debug, Clone)]
pub struct PackRules {
    secret: Vec<Rule>,
    keep: Vec<Rule>,
    cache_dirs: Vec<Rule>,
    no_link_report: Vec<Rule>,
    /// How many of the compiled patterns came from the operator, for reporting.
    pub custom_secret_count: usize,
    /// How many keep patterns came from the operator, for reporting.
    pub custom_keep_count: usize,
    /// How many cache directory names came from the operator, for reporting.
    pub custom_cache_count: usize,
}

impl Default for PackRules {
    fn default() -> Self {
        // Compiling the built-in globs cannot fail; they are literals in this
        // file and are covered by a test that compiles every one of them.
        Self::new(&RuleOverrides::default()).expect("built-in globs must compile")
    }
}

impl PackRules {
    /// Compile the built-in rules plus the operator's additions.
    ///
    /// # Arguments
    ///
    /// * `overrides` — Extra globs and directory names from `[pack]`.
    ///
    /// # Returns
    ///
    /// Rules ready to classify paths.
    ///
    /// # Errors
    ///
    /// [`PackError::BadPattern`] when an operator-supplied glob is malformed,
    /// naming the offending pattern. A typo in config must fail loudly rather
    /// than silently classifying nothing.
    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)?);
        }

        // No built-in list to seed from: see `RuleOverrides::no_link_report`.
        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(),
        })
    }

    /// The `no_link_report` glob that covers this path, if the operator
    /// declared one.
    ///
    /// # Arguments
    ///
    /// * `name` — Link name, no separators.
    /// * `rel` — Path relative to the project root, `/`-separated.
    ///
    /// # Returns
    ///
    /// The glob exactly as configured, so a caller can record which rule
    /// suppressed the report rather than only that something did.
    ///
    /// Always `None` when the operator configured nothing, which is the point:
    /// this crate does not decide on its own that some directory's links are
    /// expected.
    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())
    }

    /// Whether a directory is a regenerable cache.
    ///
    /// # Arguments
    ///
    /// * `name` — Directory name, no separators.
    /// * `rel` — Path relative to the project root, `/`-separated.
    pub fn is_cache_dir(&self, name: &str, rel: &str) -> bool {
        // `keep` outranks every exclusion, caches included.
        if self.keep_match(name, rel).is_some() {
            return false;
        }
        self.cache_dirs.iter().any(|r| r.matches(name, rel))
    }

    /// Classify a file against the secret and `keep` lists.
    ///
    /// # Arguments
    ///
    /// * `name` — File name, no separators.
    /// * `rel` — Path relative to the project root, `/`-separated.
    ///
    /// # Returns
    ///
    /// Which of the three outcomes applies, naming every glob involved. A
    /// `keep` rule outranking a secret rule yields
    /// [`FileVerdict::KeptOverSecret`] rather than [`FileVerdict::Ordinary`],
    /// so the caller can record that the file was carried past the secret list
    /// on purpose.
    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(),
            },
        }
    }

    /// The `keep` glob that rescues this path from an exclusion, if any.
    fn keep_match(&self, name: &str, rel: &str) -> Option<&str> {
        self.keep
            .iter()
            .find(|r| r.matches(name, rel))
            .map(|r| r.as_str())
    }

    /// Total number of secret patterns in force.
    pub fn secret_pattern_count(&self) -> usize {
        self.secret.len()
    }

    /// Whether the operator customized anything.
    pub fn is_customized(&self) -> bool {
        self.custom_secret_count + self.custom_keep_count + self.custom_cache_count > 0
    }
}

/// Compile built-in literals, which are known-good at authoring time.
fn compile_builtin(raw: &[&str]) -> Vec<Rule> {
    raw.iter().filter_map(|p| Rule::builtin(p)).collect()
}

/// Compile an operator-supplied glob, reporting the pattern on failure.
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::*;

    /// Whether a name is excluded as a secret — the outcome most of these
    /// tests are about. A `keep` rule rescuing the file is a *different*
    /// verdict and is asserted on directly where it matters.
    ///
    /// Passing the name as the path too puts the file at the project root,
    /// where the two coincide. Path scoping is exercised separately.
    fn is_secret(r: &PackRules, name: &str) -> bool {
        matches!(r.classify(name, name), FileVerdict::Secret { .. })
    }

    /// A directory at the project root, where its name and path coincide.
    fn is_cache(r: &PackRules, name: &str) -> bool {
        r.is_cache_dir(name, name)
    }

    // ------------------------------------------------------------------
    // built-in coverage
    // ------------------------------------------------------------------

    /// Every built-in glob compiles — `PackRules::default` relies on this.
    #[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());
    }

    /// The names that motivated making this configurable are covered by default.
    #[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");
        }
    }

    /// Ordinary project files are not secrets.
    #[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");
        }
    }

    /// Templates are rescued from the `.env.*` rule by the built-in keep list.
    #[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"));
    }

    /// Built-in cache directories are recognized.
    #[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");
    }

    // ------------------------------------------------------------------
    // operator overrides
    // ------------------------------------------------------------------

    /// A project-specific secret name can be added without losing the built-ins.
    #[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"));
        // built-ins still in force
        assert!(is_secret(&r, ".env"));
        assert!(is_secret(&r, "secret.toml"));
        assert_eq!(r.custom_secret_count, 2);
        assert!(r.is_customized());
    }

    /// `keep` subtracts: a built-in exclusion can be overridden per project.
    #[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");
    }

    /// `keep` also outranks the cache rule.
    #[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"));
    }

    /// Extra cache directories are honored.
    #[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);
    }

    /// A malformed operator glob fails loudly and names itself.
    #[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:?}"),
        }
    }

    /// The reason string names the glob that matched, so a surprising exclusion
    /// can be traced back to the rule responsible for it.
    #[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()
            }
        );
    }

    /// An empty override set leaves the defaults untouched.
    #[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());
    }

    // ------------------------------------------------------------------
    // scoping: a glob with `/` names a path, one without names a file
    // ------------------------------------------------------------------

    /// A glob with no separator keeps matching at any depth, which is what
    /// every built-in rule relies on.
    #[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 { .. }
        ));
    }

    /// A glob with a separator is anchored to that path and nowhere else.
    #[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"
        );
    }

    /// A path-scoped `keep` rescues its own directory without opening the rule
    /// up everywhere — the reason scoping was worth having.
    #[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"
        );
    }

    /// A path-scoped cache rule drops one `dist`, not every directory that
    /// happens to share the name.
    #[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"
        );
    }
}