targrep 0.9.0

Search inside compressed log archives (.tgz, .tar.zst, .tar.xz, .tar.bz2, .tar.lz4) with ripgrep's flags and per-member line numbers
Documentation
#![allow(dead_code)] // each test file uses a different subset

use std::io::Write;
use std::path::PathBuf;

/// Owns the `TempDir`, so it must be kept alive for as long as `path` is used:
/// binding only the field (`let p = helpers::tgz(..).path;`) drops the temp dir
/// immediately and the file vanishes before you can open it.
pub struct Fixture {
    pub dir: tempfile::TempDir,
    pub path: PathBuf,
}

fn build_tar(members: &[(&str, &[u8])]) -> Vec<u8> {
    let mut b = tar::Builder::new(Vec::new());
    for (name, body) in members {
        let mut h = tar::Header::new_gnu();
        h.set_size(body.len() as u64);
        h.set_mode(0o644);
        h.set_mtime(1_756_000_000);
        h.set_entry_type(tar::EntryType::Regular);
        h.set_cksum();
        b.append_data(&mut h, name, &mut &body[..]).unwrap();
    }
    b.into_inner().unwrap()
}

fn write(dir: tempfile::TempDir, name: &str, bytes: &[u8]) -> Fixture {
    let path = dir.path().join(name);
    std::fs::create_dir_all(path.parent().unwrap()).unwrap();
    std::fs::File::create(&path).unwrap().write_all(bytes).unwrap();
    Fixture { dir, path }
}

fn zstd_bytes(raw: &[u8]) -> Vec<u8> {
    zstd::stream::encode_all(raw, 3).unwrap()
}

/// A `.tar.zst`: same shape as `tgz`, different compression wrapper.
pub fn tar_zst(name: &str, members: &[(&str, &[u8])]) -> Fixture {
    let dir = tempfile::tempdir().unwrap();
    write(dir, name, &zstd_bytes(&build_tar(members)))
}

/// A `.tar.zst` whose member is itself zstd-compressed — what logrotate
/// produces on a zstd-configured host.
pub fn tar_zst_nested_zst(name: &str, inner: &str, body: &[u8]) -> Fixture {
    let inner_zst = zstd_bytes(body);
    tar_zst(name, &[(inner, inner_zst.as_slice())])
}

/// A `.tgz` whose member is zstd-compressed — mixed nesting, which is what a
/// host mid-migration actually leaves behind.
pub fn tgz_nested_zst(name: &str, inner: &str, body: &[u8]) -> Fixture {
    let inner_zst = zstd_bytes(body);
    tgz(name, &[(inner, inner_zst.as_slice())])
}

/// `bytes` behind a zstd skippable frame, the way pzstd writes its output:
/// magic 0x184D2A50, a 4-byte size, then that many bytes of metadata.
pub fn with_skippable_frame(bytes: &[u8]) -> Vec<u8> {
    let mut out = vec![0x50, 0x2A, 0x4D, 0x18, 4, 0, 0, 0, 0xDE, 0xAD, 0xBE, 0xEF];
    out.extend_from_slice(bytes);
    out
}

/// A `.tar.zst` whose stream starts with a skippable frame.
pub fn tar_zst_skippable(name: &str, members: &[(&str, &[u8])]) -> Fixture {
    let dir = tempfile::tempdir().unwrap();
    write(dir, name, &with_skippable_frame(&zstd_bytes(&build_tar(members))))
}

/// A `.tgz` whose member is zstd behind a skippable frame.
pub fn tgz_nested_zst_skippable(name: &str, inner: &str, body: &[u8]) -> Fixture {
    let inner_zst = with_skippable_frame(&zstd_bytes(body));
    tgz(name, &[(inner, inner_zst.as_slice())])
}

/// zstd with a 2^`window_log` window and long-distance matching, as
/// `zstd --long=N` writes it. Streamed with no pledged size, so the frame
/// header declares the full window however small the content.
pub fn zstd_long_bytes(raw: &[u8], window_log: u32) -> Vec<u8> {
    use zstd::stream::raw::CParameter;
    let mut e = zstd::stream::write::Encoder::new(Vec::new(), 3).unwrap();
    e.set_parameter(CParameter::WindowLog(window_log)).unwrap();
    e.set_parameter(CParameter::EnableLongDistanceMatching(true)).unwrap();
    e.write_all(raw).unwrap();
    e.finish().unwrap()
}

