Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-cli/tests/crypt_stream.rs

#![cfg(feature = "client")]

//! **The crypt filter streams** (envelope v2, 2026-10-06): clean and smudge
//! hold a bounded amount of memory whatever the file's size, an unchanged
//! file keeps the envelope it already is (v1 stays v1, an older epoch stays
//! its epoch), and an envelope that does not open is left sealed at any size.
//!
//! No server here: a clone, its unwrapped key ring where `gunnar crypt
//! unlock` puts it, and git configured to run this binary's filter, exactly
//! as `gunnar crypt init` configures it. The large-file test speaks git's
//! long-running filter protocol to the filter directly and reads its peak
//! resident memory (VmHWM) before it exits.
//!
//! Sizes: `GUNNAR_CRYPT_STREAM_MIB` (default 64) and the bound
//! `GUNNAR_CRYPT_STREAM_RSS_MIB` (default 24) on what the large file adds to
//! the filter's peak after a 1 MiB warm-up file in the same process (the
//! binary's own pages — 44 MiB for a debug build — are not the file's). The
//! absolute peaks are printed too. The ignored
//! `bench_clean_smudge` measures throughput and peak memory, v1 against v2:
//! `GUNNAR_CRYPT_BENCH_BINS="v1=<old gunnar>,v2=<new gunnar>"`,
//! `GUNNAR_CRYPT_BENCH_SIZES="1K,1M,100M,2G"`; run it with `--release`.

use std::io::{BufRead, BufReader, BufWriter, Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
use std::time::Instant;

use gunnar_crypt::envelope;
use gunnar_crypt::keyring::Keyring;

const MIB: usize = 1 << 20;

fn gunnar_bin() -> PathBuf {
    PathBuf::from(env!("CARGO_BIN_EXE_gunnar-cli"))
}

fn env_usize(name: &str, default: usize) -> usize {
    std::env::var(name)
        .ok()
        .and_then(|v| v.parse().ok())
        .unwrap_or(default)
}

fn which(tool: &str) -> bool {
    Command::new(tool)
        .arg("--version")
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .is_ok()
}

// ── a clone with an unlocked ring ───────────────────────────────────────────

struct Clone {
    _dir: tempfile::TempDir,
    top: PathBuf,
    ring: Keyring,
}

fn git_cmd(dir: &Path) -> Command {
    let mut c = Command::new("git");
    c.current_dir(dir)
        .env("GIT_CONFIG_NOSYSTEM", "1")
        .env("GIT_CONFIG_GLOBAL", "/dev/null")
        .env("HOME", dir)
        .env("LC_ALL", "C")
        .env("GIT_AUTHOR_NAME", "member")
        .env("GIT_AUTHOR_EMAIL", "member@example.invalid")
        .env("GIT_COMMITTER_NAME", "member")
        .env("GIT_COMMITTER_EMAIL", "member@example.invalid");
    c
}

fn git(dir: &Path, args: &[&str]) -> Vec<u8> {
    let out = git_cmd(dir).args(args).output().expect("git runs");
    assert!(
        out.status.success(),
        "git {} failed: {}",
        args.join(" "),
        String::from_utf8_lossy(&out.stderr)
    );
    out.stdout
}

fn git_text(dir: &Path, args: &[&str]) -> String {
    String::from_utf8_lossy(&git(dir, args)).trim().to_string()
}

/// A clone whose ring has `epochs` epochs, configured as `gunnar crypt init`
/// does, with `bin` as the filter.
fn clone_with(epochs: u32, bin: &Path) -> Clone {
    let dir = tempfile::tempdir().unwrap();
    let top = dir.path().join("vault");
    std::fs::create_dir_all(&top).unwrap();
    git(&top, &["init", "-q", "-b", "main"]);
    let mut ring = Keyring::generate();
    for _ in 1..epochs {
        ring.rotate();
    }
    let state = top.join(".git/gunnar-crypt");
    std::fs::create_dir_all(&state).unwrap();
    std::fs::write(state.join("keyring"), ring.to_text().as_bytes()).unwrap();
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(
            state.join("keyring"),
            std::fs::Permissions::from_mode(0o600),
        )
        .unwrap();
    }
    let exe = bin.display().to_string();
    for (k, v) in [
        (
            "filter.gunnar-crypt.process",
            format!("'{exe}' crypt filter-process"),
        ),
        (
            "filter.gunnar-crypt.clean",
            format!("'{exe}' crypt clean %f"),
        ),
        (
            "filter.gunnar-crypt.smudge",
            format!("'{exe}' crypt smudge %f"),
        ),
        ("filter.gunnar-crypt.required", "true".to_string()),
    ] {
        git(&top, &["config", "--local", k, &v]);
    }
    std::fs::write(
        top.join(".gitattributes"),
        "* filter=gunnar-crypt\n.gitattributes !filter\n",
    )
    .unwrap();
    git(&top, &["add", ".gitattributes"]);
    git(&top, &["commit", "-q", "-m", "attributes"]);
    Clone {
        _dir: dir,
        top,
        ring,
    }
}

