2c101abdbd
`--threads` was an `Option<usize>` with no value-parser bound. Passing
`-j 0` slipped past clap and reached the dispatch in `crypto::encrypt`
/ `decrypt`, where the `threads > 1` check evaluates to false for 0 and
the call quietly fell through to the serial path. The user thought
they had asked for "no worker threads" and got something else instead;
either way, 0 workers is not a meaningful configuration.
Switch the field to `Option<u32>` and add `value_parser!(u32).range(1..)`
so clap rejects `-j 0` at parse time with a usage error. Cast to
`usize` at the single use site. Using `u32` rather than `usize` avoids
shipping a host-pointer-width-dependent CLI surface; thread counts well
past 4 billion are not a thing we need to plan for.
Test plan:
- New integration test `rejects_zero_threads` invokes the binary
with `-j 0` and asserts non-zero exit.
- Existing `roundtrip_multi_threaded` (`-j 4`) still passes, so the
range bound has not broken normal usage.
Refs: external review (GLM51 #1; Gemini #3 misdescribed the symptom
as "silent corruption" — verified the actual behaviour was a fall-
through to the serial path, not output corruption, but the fix is
the same).
761 lines
21 KiB
Rust
761 lines
21 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";
|
|
const KEY_STR: &str = "0123456789abcdef0123456789abcdef";
|
|
|
|
fn fcry() -> Command {
|
|
Command::cargo_bin("fcry").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();
|
|
cmd.arg("-i")
|
|
.arg(plain)
|
|
.arg("-o")
|
|
.arg(ct)
|
|
.arg("--raw-key")
|
|
.arg(KEY_STR);
|
|
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 out = fcry()
|
|
.arg("-d")
|
|
.arg("-i")
|
|
.arg(ct)
|
|
.arg("-o")
|
|
.arg(rt)
|
|
.arg("--raw-key")
|
|
.arg(KEY_STR)
|
|
.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 mut enc = fcry()
|
|
.arg("--raw-key")
|
|
.arg(KEY_STR)
|
|
.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("--raw-key")
|
|
.arg(KEY_STR)
|
|
.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 = "ffffffffffffffffffffffffffffffff";
|
|
assert_ne!(wrong.as_bytes(), KEY);
|
|
let out = fcry()
|
|
.arg("-d")
|
|
.arg("-i")
|
|
.arg(&ct)
|
|
.arg("-o")
|
|
.arg(dir.path().join("rt.bin"))
|
|
.arg("--raw-key")
|
|
.arg(wrong)
|
|
.output()
|
|
.unwrap();
|
|
assert!(!out.status.success(), "decrypt with wrong key should fail");
|
|
}
|
|
|
|
#[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("--raw-key")
|
|
.arg(KEY_STR)
|
|
.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("--raw-key")
|
|
.arg(KEY_STR)
|
|
.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("--raw-key")
|
|
.arg(KEY_STR)
|
|
.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("--raw-key")
|
|
.arg(KEY_STR)
|
|
.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_raw_key() {
|
|
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("--raw-key")
|
|
.arg("tooshort")
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
!out.status.success(),
|
|
"encrypt with short raw_key should fail"
|
|
);
|
|
}
|
|
|
|
#[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")
|
|
.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 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 = "ffffffffffffffffffffffffffffffff";
|
|
let out = fcry()
|
|
.arg("-d")
|
|
.arg("-i")
|
|
.arg(&ct)
|
|
.arg("-o")
|
|
.arg(&rt)
|
|
.arg("--raw-key")
|
|
.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");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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();
|
|
cmd.arg("-i")
|
|
.arg(plain)
|
|
.arg("-o")
|
|
.arg(ct)
|
|
.arg("--raw-key")
|
|
.arg(KEY_STR)
|
|
.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 out = fcry()
|
|
.arg("-d")
|
|
.arg("-i")
|
|
.arg(ct)
|
|
.arg("-o")
|
|
.arg(rt)
|
|
.arg("--raw-key")
|
|
.arg(KEY_STR)
|
|
.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 mut enc = fcry()
|
|
.arg("--raw-key")
|
|
.arg(KEY_STR)
|
|
.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 be 0 (no length committed for stdin input).
|
|
assert_eq!(
|
|
enc_out.stdout[6], 0,
|
|
"stdin-encrypted file unexpectedly committed length"
|
|
);
|
|
|
|
let mut dec = fcry()
|
|
.arg("-d")
|
|
.arg("--raw-key")
|
|
.arg(KEY_STR)
|
|
.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 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], 2, "version should be 2");
|
|
assert_eq!(bytes[6] & 0x01, 0x01, "length-committed flag should be set");
|
|
}
|
|
|
|
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("--raw-key")
|
|
.arg(KEY_STR)
|
|
.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 mut enc = fcry()
|
|
.arg("--raw-key")
|
|
.arg(KEY_STR)
|
|
.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_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 succeed");
|
|
assert_eq!(fs::read(&out).unwrap(), Vec::<u8>::new());
|
|
}
|
|
|
|
#[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 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("--raw-key")
|
|
.arg(KEY_STR)
|
|
.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);
|
|
}
|