Skip to main content

trg/
archive.rs

1//! Turns one input path into a sequence of independently-readable members.
2//!
3//! The load-bearing decision: every tar member is handed to the caller as its
4//! own `Read`. That is what makes per-member line numbers, real member paths,
5//! and per-member binary detection all fall out for free.
6
7use std::fs::File;
8use std::io::{self, Read};
9use std::path::Path;
10
11use crate::sniff::{sniff, Format, SNIFF_LEN};
12
13/// What happened while walking one archive.
14///
15/// `errors` being non-empty is what marks the whole run partial (exit 2), and
16/// it is the **only** thing that does. In particular neither `members_seen !=
17/// members_searched` nor `members_searched == 0` may be read as a partial run:
18/// an archive whose members are all excluded by `-g` yields `seen = 2,
19/// searched = 0, errors = []`, and it was read completely. (`members_searched`
20/// also counts a glob-skipped plain file as searched, so it is not a sound
21/// basis for anything.)
22#[derive(Debug, Default)]
23pub struct Outcome {
24    pub members_seen: usize,
25    pub members_searched: usize,
26    pub errors: Vec<String>,
27    /// Whether any member matched. Set by the caller — `for_each_member` has
28    /// no opinion about what a match is — and it is the authoritative source
29    /// for exit code 0, in place of "did this archive write any bytes".
30    pub matched: bool,
31    /// Advisory lines for stderr. Deliberately **not** `errors`: a note says
32    /// the run read everything and printed less than it found, which is worth
33    /// telling an operator and must not turn a 0 or a 1 into a 2.
34    pub notes: Vec<String>,
35}
36
37/// Drops an archive's page-cache footprint when the reader that owns it dies.
38///
39/// An RAII guard rather than an explicit call after the member walk, for two
40/// reasons. It keeps the `open_decoded` signature unchanged,
41/// so nothing that already builds on them has to move; and, more importantly,
42/// it fires on the **error** paths too. A truncated archive — logrotate caught
43/// mid-write, the case `tgz_truncated` exists for — bails out of
44/// `for_each_member` early, and its pages are exactly the ones we still want
45/// gone.
46///
47/// The handle here is a `dup` of the one the reader is using. Both share one
48/// open file description, so `F_NOCACHE` set on either applies to both, and
49/// `posix_fadvise` addresses the inode's pages regardless of which fd it is
50/// called through.
51struct CacheGuard {
52    file: File,
53    drop_cache: bool,
54}
55
56impl Drop for CacheGuard {
57    fn drop(&mut self) {
58        crate::resource::finish(&self.file, self.drop_cache);
59    }
60}
61
62/// A reader that carries its `CacheGuard`. Field order is load-bearing: Rust
63/// drops fields in declaration order, so `inner` releases the decompressor and
64/// the underlying handle before `guard` issues `FADV_DONTNEED`.
65struct Guarded<R> {
66    inner: R,
67    /// Never read — it exists only for its `Drop`. Hence the underscore.
68    _guard: CacheGuard,
69}
70
71impl<R: Read> Read for Guarded<R> {
72    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
73        self.inner.read(buf)
74    }
75}
76
77// The guard is only ever read at drop time; `File` is `Send`, so the whole
78// wrapper is, which is what lets it box into `Box<dyn Read + Send>` for the
79// worker threads of Task 10.
80impl<R: Read + Send> Guarded<R> {
81    fn boxed(inner: R, guard: CacheGuard) -> Box<dyn Read + Send>
82    where
83        R: 'static,
84    {
85        Box::new(Guarded { inner, _guard: guard })
86    }
87}
88
89/// Open a path and strip one layer of compression if present.
90///
91/// Signature frozen: Tasks 4 and 8 build on it. Cache-dropping behaviour is
92/// reached through [`open_decoded_dropping`].
93pub fn open_decoded(path: &Path) -> anyhow::Result<Box<dyn Read + Send>> {
94    open_stream(path, false)
95}
96
97/// `open_decoded`, plus the option to hand the archive's pages back to the
98/// kernel once it has been read.
99///
100/// Cache-dropping is decided **here**, per input, and applies to archives
101/// only: `Format::Plain` is left alone no matter what the operator asked for.
102/// Apache holds its live logfiles open for append, and evicting those pages
103/// would evict cache another process is actively using — precisely the harm
104/// this feature exists to prevent.
105pub fn open_decoded_dropping(path: &Path, drop_cache: bool) -> anyhow::Result<Box<dyn Read + Send>> {
106    open_stream(path, drop_cache)
107}
108
109/// The streaming open. `prepare` runs after the sniff rather than immediately
110/// after `File::open`, because until the head has been read we do not know
111/// whether this is an archive we are allowed to touch.
112///
113/// The cost of that ordering is paid on macOS, and it is larger than the
114/// 512-byte sniff suggests: the file is wrapped in a 64 KiB `BufReader` before
115/// `peek`, so the first read pulls **64 KiB — 16 pages — into the cache before
116/// `F_NOCACHE` is set**. It is paid on *archives*, which are the only inputs
117/// `F_NOCACHE` is ever applied to; plain files are excluded by design and stay
118/// fully cached regardless. Sixteen pages per archive against the alternative
119/// of setting `F_NOCACHE` on a live Apache log is the trade being made.
120fn open_stream(path: &Path, drop_cache: bool) -> anyhow::Result<Box<dyn Read + Send>> {
121    let f = File::open(path)?;
122    let dup = f.try_clone()?;
123    let rdr = io::BufReader::with_capacity(64 * 1024, f);
124    let (head, body) = peek(rdr)?;
125
126    let format = sniff(&head);
127    let drop_cache = drop_cache && format != Format::Plain;
128    crate::resource::prepare(&dup, drop_cache);
129    let guard = CacheGuard { file: dup, drop_cache };
130
131    Ok(match format {
132        Format::Gzip => Guarded::boxed(flate2::read::MultiGzDecoder::new(body), guard),
133        // `zstd`'s reader is streaming like flate2's, so the bounded-memory
134        // guarantee and the cache guard carry over unchanged.
135        Format::Zstd => Guarded::boxed(zstd_decoder(body)?, guard),
136        // Multi-stream, like `MultiGzDecoder`: `cat a.xz b.xz` is a valid .xz
137        // and `xz -d` decodes all of it.
138        Format::Xz => Guarded::boxed(liblzma::read::XzDecoder::new_multi_decoder(body), guard),
139        // Multi-stream too, and here it is not a corner case: pbzip2 and
140        // lbzip2 write one stream per chunk, so a single-stream decoder would
141        // stop after the first few hundred kilobytes and report the rest clean.
142        Format::Bzip2 => Guarded::boxed(bzip2::read::MultiBzDecoder::new(body), guard),
143        Format::Lz4 => Guarded::boxed(lz4_decoder(&head, body)?, guard),
144        Format::Tar | Format::Plain => Guarded::boxed(body, guard),
145    })
146}
147
148/// A zstd decoder that accepts any window the format allows, up to 2^31.
149///
150/// The decoder's default ceiling is 2^27 (128 MB), the same as the `zstd`
151/// binary's, and archives made with `zstd --long=28` or more were refused:
152/// "Frame requires too much memory for decoding", exit 2. The window is chosen
153/// by whoever made the archive, so refusing it leaves those archives
154/// unsearchable; trg has no flag to raise it because nobody can know they need
155/// one until an incident. The memory is only ever as large as the archive's
156/// own frame header declares.
157fn zstd_decoder<'a, R: Read>(r: R) -> io::Result<zstd::stream::read::Decoder<'a, io::BufReader<R>>> {
158    let mut d = zstd::stream::read::Decoder::new(r)?;
159    d.window_log_max(31)?;
160    Ok(d)
161}
162
163/// An lz4 decoder positioned past any leading skippable frames, reading every
164/// frame to the end of the input.
165///
166/// lz4_flex reads `lz4 -l` legacy frames by itself, but stops with an error
167/// at a skippable frame. Only a leading one is stepped over here, which the
168/// sniff has already measured; one between data frames is still an error, and
169/// so still a 2 rather than a short read.
170fn lz4_decoder<R: Read>(head: &[u8], mut r: R) -> io::Result<Lz4Frames<R>> {
171    let skip = crate::sniff::skippable_prefix(head).unwrap_or(0) as u64;
172    io::copy(&mut (&mut r).take(skip), &mut io::sink())?;
173    Ok(Lz4Frames(lz4_flex::frame::FrameDecoder::new(io::BufReader::new(r))))
174}
175
176/// lz4_flex's decoder reports end-of-file at the end of every frame, so that
177/// a caller can tell frames apart. `cat a.lz4 b.lz4` is one valid lz4 file,
178/// and stopping at the first frame boundary silently drops the rest. A `0` is
179/// passed on only when the input itself is exhausted; anything left means
180/// another frame, which the next read starts.
181struct Lz4Frames<R: Read>(lz4_flex::frame::FrameDecoder<io::BufReader<R>>);
182
183impl<R: Read> Read for Lz4Frames<R> {
184    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
185        use io::BufRead;
186        loop {
187            let n = self.0.read(buf)?;
188            if n > 0 || buf.is_empty() || self.0.get_mut().fill_buf()?.is_empty() {
189                return Ok(n);
190            }
191        }
192    }
193}
194
195/// Read the sniff window, then hand back a reader with those bytes replayed in
196/// front of the remainder.
197///
198/// The return type is concrete rather than `impl Read`, and carries no `Send`
199/// bound: `Send`-ness is then inferred per call site. `open_decoded`'s
200/// `BufReader<File>` chain is still `Send` (so it boxes into
201/// `Box<dyn Read + Send>` for the worker threads of Task 10), while
202/// `for_each_member` can also call this on a `&mut tar::Entry<'_, R>`, which is
203/// not `Send`.
204/// The sniffed head, and a reader that replays it before the rest.
205type Peeked<R> = (Vec<u8>, io::Chain<io::Cursor<Vec<u8>>, R>);
206
207fn peek<R: Read>(mut rdr: R) -> io::Result<Peeked<R>> {
208    let mut head = vec![0u8; SNIFF_LEN];
209    let mut filled = 0;
210    while filled < head.len() {
211        match rdr.read(&mut head[filled..])? {
212            0 => break,
213            n => filled += n,
214        }
215    }
216    head.truncate(filled);
217    let replay = io::Cursor::new(head.clone());
218    Ok((head, replay.chain(rdr)))
219}
220
221/// Walk every regular member, handing each to `f` as its own reader.
222///
223/// Never returns `Err`: a failure on one member is recorded in `Outcome.errors`
224/// and iteration continues, because one bad archive must not abort the others.
225pub fn for_each_member<R, F>(rdr: R, globs: Option<&crate::source::Globs>, mut f: F) -> Outcome
226where
227    R: Read,
228    F: FnMut(&str, &mut dyn Read) -> io::Result<()>,
229{
230    let mut out = Outcome::default();
231
232    let (head, mut body) = match peek(rdr) {
233        Ok(v) => v,
234        Err(e) => {
235            out.errors.push(format!("read failed: {e}"));
236            return out;
237        }
238    };
239
240    // Not a tar at all: the whole input is a single anonymous member.
241    if sniff(&head) != Format::Tar {
242        out.members_seen = 1;
243        match f("", &mut body) {
244            Ok(()) => out.members_searched = 1,
245            Err(e) => out.errors.push(format!("search failed: {e}")),
246        }
247        return out;
248    }
249
250    let mut ar = tar::Archive::new(body);
251    let entries = match ar.entries() {
252        Ok(e) => e,
253        Err(e) => {
254            out.errors.push(format!("not a readable tar: {e}"));
255            return out;
256        }
257    };
258
259    for entry in entries {
260        let mut entry = match entry {
261            Ok(e) => e,
262            Err(e) => {
263                // Truncated or corrupt: stop this archive, keep what we had.
264                out.errors.push(format!("tar entry: {e}"));
265                break;
266            }
267        };
268
269        // `is_file()` is exactly `== Regular`, which excludes hardlinks,
270        // symlinks, dirs, devices, FIFOs, GNU sparse, @LongLink and pax
271        // headers in one check. Hardlinks in particular carry no content.
272        if !entry.header().entry_type().is_file() {
273            continue;
274        }
275
276        let name = match entry.path() {
277            Ok(p) => p.to_string_lossy().into_owned(),
278            Err(e) => {
279                out.errors.push(format!("bad member path: {e}"));
280                continue;
281            }
282        };
283
284        out.members_seen += 1;
285
286        if globs.is_some_and(|set| !set.is_match(&name)) {
287            continue;
288        }
289
290        // Exactly one level of nesting: logrotate puts access.log.1.gz inside
291        // the tgz. Fixed at one level — that cap is the bomb guard.
292        let (inner_head, mut inner) = match peek(&mut entry) {
293            Ok(v) => v,
294            Err(e) => {
295                out.errors.push(format!("{name}: {e}"));
296                continue;
297            }
298        };
299
300        // Exactly one level, and every compression format — logrotate leaves
301        // `access.log.1.gz` (or `.zst`, `.xz`, `.bz2`, `.lz4`) inside the archive, so a check that
302        // named only gzip would silently hand a compressed member to the
303        // searcher as binary.
304        let res = match sniff(&inner_head) {
305            Format::Gzip => {
306                let mut dec = flate2::read::MultiGzDecoder::new(&mut inner);
307                f(&name, &mut dec)
308            }
309            Format::Zstd => match zstd_decoder(&mut inner) {
310                Ok(mut dec) => f(&name, &mut dec),
311                Err(e) => Err(e),
312            },
313            Format::Xz => {
314                let mut dec = liblzma::read::XzDecoder::new_multi_decoder(&mut inner);
315                f(&name, &mut dec)
316            }
317            Format::Bzip2 => {
318                let mut dec = bzip2::read::MultiBzDecoder::new(&mut inner);
319                f(&name, &mut dec)
320            }
321            Format::Lz4 => match lz4_decoder(&inner_head, &mut inner) {
322                Ok(mut dec) => f(&name, &mut dec),
323                Err(e) => Err(e),
324            },
325            _ => f(&name, &mut inner),
326        };
327
328        match res {
329            Ok(()) => out.members_searched += 1,
330            Err(e) => out.errors.push(format!("{name}: {e}")),
331        }
332    }
333
334    out
335}