/// A `.tar.zst` made like `tar ... | zstd --long=N`.
pub fn tar_zst_long(name: &str, members: &[(&str, &[u8])], window_log: u32) -> Fixture {
    let dir = tempfile::tempdir().unwrap();
    write(dir, name, &zstd_long_bytes(&build_tar(members), window_log))
}

/// A `.tar.zst` cut off mid-stream.
pub fn tar_zst_truncated(name: &str, members: &[(&str, &[u8])], keep: f64) -> Fixture {
    let full = zstd_bytes(&build_tar(members));
    let n = ((full.len() as f64) * keep) as usize;
    let dir = tempfile::tempdir().unwrap();
    write(dir, name, &full[..n.max(8)])
}

pub fn xz_bytes(raw: &[u8]) -> Vec<u8> {
    let mut e = liblzma::write::XzEncoder::new(Vec::new(), 6);
    e.write_all(raw).unwrap();
    e.finish().unwrap()
}

/// A `.tar.xz`: same shape as `tgz`, different compression wrapper.
pub fn tar_xz(name: &str, members: &[(&str, &[u8])]) -> Fixture {
    let dir = tempfile::tempdir().unwrap();
    write(dir, name, &xz_bytes(&build_tar(members)))
}

/// A `.tar.xz` whose member is itself xz-compressed — what logrotate
/// produces with `compresscmd xz`.
pub fn tar_xz_nested_xz(name: &str, inner: &str, body: &[u8]) -> Fixture {
    let inner_xz = xz_bytes(body);
    tar_xz(name, &[(inner, inner_xz.as_slice())])
}

/// A `.tgz` whose member is xz-compressed — mixed nesting.
pub fn tgz_nested_xz(name: &str, inner: &str, body: &[u8]) -> Fixture {
    let inner_xz = xz_bytes(body);
    tgz(name, &[(inner, inner_xz.as_slice())])
}

/// A `.tar.xz` cut off mid-stream.
pub fn tar_xz_truncated(name: &str, members: &[(&str, &[u8])], keep: f64) -> Fixture {
    let full = xz_bytes(&build_tar(members));
    let n = ((full.len() as f64) * keep) as usize;
    let dir = tempfile::tempdir().unwrap();
    write(dir, name, &full[..n.max(8)])
}

fn gzip(raw: &[u8]) -> Vec<u8> {
    let mut e = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
    e.write_all(raw).unwrap();
    e.finish().unwrap()
}

/// A normal `.tgz`.
pub fn tgz(name: &str, members: &[(&str, &[u8])]) -> Fixture {
    let dir = tempfile::tempdir().unwrap();
    write(dir, name, &gzip(&build_tar(members)))
}

/// An uncompressed `.tar`, to prove sniffing handles both.
pub fn tar_plain(name: &str, members: &[(&str, &[u8])]) -> Fixture {
    let dir = tempfile::tempdir().unwrap();
    write(dir, name, &build_tar(members))
}

/// A `.tgz` cut off mid-stream, as logrotate leaves one mid-write.
pub fn tgz_truncated(name: &str, members: &[(&str, &[u8])], keep: f64) -> Fixture {
    let full = gzip(&build_tar(members));
    let n = ((full.len() as f64) * keep) as usize;
    let dir = tempfile::tempdir().unwrap();
    write(dir, name, &full[..n.max(64)])
}