/// `len` bytes of deterministic noise, generated piece by piece.
struct Noise {
    left: u64,
    state: u64,
    block: Vec<u8>,
    at: usize,
}

impl Noise {
    fn new(len: u64, seed: u64) -> Noise {
        Noise {
            left: len,
            state: seed | 1,
            block: Vec::new(),
            at: 0,
        }
    }

    fn refill(&mut self) {
        // xorshift64, 8 bytes a step: fast enough in a debug build.
        self.block.resize(64 << 10, 0);
        for w in self.block.chunks_exact_mut(8) {
            self.state ^= self.state << 13;
            self.state ^= self.state >> 7;
            self.state ^= self.state << 17;
            w.copy_from_slice(&self.state.to_le_bytes());
        }
        self.at = 0;
    }
}

impl Read for Noise {
    fn read(&mut self, out: &mut [u8]) -> std::io::Result<usize> {
        if self.left == 0 {
            return Ok(0);
        }
        if self.at == self.block.len() || self.block.is_empty() {
            self.refill();
        }
        let n = out
            .len()
            .min(self.block.len() - self.at)
            .min(self.left as usize);
        out[..n].copy_from_slice(&self.block[self.at..self.at + n]);
        self.at += n;
        self.left -= n as u64;
        Ok(n)
    }
}

fn noise(len: usize, seed: u64) -> Vec<u8> {
    let mut v = Vec::with_capacity(len);
    Noise::new(len as u64, seed).read_to_end(&mut v).unwrap();
    v
}

/// A writer that checks it is given exactly what `expect` reads.
struct Expect<R: Read> {
    expect: R,
    buf: Vec<u8>,
    seen: u64,
}

impl<R: Read> Write for Expect<R> {
    fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
        self.buf.resize(data.len(), 0);
        self.expect.read_exact(&mut self.buf)?;
        assert!(
            self.buf[..] == *data,
            "the smudged bytes differ from the input near offset {}",
            self.seen
        );
        self.seen += data.len() as u64;
        Ok(data.len())
    }

    fn flush(&mut self) -> std::io::Result<()> {
        Ok(())
    }
}

/// A writer that only counts.
struct Count(u64);

impl Write for Count {
    fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
        self.0 += data.len() as u64;
        Ok(data.len())
    }

    fn flush(&mut self) -> std::io::Result<()> {
        Ok(())
    }
}

// ── git's long-running filter protocol, from git's side ─────────────────────

const MAX_PKT: usize = 65516;

struct FilterProc {
    child: Child,
    w: BufWriter<ChildStdin>,
    r: BufReader<ChildStdout>,
    stderr: PathBuf,
}

fn pkt(w: &mut impl Write, data: &[u8]) {
    write!(w, "{:04x}", data.len() + 4).unwrap();
    w.write_all(data).unwrap();
}

fn read_pkt(r: &mut impl BufRead) -> Option<Vec<u8>> {
    let mut len = [0u8; 4];
    r.read_exact(&mut len).unwrap();
    let len = usize::from_str_radix(std::str::from_utf8(&len).unwrap(), 16).unwrap();
    if len == 0 {
        return None;
    }
    let mut buf = vec![0u8; len - 4];
    r.read_exact(&mut buf).unwrap();
    Some(buf)
}

