Files
memvid--memvid/tests/encryption_capsule.rs
wehub-resource-sync 70cb81e982
CI / Test (macos-latest, stable) (push) Has been cancelled
CI / Test (ubuntu-latest, stable) (push) Has been cancelled
CI / Test (windows-latest, stable) (push) Has been cancelled
CI / Lint (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:45:24 +08:00

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