/// A `.tgz` containing a hardlink entry, which carries no content of its own.
/// This is the case that silently searched nothing during the investigation.
pub fn tgz_with_hardlink(name: &str) -> Fixture {
    let body = b"NEEDLE in the real member\n";
    let mut b = tar::Builder::new(Vec::new());

    let mut h = tar::Header::new_gnu();
    h.set_size(body.len() as u64);
    h.set_mode(0o644);
    h.set_entry_type(tar::EntryType::Regular);
    h.set_cksum();
    b.append_data(&mut h, "logs/real.log", &mut &body[..]).unwrap();

    let mut l = tar::Header::new_gnu();
    l.set_size(0);
    l.set_mode(0o644);
    l.set_entry_type(tar::EntryType::Link);
    l.set_link_name("logs/real.log").unwrap();
    l.set_cksum();
    b.append_data(&mut l, "logs/linked.log", &mut std::io::empty()).unwrap();

    let raw = b.into_inner().unwrap();
    let dir = tempfile::tempdir().unwrap();
    write(dir, name, &gzip(&raw))
}

/// A member whose path exceeds tar's 100-byte name field, forcing GNU
/// `././@LongLink` or pax extension records.
pub fn tgz_with_long_name(name: &str, depth: usize) -> Fixture {
    let long = std::iter::repeat_n("averylongdirectorycomponent", depth)
        .collect::<Vec<_>>()
        .join("/");
    let member = format!("{long}/access.log");
    tgz(name, &[(member.as_str(), b"NEEDLE deep\n")])
}

/// A `.tgz` whose member is itself gzipped — what logrotate actually produces.
pub fn tgz_nested_gz(name: &str, inner: &str, body: &[u8]) -> Fixture {
    let inner_gz = gzip(body);
    tgz(name, &[(inner, inner_gz.as_slice())])
}

/// Per-file page-cache residency via mmap + mincore. Linux only: this is a
/// direct port of the `resident.c` used to produce the spec's measurements.
#[cfg(target_os = "linux")]
pub fn resident_pages(path: &std::path::Path) -> (usize, usize) {
    use std::os::unix::io::AsRawFd;
    let file = std::fs::File::open(path).unwrap();
    let len = file.metadata().unwrap().len() as usize;
    assert!(len > 0, "cannot measure residency of an empty file");
    let ps = unsafe { libc::sysconf(libc::_SC_PAGESIZE) } as usize;
    let np = len.div_ceil(ps);
    let m = unsafe {
        libc::mmap(std::ptr::null_mut(), len, libc::PROT_READ,
                   libc::MAP_SHARED, file.as_raw_fd(), 0)
    };
    assert_ne!(m, libc::MAP_FAILED, "mmap failed");
    let mut vec = vec![0u8; np];
    let rc = unsafe { libc::mincore(m, len, vec.as_mut_ptr() as *mut _) };
    assert_eq!(rc, 0, "mincore failed");
    let res = vec.iter().filter(|b| *b & 1 == 1).count();
    unsafe { libc::munmap(m, len) };
    (res, np)
}

/// Drop a file's pages so each measurement starts from a known state.
/// `/proc/sys/vm/drop_caches` is read-only in a container, so this is the
/// only reliable way to reset.
#[cfg(target_os = "linux")]
pub fn evict(path: &std::path::Path) {
    use std::os::unix::io::AsRawFd;
    let file = std::fs::File::open(path).unwrap();
    unsafe {
        libc::posix_fadvise(file.as_raw_fd(), 0, 0, libc::POSIX_FADV_DONTNEED);
    }
}

/// ~8 MB of mostly-unique log text, so residency figures are unambiguous.
pub fn bulky_log() -> Vec<u8> {
    (0..300_000u32)
        .flat_map(|i| format!("2026-08-24T00:00:00Z req {i} GET /path/{i}?q={i}\n").into_bytes())
        .collect()
}

/// Run the `trg` binary with a **bounded** wait: a hang fails the test loudly
/// instead of stalling the whole suite. `Command::output` would block forever
/// on a deadlocked child, and the scheduler is exactly the kind of code where
/// that has happened before.
pub fn run_trg(args: &[&str]) -> (String, String, Option<i32>) {
    use std::process::{Command, Stdio};
    use std::sync::mpsc;

    let child = Command::new(env!("CARGO_BIN_EXE_trg"))
        .args(args)
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .unwrap();

    let (tx, rx) = mpsc::channel();
    std::thread::spawn(move || {
        let _ = tx.send(child.wait_with_output());
    });
    let out = rx
        .recv_timeout(std::time::Duration::from_secs(60))
        .expect("trg did not exit within 60s")
        .unwrap();

    (
        String::from_utf8_lossy(&out.stdout).into_owned(),
        String::from_utf8_lossy(&out.stderr).into_owned(),
        out.status.code(),
    )
}