fn read_list(r: &mut impl BufRead) -> Vec<String> {
    let mut out = Vec::new();
    while let Some(p) = read_pkt(r) {
        out.push(String::from_utf8_lossy(&p).trim_end().to_string());
    }
    out
}

impl FilterProc {
    fn start(bin: &Path, clone: &Path) -> FilterProc {
        let stderr = clone.join(format!(".git/filter-stderr-{}", std::process::id()));
        let mut child = Command::new(bin)
            .current_dir(clone)
            .args(["crypt", "filter-process"])
            .env("GIT_CONFIG_NOSYSTEM", "1")
            .env("GIT_CONFIG_GLOBAL", "/dev/null")
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .stderr(std::fs::File::create(&stderr).unwrap())
            .spawn()
            .expect("the filter starts");
        let mut w = BufWriter::with_capacity(256 << 10, child.stdin.take().unwrap());
        let mut r = BufReader::with_capacity(256 << 10, child.stdout.take().unwrap());
        pkt(&mut w, b"git-filter-client\n");
        pkt(&mut w, b"version=2\n");
        w.write_all(b"0000").unwrap();
        w.flush().unwrap();
        assert_eq!(read_list(&mut r), ["git-filter-server", "version=2"]);
        pkt(&mut w, b"capability=clean\n");
        pkt(&mut w, b"capability=smudge\n");
        w.write_all(b"0000").unwrap();
        w.flush().unwrap();
        assert_eq!(read_list(&mut r), ["capability=clean", "capability=smudge"]);
        FilterProc {
            child,
            w,
            r,
            stderr,
        }
    }

    /// One request: `(first status, final status)`; content goes to `out`.
    fn run(
        &mut self,
        command: &str,
        path: &str,
        input: &mut dyn Read,
        out: &mut dyn Write,
    ) -> (String, String) {
        pkt(&mut self.w, format!("command={command}\n").as_bytes());
        pkt(&mut self.w, format!("pathname={path}\n").as_bytes());
        self.w.write_all(b"0000").unwrap();
        let mut buf = vec![0u8; MAX_PKT];
        loop {
            let mut n = 0;
            while n < MAX_PKT {
                match input.read(&mut buf[n..]).unwrap() {
                    0 => break,
                    k => n += k,
                }
            }
            if n == 0 {
                break;
            }
            pkt(&mut self.w, &buf[..n]);
        }
        self.w.write_all(b"0000").unwrap();
        self.w.flush().unwrap();
        let first = read_list(&mut self.r).join(",");
        if first != "status=success" {
            return (first, String::new());
        }
        while let Some(p) = read_pkt(&mut self.r) {
            out.write_all(&p).unwrap();
        }
        let last = read_list(&mut self.r).join(",");
        (first, last)
    }

    /// Peak resident memory so far, KiB (Linux `VmHWM`).
    fn peak_kib(&self) -> u64 {
        let status = std::fs::read_to_string(format!("/proc/{}/status", self.child.id()))
            .unwrap_or_default();
        status
            .lines()
            .find_map(|l| l.strip_prefix("VmHWM:"))
            .and_then(|v| v.trim().trim_end_matches("kB").trim().parse().ok())
            .unwrap_or(0)
    }

    fn stderr(&self) -> String {
        std::fs::read_to_string(&self.stderr).unwrap_or_default()
    }

    fn end(mut self) -> String {
        drop(self.w);
        let _ = self.child.wait();
        let s = std::fs::read_to_string(&self.stderr).unwrap_or_default();
        let _ = std::fs::remove_file(&self.stderr);
        s
    }
}

// ── the tests ───────────────────────────────────────────────────────────────

