// 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"); } #[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); }