Files
fcry/tests/roundtrip.rs
T
ddidderr 725d33939e fix: read key files through short reads
Read --key-file input in a loop until EOF or until the 33-byte rejection
threshold is reached. A single read call is enough for ordinary regular files
in practice, but FIFOs and process substitution can legally return fewer than
32 bytes before EOF. Treating that first short read as final made the new
key-file path fail spuriously for exactly the shell-friendly usage it was meant
to support.

Keep the existing exact-length policy: 32 bytes is accepted, shorter files are
rejected, and 33 or more bytes are rejected with the trailing-newline hint.
The intermediate buffer remains zeroizing.

Test Plan:
- cargo test split_fifo_key_file_read_roundtrips
- cargo fmt --check
- cargo clippy --all-targets -- -D warnings
- cargo test
- git diff --check

Refs: review finding for --key-file short reads
2026-06-09 23:46:27 +02:00

1244 lines
34 KiB
Rust

// SPDX-License-Identifier: GPL-3.0-only
//
// Integration tests for the `fcry` binary.
//
// These exercise the CLI as a black box: encrypt then decrypt and check that
// plaintext bytes are preserved, plus a handful of failure cases (tampering,
// wrong key, truncation, bad magic).
use std::fs;
use std::io::Write;
use std::process::{Command, Stdio};
use assert_cmd::cargo::CommandCargoExt;
use tempfile::TempDir;
const KEY: &[u8; 32] = b"0123456789abcdef0123456789abcdef";
fn fcry() -> Command {
Command::cargo_bin("fcry").unwrap()
}
fn write_key_file(dir: &std::path::Path) -> std::path::PathBuf {
let key = dir.join("key.bin");
fs::write(&key, KEY).unwrap();
key
}
fn key_file_near(path: &std::path::Path) -> std::path::PathBuf {
write_key_file(path.parent().unwrap())
}
/// Deterministic pseudo-random plaintext of `n` bytes (xorshift, seedable).
/// We avoid `/dev/urandom` so tests are reproducible on failure.
fn pseudo_random(seed: u64, n: usize) -> Vec<u8> {
let mut s = seed.wrapping_add(0x9E3779B97F4A7C15);
let mut out = Vec::with_capacity(n);
while out.len() < n {
s ^= s << 13;
s ^= s >> 7;
s ^= s << 17;
out.extend_from_slice(&s.to_le_bytes());
}
out.truncate(n);
out
}
fn encrypt_file(plain: &std::path::Path, ct: &std::path::Path, chunk_size: Option<u32>) {
let mut cmd = fcry();
let key = key_file_near(ct);
cmd.arg("-i")
.arg(plain)
.arg("-o")
.arg(ct)
.arg("--key-file")
.arg(key);
if let Some(cs) = chunk_size {
cmd.arg("--chunk-size").arg(cs.to_string());
}
let out = cmd.output().unwrap();
assert!(
out.status.success(),
"encrypt failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
fn decrypt_file(ct: &std::path::Path, rt: &std::path::Path) {
let key = key_file_near(ct);
let out = fcry()
.arg("-d")
.arg("-i")
.arg(ct)
.arg("-o")
.arg(rt)
.arg("--key-file")
.arg(key)
.output()
.unwrap();
assert!(
out.status.success(),
"decrypt failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
fn roundtrip_with_size(plaintext_size: usize, chunk_size: Option<u32>) {
let dir = TempDir::new().unwrap();
let plain = dir.path().join("plain.bin");
let ct = dir.path().join("ct.bin");
let rt = dir.path().join("rt.bin");
let data = pseudo_random(plaintext_size as u64, plaintext_size);
fs::write(&plain, &data).unwrap();
encrypt_file(&plain, &ct, chunk_size);
decrypt_file(&ct, &rt);
let got = fs::read(&rt).unwrap();
assert_eq!(got, data, "roundtrip mismatch at size {plaintext_size}");
}
#[test]
fn roundtrip_empty() {
roundtrip_with_size(0, None);
}
#[test]
fn roundtrip_one_byte() {
roundtrip_with_size(1, None);
}
#[test]
fn roundtrip_smaller_than_chunk() {
roundtrip_with_size(100, None);
}
#[test]
fn roundtrip_exactly_one_chunk() {
roundtrip_with_size(1024 * 1024, None);
}
#[test]
fn roundtrip_just_over_one_chunk() {
roundtrip_with_size(1024 * 1024 + 1, None);
}
#[test]
fn roundtrip_multi_chunk() {
roundtrip_with_size(5 * 1024 * 1024 + 12345, None);
}
#[test]
fn roundtrip_custom_small_chunk_size() {
// forces many chunks for a small input
roundtrip_with_size(50_000, Some(4096));
}
#[test]
fn roundtrip_chunk_size_one_byte() {
// pathological but should still work
roundtrip_with_size(257, Some(1));
}
#[test]
fn roundtrip_pipe_stdin_stdout() {
let data = pseudo_random(42, 200_000);
let dir = TempDir::new().unwrap();
let key = write_key_file(dir.path());
let mut enc = fcry()
.arg("--key-file")
.arg(&key)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
enc.stdin.as_mut().unwrap().write_all(&data).unwrap();
let enc_out = enc.wait_with_output().unwrap();
assert!(
enc_out.status.success(),
"pipe encrypt failed: {}",
String::from_utf8_lossy(&enc_out.stderr)
);
let mut dec = fcry()
.arg("-d")
.arg("--key-file")
.arg(&key)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
dec.stdin
.as_mut()
.unwrap()
.write_all(&enc_out.stdout)
.unwrap();
let dec_out = dec.wait_with_output().unwrap();
assert!(
dec_out.status.success(),
"pipe decrypt failed: {}",
String::from_utf8_lossy(&dec_out.stderr)
);
assert_eq!(dec_out.stdout, data);
}
#[test]
fn rejects_wrong_key() {
let dir = TempDir::new().unwrap();
let plain = dir.path().join("p.bin");
let ct = dir.path().join("c.bin");
fs::write(&plain, pseudo_random(1, 1000)).unwrap();
encrypt_file(&plain, &ct, None);
let wrong = dir.path().join("wrong.key");
fs::write(&wrong, b"ffffffffffffffffffffffffffffffff").unwrap();
let out = fcry()
.arg("-d")
.arg("-i")
.arg(&ct)
.arg("-o")
.arg(dir.path().join("rt.bin"))
.arg("--key-file")
.arg(wrong)
.output()
.unwrap();
assert!(!out.status.success(), "decrypt with wrong key should fail");
assert!(
String::from_utf8_lossy(&out.stderr).contains("WrongKey"),
"expected distinct WrongKey error, got {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn rejects_tampered_header() {
let dir = TempDir::new().unwrap();
let plain = dir.path().join("p.bin");
let ct = dir.path().join("c.bin");
fs::write(&plain, pseudo_random(2, 1000)).unwrap();
encrypt_file(&plain, &ct, None);
// Flip a byte in the chunk_size field of the header (offset 8: 4 magic + 4 fixed).
let mut bytes = fs::read(&ct).unwrap();
bytes[8] ^= 0xff;
fs::write(&ct, &bytes).unwrap();
let out = fcry()
.arg("-d")
.arg("-i")
.arg(&ct)
.arg("-o")
.arg(dir.path().join("rt.bin"))
.arg("--key-file")
.arg(key_file_near(&ct))
.output()
.unwrap();
assert!(
!out.status.success(),
"decrypt with tampered header should fail"
);
}
#[test]
fn rejects_tampered_ciphertext() {
let dir = TempDir::new().unwrap();
let plain = dir.path().join("p.bin");
let ct = dir.path().join("c.bin");
fs::write(&plain, pseudo_random(3, 5000)).unwrap();
encrypt_file(&plain, &ct, None);
// Flip a byte well past the header (in the first ciphertext chunk).
let mut bytes = fs::read(&ct).unwrap();
let off = bytes.len() / 2;
bytes[off] ^= 0x01;
fs::write(&ct, &bytes).unwrap();
let out = fcry()
.arg("-d")
.arg("-i")
.arg(&ct)
.arg("-o")
.arg(dir.path().join("rt.bin"))
.arg("--key-file")
.arg(key_file_near(&ct))
.output()
.unwrap();
assert!(
!out.status.success(),
"decrypt of tampered ciphertext should fail"
);
}
#[test]
fn rejects_truncated_ciphertext() {
let dir = TempDir::new().unwrap();
let plain = dir.path().join("p.bin");
let ct = dir.path().join("c.bin");
fs::write(&plain, pseudo_random(4, 3 * 1024 * 1024)).unwrap();
encrypt_file(&plain, &ct, None);
// Drop the trailing 16-byte tag of the last chunk (and then some).
let mut bytes = fs::read(&ct).unwrap();
bytes.truncate(bytes.len() - 32);
fs::write(&ct, &bytes).unwrap();
let out = fcry()
.arg("-d")
.arg("-i")
.arg(&ct)
.arg("-o")
.arg(dir.path().join("rt.bin"))
.arg("--key-file")
.arg(key_file_near(&ct))
.output()
.unwrap();
assert!(
!out.status.success(),
"decrypt of truncated ciphertext should fail"
);
}
#[test]
fn rejects_bad_magic() {
let dir = TempDir::new().unwrap();
let bogus = dir.path().join("bogus.bin");
fs::write(&bogus, b"NOPE\x01\x01\x00\x00\x00\x10\x00\x00\x00").unwrap();
let out = fcry()
.arg("-d")
.arg("-i")
.arg(&bogus)
.arg("-o")
.arg(dir.path().join("rt.bin"))
.arg("--key-file")
.arg(write_key_file(dir.path()))
.output()
.unwrap();
assert!(
!out.status.success(),
"decrypt of file with bad magic should fail"
);
assert!(
String::from_utf8_lossy(&out.stderr).contains("magic"),
"expected 'magic' in stderr, got: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn rejects_short_key_file() {
let dir = TempDir::new().unwrap();
let plain = dir.path().join("p.bin");
let key = dir.path().join("short.key");
fs::write(&plain, b"hello").unwrap();
fs::write(&key, b"tooshort").unwrap();
let out = fcry()
.arg("-i")
.arg(&plain)
.arg("-o")
.arg(dir.path().join("c.bin"))
.arg("--key-file")
.arg(&key)
.output()
.unwrap();
assert!(
!out.status.success(),
"encrypt with short key file should fail"
);
}
#[test]
fn rejects_long_key_file_and_trailing_newline() {
let dir = TempDir::new().unwrap();
let plain = dir.path().join("p.bin");
let key = dir.path().join("long.key");
fs::write(&plain, b"hello").unwrap();
fs::write(&key, b"0123456789abcdef0123456789abcdef\n").unwrap();
let out = fcry()
.arg("-i")
.arg(&plain)
.arg("-o")
.arg(dir.path().join("c.bin"))
.arg("--key-file")
.arg(&key)
.output()
.unwrap();
assert!(!out.status.success(), "long key file should fail");
assert!(
String::from_utf8_lossy(&out.stderr).contains("too long"),
"expected too-long error, got {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn non_utf8_key_file_roundtrips() {
let dir = TempDir::new().unwrap();
let key = dir.path().join("key.bin");
let plain = dir.path().join("plain.bin");
let ct = dir.path().join("ct.bin");
let rt = dir.path().join("rt.bin");
let key_bytes: Vec<u8> = (0..32u8).map(|b| b ^ 0x80).collect();
let data = pseudo_random(31, 8192);
fs::write(&key, key_bytes).unwrap();
fs::write(&plain, &data).unwrap();
let enc = fcry()
.arg("-i")
.arg(&plain)
.arg("-o")
.arg(&ct)
.arg("--key-file")
.arg(&key)
.output()
.unwrap();
assert!(
enc.status.success(),
"non-UTF-8 key encrypt failed: {}",
String::from_utf8_lossy(&enc.stderr)
);
let dec = fcry()
.arg("-d")
.arg("-i")
.arg(&ct)
.arg("-o")
.arg(&rt)
.arg("--key-file")
.arg(&key)
.output()
.unwrap();
assert!(
dec.status.success(),
"non-UTF-8 key decrypt failed: {}",
String::from_utf8_lossy(&dec.stderr)
);
assert_eq!(fs::read(&rt).unwrap(), data);
}
#[cfg(unix)]
#[test]
fn split_fifo_key_file_read_roundtrips() {
use std::ffi::CString;
use std::fs::OpenOptions;
use std::os::unix::ffi::OsStrExt;
use std::thread;
use std::time::Duration;
let dir = TempDir::new().unwrap();
let fifo = dir.path().join("key.fifo");
let fifo_c = CString::new(fifo.as_os_str().as_bytes()).unwrap();
let rc = unsafe { libc::mkfifo(fifo_c.as_ptr(), 0o600) };
assert_eq!(rc, 0, "mkfifo failed: {}", std::io::Error::last_os_error());
let plain = dir.path().join("plain.bin");
let ct = dir.path().join("ct.bin");
let rt = dir.path().join("rt.bin");
let data = pseudo_random(33, 8192);
fs::write(&plain, &data).unwrap();
let fifo_writer = fifo.clone();
let writer = thread::spawn(move || {
let mut file = OpenOptions::new().write(true).open(&fifo_writer).unwrap();
file.write_all(&KEY[..8]).unwrap();
file.flush().unwrap();
thread::sleep(Duration::from_millis(50));
file.write_all(&KEY[8..]).unwrap();
});
let enc = fcry()
.arg("-i")
.arg(&plain)
.arg("-o")
.arg(&ct)
.arg("--key-file")
.arg(&fifo)
.output()
.unwrap();
writer.join().unwrap();
assert!(
enc.status.success(),
"split FIFO key encrypt failed: {}",
String::from_utf8_lossy(&enc.stderr)
);
decrypt_file(&ct, &rt);
assert_eq!(fs::read(&rt).unwrap(), data);
}
#[test]
fn roundtrip_passphrase_argon2id() {
let dir = TempDir::new().unwrap();
let plain = dir.path().join("p.bin");
let ct = dir.path().join("c.bin");
let rt = dir.path().join("r.bin");
let data = pseudo_random(7, 100_000);
fs::write(&plain, &data).unwrap();
// Use cheap argon2 params so the test stays fast.
let enc = fcry()
.arg("-i")
.arg(&plain)
.arg("-o")
.arg(&ct)
.arg("--passphrase-env")
.arg("FCRY_TEST_PW")
.arg("--argon-memory")
.arg("8")
.arg("--argon-passes")
.arg("1")
.arg("--allow-weak-kdf")
.env("FCRY_TEST_PW", "correct horse battery staple")
.output()
.unwrap();
assert!(
enc.status.success(),
"passphrase encrypt failed: {}",
String::from_utf8_lossy(&enc.stderr)
);
let dec = fcry()
.arg("-d")
.arg("-i")
.arg(&ct)
.arg("-o")
.arg(&rt)
.arg("--passphrase-env")
.arg("FCRY_TEST_PW")
.env("FCRY_TEST_PW", "correct horse battery staple")
.output()
.unwrap();
assert!(
dec.status.success(),
"passphrase decrypt failed: {}",
String::from_utf8_lossy(&dec.stderr)
);
assert_eq!(fs::read(&rt).unwrap(), data);
// Wrong passphrase must fail.
let bad = fcry()
.arg("-d")
.arg("-i")
.arg(&ct)
.arg("-o")
.arg(dir.path().join("bad.bin"))
.arg("--passphrase-env")
.arg("FCRY_TEST_PW")
.env("FCRY_TEST_PW", "wrong passphrase")
.output()
.unwrap();
assert!(!bad.status.success(), "wrong passphrase should fail");
}
#[test]
fn weak_passphrase_kdf_rejected_without_override() {
let dir = TempDir::new().unwrap();
let plain = dir.path().join("p.bin");
fs::write(&plain, b"hello").unwrap();
let enc = fcry()
.arg("-i")
.arg(&plain)
.arg("-o")
.arg(dir.path().join("c.bin"))
.arg("--passphrase-env")
.arg("FCRY_TEST_PW")
.arg("--argon-memory")
.arg("8")
.arg("--argon-passes")
.arg("1")
.env("FCRY_TEST_PW", "short")
.output()
.unwrap();
assert!(!enc.status.success(), "weak KDF/passphrase should fail");
}
#[test]
fn decrypt_argon_memory_cap_rejects_hostile_header() {
let dir = TempDir::new().unwrap();
let plain = dir.path().join("p.bin");
let ct = dir.path().join("c.bin");
fs::write(&plain, b"hello").unwrap();
let enc = fcry()
.arg("-i")
.arg(&plain)
.arg("-o")
.arg(&ct)
.arg("--passphrase-env")
.arg("FCRY_TEST_PW")
.arg("--argon-memory")
.arg("8")
.arg("--argon-passes")
.arg("1")
.arg("--allow-weak-kdf")
.env("FCRY_TEST_PW", "correct horse battery staple")
.output()
.unwrap();
assert!(enc.status.success());
let dec = fcry()
.arg("-d")
.arg("-i")
.arg(&ct)
.arg("--passphrase-env")
.arg("FCRY_TEST_PW")
.arg("--max-argon-memory-mib")
.arg("1")
.env("FCRY_TEST_PW", "correct horse battery staple")
.output()
.unwrap();
assert!(!dec.status.success(), "low decrypt cap should reject file");
assert!(
String::from_utf8_lossy(&dec.stderr).contains("decrypt cap"),
"expected cap error, got {}",
String::from_utf8_lossy(&dec.stderr)
);
}
#[test]
fn atomic_output_no_stale_tmp_on_failure() {
// A failed decrypt (wrong key) should not leave the output file behind.
let dir = TempDir::new().unwrap();
let plain = dir.path().join("p.bin");
let ct = dir.path().join("c.bin");
let rt = dir.path().join("r.bin");
fs::write(&plain, b"hello world").unwrap();
encrypt_file(&plain, &ct, None);
let wrong = dir.path().join("wrong.key");
fs::write(&wrong, b"ffffffffffffffffffffffffffffffff").unwrap();
let out = fcry()
.arg("-d")
.arg("-i")
.arg(&ct)
.arg("-o")
.arg(&rt)
.arg("--key-file")
.arg(&wrong)
.output()
.unwrap();
assert!(!out.status.success());
assert!(!rt.exists(), "final output must not exist after failure");
let mut tmp = rt.clone();
tmp.set_file_name("r.bin.tmp");
assert!(!tmp.exists(), "temp file must be cleaned up");
}
#[test]
fn existing_output_refuses_without_force() {
let dir = TempDir::new().unwrap();
let plain = dir.path().join("p.bin");
let ct = dir.path().join("c.bin");
fs::write(&plain, b"hello").unwrap();
fs::write(&ct, b"existing").unwrap();
let out = fcry()
.arg("-i")
.arg(&plain)
.arg("-o")
.arg(&ct)
.arg("--key-file")
.arg(write_key_file(dir.path()))
.output()
.unwrap();
assert!(!out.status.success(), "existing output should refuse");
assert_eq!(fs::read(&ct).unwrap(), b"existing");
}
#[test]
fn force_replaces_only_after_success() {
let dir = TempDir::new().unwrap();
let plain = dir.path().join("p.bin");
let ct = dir.path().join("c.bin");
fs::write(&plain, b"hello").unwrap();
fs::write(&ct, b"existing").unwrap();
let out = fcry()
.arg("-i")
.arg(&plain)
.arg("-o")
.arg(&ct)
.arg("--force")
.arg("--key-file")
.arg(write_key_file(dir.path()))
.output()
.unwrap();
assert!(
out.status.success(),
"force encrypt failed: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_ne!(fs::read(&ct).unwrap(), b"existing");
}
#[test]
fn in_place_replacement_roundtrips() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("data.bin");
let original = pseudo_random(41, 50_000);
fs::write(&path, &original).unwrap();
let enc = fcry()
.arg("-i")
.arg(&path)
.arg("-o")
.arg(&path)
.arg("--key-file")
.arg(write_key_file(dir.path()))
.output()
.unwrap();
assert!(
enc.status.success(),
"in-place encrypt failed: {}",
String::from_utf8_lossy(&enc.stderr)
);
assert_ne!(fs::read(&path).unwrap(), original);
let dec = fcry()
.arg("-d")
.arg("-i")
.arg(&path)
.arg("-o")
.arg(&path)
.arg("--key-file")
.arg(write_key_file(dir.path()))
.output()
.unwrap();
assert!(
dec.status.success(),
"in-place decrypt failed: {}",
String::from_utf8_lossy(&dec.stderr)
);
assert_eq!(fs::read(&path).unwrap(), original);
}
#[test]
fn old_predictable_temp_name_input_is_not_truncated() {
let dir = TempDir::new().unwrap();
let input = dir.path().join("out.bin.tmp");
let output = dir.path().join("out.bin");
let original = pseudo_random(42, 1024);
fs::write(&input, &original).unwrap();
let out = fcry()
.arg("-i")
.arg(&input)
.arg("-o")
.arg(&output)
.arg("--key-file")
.arg(write_key_file(dir.path()))
.output()
.unwrap();
assert!(
out.status.success(),
"encrypt failed: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(fs::read(&input).unwrap(), original);
assert!(output.exists());
}
#[cfg(unix)]
#[test]
fn output_file_mode_is_0600() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().unwrap();
let plain = dir.path().join("p.bin");
let ct = dir.path().join("c.bin");
fs::write(&plain, b"hello").unwrap();
encrypt_file(&plain, &ct, None);
let mode = fs::metadata(&ct).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
}
// ---------------------------------------------------------------------------
// Multi-threaded pipeline + length-committed + random-access tests
// ---------------------------------------------------------------------------
fn encrypt_file_threads(
plain: &std::path::Path,
ct: &std::path::Path,
chunk_size: Option<u32>,
threads: usize,
) {
let mut cmd = fcry();
let key = key_file_near(ct);
cmd.arg("-i")
.arg(plain)
.arg("-o")
.arg(ct)
.arg("--key-file")
.arg(key)
.arg("-j")
.arg(threads.to_string());
if let Some(cs) = chunk_size {
cmd.arg("--chunk-size").arg(cs.to_string());
}
let out = cmd.output().unwrap();
assert!(
out.status.success(),
"encrypt -j{threads} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
fn decrypt_file_threads(ct: &std::path::Path, rt: &std::path::Path, threads: usize) {
let key = key_file_near(ct);
let out = fcry()
.arg("-d")
.arg("-i")
.arg(ct)
.arg("-o")
.arg(rt)
.arg("--key-file")
.arg(key)
.arg("-j")
.arg(threads.to_string())
.output()
.unwrap();
assert!(
out.status.success(),
"decrypt -j{threads} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn roundtrip_multi_threaded() {
// Multi-chunk input. Encrypt+decrypt with -j 4 must round-trip.
let dir = TempDir::new().unwrap();
let plain = dir.path().join("p.bin");
let ct = dir.path().join("c.bin");
let rt = dir.path().join("r.bin");
let data = pseudo_random(11, 5 * 1024 * 1024 + 12345);
fs::write(&plain, &data).unwrap();
encrypt_file_threads(&plain, &ct, Some(64 * 1024), 4);
decrypt_file_threads(&ct, &rt, 4);
assert_eq!(fs::read(&rt).unwrap(), data);
}
#[test]
fn parallel_and_serial_outputs_round_trip() {
// Encrypt with -j 4 and decrypt serially (and vice-versa); both directions
// must yield the original plaintext.
let dir = TempDir::new().unwrap();
let plain = dir.path().join("p.bin");
let data = pseudo_random(13, 256 * 1024 + 17);
fs::write(&plain, &data).unwrap();
let ct_par = dir.path().join("c_par.bin");
let ct_ser = dir.path().join("c_ser.bin");
encrypt_file_threads(&plain, &ct_par, Some(8192), 4);
encrypt_file_threads(&plain, &ct_ser, Some(8192), 1);
let rt1 = dir.path().join("r1.bin");
let rt2 = dir.path().join("r2.bin");
// par-encrypted, serial-decrypted
decrypt_file_threads(&ct_par, &rt1, 1);
// serial-encrypted, par-decrypted
decrypt_file_threads(&ct_ser, &rt2, 4);
assert_eq!(fs::read(&rt1).unwrap(), data);
assert_eq!(fs::read(&rt2).unwrap(), data);
}
#[test]
fn roundtrip_pipe_multi_threaded() {
// stdin/stdout mode with -j 4: length flag must NOT be set (no committed
// length when we don't know the input size), but encrypt/decrypt must still
// round-trip cleanly across the pipeline.
let data = pseudo_random(14, 200_000);
let dir = TempDir::new().unwrap();
let key = write_key_file(dir.path());
let mut enc = fcry()
.arg("--key-file")
.arg(&key)
.arg("-j")
.arg("4")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
enc.stdin.as_mut().unwrap().write_all(&data).unwrap();
let enc_out = enc.wait_with_output().unwrap();
assert!(
enc_out.status.success(),
"pipe encrypt -j4 failed: {}",
String::from_utf8_lossy(&enc_out.stderr)
);
// flags byte at offset 6 must not set length commitment for stdin input.
assert_eq!(
enc_out.stdout[6] & 0x01,
0,
"stdin-encrypted file unexpectedly committed length"
);
let mut dec = fcry()
.arg("-d")
.arg("--key-file")
.arg(&key)
.arg("-j")
.arg("4")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
dec.stdin
.as_mut()
.unwrap()
.write_all(&enc_out.stdout)
.unwrap();
let dec_out = dec.wait_with_output().unwrap();
assert!(
dec_out.status.success(),
"pipe decrypt -j4 failed: {}",
String::from_utf8_lossy(&dec_out.stderr)
);
assert_eq!(dec_out.stdout, data);
}
#[test]
fn stdin_chunk_size_zero_fails_but_empty_valid_chunk_succeeds() {
let dir = TempDir::new().unwrap();
let key = write_key_file(dir.path());
let mut bad = fcry()
.arg("--chunk-size")
.arg("0")
.arg("--key-file")
.arg(&key)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
bad.stdin.as_mut().unwrap().write_all(b"x").unwrap();
let bad_out = bad.wait_with_output().unwrap();
assert!(!bad_out.status.success(), "chunk-size 0 should fail");
let mut good = fcry()
.arg("--chunk-size")
.arg("1")
.arg("--key-file")
.arg(&key)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
drop(good.stdin.take());
let good_out = good.wait_with_output().unwrap();
assert!(
good_out.status.success(),
"empty stdin with valid chunk should succeed: {}",
String::from_utf8_lossy(&good_out.stderr)
);
}
#[test]
fn huge_thread_count_is_bounded() {
let dir = TempDir::new().unwrap();
let plain = dir.path().join("p.bin");
let ct = dir.path().join("c.bin");
fs::write(&plain, b"hello").unwrap();
let out = fcry()
.arg("-i")
.arg(&plain)
.arg("-o")
.arg(&ct)
.arg("--key-file")
.arg(write_key_file(dir.path()))
.arg("-j")
.arg("1000000")
.output()
.unwrap();
assert!(
out.status.success(),
"huge -j should be capped, got {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(String::from_utf8_lossy(&out.stderr).contains("capped"));
}
#[test]
fn forged_huge_chunk_header_fails_before_allocation() {
let dir = TempDir::new().unwrap();
let forged = dir.path().join("forged.bin");
let mut bytes = Vec::new();
bytes.extend_from_slice(b"fcry");
bytes.push(3); // version
bytes.push(1); // alg
bytes.push(0x02); // key commitment flag
bytes.push(0); // reserved
bytes.extend_from_slice(&u32::MAX.to_le_bytes());
fs::write(&forged, bytes).unwrap();
let out = fcry()
.arg("-d")
.arg("-i")
.arg(&forged)
.arg("--key-file")
.arg(write_key_file(dir.path()))
.output()
.unwrap();
assert!(!out.status.success(), "huge chunk header should fail");
assert!(
String::from_utf8_lossy(&out.stderr).contains("chunk_size"),
"expected chunk_size error, got {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn file_input_commits_length() {
// Encrypting from a regular file must auto-set FLAG_LENGTH_COMMITTED (bit 0
// of the flags byte at offset 6) and embed the length.
let dir = TempDir::new().unwrap();
let plain = dir.path().join("p.bin");
let ct = dir.path().join("c.bin");
let data = pseudo_random(15, 50_000);
fs::write(&plain, &data).unwrap();
encrypt_file(&plain, &ct, Some(4096));
let bytes = fs::read(&ct).unwrap();
// Magic(4) + version(1) + alg(1) + flags(1) = byte 6
assert_eq!(bytes[4], 3, "version should be 3");
assert_eq!(bytes[6] & 0x01, 0x01, "length-committed flag should be set");
assert_eq!(bytes[6] & 0x02, 0x02, "key-committed flag should be set");
}
#[test]
fn v3_downgrade_or_commitment_stripping_fails_authentication() {
let dir = TempDir::new().unwrap();
let plain = dir.path().join("p.bin");
let ct = dir.path().join("c.bin");
let rt = dir.path().join("r.bin");
fs::write(&plain, pseudo_random(51, 1000)).unwrap();
encrypt_file(&plain, &ct, None);
let mut bytes = fs::read(&ct).unwrap();
bytes[4] = 2;
bytes[6] &= !0x02;
fs::write(&ct, bytes).unwrap();
let out = fcry()
.arg("-d")
.arg("-i")
.arg(&ct)
.arg("-o")
.arg(&rt)
.arg("--key-file")
.arg(key_file_near(&ct))
.output()
.unwrap();
assert!(
!out.status.success(),
"downgraded/stripped v3 header must fail authentication"
);
}
fn encrypt_random_access_fixture(
dir: &std::path::Path,
data: &[u8],
chunk_size: u32,
) -> std::path::PathBuf {
let plain = dir.join("p.bin");
let ct = dir.join("c.bin");
fs::write(&plain, data).unwrap();
encrypt_file(&plain, &ct, Some(chunk_size));
ct
}
fn random_access_decrypt(
ct: &std::path::Path,
out: &std::path::Path,
offset: u64,
length: u64,
) -> std::process::Output {
fcry()
.arg("-d")
.arg("-i")
.arg(ct)
.arg("-o")
.arg(out)
.arg("--key-file")
.arg(key_file_near(ct))
.arg("--offset")
.arg(offset.to_string())
.arg("--length")
.arg(length.to_string())
.output()
.unwrap()
}
#[test]
fn random_access_decrypt_slices() {
let dir = TempDir::new().unwrap();
let chunk = 4096u32;
let total = 5 * 1024 * 1024 + 12345;
let data = pseudo_random(16, total);
let ct = encrypt_random_access_fixture(dir.path(), &data, chunk);
// (offset, length) cases:
// - chunk-aligned start, mid-chunk end
// - mid-chunk start crossing several chunks
// - last partial chunk
// - last byte
// - entire file
let cases: &[(u64, u64)] = &[
(0, 1),
(chunk as u64, 7),
(chunk as u64 - 5, 100),
(10, chunk as u64 * 3 + 17),
(total as u64 - 1, 1),
(total as u64 - 100, 100),
(0, total as u64),
];
for (i, (offset, length)) in cases.iter().copied().enumerate() {
let out = dir.path().join(format!("slice_{i}.bin"));
let r = random_access_decrypt(&ct, &out, offset, length);
assert!(
r.status.success(),
"slice {i} ({offset}, {length}) failed: {}",
String::from_utf8_lossy(&r.stderr)
);
let got = fs::read(&out).unwrap();
let expected = &data[offset as usize..(offset + length) as usize];
assert_eq!(got, expected, "slice {i} mismatch");
}
}
#[test]
fn random_access_rejects_out_of_range() {
let dir = TempDir::new().unwrap();
let data = pseudo_random(17, 1000);
let ct = encrypt_random_access_fixture(dir.path(), &data, 256);
let out = dir.path().join("oob.bin");
let r = random_access_decrypt(&ct, &out, 900, 1000); // 900+1000 > 1000
assert!(!r.status.success(), "out-of-range slice should fail");
}
#[test]
fn random_access_rejects_stdin_encrypted() {
// Encrypt via stdin → no length committed → random access must refuse.
let data = pseudo_random(18, 2000);
let dir = TempDir::new().unwrap();
let ct = dir.path().join("c.bin");
let key = write_key_file(dir.path());
let mut enc = fcry()
.arg("--key-file")
.arg(&key)
.arg("-o")
.arg(&ct)
.stdin(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
enc.stdin.as_mut().unwrap().write_all(&data).unwrap();
assert!(enc.wait().unwrap().success());
let out = dir.path().join("slice.bin");
let r = random_access_decrypt(&ct, &out, 0, 100);
assert!(
!r.status.success(),
"random access on stdin-encrypted file should fail"
);
}
#[test]
fn random_access_rejects_zero_length() {
let dir = TempDir::new().unwrap();
let data = pseudo_random(19, 1000);
let ct = encrypt_random_access_fixture(dir.path(), &data, 256);
let out = dir.path().join("empty.bin");
let r = random_access_decrypt(&ct, &out, 500, 0);
assert!(!r.status.success(), "zero-length slice should fail");
}
#[test]
fn random_access_tampered_length_fails() {
// Flip a byte inside the committed plaintext_length field. The header is
// AAD for every chunk, so the AEAD must reject decryption.
let dir = TempDir::new().unwrap();
let data = pseudo_random(20, 4000);
let ct = encrypt_random_access_fixture(dir.path(), &data, 1024);
let mut bytes = fs::read(&ct).unwrap();
// For raw-kdf header: magic(4)+ver(1)+alg(1)+flags(1)+rsv(1)+chunksize(4)+kdf_id(1)+nonce_prefix(19) = 32
// plaintext_length is at offset 32..40.
bytes[34] ^= 0xff;
fs::write(&ct, &bytes).unwrap();
let out = dir.path().join("bad.bin");
let r = random_access_decrypt(&ct, &out, 0, 100);
assert!(
!r.status.success(),
"tampered plaintext_length must fail authentication"
);
}
#[test]
fn buffer_verify_stdout_emits_nothing_on_truncated_ciphertext() {
let dir = TempDir::new().unwrap();
let plain = dir.path().join("p.bin");
let ct = dir.path().join("c.bin");
fs::write(&plain, pseudo_random(61, 3 * 1024 * 1024)).unwrap();
encrypt_file(&plain, &ct, Some(64 * 1024));
let mut bytes = fs::read(&ct).unwrap();
bytes.truncate(bytes.len() - 32);
fs::write(&ct, bytes).unwrap();
let out = fcry()
.arg("-d")
.arg("-i")
.arg(&ct)
.arg("--buffer-verify")
.arg("--key-file")
.arg(key_file_near(&ct))
.output()
.unwrap();
assert!(!out.status.success(), "truncated decrypt should fail");
assert!(
out.stdout.is_empty(),
"buffer-verify must suppress partial stdout"
);
}
#[test]
fn rejects_zero_threads() {
// -j 0 is almost certainly a user mistake. Clap should reject it before
// we ever reach the pipeline.
let dir = TempDir::new().unwrap();
let plain = dir.path().join("p.bin");
fs::write(&plain, b"hello").unwrap();
let out = fcry()
.arg("-i")
.arg(&plain)
.arg("-o")
.arg(dir.path().join("c.bin"))
.arg("--key-file")
.arg(write_key_file(dir.path()))
.arg("-j")
.arg("0")
.output()
.unwrap();
assert!(!out.status.success(), "-j 0 should be rejected");
}
#[test]
fn header_chunk_size_is_authoritative_on_decrypt() {
// Encrypt with a non-default chunk size; decrypt without specifying one.
// The decryptor must read chunk_size from the header.
let dir = TempDir::new().unwrap();
let plain = dir.path().join("p.bin");
let ct = dir.path().join("c.bin");
let rt = dir.path().join("r.bin");
let data = pseudo_random(5, 100_000);
fs::write(&plain, &data).unwrap();
encrypt_file(&plain, &ct, Some(7919)); // prime, deliberately weird
decrypt_file(&ct, &rt);
assert_eq!(fs::read(&rt).unwrap(), data);
}