// 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::{ErrorKind, 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 { 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) { 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) { 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 = (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, 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(); // Invalid options can make the child exit before it drains stdin. if let Err(err) = bad.stdin.as_mut().unwrap().write_all(b"x") { assert_eq!( err.kind(), ErrorKind::BrokenPipe, "unexpected stdin write error for failing chunk-size 0 process: {err}" ); } 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); }