#[test]
fn a_large_file_cleans_and_smudges_through_the_process_filter_in_bounded_memory() {
    if !which("git") {
        eprintln!("SKIP: no git");
        return;
    }
    let mib = env_usize("GUNNAR_CRYPT_STREAM_MIB", 64);
    let bound = env_usize("GUNNAR_CRYPT_STREAM_RSS_MIB", 24) as u64 * 1024;
    let len = (mib * MIB + 12345) as u64;
    let c = clone_with(1, &gunnar_bin());
    let sealed_path = c.top.join(".git/big.sealed");
    let warm_path = c.top.join(".git/warm.sealed");

    let mut clean = FilterProc::start(&gunnar_bin(), &c.top);
    // A 1 MiB file first: every code path and buffer of a clean, once.
    let mut warm = BufWriter::new(std::fs::File::create(&warm_path).unwrap());
    let st = clean.run(
        "clean",
        "warm.bin",
        &mut Noise::new(MIB as u64, 3),
        &mut warm,
    );
    assert_eq!(st.0, "status=success");
    drop(warm);
    let clean_warm = clean.peak_kib();
    let mut sealed = BufWriter::new(std::fs::File::create(&sealed_path).unwrap());
    let t = Instant::now();
    let st = clean.run("clean", "big.bin", &mut Noise::new(len, 7), &mut sealed);
    let clean_s = t.elapsed().as_secs_f64();
    sealed.flush().unwrap();
    drop(sealed);
    assert_eq!(
        st,
        ("status=success".into(), String::new()),
        "{}",
        clean.stderr()
    );
    let clean_peak = clean.peak_kib();
    clean.end();

    let env_len = std::fs::metadata(&sealed_path).unwrap().len();
    let mut head = [0u8; envelope::V2_HEADER_LEN];
    std::fs::File::open(&sealed_path)
        .unwrap()
        .read_exact(&mut head)
        .unwrap();
    assert_eq!(envelope::version_of(&head), Some(envelope::V2));
    assert_eq!(envelope::plaintext_len(&head, env_len), Some(len));

    let mut smudge = FilterProc::start(&gunnar_bin(), &c.top);
    let st = smudge.run(
        "smudge",
        "warm.bin",
        &mut BufReader::new(std::fs::File::open(&warm_path).unwrap()),
        &mut Count(0),
    );
    assert_eq!(st.0, "status=success");
    let smudge_warm = smudge.peak_kib();
    let mut check = Expect {
        expect: Noise::new(len, 7),
        buf: Vec::new(),
        seen: 0,
    };
    let t = Instant::now();
    let st = smudge.run(
        "smudge",
        "big.bin",
        &mut BufReader::new(std::fs::File::open(&sealed_path).unwrap()),
        &mut check,
    );
    let smudge_s = t.elapsed().as_secs_f64();
    assert_eq!(
        st,
        ("status=success".into(), String::new()),
        "{}",
        smudge.stderr()
    );
    assert_eq!(check.seen, len, "every byte came back");
    let smudge_peak = smudge.peak_kib();
    smudge.end();
    let _ = std::fs::remove_file(&sealed_path);
    let _ = std::fs::remove_file(&warm_path);

    eprintln!(
        "STREAM {mib} MiB: clean {:.0} MB/s peak {clean_peak} KiB (after a 1 MiB file \
         {clean_warm}); smudge {:.0} MB/s peak {smudge_peak} KiB (after 1 MiB {smudge_warm}); \
         bound on the growth {bound} KiB",
        len as f64 / clean_s / 1e6,
        len as f64 / smudge_s / 1e6,
    );
    assert!(clean_warm > 0 && smudge_warm > 0, "VmHWM is readable here");
    assert!(
        clean_peak - clean_warm < bound,
        "clean grew by {} KiB for a {mib} MiB file (bound {bound} KiB)",
        clean_peak - clean_warm
    );
    assert!(
        smudge_peak - smudge_warm < bound,
        "smudge grew by {} KiB for a {mib} MiB file (bound {bound} KiB)",
        smudge_peak - smudge_warm
    );
}

fn blob(c: &Clone, rev: &str) -> Vec<u8> {
    git(&c.top, &["cat-file", "blob", rev])
}

fn status_quiet(c: &Clone) {
    assert_eq!(
        git_text(&c.top, &["status", "--porcelain"]),
        "",
        "git status is quiet"
    );
}

