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 /// The archive's format cannot tell a complete file from a truncated one
36 /// (`.Z`), so "read everything" is only as true as the file. Set by
37 /// [`walk_archive`]; what to say about it is the caller's, since only
38 /// the caller knows whether anything matched.
39 pub unverified_end: bool,
40}
41
42/// Drops an archive's page-cache footprint when the reader that owns it dies.
43///
44/// An RAII guard rather than an explicit call after the member walk, for two
45/// reasons. It keeps the `open_decoded` signature unchanged,
46/// so nothing that already builds on them has to move; and, more importantly,
47/// it fires on the **error** paths too. A truncated archive — logrotate caught
48/// mid-write, the case `tgz_truncated` exists for — bails out of
49/// `for_each_member` early, and its pages are exactly the ones we still want
50/// gone.
51///
52/// The handle here is a `dup` of the one the reader is using. Both share one
53/// open file description, so `F_NOCACHE` set on either applies to both, and
54/// `posix_fadvise` addresses the inode's pages regardless of which fd it is
55/// called through.
56struct CacheGuard {
57 file: File,
58 drop_cache: bool,
59}
60
61impl Drop for CacheGuard {
62 fn drop(&mut self) {
63 crate::resource::finish(&self.file, self.drop_cache);
64 }
65}
66
67/// A reader that carries its `CacheGuard`. Field order is load-bearing: Rust
68/// drops fields in declaration order, so `inner` releases the decompressor and
69/// the underlying handle before `guard` issues `FADV_DONTNEED`.
70struct Guarded<R> {
71 inner: R,
72 /// Never read — it exists only for its `Drop`. Hence the underscore.
73 _guard: CacheGuard,
74}
75
76impl<R: Read> Read for Guarded<R> {
77 fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
78 self.inner.read(buf)
79 }
80}
81
82// The guard is only ever read at drop time; `File` is `Send`, so the whole
83// wrapper is, which is what lets it box into `Box<dyn Read + Send>` for the
84// worker threads of Task 10.
85impl<R: Read + Send> Guarded<R> {
86 fn boxed(inner: R, guard: CacheGuard) -> Box<dyn Read + Send>
87 where
88 R: 'static,
89 {
90 Box::new(Guarded { inner, _guard: guard })
91 }
92}
93
94/// Open a path and strip one layer of compression if present.
95///
96/// Signature frozen: Tasks 4 and 8 build on it. Cache-dropping behaviour is
97/// reached through [`open_decoded_dropping`].
98pub fn open_decoded(path: &Path) -> anyhow::Result<Box<dyn Read + Send>> {
99 open_stream(path, false)
100}
101
102/// `open_decoded`, plus the option to hand the archive's pages back to the
103/// kernel once it has been read.
104///
105/// Cache-dropping is decided **here**, per input, and applies to archives
106/// only: `Format::Plain` is left alone no matter what the operator asked for.
107/// Apache holds its live logfiles open for append, and evicting those pages
108/// would evict cache another process is actively using — precisely the harm
109/// this feature exists to prevent.
110pub fn open_decoded_dropping(path: &Path, drop_cache: bool) -> anyhow::Result<Box<dyn Read + Send>> {
111 open_stream(path, drop_cache)
112}
113
114/// The streaming open. `prepare` runs after the sniff rather than immediately
115/// after `File::open`, because until the head has been read we do not know
116/// whether this is an archive we are allowed to touch.
117///
118/// The cost of that ordering is paid on macOS, and it is larger than the
119/// 512-byte sniff suggests: the file is wrapped in a 64 KiB `BufReader` before
120/// `peek`, so the first read pulls **64 KiB — 16 pages — into the cache before
121/// `F_NOCACHE` is set**. It is paid on *archives*, which are the only inputs
122/// `F_NOCACHE` is ever applied to; plain files are excluded by design and stay
123/// fully cached regardless. Sixteen pages per archive against the alternative
124/// of setting `F_NOCACHE` on a live Apache log is the trade being made.
125fn open_stream(path: &Path, drop_cache: bool) -> anyhow::Result<Box<dyn Read + Send>> {
126 match open_input(path, drop_cache)? {
127 Opened::Stream(r, _) => Ok(r),
128 Opened::Zip(..) => anyhow::bail!("a zip is not a stream; it is read through walk_archive"),
129 }
130}
131
132/// An input, opened: a byte stream with its outer compression removed, or a
133/// zip, which is read by seeking and so cannot be one.
134enum Opened {
135 /// The stream, and whether its format can detect its own truncation.
136 Stream(Box<dyn Read + Send>, bool),
137 /// The file, rewound, and the guard that drops its pages when the walk is
138 /// over.
139 Zip(File, CacheGuard),
140}
141
142fn open_input(path: &Path, drop_cache: bool) -> anyhow::Result<Opened> {
143 let f = File::open(path)?;
144 let dup = f.try_clone()?;
145 let rdr = io::BufReader::with_capacity(64 * 1024, f);
146 let (head, body) = peek(rdr)?;
147
148 let format = sniff(&head);
149 let drop_cache = drop_cache && format != Format::Plain;
150 crate::resource::prepare(&dup, drop_cache);
151 let guard = CacheGuard { file: dup, drop_cache };
152
153 let unverified_end = format == Format::CompressZ;
154 let stream = match format {
155 Format::Gzip => Guarded::boxed(flate2::read::MultiGzDecoder::new(body), guard),
156 // `zstd`'s reader is streaming like flate2's, so the bounded-memory
157 // guarantee and the cache guard carry over unchanged.
158 Format::Zstd => Guarded::boxed(zstd_decoder(body)?, guard),
159 // Multi-stream, like `MultiGzDecoder`: `cat a.xz b.xz` is a valid .xz
160 // and `xz -d` decodes all of it.
161 Format::Xz => Guarded::boxed(liblzma::read::XzDecoder::new_multi_decoder(body), guard),
162 // Multi-stream too, and here it is not a corner case: pbzip2 and
163 // lbzip2 write one stream per chunk, so a single-stream decoder would
164 // stop after the first few hundred kilobytes and report the rest clean.
165 Format::Bzip2 => Guarded::boxed(bzip2::read::MultiBzDecoder::new(body), guard),
166 Format::Lz4 => Guarded::boxed(lz4_decoder(&head, body)?, guard),
167 Format::CompressZ => Guarded::boxed(compress_z_decoder(body)?, guard),
168 Format::Zip => {
169 // Shares the open file description with the reader just dropped,
170 // and so its offset: past the sniff, not at the start.
171 drop(body);
172 let mut file = guard.file.try_clone()?;
173 io::Seek::rewind(&mut file)?;
174 return Ok(Opened::Zip(file, guard));
175 }
176 Format::Tar | Format::Plain => Guarded::boxed(body, guard),
177 };
178 Ok(Opened::Stream(stream, unverified_end))
179}
180
181/// `.Z`, through stuffr's decoder.
182///
183/// The format has no length and no checksum, so a truncated `.Z` decodes
184/// cleanly to a shorter file, wherever the cut falls: the one format here
185/// where a 1 is only as good as the file it was read from. Hence
186/// [`Outcome::unverified_end`]. stuffr's decoder is reachable
187/// only as a codec over a reader it owns, which is why a `.Z` *member* inside
188/// an archive is refused rather than decoded; see [`decode_member`].
189fn compress_z_decoder<R: Read + Send + 'static>(r: R) -> io::Result<Box<dyn stuffr_core::Source>> {
190 use stuffr_core::Codec;
191 stuffr_formats::legacy::compress_z::CompressZ
192 .decoder(Box::new(stuffr_core::ReaderSource::new(r)), &Default::default())
193 .map_err(io::Error::other)
194}
195
196/// Open `path` and hand each member to `f`, whatever kind of archive it is:
197/// a tar under any one compression, a zip, or a single compressed or plain
198/// file as one anonymous member. Never returns `Err`, for the reason
199/// [`for_each_member`] gives; a file that cannot be opened is an error on its
200/// [`Outcome`].
201pub fn walk_archive<F>(
202 path: &Path,
203 drop_cache: bool,
204 globs: Option<&crate::source::Globs>,
205 f: F,
206) -> Outcome
207where
208 F: FnMut(&str, &mut dyn Read) -> io::Result<()>,
209{
210 let mut out = match open_input(path, drop_cache) {
211 Ok(Opened::Stream(r, unverified_end)) => Outcome {
212 unverified_end,
213 ..for_each_member(r, globs, f)
214 },
215 // The guard outlives the walk, so the pages go once it is over.
216 Ok(Opened::Zip(file, _guard)) => for_each_zip_member(file, globs, f),
217 Err(e) => {
218 let mut o = Outcome::default();
219 o.errors.push(e.to_string());
220 o
221 }
222 };
223 // Named here, once, for every way a walk can fail. A sweep of a month of
224 // archives that says only "unexpected end of file" has not said which
225 // one to look at.
226 for e in &mut out.errors {
227 *e = format!("{}: {e}", path.display());
228 }
229 out
230}
231
232/// Walk a zip's members through stuffr's zip container.
233///
234/// Read from the central directory, by seeking, which is why a zip goes
235/// through here and not through the streaming path: the local headers alone
236/// can leave sizes unknown, and the directory at the end is the archive's own
237/// account of what it holds. Only a whole file can be read this way, so a zip
238/// inside another archive is searched as bytes, like any member that is not
239/// compressed text.
240///
241/// Anything stuffr reports about the archive itself — records it could not
242/// account for, such as two sharing one name — lands in `errors`, not
243/// `notes`: those are members that were not searched.
244fn for_each_zip_member<F>(file: File, globs: Option<&crate::source::Globs>, mut f: F) -> Outcome
245where
246 F: FnMut(&str, &mut dyn Read) -> io::Result<()>,
247{
248 use stuffr_core::{Container, EntryKind};
249 use stuffr_formats::zip::{Zip, ZIP};
250
251 let mut out = Outcome::default();
252 let opened = stuffr_core::FileSource::from_file(file).and_then(|src| {
253 let resolved =
254 stuffr_core::resolve(Box::new(src), ZIP, Zip.caps(), &stuffr_core::StreamPolicy::Exact)?;
255 Zip.open(resolved, &Default::default())
256 });
257 let mut archive = match opened {
258 Ok(a) => a,
259 Err(e) => {
260 out.errors.push(format!("not a readable zip: {e}"));
261 return out;
262 }
263 };
264
265 // Records stuffr failed to hand back in a row. Read by index from the
266 // central directory, each call moves on to the next record whether or not
267 // this one could be read — an encrypted member is one bad record, not the
268 // end of the archive — but that is stuffr's behaviour and not its
269 // contract, so a run of failures this long ends the walk rather than
270 // trusting it to.
271 const GIVE_UP_AFTER: usize = 1000;
272 let mut failed_in_a_row = 0;
273 let mut index = 0usize;
274 loop {
275 index += 1;
276 let mut entry = match archive.next_entry() {
277 Ok(Some(e)) => e,
278 Ok(None) => break,
279 Err(e) => {
280 out.errors.push(format!("zip entry {index}: {e}"));
281 failed_in_a_row += 1;
282 if failed_in_a_row >= GIVE_UP_AFTER {
283 out.errors.push(format!("gave up after {GIVE_UP_AFTER} unreadable zip entries in a row"));
284 break;
285 }
286 continue;
287 }
288 };
289 failed_in_a_row = 0;
290 if !matches!(entry.meta().kind, EntryKind::File) {
291 continue;
292 }
293 let name = entry.meta().name.clone();
294 out.members_seen += 1;
295 if globs.is_some_and(|set| !set.is_match(&name)) {
296 continue;
297 }
298 match decode_member(&name, entry.reader(), &mut f) {
299 Ok(()) => out.members_searched += 1,
300 Err(e) => out.errors.push(format!("{name}: {e}")),
301 }
302 }
303
304 let report = archive.fidelity();
305 if report.has_warnings() {
306 out.errors.push(format!("{report:?}"));
307 }
308 out
309}
310
311/// A zstd decoder that accepts any window the format allows, up to 2^31.
312///
313/// The decoder's default ceiling is 2^27 (128 MB), the same as the `zstd`
314/// binary's, and archives made with `zstd --long=28` or more were refused:
315/// "Frame requires too much memory for decoding", exit 2. The window is chosen
316/// by whoever made the archive, so refusing it leaves those archives
317/// unsearchable; trg has no flag to raise it because nobody can know they need
318/// one until an incident. The memory is only ever as large as the archive's
319/// own frame header declares.
320fn zstd_decoder<'a, R: Read>(r: R) -> io::Result<zstd::stream::read::Decoder<'a, io::BufReader<R>>> {
321 let mut d = zstd::stream::read::Decoder::new(r)?;
322 d.window_log_max(31)?;
323 Ok(d)
324}
325
326/// An lz4 decoder positioned past any leading skippable frames, reading every
327/// frame to the end of the input.
328///
329/// lz4_flex reads `lz4 -l` legacy frames by itself, but stops with an error
330/// at a skippable frame. Only a leading one is stepped over here, which the
331/// sniff has already measured; one between data frames is still an error, and
332/// so still a 2 rather than a short read.
333fn lz4_decoder<R: Read>(head: &[u8], mut r: R) -> io::Result<Lz4Frames<R>> {
334 let skip = crate::sniff::skippable_prefix(head).unwrap_or(0) as u64;
335 io::copy(&mut (&mut r).take(skip), &mut io::sink())?;
336 Ok(Lz4Frames(lz4_flex::frame::FrameDecoder::new(io::BufReader::new(r))))
337}
338
339/// lz4_flex's decoder reports end-of-file at the end of every frame, so that
340/// a caller can tell frames apart. `cat a.lz4 b.lz4` is one valid lz4 file,
341/// and stopping at the first frame boundary silently drops the rest. A `0` is
342/// passed on only when the input itself is exhausted; anything left means
343/// another frame, which the next read starts.
344struct Lz4Frames<R: Read>(lz4_flex::frame::FrameDecoder<io::BufReader<R>>);
345
346impl<R: Read> Read for Lz4Frames<R> {
347 fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
348 use io::BufRead;
349 loop {
350 let n = self.0.read(buf)?;
351 if n > 0 || buf.is_empty() || self.0.get_mut().fill_buf()?.is_empty() {
352 return Ok(n);
353 }
354 }
355 }
356}
357
358/// Read the sniff window, then hand back a reader with those bytes replayed in
359/// front of the remainder.
360///
361/// The return type is concrete rather than `impl Read`, and carries no `Send`
362/// bound: `Send`-ness is then inferred per call site. `open_decoded`'s
363/// `BufReader<File>` chain is still `Send` (so it boxes into
364/// `Box<dyn Read + Send>` for the worker threads of Task 10), while
365/// `for_each_member` can also call this on a `&mut tar::Entry<'_, R>`, which is
366/// not `Send`.
367/// The sniffed head, and a reader that replays it before the rest.
368type Peeked<R> = (Vec<u8>, io::Chain<io::Cursor<Vec<u8>>, R>);
369
370fn peek<R: Read>(mut rdr: R) -> io::Result<Peeked<R>> {
371 let mut head = vec![0u8; SNIFF_LEN];
372 let mut filled = 0;
373 while filled < head.len() {
374 match rdr.read(&mut head[filled..])? {
375 0 => break,
376 n => filled += n,
377 }
378 }
379 head.truncate(filled);
380 let replay = io::Cursor::new(head.clone());
381 Ok((head, replay.chain(rdr)))
382}
383
384/// Hand one member to `f`, with one level of compression removed.
385///
386/// Exactly one level of nesting: logrotate puts `access.log.1.gz` inside the
387/// archive. Fixed at one level — that cap is the bomb guard. Every format is
388/// checked, because a check that named only gzip would silently hand a
389/// compressed member to the searcher as binary.
390fn decode_member<F>(name: &str, member: &mut dyn Read, f: &mut F) -> io::Result<()>
391where
392 F: FnMut(&str, &mut dyn Read) -> io::Result<()>,
393{
394 let (inner_head, mut inner) = peek(member)?;
395 match sniff(&inner_head) {
396 Format::Gzip => f(name, &mut flate2::read::MultiGzDecoder::new(&mut inner)),
397 Format::Zstd => f(name, &mut zstd_decoder(&mut inner)?),
398 Format::Xz => f(name, &mut liblzma::read::XzDecoder::new_multi_decoder(&mut inner)),
399 Format::Bzip2 => f(name, &mut bzip2::read::MultiBzDecoder::new(&mut inner)),
400 Format::Lz4 => f(name, &mut lz4_decoder(&inner_head, &mut inner)?),
401 // Searched as it is, this would be binary and skipped: a member never
402 // read, called clean. Unread is a 2.
403 Format::CompressZ => Err(io::Error::other(
404 ".Z (compress) data is read only as a whole file, not inside an archive",
405 )),
406 _ => f(name, &mut inner),
407 }
408}
409
410/// Walk every regular member, handing each to `f` as its own reader.
411///
412/// Never returns `Err`: a failure on one member is recorded in `Outcome.errors`
413/// and iteration continues, because one bad archive must not abort the others.
414pub fn for_each_member<R, F>(rdr: R, globs: Option<&crate::source::Globs>, mut f: F) -> Outcome
415where
416 R: Read,
417 F: FnMut(&str, &mut dyn Read) -> io::Result<()>,
418{
419 let mut out = Outcome::default();
420
421 let (head, mut body) = match peek(rdr) {
422 Ok(v) => v,
423 Err(e) => {
424 out.errors.push(format!("read failed: {e}"));
425 return out;
426 }
427 };
428
429 // Not a tar at all: the whole input is a single anonymous member.
430 if sniff(&head) != Format::Tar {
431 out.members_seen = 1;
432 match f("", &mut body) {
433 Ok(()) => out.members_searched = 1,
434 Err(e) => out.errors.push(format!("search failed: {e}")),
435 }
436 return out;
437 }
438
439 let mut ar = tar::Archive::new(body);
440 let entries = match ar.entries() {
441 Ok(e) => e,
442 Err(e) => {
443 out.errors.push(format!("not a readable tar: {e}"));
444 return out;
445 }
446 };
447
448 for entry in entries {
449 let mut entry = match entry {
450 Ok(e) => e,
451 Err(e) => {
452 // Truncated or corrupt: stop this archive, keep what we had.
453 out.errors.push(format!("tar entry: {e}"));
454 break;
455 }
456 };
457
458 // `is_file()` is exactly `== Regular`, which excludes hardlinks,
459 // symlinks, dirs, devices, FIFOs, GNU sparse, @LongLink and pax
460 // headers in one check. Hardlinks in particular carry no content.
461 if !entry.header().entry_type().is_file() {
462 continue;
463 }
464
465 let name = match entry.path() {
466 Ok(p) => p.to_string_lossy().into_owned(),
467 Err(e) => {
468 out.errors.push(format!("bad member path: {e}"));
469 continue;
470 }
471 };
472
473 out.members_seen += 1;
474
475 if globs.is_some_and(|set| !set.is_match(&name)) {
476 continue;
477 }
478
479 let res = decode_member(&name, &mut entry, &mut f);
480 match res {
481 Ok(()) => out.members_searched += 1,
482 Err(e) => out.errors.push(format!("{name}: {e}")),
483 }
484 }
485
486 out
487}