// 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 { 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(); 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) { 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, 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::::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 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); }