/// Commit `sealed` (an envelope made here) as `name`, then check it out as
/// plaintext the way a clone does after `unlock`.
fn commit_sealed(c: &Clone, name: &str, sealed: &[u8], plain: &[u8]) -> String {
    std::fs::write(c.top.join(name), sealed).unwrap();
    // Already an envelope: clean passes it through as it is.
    git(&c.top, &["add", name]);
    git(&c.top, &["commit", "-q", "-m", name]);
    let oid = git_text(&c.top, &["rev-parse", &format!("HEAD:{name}")]);
    assert_eq!(blob(c, &oid), sealed);
    std::fs::remove_file(c.top.join(name)).unwrap();
    git(&c.top, &["checkout", "--", name]);
    assert!(
        std::fs::read(c.top.join(name)).unwrap() == plain,
        "{name} checks out as plaintext"
    );
    oid
}

#[test]
fn an_unchanged_v1_file_stays_v1_and_a_changed_one_is_sealed_v2() {
    if !which("git") {
        eprintln!("SKIP: no git");
        return;
    }
    let c = clone_with(1, &gunnar_bin());
    let plain = b"sealed before envelope v2\n";
    let v1 = envelope::seal_v1(c.ring.newest(), plain);
    let oid = commit_sealed(&c, "old.txt", &v1, plain);
    // A big v1 file too (spooled on the way in): v1 opens whole, as before.
    let big = noise(6 * MIB + 3, 11);
    let big_oid = commit_sealed(
        &c,
        "old.bin",
        &envelope::seal_v1(c.ring.newest(), &big),
        &big,
    );

    // Touched, re-added, renormalized: the bytes are the same, so the blobs
    // stay the v1 envelopes they are — no repository-wide diff after v2.
    std::fs::write(c.top.join("old.txt"), plain).unwrap();
    std::fs::write(c.top.join("old.bin"), &big).unwrap();
    git(&c.top, &["add", "old.txt", "old.bin"]);
    git(&c.top, &["add", "--renormalize", "."]);
    assert_eq!(git_text(&c.top, &["rev-parse", ":old.txt"]), oid);
    assert_eq!(git_text(&c.top, &["rev-parse", ":old.bin"]), big_oid);
    status_quiet(&c);

    // Changed: sealed v2 under the newest epoch.
    std::fs::write(c.top.join("old.txt"), b"changed after v2\n").unwrap();
    git(&c.top, &["add", "old.txt"]);
    let new = blob(&c, ":old.txt");
    assert_eq!(envelope::version_of(&new), Some(envelope::V2));
    assert_eq!(envelope::epoch_of(&new), Some(c.ring.newest().epoch()));
    assert_eq!(
        envelope::open(&c.ring, &new).unwrap(),
        b"changed after v2\n"
    );
}

#[test]
fn after_a_rotation_an_unchanged_file_keeps_its_older_epoch_v1_or_v2() {
    if !which("git") {
        eprintln!("SKIP: no git");
        return;
    }
    let c = clone_with(2, &gunnar_bin());
    let old = c.ring.get(1).unwrap();
    let small = b"written under epoch 1\n".to_vec();
    let large = noise(5 * MIB + 77, 5);
    let oids = [
        commit_sealed(&c, "a.txt", &envelope::seal(old, &small), &small),
        commit_sealed(&c, "b.bin", &envelope::seal(old, &large), &large),
        commit_sealed(&c, "c.txt", &envelope::seal_v1(old, &small), &small),
    ];
    for f in ["a.txt", "b.bin", "c.txt"] {
        let bytes = std::fs::read(c.top.join(f)).unwrap();
        std::fs::write(c.top.join(f), bytes).unwrap();
    }
    git(&c.top, &["add", "--renormalize", "."]);
    for (f, oid) in ["a.txt", "b.bin", "c.txt"].iter().zip(&oids) {
        assert_eq!(
            &git_text(&c.top, &["rev-parse", &format!(":{f}")]),
            oid,
            "{f}"
        );
        assert_eq!(envelope::epoch_of(&blob(&c, oid)), Some(1), "{f}");
    }
    status_quiet(&c);
    // A real change: the newest epoch.
    let mut changed = large.clone();
    changed[3 * MIB] ^= 1;
    std::fs::write(c.top.join("b.bin"), &changed).unwrap();
    git(&c.top, &["add", "b.bin"]);
    let b = blob(&c, ":b.bin");
    assert_eq!(envelope::epoch_of(&b), Some(2));
    assert_eq!(envelope::open(&c.ring, &b).unwrap(), changed);
}

