Skip to main content

lds_pack/
scan.rs

1//! Classification pass: decide, for every path under the project root, whether
2//! it travels in the pack, is dropped, or is merely reported.
3//!
4//! The scan is deliberately independent of `.gitignore`. A pack carries the
5//! whole project — tracked files, the `.git` directory itself, and the
6//! untracked local state that ordinarily never leaves the machine (a
7//! `workspace/` directory, journal databases, sandbox snapshots). Consulting
8//! ignore rules would drop exactly the material the pack exists to preserve.
9//!
10//! Four rules decide what does *not* travel verbatim:
11//!
12//! | rule | effect |
13//! |---|---|
14//! | cache directory | not packed, recorded with the file count, size, and any credentials that went with it |
15//! | secret file | not packed, reported; moving credentials is the operator's own business |
16//! | OS debris (`.DS_Store`) | not packed, recorded; nothing to act on, but nothing leaves untraced either |
17//! | symlink | packed as a link, never dereferenced; recorded so restore can report dangles |
18//! | `no_link_report` path | packed as links, left out of the link report; the rule that did so is recorded |
19//!
20//! Only the last is opt-in. A symlink breaks when the project is carried
21//! elsewhere, so reporting one is the default and suppressing it takes an
22//! explicit declaration from the operator, who alone knows which of their
23//! directories are links by design.
24//!
25//! **Every rule that fired is named in the output.** The reports are read in
26//! order to act on them — to move a secret out of band, to repair a link, to
27//! regenerate a cache — so a rule that quietly changed what a report contains
28//! would send the reader after the wrong thing. Suppressing a link report never
29//! suppresses the record that it was suppressed.
30
31use std::path::{Component, Path, PathBuf};
32
33use walkdir::WalkDir;
34
35use crate::error::PackError;
36use crate::manifest::{
37    CacheRecord, KeptOverSecret, SkipRecord, SymlinkRecord, WorktreeOrigin, WorktreeRecord,
38};
39use crate::rules::{FileVerdict, PackRules};
40
41/// OS debris that is neither cache nor content: dropped, but recorded.
42const NOISE_FILES: &[&str] = &[".DS_Store", "Thumbs.db"];
43
44/// What a scanned path is, for the purpose of writing the archive.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum EntryKind {
47    /// A regular file, packed by content.
48    File,
49    /// A directory, packed as an entry so empty directories survive.
50    Dir,
51    /// A symlink, packed as a link without following it.
52    Symlink,
53}
54
55/// One path that will be written into the archive.
56#[derive(Debug, Clone)]
57pub struct Entry {
58    /// Path relative to the project root, using `/` separators.
59    pub rel: String,
60    /// Absolute path on disk.
61    pub abs: PathBuf,
62    /// What kind of entry this is.
63    pub kind: EntryKind,
64    /// Size in bytes for regular files, `0` otherwise.
65    pub size: u64,
66}
67
68/// Result of classifying a project root.
69#[derive(Debug, Default)]
70pub struct Scan {
71    /// Everything that will be written to the archive, in walk order.
72    pub entries: Vec<Entry>,
73    /// Cache directories that were dropped, with the size of what went too.
74    pub skipped_cache: Vec<CacheRecord>,
75    /// Secret-looking files that were dropped and reported.
76    pub skipped_secret: Vec<SkipRecord>,
77    /// OS debris that was dropped — recorded so nothing leaves without a trace.
78    pub skipped_noise: Vec<SkipRecord>,
79    /// Every symlink found, one record each, except those a `no_link_report`
80    /// glob covered.
81    pub symlinks: Vec<SymlinkRecord>,
82    /// `no_link_report` globs that actually suppressed at least one link, in
83    /// declaration order.
84    ///
85    /// A rule that matched nothing does not appear: this records what the scan
86    /// did, not what was configured.
87    pub no_link_report_applied: Vec<String>,
88    /// Files a `keep` rule carried past a secret rule.
89    pub kept_over_secret: Vec<KeptOverSecret>,
90    /// Registered git worktrees discovered under `.git/worktrees/`.
91    pub worktrees: Vec<WorktreeRecord>,
92    /// Set when this root is itself a worktree of a repository elsewhere.
93    pub worktree_of: Option<WorktreeOrigin>,
94}
95
96impl Scan {
97    /// Total byte count of regular files to be packed.
98    pub fn total_bytes(&self) -> u64 {
99        self.entries.iter().map(|e| e.size).sum()
100    }
101
102    /// Number of regular files to be packed.
103    pub fn file_count(&self) -> u64 {
104        self.entries
105            .iter()
106            .filter(|e| e.kind == EntryKind::File)
107            .count() as u64
108    }
109
110    /// Number of symlinks to be packed.
111    pub fn symlink_count(&self) -> u64 {
112        self.entries
113            .iter()
114            .filter(|e| e.kind == EntryKind::Symlink)
115            .count() as u64
116    }
117}
118
119/// Classify every path under `root` using the default rules.
120///
121/// Convenience wrapper over [`scan_with`] for callers that do not customize
122/// classification.
123///
124/// # Errors
125///
126/// Same as [`scan_with`].
127pub fn scan(root: &Path) -> Result<Scan, PackError> {
128    scan_with(root, &PackRules::default())
129}
130
131/// Classify every path under `root`.
132///
133/// # Arguments
134///
135/// * `root` — Absolute path to the project root.
136/// * `rules` — Which names count as secrets and caches (see [`PackRules`]).
137///
138/// # Returns
139///
140/// A [`Scan`] listing what to pack and what was deliberately left out.
141///
142/// # Errors
143///
144/// - [`PackError::NotADirectory`] if `root` is not a directory.
145/// - [`PackError::Io`] if the tree cannot be walked or a link cannot be read.
146pub fn scan_with(root: &Path, rules: &PackRules) -> Result<Scan, PackError> {
147    if !root.is_dir() {
148        return Err(PackError::NotADirectory(root.to_path_buf()));
149    }
150    // Resolve the root up front so paths recorded by git (which may name the
151    // same directory through a different symlinked prefix) can be compared
152    // against it. Without this, a worktree inside the project reads as being
153    // outside it and its contents silently stop travelling.
154    let root = &canonicalize_or(root);
155
156    let mut scan = Scan::default();
157
158    let walker = WalkDir::new(root)
159        .follow_links(false)
160        .min_depth(1)
161        .sort_by_file_name()
162        .into_iter();
163
164    // `filter_entry` prunes whole subtrees, so a skipped cache directory costs
165    // one stat rather than a full descent into it.
166    let it = walker.filter_entry(|e| {
167        let name = e.file_name().to_string_lossy();
168        // Never descend into a symlinked directory: it is packed as a link.
169        if e.file_type().is_symlink() {
170            return true;
171        }
172        if !e.file_type().is_dir() {
173            return true;
174        }
175        // A path-scoped rule needs the path; one that cannot be made relative
176        // is outside the root and no rule can be about it.
177        let Some(rel) = rel_path(root, e.path()) else {
178            return true;
179        };
180        !rules.is_cache_dir(name.as_ref(), &rel)
181    });
182
183    // Cache directories are pruned above, which also hides them from the
184    // record; walk their parents separately so each dropped cache is named.
185    collect_cache_records(root, rules, &mut scan)?;
186
187    for next in it {
188        let entry = next?;
189        let abs = entry.path().to_path_buf();
190        let Some(rel) = rel_path(root, &abs) else {
191            continue;
192        };
193        let name = entry.file_name().to_string_lossy().to_string();
194
195        if NOISE_FILES.contains(&name.as_str()) {
196            scan.skipped_noise.push(SkipRecord {
197                path: rel,
198                reason: format!("os debris: {name}"),
199            });
200            continue;
201        }
202
203        let file_type = entry.file_type();
204
205        if file_type.is_symlink() {
206            let target = std::fs::read_link(&abs)?;
207            // A link the operator declared expected is packed like any other,
208            // just not reported. The rule that made that call is recorded, so
209            // "no links here" and "links hidden here" stay distinguishable.
210            match rules.no_link_report_match(&name, &rel) {
211                Some(glob) => {
212                    if !scan.no_link_report_applied.iter().any(|g| g == glob) {
213                        scan.no_link_report_applied.push(glob.to_string());
214                    }
215                }
216                None => scan.symlinks.push(SymlinkRecord {
217                    path: rel.clone(),
218                    target: target.to_string_lossy().into_owned(),
219                    outside_root: resolves_outside(root, &abs, &target),
220                }),
221            }
222            scan.entries.push(Entry {
223                rel,
224                abs,
225                kind: EntryKind::Symlink,
226                size: 0,
227            });
228            continue;
229        }
230
231        if file_type.is_dir() {
232            scan.entries.push(Entry {
233                rel,
234                abs,
235                kind: EntryKind::Dir,
236                size: 0,
237            });
238            continue;
239        }
240
241        match rules.classify(&name, &rel) {
242            FileVerdict::Secret { pattern } => {
243                scan.skipped_secret.push(SkipRecord {
244                    path: rel,
245                    reason: format!("secret pattern: {pattern}"),
246                });
247                continue;
248            }
249            // Packed, because the operator asked for it — and recorded, because
250            // this is the only way a file the secret rules named gets in.
251            FileVerdict::KeptOverSecret {
252                keep_pattern,
253                secret_pattern,
254            } => scan.kept_over_secret.push(KeptOverSecret {
255                path: rel.clone(),
256                keep_pattern,
257                secret_pattern,
258            }),
259            FileVerdict::Ordinary => {}
260        }
261
262        let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
263        scan.entries.push(Entry {
264            rel,
265            abs,
266            kind: EntryKind::File,
267            size,
268        });
269    }
270
271    scan.worktrees = discover_worktrees(root)?;
272    scan.worktree_of = discover_worktree_origin(root);
273
274    Ok(scan)
275}
276
277/// Walk the tree a second time, shallowly, to name every pruned cache directory.
278///
279/// `filter_entry` removes cache directories before they are yielded, so they
280/// would otherwise vanish without a record. This pass descends normally but
281/// stops at each cache directory it names, so the cost stays proportional to
282/// the surviving tree.
283fn collect_cache_records(root: &Path, rules: &PackRules, scan: &mut Scan) -> Result<(), PackError> {
284    let walker = WalkDir::new(root)
285        .follow_links(false)
286        .min_depth(1)
287        .sort_by_file_name()
288        .into_iter();
289
290    let mut it = walker.filter_entry(|e| {
291        if e.file_type().is_symlink() {
292            return false;
293        }
294        if !e.file_type().is_dir() {
295            return false;
296        }
297        true
298    });
299
300    while let Some(next) = it.next() {
301        let entry = next?;
302        let name = entry.file_name().to_string_lossy().to_string();
303        let Some(rel) = rel_path(root, entry.path()) else {
304            continue;
305        };
306        if !rules.is_cache_dir(&name, &rel) {
307            continue;
308        }
309        let (file_count, total_bytes, secrets) = measure_cache(entry.path(), root, rules);
310        scan.skipped_cache.push(CacheRecord {
311            path: rel,
312            reason: format!("cache directory: {name}"),
313            file_count,
314            total_bytes,
315            secrets,
316        });
317        it.skip_current_dir();
318    }
319
320    Ok(())
321}
322
323/// Measure a cache directory that is about to be dropped: how many files, how
324/// many bytes, and which of them look like credentials.
325///
326/// One record stands in for the whole subtree, so this is what makes that
327/// record checkable: a `cache_dirs` entry aimed at hand-written source shows up
328/// as a small file count next to `target/`'s enormous one, where the path alone
329/// would read the same either way. Walking a pruned tree costs a stat per file
330/// and no reads, which is cheap next to compressing everything else.
331///
332/// The secret scan rides along on that same walk. Pruning happens before the
333/// classification pass, so a `node_modules/.npmrc` was previously neither
334/// packed nor reported — safe, but the operator never learned that a token was
335/// sitting there. Nothing here changes what travels; these files are dropped
336/// with the rest of the cache either way.
337///
338/// Unreadable entries contribute nothing rather than aborting the pack: these
339/// figures exist to be eyeballed, and a cache directory is regenerable, so
340/// failing a whole pack over a permission error inside one would trade a real
341/// capability for a rounding error.
342fn measure_cache(dir: &Path, root: &Path, rules: &PackRules) -> (u64, u64, Vec<SkipRecord>) {
343    let mut file_count = 0;
344    let mut total_bytes = 0;
345    let mut secrets = Vec::new();
346
347    for entry in WalkDir::new(dir)
348        .follow_links(false)
349        .sort_by_file_name()
350        .into_iter()
351        .filter_map(Result::ok)
352        .filter(|e| e.file_type().is_file())
353    {
354        if let Ok(meta) = entry.metadata() {
355            file_count += 1;
356            total_bytes += meta.len();
357        }
358
359        let name = entry.file_name().to_string_lossy().to_string();
360        let Some(rel) = rel_path(root, entry.path()) else {
361            continue;
362        };
363        // Only an unrescued secret is worth naming. A `keep` rule matching in
364        // here means the operator already called the file safe to carry, and it
365        // is being dropped with the cache regardless.
366        if let FileVerdict::Secret { pattern } = rules.classify(&name, &rel) {
367            secrets.push(SkipRecord {
368                path: rel,
369                reason: format!("secret pattern: {pattern}"),
370            });
371        }
372    }
373
374    (file_count, total_bytes, secrets)
375}
376
377/// Convert an absolute path to a `/`-separated path relative to `root`.
378fn rel_path(root: &Path, abs: &Path) -> Option<String> {
379    let rel = abs.strip_prefix(root).ok()?;
380    let s = rel
381        .components()
382        .map(|c| c.as_os_str().to_string_lossy())
383        .collect::<Vec<_>>()
384        .join("/");
385    if s.is_empty() { None } else { Some(s) }
386}
387
388/// Whether a link target escapes the project root.
389///
390/// Relative targets are resolved against the link's own directory. The result
391/// is resolved against the filesystem where possible, so a link written through
392/// one symlinked prefix is not mistaken for pointing outside a root named
393/// through another. A dangling target cannot be resolved and falls back to
394/// lexical normalization, which is enough to classify it.
395fn resolves_outside(root: &Path, link_path: &Path, target: &Path) -> bool {
396    let joined = if target.is_absolute() {
397        target.to_path_buf()
398    } else {
399        match link_path.parent() {
400            Some(parent) => parent.join(target),
401            None => return true,
402        }
403    };
404    !canonicalize_or(&joined).starts_with(canonicalize_or(root))
405}
406
407/// Resolve a path against the filesystem, falling back to lexical
408/// normalization when it does not exist.
409pub(crate) fn canonicalize_or(path: &Path) -> PathBuf {
410    std::fs::canonicalize(path).unwrap_or_else(|_| normalize(path))
411}
412
413/// Lexically normalize a path, collapsing `.` and `..` without touching disk.
414pub(crate) fn normalize(path: &Path) -> PathBuf {
415    let mut out = PathBuf::new();
416    for component in path.components() {
417        match component {
418            Component::ParentDir => {
419                out.pop();
420            }
421            Component::CurDir => {}
422            other => out.push(other.as_os_str()),
423        }
424    }
425    out
426}
427
428/// Read `.git/worktrees/` to learn which worktrees this repository has.
429///
430/// Each admin directory holds a `gitdir` file whose contents are the absolute
431/// path of the worktree's own `.git` file; the worktree root is that file's
432/// parent. Worktrees inside the project root travel with the pack, and their
433/// pointers are rewritten on restore. Worktrees outside it are recorded but
434/// their contents are not collected — reaching outside the root to pull in an
435/// arbitrary directory is a different decision than packing a project.
436fn discover_worktrees(root: &Path) -> Result<Vec<WorktreeRecord>, PackError> {
437    let admin = root.join(".git").join("worktrees");
438    if !admin.is_dir() {
439        return Ok(Vec::new());
440    }
441
442    let mut records = Vec::new();
443    let mut dirs: Vec<PathBuf> = std::fs::read_dir(&admin)?
444        .filter_map(|e| e.ok())
445        .map(|e| e.path())
446        .filter(|p| p.is_dir())
447        .collect();
448    dirs.sort();
449
450    for dir in dirs {
451        let Some(name) = dir.file_name().map(|n| n.to_string_lossy().into_owned()) else {
452            continue;
453        };
454        let gitdir_file = dir.join("gitdir");
455        let Ok(contents) = std::fs::read_to_string(&gitdir_file) else {
456            continue;
457        };
458        // `gitdir` holds the path of the worktree's `.git` file; its parent is
459        // the worktree root.
460        let dot_git = PathBuf::from(contents.trim());
461        let Some(worktree_root) = dot_git.parent() else {
462            continue;
463        };
464        // git records this path as it saw it, which need not match the resolved
465        // root; resolve both sides before deciding whether it lives inside.
466        let resolved = canonicalize_or(worktree_root);
467        let rel = rel_path(root, &resolved);
468        records.push(WorktreeRecord {
469            name,
470            included: rel.is_some(),
471            path: rel,
472            // Resolved rather than verbatim, so it can be compared against
473            // `source_root` — which is canonical — when restore works out where
474            // an outside worktree moved to.
475            source_path: resolved.to_string_lossy().into_owned(),
476        });
477    }
478
479    Ok(records)
480}
481
482/// Work out whether this root is itself a worktree, and of what.
483///
484/// A worktree checkout has no `.git` *directory*: its `.git` is a file holding
485/// `gitdir: <parent>/.git/worktrees/<name>`. That single line is the only trace
486/// of the parent in the checkout, and it is an absolute path — so it is
487/// recorded here for restore to rebuild rather than lost with the machine.
488///
489/// Only the layout git itself writes is accepted. Anything else (a `commondir`
490/// pointing somewhere unusual, a hand-made `.git` file) yields `None`: guessing
491/// a parent from an unrecognized shape would be worse than reporting nothing.
492fn discover_worktree_origin(root: &Path) -> Option<WorktreeOrigin> {
493    let dot_git = root.join(".git");
494    if !dot_git.is_file() {
495        return None;
496    }
497    let contents = std::fs::read_to_string(&dot_git).ok()?;
498    let admin = PathBuf::from(contents.trim().strip_prefix("gitdir:")?.trim());
499
500    // `<parent_root>/.git/worktrees/<name>` — anything else is not ours to read.
501    let name = admin.file_name()?.to_string_lossy().into_owned();
502    let worktrees_dir = admin.parent()?;
503    if worktrees_dir.file_name()? != "worktrees" {
504        return None;
505    }
506    let git_dir = worktrees_dir.parent()?;
507    if git_dir.file_name()? != ".git" {
508        return None;
509    }
510    let parent_root = canonicalize_or(git_dir.parent()?);
511
512    Some(WorktreeOrigin {
513        name,
514        admin_path: admin.to_string_lossy().into_owned(),
515        // Canonical, to be comparable with `source_root` when restore works out
516        // where the parent repository moved to.
517        parent_root: parent_root.to_string_lossy().into_owned(),
518    })
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524    use std::fs;
525    use tempfile::TempDir;
526
527    fn touch(path: &Path) {
528        if let Some(parent) = path.parent() {
529            fs::create_dir_all(parent).expect("mkdir should succeed in test");
530        }
531        fs::write(path, b"x").expect("write should succeed in test");
532    }
533
534    fn rels(scan: &Scan) -> Vec<String> {
535        scan.entries.iter().map(|e| e.rel.clone()).collect()
536    }
537
538    // ------------------------------------------------------------------
539    // scan behaviour
540    // ------------------------------------------------------------------
541
542    /// Untracked local state travels; `.git` travels; caches and secrets do not.
543    #[test]
544    fn test_scan_partitions_tree() {
545        let dir = TempDir::new().expect("tempdir");
546        let root = dir.path();
547
548        touch(&root.join("src/main.rs"));
549        touch(&root.join(".git/HEAD"));
550        touch(&root.join("workspace/journal.md"));
551        touch(&root.join("workspace/.journal.db"));
552        touch(&root.join(".mcp.json"));
553        touch(&root.join("target/debug/binary"));
554        touch(&root.join("crates/inner/target/x.rlib"));
555        touch(&root.join(".env"));
556        touch(&root.join(".env.example"));
557        touch(&root.join("key.pem"));
558
559        let scan = scan(root).expect("scan should succeed");
560        let packed = rels(&scan);
561
562        assert!(packed.contains(&"src/main.rs".to_string()));
563        assert!(
564            packed.contains(&".git/HEAD".to_string()),
565            "`.git` must travel"
566        );
567        assert!(packed.contains(&"workspace/journal.md".to_string()));
568        assert!(
569            packed.contains(&"workspace/.journal.db".to_string()),
570            "journal database is exactly the local state a pack exists to carry"
571        );
572        assert!(packed.contains(&".mcp.json".to_string()));
573        assert!(packed.contains(&".env.example".to_string()));
574
575        assert!(
576            !packed.iter().any(|p| p.starts_with("target/")),
577            "cache tree must not be packed"
578        );
579        assert!(
580            !packed.iter().any(|p| p.contains("/target/")),
581            "nested cache tree must not be packed"
582        );
583        assert!(!packed.contains(&".env".to_string()));
584        assert!(!packed.contains(&"key.pem".to_string()));
585
586        let secrets: Vec<&str> = scan
587            .skipped_secret
588            .iter()
589            .map(|s| s.path.as_str())
590            .collect();
591        assert!(secrets.contains(&".env"));
592        assert!(secrets.contains(&"key.pem"));
593
594        let caches: Vec<&str> = scan.skipped_cache.iter().map(|s| s.path.as_str()).collect();
595        assert!(caches.contains(&"target"));
596        assert!(caches.contains(&"crates/inner/target"));
597    }
598
599    /// A dropped cache carries the size of what it took with it, so a rule
600    /// that caught the wrong directory is visible rather than one bare line.
601    #[test]
602    fn test_cache_record_measures_what_it_dropped() {
603        use crate::rules::RuleOverrides;
604        let dir = TempDir::new().expect("tempdir");
605        let root = dir.path();
606
607        fs::create_dir_all(root.join("dist/nested")).expect("mkdir");
608        fs::write(root.join("dist/a.js"), "0123456789").expect("write");
609        fs::write(root.join("dist/nested/b.js"), "01234").expect("write");
610
611        let rules = PackRules::new(&RuleOverrides {
612            cache_dirs: vec!["dist".to_string()],
613            ..RuleOverrides::default()
614        })
615        .expect("compile");
616        let scan = scan_with(root, &rules).expect("scan should succeed");
617
618        assert_eq!(scan.skipped_cache.len(), 1);
619        let dropped = &scan.skipped_cache[0];
620        assert_eq!(dropped.path, "dist");
621        assert_eq!(
622            dropped.file_count, 2,
623            "counts the whole subtree, not depth 1"
624        );
625        assert_eq!(dropped.total_bytes, 15);
626        assert!(dropped.secrets.is_empty());
627    }
628
629    /// A credential inside a dropped cache is named. The cache is pruned before
630    /// the secret pass, so without this it is neither packed nor reported and
631    /// the operator never learns the token is sitting on their disk.
632    #[test]
633    fn test_secrets_inside_a_dropped_cache_are_named() {
634        let dir = TempDir::new().expect("tempdir");
635        let root = dir.path();
636
637        fs::create_dir_all(root.join("node_modules/pkg")).expect("mkdir");
638        touch(&root.join("node_modules/.npmrc"));
639        touch(&root.join("node_modules/pkg/index.js"));
640        touch(&root.join("node_modules/.env.example"));
641
642        let scan = scan(root).expect("scan should succeed");
643
644        assert_eq!(scan.skipped_cache.len(), 1);
645        let dropped = &scan.skipped_cache[0];
646        assert_eq!(dropped.path, "node_modules");
647
648        let named: Vec<&str> = dropped.secrets.iter().map(|s| s.path.as_str()).collect();
649        assert_eq!(
650            named,
651            vec!["node_modules/.npmrc"],
652            "a file `keep` already calls safe is not re-flagged as a credential"
653        );
654        assert!(
655            dropped.secrets[0].reason.contains(".npmrc"),
656            "the rule that flagged it has to be visible, got {:?}",
657            dropped.secrets[0].reason
658        );
659
660        // Naming them changes nothing about what travels.
661        assert!(rels(&scan).iter().all(|p| !p.starts_with("node_modules/")));
662        assert!(scan.skipped_secret.is_empty());
663    }
664
665    /// End to end: a path-scoped `keep` rescues its own directory and leaves a
666    /// namesake elsewhere excluded, with the override recorded either way.
667    #[test]
668    fn test_path_scoped_keep_end_to_end() {
669        use crate::rules::RuleOverrides;
670        let dir = TempDir::new().expect("tempdir");
671        let root = dir.path();
672
673        fs::create_dir_all(root.join("docs/samples")).expect("mkdir");
674        fs::create_dir_all(root.join("deploy")).expect("mkdir");
675        touch(&root.join("docs/samples/demo.pem"));
676        touch(&root.join("deploy/server.pem"));
677
678        let rules = PackRules::new(&RuleOverrides {
679            keep: vec!["docs/samples/*.pem".to_string()],
680            ..RuleOverrides::default()
681        })
682        .expect("compile");
683        let scan = scan_with(root, &rules).expect("scan should succeed");
684
685        let packed = rels(&scan);
686        assert!(packed.contains(&"docs/samples/demo.pem".to_string()));
687        assert!(
688            !packed.contains(&"deploy/server.pem".to_string()),
689            "the real key must stay out"
690        );
691
692        assert_eq!(scan.kept_over_secret.len(), 1);
693        assert_eq!(scan.kept_over_secret[0].path, "docs/samples/demo.pem");
694        assert_eq!(scan.skipped_secret.len(), 1);
695        assert_eq!(scan.skipped_secret[0].path, "deploy/server.pem");
696    }
697
698    /// OS debris is dropped, but never without a record: a path in the source
699    /// tree and not in the payload must always be explainable.
700    #[test]
701    fn test_os_debris_is_dropped_but_recorded() {
702        let dir = TempDir::new().expect("tempdir");
703        let root = dir.path();
704
705        fs::create_dir_all(root.join("sub")).expect("mkdir");
706        touch(&root.join(".DS_Store"));
707        touch(&root.join("sub/.DS_Store"));
708        touch(&root.join("keep.txt"));
709
710        let scan = scan(root).expect("scan should succeed");
711
712        let packed = rels(&scan);
713        assert!(packed.contains(&"keep.txt".to_string()));
714        assert!(
715            !packed.iter().any(|p| p.ends_with(".DS_Store")),
716            "debris must not be packed"
717        );
718
719        let noise: Vec<&str> = scan.skipped_noise.iter().map(|s| s.path.as_str()).collect();
720        assert_eq!(noise, vec![".DS_Store", "sub/.DS_Store"]);
721        assert!(scan.skipped_noise[0].reason.contains(".DS_Store"));
722    }
723
724    /// Symlinks are recorded individually and packed as links.
725    #[cfg(unix)]
726    #[test]
727    fn test_scan_records_symlinks_individually() {
728        let dir = TempDir::new().expect("tempdir");
729        let root = dir.path();
730        let outside = TempDir::new().expect("tempdir");
731
732        touch(&root.join("real.txt"));
733        std::os::unix::fs::symlink(root.join("real.txt"), root.join("inside-link"))
734            .expect("symlink");
735        std::os::unix::fs::symlink(outside.path().join("far.txt"), root.join("outside-link"))
736            .expect("symlink");
737
738        let scan = scan(root).expect("scan should succeed");
739
740        assert_eq!(scan.symlinks.len(), 2);
741        let inside = scan
742            .symlinks
743            .iter()
744            .find(|s| s.path == "inside-link")
745            .expect("inside link recorded");
746        let outside_rec = scan
747            .symlinks
748            .iter()
749            .find(|s| s.path == "outside-link")
750            .expect("outside link recorded");
751        assert!(!inside.outside_root);
752        assert!(outside_rec.outside_root);
753
754        assert!(rels(&scan).contains(&"outside-link".to_string()));
755    }
756
757    /// Build a project whose `links/` directory is links by design — the shape
758    /// an operator declares `no_link_report` for.
759    #[cfg(unix)]
760    fn link_farm(root: &Path, shared: &Path) -> (String, String) {
761        let first = shared.join("group/alpha");
762        let second = shared.join("group/beta");
763        fs::create_dir_all(&first).expect("mkdir");
764        fs::create_dir_all(&second).expect("mkdir");
765        touch(&first.join("a.md"));
766        touch(&second.join("b.md"));
767
768        fs::create_dir_all(root.join("links")).expect("mkdir");
769        std::os::unix::fs::symlink(first.join("a.md"), root.join("links/a.md")).expect("symlink");
770        std::os::unix::fs::symlink(second.join("b.md"), root.join("links/b.md")).expect("symlink");
771
772        ("links/a.md".to_string(), "links/b.md".to_string())
773    }
774
775    fn rules_with_no_link_report(globs: &[&str]) -> PackRules {
776        use crate::rules::RuleOverrides;
777        PackRules::new(&RuleOverrides {
778            no_link_report: globs.iter().map(|g| (*g).to_string()).collect(),
779            ..RuleOverrides::default()
780        })
781        .expect("globs should compile")
782    }
783
784    /// A declared path drops out of the link report while its links still
785    /// travel, and the rule that did it is named.
786    #[cfg(unix)]
787    #[test]
788    fn test_declared_path_leaves_the_link_report() {
789        let dir = TempDir::new().expect("tempdir");
790        let shared = TempDir::new().expect("tempdir");
791        let (linked_a, linked_b) = link_farm(dir.path(), shared.path());
792
793        let scan = scan_with(dir.path(), &rules_with_no_link_report(&["links/**"]))
794            .expect("scan should succeed");
795
796        assert!(
797            scan.symlinks.is_empty(),
798            "a declared link must not be reported, got {:?}",
799            scan.symlinks
800        );
801        assert_eq!(
802            scan.no_link_report_applied,
803            vec!["links/**".to_string()],
804            "the rule that suppressed the report has to be visible"
805        );
806        // Suppression changes the reporting, never what travels.
807        assert!(rels(&scan).contains(&linked_a));
808        assert!(rels(&scan).contains(&linked_b));
809    }
810
811    /// With nothing declared, every link is reported — this crate has no
812    /// directory it treats as expected on its own.
813    #[cfg(unix)]
814    #[test]
815    fn test_every_link_is_reported_by_default() {
816        let dir = TempDir::new().expect("tempdir");
817        let shared = TempDir::new().expect("tempdir");
818        link_farm(dir.path(), shared.path());
819
820        let scan = scan(dir.path()).expect("scan should succeed");
821
822        assert!(scan.no_link_report_applied.is_empty());
823        assert_eq!(
824            scan.symlinks.len(),
825            2,
826            "undeclared links are reported one by one"
827        );
828    }
829
830    /// A rule that matched nothing is not recorded as applied: the manifest
831    /// says what the scan did, not what the config said.
832    #[cfg(unix)]
833    #[test]
834    fn test_unmatched_rule_is_not_recorded_as_applied() {
835        let dir = TempDir::new().expect("tempdir");
836        let shared = TempDir::new().expect("tempdir");
837        link_farm(dir.path(), shared.path());
838
839        let scan = scan_with(dir.path(), &rules_with_no_link_report(&["vendor/**"]))
840            .expect("scan should succeed");
841
842        assert!(scan.no_link_report_applied.is_empty());
843        assert_eq!(scan.symlinks.len(), 2);
844    }
845
846    /// Overlapping rules record each applied rule once, not once per link.
847    #[cfg(unix)]
848    #[test]
849    fn test_applied_rules_are_deduplicated() {
850        let dir = TempDir::new().expect("tempdir");
851        let shared = TempDir::new().expect("tempdir");
852        link_farm(dir.path(), shared.path());
853
854        let scan = scan_with(
855            dir.path(),
856            &rules_with_no_link_report(&["links/a.md", "links/**"]),
857        )
858        .expect("scan should succeed");
859
860        assert!(scan.symlinks.is_empty());
861        assert_eq!(
862            scan.no_link_report_applied,
863            vec!["links/a.md".to_string(), "links/**".to_string()],
864            "both rules fired, each recorded once"
865        );
866    }
867
868    /// A `keep` glob carrying a file past a secret rule is packed *and*
869    /// recorded — the only way a secret-matching file enters the archive.
870    #[test]
871    fn test_keep_over_secret_is_recorded() {
872        use crate::rules::RuleOverrides;
873        let dir = TempDir::new().expect("tempdir");
874        touch(&dir.path().join(".env.example"));
875        touch(&dir.path().join(".env"));
876
877        let scan = scan(dir.path()).expect("scan should succeed");
878
879        assert_eq!(scan.kept_over_secret.len(), 1);
880        let kept = &scan.kept_over_secret[0];
881        assert_eq!(kept.path, ".env.example");
882        assert_eq!(kept.keep_pattern, ".env.example");
883        assert_eq!(kept.secret_pattern, ".env.*");
884        assert!(rels(&scan).contains(&".env.example".to_string()));
885
886        // The unrescued sibling still goes nowhere.
887        assert_eq!(scan.skipped_secret.len(), 1);
888        assert_eq!(scan.skipped_secret[0].path, ".env");
889
890        // An operator glob that rescues a real credential is recorded the same
891        // way, which is the case the record exists for.
892        let rules = PackRules::new(&RuleOverrides {
893            keep: vec!["*.pem".to_string()],
894            ..RuleOverrides::default()
895        })
896        .expect("glob should compile");
897        touch(&dir.path().join("server.pem"));
898        let scan = scan_with(dir.path(), &rules).expect("scan should succeed");
899
900        assert!(
901            scan.kept_over_secret
902                .iter()
903                .any(|k| k.path == "server.pem" && k.secret_pattern == "*.pem"),
904            "a keep rule outranking a secret rule must never be silent, got {:?}",
905            scan.kept_over_secret
906        );
907    }
908
909    /// A missing `.git/worktrees` yields no worktree records.
910    #[test]
911    fn test_scan_without_worktrees() {
912        let dir = TempDir::new().expect("tempdir");
913        touch(&dir.path().join(".git/HEAD"));
914        let scan = scan(dir.path()).expect("scan should succeed");
915        assert!(scan.worktrees.is_empty());
916    }
917
918    /// A worktree inside the root is discovered and marked as included.
919    #[test]
920    fn test_scan_discovers_inside_worktree() {
921        let dir = TempDir::new().expect("tempdir");
922        let root = dir.path();
923        let wt = root.join(".worktrees/feature");
924        touch(&wt.join("file.txt"));
925        fs::write(wt.join(".git"), "gitdir: /ignored\n").expect("write");
926        let admin = root.join(".git/worktrees/feature");
927        fs::create_dir_all(&admin).expect("mkdir");
928        fs::write(
929            admin.join("gitdir"),
930            format!("{}\n", wt.join(".git").display()),
931        )
932        .expect("write");
933
934        let scan = scan(root).expect("scan should succeed");
935
936        assert_eq!(scan.worktrees.len(), 1);
937        let rec = &scan.worktrees[0];
938        assert_eq!(rec.name, "feature");
939        assert_eq!(rec.path.as_deref(), Some(".worktrees/feature"));
940        assert!(rec.included);
941        assert!(rels(&scan).contains(&".worktrees/feature/file.txt".to_string()));
942    }
943
944    /// A worktree outside the root is reported but its contents are not collected.
945    #[test]
946    fn test_scan_reports_outside_worktree_without_including_it() {
947        let dir = TempDir::new().expect("tempdir");
948        let root = dir.path();
949        let elsewhere = TempDir::new().expect("tempdir");
950        let wt = elsewhere.path().join("detached");
951        touch(&wt.join("file.txt"));
952
953        let admin = root.join(".git/worktrees/detached");
954        fs::create_dir_all(&admin).expect("mkdir");
955        fs::write(
956            admin.join("gitdir"),
957            format!("{}\n", wt.join(".git").display()),
958        )
959        .expect("write");
960
961        let scan = scan(root).expect("scan should succeed");
962
963        assert_eq!(scan.worktrees.len(), 1);
964        assert!(!scan.worktrees[0].included);
965        assert!(scan.worktrees[0].path.is_none());
966        assert!(!rels(&scan).iter().any(|p| p.contains("detached/file.txt")));
967    }
968
969    /// A worktree checkout knows which repository it belongs to, and says so —
970    /// its `.git` file is the only trace, and it names an absolute path that
971    /// will not survive the move on its own.
972    #[test]
973    fn test_scan_records_the_repository_a_worktree_belongs_to() {
974        let dir = TempDir::new().expect("tempdir");
975        let parent = dir.path().join("proj");
976        let admin = parent.join(".git/worktrees/feature");
977        fs::create_dir_all(&admin).expect("mkdir");
978
979        let wt = dir.path().join("proj-feature");
980        touch(&wt.join("work.txt"));
981        fs::write(wt.join(".git"), format!("gitdir: {}\n", admin.display())).expect("write");
982
983        let scan = scan(&wt).expect("scan should succeed");
984
985        let origin = scan
986            .worktree_of
987            .expect("a worktree must know its repository");
988        assert_eq!(origin.name, "feature");
989        assert_eq!(origin.admin_path, admin.display().to_string());
990        assert_eq!(
991            origin.parent_root,
992            fs::canonicalize(&parent)
993                .expect("canonicalize")
994                .display()
995                .to_string()
996        );
997        // It has no worktrees of its own.
998        assert!(scan.worktrees.is_empty());
999    }
1000
1001    /// An ordinary repository is not a worktree of anything.
1002    #[test]
1003    fn test_scan_records_no_origin_for_an_ordinary_repository() {
1004        let dir = TempDir::new().expect("tempdir");
1005        touch(&dir.path().join(".git/HEAD"));
1006
1007        let scan = scan(dir.path()).expect("scan should succeed");
1008
1009        assert!(scan.worktree_of.is_none());
1010    }
1011
1012    /// A `.git` file that is not the shape git writes is left alone rather than
1013    /// guessed at — inventing a repository path would be worse than none.
1014    #[test]
1015    fn test_scan_ignores_an_unrecognized_git_file() {
1016        let dir = TempDir::new().expect("tempdir");
1017        fs::write(dir.path().join(".git"), "gitdir: /somewhere/odd\n").expect("write");
1018
1019        let scan = scan(dir.path()).expect("scan should succeed");
1020
1021        assert!(scan.worktree_of.is_none());
1022    }
1023
1024    /// Scanning a file rather than a directory is an error.
1025    #[test]
1026    fn test_scan_rejects_non_directory() {
1027        let dir = TempDir::new().expect("tempdir");
1028        let file = dir.path().join("f.txt");
1029        touch(&file);
1030        assert!(matches!(scan(&file), Err(PackError::NotADirectory(_))));
1031    }
1032
1033    // ------------------------------------------------------------------
1034    // helpers
1035    // ------------------------------------------------------------------
1036
1037    /// Relative link targets are resolved against the link's own directory.
1038    #[test]
1039    fn test_resolves_outside_relative_target() {
1040        let root = Path::new("/proj");
1041        assert!(!resolves_outside(
1042            root,
1043            Path::new("/proj/sub/link"),
1044            Path::new("../file.txt")
1045        ));
1046        assert!(resolves_outside(
1047            root,
1048            Path::new("/proj/sub/link"),
1049            Path::new("../../escape.txt")
1050        ));
1051    }
1052}