trg/source.rs
1//! Positional arguments become an ordered list of archives to search.
2//! Order is preserved because these archives are days, so argument order is
3//! chronological order.
4
5use std::collections::HashSet;
6use std::path::{Path, PathBuf};
7
8#[cfg(unix)]
9use std::os::unix::fs::MetadataExt;
10
11/// Identity used to de-duplicate resolved work items: on Unix, `(dev, ino)`,
12/// which collapses both a symlink-and-its-target pair *and* hardlinks to the
13/// same file down to a single search. `canonicalize()` would catch the
14/// symlink case but not hardlinks -- and hardlinked rotated logs are a real
15/// pattern this project has already been bitten by (GNU tar's inode-based
16/// dedup of a hardlinked copy once produced a 696 KB archive that claimed to
17/// hold 577 MB). Non-Unix targets have no `dev`/`ino`, so they fall back to a
18/// canonicalized path -- macOS and Linux are the supported targets, so that
19/// fallback only needs to keep the code compiling elsewhere, not be as
20/// thorough.
21#[cfg(unix)]
22type Identity = (u64, u64);
23#[cfg(not(unix))]
24type Identity = PathBuf;
25
26/// `None` means identity couldn't be determined (metadata read failed after
27/// the fact, e.g. a TOCTOU removal). Callers must treat that as "assume
28/// unique" -- better to search a file twice than to silently drop it.
29#[cfg(unix)]
30fn identity(p: &Path) -> Option<Identity> {
31 std::fs::metadata(p).ok().map(|md| (md.dev(), md.ino()))
32}
33
34#[cfg(not(unix))]
35fn identity(p: &Path) -> Option<Identity> {
36 std::fs::canonicalize(p).ok()
37}
38
39/// Push `p` onto `items` unless something already pushed with the same file
40/// identity got there first. Dedup is by first-occurrence in resolution
41/// order, across the whole run -- not just within one directory walk -- so
42/// `trg pat a.log a.log` and `trg pat dir/` (where `dir/` holds a file and a
43/// symlink to it) both search the underlying file exactly once.
44fn push_unique(items: &mut Vec<PathBuf>, seen: &mut HashSet<Identity>, p: PathBuf) {
45 match identity(&p) {
46 Some(id) if !seen.insert(id) => {} // already seen: drop this occurrence
47 _ => items.push(p),
48 }
49}
50
51pub fn resolve(paths: &[PathBuf]) -> (Vec<PathBuf>, Vec<String>) {
52 let mut items = Vec::new();
53 let mut errors = Vec::new();
54 let mut seen = HashSet::new();
55
56 for p in paths {
57 match std::fs::metadata(p) {
58 Err(e) => errors.push(format!("{}: {e}", p.display())),
59 Ok(md) if md.is_dir() => {
60 let mut found = walk(p, &mut errors);
61 found.sort();
62 for f in found {
63 push_unique(&mut items, &mut seen, f);
64 }
65 }
66 Ok(_) => push_unique(&mut items, &mut seen, p.clone()),
67 }
68 }
69
70 (items, errors)
71}
72
73/// `standard_filters(false)`: a log directory is not a source tree, so
74/// `.gitignore`/hidden-file rules must not silently drop archives.
75///
76/// `follow_links(true)`: `/var/log` is full of symlinked and rotated-archive
77/// symlinks, so `file_type()` must report the target's type rather than
78/// `symlink`, or every symlinked log would be silently skipped by the
79/// `is_file()` filter below. A broken symlink then surfaces as an `Err` from
80/// the walk (the same as naming it directly would), which the `Err` arm
81/// below records as an error rather than dropping it — a path's failure must
82/// be reported the same way whether it was discovered by the walk or named
83/// on the command line. `ignore` itself detects directory symlink cycles
84/// (reported as an `Err`, not a hang) and a mutual file-symlink pair hits the
85/// OS's own `ELOOP` first, also surfacing as an `Err` — see `tests/source.rs`
86/// and the fix-round-1 section of the task report for how this was verified.
87///
88/// Following links means a symlink and its target (or two hardlinks) can
89/// both be yielded here as distinct paths to the same file; `resolve`'s
90/// `push_unique` is what collapses those back down to one work item.
91fn walk(root: &Path, errors: &mut Vec<String>) -> Vec<PathBuf> {
92 let mut out = Vec::new();
93 for res in ignore::WalkBuilder::new(root)
94 .standard_filters(false)
95 .follow_links(true)
96 .build()
97 {
98 match res {
99 Ok(e) => {
100 if e.file_type().map_or(false, |t| t.is_file()) {
101 out.push(e.into_path());
102 }
103 }
104 Err(e) => errors.push(format!("{}: {e}", root.display())),
105 }
106 }
107 out
108}
109
110pub fn build_globs(pats: &[String]) -> anyhow::Result<Option<globset::GlobSet>> {
111 if pats.is_empty() {
112 return Ok(None);
113 }
114 let mut b = globset::GlobSetBuilder::new();
115 for p in pats {
116 b.add(globset::Glob::new(p)?);
117 }
118 Ok(Some(b.build()?))
119}