#[test]
fn a_multi_block_file_round_trips_through_git_add_and_checkout() {
    if !which("git") {
        eprintln!("SKIP: no git");
        return;
    }
    let c = clone_with(1, &gunnar_bin());
    let data = noise(9 * MIB + 123, 3);
    std::fs::write(c.top.join("data.bin"), &data).unwrap();
    git(&c.top, &["add", "data.bin"]);
    git(&c.top, &["commit", "-q", "-m", "data"]);
    let stored = blob(&c, "HEAD:data.bin");
    assert_eq!(envelope::version_of(&stored), Some(envelope::V2));
    assert_eq!(
        stored.len(),
        envelope::V2_HEADER_LEN + 10 * envelope::TAG_LEN + data.len(),
        "10 blocks of 1 MiB at most"
    );
    std::fs::remove_file(c.top.join("data.bin")).unwrap();
    git(&c.top, &["checkout", "--", "data.bin"]);
    assert!(std::fs::read(c.top.join("data.bin")).unwrap() == data);
    status_quiet(&c);
    // `git diff` / `log -p` go through textconv, streamed.
    let mut tc = Command::new(gunnar_bin());
    let shown = tc
        .current_dir(&c.top)
        .args(["crypt", "textconv"])
        .arg(c.top.join(".git/textconv.in"));
    std::fs::write(c.top.join(".git/textconv.in"), &stored).unwrap();
    let out = shown.output().unwrap();
    assert!(out.status.success());
    assert!(out.stdout == data, "textconv opens the envelope");
}

#[test]
fn an_envelope_that_does_not_open_is_left_sealed_with_a_warning_at_any_size() {
    if !which("git") {
        eprintln!("SKIP: no git");
        return;
    }
    let c = clone_with(1, &gunnar_bin());
    let mut f = FilterProc::start(&gunnar_bin(), &c.top);
    for (name, len) in [("small.bin", 1000usize), ("large.bin", 6 * MIB + 9)] {
        let mut env = envelope::seal(c.ring.newest(), &noise(len, 9));
        let last = env.len() - 1;
        env[last] ^= 1;
        let mut out = Vec::new();
        let st = f.run("smudge", name, &mut &env[..], &mut out);
        assert_eq!(st, ("status=success".into(), String::new()), "{name}");
        assert!(out == env, "{name}: left sealed, byte for byte");
        // Cut at a block boundary: well formed, and still refused.
        if len > MIB {
            let cut = &env[..envelope::V2_HEADER_LEN + 2 * (envelope::TAG_LEN + MIB)];
            let mut out = Vec::new();
            let st = f.run("smudge", name, &mut &cut[..], &mut out);
            assert_eq!(st.0, "status=success");
            assert!(out == cut, "{name}: a dropped tail is left sealed");
        }
    }
    let said = f.end();
    assert!(
        said.matches("left sealed").count() >= 3,
        "each one is named: {said}"
    );
}

#[test]
fn the_one_shot_filters_stream_too() {
    if !which("git") {
        eprintln!("SKIP: no git");
        return;
    }
    let c = clone_with(1, &gunnar_bin());
    let data = noise(6 * MIB + 5, 21);
    let run = |verb: &str, input: &[u8]| -> Vec<u8> {
        let mut child = Command::new(gunnar_bin())
            .current_dir(&c.top)
            .args(["crypt", verb, "one.bin"])
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .spawn()
            .unwrap();
        let mut stdin = child.stdin.take().unwrap();
        let input = input.to_vec();
        let feeder = std::thread::spawn(move || stdin.write_all(&input));
        let out = child.wait_with_output().unwrap();
        feeder.join().unwrap().unwrap();
        assert!(out.status.success(), "gunnar crypt {verb}");
        out.stdout
    };
    let sealed = run("clean", &data);
    assert_eq!(envelope::version_of(&sealed), Some(envelope::V2));
    assert!(run("smudge", &sealed) == data);
}

