//! CLI round-trip integration tests. //! //! Each test runs the actual `encrypted_archive` binary via `assert_cmd`, //! packs files into an archive, unpacks them, and verifies byte-identical output. //! All tests use `tempdir()` for isolation (auto-cleanup, parallel-safe). use assert_cmd::Command; use predicates::prelude::*; use std::fs; use std::os::unix::fs::PermissionsExt; use tempfile::tempdir; /// Hex-encoded 32-byte key for test archives (matches legacy hardcoded key) const TEST_KEY_HEX: &str = "7a35c1d94fe82b6a910df358bc74a61e428fd063e5179b2cfa8406cd3e79b550"; /// Helper: get a Command for the encrypted_archive binary with --key pre-set. fn cmd_with_key() -> Command { let mut c = Command::new(assert_cmd::cargo::cargo_bin!("encrypted_archive")); c.args(["--key", TEST_KEY_HEX]); c } /// Helper: get a Command for the encrypted_archive binary without a key. fn cmd() -> Command { Command::new(assert_cmd::cargo::cargo_bin!("encrypted_archive")) } /// Single text file: pack "Hello", unpack, verify byte-identical. #[test] fn test_roundtrip_single_text_file() { let dir = tempdir().unwrap(); let input_file = dir.path().join("hello.txt"); let archive = dir.path().join("archive.bin"); let output_dir = dir.path().join("output"); fs::write(&input_file, b"Hello").unwrap(); cmd_with_key() .args(["pack", input_file.to_str().unwrap(), "-o", archive.to_str().unwrap()]) .assert() .success(); cmd_with_key() .args(["unpack", archive.to_str().unwrap(), "-o", output_dir.to_str().unwrap()]) .assert() .success(); let extracted = fs::read(output_dir.join("hello.txt")).unwrap(); assert_eq!(extracted, b"Hello"); } /// Multiple files: pack two files, unpack, verify both byte-identical. #[test] fn test_roundtrip_multiple_files() { let dir = tempdir().unwrap(); let text_file = dir.path().join("text.txt"); let binary_file = dir.path().join("binary.dat"); let archive = dir.path().join("archive.bin"); let output_dir = dir.path().join("output"); fs::write(&text_file, b"Some text content").unwrap(); fs::write(&binary_file, &[0x42u8; 256]).unwrap(); cmd_with_key() .args([ "pack", text_file.to_str().unwrap(), binary_file.to_str().unwrap(), "-o", archive.to_str().unwrap(), ]) .assert() .success(); cmd_with_key() .args(["unpack", archive.to_str().unwrap(), "-o", output_dir.to_str().unwrap()]) .assert() .success(); assert_eq!( fs::read(output_dir.join("text.txt")).unwrap(), b"Some text content" ); assert_eq!( fs::read(output_dir.join("binary.dat")).unwrap(), vec![0x42u8; 256] ); } /// Empty file: pack 0-byte file, unpack, verify extracted file is exactly 0 bytes. /// This tests PKCS7 full-block padding on 0-byte input (encrypted_size = 16). #[test] fn test_roundtrip_empty_file() { let dir = tempdir().unwrap(); let input_file = dir.path().join("empty.txt"); let archive = dir.path().join("archive.bin"); let output_dir = dir.path().join("output"); fs::write(&input_file, b"").unwrap(); cmd_with_key() .args(["pack", input_file.to_str().unwrap(), "-o", archive.to_str().unwrap()]) .assert() .success(); cmd_with_key() .args(["unpack", archive.to_str().unwrap(), "-o", output_dir.to_str().unwrap()]) .assert() .success(); let extracted = fs::read(output_dir.join("empty.txt")).unwrap(); assert!(extracted.is_empty(), "Empty file should extract as 0 bytes"); } /// Cyrillic filename: pack file with UTF-8 non-ASCII name, unpack, verify content. /// This tests non-ASCII (UTF-8) filename handling in TOC entries. #[test] fn test_roundtrip_cyrillic_filename() { let dir = tempdir().unwrap(); let input_file = dir.path().join("файл.txt"); let archive = dir.path().join("archive.bin"); let output_dir = dir.path().join("output"); let content = "Содержимое".as_bytes(); fs::write(&input_file, content).unwrap(); cmd_with_key() .args(["pack", input_file.to_str().unwrap(), "-o", archive.to_str().unwrap()]) .assert() .success(); cmd_with_key() .args(["unpack", archive.to_str().unwrap(), "-o", output_dir.to_str().unwrap()]) .assert() .success(); let extracted = fs::read(output_dir.join("файл.txt")).unwrap(); assert_eq!(extracted, content); } /// Large file (11MB): pack, unpack, verify byte-identical. /// Tests large file handling (>10MB) per Phase 3 success criteria. #[test] fn test_roundtrip_large_file() { let dir = tempdir().unwrap(); let input_file = dir.path().join("large.bin"); let archive = dir.path().join("archive.bin"); let output_dir = dir.path().join("output"); // Generate 11MB of deterministic pseudo-random data let data: Vec = (0..11_000_000u32) .map(|i| i.wrapping_mul(2654435761) as u8) .collect(); fs::write(&input_file, &data).unwrap(); // Pack with --no-compress bin (skip compression for binary extension) cmd_with_key() .args([ "pack", input_file.to_str().unwrap(), "-o", archive.to_str().unwrap(), "--no-compress", "bin", ]) .assert() .success(); cmd_with_key() .args(["unpack", archive.to_str().unwrap(), "-o", output_dir.to_str().unwrap()]) .assert() .success(); let extracted = fs::read(output_dir.join("large.bin")).unwrap(); assert_eq!(extracted.len(), data.len(), "Extracted file size must match original"); assert_eq!(extracted, data, "Extracted file content must be byte-identical"); } /// No-compress flag: pack APK file (auto-detected as no-compress), unpack, verify. /// APK extension is in the known compressed extensions list, so compression_flag=0. #[test] fn test_roundtrip_no_compress_flag() { let dir = tempdir().unwrap(); let input_file = dir.path().join("data.apk"); let archive = dir.path().join("archive.bin"); let output_dir = dir.path().join("output"); // 100 bytes of pattern data let data: Vec = (0..100u8).collect(); fs::write(&input_file, &data).unwrap(); cmd_with_key() .args(["pack", input_file.to_str().unwrap(), "-o", archive.to_str().unwrap()]) .assert() .success(); cmd_with_key() .args(["unpack", archive.to_str().unwrap(), "-o", output_dir.to_str().unwrap()]) .assert() .success(); let extracted = fs::read(output_dir.join("data.apk")).unwrap(); assert_eq!(extracted, data); } /// Directory round-trip: pack a directory tree, unpack, verify files, empty dirs, and permissions. #[test] fn test_roundtrip_directory() { let dir = tempdir().unwrap(); let testdir = dir.path().join("testdir"); let subdir = testdir.join("subdir"); let emptydir = testdir.join("empty"); let archive = dir.path().join("archive.bin"); let output_dir = dir.path().join("output"); // Create directory structure fs::create_dir_all(&subdir).unwrap(); fs::create_dir_all(&emptydir).unwrap(); fs::write(testdir.join("hello.txt"), b"Hello from dir").unwrap(); fs::write(subdir.join("nested.txt"), b"Nested file").unwrap(); // Set specific permissions fs::set_permissions(&testdir, fs::Permissions::from_mode(0o755)).unwrap(); fs::set_permissions(testdir.join("hello.txt"), fs::Permissions::from_mode(0o644)).unwrap(); fs::set_permissions(&subdir, fs::Permissions::from_mode(0o755)).unwrap(); fs::set_permissions(subdir.join("nested.txt"), fs::Permissions::from_mode(0o755)).unwrap(); fs::set_permissions(&emptydir, fs::Permissions::from_mode(0o700)).unwrap(); // Pack directory cmd_with_key() .args(["pack", testdir.to_str().unwrap(), "-o", archive.to_str().unwrap()]) .assert() .success(); // Unpack cmd_with_key() .args(["unpack", archive.to_str().unwrap(), "-o", output_dir.to_str().unwrap()]) .assert() .success(); // Verify file contents let hello = fs::read(output_dir.join("testdir/hello.txt")).unwrap(); assert_eq!(hello, b"Hello from dir"); let nested = fs::read(output_dir.join("testdir/subdir/nested.txt")).unwrap(); assert_eq!(nested, b"Nested file"); // Verify empty directory exists assert!( output_dir.join("testdir/empty").is_dir(), "Empty directory should be recreated" ); // Verify permissions let nested_mode = fs::metadata(output_dir.join("testdir/subdir/nested.txt")) .unwrap() .permissions() .mode() & 0o7777; assert_eq!(nested_mode, 0o755, "nested.txt should have mode 0755"); let empty_mode = fs::metadata(output_dir.join("testdir/empty")) .unwrap() .permissions() .mode() & 0o7777; assert_eq!(empty_mode, 0o700, "empty dir should have mode 0700"); } /// Mixed files and directories: pack both a standalone file and a directory, verify round-trip. #[test] fn test_roundtrip_mixed_files_and_dirs() { let dir = tempdir().unwrap(); let standalone = dir.path().join("standalone.txt"); let mydir = dir.path().join("mydir"); let archive = dir.path().join("archive.bin"); let output_dir = dir.path().join("output"); fs::write(&standalone, b"Standalone").unwrap(); fs::create_dir_all(&mydir).unwrap(); fs::write(mydir.join("inner.txt"), b"Inner").unwrap(); // Pack both file and directory cmd_with_key() .args([ "pack", standalone.to_str().unwrap(), mydir.to_str().unwrap(), "-o", archive.to_str().unwrap(), ]) .assert() .success(); // Unpack cmd_with_key() .args(["unpack", archive.to_str().unwrap(), "-o", output_dir.to_str().unwrap()]) .assert() .success(); // Verify both entries assert_eq!( fs::read(output_dir.join("standalone.txt")).unwrap(), b"Standalone" ); assert_eq!( fs::read(output_dir.join("mydir/inner.txt")).unwrap(), b"Inner" ); } /// Inspect shows directory info: entry type and permissions for directory entries. /// Now requires --key to see full TOC listing. #[test] fn test_inspect_shows_directory_info() { let dir = tempdir().unwrap(); let testdir = dir.path().join("testdir"); let archive = dir.path().join("archive.bin"); fs::create_dir_all(&testdir).unwrap(); fs::write(testdir.join("file.txt"), b"content").unwrap(); cmd_with_key() .args(["pack", testdir.to_str().unwrap(), "-o", archive.to_str().unwrap()]) .assert() .success(); // Inspect with key: shows full TOC entry listing cmd_with_key() .args(["inspect", archive.to_str().unwrap()]) .assert() .success() .stdout(predicate::str::contains("dir")) .stdout(predicate::str::contains("file")) .stdout(predicate::str::contains("0755").or(predicate::str::contains("0775"))) .stdout(predicate::str::contains("Permissions:")); } // ========== New tests for key input ========== /// Key file round-trip: create a 32-byte key file, pack with --key-file, unpack with --key-file. #[test] fn test_key_file_roundtrip() { let dir = tempdir().unwrap(); let input_file = dir.path().join("data.txt"); let key_file = dir.path().join("test.key"); let archive = dir.path().join("archive.bin"); let output_dir = dir.path().join("output"); fs::write(&input_file, b"Key file test data").unwrap(); // Write a 32-byte key file (raw bytes) let key_bytes: [u8; 32] = [ 0x7A, 0x35, 0xC1, 0xD9, 0x4F, 0xE8, 0x2B, 0x6A, 0x91, 0x0D, 0xF3, 0x58, 0xBC, 0x74, 0xA6, 0x1E, 0x42, 0x8F, 0xD0, 0x63, 0xE5, 0x17, 0x9B, 0x2C, 0xFA, 0x84, 0x06, 0xCD, 0x3E, 0x79, 0xB5, 0x50, ]; fs::write(&key_file, key_bytes).unwrap(); // Pack with --key-file cmd() .args([ "--key-file", key_file.to_str().unwrap(), "pack", input_file.to_str().unwrap(), "-o", archive.to_str().unwrap(), ]) .assert() .success(); // Unpack with --key-file cmd() .args([ "--key-file", key_file.to_str().unwrap(), "unpack", archive.to_str().unwrap(), "-o", output_dir.to_str().unwrap(), ]) .assert() .success(); let extracted = fs::read(output_dir.join("data.txt")).unwrap(); assert_eq!(extracted, b"Key file test data"); } /// Wrong key: pack with one key, try unpack with different key, expect HMAC failure. #[test] fn test_rejects_wrong_key() { let dir = tempdir().unwrap(); let input_file = dir.path().join("secret.txt"); let archive = dir.path().join("archive.bin"); let output_dir = dir.path().join("output"); fs::write(&input_file, b"Secret data").unwrap(); // Pack with the test key cmd_with_key() .args(["pack", input_file.to_str().unwrap(), "-o", archive.to_str().unwrap()]) .assert() .success(); // Try to unpack with a different key (all zeros). // The wrong key causes TOC decryption to fail (invalid padding) or HMAC verification // to fail on individual files, depending on where the decryption error surfaces first. let wrong_key = "0000000000000000000000000000000000000000000000000000000000000000"; cmd() .args([ "--key", wrong_key, "unpack", archive.to_str().unwrap(), "-o", output_dir.to_str().unwrap(), ]) .assert() .failure() .stderr( predicate::str::contains("HMAC") .or(predicate::str::contains("verification")) .or(predicate::str::contains("Decryption failed")) .or(predicate::str::contains("wrong key")) ); } /// Bad hex: --key with too-short hex string should produce a clear error. #[test] fn test_rejects_bad_hex() { let dir = tempdir().unwrap(); let input_file = dir.path().join("data.txt"); let archive = dir.path().join("archive.bin"); fs::write(&input_file, b"data").unwrap(); cmd() .args([ "--key", "abcd", "pack", input_file.to_str().unwrap(), "-o", archive.to_str().unwrap(), ]) .assert() .failure() .stderr(predicate::str::contains("32 bytes").or(predicate::str::contains("hex"))); } /// Missing key: running pack without any key arg should produce a clear error. #[test] fn test_rejects_missing_key() { let dir = tempdir().unwrap(); let input_file = dir.path().join("data.txt"); let archive = dir.path().join("archive.bin"); fs::write(&input_file, b"data").unwrap(); cmd() .args(["pack", input_file.to_str().unwrap(), "-o", archive.to_str().unwrap()]) .assert() .failure() .stderr(predicate::str::contains("required for pack")); } /// Inspect without key: should succeed and show header metadata but NOT entry listing. #[test] fn test_inspect_without_key() { let dir = tempdir().unwrap(); let input_file = dir.path().join("data.txt"); let archive = dir.path().join("archive.bin"); fs::write(&input_file, b"Hello inspect").unwrap(); // Pack with key cmd_with_key() .args(["pack", input_file.to_str().unwrap(), "-o", archive.to_str().unwrap()]) .assert() .success(); // Inspect without key: should show header metadata, print TOC encrypted message cmd() .args(["inspect", archive.to_str().unwrap()]) .assert() .success() .stdout(predicate::str::contains("Version:")) .stdout(predicate::str::contains("Flags:")) .stdout(predicate::str::contains("Entries:")) .stdout(predicate::str::contains("TOC is encrypted, provide a key to see entry listing")); } /// Inspect with key: should succeed and show full TOC entry listing. #[test] fn test_inspect_with_key() { let dir = tempdir().unwrap(); let input_file = dir.path().join("data.txt"); let archive = dir.path().join("archive.bin"); fs::write(&input_file, b"Hello inspect with key").unwrap(); // Pack with key cmd_with_key() .args(["pack", input_file.to_str().unwrap(), "-o", archive.to_str().unwrap()]) .assert() .success(); // Inspect with key: should show full entry listing cmd_with_key() .args(["inspect", archive.to_str().unwrap()]) .assert() .success() .stdout(predicate::str::contains("Version:")) .stdout(predicate::str::contains("data.txt")) .stdout(predicate::str::contains("Original:")) .stdout(predicate::str::contains("SHA-256:")); } // ========== Password-based key derivation tests ========== /// Password round-trip: pack with --password, unpack with same --password, verify byte-identical. #[test] fn test_password_roundtrip() { let dir = tempdir().unwrap(); let input_file = dir.path().join("secret.txt"); let archive = dir.path().join("archive.aea"); let output_dir = dir.path().join("output"); fs::write(&input_file, b"Password protected data").unwrap(); // Pack with --password cmd() .args([ "--password", "testpass123", "pack", input_file.to_str().unwrap(), "-o", archive.to_str().unwrap(), ]) .assert() .success(); // Unpack with same --password cmd() .args([ "--password", "testpass123", "unpack", archive.to_str().unwrap(), "-o", output_dir.to_str().unwrap(), ]) .assert() .success(); let extracted = fs::read(output_dir.join("secret.txt")).unwrap(); assert_eq!(extracted, b"Password protected data"); } /// Wrong password: pack with correct, unpack with wrong, expect HMAC/decryption failure. #[test] fn test_password_wrong_rejects() { let dir = tempdir().unwrap(); let input_file = dir.path().join("data.txt"); let archive = dir.path().join("archive.aea"); let output_dir = dir.path().join("output"); fs::write(&input_file, b"Sensitive data").unwrap(); // Pack with correct password cmd() .args([ "--password", "correctpassword", "pack", input_file.to_str().unwrap(), "-o", archive.to_str().unwrap(), ]) .assert() .success(); // Try unpack with wrong password cmd() .args([ "--password", "wrongpassword", "unpack", archive.to_str().unwrap(), "-o", output_dir.to_str().unwrap(), ]) .assert() .failure() .stderr( predicate::str::contains("HMAC") .or(predicate::str::contains("verification")) .or(predicate::str::contains("Decryption failed")) .or(predicate::str::contains("wrong key")) ); } /// Password archive has salt flag: flags should contain bit 4 (0x10). #[test] fn test_password_archive_has_salt_flag() { let dir = tempdir().unwrap(); let input_file = dir.path().join("data.txt"); let archive = dir.path().join("archive.aea"); fs::write(&input_file, b"Flagged data").unwrap(); // Pack with --password cmd() .args([ "--password", "testpass", "pack", input_file.to_str().unwrap(), "-o", archive.to_str().unwrap(), ]) .assert() .success(); // Inspect with --password to see flags cmd() .args([ "--password", "testpass", "inspect", archive.to_str().unwrap(), ]) .assert() .success() .stdout(predicate::str::contains("Flags: 0x1F")); // 0x0F (bits 0-3) + 0x10 (bit 4) = 0x1F } /// Key archive has no salt flag: flags should NOT contain bit 4 (0x10). #[test] fn test_key_archive_no_salt_flag() { let dir = tempdir().unwrap(); let input_file = dir.path().join("data.txt"); let archive = dir.path().join("archive.aea"); fs::write(&input_file, b"No salt data").unwrap(); // Pack with --key (no password, no salt) cmd_with_key() .args(["pack", input_file.to_str().unwrap(), "-o", archive.to_str().unwrap()]) .assert() .success(); // Inspect with --key cmd_with_key() .args(["inspect", archive.to_str().unwrap()]) .assert() .success() .stdout(predicate::str::contains("Flags: 0x0F")); // bits 0-3 set, bit 4 clear } /// Password archive multiple files: pack a directory with --password, unpack, verify. #[test] fn test_password_roundtrip_directory() { let dir = tempdir().unwrap(); let testdir = dir.path().join("mydir"); let archive = dir.path().join("archive.aea"); let output_dir = dir.path().join("output"); fs::create_dir_all(&testdir).unwrap(); fs::write(testdir.join("file1.txt"), b"File one content").unwrap(); fs::write(testdir.join("file2.txt"), b"File two content").unwrap(); // Pack with --password cmd() .args([ "--password", "dirpass", "pack", testdir.to_str().unwrap(), "-o", archive.to_str().unwrap(), ]) .assert() .success(); // Unpack with same --password cmd() .args([ "--password", "dirpass", "unpack", archive.to_str().unwrap(), "-o", output_dir.to_str().unwrap(), ]) .assert() .success(); assert_eq!( fs::read(output_dir.join("mydir/file1.txt")).unwrap(), b"File one content" ); assert_eq!( fs::read(output_dir.join("mydir/file2.txt")).unwrap(), b"File two content" ); }