517 lines
17 KiB
Rust
517 lines
17 KiB
Rust
//! Encryption capsule tests (.mv2e).
|
|
|
|
#[cfg(feature = "encryption")]
|
|
use memvid_core::encryption::{EncryptionError, Mv2eHeader, lock_file, unlock_file};
|
|
#[cfg(feature = "encryption")]
|
|
use memvid_core::{Memvid, PutOptions};
|
|
|
|
#[cfg(feature = "encryption")]
|
|
use std::fs::read;
|
|
#[cfg(feature = "encryption")]
|
|
use std::path::Path;
|
|
#[cfg(feature = "encryption")]
|
|
use tempfile::TempDir;
|
|
|
|
#[test]
|
|
#[cfg(feature = "encryption")]
|
|
fn mv2e_header_roundtrip() {
|
|
let header = Mv2eHeader {
|
|
magic: memvid_core::encryption::MV2E_MAGIC,
|
|
version: memvid_core::encryption::MV2E_VERSION,
|
|
kdf_algorithm: memvid_core::encryption::KdfAlgorithm::Argon2id,
|
|
cipher_algorithm: memvid_core::encryption::CipherAlgorithm::Aes256Gcm,
|
|
salt: [1u8; memvid_core::encryption::SALT_SIZE],
|
|
nonce: [2u8; memvid_core::encryption::NONCE_SIZE],
|
|
original_size: 1024,
|
|
reserved: [0u8; 4],
|
|
};
|
|
|
|
let encoded = header.encode();
|
|
let decoded = Mv2eHeader::decode(&encoded).expect("decode");
|
|
|
|
assert_eq!(decoded.magic, header.magic);
|
|
assert_eq!(decoded.version, header.version);
|
|
assert_eq!(decoded.salt, header.salt);
|
|
assert_eq!(decoded.nonce, header.nonce);
|
|
assert_eq!(decoded.original_size, header.original_size);
|
|
}
|
|
|
|
#[test]
|
|
#[cfg(feature = "encryption")]
|
|
fn lock_unlock_roundtrip_preserves_bytes() {
|
|
let dir = TempDir::new().expect("tmp");
|
|
let mv2_path = dir.path().join("test.mv2");
|
|
let mv2e_path = dir.path().join("test.mv2e");
|
|
let restored_path = dir.path().join("restored.mv2");
|
|
|
|
{
|
|
let mut mem = Memvid::create(&mv2_path).expect("create");
|
|
mem.put_bytes_with_options(
|
|
b"hello",
|
|
PutOptions {
|
|
title: Some("doc".to_string()),
|
|
labels: vec!["note".to_string()],
|
|
..Default::default()
|
|
},
|
|
)
|
|
.expect("put");
|
|
mem.commit().expect("commit");
|
|
}
|
|
|
|
lock_file(&mv2_path, Some(mv2e_path.as_path()), b"test-password-123").expect("lock");
|
|
unlock_file(
|
|
&mv2e_path,
|
|
Some(restored_path.as_path()),
|
|
b"test-password-123",
|
|
)
|
|
.expect("unlock");
|
|
|
|
let original = read(&mv2_path).expect("read original");
|
|
let restored = read(&restored_path).expect("read restored");
|
|
assert_eq!(original, restored);
|
|
}
|
|
|
|
#[test]
|
|
#[cfg(feature = "encryption")]
|
|
fn wrong_password_fails() {
|
|
let dir = TempDir::new().expect("tmp");
|
|
let mv2_path = dir.path().join("test.mv2");
|
|
let mv2e_path = dir.path().join("test.mv2e");
|
|
|
|
{
|
|
let mut mem = Memvid::create(&mv2_path).expect("create");
|
|
mem.put_bytes(b"hello").expect("put");
|
|
mem.commit().expect("commit");
|
|
}
|
|
|
|
lock_file(&mv2_path, Some(mv2e_path.as_path()), b"password-a").expect("lock");
|
|
let err = unlock_file(&mv2e_path, None, b"password-b").expect_err("should fail");
|
|
assert!(matches!(err, EncryptionError::Decryption { .. }));
|
|
}
|
|
|
|
/// Test streaming encryption with a large file (>1MB to trigger multiple chunks)
|
|
/// Note: The mv2 file format includes a 64MB WAL by default, so even small content
|
|
/// creates large files. This test focuses on verifying the streaming format works.
|
|
#[test]
|
|
#[cfg(feature = "encryption")]
|
|
fn streaming_encryption_large_file() {
|
|
let dir = TempDir::new().expect("tmp");
|
|
let mv2_path = dir.path().join("large.mv2");
|
|
let mv2e_path = dir.path().join("large.mv2e");
|
|
let restored_path = dir.path().join("large_restored.mv2");
|
|
|
|
// Create a memory file with modest content (the file will be large due to WAL)
|
|
{
|
|
let mut mem = Memvid::create(&mv2_path).expect("create");
|
|
|
|
// Add 5 entries - this should create a file >1MB due to WAL overhead
|
|
for i in 0..5 {
|
|
let content = format!("Entry {} with content: {}", i, "x".repeat(10_000));
|
|
mem.put_bytes_with_options(
|
|
content.as_bytes(),
|
|
PutOptions {
|
|
title: Some(format!("Entry {}", i)),
|
|
labels: vec!["test".to_string()],
|
|
..Default::default()
|
|
},
|
|
)
|
|
.expect("put");
|
|
}
|
|
mem.commit().expect("commit");
|
|
}
|
|
|
|
// The file should be >1MB due to embedded WAL
|
|
let original_size = std::fs::metadata(&mv2_path).expect("metadata").len();
|
|
assert!(
|
|
original_size > 1_000_000,
|
|
"File should be >1MB, got {} bytes",
|
|
original_size
|
|
);
|
|
println!(
|
|
"Created test file: {} bytes ({:.2} MB)",
|
|
original_size,
|
|
original_size as f64 / 1_000_000.0
|
|
);
|
|
|
|
// Encrypt using streaming
|
|
lock_file(
|
|
&mv2_path,
|
|
Some(mv2e_path.as_path()),
|
|
b"streaming-test-password",
|
|
)
|
|
.expect("lock");
|
|
|
|
// Verify encrypted file has streaming marker (reserved[0] == 0x01)
|
|
let encrypted_bytes = read(&mv2e_path).expect("read encrypted");
|
|
let header_bytes: [u8; Mv2eHeader::SIZE] = encrypted_bytes[..Mv2eHeader::SIZE]
|
|
.try_into()
|
|
.expect("slice to array");
|
|
let header = Mv2eHeader::decode(&header_bytes).expect("decode header");
|
|
assert_eq!(
|
|
header.reserved[0], 0x01,
|
|
"Should use streaming format (reserved[0] == 0x01)"
|
|
);
|
|
println!(
|
|
"Encrypted file: {} bytes, streaming format confirmed",
|
|
encrypted_bytes.len()
|
|
);
|
|
|
|
// Decrypt
|
|
unlock_file(
|
|
&mv2e_path,
|
|
Some(restored_path.as_path()),
|
|
b"streaming-test-password",
|
|
)
|
|
.expect("unlock");
|
|
|
|
// Verify content matches
|
|
let original = read(&mv2_path).expect("read original");
|
|
let restored = read(&restored_path).expect("read restored");
|
|
assert_eq!(original.len(), restored.len(), "Size mismatch");
|
|
assert_eq!(original, restored, "Content mismatch");
|
|
println!(
|
|
"Decryption successful, {} bytes restored correctly",
|
|
restored.len()
|
|
);
|
|
|
|
// Verify the restored file is valid and readable
|
|
let mem = Memvid::open(&restored_path).expect("open restored");
|
|
let stats = mem.stats().expect("stats");
|
|
assert!(
|
|
stats.frame_count >= 5,
|
|
"Should have at least 5 frames, got {}",
|
|
stats.frame_count
|
|
);
|
|
println!("Restored memory verified: {} frames", stats.frame_count);
|
|
}
|
|
|
|
/// Test that wrong password still fails with streaming format
|
|
#[test]
|
|
#[cfg(feature = "encryption")]
|
|
fn wrong_password_fails_streaming() {
|
|
let dir = TempDir::new().expect("tmp");
|
|
let mv2_path = dir.path().join("test_stream.mv2");
|
|
let mv2e_path = dir.path().join("test_stream.mv2e");
|
|
|
|
// Create a file (will be >1MB due to WAL overhead)
|
|
{
|
|
let mut mem = Memvid::create(&mv2_path).expect("create");
|
|
for i in 0..3 {
|
|
let content = format!("Entry {} {}", i, "data".repeat(10_000));
|
|
mem.put_bytes(content.as_bytes()).expect("put");
|
|
}
|
|
mem.commit().expect("commit");
|
|
}
|
|
|
|
lock_file(&mv2_path, Some(mv2e_path.as_path()), b"correct-password").expect("lock");
|
|
|
|
// Verify streaming format (files >1MB use streaming)
|
|
let encrypted = read(&mv2e_path).expect("read");
|
|
let header_bytes: [u8; Mv2eHeader::SIZE] = encrypted[..Mv2eHeader::SIZE]
|
|
.try_into()
|
|
.expect("slice to array");
|
|
let header = Mv2eHeader::decode(&header_bytes).expect("decode");
|
|
assert_eq!(header.reserved[0], 0x01, "Should use streaming format");
|
|
|
|
// Wrong password should fail
|
|
let err = unlock_file(&mv2e_path, None, b"wrong-password").expect_err("should fail");
|
|
assert!(
|
|
matches!(err, EncryptionError::Decryption { .. }),
|
|
"Expected Decryption error, got {:?}",
|
|
err
|
|
);
|
|
println!("Wrong password correctly rejected for streaming format");
|
|
}
|
|
|
|
/// Helper: reads and decodes the Mv2eHeader from an encrypted file.
|
|
#[cfg(feature = "encryption")]
|
|
fn read_header(path: &Path) -> Mv2eHeader {
|
|
let bytes = read(path).expect("read file");
|
|
let header_bytes: [u8; Mv2eHeader::SIZE] =
|
|
bytes[..Mv2eHeader::SIZE].try_into().expect("header bytes");
|
|
Mv2eHeader::decode(&header_bytes).expect("decode header")
|
|
}
|
|
|
|
/*
|
|
This test verifies two things:
|
|
1. Legacy format marker exists (reserved[0] == 0x00)
|
|
2. Decryption works (new code can decrypt old format files)
|
|
*/
|
|
#[test]
|
|
#[ignore = "legacy_test.mv2 fixture missing - regenerate with legacy encryption code"]
|
|
#[cfg(feature = "encryption")]
|
|
fn decrypt_legacy_format_with_new_code() {
|
|
use std::path::PathBuf;
|
|
|
|
let mut fixture_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
|
fixture_dir.push("tests/fixtures");
|
|
|
|
let original_mv2 = fixture_dir.join("legacy_test.mv2");
|
|
let original_mv2e = fixture_dir.join("legacy_test.mv2e");
|
|
|
|
assert!(original_mv2.exists(), "legacy mv2 fixture missing");
|
|
assert!(original_mv2e.exists(), "legacy mv2e fixture missing");
|
|
|
|
let header = read_header(&original_mv2e);
|
|
assert_eq!(header.reserved[0], 0x00, "should be legacy format");
|
|
|
|
let dir = TempDir::new().expect("temp");
|
|
let decrypted_path = dir.path().join("decrypted.mv2");
|
|
|
|
unlock_file(
|
|
&original_mv2e,
|
|
Some(decrypted_path.as_ref()),
|
|
b"legacy-password",
|
|
)
|
|
.expect("unlock");
|
|
|
|
let original = read(&original_mv2).expect("original");
|
|
let decrypted = read(&decrypted_path).expect("decrypted");
|
|
assert_eq!(original, decrypted);
|
|
}
|
|
|
|
/*
|
|
This test verifies dispatcher logic selects correct decoder:
|
|
1. New file (reserved[0] = 0x01) → uses streaming path
|
|
2. Legacy file (reserved[0] = 0x00) → uses oneshot path
|
|
*/
|
|
#[test]
|
|
#[cfg(feature = "encryption")]
|
|
fn auto_detection_chooses_correct_decoder() {
|
|
// password for legacy file decryption: [b"legacy-password"]
|
|
use std::path::PathBuf;
|
|
|
|
let dir = TempDir::new().expect("temp");
|
|
let mv2_path = dir.path().join("test.mv2");
|
|
let mv2e_path = dir.path().join("test.mv2e");
|
|
let decrypted_path = dir.path().join("decrypted.mv2");
|
|
|
|
{
|
|
let mut mem = Memvid::create(&mv2_path).expect("memvid");
|
|
mem.put_bytes(b"testing: auto detection chooses correct decoder.")
|
|
.unwrap();
|
|
mem.commit().unwrap();
|
|
}
|
|
|
|
lock_file(&mv2_path, Some(&mv2e_path), b"test-password").expect("lock");
|
|
|
|
let header = read_header(&mv2e_path);
|
|
assert_eq!(header.reserved[0], 0x01, "new file should use streaming");
|
|
|
|
unlock_file(&mv2e_path, Some(&decrypted_path), b"test-password").expect("unlock");
|
|
|
|
let mut fixture_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
|
fixture_dir.push("tests/fixtures");
|
|
let legacy_mv2e = fixture_dir.join("legacy_test.mv2e");
|
|
let legacy_decrypted = dir.path().join("legacy_decrypted.mv2");
|
|
|
|
let header = read_header(&legacy_mv2e);
|
|
assert_eq!(header.reserved[0], 0x00, "legacy file should be oneshot");
|
|
|
|
unlock_file(&legacy_mv2e, Some(&legacy_decrypted), b"legacy-password").expect("unlock");
|
|
}
|
|
|
|
/*
|
|
This test verifies legacy file upgrade flow:
|
|
1. Decrypt legacy file (reserved[0] = 0x00)
|
|
2. Re-encrypt → produces streaming format (reserved[0] = 0x01)
|
|
3. Content integrity preserved after upgrade
|
|
*/
|
|
#[test]
|
|
#[ignore = "legacy_test.mv2 fixture missing - regenerate with legacy encryption code"]
|
|
#[cfg(feature = "encryption")]
|
|
fn legacy_file_upgrade_on_reencrypt() {
|
|
use std::{fs, path::PathBuf};
|
|
|
|
let dir = TempDir::new().expect("temp");
|
|
let legacy_mv2 = dir.path().join("test.mv2");
|
|
let legacy_mv2e = dir.path().join("test.mv2e");
|
|
let legacy_decrypted = dir.path().join("decrypt.mv2");
|
|
let new_mv2e = dir.path().join("new.mv2e");
|
|
let new_decrypted = dir.path().join("new_decrypted.mv2");
|
|
|
|
let mut fixture_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
|
fixture_dir.push("tests/fixtures");
|
|
|
|
fs::copy(fixture_dir.join("legacy_test.mv2"), &legacy_mv2).expect("copy mv2");
|
|
fs::copy(fixture_dir.join("legacy_test.mv2e"), &legacy_mv2e).expect("copy mv2");
|
|
|
|
let header = read_header(&legacy_mv2e);
|
|
assert_eq!(header.reserved[0], 0x00);
|
|
|
|
unlock_file(&legacy_mv2e, Some(&legacy_decrypted), b"legacy-password").expect("unlock");
|
|
|
|
lock_file(&legacy_decrypted, Some(&new_mv2e), b"new-password").expect("lock");
|
|
|
|
let header = read_header(&new_mv2e);
|
|
assert_eq!(header.reserved[0], 0x01, "should now be streaming format");
|
|
|
|
unlock_file(&new_mv2e, Some(&new_decrypted), b"new-password").expect("unlock new");
|
|
|
|
let original_content = read(&legacy_mv2).expect("read legacy");
|
|
let final_content = read(&new_decrypted).expect("read new decrypted");
|
|
assert_eq!(
|
|
final_content, original_content,
|
|
"content should match after upgrade"
|
|
);
|
|
}
|
|
|
|
/*
|
|
Test: Invalid magic header detection
|
|
1. Create and encrypt a valid .mv2 file
|
|
2. Corrupt the magic bytes (MV2E → 0x00000000)
|
|
3. Attempt decrypt → should return InvalidMagic error
|
|
*/
|
|
#[test]
|
|
#[cfg(feature = "encryption")]
|
|
fn invalid_magic_header() {
|
|
use std::fs;
|
|
|
|
let dir = TempDir::new().expect("temp");
|
|
let mv2_path = dir.path().join("test.mv2");
|
|
let mv2e_path = dir.path().join("test.mv2e");
|
|
|
|
{
|
|
let mut mem = Memvid::create(&mv2_path).expect("create");
|
|
mem.put_bytes(b"testing invalid magic header").unwrap();
|
|
mem.commit().unwrap();
|
|
}
|
|
|
|
lock_file(&mv2_path, Some(&mv2e_path), b"test-password").expect("lock");
|
|
|
|
let mut bytes = fs::read(&mv2e_path).unwrap();
|
|
bytes[..4].copy_from_slice(&[0u8; 4]);
|
|
fs::write(&mv2e_path, &bytes).unwrap();
|
|
|
|
let err = unlock_file(&mv2e_path, None, b"test-password")
|
|
.expect_err("should fail with invalid magic");
|
|
|
|
assert!(matches!(
|
|
err,
|
|
EncryptionError::InvalidMagic {
|
|
expected: _,
|
|
found: _
|
|
}
|
|
));
|
|
}
|
|
|
|
/*
|
|
Test: Truncated file error handling
|
|
1. Create and encrypt a valid .mv2 file
|
|
2. Truncate the .mv2e file (cut in half)
|
|
3. Attempt decrypt → should return error (not crash)
|
|
*/
|
|
#[test]
|
|
#[cfg(feature = "encryption")]
|
|
fn truncated_file_fails_gracefully() {
|
|
use std::fs;
|
|
|
|
let dir = TempDir::new().expect("temp");
|
|
let mv2_path = dir.path().join("test.mv2");
|
|
let mv2e_path = dir.path().join("test.mv2e");
|
|
let truncated_path = dir.path().join("truncated.mv2e");
|
|
|
|
{
|
|
let mut mem = Memvid::create(&mv2_path).unwrap();
|
|
mem.put_bytes(b"testing truncated files").unwrap();
|
|
mem.commit().unwrap();
|
|
}
|
|
|
|
lock_file(&mv2_path, Some(&mv2e_path), b"test-password").expect("lock");
|
|
|
|
let bytes = read(&mv2e_path).unwrap();
|
|
let truncated = &bytes[..bytes.len() / 2];
|
|
fs::write(&truncated_path, truncated).unwrap();
|
|
|
|
let result = unlock_file(&truncated_path, None, b"test-password");
|
|
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
/*
|
|
Test: Non-MV2 file rejection
|
|
1. Create random file (not .mv2)
|
|
2. Attempt encrypt → should return NotMv2File error
|
|
*/
|
|
#[test]
|
|
#[cfg(feature = "encryption")]
|
|
fn non_mv2_file_rejected() {
|
|
use std::fs;
|
|
|
|
let dir = TempDir::new().expect("temp");
|
|
let fake_file = dir.path().join("not_a_mv2.txt");
|
|
|
|
fs::write(&fake_file, b"this is a fake file, not a mv2 file").unwrap();
|
|
|
|
let err =
|
|
lock_file(&fake_file, None, b"test-password").expect_err("should reject non mv2 file");
|
|
|
|
assert!(matches!(err, EncryptionError::NotMv2File { path: _ }))
|
|
}
|
|
|
|
/*
|
|
Test: Exact chunk boundary (5MB)
|
|
1. Create 5MB content → multiple 1MB chunks
|
|
2. Encrypt/decrypt roundtrip
|
|
3. Verify content integrity
|
|
*/
|
|
#[test]
|
|
#[cfg(feature = "encryption")]
|
|
fn exact_chunk_boundary_file() {
|
|
let dir = TempDir::new().unwrap();
|
|
let mv2_path = dir.path().join("test.mv2");
|
|
let mv2e_path = dir.path().join("test.mv2e");
|
|
let decrypted_path = dir.path().join("decrypted.mv2");
|
|
|
|
{
|
|
let mut mem = Memvid::create(&mv2_path).unwrap();
|
|
mem.put_bytes(&[0u8; 1024 * 1024 * 5]).unwrap();
|
|
mem.commit().unwrap();
|
|
}
|
|
|
|
lock_file(&mv2_path, Some(&mv2e_path), b"test-password").expect("lock");
|
|
|
|
let header = read_header(&mv2e_path);
|
|
assert_eq!(header.reserved[0], 0x01);
|
|
|
|
unlock_file(&mv2e_path, Some(&decrypted_path), b"test-password").expect("unlock");
|
|
|
|
let original_content = read(&mv2_path).expect("read original");
|
|
let final_content = read(&decrypted_path).expect("read final");
|
|
assert_eq!(final_content, original_content);
|
|
}
|
|
|
|
/*
|
|
Test: Empty MV2 file encryption
|
|
1. Create .mv2 with no frames
|
|
2. Encrypt/decrypt roundtrip
|
|
3. Verify integrity and Memvid::open works
|
|
*/
|
|
#[test]
|
|
#[cfg(feature = "encryption")]
|
|
fn empty_mv2_file_encryption() {
|
|
let dir = TempDir::new().unwrap();
|
|
let mv2_path = dir.path().join("test.mv2");
|
|
let mv2e_path = dir.path().join("test.mv2e");
|
|
let decrypted_path = dir.path().join("decrypted.mv2");
|
|
|
|
{
|
|
let mut mem = Memvid::create(&mv2_path).unwrap();
|
|
mem.commit().unwrap();
|
|
}
|
|
|
|
lock_file(&mv2_path, Some(&mv2e_path), b"test-password").expect("lock");
|
|
|
|
unlock_file(&mv2e_path, Some(&decrypted_path), b"test-password").expect("unlock");
|
|
|
|
let original_content = read(&mv2_path).unwrap();
|
|
let final_content = read(&decrypted_path).unwrap();
|
|
assert_eq!(final_content, original_content);
|
|
|
|
{
|
|
let mem = Memvid::open(&decrypted_path).expect("should open decrypted file");
|
|
let stats = mem.stats().expect("stats");
|
|
assert_eq!(stats.frame_count, 0, "empty file should have 0 frames");
|
|
}
|
|
}
|