/// A member that matches early and then turns binary, which is what trips
/// `SummarySink::finish` into zeroing its own match count. The filler matters:
/// the match must be reported well before the NUL bytes are reached, so the
/// searcher genuinely matched and *then* quit.
pub fn match_then_binary() -> Vec<u8> {
    let mut body = b"NEEDLE first\n".to_vec();
    body.extend(std::iter::repeat_n(b'x', 320 * 1024));
    body.push(b'\n');
    body.extend_from_slice(&[0u8; 64]);
    body.push(b'\n');
    body
}

pub fn bz2_bytes(raw: &[u8]) -> Vec<u8> {
    let mut e = bzip2::write::BzEncoder::new(Vec::new(), bzip2::Compression::default());
    e.write_all(raw).unwrap();
    e.finish().unwrap()
}

/// A `.tar.bz2`: same shape as `tgz`, different compression wrapper.
pub fn tar_bz2(name: &str, members: &[(&str, &[u8])]) -> Fixture {
    let dir = tempfile::tempdir().unwrap();
    write(dir, name, &bz2_bytes(&build_tar(members)))
}

/// A `.tar.bz2` the way pbzip2 and lbzip2 write it: one bzip2 stream per
/// `chunk` bytes of input, concatenated.
pub fn tar_bz2_multistream(name: &str, members: &[(&str, &[u8])], chunk: usize) -> Fixture {
    let tar = build_tar(members);
    let out: Vec<u8> = tar.chunks(chunk).flat_map(bz2_bytes).collect();
    let dir = tempfile::tempdir().unwrap();
    write(dir, name, &out)
}

/// A `.tar.bz2` cut off mid-stream.
pub fn tar_bz2_truncated(name: &str, members: &[(&str, &[u8])], keep: f64) -> Fixture {
    let full = bz2_bytes(&build_tar(members));
    let n = ((full.len() as f64) * keep) as usize;
    let dir = tempfile::tempdir().unwrap();
    write(dir, name, &full[..n.max(12)])
}

/// The lz4 frame format, what the `lz4` binary writes by default.
pub fn lz4_bytes(raw: &[u8]) -> Vec<u8> {
    let mut e = lz4_flex::frame::FrameEncoder::new(Vec::new());
    e.write_all(raw).unwrap();
    e.finish().unwrap()
}

/// The legacy format, `lz4 -l`: a magic, then blocks of up to 8 MiB of
/// input, each a 4-byte little-endian compressed length and an lz4 block.
/// Built by hand because lz4_flex only writes the frame format.
pub fn lz4_legacy_bytes(raw: &[u8]) -> Vec<u8> {
    let mut out = vec![0x02, 0x21, 0x4C, 0x18];
    for chunk in raw.chunks(8 << 20) {
        let block = lz4_flex::block::compress(chunk);
        out.extend_from_slice(&(block.len() as u32).to_le_bytes());
        out.extend_from_slice(&block);
    }
    out
}

/// A `.tar.lz4`.
pub fn tar_lz4(name: &str, members: &[(&str, &[u8])]) -> Fixture {
    let dir = tempfile::tempdir().unwrap();
    write(dir, name, &lz4_bytes(&build_tar(members)))
}

/// A `.tar.lz4` cut off mid-stream.
pub fn tar_lz4_truncated(name: &str, members: &[(&str, &[u8])], keep: f64) -> Fixture {
    let full = lz4_bytes(&build_tar(members));
    let n = ((full.len() as f64) * keep) as usize;
    let dir = tempfile::tempdir().unwrap();
    write(dir, name, &full[..n.max(8)])
}

/// Any bytes as an archive file of their own.
pub fn raw(name: &str, bytes: &[u8]) -> Fixture {
    write(tempfile::tempdir().unwrap(), name, bytes)
}

/// The tar of `members`, uncompressed, for wrapping by hand.
pub fn tar_bytes(members: &[(&str, &[u8])]) -> Vec<u8> {
    build_tar(members)
}