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();
}
}