// ── the bench (ignored; run with --release) ─────────────────────────────────

fn parse_size(s: &str) -> u64 {
    let (n, unit) = s.split_at(s.trim_end_matches(['K', 'M', 'G']).len());
    let n: u64 = n.parse().expect("a size like 1K, 100M, 2G");
    n * match unit {
        "K" => 1 << 10,
        "M" => 1 << 20,
        "G" => 1 << 30,
        _ => 1,
    }
}

#[test]
#[ignore]
fn bench_clean_smudge() {
    let bins: Vec<(String, PathBuf)> = std::env::var("GUNNAR_CRYPT_BENCH_BINS")
        .map(|v| {
            v.split(',')
                .map(|p| {
                    let (name, path) = p.split_once('=').expect("name=path");
                    (name.to_string(), PathBuf::from(path))
                })
                .collect()
        })
        .unwrap_or_else(|_| vec![("this".into(), gunnar_bin())]);
    let sizes: Vec<u64> = std::env::var("GUNNAR_CRYPT_BENCH_SIZES")
        .unwrap_or_else(|_| "1K,1M,100M".into())
        .split(',')
        .map(parse_size)
        .collect();
    let mut table = String::from("bin\top\tsize\tfiles\tMB/s\tms/file\tpeak_RSS_MiB\n");
    for (name, bin) in &bins {
        let c = clone_with(1, bin);
        for &size in &sizes {
            // Small files: many in one filter process, as `git add .` does.
            let files = (64 * MIB as u64 / size).clamp(1, 2000);
            let sealed_path = c.top.join(".git/bench.sealed");
            // clean
            let mut f = FilterProc::start(bin, &c.top);
            let t = Instant::now();
            for i in 0..files {
                let mut sink = BufWriter::new(std::fs::File::create(&sealed_path).unwrap());
                let st = f.run(
                    "clean",
                    &format!("f{i}.bin"),
                    &mut Noise::new(size, i + 1),
                    &mut sink,
                );
                assert_eq!(st.0, "status=success", "{name} clean: {}", f.stderr());
                sink.flush().unwrap();
            }
            let secs = t.elapsed().as_secs_f64();
            let peak = f.peak_kib();
            f.end();
            table.push_str(&format!(
                "{name}\tclean\t{size}\t{files}\t{:.1}\t{:.3}\t{:.1}\n",
                (size * files) as f64 / secs / 1e6,
                secs * 1e3 / files as f64,
                peak as f64 / 1024.0
            ));
            // smudge (of the last file's envelope, `files` times)
            let mut f = FilterProc::start(bin, &c.top);
            let t = Instant::now();
            for i in 0..files {
                let mut input = BufReader::new(std::fs::File::open(&sealed_path).unwrap());
                let mut out = Count(0);
                let st = f.run("smudge", &format!("f{i}.bin"), &mut input, &mut out);
                assert_eq!(st.0, "status=success", "{name} smudge: {}", f.stderr());
                assert_eq!(out.0, size, "{name} smudge gave the file back");
            }
            let secs = t.elapsed().as_secs_f64();
            let peak = f.peak_kib();
            let said = f.end();
            assert!(!said.contains("left sealed"), "{name}: {said}");
            table.push_str(&format!(
                "{name}\tsmudge\t{size}\t{files}\t{:.1}\t{:.3}\t{:.1}\n",
                (size * files) as f64 / secs / 1e6,
                secs * 1e3 / files as f64,
                peak as f64 / 1024.0
            ));
            let _ = std::fs::remove_file(&sealed_path);
            eprint!(
                "{}",
                table
                    .lines()
                    .last()
                    .map(|l| format!("{l}\n"))
                    .unwrap_or_default()
            );
        }
    }
    eprintln!("BENCH\n{table}");
    if let Ok(out) = std::env::var("GUNNAR_CRYPT_BENCH_OUT") {
        std::fs::write(out, &table).unwrap();
    }
}