From b9afbff3c1829f11a4ae22e99d5105ecbedcf73a Mon Sep 17 00:00:00 2001 From: Adel HB Date: Sun, 5 Apr 2026 04:07:09 +0100 Subject: [PATCH 01/32] docs: added unreleased title to CHANGELOG.md file --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8049cf..706cf8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +### Changed + +### Fixed + ## [v0.3.4](https://github.com/MicroClub-USTHB/M-Security/releases/tag/v0.3.4) - 2026-04-05 ### Added From 9c1eae108c884b0d4703e7034c97b8eaf4602a50 Mon Sep 17 00:00:00 2001 From: Adel-Ayoub Date: Sun, 5 Apr 2026 09:45:24 +0100 Subject: [PATCH 02/32] feat(evfs): add index dirty flag and explicit vault_flush API Track index mutation state with an in-memory dirty flag. Read-only operations never trigger a flush. vault_flush() provides explicit durability control. vault_close() flushes if dirty before releasing lock. --- rust/src/api/evfs/mod.rs | 47 ++++++++++++++++ rust/src/api/evfs/tests.rs | 110 +++++++++++++++++++++++++++++++++++++ rust/src/api/evfs/types.rs | 2 + 3 files changed, 159 insertions(+) diff --git a/rust/src/api/evfs/mod.rs b/rust/src/api/evfs/mod.rs index 0c6d1e3..563d370 100644 --- a/rust/src/api/evfs/mod.rs +++ b/rust/src/api/evfs/mod.rs @@ -86,6 +86,7 @@ pub fn vault_create( file, wal, lock, + index_dirty: false, }) } @@ -251,6 +252,7 @@ pub fn vault_open(path: String, mut key: Vec) -> Result Result<(), Crypto handle.index.deallocate(entry.offset, entry.size); // Flush index (primary + shadow) + handle.index_dirty = true; flush_index( &mut handle.file, &handle.index, @@ -779,6 +786,7 @@ pub fn vault_delete(handle: &mut VaultHandle, name: String) -> Result<(), Crypto handle.index.capacity, handle.index_pad_size, )?; + handle.index_dirty = false; // WAL commit + refresh mmap (file contents changed) handle.wal.commit()?; @@ -826,6 +834,7 @@ fn vault_resize_grow_impl( // Update capacity and flush index to primary + shadow at new position handle.index.capacity = new_capacity; + handle.index_dirty = true; flush_index( &mut handle.file, &handle.index, @@ -834,6 +843,7 @@ fn vault_resize_grow_impl( new_capacity, handle.index_pad_size, )?; + handle.index_dirty = false; handle.wal.commit()?; handle.wal.checkpoint()?; @@ -890,6 +900,7 @@ fn vault_resize_shrink_impl( }); // Flush index to primary + shadow at new position. + handle.index_dirty = true; flush_index( &mut handle.file, &handle.index, @@ -898,6 +909,7 @@ fn vault_resize_shrink_impl( new_capacity, handle.index_pad_size, )?; + handle.index_dirty = false; // Truncate file to new total size. let new_total = format::total_vault_size(new_capacity, handle.index_pad_size)?; @@ -938,6 +950,25 @@ pub fn vault_health(handle: &VaultHandle) -> VaultHealthInfo { handle.health() } +/// Explicitly flush the in-memory index to disk if it has been modified. +/// No-op if the index is clean (no mutations since last flush). +#[cfg(feature = "compression")] +pub fn vault_flush(handle: &mut VaultHandle) -> Result<(), CryptoError> { + if !handle.index_dirty { + return Ok(()); + } + flush_index( + &mut handle.file, + &handle.index, + &handle.keys, + handle.algorithm, + handle.index.capacity, + handle.index_pad_size, + )?; + handle.index_dirty = false; + Ok(()) +} + /// Defragment the vault: compact all segments toward the data region start, /// coalesce free space into a single contiguous block at the end. /// @@ -1015,6 +1046,7 @@ pub fn vault_defragment(handle: &mut VaultHandle) -> Result Result Result Result Result<(), CryptoError> { + if handle.index_dirty { + flush_index( + &mut handle.file, + &handle.index, + &handle.keys, + handle.algorithm, + handle.index.capacity, + handle.index_pad_size, + )?; + handle.index_dirty = false; + } handle.wal.checkpoint()?; handle.lock.release()?; // VaultKeys are zeroized on drop (ZeroizeOnDrop) diff --git a/rust/src/api/evfs/tests.rs b/rust/src/api/evfs/tests.rs index c8a690e..c7a6d74 100644 --- a/rust/src/api/evfs/tests.rs +++ b/rust/src/api/evfs/tests.rs @@ -3398,3 +3398,113 @@ fn test_import_chacha20() { vault_close(imported).expect("close"); } + +// -- Index caching / dirty flag ------------------------------------------ + +#[test] +fn test_index_dirty_false_after_create() { + let dir = tempfile::tempdir().expect("tempdir"); + let handle = create_test_vault(&dir, 1_048_576); + assert!(!handle.index_dirty); + vault_close(handle).expect("close"); +} + +#[test] +fn test_index_dirty_false_after_open() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = vault_path(&dir); + let handle = vault_create(path.clone(), test_key(), "aes-256-gcm".into(), 1_048_576) + .expect("create"); + vault_close(handle).expect("close"); + + let handle = vault_open(path, test_key()).expect("open"); + assert!(!handle.index_dirty); + vault_close(handle).expect("close"); +} + +#[test] +fn test_index_clean_after_write() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None).expect("write"); + // After write completes, dirty flag should be cleared (flushed) + assert!(!handle.index_dirty); + vault_close(handle).expect("close"); +} + +#[test] +fn test_index_clean_after_delete() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None).expect("write"); + vault_delete(&mut handle, "a.txt".into()).expect("delete"); + assert!(!handle.index_dirty); + vault_close(handle).expect("close"); +} + +#[test] +fn test_read_does_not_set_dirty() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None).expect("write"); + assert!(!handle.index_dirty); + + let _ = vault_read(&mut handle, "a.txt".into()).expect("read"); + assert!(!handle.index_dirty); + + let _ = vault_list(&handle); + assert!(!handle.index_dirty); + + let _ = vault_capacity(&handle); + assert!(!handle.index_dirty); + + let _ = vault_health(&handle); + assert!(!handle.index_dirty); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_vault_flush_noop_when_clean() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + assert!(!handle.index_dirty); + // Should be a no-op — no error, no disk I/O + vault_flush(&mut handle).expect("flush clean"); + assert!(!handle.index_dirty); + vault_close(handle).expect("close"); +} + +#[test] +fn test_vault_flush_clears_dirty() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + // Manually set dirty to simulate a deferred mutation + handle.index_dirty = true; + vault_flush(&mut handle).expect("flush dirty"); + assert!(!handle.index_dirty); + vault_close(handle).expect("close"); +} + +#[test] +fn test_index_clean_after_defragment() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + vault_write(&mut handle, "a.txt".into(), b"aaa".to_vec(), None).expect("write a"); + vault_write(&mut handle, "b.txt".into(), b"bbb".to_vec(), None).expect("write b"); + vault_delete(&mut handle, "a.txt".into()).expect("delete a"); + vault_defragment(&mut handle).expect("defrag"); + assert!(!handle.index_dirty); + vault_close(handle).expect("close"); +} + +#[test] +fn test_index_clean_after_resize() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + vault_resize(&mut handle, 2_097_152).expect("grow"); + assert!(!handle.index_dirty); + vault_resize(&mut handle, 1_048_576).expect("shrink"); + assert!(!handle.index_dirty); + vault_close(handle).expect("close"); +} diff --git a/rust/src/api/evfs/types.rs b/rust/src/api/evfs/types.rs index 0a9d77f..58b012d 100644 --- a/rust/src/api/evfs/types.rs +++ b/rust/src/api/evfs/types.rs @@ -101,6 +101,8 @@ pub struct VaultHandle { pub(crate) file: File, pub(crate) wal: WriteAheadLog, pub(crate) lock: VaultLock, + /// True when the in-memory index has been modified but not yet flushed to disk. + pub(crate) index_dirty: bool, } impl VaultHandle { From 7d716d4fb0961db73bf93533c7ced2e7c11730c8 Mon Sep 17 00:00:00 2001 From: Adel-Ayoub Date: Sun, 5 Apr 2026 09:51:21 +0100 Subject: [PATCH 03/32] fix(evfs): add WAL safety to vault_flush and harden vault_close vault_flush() now wraps flush_index in WAL begin/commit for crash safety. vault_close() uses vault_flush() and does best-effort WAL checkpoint + lock release even on flush error. Added tests for flush-persist-reopen, close-dirty-persist, and rotate-key dirty flag. --- rust/src/api/evfs/mod.rs | 37 ++++++++++++++++------------ rust/src/api/evfs/tests.rs | 50 ++++++++++++++++++++++++++++++++++---- 2 files changed, 67 insertions(+), 20 deletions(-) diff --git a/rust/src/api/evfs/mod.rs b/rust/src/api/evfs/mod.rs index 563d370..017896a 100644 --- a/rust/src/api/evfs/mod.rs +++ b/rust/src/api/evfs/mod.rs @@ -957,6 +957,14 @@ pub fn vault_flush(handle: &mut VaultHandle) -> Result<(), CryptoError> { if !handle.index_dirty { return Ok(()); } + let old_encrypted_index = read_encrypted_index( + &mut handle.file, + PRIMARY_INDEX_OFFSET, + format::encrypted_index_size(handle.index_pad_size), + )?; + handle + .wal + .begin(WalOp::UpdateIndex, &old_encrypted_index)?; flush_index( &mut handle.file, &handle.index, @@ -966,6 +974,7 @@ pub fn vault_flush(handle: &mut VaultHandle) -> Result<(), CryptoError> { handle.index_pad_size, )?; handle.index_dirty = false; + handle.wal.commit()?; Ok(()) } @@ -1579,24 +1588,22 @@ fn vault_export_write( Ok(()) } -/// Close the vault — checkpoint WAL, release lock, zeroize keys on drop. +/// Close the vault — flush dirty index, checkpoint WAL, release lock, zeroize keys on drop. #[cfg(feature = "compression")] pub fn vault_close(mut handle: VaultHandle) -> Result<(), CryptoError> { - if handle.index_dirty { - flush_index( - &mut handle.file, - &handle.index, - &handle.keys, - handle.algorithm, - handle.index.capacity, - handle.index_pad_size, - )?; - handle.index_dirty = false; - } - handle.wal.checkpoint()?; - handle.lock.release()?; + // NOTE: index_dirty should only be true here if a prior mutation's flush_index + // failed and the caller proceeded to close anyway. Normal paths always clear it. + let flush_result = if handle.index_dirty { + vault_flush(&mut handle) + } else { + Ok(()) + }; + // Best-effort cleanup: checkpoint WAL and release lock even if flush failed, + // so the vault file is not left permanently locked. + let _ = handle.wal.checkpoint(); + let _ = handle.lock.release(); // VaultKeys are zeroized on drop (ZeroizeOnDrop) - Ok(()) + flush_result } /// Unwraps export key, creates new vault at dest_path, writes all segments under new_master_key. diff --git a/rust/src/api/evfs/tests.rs b/rust/src/api/evfs/tests.rs index c7a6d74..726592e 100644 --- a/rust/src/api/evfs/tests.rs +++ b/rust/src/api/evfs/tests.rs @@ -3476,14 +3476,54 @@ fn test_vault_flush_noop_when_clean() { } #[test] -fn test_vault_flush_clears_dirty() { +fn test_vault_flush_persists_and_survives_reopen() { let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - // Manually set dirty to simulate a deferred mutation - handle.index_dirty = true; - vault_flush(&mut handle).expect("flush dirty"); + let path = vault_path(&dir); + let mut handle = + vault_create(path.clone(), test_key(), "aes-256-gcm".into(), 1_048_576).expect("create"); + vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None).expect("write"); + // Explicit flush then close — data must survive reopen + vault_flush(&mut handle).expect("flush"); assert!(!handle.index_dirty); vault_close(handle).expect("close"); + + let mut reopened = vault_open(path, test_key()).expect("reopen"); + assert_eq!( + vault_read(&mut reopened, "a.txt".into()).expect("read"), + b"hello" + ); + vault_close(reopened).expect("close"); +} + +#[test] +fn test_vault_close_flushes_dirty_and_persists() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = vault_path(&dir); + let mut handle = + vault_create(path.clone(), test_key(), "aes-256-gcm".into(), 1_048_576).expect("create"); + vault_write(&mut handle, "b.txt".into(), b"world".to_vec(), None).expect("write"); + // Simulate a dirty handle at close time by manually setting the flag. + // This exercises the vault_close dirty-flush path. + handle.index_dirty = true; + vault_close(handle).expect("close"); + + let mut reopened = vault_open(path, test_key()).expect("reopen"); + assert_eq!( + vault_read(&mut reopened, "b.txt".into()).expect("read"), + b"world" + ); + vault_close(reopened).expect("close"); +} + +#[test] +fn test_index_dirty_false_after_rotate_key() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = vault_path(&dir); + let handle = + vault_create(path.clone(), test_key(), "aes-256-gcm".into(), 1_048_576).expect("create"); + let rotated = vault_rotate_key(handle, test_key2()).expect("rotate"); + assert!(!rotated.index_dirty); + vault_close(rotated).expect("close"); } #[test] From c1a0122d7d8d35ff781548aba16280f786de05b0 Mon Sep 17 00:00:00 2001 From: Adel-Ayoub Date: Sun, 5 Apr 2026 10:15:11 +0100 Subject: [PATCH 04/32] build(evfs): add rayon dependency for parallel reads --- rust/Cargo.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/rust/Cargo.toml b/rust/Cargo.toml index d0fbd40..677d331 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -48,6 +48,9 @@ fs4 = "0.12" memmap2 = "0.9" libc = "0.2" +# Parallel segment reads +rayon = "1.10" + # Compression zstd = { version = "0.13", optional = true } brotli = { version = "7.0", optional = true } From 844ad9d8172df0d8b5e61cec535681061f9a9c76 Mon Sep 17 00:00:00 2001 From: Adel-Ayoub Date: Sun, 5 Apr 2026 10:15:21 +0100 Subject: [PATCH 05/32] feat(evfs): add SegmentResult type for parallel read results --- rust/src/api/evfs/types.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/rust/src/api/evfs/types.rs b/rust/src/api/evfs/types.rs index 58b012d..c84f78f 100644 --- a/rust/src/api/evfs/types.rs +++ b/rust/src/api/evfs/types.rs @@ -133,6 +133,13 @@ pub struct DefragResult { pub free_regions_before: u32, } +/// Result of a single segment read from `vault_read_parallel`. +#[frb(non_opaque)] +pub struct SegmentResult { + pub name: String, + pub data: Vec, +} + /// Vault health and diagnostic info returned to callers. #[frb(non_opaque)] #[derive(Debug, Clone, PartialEq)] From 885886f206aa21aa4a03c1bf98aa6f7d462c4e92 Mon Sep 17 00:00:00 2001 From: Adel-Ayoub Date: Sun, 5 Apr 2026 10:15:32 +0100 Subject: [PATCH 06/32] feat(evfs): add mmap-only streaming chunks decoder for parallel reads --- rust/src/api/evfs/helpers.rs | 82 ++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/rust/src/api/evfs/helpers.rs b/rust/src/api/evfs/helpers.rs index ae4d2a7..03184f2 100644 --- a/rust/src/api/evfs/helpers.rs +++ b/rust/src/api/evfs/helpers.rs @@ -190,6 +190,88 @@ pub(crate) fn decrypt_streaming_chunks( Ok(hasher.finalize().into()) } +/// Decrypt all chunks of a streaming segment using mmap only (no File I/O). +/// Returns `(reassembled_plaintext, blake3_checksum)`. +/// +/// This is the parallel-read variant of [`decrypt_streaming_chunks`] — it takes +/// an immutable `&VaultMmap` instead of `&mut File`, making it safe to call from +/// multiple threads concurrently. +#[allow(clippy::too_many_arguments)] +#[cfg(feature = "compression")] +pub(crate) fn decrypt_streaming_chunks_mmap( + mmap: &super::types::VaultMmap, + cipher_key: &[u8], + nonce_key: &[u8], + algorithm: Algorithm, + index_pad_size: usize, + seg_offset: u64, + generation: u64, + compression: CompressionAlgorithm, + chunk_count: u32, +) -> Result<(Vec, [u8; 32]), CryptoError> { + let data_region = format::data_region_offset(index_pad_size); + let enc_chunk_size = crate::core::streaming::ENCRYPTED_CHUNK_SIZE; + let mut hasher = blake3::Hasher::new(); + let mut decompressor = if compression != CompressionAlgorithm::None { + Some(crate::core::compression::streaming::new_decompressor( + compression, + )?) + } else { + None + }; + let mut decomp_buf = Vec::with_capacity(crate::core::streaming::CHUNK_SIZE * 2); + let mut full_plaintext = Vec::new(); + + for i in 0..chunk_count { + let chunk_offset = (i as u64) + .checked_mul(enc_chunk_size as u64) + .and_then(|co| data_region.checked_add(seg_offset)?.checked_add(co)) + .ok_or_else(|| CryptoError::InvalidParameter("chunk offset overflow".into()))?; + + let encrypted_ref = mmap.slice(chunk_offset, enc_chunk_size as u64)?; + + let expected_nonce = segment::derive_chunk_nonce(nonce_key, i as u64, generation)?; + let (stored_nonce, _) = encrypted_ref.split_at(crate::core::streaming::NONCE_SIZE); + if stored_nonce.ct_ne(&expected_nonce).into() { + return Err(CryptoError::AuthenticationFailed); + } + + let is_final = i == chunk_count - 1; + let aad = segment::VaultChunkAad { + generation, + chunk_index: i as u64, + is_final, + } + .to_bytes(); + + let decrypted = + segment::aead_decrypt_with_stored_nonce(cipher_key, encrypted_ref, &aad, algorithm)?; + + let plaintext = if is_final { + crate::core::streaming::strip_last_chunk_padding(&decrypted)? + } else { + decrypted + }; + + let final_data = if let Some(ref mut dec) = decompressor { + dec.decompress_chunk(&plaintext, &mut decomp_buf)?; + if is_final { + dec.finish(&mut decomp_buf)?; + } + let data = decomp_buf.clone(); + decomp_buf.clear(); + data + } else { + plaintext + }; + + hasher.update(&final_data); + full_plaintext.extend_from_slice(&final_data); + } + + Ok((full_plaintext, hasher.finalize().into())) +} + /// Decrypt a raw encrypted monolithic segment blob (`nonce || ciphertext || tag`) /// without going through a `VaultHandle` read path. /// From 3e03f817e6f0224c9350835a8180d950938cbada Mon Sep 17 00:00:00 2001 From: Adel-Ayoub Date: Sun, 5 Apr 2026 10:15:57 +0100 Subject: [PATCH 07/32] feat(evfs): add vault_read_parallel with rayon mmap concurrency Takes &VaultHandle (immutable) for thread-safe parallel decryption. Uses rayon par_iter over mmap zero-copy slices. Falls back to sequential per-fd reads when mmap is unavailable. --- rust/src/api/evfs/mod.rs | 182 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) diff --git a/rust/src/api/evfs/mod.rs b/rust/src/api/evfs/mod.rs index 017896a..b687548 100644 --- a/rust/src/api/evfs/mod.rs +++ b/rust/src/api/evfs/mod.rs @@ -950,6 +950,188 @@ pub fn vault_health(handle: &VaultHandle) -> VaultHealthInfo { handle.health() } +/// Read multiple segments concurrently using mmap zero-copy + rayon. +/// +/// Returns results in the same order as `names`. Each entry is either the +/// decrypted segment data or a per-segment error (e.g. `SegmentNotFound`). +/// All segments are decrypted into memory simultaneously — callers should +/// be mindful of total memory when reading many large segments at once. +/// +/// Falls back to sequential file-based reads when mmap is unavailable. +#[cfg(feature = "compression")] +pub fn vault_read_parallel( + handle: &VaultHandle, + names: Vec, +) -> Vec> { + if names.is_empty() { + return Vec::new(); + } + + if let Some(ref mmap) = handle.mmap { + use rayon::prelude::*; + names + .par_iter() + .map(|name| { + read_segment_from_mmap( + name, + mmap, + &handle.keys, + handle.algorithm, + handle.index_pad_size, + &handle.index, + ) + }) + .collect() + } else { + // No mmap — sequential fallback, each read opens its own fd + names + .iter() + .map(|name| { + read_segment_with_file( + name, + &handle.path, + &handle.keys, + handle.algorithm, + handle.index_pad_size, + &handle.index, + ) + }) + .collect() + } +} + +/// Read a single segment using only the mmap (no file I/O). +#[cfg(feature = "compression")] +fn read_segment_from_mmap( + name: &str, + mmap: &types::VaultMmap, + keys: &segment::VaultKeys, + algorithm: Algorithm, + index_pad_size: usize, + index: &format::SegmentIndex, +) -> Result { + let entry = index + .find(name) + .ok_or_else(|| CryptoError::SegmentNotFound(name.to_string()))?; + + let data = if entry.chunk_count > 0 { + let (plaintext, checksum) = decrypt_streaming_chunks_mmap( + mmap, + keys.cipher_key.as_bytes(), + keys.nonce_key.as_bytes(), + algorithm, + index_pad_size, + entry.offset, + entry.generation, + entry.compression, + entry.chunk_count, + )?; + if checksum.ct_ne(&entry.checksum).into() { + return Err(CryptoError::VaultCorrupted(format!( + "integrity check failed for segment '{name}'" + ))); + } + plaintext + } else { + let abs_offset = format::data_region_offset(index_pad_size) + entry.offset; + let encrypted = mmap.slice(abs_offset, entry.size)?; + let params = SegmentCryptoParams { + cipher_key: keys.cipher_key.as_bytes(), + nonce_key: keys.nonce_key.as_bytes(), + algorithm, + segment_index: 0, + generation: entry.generation, + }; + let plaintext = segment::decrypt_segment(¶ms, encrypted, entry.compression)?; + if !segment::verify_checksum(&plaintext, &entry.checksum) { + return Err(CryptoError::VaultCorrupted(format!( + "integrity check failed for segment '{name}'" + ))); + } + plaintext + }; + + Ok(SegmentResult { + name: name.to_string(), + data, + }) +} + +/// Read a single segment by opening a temporary read-only file handle. +/// Used as fallback when mmap is unavailable. +#[cfg(feature = "compression")] +fn read_segment_with_file( + name: &str, + path: &str, + keys: &segment::VaultKeys, + algorithm: Algorithm, + index_pad_size: usize, + index: &format::SegmentIndex, +) -> Result { + let entry = index + .find(name) + .ok_or_else(|| CryptoError::SegmentNotFound(name.to_string()))?; + + let mut file = File::open(path) + .map_err(|e| CryptoError::IoError(format!("cannot open vault for read: {e}")))?; + + let data = if entry.chunk_count > 0 { + let mut full_plaintext = Vec::new(); + let checksum = decrypt_streaming_chunks( + &mut file, + None, + keys.cipher_key.as_bytes(), + keys.nonce_key.as_bytes(), + algorithm, + index_pad_size, + entry.offset, + entry.generation, + entry.compression, + entry.chunk_count, + |chunk_data, _| { + full_plaintext.extend_from_slice(&chunk_data); + Ok(()) + }, + )?; + if checksum.ct_ne(&entry.checksum).into() { + return Err(CryptoError::VaultCorrupted(format!( + "integrity check failed for segment '{name}'" + ))); + } + full_plaintext + } else { + let abs_offset = format::data_region_offset(index_pad_size) + entry.offset; + let read_len = usize::try_from(entry.size).map_err(|_| { + CryptoError::VaultCorrupted(format!( + "segment size {} exceeds platform address space", + entry.size + )) + })?; + file.seek(SeekFrom::Start(abs_offset))?; + let mut buf = vec![0u8; read_len]; + file.read_exact(&mut buf)?; + let params = SegmentCryptoParams { + cipher_key: keys.cipher_key.as_bytes(), + nonce_key: keys.nonce_key.as_bytes(), + algorithm, + segment_index: 0, + generation: entry.generation, + }; + let plaintext = segment::decrypt_segment(¶ms, &buf, entry.compression)?; + if !segment::verify_checksum(&plaintext, &entry.checksum) { + return Err(CryptoError::VaultCorrupted(format!( + "integrity check failed for segment '{name}'" + ))); + } + plaintext + }; + + Ok(SegmentResult { + name: name.to_string(), + data, + }) +} + /// Explicitly flush the in-memory index to disk if it has been modified. /// No-op if the index is clean (no mutations since last flush). #[cfg(feature = "compression")] From 14d3a5de65d13230cbd52a3c6ce8b7b40a09ee5d Mon Sep 17 00:00:00 2001 From: Adel-Ayoub Date: Sun, 5 Apr 2026 10:16:23 +0100 Subject: [PATCH 08/32] test(evfs): add parallel read tests and Sync assertion --- rust/src/api/evfs/tests.rs | 176 +++++++++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) diff --git a/rust/src/api/evfs/tests.rs b/rust/src/api/evfs/tests.rs index 726592e..d0b03f3 100644 --- a/rust/src/api/evfs/tests.rs +++ b/rust/src/api/evfs/tests.rs @@ -3548,3 +3548,179 @@ fn test_index_clean_after_resize() { assert!(!handle.index_dirty); vault_close(handle).expect("close"); } + +// -- Parallel reads ------------------------------------------------------- + +// Compile-time assertion: VaultHandle must be Sync for &VaultHandle across rayon threads +#[allow(dead_code)] +const _: () = { + fn assert_sync() {} + fn _check() { + assert_sync::(); + } +}; + +#[test] +fn test_parallel_read_matches_sequential() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 4_194_304); + + vault_write(&mut handle, "a.txt".into(), b"alpha".to_vec(), None).expect("write a"); + vault_write(&mut handle, "b.txt".into(), b"bravo".to_vec(), None).expect("write b"); + vault_write(&mut handle, "c.txt".into(), b"charlie".to_vec(), None).expect("write c"); + + // Sequential reads for comparison + let seq_a = vault_read(&mut handle, "a.txt".into()).expect("read a"); + let seq_b = vault_read(&mut handle, "b.txt".into()).expect("read b"); + let seq_c = vault_read(&mut handle, "c.txt".into()).expect("read c"); + + // Parallel read + let results = vault_read_parallel( + &handle, + vec!["a.txt".into(), "b.txt".into(), "c.txt".into()], + ); + + assert_eq!(results.len(), 3); + assert_eq!(results[0].as_ref().expect("par a").data, seq_a); + assert_eq!(results[1].as_ref().expect("par b").data, seq_b); + assert_eq!(results[2].as_ref().expect("par c").data, seq_c); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_parallel_read_10_segments() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 4_194_304); + + let mut expected = Vec::new(); + for i in 0..10u8 { + let name = format!("seg_{i}"); + let data = vec![i; 1024]; + vault_write(&mut handle, name, data.clone(), None).expect("write"); + expected.push(data); + } + + let names: Vec = (0..10).map(|i| format!("seg_{i}")).collect(); + let results = vault_read_parallel(&handle, names); + + assert_eq!(results.len(), 10); + for (i, result) in results.iter().enumerate() { + let sr = result.as_ref().expect("parallel read"); + assert_eq!(sr.name, format!("seg_{i}")); + assert_eq!(sr.data, expected[i]); + } + + vault_close(handle).expect("close"); +} + +#[test] +fn test_parallel_read_mixed_monolithic_and_streaming() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 4_194_304); + + // Monolithic segment + let mono_data = vec![0xAA; 512]; + vault_write(&mut handle, "mono".into(), mono_data.clone(), None).expect("write mono"); + + // Streaming segment (>64KB triggers chunking) + let stream_data: Vec = (0..=255u8).cycle().take(100_000).collect(); + let stream_iter = stream_data.chunks(8192).map(|c| c.to_vec()); + vault_write_stream(&mut handle, "stream".into(), stream_data.len() as u64, stream_iter) + .expect("write stream"); + + let results = vault_read_parallel(&handle, vec!["mono".into(), "stream".into()]); + + assert_eq!(results.len(), 2); + assert_eq!(results[0].as_ref().expect("mono").data, mono_data); + assert_eq!(results[1].as_ref().expect("stream").data, stream_data); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_parallel_read_missing_segment() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write(&mut handle, "exists".into(), vec![1, 2, 3], None).expect("write"); + + let results = vault_read_parallel(&handle, vec!["exists".into(), "missing".into()]); + + assert_eq!(results.len(), 2); + assert!(results[0].is_ok()); + assert!(matches!( + results[1], + Err(CryptoError::SegmentNotFound(_)) + )); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_parallel_read_empty_names() { + let dir = tempfile::tempdir().expect("tempdir"); + let handle = create_test_vault(&dir, 1_048_576); + + let results = vault_read_parallel(&handle, vec![]); + assert!(results.is_empty()); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_parallel_read_single_name() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write(&mut handle, "only".into(), b"solo".to_vec(), None).expect("write"); + + let seq = vault_read(&mut handle, "only".into()).expect("sequential"); + let results = vault_read_parallel(&handle, vec!["only".into()]); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].as_ref().expect("parallel").data, seq); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_parallel_read_preserves_order() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 4_194_304); + + for i in 0..10u8 { + vault_write(&mut handle, format!("seg_{i}"), vec![i; 256], None).expect("write"); + } + + // Request in reverse order + let names: Vec = (0..10).rev().map(|i| format!("seg_{i}")).collect(); + let results = vault_read_parallel(&handle, names.clone()); + + assert_eq!(results.len(), 10); + for (i, result) in results.iter().enumerate() { + let sr = result.as_ref().expect("read"); + assert_eq!(sr.name, names[i]); + } + + vault_close(handle).expect("close"); +} + +#[test] +fn test_parallel_read_chacha20() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = vault_path(&dir); + + let mut handle = + vault_create(path, test_key(), "chacha20-poly1305".into(), 4_194_304).expect("create"); + + vault_write(&mut handle, "x".into(), b"chacha-data".to_vec(), None).expect("write"); + vault_write(&mut handle, "y".into(), b"poly1305-data".to_vec(), None).expect("write"); + + let results = vault_read_parallel(&handle, vec!["x".into(), "y".into()]); + + assert_eq!(results[0].as_ref().expect("x").data, b"chacha-data"); + assert_eq!(results[1].as_ref().expect("y").data, b"poly1305-data"); + + vault_close(handle).expect("close"); +} From 7d6f59eb1383098ddbec2c9a37f5419269249468 Mon Sep 17 00:00:00 2001 From: Adel-Ayoub Date: Sun, 5 Apr 2026 10:33:29 +0100 Subject: [PATCH 09/32] fix(evfs): FRB-compatible return type, checked offsets, and zeroization for vault_read_parallel Vec with error field, checked_add for offset overflow, plaintext zeroization on checksum failure, single-fd sequential fallback, and fallback path test coverage. --- rust/src/api/evfs/mod.rs | 138 +++++++++++++++++++++++++++---------- rust/src/api/evfs/tests.rs | 78 +++++++++++++-------- rust/src/api/evfs/types.rs | 4 ++ 3 files changed, 155 insertions(+), 65 deletions(-) diff --git a/rust/src/api/evfs/mod.rs b/rust/src/api/evfs/mod.rs index b687548..6a80e20 100644 --- a/rust/src/api/evfs/mod.rs +++ b/rust/src/api/evfs/mod.rs @@ -952,8 +952,10 @@ pub fn vault_health(handle: &VaultHandle) -> VaultHealthInfo { /// Read multiple segments concurrently using mmap zero-copy + rayon. /// -/// Returns results in the same order as `names`. Each entry is either the -/// decrypted segment data or a per-segment error (e.g. `SegmentNotFound`). +/// Returns results in the same order as `names`. On success, `data` contains +/// decrypted plaintext and `error` is `None`. On failure, `data` is empty and +/// `error` describes the problem (e.g. segment not found). +/// /// All segments are decrypted into memory simultaneously — callers should /// be mindful of total memory when reading many large segments at once. /// @@ -962,7 +964,7 @@ pub fn vault_health(handle: &VaultHandle) -> VaultHealthInfo { pub fn vault_read_parallel( handle: &VaultHandle, names: Vec, -) -> Vec> { +) -> Vec { if names.is_empty() { return Vec::new(); } @@ -983,13 +985,31 @@ pub fn vault_read_parallel( }) .collect() } else { - // No mmap — sequential fallback, each read opens its own fd + // No mmap — sequential fallback with a single shared read-only fd. + // NOTE: this fd is separate from handle.file. A non-cooperating writer + // between index lookup and file read will be caught by AEAD authentication, + // but the error is indistinguishable from normal corruption. + let file_result = File::open(&handle.path) + .map_err(|e| CryptoError::IoError(format!("cannot open vault for read: {e}"))); + let mut file = match file_result { + Ok(f) => f, + Err(e) => { + return names + .iter() + .map(|n| SegmentResult { + name: n.clone(), + data: Vec::new(), + error: Some(e.to_string()), + }) + .collect(); + } + }; names .iter() .map(|name| { read_segment_with_file( name, - &handle.path, + &mut file, &handle.keys, handle.algorithm, handle.index_pad_size, @@ -1009,13 +1029,36 @@ fn read_segment_from_mmap( algorithm: Algorithm, index_pad_size: usize, index: &format::SegmentIndex, -) -> Result { +) -> SegmentResult { + match read_segment_from_mmap_inner(name, mmap, keys, algorithm, index_pad_size, index) { + Ok(data) => SegmentResult { + name: name.to_string(), + data, + error: None, + }, + Err(e) => SegmentResult { + name: name.to_string(), + data: Vec::new(), + error: Some(e.to_string()), + }, + } +} + +#[cfg(feature = "compression")] +fn read_segment_from_mmap_inner( + name: &str, + mmap: &types::VaultMmap, + keys: &segment::VaultKeys, + algorithm: Algorithm, + index_pad_size: usize, + index: &format::SegmentIndex, +) -> Result, CryptoError> { let entry = index .find(name) .ok_or_else(|| CryptoError::SegmentNotFound(name.to_string()))?; - let data = if entry.chunk_count > 0 { - let (plaintext, checksum) = decrypt_streaming_chunks_mmap( + if entry.chunk_count > 0 { + let (mut plaintext, checksum) = decrypt_streaming_chunks_mmap( mmap, keys.cipher_key.as_bytes(), keys.nonce_key.as_bytes(), @@ -1027,13 +1070,18 @@ fn read_segment_from_mmap( entry.chunk_count, )?; if checksum.ct_ne(&entry.checksum).into() { + plaintext.zeroize(); return Err(CryptoError::VaultCorrupted(format!( "integrity check failed for segment '{name}'" ))); } - plaintext + Ok(plaintext) } else { - let abs_offset = format::data_region_offset(index_pad_size) + entry.offset; + let abs_offset = format::data_region_offset(index_pad_size) + .checked_add(entry.offset) + .ok_or_else(|| { + CryptoError::VaultCorrupted(format!("segment offset overflow for '{name}'")) + })?; let encrypted = mmap.slice(abs_offset, entry.size)?; let params = SegmentCryptoParams { cipher_key: keys.cipher_key.as_bytes(), @@ -1042,43 +1090,58 @@ fn read_segment_from_mmap( segment_index: 0, generation: entry.generation, }; - let plaintext = segment::decrypt_segment(¶ms, encrypted, entry.compression)?; + let mut plaintext = segment::decrypt_segment(¶ms, encrypted, entry.compression)?; if !segment::verify_checksum(&plaintext, &entry.checksum) { + plaintext.zeroize(); return Err(CryptoError::VaultCorrupted(format!( "integrity check failed for segment '{name}'" ))); } - plaintext - }; - - Ok(SegmentResult { - name: name.to_string(), - data, - }) + Ok(plaintext) + } } -/// Read a single segment by opening a temporary read-only file handle. -/// Used as fallback when mmap is unavailable. +/// Read a single segment via file I/O (no-mmap fallback). #[cfg(feature = "compression")] fn read_segment_with_file( name: &str, - path: &str, + file: &mut File, + keys: &segment::VaultKeys, + algorithm: Algorithm, + index_pad_size: usize, + index: &format::SegmentIndex, +) -> SegmentResult { + match read_segment_with_file_inner(name, file, keys, algorithm, index_pad_size, index) { + Ok(data) => SegmentResult { + name: name.to_string(), + data, + error: None, + }, + Err(e) => SegmentResult { + name: name.to_string(), + data: Vec::new(), + error: Some(e.to_string()), + }, + } +} + +#[cfg(feature = "compression")] +fn read_segment_with_file_inner( + name: &str, + file: &mut File, keys: &segment::VaultKeys, algorithm: Algorithm, index_pad_size: usize, index: &format::SegmentIndex, -) -> Result { +) -> Result, CryptoError> { let entry = index .find(name) .ok_or_else(|| CryptoError::SegmentNotFound(name.to_string()))?; - let mut file = File::open(path) - .map_err(|e| CryptoError::IoError(format!("cannot open vault for read: {e}")))?; - - let data = if entry.chunk_count > 0 { + if entry.chunk_count > 0 { let mut full_plaintext = Vec::new(); let checksum = decrypt_streaming_chunks( - &mut file, + file, None, keys.cipher_key.as_bytes(), keys.nonce_key.as_bytes(), @@ -1094,13 +1157,18 @@ fn read_segment_with_file( }, )?; if checksum.ct_ne(&entry.checksum).into() { + full_plaintext.zeroize(); return Err(CryptoError::VaultCorrupted(format!( "integrity check failed for segment '{name}'" ))); } - full_plaintext + Ok(full_plaintext) } else { - let abs_offset = format::data_region_offset(index_pad_size) + entry.offset; + let abs_offset = format::data_region_offset(index_pad_size) + .checked_add(entry.offset) + .ok_or_else(|| { + CryptoError::VaultCorrupted(format!("segment offset overflow for '{name}'")) + })?; let read_len = usize::try_from(entry.size).map_err(|_| { CryptoError::VaultCorrupted(format!( "segment size {} exceeds platform address space", @@ -1117,19 +1185,15 @@ fn read_segment_with_file( segment_index: 0, generation: entry.generation, }; - let plaintext = segment::decrypt_segment(¶ms, &buf, entry.compression)?; + let mut plaintext = segment::decrypt_segment(¶ms, &buf, entry.compression)?; if !segment::verify_checksum(&plaintext, &entry.checksum) { + plaintext.zeroize(); return Err(CryptoError::VaultCorrupted(format!( "integrity check failed for segment '{name}'" ))); } - plaintext - }; - - Ok(SegmentResult { - name: name.to_string(), - data, - }) + Ok(plaintext) + } } /// Explicitly flush the in-memory index to disk if it has been modified. diff --git a/rust/src/api/evfs/tests.rs b/rust/src/api/evfs/tests.rs index d0b03f3..6981a54 100644 --- a/rust/src/api/evfs/tests.rs +++ b/rust/src/api/evfs/tests.rs @@ -3553,12 +3553,8 @@ fn test_index_clean_after_resize() { // Compile-time assertion: VaultHandle must be Sync for &VaultHandle across rayon threads #[allow(dead_code)] -const _: () = { - fn assert_sync() {} - fn _check() { - assert_sync::(); - } -}; +trait AssertSync: Sync {} +impl AssertSync for super::types::VaultHandle {} #[test] fn test_parallel_read_matches_sequential() { @@ -3569,21 +3565,20 @@ fn test_parallel_read_matches_sequential() { vault_write(&mut handle, "b.txt".into(), b"bravo".to_vec(), None).expect("write b"); vault_write(&mut handle, "c.txt".into(), b"charlie".to_vec(), None).expect("write c"); - // Sequential reads for comparison let seq_a = vault_read(&mut handle, "a.txt".into()).expect("read a"); let seq_b = vault_read(&mut handle, "b.txt".into()).expect("read b"); let seq_c = vault_read(&mut handle, "c.txt".into()).expect("read c"); - // Parallel read let results = vault_read_parallel( &handle, vec!["a.txt".into(), "b.txt".into(), "c.txt".into()], ); assert_eq!(results.len(), 3); - assert_eq!(results[0].as_ref().expect("par a").data, seq_a); - assert_eq!(results[1].as_ref().expect("par b").data, seq_b); - assert_eq!(results[2].as_ref().expect("par c").data, seq_c); + assert!(results[0].error.is_none()); + assert_eq!(results[0].data, seq_a); + assert_eq!(results[1].data, seq_b); + assert_eq!(results[2].data, seq_c); vault_close(handle).expect("close"); } @@ -3605,8 +3600,8 @@ fn test_parallel_read_10_segments() { let results = vault_read_parallel(&handle, names); assert_eq!(results.len(), 10); - for (i, result) in results.iter().enumerate() { - let sr = result.as_ref().expect("parallel read"); + for (i, sr) in results.iter().enumerate() { + assert!(sr.error.is_none(), "segment {i} had error: {:?}", sr.error); assert_eq!(sr.name, format!("seg_{i}")); assert_eq!(sr.data, expected[i]); } @@ -3619,11 +3614,9 @@ fn test_parallel_read_mixed_monolithic_and_streaming() { let dir = tempfile::tempdir().expect("tempdir"); let mut handle = create_test_vault(&dir, 4_194_304); - // Monolithic segment let mono_data = vec![0xAA; 512]; vault_write(&mut handle, "mono".into(), mono_data.clone(), None).expect("write mono"); - // Streaming segment (>64KB triggers chunking) let stream_data: Vec = (0..=255u8).cycle().take(100_000).collect(); let stream_iter = stream_data.chunks(8192).map(|c| c.to_vec()); vault_write_stream(&mut handle, "stream".into(), stream_data.len() as u64, stream_iter) @@ -3632,8 +3625,10 @@ fn test_parallel_read_mixed_monolithic_and_streaming() { let results = vault_read_parallel(&handle, vec!["mono".into(), "stream".into()]); assert_eq!(results.len(), 2); - assert_eq!(results[0].as_ref().expect("mono").data, mono_data); - assert_eq!(results[1].as_ref().expect("stream").data, stream_data); + assert!(results[0].error.is_none()); + assert_eq!(results[0].data, mono_data); + assert!(results[1].error.is_none()); + assert_eq!(results[1].data, stream_data); vault_close(handle).expect("close"); } @@ -3648,11 +3643,14 @@ fn test_parallel_read_missing_segment() { let results = vault_read_parallel(&handle, vec!["exists".into(), "missing".into()]); assert_eq!(results.len(), 2); - assert!(results[0].is_ok()); - assert!(matches!( - results[1], - Err(CryptoError::SegmentNotFound(_)) - )); + assert!(results[0].error.is_none()); + assert_eq!(results[0].data, vec![1, 2, 3]); + assert!(results[1].error.is_some()); + assert!( + results[1].error.as_ref().expect("error").contains("missing"), + "error should mention segment name" + ); + assert!(results[1].data.is_empty()); vault_close(handle).expect("close"); } @@ -3679,7 +3677,8 @@ fn test_parallel_read_single_name() { let results = vault_read_parallel(&handle, vec!["only".into()]); assert_eq!(results.len(), 1); - assert_eq!(results[0].as_ref().expect("parallel").data, seq); + assert!(results[0].error.is_none()); + assert_eq!(results[0].data, seq); vault_close(handle).expect("close"); } @@ -3693,13 +3692,12 @@ fn test_parallel_read_preserves_order() { vault_write(&mut handle, format!("seg_{i}"), vec![i; 256], None).expect("write"); } - // Request in reverse order let names: Vec = (0..10).rev().map(|i| format!("seg_{i}")).collect(); let results = vault_read_parallel(&handle, names.clone()); assert_eq!(results.len(), 10); - for (i, result) in results.iter().enumerate() { - let sr = result.as_ref().expect("read"); + for (i, sr) in results.iter().enumerate() { + assert!(sr.error.is_none()); assert_eq!(sr.name, names[i]); } @@ -3719,8 +3717,32 @@ fn test_parallel_read_chacha20() { let results = vault_read_parallel(&handle, vec!["x".into(), "y".into()]); - assert_eq!(results[0].as_ref().expect("x").data, b"chacha-data"); - assert_eq!(results[1].as_ref().expect("y").data, b"poly1305-data"); + assert!(results[0].error.is_none()); + assert_eq!(results[0].data, b"chacha-data"); + assert!(results[1].error.is_none()); + assert_eq!(results[1].data, b"poly1305-data"); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_parallel_read_fallback_sequential() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 4_194_304); + + vault_write(&mut handle, "a".into(), b"alpha".to_vec(), None).expect("write a"); + vault_write(&mut handle, "b".into(), b"bravo".to_vec(), None).expect("write b"); + + // Force no-mmap fallback + handle.mmap = None; + + let results = vault_read_parallel(&handle, vec!["a".into(), "b".into()]); + + assert_eq!(results.len(), 2); + assert!(results[0].error.is_none()); + assert_eq!(results[0].data, b"alpha"); + assert!(results[1].error.is_none()); + assert_eq!(results[1].data, b"bravo"); vault_close(handle).expect("close"); } diff --git a/rust/src/api/evfs/types.rs b/rust/src/api/evfs/types.rs index c84f78f..f2e8e05 100644 --- a/rust/src/api/evfs/types.rs +++ b/rust/src/api/evfs/types.rs @@ -134,10 +134,14 @@ pub struct DefragResult { } /// Result of a single segment read from `vault_read_parallel`. +/// +/// On success, `data` contains the decrypted plaintext and `error` is `None`. +/// On failure, `data` is empty and `error` contains the error description. #[frb(non_opaque)] pub struct SegmentResult { pub name: String, pub data: Vec, + pub error: Option, } /// Vault health and diagnostic info returned to callers. From 2a80869f185d4ddd836c760445065d9049716abd Mon Sep 17 00:00:00 2001 From: Adel-Ayoub Date: Sun, 5 Apr 2026 10:34:26 +0100 Subject: [PATCH 10/32] build(evfs): update Cargo.lock for rayon dependency --- rust/Cargo.lock | 52 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 4827405..5ec27cf 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -337,6 +337,31 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crypto-common" version = "0.1.6" @@ -401,6 +426,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "env_filter" version = "0.1.4" @@ -747,6 +778,7 @@ dependencies = [ "log", "memmap2", "rand", + "rayon", "sha2", "sha3", "subtle", @@ -966,6 +998,26 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" From 96a45c16f74485dfaa2bee609101d09cf734e9ca Mon Sep 17 00:00:00 2001 From: Adel HB Date: Mon, 6 Apr 2026 18:35:05 +0100 Subject: [PATCH 11/32] feat(core): add DuplicateSegment error variant to CryptoError --- rust/src/core/error.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/rust/src/core/error.rs b/rust/src/core/error.rs index 34b8585..bd6f6dd 100644 --- a/rust/src/core/error.rs +++ b/rust/src/core/error.rs @@ -44,6 +44,9 @@ pub enum CryptoError { #[error("Segment not found: {0}")] SegmentNotFound(String), + #[error("Duplicate segment {0}")] + DuplicateSegment(String), + #[error("Vault corrupted: {0}")] VaultCorrupted(String), From b2514147044102d18d44e57ef43b157993f377ff Mon Sep 17 00:00:00 2001 From: Adel HB Date: Mon, 6 Apr 2026 18:51:02 +0100 Subject: [PATCH 12/32] feat(evfs): implement logical rename in SegmentIndex --- rust/src/core/evfs/format.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/rust/src/core/evfs/format.rs b/rust/src/core/evfs/format.rs index 4911151..0306756 100644 --- a/rust/src/core/evfs/format.rs +++ b/rust/src/core/evfs/format.rs @@ -553,6 +553,31 @@ impl SegmentIndex { self.entries.iter_mut().find(|e| e.name == name) } + /// Renames a segment in the index. Returns an error if the old name is missing, + /// the new name is invalid, or the new name is already taken. + pub fn rename(&mut self, old_name: &str, new_name: &str) -> Result<(), CryptoError> { + if new_name.is_empty() || new_name.len() > MAX_SEGMENT_NAME_LEN { + return Err(CryptoError::InvalidParameter(format!( + "new segment name invalid: {}", + new_name.len() + ))); + } + + if self.find(new_name).is_some() { + return Err(CryptoError::DuplicateSegment(new_name.to_string())); + } + + let entry = self + .entries + .iter_mut() + .find(|e| e.name == old_name) + .ok_or_else(|| CryptoError::SegmentNotFound(old_name.to_string()))?; + + entry.name = new_name.to_string(); + + Ok(()) + } + pub fn remove(&mut self, name: &str) -> Option { if let Some(pos) = self.entries.iter().position(|e| e.name == name) { Some(self.entries.remove(pos)) From fdc56d3afd6bf675a0e5483489d8ebc98f5cc46c Mon Sep 17 00:00:00 2001 From: Adel HB Date: Mon, 6 Apr 2026 19:05:22 +0100 Subject: [PATCH 13/32] feat(api): implement vault_rename_segment with WAL crash recovery --- rust/src/api/evfs/mod.rs | 64 +++++++++++++++++++++++++++++++++++----- 1 file changed, 56 insertions(+), 8 deletions(-) diff --git a/rust/src/api/evfs/mod.rs b/rust/src/api/evfs/mod.rs index 6a80e20..8472983 100644 --- a/rust/src/api/evfs/mod.rs +++ b/rust/src/api/evfs/mod.rs @@ -12,7 +12,8 @@ pub use types::*; use crate::api::compression::{CompressionAlgorithm, CompressionConfig}; use crate::core::error::CryptoError; use crate::core::evfs::format::{ - self, SegmentEntry, SegmentIndex, VaultHeader, PRIMARY_INDEX_OFFSET, VAULT_HEADER_SIZE, + self, SegmentEntry, SegmentIndex, VaultHeader, MAX_SEGMENT_NAME_LEN, PRIMARY_INDEX_OFFSET, + VAULT_HEADER_SIZE, }; use crate::core::evfs::segment::{self, SegmentCryptoParams}; use crate::core::evfs::wal::{VaultLock, WalOp, WriteAheadLog}; @@ -747,6 +748,58 @@ fn write_encrypted_chunk( Ok(()) } +/// Renames an existing segment in the vault index without modifying its encrypted data. +/// Index-only operation — no data I/O, no re-encryption. +#[cfg(feature = "compression")] +pub fn vault_rename_segment( + handle: &mut VaultHandle, + old_name: String, + new_name: String, +) -> Result<(), CryptoError> { + // Pre-flight read-only checks to avoid unnecessary WAL I/O on failure + if new_name.is_empty() || new_name.len() > MAX_SEGMENT_NAME_LEN { + return Err(CryptoError::InvalidParameter(format!( + "invalid new segment name length: {}", + new_name.len() + ))); + } + if handle.index.find(&new_name).is_some() { + return Err(CryptoError::DuplicateSegment(new_name)); + } + if handle.index.find(&old_name).is_none() { + return Err(CryptoError::SegmentNotFound(old_name)); + } + + // WAL journal old index snapshot + let old_encrypted_index = read_encrypted_index( + &mut handle.file, + PRIMARY_INDEX_OFFSET, + format::encrypted_index_size(handle.index_pad_size), + )?; + handle.wal.begin(WalOp::UpdateIndex, &old_encrypted_index)?; + + // Update entry in memory + handle.index.rename(&old_name, &new_name)?; + + // Flush index (primary + shadow) + fsync + handle.index_dirty = true; + flush_index( + &mut handle.file, + &handle.index, + &handle.keys, + handle.algorithm, + handle.index.capacity, + handle.index_pad_size, + )?; + handle.index_dirty = false; + + // WAL commit + refresh mmap + handle.wal.commit()?; + handle.refresh_mmap(); + + Ok(()) +} + /// Delete a named segment. The region is secure-erased and returned to the /// free list for reuse by future writes. #[cfg(feature = "compression")] @@ -961,10 +1014,7 @@ pub fn vault_health(handle: &VaultHandle) -> VaultHealthInfo { /// /// Falls back to sequential file-based reads when mmap is unavailable. #[cfg(feature = "compression")] -pub fn vault_read_parallel( - handle: &VaultHandle, - names: Vec, -) -> Vec { +pub fn vault_read_parallel(handle: &VaultHandle, names: Vec) -> Vec { if names.is_empty() { return Vec::new(); } @@ -1208,9 +1258,7 @@ pub fn vault_flush(handle: &mut VaultHandle) -> Result<(), CryptoError> { PRIMARY_INDEX_OFFSET, format::encrypted_index_size(handle.index_pad_size), )?; - handle - .wal - .begin(WalOp::UpdateIndex, &old_encrypted_index)?; + handle.wal.begin(WalOp::UpdateIndex, &old_encrypted_index)?; flush_index( &mut handle.file, &handle.index, From f98dd0ac44ad2f9dcb707604a0f70c4907f5b8f0 Mon Sep 17 00:00:00 2001 From: Adel HB Date: Mon, 6 Apr 2026 19:06:03 +0100 Subject: [PATCH 14/32] test(evfs): add comprehensive test suite for vault_rename_segment feature --- rust/src/api/evfs/tests.rs | 187 +++++++++++++++++++++++++++++++++++-- 1 file changed, 179 insertions(+), 8 deletions(-) diff --git a/rust/src/api/evfs/tests.rs b/rust/src/api/evfs/tests.rs index 6981a54..2bd1ebc 100644 --- a/rust/src/api/evfs/tests.rs +++ b/rust/src/api/evfs/tests.rs @@ -2760,7 +2760,9 @@ fn parse_archive( assert!(data.len() >= ARCHIVE_HEADER_SIZE + WRAPPED_KEY_SIZE + ARCHIVE_TRAILER_SIZE); let header = ArchiveHeader::from_bytes( - data[..ARCHIVE_HEADER_SIZE].try_into().expect("header slice"), + data[..ARCHIVE_HEADER_SIZE] + .try_into() + .expect("header slice"), ) .expect("parse header"); @@ -2784,8 +2786,9 @@ fn parse_archive( }); } - let trailer_bytes: [u8; ARCHIVE_TRAILER_SIZE] = - data[pos..pos + ARCHIVE_TRAILER_SIZE].try_into().expect("trailer slice"); + let trailer_bytes: [u8; ARCHIVE_TRAILER_SIZE] = data[pos..pos + ARCHIVE_TRAILER_SIZE] + .try_into() + .expect("trailer slice"); let trailer = ArchiveTrailer::from_bytes(&trailer_bytes).expect("parse trailer"); // Verify BLAKE3 trailer covers everything before the trailer @@ -3413,8 +3416,8 @@ fn test_index_dirty_false_after_create() { fn test_index_dirty_false_after_open() { let dir = tempfile::tempdir().expect("tempdir"); let path = vault_path(&dir); - let handle = vault_create(path.clone(), test_key(), "aes-256-gcm".into(), 1_048_576) - .expect("create"); + let handle = + vault_create(path.clone(), test_key(), "aes-256-gcm".into(), 1_048_576).expect("create"); vault_close(handle).expect("close"); let handle = vault_open(path, test_key()).expect("open"); @@ -3619,8 +3622,13 @@ fn test_parallel_read_mixed_monolithic_and_streaming() { let stream_data: Vec = (0..=255u8).cycle().take(100_000).collect(); let stream_iter = stream_data.chunks(8192).map(|c| c.to_vec()); - vault_write_stream(&mut handle, "stream".into(), stream_data.len() as u64, stream_iter) - .expect("write stream"); + vault_write_stream( + &mut handle, + "stream".into(), + stream_data.len() as u64, + stream_iter, + ) + .expect("write stream"); let results = vault_read_parallel(&handle, vec!["mono".into(), "stream".into()]); @@ -3647,7 +3655,11 @@ fn test_parallel_read_missing_segment() { assert_eq!(results[0].data, vec![1, 2, 3]); assert!(results[1].error.is_some()); assert!( - results[1].error.as_ref().expect("error").contains("missing"), + results[1] + .error + .as_ref() + .expect("error") + .contains("missing"), "error should mention segment name" ); assert!(results[1].data.is_empty()); @@ -3746,3 +3758,162 @@ fn test_parallel_read_fallback_sequential() { vault_close(handle).expect("close"); } + +// -- Rename Segment Tests ----------------------------------------------- + +#[test] +fn test_rename_read_success() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write(&mut handle, "old.txt".into(), b"data".to_vec(), None).expect("write"); + vault_rename_segment(&mut handle, "old.txt".into(), "new.txt".into()).expect("rename"); + + let data = vault_read(&mut handle, "new.txt".into()).expect("read new"); + assert_eq!(data, b"data"); + + let result = vault_read(&mut handle, "old.txt".into()); + assert!(matches!(result, Err(CryptoError::SegmentNotFound(_)))); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_rename_duplicate_name_fails() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write(&mut handle, "a.txt".into(), b"a".to_vec(), None).expect("write a"); + vault_write(&mut handle, "b.txt".into(), b"b".to_vec(), None).expect("write b"); + + let result = vault_rename_segment(&mut handle, "a.txt".into(), "b.txt".into()); + assert!(matches!(result, Err(CryptoError::DuplicateSegment(_)))); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_rename_nonexistent_fails() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + let result = vault_rename_segment(&mut handle, "missing.txt".into(), "new.txt".into()); + assert!(matches!(result, Err(CryptoError::SegmentNotFound(_)))); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_rename_preserves_metadata() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write(&mut handle, "old.txt".into(), b"data".to_vec(), None).expect("write"); + let old_entry = handle.index.find("old.txt").expect("find").clone(); + + vault_rename_segment(&mut handle, "old.txt".into(), "new.txt".into()).expect("rename"); + let new_entry = handle.index.find("new.txt").expect("find"); + + assert_eq!(old_entry.offset, new_entry.offset); + assert_eq!(old_entry.size, new_entry.size); + assert_eq!(old_entry.generation, new_entry.generation); + assert_eq!(old_entry.checksum, new_entry.checksum); + assert_eq!(old_entry.compression, new_entry.compression); + assert_eq!(old_entry.chunk_count, new_entry.chunk_count); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_rename_multiple_sequence() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write(&mut handle, "1.txt".into(), b"sequence".to_vec(), None).expect("write"); + vault_rename_segment(&mut handle, "1.txt".into(), "2.txt".into()).expect("r1"); + vault_rename_segment(&mut handle, "2.txt".into(), "3.txt".into()).expect("r2"); + + assert!(handle.index.find("1.txt").is_none()); + assert!(handle.index.find("2.txt").is_none()); + + let data = vault_read(&mut handle, "3.txt".into()).expect("read"); + assert_eq!(data, b"sequence"); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_rename_streaming_segment() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 2_097_152); + + let data = vec![0x77; 150_000]; + let chunks: Vec> = data.chunks(65536).map(|c| c.to_vec()).collect(); + vault_write_stream( + &mut handle, + "stream.bin".into(), + data.len() as u64, + chunks.into_iter(), + ) + .expect("write stream"); + + vault_rename_segment( + &mut handle, + "stream.bin".into(), + "renamed_stream.bin".into(), + ) + .expect("rename"); + + // Interop read on streaming segment + let readback = vault_read(&mut handle, "renamed_stream.bin".into()).expect("read"); + assert_eq!(readback, data); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_rename_and_defragment() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write(&mut handle, "a.txt".into(), vec![0xAA; 100], None).expect("A"); + vault_write(&mut handle, "b.txt".into(), vec![0xBB; 100], None).expect("B"); + vault_write(&mut handle, "c.txt".into(), vec![0xCC; 100], None).expect("C"); + + vault_delete(&mut handle, "b.txt".into()).expect("del B"); + vault_rename_segment(&mut handle, "c.txt".into(), "c_renamed.txt".into()).expect("rename"); + + vault_defragment(&mut handle).expect("defrag"); + + let data = vault_read(&mut handle, "c_renamed.txt".into()).expect("read"); + assert_eq!(data, vec![0xCC; 100]); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_rename_chacha20() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = vault_path(&dir); + let mut handle = + vault_create(path, test_key(), "chacha20-poly1305".into(), 1_048_576).expect("create"); + + vault_write( + &mut handle, + "old_chacha.txt".into(), + b"chacha".to_vec(), + None, + ) + .expect("write"); + vault_rename_segment( + &mut handle, + "old_chacha.txt".into(), + "new_chacha.txt".into(), + ) + .expect("rename"); + + let data = vault_read(&mut handle, "new_chacha.txt".into()).expect("read"); + assert_eq!(data, b"chacha"); + + vault_close(handle).expect("close"); +} From 167e652700e3ddca129e39029961c375f1d435eb Mon Sep 17 00:00:00 2001 From: Adel-Ayoub Date: Tue, 7 Apr 2026 11:50:05 +0100 Subject: [PATCH 15/32] fix(evfs): handle rename-to-self and remove redundant validation --- rust/src/api/evfs/mod.rs | 11 ++++++++--- rust/src/api/evfs/tests.rs | 14 ++++++++++++++ rust/src/core/evfs/format.rs | 15 ++------------- 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/rust/src/api/evfs/mod.rs b/rust/src/api/evfs/mod.rs index 8472983..83bc557 100644 --- a/rust/src/api/evfs/mod.rs +++ b/rust/src/api/evfs/mod.rs @@ -756,6 +756,11 @@ pub fn vault_rename_segment( old_name: String, new_name: String, ) -> Result<(), CryptoError> { + // No-op when names are identical + if old_name == new_name { + return Ok(()); + } + // Pre-flight read-only checks to avoid unnecessary WAL I/O on failure if new_name.is_empty() || new_name.len() > MAX_SEGMENT_NAME_LEN { return Err(CryptoError::InvalidParameter(format!( @@ -763,12 +768,12 @@ pub fn vault_rename_segment( new_name.len() ))); } - if handle.index.find(&new_name).is_some() { - return Err(CryptoError::DuplicateSegment(new_name)); - } if handle.index.find(&old_name).is_none() { return Err(CryptoError::SegmentNotFound(old_name)); } + if handle.index.find(&new_name).is_some() { + return Err(CryptoError::DuplicateSegment(new_name)); + } // WAL journal old index snapshot let old_encrypted_index = read_encrypted_index( diff --git a/rust/src/api/evfs/tests.rs b/rust/src/api/evfs/tests.rs index 2bd1ebc..003f13e 100644 --- a/rust/src/api/evfs/tests.rs +++ b/rust/src/api/evfs/tests.rs @@ -3917,3 +3917,17 @@ fn test_rename_chacha20() { vault_close(handle).expect("close"); } + +#[test] +fn test_rename_to_same_name_is_noop() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write(&mut handle, "same.txt".into(), b"data".to_vec(), None).expect("write"); + vault_rename_segment(&mut handle, "same.txt".into(), "same.txt".into()).expect("noop rename"); + + let data = vault_read(&mut handle, "same.txt".into()).expect("read"); + assert_eq!(data, b"data"); + + vault_close(handle).expect("close"); +} diff --git a/rust/src/core/evfs/format.rs b/rust/src/core/evfs/format.rs index 0306756..fe52d36 100644 --- a/rust/src/core/evfs/format.rs +++ b/rust/src/core/evfs/format.rs @@ -553,20 +553,9 @@ impl SegmentIndex { self.entries.iter_mut().find(|e| e.name == name) } - /// Renames a segment in the index. Returns an error if the old name is missing, - /// the new name is invalid, or the new name is already taken. + /// Renames a segment in the index. + /// Caller must validate names and check for duplicates before calling. pub fn rename(&mut self, old_name: &str, new_name: &str) -> Result<(), CryptoError> { - if new_name.is_empty() || new_name.len() > MAX_SEGMENT_NAME_LEN { - return Err(CryptoError::InvalidParameter(format!( - "new segment name invalid: {}", - new_name.len() - ))); - } - - if self.find(new_name).is_some() { - return Err(CryptoError::DuplicateSegment(new_name.to_string())); - } - let entry = self .entries .iter_mut() From 76adc74363af0cd09f93dbcc8a0996ec698e1570 Mon Sep 17 00:00:00 2001 From: Adel-Ayoub Date: Wed, 8 Apr 2026 21:05:43 +0100 Subject: [PATCH 16/32] feat(evfs): add metadata field to SegmentEntry with wire format serialization --- rust/src/api/evfs/types.rs | 9 +++ rust/src/core/evfs/format.rs | 135 +++++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+) diff --git a/rust/src/api/evfs/types.rs b/rust/src/api/evfs/types.rs index f2e8e05..325dae9 100644 --- a/rust/src/api/evfs/types.rs +++ b/rust/src/api/evfs/types.rs @@ -5,6 +5,7 @@ use crate::core::evfs::wal::{VaultLock, WriteAheadLog}; use crate::core::format::Algorithm; use flutter_rust_bridge::frb; use memmap2::Mmap; +use std::collections::HashMap; use std::fs::File; // --------------------------------------------------------------------------- @@ -144,6 +145,14 @@ pub struct SegmentResult { pub error: Option, } +/// Result of reading a segment — includes decrypted data and metadata. +#[frb(non_opaque)] +#[derive(Debug, Clone, PartialEq)] +pub struct SegmentReadResult { + pub data: Vec, + pub metadata: HashMap, +} + /// Vault health and diagnostic info returned to callers. #[frb(non_opaque)] #[derive(Debug, Clone, PartialEq)] diff --git a/rust/src/core/evfs/format.rs b/rust/src/core/evfs/format.rs index fe52d36..dee0c62 100644 --- a/rust/src/core/evfs/format.rs +++ b/rust/src/core/evfs/format.rs @@ -2,6 +2,7 @@ use crate::api::compression::CompressionAlgorithm; use crate::core::error::CryptoError; +use std::collections::HashMap; // --------------------------------------------------------------------------- // Constants @@ -14,6 +15,12 @@ pub const VAULT_HEADER_SIZE: usize = 32; /// Max segment name length in bytes (UTF-8). pub const MAX_SEGMENT_NAME_LEN: usize = 255; +/// Max number of metadata key-value pairs per segment. +const MAX_METADATA_PAIRS: u16 = 1024; + +/// Max total serialized metadata size per segment (64KB). +const MAX_METADATA_BYTES: usize = 64 * 1024; + /// Minimum index padding size (64KB). One page supports ~200 segments. pub const MIN_INDEX_PAD_SIZE: usize = 64 * 1024; @@ -240,6 +247,8 @@ pub struct SegmentEntry { /// Number of chunks for streaming segments. 0 = monolithic (one-shot), /// >0 = N independently-encrypted chunks within this segment slot. pub chunk_count: u32, + /// Optional key-value tags. Stored in the encrypted index, not segment data. + pub metadata: HashMap, } impl SegmentEntry { @@ -251,6 +260,7 @@ impl SegmentEntry { checksum: [u8; 32], compression: CompressionAlgorithm, chunk_count: u32, + metadata: HashMap, ) -> Result { if name.is_empty() || name.len() > MAX_SEGMENT_NAME_LEN { return Err(CryptoError::InvalidParameter(format!( @@ -266,6 +276,7 @@ impl SegmentEntry { checksum, compression, chunk_count, + metadata, }) } @@ -386,6 +397,7 @@ impl SegmentIndex { /// per entry: /// [name_len: u16] [name: UTF-8] [offset: u64] [size: u64] /// [generation: u64] [checksum: 32B] [compression: u8] [chunk_count: u32] + /// [meta_count: u16] ([key_len: u16][key][val_len: u16][val])* /// -- free regions -- /// per region: /// [offset: u64] [size: u64] @@ -418,6 +430,24 @@ impl SegmentIndex { buf.extend_from_slice(&entry.checksum); buf.push(entry.compression.to_u8()); put_u32(&mut buf, entry.chunk_count); + + // Metadata: [meta_count: u16] ([key_len: u16][key][val_len: u16][val])* + let meta_count = u16::try_from(entry.metadata.len()).map_err(|_| { + CryptoError::InvalidParameter("too many metadata pairs for u16".into()) + })?; + put_u16(&mut buf, meta_count); + for (key, val) in &entry.metadata { + let klen = u16::try_from(key.len()).map_err(|_| { + CryptoError::InvalidParameter("metadata key too long for u16".into()) + })?; + let vlen = u16::try_from(val.len()).map_err(|_| { + CryptoError::InvalidParameter("metadata value too long for u16".into()) + })?; + put_u16(&mut buf, klen); + buf.extend_from_slice(key.as_bytes()); + put_u16(&mut buf, vlen); + buf.extend_from_slice(val.as_bytes()); + } } for region in &self.free_regions { @@ -504,6 +534,35 @@ impl SegmentIndex { } } + // Metadata: [meta_count: u16] ([key_len: u16][key][val_len: u16][val])* + let meta_count = read_u16(data, &mut off)?; + if meta_count > MAX_METADATA_PAIRS { + return Err(CryptoError::VaultCorrupted(format!( + "segment '{name}': metadata count {meta_count} exceeds limit {MAX_METADATA_PAIRS}" + ))); + } + let mut metadata = HashMap::with_capacity(meta_count as usize); + let meta_start = off; + for _ in 0..meta_count { + let klen = read_u16(data, &mut off)? as usize; + let key_bytes = read_bytes(data, &mut off, klen)?; + let key = String::from_utf8(key_bytes).map_err(|_| { + CryptoError::VaultCorrupted("metadata key is not valid UTF-8".into()) + })?; + let vlen = read_u16(data, &mut off)? as usize; + let val_bytes = read_bytes(data, &mut off, vlen)?; + let val = String::from_utf8(val_bytes).map_err(|_| { + CryptoError::VaultCorrupted("metadata value is not valid UTF-8".into()) + })?; + // OOM guard: cap total metadata bytes consumed + if off - meta_start > MAX_METADATA_BYTES { + return Err(CryptoError::VaultCorrupted(format!( + "segment '{name}': metadata exceeds {MAX_METADATA_BYTES} byte limit" + ))); + } + metadata.insert(key, val); + } + entries.push(SegmentEntry { name, offset, @@ -512,6 +571,7 @@ impl SegmentIndex { checksum, compression, chunk_count, + metadata, }); } @@ -815,6 +875,7 @@ mod tests { dummy_checksum(0), CompressionAlgorithm::None, 0, + HashMap::new(), ); assert!(e.is_ok()); @@ -828,6 +889,7 @@ mod tests { dummy_checksum(0), CompressionAlgorithm::None, 0, + HashMap::new(), ); assert!(e.is_ok()); } @@ -843,6 +905,7 @@ mod tests { dummy_checksum(0), CompressionAlgorithm::None, 0, + HashMap::new(), ); assert!(e.is_err()); } @@ -857,6 +920,7 @@ mod tests { dummy_checksum(0), CompressionAlgorithm::None, 0, + HashMap::new(), ); assert!(e.is_err()); } @@ -874,6 +938,7 @@ mod tests { dummy_checksum(0xAA), CompressionAlgorithm::Zstd, 0, // monolithic + HashMap::new(), ) .expect("entry"), ) @@ -887,6 +952,7 @@ mod tests { dummy_checksum(0xBB), CompressionAlgorithm::None, 0, + HashMap::new(), ) .expect("entry"), ) @@ -930,6 +996,54 @@ mod tests { assert_eq!(parsed.next_generation, 3); } + #[test] + fn test_metadata_serialization_roundtrip() { + let mut idx = SegmentIndex::new(1024 * 1024); + let mut meta = HashMap::new(); + meta.insert("mime".to_string(), "text/plain".to_string()); + meta.insert("created".to_string(), "2026-04-08".to_string()); + meta.insert("".to_string(), "empty key".to_string()); + meta.insert("empty_val".to_string(), "".to_string()); + + idx.add( + SegmentEntry::new( + "with_meta", + 0, + 4096, + 1, + dummy_checksum(0), + CompressionAlgorithm::None, + 0, + meta.clone(), + ) + .expect("entry"), + ) + .expect("add"); + + idx.add( + SegmentEntry::new( + "no_meta", + 4096, + 2048, + 2, + dummy_checksum(1), + CompressionAlgorithm::None, + 0, + HashMap::new(), + ) + .expect("entry"), + ) + .expect("add"); + + let bytes = idx + .to_bytes(compute_index_size(idx.capacity)) + .expect("serialize"); + let parsed = SegmentIndex::from_bytes(&bytes).expect("parse"); + + assert_eq!(parsed.entries[0].metadata, meta); + assert!(parsed.entries[1].metadata.is_empty()); + } + #[test] fn test_segment_index_roundtrip_with_generation() { let mut idx = SegmentIndex::new(1024); @@ -943,6 +1057,7 @@ mod tests { dummy_checksum(0), CompressionAlgorithm::None, 0, + HashMap::new(), ) .expect("entry"), ); @@ -974,6 +1089,7 @@ mod tests { dummy_checksum(i as u8), *algo, 0, + HashMap::new(), ) .expect("entry"), ); @@ -1061,6 +1177,7 @@ mod tests { dummy_checksum(0), CompressionAlgorithm::None, 0, + HashMap::new(), ) .expect("entry"), ); @@ -1356,6 +1473,7 @@ mod tests { dummy_checksum(0), CompressionAlgorithm::None, 0, + HashMap::new(), ) .expect("entry"); let e2 = SegmentEntry::new( @@ -1366,6 +1484,7 @@ mod tests { dummy_checksum(1), CompressionAlgorithm::None, 0, + HashMap::new(), ) .expect("entry"); idx.add(e1).expect("first add"); @@ -1387,6 +1506,7 @@ mod tests { dummy_checksum(0), CompressionAlgorithm::None, 0, + HashMap::new(), ) .expect("entry"), ); @@ -1399,6 +1519,7 @@ mod tests { dummy_checksum(1), CompressionAlgorithm::None, 0, + HashMap::new(), ) .expect("entry"), ); @@ -1421,6 +1542,7 @@ mod tests { dummy_checksum(0), CompressionAlgorithm::None, 0, + HashMap::new(), ) .expect("entry"), ); @@ -1433,6 +1555,7 @@ mod tests { dummy_checksum(1), CompressionAlgorithm::None, 0, + HashMap::new(), ) .expect("entry"), ); @@ -1462,6 +1585,7 @@ mod tests { dummy_checksum(0), CompressionAlgorithm::None, 0, + HashMap::new(), ) .expect("entry"), ); @@ -1490,6 +1614,7 @@ mod tests { dummy_checksum(0), CompressionAlgorithm::None, 0, + HashMap::new(), ) .expect("entry"), ); @@ -1502,6 +1627,7 @@ mod tests { dummy_checksum(1), CompressionAlgorithm::None, 0, + HashMap::new(), ) .expect("entry"), ); @@ -1514,6 +1640,7 @@ mod tests { dummy_checksum(2), CompressionAlgorithm::None, 0, + HashMap::new(), ) .expect("entry"), ); @@ -1560,6 +1687,7 @@ mod tests { dummy_checksum(0), CompressionAlgorithm::None, 0, + HashMap::new(), ) .expect("entry"), ); @@ -1572,6 +1700,7 @@ mod tests { dummy_checksum(1), CompressionAlgorithm::None, 0, + HashMap::new(), ) .expect("entry"), ); @@ -1624,6 +1753,7 @@ mod tests { dummy_checksum(0), CompressionAlgorithm::None, 0, + HashMap::new(), ) .expect("entry"), ); @@ -1637,6 +1767,7 @@ mod tests { dummy_checksum(1), CompressionAlgorithm::None, 5, + HashMap::new(), ) .expect("entry"), ); @@ -1666,6 +1797,7 @@ mod tests { checksum: dummy_checksum(0), compression: CompressionAlgorithm::None, chunk_count: 5, + metadata: HashMap::new(), }); let bytes = idx @@ -1686,6 +1818,7 @@ mod tests { checksum: dummy_checksum(0), compression: CompressionAlgorithm::None, chunk_count: 5, + metadata: HashMap::new(), }); let bytes2 = idx2 .to_bytes(compute_index_size(idx2.capacity)) @@ -1771,6 +1904,7 @@ mod tests { dummy_checksum(0), CompressionAlgorithm::None, 0, + HashMap::new(), ) .expect("entry"); assert!(!mono.is_streaming()); @@ -1783,6 +1917,7 @@ mod tests { dummy_checksum(0), CompressionAlgorithm::None, 1, + HashMap::new(), ) .expect("entry"); assert!(stream.is_streaming()); From 5e49b4dbaae8066df7f8b183838e9ba37a371521 Mon Sep 17 00:00:00 2001 From: Adel-Ayoub Date: Wed, 8 Apr 2026 21:06:03 +0100 Subject: [PATCH 17/32] feat(api): accept and return segment metadata in vault read/write APIs --- rust/src/api/evfs/mod.rs | 36 ++++++++++++++++++++++++++++-------- rust/src/frb_generated.rs | 3 ++- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/rust/src/api/evfs/mod.rs b/rust/src/api/evfs/mod.rs index 83bc557..3d68541 100644 --- a/rust/src/api/evfs/mod.rs +++ b/rust/src/api/evfs/mod.rs @@ -11,6 +11,7 @@ pub use types::*; use crate::api::compression::{CompressionAlgorithm, CompressionConfig}; use crate::core::error::CryptoError; +use std::collections::HashMap; use crate::core::evfs::format::{ self, SegmentEntry, SegmentIndex, VaultHeader, MAX_SEGMENT_NAME_LEN, PRIMARY_INDEX_OFFSET, VAULT_HEADER_SIZE, @@ -268,6 +269,7 @@ pub fn vault_write( name: String, data: Vec, compression: Option, + metadata: Option>, ) -> Result<(), CryptoError> { // SAFETY: Zeroizing guarantees plaintext is wiped on all exit paths let data = Zeroizing::new(data); @@ -331,6 +333,7 @@ pub fn vault_write( checksum, effective_algo, 0, // monolithic (one-shot) segment + metadata.unwrap_or_default(), )?; handle.index.add(entry)?; @@ -356,7 +359,10 @@ pub fn vault_write( /// Read a named segment. Handles both monolithic and streaming segments. /// Decompression is automatic for monolithic segments. #[cfg(feature = "compression")] -pub fn vault_read(handle: &mut VaultHandle, name: String) -> Result, CryptoError> { +pub fn vault_read( + handle: &mut VaultHandle, + name: String, +) -> Result { let entry = handle .index .find(&name) @@ -368,6 +374,7 @@ pub fn vault_read(handle: &mut VaultHandle, name: String) -> Result, Cry let seg_compression = entry.compression; let seg_checksum = entry.checksum; let chunk_count = entry.chunk_count; + let seg_metadata = entry.metadata.clone(); // INTEROP: If the segment is chunked, reassemble it into a single vector if chunk_count > 0 { @@ -395,7 +402,10 @@ pub fn vault_read(handle: &mut VaultHandle, name: String) -> Result, Cry ))); } - return Ok(full_plaintext); + return Ok(SegmentReadResult { + data: full_plaintext, + metadata: seg_metadata, + }); } // Monolithic read (chunk_count == 0): prefer mmap zero-copy, fall back to heap @@ -431,7 +441,10 @@ pub fn vault_read(handle: &mut VaultHandle, name: String) -> Result, Cry ))); } - Ok(plaintext) + Ok(SegmentReadResult { + data: plaintext, + metadata: seg_metadata, + }) } /// Read a named segment sequentially through a stream. Decompression is automatic. @@ -456,8 +469,8 @@ pub fn vault_read_stream( // INTEROP: If chunk_count is 0, this is a monolithic segment. Handle with a one-shot read. if chunk_count == 0 { - let plaintext = vault_read(handle, name)?; - let _ = sink.add(plaintext); + let result = vault_read(handle, name)?; + let _ = sink.add(result.data); let _ = on_progress.add(1.0); return Ok(()); } @@ -496,6 +509,7 @@ pub fn vault_write_stream( name: String, total_plaintext_size: u64, data_stream: impl Iterator>, + metadata: Option>, ) -> Result<(), CryptoError> { use crate::core::streaming::{pad_last_chunk, CHUNK_SIZE}; @@ -621,6 +635,7 @@ pub fn vault_write_stream( checksum, CompressionAlgorithm::None, expected_chunks, + metadata.unwrap_or_default(), )?; handle.index.add(entry)?; @@ -685,7 +700,7 @@ pub fn vault_write_file( } }); - let result = vault_write_stream(handle, name, file_size, data_stream); + let result = vault_write_stream(handle, name, file_size, data_stream, None); // Surface the real I/O error instead of a misleading "stream underflow" if let Some(io_err) = read_error { @@ -1568,6 +1583,7 @@ pub fn vault_rotate_key( old_entry.checksum, CompressionAlgorithm::None, expected_chunks, + old_entry.metadata.clone(), )?; new_index.add(new_entry)?; } else { @@ -1624,6 +1640,7 @@ pub fn vault_rotate_key( old_entry.checksum, CompressionAlgorithm::None, 0, + old_entry.metadata.clone(), )?; new_index.add(new_entry)?; } @@ -1772,7 +1789,7 @@ fn vault_export_write( archive_hasher.update(&header_bytes); archive_hasher.update(wrapped_key); - // Clone entry metadata to avoid borrow conflict with handle + // Clone entry data to avoid borrow conflict with handle let entries: Vec<_> = handle .index .entries @@ -1786,13 +1803,14 @@ fn vault_export_write( e.compression, e.checksum, e.chunk_count, + e.metadata.clone(), ) }) .collect(); let vault_capacity = handle.index.capacity; - for (name, offset, size, generation, compression, checksum, chunk_count) in &entries { + for (name, offset, size, generation, compression, checksum, chunk_count, _metadata) in &entries { // Decrypt the segment plaintext (handles both monolithic and streaming) let plaintext: Zeroizing> = if *chunk_count > 0 { let mut full_plaintext: Zeroizing> = Zeroizing::new(Vec::new()); @@ -2066,6 +2084,7 @@ pub fn vault_import( }; // vault_write takes ownership of plaintext and handles its zeroization internally + // NOTE: metadata is not preserved in .mvex v1 — imported segments get empty metadata vault_write( &mut dest_vault, name, @@ -2074,6 +2093,7 @@ pub fn vault_import( algorithm: comp_algo, level: None, }), + None, )?; } diff --git a/rust/src/frb_generated.rs b/rust/src/frb_generated.rs index 8aae2e6..a488699 100644 --- a/rust/src/frb_generated.rs +++ b/rust/src/frb_generated.rs @@ -1513,7 +1513,7 @@ fn wire__crate__api__evfs__vault_read_impl( } let mut api_handle_guard = api_handle_guard.unwrap(); let output_ok = crate::api::evfs::vault_read(&mut *api_handle_guard, api_name)?; - Ok(output_ok) + Ok(output_ok.data) })()) } }, @@ -1680,6 +1680,7 @@ fn wire__crate__api__evfs__vault_write_impl( api_name, api_data, api_compression, + None, )?; Ok(output_ok) })()) From 43e3ea2d4ac5f1e6e8df8e586c1574069bbb61c8 Mon Sep 17 00:00:00 2001 From: Adel-Ayoub Date: Wed, 8 Apr 2026 21:06:15 +0100 Subject: [PATCH 18/32] test(evfs): add segment metadata tests and update existing callers --- rust/src/api/evfs/tests.rs | 769 ++++++++++++++++++++++++++----------- 1 file changed, 544 insertions(+), 225 deletions(-) diff --git a/rust/src/api/evfs/tests.rs b/rust/src/api/evfs/tests.rs index 003f13e..3ba4f8a 100644 --- a/rust/src/api/evfs/tests.rs +++ b/rust/src/api/evfs/tests.rs @@ -78,7 +78,7 @@ fn test_open_runs_wal_recovery() { { let mut handle = vault_create(path.clone(), test_key(), "aes-256-gcm".into(), 1_048_576) .expect("create"); - vault_write(&mut handle, "a.txt".into(), b"data-A".to_vec(), None).expect("write A"); + vault_write(&mut handle, "a.txt".into(), b"data-A".to_vec(), None, None).expect("write A"); vault_close(handle).expect("close"); } @@ -96,7 +96,7 @@ fn test_open_runs_wal_recovery() { // Add segment B normally (both index and data on disk) { let mut handle = vault_open(path.clone(), test_key()).expect("open"); - vault_write(&mut handle, "b.txt".into(), b"data-B".to_vec(), None).expect("write B"); + vault_write(&mut handle, "b.txt".into(), b"data-B".to_vec(), None, None).expect("write B"); vault_close(handle).expect("close"); } @@ -110,7 +110,7 @@ fn test_open_runs_wal_recovery() { // Reopen — WAL recovery should roll back to A-only index let mut handle = vault_open(path, test_key()).expect("open after recovery"); - let data = vault_read(&mut handle, "a.txt".into()).expect("read A"); + let data = vault_read(&mut handle, "a.txt".into()).expect("read A").data; assert_eq!(data, b"data-A"); let result = vault_read(&mut handle, "b.txt".into()); @@ -126,9 +126,9 @@ fn test_write_read_roundtrip() { let dir = tempfile::tempdir().expect("tempdir"); let mut handle = create_test_vault(&dir, 1_048_576); - vault_write(&mut handle, "doc.txt".into(), b"hello vault".to_vec(), None).expect("write"); + vault_write(&mut handle, "doc.txt".into(), b"hello vault".to_vec(), None, None).expect("write"); - let data = vault_read(&mut handle, "doc.txt".into()).expect("read"); + let data = vault_read(&mut handle, "doc.txt".into()).expect("read").data; assert_eq!(data, b"hello vault"); vault_close(handle).expect("close"); @@ -142,13 +142,13 @@ fn test_write_read_multiple_segments() { for i in 0..5 { let name = format!("seg{i}.bin"); let data = format!("data for segment {i}").into_bytes(); - vault_write(&mut handle, name, data, None).expect("write"); + vault_write(&mut handle, name, data, None, None).expect("write"); } for i in 0..5 { let name = format!("seg{i}.bin"); let expected = format!("data for segment {i}").into_bytes(); - let data = vault_read(&mut handle, name).expect("read"); + let data = vault_read(&mut handle, name).expect("read").data; assert_eq!(data, expected); } @@ -161,10 +161,10 @@ fn test_write_overwrite() { let dir = tempfile::tempdir().expect("tempdir"); let mut handle = create_test_vault(&dir, 1_048_576); - vault_write(&mut handle, "doc.txt".into(), b"version 1".to_vec(), None).expect("write v1"); - vault_write(&mut handle, "doc.txt".into(), b"version 2".to_vec(), None).expect("write v2"); + vault_write(&mut handle, "doc.txt".into(), b"version 1".to_vec(), None, None).expect("write v1"); + vault_write(&mut handle, "doc.txt".into(), b"version 2".to_vec(), None, None).expect("write v2"); - let data = vault_read(&mut handle, "doc.txt".into()).expect("read"); + let data = vault_read(&mut handle, "doc.txt".into()).expect("read").data; assert_eq!(data, b"version 2"); assert_eq!(vault_list(&handle).len(), 1); @@ -176,10 +176,10 @@ fn test_write_overwrite_increments_generation() { let dir = tempfile::tempdir().expect("tempdir"); let mut handle = create_test_vault(&dir, 1_048_576); - vault_write(&mut handle, "doc.txt".into(), b"v1".to_vec(), None).expect("write v1"); + vault_write(&mut handle, "doc.txt".into(), b"v1".to_vec(), None, None).expect("write v1"); let gen1 = handle.index.find("doc.txt").expect("find").generation; - vault_write(&mut handle, "doc.txt".into(), b"v2".to_vec(), None).expect("write v2"); + vault_write(&mut handle, "doc.txt".into(), b"v2".to_vec(), None, None).expect("write v2"); let gen2 = handle.index.find("doc.txt").expect("find").generation; assert!(gen2 > gen1); @@ -207,6 +207,7 @@ fn test_read_tampered_segment() { "secret.txt".into(), b"important data".to_vec(), None, + None, ) .expect("write"); @@ -239,8 +240,8 @@ fn test_write_read_zstd_roundtrip() { algorithm: CompressionAlgorithm::Zstd, level: None, }; - vault_write(&mut handle, "data.txt".into(), data.clone(), Some(config)).expect("write"); - let read_back = vault_read(&mut handle, "data.txt".into()).expect("read"); + vault_write(&mut handle, "data.txt".into(), data.clone(), Some(config), None).expect("write"); + let read_back = vault_read(&mut handle, "data.txt".into()).expect("read").data; assert_eq!(read_back, data); vault_close(handle).expect("close"); @@ -256,8 +257,8 @@ fn test_write_read_brotli_roundtrip() { algorithm: CompressionAlgorithm::Brotli, level: None, }; - vault_write(&mut handle, "notes.md".into(), data.clone(), Some(config)).expect("write"); - let read_back = vault_read(&mut handle, "notes.md".into()).expect("read"); + vault_write(&mut handle, "notes.md".into(), data.clone(), Some(config), None).expect("write"); + let read_back = vault_read(&mut handle, "notes.md".into()).expect("read").data; assert_eq!(read_back, data); vault_close(handle).expect("close"); @@ -273,8 +274,8 @@ fn test_write_read_no_compression() { algorithm: CompressionAlgorithm::None, level: None, }; - vault_write(&mut handle, "raw.bin".into(), data.clone(), Some(config)).expect("write"); - let read_back = vault_read(&mut handle, "raw.bin".into()).expect("read"); + vault_write(&mut handle, "raw.bin".into(), data.clone(), Some(config), None).expect("write"); + let read_back = vault_read(&mut handle, "raw.bin".into()).expect("read").data; assert_eq!(read_back, data); vault_close(handle).expect("close"); @@ -294,13 +295,14 @@ fn test_write_jpg_skips_compression() { "photo.jpg".into(), b"fake jpeg".to_vec(), Some(config), + None, ) .expect("write"); let entry = handle.index.find("photo.jpg").expect("find"); assert_eq!(entry.compression, CompressionAlgorithm::None); - let data = vault_read(&mut handle, "photo.jpg".into()).expect("read"); + let data = vault_read(&mut handle, "photo.jpg".into()).expect("read").data; assert_eq!(data, b"fake jpeg"); vault_close(handle).expect("close"); @@ -316,10 +318,10 @@ fn test_read_decompresses_automatically() { algorithm: CompressionAlgorithm::Zstd, level: None, }; - vault_write(&mut handle, "auto.txt".into(), data.clone(), Some(config)).expect("write"); + vault_write(&mut handle, "auto.txt".into(), data.clone(), Some(config), None).expect("write"); // Read back — decompression is automatic (no config needed) - let read_back = vault_read(&mut handle, "auto.txt".into()).expect("read"); + let read_back = vault_read(&mut handle, "auto.txt".into()).expect("read").data; assert_eq!(read_back, data); vault_close(handle).expect("close"); @@ -348,6 +350,7 @@ fn test_mixed_compression_segments() { "text.txt".into(), text.clone(), Some(zstd_conf), + None, ) .expect("zstd"); vault_write( @@ -355,16 +358,17 @@ fn test_mixed_compression_segments() { "data.bin".into(), binary.clone(), Some(brotli_conf), + None, ) .expect("brotli"); - vault_write(&mut handle, "raw.dat".into(), raw.clone(), None).expect("none"); + vault_write(&mut handle, "raw.dat".into(), raw.clone(), None, None).expect("none"); - assert_eq!(vault_read(&mut handle, "text.txt".into()).expect("r"), text); + assert_eq!(vault_read(&mut handle, "text.txt".into()).expect("r").data, text); assert_eq!( - vault_read(&mut handle, "data.bin".into()).expect("r"), + vault_read(&mut handle, "data.bin".into()).expect("r").data, binary ); - assert_eq!(vault_read(&mut handle, "raw.dat".into()).expect("r"), raw); + assert_eq!(vault_read(&mut handle, "raw.dat".into()).expect("r").data, raw); vault_close(handle).expect("close"); } @@ -379,7 +383,7 @@ fn test_checksum_on_original_plaintext() { algorithm: CompressionAlgorithm::Zstd, level: None, }; - vault_write(&mut handle, "check.txt".into(), data.clone(), Some(config)).expect("write"); + vault_write(&mut handle, "check.txt".into(), data.clone(), Some(config), None).expect("write"); // Verify the stored checksum matches original plaintext (not compressed form) let entry = handle.index.find("check.txt").expect("find"); @@ -400,6 +404,7 @@ fn test_delete_returns_space_to_free_list() { "a.txt".into(), b"some data here".to_vec(), None, + None, ) .expect("write"); let seg_size = handle.index.find("a.txt").expect("find").size; @@ -419,7 +424,7 @@ fn test_write_reuses_deleted_space() { let mut handle = create_test_vault(&dir, 1_048_576); // Write A — note the offset and size - vault_write(&mut handle, "a.txt".into(), vec![0xAA; 200], None).expect("write A"); + vault_write(&mut handle, "a.txt".into(), vec![0xAA; 200], None, None).expect("write A"); let a_offset = handle.index.find("a.txt").expect("A").offset; let a_size = handle.index.find("a.txt").expect("A").size; @@ -427,7 +432,7 @@ fn test_write_reuses_deleted_space() { vault_delete(&mut handle, "a.txt".into()).expect("delete A"); // Write B (smaller) — should reuse A's space - vault_write(&mut handle, "b.txt".into(), vec![0xBB; 50], None).expect("write B"); + vault_write(&mut handle, "b.txt".into(), vec![0xBB; 50], None, None).expect("write B"); let b_offset = handle.index.find("b.txt").expect("B").offset; let b_size = handle.index.find("b.txt").expect("B").size; @@ -447,13 +452,13 @@ fn test_write_after_delete_exact_fit() { let mut handle = create_test_vault(&dir, 1_048_576); // Write and capture the encrypted size - vault_write(&mut handle, "a.txt".into(), vec![0xAA; 200], None).expect("write A"); + vault_write(&mut handle, "a.txt".into(), vec![0xAA; 200], None, None).expect("write A"); let a_offset = handle.index.find("a.txt").expect("A").offset; let a_size = handle.index.find("a.txt").expect("A").size; vault_delete(&mut handle, "a.txt".into()).expect("delete A"); // Write B with same plaintext size — encrypted size should match exactly - vault_write(&mut handle, "b.txt".into(), vec![0xBB; 200], None).expect("write B"); + vault_write(&mut handle, "b.txt".into(), vec![0xBB; 200], None, None).expect("write B"); let b_offset = handle.index.find("b.txt").expect("B").offset; let b_size = handle.index.find("b.txt").expect("B").size; @@ -469,11 +474,11 @@ fn test_overwrite_reclaims_then_allocates() { let dir = tempfile::tempdir().expect("tempdir"); let mut handle = create_test_vault(&dir, 1_048_576); - vault_write(&mut handle, "doc.txt".into(), vec![0xAA; 500], None).expect("write big"); + vault_write(&mut handle, "doc.txt".into(), vec![0xAA; 500], None, None).expect("write big"); let old_size = handle.index.find("doc.txt").expect("old").size; // Overwrite with smaller data - vault_write(&mut handle, "doc.txt".into(), vec![0xBB; 100], None).expect("overwrite small"); + vault_write(&mut handle, "doc.txt".into(), vec![0xBB; 100], None, None).expect("overwrite small"); let new_size = handle.index.find("doc.txt").expect("new").size; assert!(new_size < old_size); @@ -490,9 +495,9 @@ fn test_delete_multiple_merges_adjacent() { let mut handle = create_test_vault(&dir, 1_048_576); // Write A, B, C contiguously - vault_write(&mut handle, "a.txt".into(), vec![0xAA; 100], None).expect("A"); - vault_write(&mut handle, "b.txt".into(), vec![0xBB; 100], None).expect("B"); - vault_write(&mut handle, "c.txt".into(), vec![0xCC; 100], None).expect("C"); + vault_write(&mut handle, "a.txt".into(), vec![0xAA; 100], None, None).expect("A"); + vault_write(&mut handle, "b.txt".into(), vec![0xBB; 100], None, None).expect("B"); + vault_write(&mut handle, "c.txt".into(), vec![0xCC; 100], None, None).expect("C"); let b_size = handle.index.find("b.txt").expect("B").size; let c_size = handle.index.find("c.txt").expect("C").size; @@ -513,12 +518,12 @@ fn test_free_list_falls_back_to_append() { let mut handle = create_test_vault(&dir, 1_048_576); // Write A (small), delete it - vault_write(&mut handle, "a.txt".into(), vec![0xAA; 50], None).expect("A"); + vault_write(&mut handle, "a.txt".into(), vec![0xAA; 50], None, None).expect("A"); let a_size = handle.index.find("a.txt").expect("A").size; vault_delete(&mut handle, "a.txt".into()).expect("del A"); // Write B (much larger) — won't fit in A's free region - vault_write(&mut handle, "b.txt".into(), vec![0xBB; 5000], None).expect("B"); + vault_write(&mut handle, "b.txt".into(), vec![0xBB; 5000], None, None).expect("B"); let b_offset = handle.index.find("b.txt").expect("B").offset; // B should be appended (offset > A's region) @@ -537,8 +542,8 @@ fn test_capacity_reflects_free_list() { let capacity = 1_048_576u64; let mut handle = create_test_vault(&dir, capacity); - vault_write(&mut handle, "a.txt".into(), vec![0xAA; 200], None).expect("A"); - vault_write(&mut handle, "b.txt".into(), vec![0xBB; 300], None).expect("B"); + vault_write(&mut handle, "a.txt".into(), vec![0xAA; 200], None, None).expect("A"); + vault_write(&mut handle, "b.txt".into(), vec![0xBB; 300], None, None).expect("B"); let cap = vault_capacity(&handle); assert_eq!(cap.total_bytes, capacity); @@ -559,7 +564,7 @@ fn test_delete_segment() { let dir = tempfile::tempdir().expect("tempdir"); let mut handle = create_test_vault(&dir, 1_048_576); - vault_write(&mut handle, "tmp.txt".into(), b"temp data".to_vec(), None).expect("write"); + vault_write(&mut handle, "tmp.txt".into(), b"temp data".to_vec(), None, None).expect("write"); vault_delete(&mut handle, "tmp.txt".into()).expect("delete"); let result = vault_read(&mut handle, "tmp.txt".into()); @@ -578,6 +583,7 @@ fn test_delete_secure_erase() { "secret.txt".into(), b"sensitive data".to_vec(), None, + None, ) .expect("write"); @@ -616,10 +622,10 @@ fn test_vault_full() { let mut handle = create_test_vault(&dir, 256); // First write (encrypted data ~ 28 + plaintext bytes) - vault_write(&mut handle, "a.txt".into(), vec![0xAA; 200], None).expect("A"); + vault_write(&mut handle, "a.txt".into(), vec![0xAA; 200], None, None).expect("A"); // Second write should fail — not enough space - let result = vault_write(&mut handle, "b.txt".into(), vec![0xBB; 200], None); + let result = vault_write(&mut handle, "b.txt".into(), vec![0xBB; 200], None, None); assert!(matches!(result, Err(CryptoError::VaultFull { .. }))); vault_close(handle).expect("close"); @@ -632,15 +638,15 @@ fn test_vault_full_with_free_list() { let mut handle = create_test_vault(&dir, 512); // Fill vault with A - vault_write(&mut handle, "a.txt".into(), vec![0xAA; 400], None).expect("A"); + vault_write(&mut handle, "a.txt".into(), vec![0xAA; 400], None, None).expect("A"); // Delete A (space returned to free list) vault_delete(&mut handle, "a.txt".into()).expect("del A"); // Write B using freed space — should succeed - vault_write(&mut handle, "b.txt".into(), vec![0xBB; 400], None).expect("B from free list"); + vault_write(&mut handle, "b.txt".into(), vec![0xBB; 400], None, None).expect("B from free list"); - let data = vault_read(&mut handle, "b.txt".into()).expect("read B"); + let data = vault_read(&mut handle, "b.txt".into()).expect("read B").data; assert_eq!(data, vec![0xBB; 400]); vault_close(handle).expect("close"); @@ -659,7 +665,7 @@ fn test_capacity_info() { assert_eq!(cap.unallocated_bytes, capacity); assert_eq!(cap.segment_count, 0); - vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None).expect("write"); + vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None, None).expect("write"); let cap = vault_capacity(&handle); assert_eq!(cap.segment_count, 1); @@ -706,6 +712,7 @@ fn test_write_close_open_read() { "persist.txt".into(), b"survives close".to_vec(), None, + None, ) .expect("write"); vault_close(handle).expect("close"); @@ -713,7 +720,7 @@ fn test_write_close_open_read() { { let mut handle = vault_open(path, test_key()).expect("open"); - let data = vault_read(&mut handle, "persist.txt".into()).expect("read"); + let data = vault_read(&mut handle, "persist.txt".into()).expect("read").data; assert_eq!(data, b"survives close"); vault_close(handle).expect("close"); } @@ -729,7 +736,7 @@ fn test_corrupted_primary_falls_back_to_shadow() { { let mut handle = vault_create(path.clone(), test_key(), "aes-256-gcm".into(), 1_048_576) .expect("create"); - vault_write(&mut handle, "doc.txt".into(), b"shadow test".to_vec(), None).expect("write"); + vault_write(&mut handle, "doc.txt".into(), b"shadow test".to_vec(), None, None).expect("write"); vault_close(handle).expect("close"); } @@ -742,7 +749,7 @@ fn test_corrupted_primary_falls_back_to_shadow() { // Open should succeed via shadow let mut handle = vault_open(path, test_key()).expect("open via shadow"); - let data = vault_read(&mut handle, "doc.txt".into()).expect("read"); + let data = vault_read(&mut handle, "doc.txt".into()).expect("read").data; assert_eq!(data, b"shadow test"); vault_close(handle).expect("close"); @@ -758,19 +765,19 @@ fn test_resize_grow_then_write_in_new_space() { // Fill most of the original 1MB capacity let filler = vec![0xAA; 900_000]; - vault_write(&mut handle, "filler.bin".into(), filler.clone(), None).expect("write filler"); + vault_write(&mut handle, "filler.bin".into(), filler.clone(), None, None).expect("write filler"); // Grow to 2MB vault_resize(&mut handle, 2 * SIZE_MB).expect("grow to 2MB"); // Write a segment that requires space in the new region let big = vec![0xBB; 900_000]; - vault_write(&mut handle, "big.bin".into(), big.clone(), None).expect("write in new space"); + vault_write(&mut handle, "big.bin".into(), big.clone(), None, None).expect("write in new space"); // Read both back - let filler_read = vault_read(&mut handle, "filler.bin".into()).expect("read filler"); + let filler_read = vault_read(&mut handle, "filler.bin".into()).expect("read filler").data; assert_eq!(filler_read, filler); - let big_read = vault_read(&mut handle, "big.bin".into()).expect("read big"); + let big_read = vault_read(&mut handle, "big.bin".into()).expect("read big").data; assert_eq!(big_read, big); vault_close(handle).expect("close"); @@ -786,7 +793,7 @@ fn test_resize_shrink_after_consolidation() { let mut handle = vault_create(path.clone(), test_key(), "aes-256-gcm".into(), 2 * SIZE_MB) .expect("create 2MB"); let data = vec![0xCC; 500_000]; - vault_write(&mut handle, "doc.bin".into(), data, None).expect("write 500KB"); + vault_write(&mut handle, "doc.bin".into(), data, None, None).expect("write 500KB"); vault_close(handle).expect("close"); } @@ -796,7 +803,7 @@ fn test_resize_shrink_after_consolidation() { vault_resize(&mut handle, SIZE_MB).expect("shrink to 1MB"); // Verify all data is still readable. - let data = vault_read(&mut handle, "doc.bin".into()).expect("read after shrink"); + let data = vault_read(&mut handle, "doc.bin".into()).expect("read after shrink").data; assert_eq!(data, vec![0xCC; 500_000]); // Verify capacity reflects the new size. @@ -809,7 +816,7 @@ fn test_resize_shrink_after_consolidation() { // Reopen again to confirm persistence across close/open. { let mut handle = vault_open(path, test_key()).expect("reopen again"); - let data = vault_read(&mut handle, "doc.bin".into()).expect("read persisted"); + let data = vault_read(&mut handle, "doc.bin".into()).expect("read persisted").data; assert_eq!(data, vec![0xCC; 500_000]); vault_close(handle).expect("close"); } @@ -821,7 +828,7 @@ fn test_resize_shrink_below_used_space_fails() { let mut handle = create_test_vault(&dir, SIZE_MB); // Write enough data so that shrinking below used space is impossible. - vault_write(&mut handle, "a.bin".into(), vec![0xDD; 600_000], None).expect("write"); + vault_write(&mut handle, "a.bin".into(), vec![0xDD; 600_000], None, None).expect("write"); // Attempt to shrink to 256KB — should fail with VaultFull. let result = vault_resize(&mut handle, 256 * 1024); @@ -831,7 +838,7 @@ fn test_resize_shrink_below_used_space_fails() { ); // Vault should still be usable after failed shrink. - let data = vault_read(&mut handle, "a.bin".into()).expect("read after failed shrink"); + let data = vault_read(&mut handle, "a.bin".into()).expect("read after failed shrink").data; assert_eq!(data, vec![0xDD; 600_000]); vault_close(handle).expect("close"); @@ -877,7 +884,7 @@ fn test_resize_grow_crash_recovery() { { let mut handle = vault_create(path.clone(), test_key(), "aes-256-gcm".into(), SIZE_MB).expect("create"); - vault_write(&mut handle, "a.txt".into(), b"before grow".to_vec(), None).expect("write A"); + vault_write(&mut handle, "a.txt".into(), b"before grow".to_vec(), None, None).expect("write A"); vault_close(handle).expect("close"); } @@ -896,7 +903,7 @@ fn test_resize_grow_crash_recovery() { { let mut handle = vault_open(path.clone(), test_key()).expect("open"); vault_resize(&mut handle, 2 * SIZE_MB).expect("grow"); - vault_write(&mut handle, "b.txt".into(), b"after grow".to_vec(), None).expect("write B"); + vault_write(&mut handle, "b.txt".into(), b"after grow".to_vec(), None, None).expect("write B"); vault_close(handle).expect("close"); } @@ -914,7 +921,7 @@ fn test_resize_grow_crash_recovery() { let mut handle = vault_open(path, test_key()).expect("open after recovery"); // A should be readable. - let data = vault_read(&mut handle, "a.txt".into()).expect("read A"); + let data = vault_read(&mut handle, "a.txt".into()).expect("read A").data; assert_eq!(data, b"before grow"); // B should NOT be in the recovered index. @@ -929,7 +936,7 @@ fn test_resize_same_capacity_is_noop() { let dir = tempfile::tempdir().expect("tempdir"); let mut handle = create_test_vault(&dir, SIZE_MB); - vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None).expect("write"); + vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None, None).expect("write"); let cap_before = vault_capacity(&handle); // Resize to same capacity — should return Ok without I/O @@ -939,7 +946,7 @@ fn test_resize_same_capacity_is_noop() { assert_eq!(cap_before.total_bytes, cap_after.total_bytes); assert_eq!(cap_before.used_bytes, cap_after.used_bytes); - let data = vault_read(&mut handle, "a.txt".into()).expect("read"); + let data = vault_read(&mut handle, "a.txt".into()).expect("read").data; assert_eq!(data, b"hello"); vault_close(handle).expect("close"); @@ -951,9 +958,9 @@ fn test_resize_shrink_after_defrag_reclaims_max_space() { let mut handle = create_test_vault(&dir, 2 * SIZE_MB); // Write three segments, delete the first two to create gaps - vault_write(&mut handle, "a.bin".into(), vec![0xAA; 200_000], None).expect("A"); - vault_write(&mut handle, "b.bin".into(), vec![0xBB; 200_000], None).expect("B"); - vault_write(&mut handle, "keep.bin".into(), vec![0xCC; 100_000], None).expect("keep"); + vault_write(&mut handle, "a.bin".into(), vec![0xAA; 200_000], None, None).expect("A"); + vault_write(&mut handle, "b.bin".into(), vec![0xBB; 200_000], None, None).expect("B"); + vault_write(&mut handle, "keep.bin".into(), vec![0xCC; 100_000], None, None).expect("keep"); vault_delete(&mut handle, "a.bin".into()).expect("del A"); vault_delete(&mut handle, "b.bin".into()).expect("del B"); @@ -977,7 +984,7 @@ fn test_resize_shrink_after_defrag_reclaims_max_space() { assert_eq!(cap.total_bytes, new_cap); // Data intact after shrink - let data = vault_read(&mut handle, "keep.bin".into()).expect("read keep"); + let data = vault_read(&mut handle, "keep.bin".into()).expect("read keep").data; assert_eq!(data, vec![0xCC; 100_000]); vault_close(handle).expect("close"); @@ -995,7 +1002,7 @@ fn test_resize_grow_crash_midway_recovers() { { let mut handle = vault_create(path.clone(), test_key(), "aes-256-gcm".into(), SIZE_MB).expect("create"); - vault_write(&mut handle, "a.txt".into(), b"safe data".to_vec(), None).expect("write A"); + vault_write(&mut handle, "a.txt".into(), b"safe data".to_vec(), None, None).expect("write A"); vault_close(handle).expect("close"); } @@ -1046,12 +1053,12 @@ fn test_resize_grow_crash_midway_recovers() { assert_eq!(actual_size, expected_size); // Data intact - let data = vault_read(&mut handle, "a.txt".into()).expect("read A"); + let data = vault_read(&mut handle, "a.txt".into()).expect("read A").data; assert_eq!(data, b"safe data"); // Vault usable after recovery — write + read works - vault_write(&mut handle, "b.txt".into(), b"post recovery".to_vec(), None).expect("write B"); - let b_data = vault_read(&mut handle, "b.txt".into()).expect("read B"); + vault_write(&mut handle, "b.txt".into(), b"post recovery".to_vec(), None, None).expect("write B"); + let b_data = vault_read(&mut handle, "b.txt".into()).expect("read B").data; assert_eq!(b_data, b"post recovery"); vault_close(handle).expect("close"); @@ -1064,9 +1071,9 @@ fn test_defragment_basic() { let dir = tempfile::tempdir().expect("tempdir"); let mut handle = create_test_vault(&dir, 1_048_576); - vault_write(&mut handle, "a.txt".into(), vec![0xAA; 200], None).expect("A"); - vault_write(&mut handle, "b.txt".into(), vec![0xBB; 200], None).expect("B"); - vault_write(&mut handle, "c.txt".into(), vec![0xCC; 200], None).expect("C"); + vault_write(&mut handle, "a.txt".into(), vec![0xAA; 200], None, None).expect("A"); + vault_write(&mut handle, "b.txt".into(), vec![0xBB; 200], None, None).expect("B"); + vault_write(&mut handle, "c.txt".into(), vec![0xCC; 200], None, None).expect("C"); let c_offset_before = handle.index.find("c.txt").expect("C").offset; @@ -1096,11 +1103,11 @@ fn test_defragment_basic() { // Data integrity preserved assert_eq!( - vault_read(&mut handle, "a.txt".into()).expect("read A"), + vault_read(&mut handle, "a.txt".into()).expect("read A").data, vec![0xAA; 200] ); assert_eq!( - vault_read(&mut handle, "c.txt".into()).expect("read C"), + vault_read(&mut handle, "c.txt".into()).expect("read C").data, vec![0xCC; 200] ); @@ -1112,11 +1119,11 @@ fn test_defragment_multiple_gaps() { let dir = tempfile::tempdir().expect("tempdir"); let mut handle = create_test_vault(&dir, 1_048_576); - vault_write(&mut handle, "a.txt".into(), vec![0xAA; 100], None).expect("A"); - vault_write(&mut handle, "b.txt".into(), vec![0xBB; 100], None).expect("B"); - vault_write(&mut handle, "c.txt".into(), vec![0xCC; 100], None).expect("C"); - vault_write(&mut handle, "d.txt".into(), vec![0xDD; 100], None).expect("D"); - vault_write(&mut handle, "e.txt".into(), vec![0xEE; 100], None).expect("E"); + vault_write(&mut handle, "a.txt".into(), vec![0xAA; 100], None, None).expect("A"); + vault_write(&mut handle, "b.txt".into(), vec![0xBB; 100], None, None).expect("B"); + vault_write(&mut handle, "c.txt".into(), vec![0xCC; 100], None, None).expect("C"); + vault_write(&mut handle, "d.txt".into(), vec![0xDD; 100], None, None).expect("D"); + vault_write(&mut handle, "e.txt".into(), vec![0xEE; 100], None, None).expect("E"); // Delete B and D — two gaps vault_delete(&mut handle, "b.txt".into()).expect("del B"); @@ -1129,15 +1136,15 @@ fn test_defragment_multiple_gaps() { // All surviving segments readable assert_eq!( - vault_read(&mut handle, "a.txt".into()).expect("A"), + vault_read(&mut handle, "a.txt".into()).expect("A").data, vec![0xAA; 100] ); assert_eq!( - vault_read(&mut handle, "c.txt".into()).expect("C"), + vault_read(&mut handle, "c.txt".into()).expect("C").data, vec![0xCC; 100] ); assert_eq!( - vault_read(&mut handle, "e.txt".into()).expect("E"), + vault_read(&mut handle, "e.txt".into()).expect("E").data, vec![0xEE; 100] ); @@ -1150,9 +1157,9 @@ fn test_defragment_already_compact() { let dir = tempfile::tempdir().expect("tempdir"); let mut handle = create_test_vault(&dir, 1_048_576); - vault_write(&mut handle, "a.txt".into(), vec![0xAA; 100], None).expect("A"); - vault_write(&mut handle, "b.txt".into(), vec![0xBB; 100], None).expect("B"); - vault_write(&mut handle, "c.txt".into(), vec![0xCC; 100], None).expect("C"); + vault_write(&mut handle, "a.txt".into(), vec![0xAA; 100], None, None).expect("A"); + vault_write(&mut handle, "b.txt".into(), vec![0xBB; 100], None, None).expect("B"); + vault_write(&mut handle, "c.txt".into(), vec![0xCC; 100], None, None).expect("C"); let result = vault_defragment(&mut handle).expect("defrag"); assert_eq!(result.segments_moved, 0); @@ -1161,15 +1168,15 @@ fn test_defragment_already_compact() { // All segments still readable assert_eq!( - vault_read(&mut handle, "a.txt".into()).expect("A"), + vault_read(&mut handle, "a.txt".into()).expect("A").data, vec![0xAA; 100] ); assert_eq!( - vault_read(&mut handle, "b.txt".into()).expect("B"), + vault_read(&mut handle, "b.txt".into()).expect("B").data, vec![0xBB; 100] ); assert_eq!( - vault_read(&mut handle, "c.txt".into()).expect("C"), + vault_read(&mut handle, "c.txt".into()).expect("C").data, vec![0xCC; 100] ); @@ -1194,8 +1201,8 @@ fn test_defragment_single_segment_at_gap() { let dir = tempfile::tempdir().expect("tempdir"); let mut handle = create_test_vault(&dir, 1_048_576); - vault_write(&mut handle, "a.txt".into(), vec![0xAA; 200], None).expect("A"); - vault_write(&mut handle, "b.txt".into(), vec![0xBB; 200], None).expect("B"); + vault_write(&mut handle, "a.txt".into(), vec![0xAA; 200], None, None).expect("A"); + vault_write(&mut handle, "b.txt".into(), vec![0xBB; 200], None, None).expect("B"); // Delete A — gap at start, B remains at higher offset vault_delete(&mut handle, "a.txt".into()).expect("del A"); @@ -1207,7 +1214,7 @@ fn test_defragment_single_segment_at_gap() { // B moved to offset 0 assert_eq!(handle.index.find("b.txt").expect("B").offset, 0); assert_eq!( - vault_read(&mut handle, "b.txt".into()).expect("read B"), + vault_read(&mut handle, "b.txt".into()).expect("read B").data, vec![0xBB; 200] ); @@ -1223,9 +1230,9 @@ fn test_defragment_crash_recovery() { { let mut handle = vault_create(path.clone(), test_key(), "aes-256-gcm".into(), 1_048_576) .expect("create"); - vault_write(&mut handle, "a.txt".into(), vec![0xAA; 100], None).expect("A"); - vault_write(&mut handle, "b.txt".into(), vec![0xBB; 100], None).expect("B"); - vault_write(&mut handle, "c.txt".into(), vec![0xCC; 100], None).expect("C"); + vault_write(&mut handle, "a.txt".into(), vec![0xAA; 100], None, None).expect("A"); + vault_write(&mut handle, "b.txt".into(), vec![0xBB; 100], None, None).expect("B"); + vault_write(&mut handle, "c.txt".into(), vec![0xCC; 100], None, None).expect("C"); vault_delete(&mut handle, "b.txt".into()).expect("del B"); vault_close(handle).expect("close"); } @@ -1254,11 +1261,11 @@ fn test_defragment_crash_recovery() { // C should still be at its original (pre-defrag) offset, readable assert_eq!( - vault_read(&mut handle, "c.txt".into()).expect("read C"), + vault_read(&mut handle, "c.txt".into()).expect("read C").data, vec![0xCC; 100] ); assert_eq!( - vault_read(&mut handle, "a.txt".into()).expect("read A"), + vault_read(&mut handle, "a.txt".into()).expect("read A").data, vec![0xAA; 100] ); @@ -1270,11 +1277,11 @@ fn test_defragment_crash_recovery() { assert!(result.segments_moved > 0); assert_eq!( - vault_read(&mut handle, "a.txt".into()).expect("A"), + vault_read(&mut handle, "a.txt".into()).expect("A").data, vec![0xAA; 100] ); assert_eq!( - vault_read(&mut handle, "c.txt".into()).expect("C"), + vault_read(&mut handle, "c.txt".into()).expect("C").data, vec![0xCC; 100] ); @@ -1294,8 +1301,8 @@ fn test_defragment_crash_overlapping_move_recovers() { { let mut handle = vault_create(path.clone(), test_key(), "aes-256-gcm".into(), SIZE_MB).expect("create"); - vault_write(&mut handle, "a.txt".into(), vec![0xAA; 100], None).expect("A"); - vault_write(&mut handle, "b.txt".into(), vec![0xBB; 10_000], None).expect("B"); + vault_write(&mut handle, "a.txt".into(), vec![0xAA; 100], None, None).expect("A"); + vault_write(&mut handle, "b.txt".into(), vec![0xBB; 10_000], None, None).expect("B"); vault_delete(&mut handle, "a.txt".into()).expect("del A"); vault_close(handle).expect("close"); } @@ -1363,13 +1370,13 @@ fn test_defragment_crash_overlapping_move_recovers() { let mut handle = vault_open(path, test_key()).expect("open after crash"); // B should be readable at old position (overlap zone restored) - let b_data = vault_read(&mut handle, "b.txt".into()).expect("read B"); + let b_data = vault_read(&mut handle, "b.txt".into()).expect("read B").data; assert_eq!(b_data, vec![0xBB; 10_000]); // Defrag should still work after recovery let result = vault_defragment(&mut handle).expect("defrag after recovery"); assert!(result.segments_moved > 0); - let b_data = vault_read(&mut handle, "b.txt".into()).expect("read B post-defrag"); + let b_data = vault_read(&mut handle, "b.txt".into()).expect("read B post-defrag").data; assert_eq!(b_data, vec![0xBB; 10_000]); vault_close(handle).expect("close"); @@ -1392,10 +1399,11 @@ fn test_defragment_preserves_compression() { "text.txt".into(), zstd_data.clone(), Some(zstd_conf), + None, ) .expect("zstd"); - vault_write(&mut handle, "spacer.bin".into(), vec![0xFF; 300], None).expect("spacer"); - vault_write(&mut handle, "raw.dat".into(), raw_data.clone(), None).expect("raw"); + vault_write(&mut handle, "spacer.bin".into(), vec![0xFF; 300], None, None).expect("spacer"); + vault_write(&mut handle, "raw.dat".into(), raw_data.clone(), None, None).expect("raw"); // Delete spacer — gap between text.txt and raw.dat vault_delete(&mut handle, "spacer.bin".into()).expect("del spacer"); @@ -1411,11 +1419,11 @@ fn test_defragment_preserves_compression() { // Data integrity — decompression works after defrag assert_eq!( - vault_read(&mut handle, "text.txt".into()).expect("text"), + vault_read(&mut handle, "text.txt".into()).expect("text").data, zstd_data ); assert_eq!( - vault_read(&mut handle, "raw.dat".into()).expect("raw"), + vault_read(&mut handle, "raw.dat".into()).expect("raw").data, raw_data ); @@ -1427,10 +1435,10 @@ fn test_defragment_preserves_generation() { let dir = tempfile::tempdir().expect("tempdir"); let mut handle = create_test_vault(&dir, 1_048_576); - vault_write(&mut handle, "a.txt".into(), vec![0xAA; 100], None).expect("A"); + vault_write(&mut handle, "a.txt".into(), vec![0xAA; 100], None, None).expect("A"); // Overwrite A to bump its generation - vault_write(&mut handle, "a.txt".into(), vec![0xAA; 100], None).expect("A v2"); - vault_write(&mut handle, "b.txt".into(), vec![0xBB; 100], None).expect("B"); + vault_write(&mut handle, "a.txt".into(), vec![0xAA; 100], None, None).expect("A v2"); + vault_write(&mut handle, "b.txt".into(), vec![0xBB; 100], None, None).expect("B"); let b_gen_before = handle.index.find("b.txt").expect("B").generation; @@ -1446,7 +1454,7 @@ fn test_defragment_preserves_generation() { // Nonce derivation still works (read succeeds) assert_eq!( - vault_read(&mut handle, "b.txt".into()).expect("read B"), + vault_read(&mut handle, "b.txt".into()).expect("read B").data, vec![0xBB; 100] ); @@ -1458,9 +1466,9 @@ fn test_defragment_large_gap_at_start() { let dir = tempfile::tempdir().expect("tempdir"); let mut handle = create_test_vault(&dir, 1_048_576); - vault_write(&mut handle, "a.txt".into(), vec![0xAA; 300], None).expect("A"); - vault_write(&mut handle, "b.txt".into(), vec![0xBB; 300], None).expect("B"); - vault_write(&mut handle, "c.txt".into(), vec![0xCC; 300], None).expect("C"); + vault_write(&mut handle, "a.txt".into(), vec![0xAA; 300], None, None).expect("A"); + vault_write(&mut handle, "b.txt".into(), vec![0xBB; 300], None, None).expect("B"); + vault_write(&mut handle, "c.txt".into(), vec![0xCC; 300], None, None).expect("C"); let ab_size = handle.index.find("a.txt").expect("A").size + handle.index.find("b.txt").expect("B").size; @@ -1475,7 +1483,7 @@ fn test_defragment_large_gap_at_start() { assert_eq!(handle.index.find("c.txt").expect("C").offset, 0); assert_eq!( - vault_read(&mut handle, "c.txt".into()).expect("C"), + vault_read(&mut handle, "c.txt".into()).expect("C").data, vec![0xCC; 300] ); assert_eq!(vault_capacity(&handle).free_list_bytes, 0); @@ -1488,27 +1496,27 @@ fn test_defragment_write_after_defrag() { let dir = tempfile::tempdir().expect("tempdir"); let mut handle = create_test_vault(&dir, 1_048_576); - vault_write(&mut handle, "a.txt".into(), vec![0xAA; 200], None).expect("A"); - vault_write(&mut handle, "b.txt".into(), vec![0xBB; 200], None).expect("B"); - vault_write(&mut handle, "c.txt".into(), vec![0xCC; 200], None).expect("C"); + vault_write(&mut handle, "a.txt".into(), vec![0xAA; 200], None, None).expect("A"); + vault_write(&mut handle, "b.txt".into(), vec![0xBB; 200], None, None).expect("B"); + vault_write(&mut handle, "c.txt".into(), vec![0xCC; 200], None, None).expect("C"); vault_delete(&mut handle, "b.txt".into()).expect("del B"); vault_defragment(&mut handle).expect("defrag"); // Allocator should work: new writes go after the packed segments - vault_write(&mut handle, "d.txt".into(), vec![0xDD; 500], None).expect("D after defrag"); + vault_write(&mut handle, "d.txt".into(), vec![0xDD; 500], None, None).expect("D after defrag"); // All segments readable assert_eq!( - vault_read(&mut handle, "a.txt".into()).expect("A"), + vault_read(&mut handle, "a.txt".into()).expect("A").data, vec![0xAA; 200] ); assert_eq!( - vault_read(&mut handle, "c.txt".into()).expect("C"), + vault_read(&mut handle, "c.txt".into()).expect("C").data, vec![0xCC; 200] ); assert_eq!( - vault_read(&mut handle, "d.txt".into()).expect("D"), + vault_read(&mut handle, "d.txt".into()).expect("D").data, vec![0xDD; 500] ); @@ -1533,7 +1541,7 @@ fn test_defragment_ten_segments_delete_odd() { for i in 0..10 { let name = format!("seg{i}.bin"); let data = vec![(i as u8) | 0x10; 1024]; - vault_write(&mut handle, name, data, None).expect("write"); + vault_write(&mut handle, name, data, None, None).expect("write"); } // Delete odd-numbered segments (1, 3, 5, 7, 9) — 5 gaps @@ -1571,7 +1579,7 @@ fn test_defragment_ten_segments_delete_odd() { for i in (0..10).step_by(2) { let name = format!("seg{i}.bin"); let expected = vec![(i as u8) | 0x10; 1024]; - let data = vault_read(&mut handle, name).expect("read"); + let data = vault_read(&mut handle, name).expect("read").data; assert_eq!(data, expected); } @@ -1597,9 +1605,9 @@ fn test_vault_health_after_write_delete_and_defrag() { let dir = tempfile::tempdir().expect("tempdir"); let mut handle = create_test_vault(&dir, 1_048_576); - vault_write(&mut handle, "a".into(), vec![0xAA; 200], None).expect("A"); - vault_write(&mut handle, "b".into(), vec![0xBB; 500], None).expect("B"); - vault_write(&mut handle, "c".into(), vec![0xCC; 300], None).expect("C"); + vault_write(&mut handle, "a".into(), vec![0xAA; 200], None, None).expect("A"); + vault_write(&mut handle, "b".into(), vec![0xBB; 500], None, None).expect("B"); + vault_write(&mut handle, "c".into(), vec![0xCC; 300], None, None).expect("C"); let before = vault_health(&handle); assert!(before.used_bytes > 0); @@ -1639,7 +1647,7 @@ fn test_largest_free_block_accounts_for_tail() { let dir = tempfile::tempdir().expect("tempdir"); let mut handle = create_test_vault(&dir, 1024u64); - vault_write(&mut handle, "s".into(), vec![0xAA; 100], None).expect("write"); + vault_write(&mut handle, "s".into(), vec![0xAA; 100], None, None).expect("write"); let h = vault_health(&handle); let free_list_max = handle @@ -1666,7 +1674,7 @@ fn test_vault_health_full_capacity() { let mut i = 0; loop { let name = format!("seg_{i}"); - if vault_write(&mut handle, name, vec![0xAA; 8192], None).is_err() { + if vault_write(&mut handle, name, vec![0xAA; 8192], None, None).is_err() { break; } i += 1; @@ -1688,12 +1696,12 @@ fn test_oneshot_write_oneshot_read_interop() { let mut handle = create_test_vault(&dir, 1_048_576); let data = b"monolithic data".to_vec(); - vault_write(&mut handle, "mono.txt".into(), data.clone(), None).expect("write"); + vault_write(&mut handle, "mono.txt".into(), data.clone(), None, None).expect("write"); let entry = handle.index.find("mono.txt").expect("find"); assert_eq!(entry.chunk_count, 0, "Should be written as monolithic"); - let read_back = vault_read(&mut handle, "mono.txt".into()).expect("read"); + let read_back = vault_read(&mut handle, "mono.txt".into()).expect("read").data; assert_eq!(read_back, data); vault_close(handle).expect("close"); @@ -1715,6 +1723,7 @@ fn test_stream_write_oneshot_read_matches_interop() { "streamed.bin".into(), data.len() as u64, chunks.into_iter(), + None, ) .expect("stream write"); @@ -1722,7 +1731,7 @@ fn test_stream_write_oneshot_read_matches_interop() { assert!(entry.chunk_count > 0, "Should be written as chunked"); // Read using one-shot (interop) testing the chunk-assembly loop - let read_back = vault_read(&mut handle, "streamed.bin".into()).expect("read"); + let read_back = vault_read(&mut handle, "streamed.bin".into()).expect("read").data; assert_eq!(read_back, data, "Data should match byte-for-byte"); vault_close(handle).expect("close"); @@ -1742,6 +1751,7 @@ fn test_tamper_with_chunk_detected() { "streamed.txt".into(), data.len() as u64, chunks.into_iter(), + None, ) .expect("stream write"); @@ -1781,6 +1791,7 @@ fn test_reordered_chunks_detected() { "reorder.bin".into(), data.len() as u64, chunks.into_iter(), + None, ) .expect("stream write"); @@ -1828,6 +1839,7 @@ fn test_integrity_checksum_failure_on_stream() { "checksum.bin".into(), data.len() as u64, vec![data].into_iter(), + None, ) .expect("write"); @@ -1859,6 +1871,7 @@ fn stream_write_chunks( name.to_string(), data.len() as u64, chunks.into_iter(), + None, ) } @@ -1871,7 +1884,7 @@ fn test_stream_write_read_roundtrip() { let data = vec![0x42u8; 200_000]; // ~3 chunks stream_write_chunks(&mut handle, "video.bin", &data, 4096).expect("stream write"); - let readback = vault_read(&mut handle, "video.bin".into()).expect("read"); + let readback = vault_read(&mut handle, "video.bin".into()).expect("read").data; assert_eq!(readback, data); vault_close(handle).expect("close"); @@ -1885,7 +1898,7 @@ fn test_stream_write_single_byte() { let data = vec![0xAA; 1]; stream_write_chunks(&mut handle, "tiny.bin", &data, 1).expect("stream write"); - let readback = vault_read(&mut handle, "tiny.bin".into()).expect("read"); + let readback = vault_read(&mut handle, "tiny.bin".into()).expect("read").data; assert_eq!(readback, data); vault_close(handle).expect("close"); @@ -1897,10 +1910,10 @@ fn test_stream_write_empty_segment() { let mut handle = create_test_vault(&dir, 1_048_576); // 0 bytes — still produces 1 padded chunk - vault_write_stream(&mut handle, "empty.bin".into(), 0, std::iter::empty()) + vault_write_stream(&mut handle, "empty.bin".into(), 0, std::iter::empty(), None) .expect("stream write empty"); - let readback = vault_read(&mut handle, "empty.bin".into()).expect("read"); + let readback = vault_read(&mut handle, "empty.bin".into()).expect("read").data; assert!(readback.is_empty()); vault_close(handle).expect("close"); @@ -1917,7 +1930,7 @@ fn test_stream_write_exact_chunk_boundary() { let data = vec![0xBB; CHUNK_SIZE]; stream_write_chunks(&mut handle, "aligned.bin", &data, CHUNK_SIZE).expect("stream write"); - let readback = vault_read(&mut handle, "aligned.bin".into()).expect("read"); + let readback = vault_read(&mut handle, "aligned.bin".into()).expect("read").data; assert_eq!(readback, data); // Verify chunk_count = 2 (1 full + 1 empty padded) @@ -1938,6 +1951,7 @@ fn test_stream_write_overwrite_existing() { "doc.txt".into(), b"original data".to_vec(), None, + None, ) .expect("write"); @@ -1945,7 +1959,7 @@ fn test_stream_write_overwrite_existing() { let new_data = vec![0xCC; 100_000]; stream_write_chunks(&mut handle, "doc.txt", &new_data, 8192).expect("stream overwrite"); - let readback = vault_read(&mut handle, "doc.txt".into()).expect("read"); + let readback = vault_read(&mut handle, "doc.txt".into()).expect("read").data; assert_eq!(readback, new_data); // Verify it's now a streaming segment @@ -1968,7 +1982,7 @@ fn test_stream_write_overwrite_with_smaller() { let smaller = vec![0xBB; 1000]; stream_write_chunks(&mut handle, "file.bin", &smaller, 500).expect("write small"); - let readback = vault_read(&mut handle, "file.bin".into()).expect("read"); + let readback = vault_read(&mut handle, "file.bin".into()).expect("read").data; assert_eq!(readback, smaller); vault_close(handle).expect("close"); @@ -1981,7 +1995,7 @@ fn test_stream_write_wrong_size_too_few_bytes() { // Claim 1000 bytes but provide only 500 let data = vec![0xAA; 500]; - let result = vault_write_stream(&mut handle, "bad.bin".into(), 1000, vec![data].into_iter()); + let result = vault_write_stream(&mut handle, "bad.bin".into(), 1000, vec![data].into_iter(), None); assert!(result.is_err()); let err = result .expect_err("expected an error (underflow)") @@ -1996,7 +2010,7 @@ fn test_stream_write_wrong_size_too_many_bytes() { // Claim 500 bytes but provide 1000 let data = vec![0xAA; 1000]; - let result = vault_write_stream(&mut handle, "bad.bin".into(), 500, vec![data].into_iter()); + let result = vault_write_stream(&mut handle, "bad.bin".into(), 500, vec![data].into_iter(), None); assert!(result.is_err()); let err = result .expect_err("expected an error (exceeded)") @@ -2024,7 +2038,7 @@ fn test_stream_write_persist_reopen() { // Reopen and verify let mut handle = vault_open(path, test_key()).expect("open"); - let readback = vault_read(&mut handle, "persist.bin".into()).expect("read"); + let readback = vault_read(&mut handle, "persist.bin".into()).expect("read").data; assert_eq!(readback, data); vault_close(handle).expect("close"); @@ -2050,7 +2064,7 @@ fn test_stream_write_chacha20() { let data = vec![0xEE; 100_000]; stream_write_chunks(&mut handle, "chacha.bin", &data, 8192).expect("stream write"); - let readback = vault_read(&mut handle, "chacha.bin".into()).expect("read"); + let readback = vault_read(&mut handle, "chacha.bin".into()).expect("read").data; assert_eq!(readback, data); vault_close(handle).expect("close"); @@ -2079,10 +2093,11 @@ fn test_stream_write_arbitrary_input_sizes() { "irregular.bin".into(), total as u64, pieces.into_iter(), + None, ) .expect("stream write"); - let readback = vault_read(&mut handle, "irregular.bin".into()).expect("read"); + let readback = vault_read(&mut handle, "irregular.bin".into()).expect("read").data; assert_eq!(readback, data); vault_close(handle).expect("close"); @@ -2094,17 +2109,17 @@ fn test_stream_write_coexists_with_monolithic() { let mut handle = create_test_vault(&dir, 2 * 1024 * 1024); // Write monolithic - vault_write(&mut handle, "mono.txt".into(), b"mono data".to_vec(), None).expect("write mono"); + vault_write(&mut handle, "mono.txt".into(), b"mono data".to_vec(), None, None).expect("write mono"); // Write streaming let stream_data = vec![0xFF; 80_000]; stream_write_chunks(&mut handle, "stream.bin", &stream_data, 4096).expect("stream write"); // Read both back - let mono = vault_read(&mut handle, "mono.txt".into()).expect("read mono"); + let mono = vault_read(&mut handle, "mono.txt".into()).expect("read mono").data; assert_eq!(mono, b"mono data"); - let stream = vault_read(&mut handle, "stream.bin".into()).expect("read stream"); + let stream = vault_read(&mut handle, "stream.bin".into()).expect("read stream").data; assert_eq!(stream, stream_data); // Verify types @@ -2172,7 +2187,7 @@ fn test_stream_read_matches_oneshot_read() { let data = vec![0x42u8; 200_000]; stream_write_chunks(&mut handle, "video.bin", &data, 4096).expect("stream write"); - let oneshot = vault_read(&mut handle, "video.bin".into()).expect("oneshot read"); + let oneshot = vault_read(&mut handle, "video.bin".into()).expect("oneshot read").data; let (streamed, _, _) = stream_read_chunks(&mut handle, "video.bin").expect("stream read"); assert_eq!( @@ -2242,7 +2257,7 @@ fn test_stream_read_empty_segment() { let dir = tempfile::tempdir().expect("tempdir"); let mut handle = create_test_vault(&dir, 1_048_576); - vault_write_stream(&mut handle, "empty.bin".into(), 0, std::iter::empty()) + vault_write_stream(&mut handle, "empty.bin".into(), 0, std::iter::empty(), None) .expect("stream write empty"); let (collected, indices, _) = @@ -2419,10 +2434,11 @@ fn test_write_file_roundtrip() { "from_file.bin".into(), file_data.len() as u64, chunks.into_iter(), + None, ) .expect("write stream"); - let readback = vault_read(&mut handle, "from_file.bin".into()).expect("read"); + let readback = vault_read(&mut handle, "from_file.bin".into()).expect("read").data; assert_eq!(readback, data); vault_close(handle).expect("close"); @@ -2444,6 +2460,7 @@ fn test_write_file_stream_read_interop() { "interop.bin".into(), file_data.len() as u64, chunks.into_iter(), + None, ) .expect("write stream"); @@ -2475,10 +2492,11 @@ fn test_write_file_large() { "large.bin".into(), file_data.len() as u64, chunks.into_iter(), + None, ) .expect("write stream"); - let readback = vault_read(&mut handle, "large.bin".into()).expect("read"); + let readback = vault_read(&mut handle, "large.bin".into()).expect("read").data; assert_eq!(readback.len(), data.len()); assert_eq!(readback, data); @@ -2495,10 +2513,10 @@ fn test_write_file_empty() { use crate::core::streaming::CHUNK_SIZE; let file_data = std::fs::read(&file_path).expect("read file"); let chunks: Vec> = file_data.chunks(CHUNK_SIZE).map(|c| c.to_vec()).collect(); - vault_write_stream(&mut handle, "empty.bin".into(), 0, chunks.into_iter()) + vault_write_stream(&mut handle, "empty.bin".into(), 0, chunks.into_iter(), None) .expect("write stream"); - let readback = vault_read(&mut handle, "empty.bin".into()).expect("read"); + let readback = vault_read(&mut handle, "empty.bin".into()).expect("read").data; assert!(readback.is_empty()); vault_close(handle).expect("close"); @@ -2515,17 +2533,17 @@ fn test_rotate_key_basic() { let dir = tempfile::tempdir().expect("tempdir"); let mut handle = create_test_vault(&dir, 1_048_576); - vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None).expect("write a"); - vault_write(&mut handle, "b.txt".into(), b"world".to_vec(), None).expect("write b"); + vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None, None).expect("write a"); + vault_write(&mut handle, "b.txt".into(), b"world".to_vec(), None, None).expect("write b"); let mut handle = vault_rotate_key(handle, test_key2()).expect("rotate"); assert_eq!( - vault_read(&mut handle, "a.txt".into()).expect("read a"), + vault_read(&mut handle, "a.txt".into()).expect("read a").data, b"hello" ); assert_eq!( - vault_read(&mut handle, "b.txt".into()).expect("read b"), + vault_read(&mut handle, "b.txt".into()).expect("read b").data, b"world" ); @@ -2545,6 +2563,7 @@ fn test_rotate_key_old_key_fails() { "secret.txt".into(), b"top secret".to_vec(), None, + None, ) .expect("write"); let handle = vault_rotate_key(handle, test_key2()).expect("rotate"); @@ -2557,7 +2576,7 @@ fn test_rotate_key_old_key_fails() { // New key must still work and data must be intact let mut handle = vault_open(path, test_key2()).expect("open with new key"); assert_eq!( - vault_read(&mut handle, "secret.txt".into()).expect("read"), + vault_read(&mut handle, "secret.txt".into()).expect("read").data, b"top secret" ); vault_close(handle).expect("close"); @@ -2573,7 +2592,7 @@ fn test_rotate_key_streaming_segment_survives() { let mut handle = vault_rotate_key(handle, test_key2()).expect("rotate"); - let readback = vault_read(&mut handle, "video.bin".into()).expect("read after rotate"); + let readback = vault_read(&mut handle, "video.bin".into()).expect("read after rotate").data; assert_eq!(readback, data); vault_close(handle).expect("close"); @@ -2589,11 +2608,11 @@ fn test_rotate_key_compressed_segment_survives() { algorithm: CompressionAlgorithm::Zstd, level: None, }; - vault_write(&mut handle, "data.bin".into(), data.clone(), Some(config)).expect("write"); + vault_write(&mut handle, "data.bin".into(), data.clone(), Some(config), None).expect("write"); let mut handle = vault_rotate_key(handle, test_key2()).expect("rotate"); - let readback = vault_read(&mut handle, "data.bin".into()).expect("read after rotate"); + let readback = vault_read(&mut handle, "data.bin".into()).expect("read after rotate").data; assert_eq!(readback, data); vault_close(handle).expect("close"); @@ -2609,6 +2628,7 @@ fn test_rotate_key_checksum_preserved() { "doc.txt".into(), b"checksum test data".to_vec(), None, + None, ) .expect("write"); @@ -2685,12 +2705,13 @@ fn test_rotate_key_chacha20() { "msg.txt".into(), b"chacha payload".to_vec(), None, + None, ) .expect("write"); let mut handle = vault_rotate_key(handle, test_key2()).expect("rotate chacha20 vault"); - let readback = vault_read(&mut handle, "msg.txt".into()).expect("read after rotate"); + let readback = vault_read(&mut handle, "msg.txt".into()).expect("read after rotate").data; assert_eq!(readback, b"chacha payload"); vault_close(handle).expect("close"); @@ -2706,6 +2727,7 @@ fn test_rotate_key_multiple_rotations() { "doc.txt".into(), b"original data".to_vec(), None, + None, ) .expect("write"); @@ -2714,7 +2736,7 @@ fn test_rotate_key_multiple_rotations() { // Second rotation: new_key → wrong_key let mut handle = vault_rotate_key(handle, wrong_key()).expect("second rotation"); - let readback = vault_read(&mut handle, "doc.txt".into()).expect("read after two rotations"); + let readback = vault_read(&mut handle, "doc.txt".into()).expect("read after two rotations").data; assert_eq!(readback, b"original data"); vault_close(handle).expect("close"); @@ -2918,8 +2940,8 @@ fn test_export_produces_valid_archive() { let dir = tempfile::tempdir().expect("tempdir"); let mut handle = create_test_vault(&dir, 1_048_576); - vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None).expect("write"); - vault_write(&mut handle, "b.txt".into(), b"world".to_vec(), None).expect("write"); + vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None, None).expect("write"); + vault_write(&mut handle, "b.txt".into(), b"world".to_vec(), None, None).expect("write"); let epath = export_path(&dir); vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); @@ -2974,11 +2996,12 @@ fn test_export_with_streaming_segments() { "big.bin".into(), data.len() as u64, chunks.into_iter(), + None, ) .expect("stream write"); // Also write a monolithic segment - vault_write(&mut handle, "small.txt".into(), b"tiny".to_vec(), None).expect("write"); + vault_write(&mut handle, "small.txt".into(), b"tiny".to_vec(), None, None).expect("write"); let epath = export_path(&dir); vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); @@ -3014,6 +3037,7 @@ fn test_export_with_compressed_segments() { "compressed.txt".into(), data.clone(), Some(config), + None, ) .expect("write"); @@ -3035,8 +3059,8 @@ fn test_export_does_not_modify_vault() { let dir = tempfile::tempdir().expect("tempdir"); let mut handle = create_test_vault(&dir, 1_048_576); - vault_write(&mut handle, "a.txt".into(), b"data-A".to_vec(), None).expect("write"); - vault_write(&mut handle, "b.txt".into(), b"data-B".to_vec(), None).expect("write"); + vault_write(&mut handle, "a.txt".into(), b"data-A".to_vec(), None, None).expect("write"); + vault_write(&mut handle, "b.txt".into(), b"data-B".to_vec(), None, None).expect("write"); // Snapshot state before export let names_before: Vec = vault_list(&handle).into_iter().collect(); @@ -3052,9 +3076,9 @@ fn test_export_does_not_modify_vault() { assert_eq!(health_before, health_after); // Verify data still readable - let a = vault_read(&mut handle, "a.txt".into()).expect("read a"); + let a = vault_read(&mut handle, "a.txt".into()).expect("read a").data; assert_eq!(a, b"data-A"); - let b = vault_read(&mut handle, "b.txt".into()).expect("read b"); + let b = vault_read(&mut handle, "b.txt".into()).expect("read b").data; assert_eq!(b, b"data-B"); vault_close(handle).expect("close"); @@ -3070,6 +3094,7 @@ fn test_export_wrong_wrapping_key_cannot_decrypt() { "secret.txt".into(), b"top secret".to_vec(), None, + None, ) .expect("write"); @@ -3122,8 +3147,8 @@ fn test_import_full_roundtrip() { let dir = tempfile::tempdir().expect("tempdir"); let mut handle = create_test_vault(&dir, 2_097_152); - vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None).expect("write"); - vault_write(&mut handle, "b.txt".into(), b"world".to_vec(), None).expect("write"); + vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None, None).expect("write"); + vault_write(&mut handle, "b.txt".into(), b"world".to_vec(), None, None).expect("write"); let epath = export_path(&dir); vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); @@ -3141,11 +3166,11 @@ fn test_import_full_roundtrip() { .expect("import"); assert_eq!( - vault_read(&mut imported, "a.txt".into()).expect("read a"), + vault_read(&mut imported, "a.txt".into()).expect("read a").data, b"hello" ); assert_eq!( - vault_read(&mut imported, "b.txt".into()).expect("read b"), + vault_read(&mut imported, "b.txt".into()).expect("read b").data, b"world" ); @@ -3157,7 +3182,7 @@ fn test_import_wrong_wrapping_key_fails() { let dir = tempfile::tempdir().expect("tempdir"); let mut handle = create_test_vault(&dir, 1_048_576); - vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None).expect("write"); + vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None, None).expect("write"); let epath = export_path(&dir); vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); vault_close(handle).expect("close"); @@ -3180,7 +3205,7 @@ fn test_import_wrong_wrapping_key_fails() { fn test_import_truncated_archive() { let dir = tempfile::tempdir().expect("tempdir"); let mut handle = create_test_vault(&dir, 1_048_576); - vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None).expect("write"); + vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None, None).expect("write"); let epath = export_path(&dir); vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); vault_close(handle).expect("close"); @@ -3211,7 +3236,7 @@ fn test_import_truncated_archive() { fn test_import_tampered_segment_data() { let dir = tempfile::tempdir().expect("tempdir"); let mut handle = create_test_vault(&dir, 1_048_576); - vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None).expect("write"); + vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None, None).expect("write"); let epath = export_path(&dir); vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); vault_close(handle).expect("close"); @@ -3239,7 +3264,7 @@ fn test_import_tampered_segment_data() { fn test_import_tampered_trailer_checksum() { let dir = tempfile::tempdir().expect("tempdir"); let mut handle = create_test_vault(&dir, 1_048_576); - vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None).expect("write"); + vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None, None).expect("write"); let epath = export_path(&dir); vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); vault_close(handle).expect("close"); @@ -3267,7 +3292,7 @@ fn test_import_tampered_trailer_checksum() { fn test_import_insufficient_capacity() { let dir = tempfile::tempdir().expect("tempdir"); let mut handle = create_test_vault(&dir, 5_000_000); - vault_write(&mut handle, "big.txt".into(), vec![0xBB; 2_000_000], None).expect("write"); + vault_write(&mut handle, "big.txt".into(), vec![0xBB; 2_000_000], None, None).expect("write"); let epath = export_path(&dir); vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); vault_close(handle).expect("close"); @@ -3297,6 +3322,7 @@ fn test_import_streaming_segment() { "stream.bin".into(), data.len() as u64, chunks.into_iter(), + None, ) .expect("stream write"); @@ -3315,7 +3341,7 @@ fn test_import_streaming_segment() { ) .expect("import"); - let readback = vault_read(&mut imported, "stream.bin".into()).expect("read"); + let readback = vault_read(&mut imported, "stream.bin".into()).expect("read").data; assert_eq!(readback, data); vault_close(imported).expect("close"); @@ -3336,6 +3362,7 @@ fn test_import_compressed_segment() { "compressed.txt".into(), data.clone(), Some(config), + None, ) .expect("write"); @@ -3357,7 +3384,7 @@ fn test_import_compressed_segment() { let entry = imported.index.find("compressed.txt").expect("find"); assert_eq!(entry.compression, CompressionAlgorithm::Zstd); - let readback = vault_read(&mut imported, "compressed.txt".into()).expect("read"); + let readback = vault_read(&mut imported, "compressed.txt".into()).expect("read").data; assert_eq!(readback, data); vault_close(imported).expect("close"); @@ -3374,7 +3401,7 @@ fn test_import_chacha20() { ) .expect("create"); - vault_write(&mut handle, "c.txt".into(), b"chacha".to_vec(), None).expect("write"); + vault_write(&mut handle, "c.txt".into(), b"chacha".to_vec(), None, None).expect("write"); let epath = export_path(&dir); vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); vault_close(handle).expect("close"); @@ -3395,7 +3422,7 @@ fn test_import_chacha20() { crate::core::format::Algorithm::ChaCha20Poly1305 ); assert_eq!( - vault_read(&mut imported, "c.txt".into()).expect("read c"), + vault_read(&mut imported, "c.txt".into()).expect("read c").data, b"chacha" ); @@ -3429,7 +3456,7 @@ fn test_index_dirty_false_after_open() { fn test_index_clean_after_write() { let dir = tempfile::tempdir().expect("tempdir"); let mut handle = create_test_vault(&dir, 1_048_576); - vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None).expect("write"); + vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None, None).expect("write"); // After write completes, dirty flag should be cleared (flushed) assert!(!handle.index_dirty); vault_close(handle).expect("close"); @@ -3439,7 +3466,7 @@ fn test_index_clean_after_write() { fn test_index_clean_after_delete() { let dir = tempfile::tempdir().expect("tempdir"); let mut handle = create_test_vault(&dir, 1_048_576); - vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None).expect("write"); + vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None, None).expect("write"); vault_delete(&mut handle, "a.txt".into()).expect("delete"); assert!(!handle.index_dirty); vault_close(handle).expect("close"); @@ -3449,10 +3476,10 @@ fn test_index_clean_after_delete() { fn test_read_does_not_set_dirty() { let dir = tempfile::tempdir().expect("tempdir"); let mut handle = create_test_vault(&dir, 1_048_576); - vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None).expect("write"); + vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None, None).expect("write"); assert!(!handle.index_dirty); - let _ = vault_read(&mut handle, "a.txt".into()).expect("read"); + let _ = vault_read(&mut handle, "a.txt".into()).expect("read").data; assert!(!handle.index_dirty); let _ = vault_list(&handle); @@ -3484,7 +3511,7 @@ fn test_vault_flush_persists_and_survives_reopen() { let path = vault_path(&dir); let mut handle = vault_create(path.clone(), test_key(), "aes-256-gcm".into(), 1_048_576).expect("create"); - vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None).expect("write"); + vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None, None).expect("write"); // Explicit flush then close — data must survive reopen vault_flush(&mut handle).expect("flush"); assert!(!handle.index_dirty); @@ -3492,7 +3519,7 @@ fn test_vault_flush_persists_and_survives_reopen() { let mut reopened = vault_open(path, test_key()).expect("reopen"); assert_eq!( - vault_read(&mut reopened, "a.txt".into()).expect("read"), + vault_read(&mut reopened, "a.txt".into()).expect("read").data, b"hello" ); vault_close(reopened).expect("close"); @@ -3504,7 +3531,7 @@ fn test_vault_close_flushes_dirty_and_persists() { let path = vault_path(&dir); let mut handle = vault_create(path.clone(), test_key(), "aes-256-gcm".into(), 1_048_576).expect("create"); - vault_write(&mut handle, "b.txt".into(), b"world".to_vec(), None).expect("write"); + vault_write(&mut handle, "b.txt".into(), b"world".to_vec(), None, None).expect("write"); // Simulate a dirty handle at close time by manually setting the flag. // This exercises the vault_close dirty-flush path. handle.index_dirty = true; @@ -3512,7 +3539,7 @@ fn test_vault_close_flushes_dirty_and_persists() { let mut reopened = vault_open(path, test_key()).expect("reopen"); assert_eq!( - vault_read(&mut reopened, "b.txt".into()).expect("read"), + vault_read(&mut reopened, "b.txt".into()).expect("read").data, b"world" ); vault_close(reopened).expect("close"); @@ -3533,8 +3560,8 @@ fn test_index_dirty_false_after_rotate_key() { fn test_index_clean_after_defragment() { let dir = tempfile::tempdir().expect("tempdir"); let mut handle = create_test_vault(&dir, 1_048_576); - vault_write(&mut handle, "a.txt".into(), b"aaa".to_vec(), None).expect("write a"); - vault_write(&mut handle, "b.txt".into(), b"bbb".to_vec(), None).expect("write b"); + vault_write(&mut handle, "a.txt".into(), b"aaa".to_vec(), None, None).expect("write a"); + vault_write(&mut handle, "b.txt".into(), b"bbb".to_vec(), None, None).expect("write b"); vault_delete(&mut handle, "a.txt".into()).expect("delete a"); vault_defragment(&mut handle).expect("defrag"); assert!(!handle.index_dirty); @@ -3564,13 +3591,13 @@ fn test_parallel_read_matches_sequential() { let dir = tempfile::tempdir().expect("tempdir"); let mut handle = create_test_vault(&dir, 4_194_304); - vault_write(&mut handle, "a.txt".into(), b"alpha".to_vec(), None).expect("write a"); - vault_write(&mut handle, "b.txt".into(), b"bravo".to_vec(), None).expect("write b"); - vault_write(&mut handle, "c.txt".into(), b"charlie".to_vec(), None).expect("write c"); + vault_write(&mut handle, "a.txt".into(), b"alpha".to_vec(), None, None).expect("write a"); + vault_write(&mut handle, "b.txt".into(), b"bravo".to_vec(), None, None).expect("write b"); + vault_write(&mut handle, "c.txt".into(), b"charlie".to_vec(), None, None).expect("write c"); - let seq_a = vault_read(&mut handle, "a.txt".into()).expect("read a"); - let seq_b = vault_read(&mut handle, "b.txt".into()).expect("read b"); - let seq_c = vault_read(&mut handle, "c.txt".into()).expect("read c"); + let seq_a = vault_read(&mut handle, "a.txt".into()).expect("read a").data; + let seq_b = vault_read(&mut handle, "b.txt".into()).expect("read b").data; + let seq_c = vault_read(&mut handle, "c.txt".into()).expect("read c").data; let results = vault_read_parallel( &handle, @@ -3595,7 +3622,7 @@ fn test_parallel_read_10_segments() { for i in 0..10u8 { let name = format!("seg_{i}"); let data = vec![i; 1024]; - vault_write(&mut handle, name, data.clone(), None).expect("write"); + vault_write(&mut handle, name, data.clone(), None, None).expect("write"); expected.push(data); } @@ -3618,7 +3645,7 @@ fn test_parallel_read_mixed_monolithic_and_streaming() { let mut handle = create_test_vault(&dir, 4_194_304); let mono_data = vec![0xAA; 512]; - vault_write(&mut handle, "mono".into(), mono_data.clone(), None).expect("write mono"); + vault_write(&mut handle, "mono".into(), mono_data.clone(), None, None).expect("write mono"); let stream_data: Vec = (0..=255u8).cycle().take(100_000).collect(); let stream_iter = stream_data.chunks(8192).map(|c| c.to_vec()); @@ -3627,6 +3654,7 @@ fn test_parallel_read_mixed_monolithic_and_streaming() { "stream".into(), stream_data.len() as u64, stream_iter, + None, ) .expect("write stream"); @@ -3646,7 +3674,7 @@ fn test_parallel_read_missing_segment() { let dir = tempfile::tempdir().expect("tempdir"); let mut handle = create_test_vault(&dir, 1_048_576); - vault_write(&mut handle, "exists".into(), vec![1, 2, 3], None).expect("write"); + vault_write(&mut handle, "exists".into(), vec![1, 2, 3], None, None).expect("write"); let results = vault_read_parallel(&handle, vec!["exists".into(), "missing".into()]); @@ -3683,9 +3711,9 @@ fn test_parallel_read_single_name() { let dir = tempfile::tempdir().expect("tempdir"); let mut handle = create_test_vault(&dir, 1_048_576); - vault_write(&mut handle, "only".into(), b"solo".to_vec(), None).expect("write"); + vault_write(&mut handle, "only".into(), b"solo".to_vec(), None, None).expect("write"); - let seq = vault_read(&mut handle, "only".into()).expect("sequential"); + let seq = vault_read(&mut handle, "only".into()).expect("sequential").data; let results = vault_read_parallel(&handle, vec!["only".into()]); assert_eq!(results.len(), 1); @@ -3701,7 +3729,7 @@ fn test_parallel_read_preserves_order() { let mut handle = create_test_vault(&dir, 4_194_304); for i in 0..10u8 { - vault_write(&mut handle, format!("seg_{i}"), vec![i; 256], None).expect("write"); + vault_write(&mut handle, format!("seg_{i}"), vec![i; 256], None, None).expect("write"); } let names: Vec = (0..10).rev().map(|i| format!("seg_{i}")).collect(); @@ -3724,8 +3752,8 @@ fn test_parallel_read_chacha20() { let mut handle = vault_create(path, test_key(), "chacha20-poly1305".into(), 4_194_304).expect("create"); - vault_write(&mut handle, "x".into(), b"chacha-data".to_vec(), None).expect("write"); - vault_write(&mut handle, "y".into(), b"poly1305-data".to_vec(), None).expect("write"); + vault_write(&mut handle, "x".into(), b"chacha-data".to_vec(), None, None).expect("write"); + vault_write(&mut handle, "y".into(), b"poly1305-data".to_vec(), None, None).expect("write"); let results = vault_read_parallel(&handle, vec!["x".into(), "y".into()]); @@ -3742,8 +3770,8 @@ fn test_parallel_read_fallback_sequential() { let dir = tempfile::tempdir().expect("tempdir"); let mut handle = create_test_vault(&dir, 4_194_304); - vault_write(&mut handle, "a".into(), b"alpha".to_vec(), None).expect("write a"); - vault_write(&mut handle, "b".into(), b"bravo".to_vec(), None).expect("write b"); + vault_write(&mut handle, "a".into(), b"alpha".to_vec(), None, None).expect("write a"); + vault_write(&mut handle, "b".into(), b"bravo".to_vec(), None, None).expect("write b"); // Force no-mmap fallback handle.mmap = None; @@ -3766,10 +3794,10 @@ fn test_rename_read_success() { let dir = tempfile::tempdir().expect("tempdir"); let mut handle = create_test_vault(&dir, 1_048_576); - vault_write(&mut handle, "old.txt".into(), b"data".to_vec(), None).expect("write"); + vault_write(&mut handle, "old.txt".into(), b"data".to_vec(), None, None).expect("write"); vault_rename_segment(&mut handle, "old.txt".into(), "new.txt".into()).expect("rename"); - let data = vault_read(&mut handle, "new.txt".into()).expect("read new"); + let data = vault_read(&mut handle, "new.txt".into()).expect("read new").data; assert_eq!(data, b"data"); let result = vault_read(&mut handle, "old.txt".into()); @@ -3783,8 +3811,8 @@ fn test_rename_duplicate_name_fails() { let dir = tempfile::tempdir().expect("tempdir"); let mut handle = create_test_vault(&dir, 1_048_576); - vault_write(&mut handle, "a.txt".into(), b"a".to_vec(), None).expect("write a"); - vault_write(&mut handle, "b.txt".into(), b"b".to_vec(), None).expect("write b"); + vault_write(&mut handle, "a.txt".into(), b"a".to_vec(), None, None).expect("write a"); + vault_write(&mut handle, "b.txt".into(), b"b".to_vec(), None, None).expect("write b"); let result = vault_rename_segment(&mut handle, "a.txt".into(), "b.txt".into()); assert!(matches!(result, Err(CryptoError::DuplicateSegment(_)))); @@ -3808,7 +3836,7 @@ fn test_rename_preserves_metadata() { let dir = tempfile::tempdir().expect("tempdir"); let mut handle = create_test_vault(&dir, 1_048_576); - vault_write(&mut handle, "old.txt".into(), b"data".to_vec(), None).expect("write"); + vault_write(&mut handle, "old.txt".into(), b"data".to_vec(), None, None).expect("write"); let old_entry = handle.index.find("old.txt").expect("find").clone(); vault_rename_segment(&mut handle, "old.txt".into(), "new.txt".into()).expect("rename"); @@ -3829,14 +3857,14 @@ fn test_rename_multiple_sequence() { let dir = tempfile::tempdir().expect("tempdir"); let mut handle = create_test_vault(&dir, 1_048_576); - vault_write(&mut handle, "1.txt".into(), b"sequence".to_vec(), None).expect("write"); + vault_write(&mut handle, "1.txt".into(), b"sequence".to_vec(), None, None).expect("write"); vault_rename_segment(&mut handle, "1.txt".into(), "2.txt".into()).expect("r1"); vault_rename_segment(&mut handle, "2.txt".into(), "3.txt".into()).expect("r2"); assert!(handle.index.find("1.txt").is_none()); assert!(handle.index.find("2.txt").is_none()); - let data = vault_read(&mut handle, "3.txt".into()).expect("read"); + let data = vault_read(&mut handle, "3.txt".into()).expect("read").data; assert_eq!(data, b"sequence"); vault_close(handle).expect("close"); @@ -3854,6 +3882,7 @@ fn test_rename_streaming_segment() { "stream.bin".into(), data.len() as u64, chunks.into_iter(), + None, ) .expect("write stream"); @@ -3865,7 +3894,7 @@ fn test_rename_streaming_segment() { .expect("rename"); // Interop read on streaming segment - let readback = vault_read(&mut handle, "renamed_stream.bin".into()).expect("read"); + let readback = vault_read(&mut handle, "renamed_stream.bin".into()).expect("read").data; assert_eq!(readback, data); vault_close(handle).expect("close"); @@ -3876,16 +3905,16 @@ fn test_rename_and_defragment() { let dir = tempfile::tempdir().expect("tempdir"); let mut handle = create_test_vault(&dir, 1_048_576); - vault_write(&mut handle, "a.txt".into(), vec![0xAA; 100], None).expect("A"); - vault_write(&mut handle, "b.txt".into(), vec![0xBB; 100], None).expect("B"); - vault_write(&mut handle, "c.txt".into(), vec![0xCC; 100], None).expect("C"); + vault_write(&mut handle, "a.txt".into(), vec![0xAA; 100], None, None).expect("A"); + vault_write(&mut handle, "b.txt".into(), vec![0xBB; 100], None, None).expect("B"); + vault_write(&mut handle, "c.txt".into(), vec![0xCC; 100], None, None).expect("C"); vault_delete(&mut handle, "b.txt".into()).expect("del B"); vault_rename_segment(&mut handle, "c.txt".into(), "c_renamed.txt".into()).expect("rename"); vault_defragment(&mut handle).expect("defrag"); - let data = vault_read(&mut handle, "c_renamed.txt".into()).expect("read"); + let data = vault_read(&mut handle, "c_renamed.txt".into()).expect("read").data; assert_eq!(data, vec![0xCC; 100]); vault_close(handle).expect("close"); @@ -3903,6 +3932,7 @@ fn test_rename_chacha20() { "old_chacha.txt".into(), b"chacha".to_vec(), None, + None, ) .expect("write"); vault_rename_segment( @@ -3912,7 +3942,7 @@ fn test_rename_chacha20() { ) .expect("rename"); - let data = vault_read(&mut handle, "new_chacha.txt".into()).expect("read"); + let data = vault_read(&mut handle, "new_chacha.txt".into()).expect("read").data; assert_eq!(data, b"chacha"); vault_close(handle).expect("close"); @@ -3923,11 +3953,300 @@ fn test_rename_to_same_name_is_noop() { let dir = tempfile::tempdir().expect("tempdir"); let mut handle = create_test_vault(&dir, 1_048_576); - vault_write(&mut handle, "same.txt".into(), b"data".to_vec(), None).expect("write"); + vault_write(&mut handle, "same.txt".into(), b"data".to_vec(), None, None).expect("write"); vault_rename_segment(&mut handle, "same.txt".into(), "same.txt".into()).expect("noop rename"); - let data = vault_read(&mut handle, "same.txt".into()).expect("read"); + let data = vault_read(&mut handle, "same.txt".into()).expect("read").data; assert_eq!(data, b"data"); vault_close(handle).expect("close"); } + +// -- Segment Metadata ------------------------------------------------------ + +#[test] +fn test_metadata_write_read_roundtrip() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + let mut meta = std::collections::HashMap::new(); + meta.insert("mime".to_string(), "text/plain".to_string()); + meta.insert("created".to_string(), "2026-04-08".to_string()); + + vault_write( + &mut handle, + "doc.txt".into(), + b"hello metadata".to_vec(), + None, + Some(meta.clone()), + ) + .expect("write"); + + let result = vault_read(&mut handle, "doc.txt".into()).expect("read"); + assert_eq!(result.data, b"hello metadata"); + assert_eq!(result.metadata, meta); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_metadata_none_returns_empty_map() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write(&mut handle, "plain.txt".into(), b"no meta".to_vec(), None, None).expect("write"); + + let result = vault_read(&mut handle, "plain.txt".into()).expect("read"); + assert_eq!(result.data, b"no meta"); + assert!(result.metadata.is_empty()); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_metadata_overwrite_replaces_old() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + let mut meta_v1 = std::collections::HashMap::new(); + meta_v1.insert("version".to_string(), "1".to_string()); + + vault_write( + &mut handle, + "doc.txt".into(), + b"v1".to_vec(), + None, + Some(meta_v1), + ) + .expect("write v1"); + + let mut meta_v2 = std::collections::HashMap::new(); + meta_v2.insert("version".to_string(), "2".to_string()); + meta_v2.insert("author".to_string(), "test".to_string()); + + vault_write( + &mut handle, + "doc.txt".into(), + b"v2".to_vec(), + None, + Some(meta_v2.clone()), + ) + .expect("write v2"); + + let result = vault_read(&mut handle, "doc.txt".into()).expect("read"); + assert_eq!(result.data, b"v2"); + assert_eq!(result.metadata, meta_v2); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_metadata_independent_per_segment() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + let mut meta_a = std::collections::HashMap::new(); + meta_a.insert("type".to_string(), "image".to_string()); + let mut meta_b = std::collections::HashMap::new(); + meta_b.insert("type".to_string(), "document".to_string()); + + vault_write(&mut handle, "a.png".into(), b"img".to_vec(), None, Some(meta_a.clone())) + .expect("write a"); + vault_write(&mut handle, "b.pdf".into(), b"pdf".to_vec(), None, Some(meta_b.clone())) + .expect("write b"); + + let result_a = vault_read(&mut handle, "a.png".into()).expect("read a"); + let result_b = vault_read(&mut handle, "b.pdf".into()).expect("read b"); + + assert_eq!(result_a.metadata, meta_a); + assert_eq!(result_b.metadata, meta_b); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_metadata_empty_keys_and_values() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + let mut meta = std::collections::HashMap::new(); + meta.insert("".to_string(), "empty key".to_string()); + meta.insert("empty_val".to_string(), "".to_string()); + + vault_write( + &mut handle, + "edge.bin".into(), + b"data".to_vec(), + None, + Some(meta.clone()), + ) + .expect("write"); + + let result = vault_read(&mut handle, "edge.bin".into()).expect("read"); + assert_eq!(result.metadata, meta); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_metadata_survives_close_reopen() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = vault_path(&dir); + + let mut meta = std::collections::HashMap::new(); + meta.insert("persist".to_string(), "yes".to_string()); + + { + let mut handle = + vault_create(path.clone(), test_key(), "aes-256-gcm".into(), 1_048_576).expect("create"); + vault_write( + &mut handle, + "persist.txt".into(), + b"durable".to_vec(), + None, + Some(meta.clone()), + ) + .expect("write"); + vault_close(handle).expect("close"); + } + + let mut handle = vault_open(path, test_key()).expect("reopen"); + let result = vault_read(&mut handle, "persist.txt".into()).expect("read"); + assert_eq!(result.data, b"durable"); + assert_eq!(result.metadata, meta); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_metadata_survives_defragment() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + let mut meta = std::collections::HashMap::new(); + meta.insert("tag".to_string(), "keep".to_string()); + + vault_write(&mut handle, "a.txt".into(), vec![0xAA; 200], None, None).expect("write a"); + vault_write( + &mut handle, + "b.txt".into(), + vec![0xBB; 200], + None, + Some(meta.clone()), + ) + .expect("write b"); + + vault_delete(&mut handle, "a.txt".into()).expect("delete a"); + vault_defragment(&mut handle).expect("defrag"); + + let result = vault_read(&mut handle, "b.txt".into()).expect("read b"); + assert_eq!(result.data, vec![0xBB; 200]); + assert_eq!(result.metadata, meta); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_metadata_survives_resize() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + let mut meta = std::collections::HashMap::new(); + meta.insert("resize".to_string(), "test".to_string()); + + vault_write( + &mut handle, + "doc.txt".into(), + b"data".to_vec(), + None, + Some(meta.clone()), + ) + .expect("write"); + + vault_resize(&mut handle, 2_097_152).expect("grow"); + + let result = vault_read(&mut handle, "doc.txt".into()).expect("read"); + assert_eq!(result.data, b"data"); + assert_eq!(result.metadata, meta); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_metadata_survives_key_rotation() { + let dir = tempfile::tempdir().expect("tempdir"); + + let mut meta = std::collections::HashMap::new(); + meta.insert("mime".to_string(), "application/pdf".to_string()); + + let mut handle = create_test_vault(&dir, 1_048_576); + vault_write( + &mut handle, + "file.pdf".into(), + b"pdf content".to_vec(), + None, + Some(meta.clone()), + ) + .expect("write"); + + let mut handle = vault_rotate_key(handle, test_key2()).expect("rotate"); + + let result = vault_read(&mut handle, "file.pdf".into()).expect("read"); + assert_eq!(result.data, b"pdf content"); + assert_eq!(result.metadata, meta); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_metadata_large_near_index_limit() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + let mut meta = std::collections::HashMap::new(); + for i in 0..100 { + meta.insert(format!("key_{i:03}"), format!("value_{i:03}_padding")); + } + + vault_write( + &mut handle, + "big_meta.bin".into(), + b"data".to_vec(), + None, + Some(meta.clone()), + ) + .expect("write"); + + let result = vault_read(&mut handle, "big_meta.bin".into()).expect("read"); + assert_eq!(result.data, b"data"); + assert_eq!(result.metadata, meta); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_metadata_rename_preserves() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + let mut meta = std::collections::HashMap::new(); + meta.insert("tag".to_string(), "renamed".to_string()); + + vault_write( + &mut handle, + "old_name.txt".into(), + b"data".to_vec(), + None, + Some(meta.clone()), + ) + .expect("write"); + + vault_rename_segment(&mut handle, "old_name.txt".into(), "new_name.txt".into()) + .expect("rename"); + + let result = vault_read(&mut handle, "new_name.txt".into()).expect("read"); + assert_eq!(result.data, b"data"); + assert_eq!(result.metadata, meta); + + vault_close(handle).expect("close"); +} From 9a69d64b2799a6e8a37db35f0e2f421fbb435561 Mon Sep 17 00:00:00 2001 From: Adel-Ayoub Date: Wed, 8 Apr 2026 21:15:04 +0100 Subject: [PATCH 19/32] refactor(evfs): split tests into modules and add metadata tests --- rust/src/api/evfs/tests.rs | 4252 ---------------------- rust/src/api/evfs/tests/allocator.rs | 167 + rust/src/api/evfs/tests/compression.rs | 166 + rust/src/api/evfs/tests/crud.rs | 199 + rust/src/api/evfs/tests/defragment.rs | 627 ++++ rust/src/api/evfs/tests/delete.rs | 200 + rust/src/api/evfs/tests/export_import.rs | 677 ++++ rust/src/api/evfs/tests/index_cache.rs | 152 + rust/src/api/evfs/tests/metadata.rs | 290 ++ rust/src/api/evfs/tests/mod.rs | 65 + rust/src/api/evfs/tests/parallel.rs | 210 ++ rust/src/api/evfs/tests/rename.rs | 177 + rust/src/api/evfs/tests/resize.rs | 310 ++ rust/src/api/evfs/tests/rotation.rs | 230 ++ rust/src/api/evfs/tests/streaming.rs | 822 +++++ 15 files changed, 4292 insertions(+), 4252 deletions(-) delete mode 100644 rust/src/api/evfs/tests.rs create mode 100644 rust/src/api/evfs/tests/allocator.rs create mode 100644 rust/src/api/evfs/tests/compression.rs create mode 100644 rust/src/api/evfs/tests/crud.rs create mode 100644 rust/src/api/evfs/tests/defragment.rs create mode 100644 rust/src/api/evfs/tests/delete.rs create mode 100644 rust/src/api/evfs/tests/export_import.rs create mode 100644 rust/src/api/evfs/tests/index_cache.rs create mode 100644 rust/src/api/evfs/tests/metadata.rs create mode 100644 rust/src/api/evfs/tests/mod.rs create mode 100644 rust/src/api/evfs/tests/parallel.rs create mode 100644 rust/src/api/evfs/tests/rename.rs create mode 100644 rust/src/api/evfs/tests/resize.rs create mode 100644 rust/src/api/evfs/tests/rotation.rs create mode 100644 rust/src/api/evfs/tests/streaming.rs diff --git a/rust/src/api/evfs/tests.rs b/rust/src/api/evfs/tests.rs deleted file mode 100644 index 3ba4f8a..0000000 --- a/rust/src/api/evfs/tests.rs +++ /dev/null @@ -1,4252 +0,0 @@ -use super::*; -use crate::core::evfs::format::{encrypted_index_size, MIN_INDEX_PAD_SIZE}; - -fn test_key() -> Vec { - vec![0xAA; 32] -} - -fn test_key2() -> Vec { - vec![0xAB; 32] -} - -fn wrong_key() -> Vec { - vec![0xBB; 32] -} - -fn create_test_vault(dir: &tempfile::TempDir, capacity: u64) -> VaultHandle { - let path = dir - .path() - .join("test.vault") - .to_str() - .expect("path") - .to_string(); - vault_create(path, test_key(), "aes-256-gcm".into(), capacity).expect("create vault") -} - -fn vault_path(dir: &tempfile::TempDir) -> String { - dir.path() - .join("test.vault") - .to_str() - .expect("path") - .to_string() -} - -// -- Create / Open ------------------------------------------------------ - -#[test] -fn test_create_and_open() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = vault_path(&dir); - - { - let handle = vault_create(path.clone(), test_key(), "aes-256-gcm".into(), 1_048_576) - .expect("create"); - let names = vault_list(&handle); - assert!(names.is_empty()); - vault_close(handle).expect("close"); - } - - { - let handle = vault_open(path, test_key()).expect("open"); - let names = vault_list(&handle); - assert!(names.is_empty()); - vault_close(handle).expect("close"); - } -} - -#[test] -fn test_open_wrong_key_fails() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = vault_path(&dir); - - { - let handle = vault_create(path.clone(), test_key(), "aes-256-gcm".into(), 1_048_576) - .expect("create"); - vault_close(handle).expect("close"); - } - - let result = vault_open(path, wrong_key()); - assert!(result.is_err()); -} - -#[test] -fn test_open_runs_wal_recovery() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = vault_path(&dir); - - // Create vault with segment A - { - let mut handle = vault_create(path.clone(), test_key(), "aes-256-gcm".into(), 1_048_576) - .expect("create"); - vault_write(&mut handle, "a.txt".into(), b"data-A".to_vec(), None, None).expect("write A"); - vault_close(handle).expect("close"); - } - - // Save the "good" encrypted index (containing only A) - let good_encrypted = { - let mut f = File::open(&path).expect("open"); - read_encrypted_index( - &mut f, - PRIMARY_INDEX_OFFSET, - encrypted_index_size(MIN_INDEX_PAD_SIZE), - ) - .expect("read index") - }; - - // Add segment B normally (both index and data on disk) - { - let mut handle = vault_open(path.clone(), test_key()).expect("open"); - vault_write(&mut handle, "b.txt".into(), b"data-B".to_vec(), None, None).expect("write B"); - vault_close(handle).expect("close"); - } - - // Simulate crash: uncommitted WAL entry restoring the A-only index - { - let mut wal = WriteAheadLog::open(&path).expect("wal"); - wal.begin(WalOp::WriteSegment, &good_encrypted) - .expect("begin"); - // Don't commit — simulates crash - } - - // Reopen — WAL recovery should roll back to A-only index - let mut handle = vault_open(path, test_key()).expect("open after recovery"); - let data = vault_read(&mut handle, "a.txt".into()).expect("read A").data; - assert_eq!(data, b"data-A"); - - let result = vault_read(&mut handle, "b.txt".into()); - assert!(matches!(result, Err(CryptoError::SegmentNotFound(_)))); - - vault_close(handle).expect("close"); -} - -// -- Write / Read ------------------------------------------------------- - -#[test] -fn test_write_read_roundtrip() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - vault_write(&mut handle, "doc.txt".into(), b"hello vault".to_vec(), None, None).expect("write"); - - let data = vault_read(&mut handle, "doc.txt".into()).expect("read").data; - assert_eq!(data, b"hello vault"); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_write_read_multiple_segments() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - for i in 0..5 { - let name = format!("seg{i}.bin"); - let data = format!("data for segment {i}").into_bytes(); - vault_write(&mut handle, name, data, None, None).expect("write"); - } - - for i in 0..5 { - let name = format!("seg{i}.bin"); - let expected = format!("data for segment {i}").into_bytes(); - let data = vault_read(&mut handle, name).expect("read").data; - assert_eq!(data, expected); - } - - assert_eq!(vault_list(&handle).len(), 5); - vault_close(handle).expect("close"); -} - -#[test] -fn test_write_overwrite() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - vault_write(&mut handle, "doc.txt".into(), b"version 1".to_vec(), None, None).expect("write v1"); - vault_write(&mut handle, "doc.txt".into(), b"version 2".to_vec(), None, None).expect("write v2"); - - let data = vault_read(&mut handle, "doc.txt".into()).expect("read").data; - assert_eq!(data, b"version 2"); - assert_eq!(vault_list(&handle).len(), 1); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_write_overwrite_increments_generation() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - vault_write(&mut handle, "doc.txt".into(), b"v1".to_vec(), None, None).expect("write v1"); - let gen1 = handle.index.find("doc.txt").expect("find").generation; - - vault_write(&mut handle, "doc.txt".into(), b"v2".to_vec(), None, None).expect("write v2"); - let gen2 = handle.index.find("doc.txt").expect("find").generation; - - assert!(gen2 > gen1); - vault_close(handle).expect("close"); -} - -#[test] -fn test_read_nonexistent_segment() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - let result = vault_read(&mut handle, "nope.txt".into()); - assert!(matches!(result, Err(CryptoError::SegmentNotFound(_)))); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_read_tampered_segment() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - vault_write( - &mut handle, - "secret.txt".into(), - b"important data".to_vec(), - None, - None, - ) - .expect("write"); - - // Tamper with encrypted data on disk - let entry = handle.index.find("secret.txt").expect("find"); - let disk_offset = format::data_region_offset(MIN_INDEX_PAD_SIZE) + entry.offset; - // Flip a byte in the ciphertext (after the 12-byte nonce) - handle - .file - .seek(SeekFrom::Start(disk_offset + 13)) - .expect("seek"); - handle.file.write_all(&[0xFF]).expect("tamper"); - handle.file.sync_all().expect("sync"); - - let result = vault_read(&mut handle, "secret.txt".into()); - assert!(result.is_err()); - - vault_close(handle).expect("close"); -} - -// -- Compression integration -------------------------------------------- - -#[test] -fn test_write_read_zstd_roundtrip() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - let data = b"compressible data repeated ".repeat(100); - - let config = CompressionConfig { - algorithm: CompressionAlgorithm::Zstd, - level: None, - }; - vault_write(&mut handle, "data.txt".into(), data.clone(), Some(config), None).expect("write"); - let read_back = vault_read(&mut handle, "data.txt".into()).expect("read").data; - assert_eq!(read_back, data); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_write_read_brotli_roundtrip() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - let data = b"brotli compressible data ".repeat(80); - - let config = CompressionConfig { - algorithm: CompressionAlgorithm::Brotli, - level: None, - }; - vault_write(&mut handle, "notes.md".into(), data.clone(), Some(config), None).expect("write"); - let read_back = vault_read(&mut handle, "notes.md".into()).expect("read").data; - assert_eq!(read_back, data); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_write_read_no_compression() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - let data = b"uncompressed payload".to_vec(); - - let config = CompressionConfig { - algorithm: CompressionAlgorithm::None, - level: None, - }; - vault_write(&mut handle, "raw.bin".into(), data.clone(), Some(config), None).expect("write"); - let read_back = vault_read(&mut handle, "raw.bin".into()).expect("read").data; - assert_eq!(read_back, data); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_write_jpg_skips_compression() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - let config = CompressionConfig { - algorithm: CompressionAlgorithm::Zstd, - level: None, - }; - vault_write( - &mut handle, - "photo.jpg".into(), - b"fake jpeg".to_vec(), - Some(config), - None, - ) - .expect("write"); - - let entry = handle.index.find("photo.jpg").expect("find"); - assert_eq!(entry.compression, CompressionAlgorithm::None); - - let data = vault_read(&mut handle, "photo.jpg".into()).expect("read").data; - assert_eq!(data, b"fake jpeg"); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_read_decompresses_automatically() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - let data = b"auto-decompress test data ".repeat(50); - - let config = CompressionConfig { - algorithm: CompressionAlgorithm::Zstd, - level: None, - }; - vault_write(&mut handle, "auto.txt".into(), data.clone(), Some(config), None).expect("write"); - - // Read back — decompression is automatic (no config needed) - let read_back = vault_read(&mut handle, "auto.txt".into()).expect("read").data; - assert_eq!(read_back, data); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_mixed_compression_segments() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - let text = b"text data ".repeat(50); - let binary = vec![0xABu8; 500]; - let raw = b"raw data no compress".to_vec(); - - let zstd_conf = CompressionConfig { - algorithm: CompressionAlgorithm::Zstd, - level: None, - }; - let brotli_conf = CompressionConfig { - algorithm: CompressionAlgorithm::Brotli, - level: None, - }; - - vault_write( - &mut handle, - "text.txt".into(), - text.clone(), - Some(zstd_conf), - None, - ) - .expect("zstd"); - vault_write( - &mut handle, - "data.bin".into(), - binary.clone(), - Some(brotli_conf), - None, - ) - .expect("brotli"); - vault_write(&mut handle, "raw.dat".into(), raw.clone(), None, None).expect("none"); - - assert_eq!(vault_read(&mut handle, "text.txt".into()).expect("r").data, text); - assert_eq!( - vault_read(&mut handle, "data.bin".into()).expect("r").data, - binary - ); - assert_eq!(vault_read(&mut handle, "raw.dat".into()).expect("r").data, raw); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_checksum_on_original_plaintext() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - let data = b"checksum covers original ".repeat(50); - let config = CompressionConfig { - algorithm: CompressionAlgorithm::Zstd, - level: None, - }; - vault_write(&mut handle, "check.txt".into(), data.clone(), Some(config), None).expect("write"); - - // Verify the stored checksum matches original plaintext (not compressed form) - let entry = handle.index.find("check.txt").expect("find"); - assert!(segment::verify_checksum(&data, &entry.checksum)); - - vault_close(handle).expect("close"); -} - -// -- Space reclamation (free list) -------------------------------------- - -#[test] -fn test_delete_returns_space_to_free_list() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - vault_write( - &mut handle, - "a.txt".into(), - b"some data here".to_vec(), - None, - None, - ) - .expect("write"); - let seg_size = handle.index.find("a.txt").expect("find").size; - - vault_delete(&mut handle, "a.txt".into()).expect("delete"); - - let cap = vault_capacity(&handle); - assert_eq!(cap.free_list_bytes, seg_size); - assert_eq!(cap.segment_count, 0); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_write_reuses_deleted_space() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - // Write A — note the offset and size - vault_write(&mut handle, "a.txt".into(), vec![0xAA; 200], None, None).expect("write A"); - let a_offset = handle.index.find("a.txt").expect("A").offset; - let a_size = handle.index.find("a.txt").expect("A").size; - - // Delete A - vault_delete(&mut handle, "a.txt".into()).expect("delete A"); - - // Write B (smaller) — should reuse A's space - vault_write(&mut handle, "b.txt".into(), vec![0xBB; 50], None, None).expect("write B"); - let b_offset = handle.index.find("b.txt").expect("B").offset; - let b_size = handle.index.find("b.txt").expect("B").size; - - assert_eq!(b_offset, a_offset); - assert!(b_size < a_size); - - // Free list should have leftover from A's region - let cap = vault_capacity(&handle); - assert_eq!(cap.free_list_bytes, a_size - b_size); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_write_after_delete_exact_fit() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - // Write and capture the encrypted size - vault_write(&mut handle, "a.txt".into(), vec![0xAA; 200], None, None).expect("write A"); - let a_offset = handle.index.find("a.txt").expect("A").offset; - let a_size = handle.index.find("a.txt").expect("A").size; - vault_delete(&mut handle, "a.txt".into()).expect("delete A"); - - // Write B with same plaintext size — encrypted size should match exactly - vault_write(&mut handle, "b.txt".into(), vec![0xBB; 200], None, None).expect("write B"); - let b_offset = handle.index.find("b.txt").expect("B").offset; - let b_size = handle.index.find("b.txt").expect("B").size; - - assert_eq!(b_offset, a_offset); - assert_eq!(b_size, a_size); - assert_eq!(vault_capacity(&handle).free_list_bytes, 0); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_overwrite_reclaims_then_allocates() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - vault_write(&mut handle, "doc.txt".into(), vec![0xAA; 500], None, None).expect("write big"); - let old_size = handle.index.find("doc.txt").expect("old").size; - - // Overwrite with smaller data - vault_write(&mut handle, "doc.txt".into(), vec![0xBB; 100], None, None).expect("overwrite small"); - let new_size = handle.index.find("doc.txt").expect("new").size; - - assert!(new_size < old_size); - // Free list should have leftover from reclaimed space - let cap = vault_capacity(&handle); - assert!(cap.free_list_bytes > 0 || cap.unallocated_bytes > 0); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_delete_multiple_merges_adjacent() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - // Write A, B, C contiguously - vault_write(&mut handle, "a.txt".into(), vec![0xAA; 100], None, None).expect("A"); - vault_write(&mut handle, "b.txt".into(), vec![0xBB; 100], None, None).expect("B"); - vault_write(&mut handle, "c.txt".into(), vec![0xCC; 100], None, None).expect("C"); - - let b_size = handle.index.find("b.txt").expect("B").size; - let c_size = handle.index.find("c.txt").expect("C").size; - - // Delete B then C — should merge into one free region - vault_delete(&mut handle, "b.txt".into()).expect("del B"); - vault_delete(&mut handle, "c.txt".into()).expect("del C"); - - assert_eq!(handle.index.free_regions.len(), 1); - assert_eq!(handle.index.free_regions[0].size, b_size + c_size); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_free_list_falls_back_to_append() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - // Write A (small), delete it - vault_write(&mut handle, "a.txt".into(), vec![0xAA; 50], None, None).expect("A"); - let a_size = handle.index.find("a.txt").expect("A").size; - vault_delete(&mut handle, "a.txt".into()).expect("del A"); - - // Write B (much larger) — won't fit in A's free region - vault_write(&mut handle, "b.txt".into(), vec![0xBB; 5000], None, None).expect("B"); - let b_offset = handle.index.find("b.txt").expect("B").offset; - - // B should be appended (offset > A's region) - assert!(b_offset >= a_size); - - // Free list should still have A's old region - let cap = vault_capacity(&handle); - assert_eq!(cap.free_list_bytes, a_size); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_capacity_reflects_free_list() { - let dir = tempfile::tempdir().expect("tempdir"); - let capacity = 1_048_576u64; - let mut handle = create_test_vault(&dir, capacity); - - vault_write(&mut handle, "a.txt".into(), vec![0xAA; 200], None, None).expect("A"); - vault_write(&mut handle, "b.txt".into(), vec![0xBB; 300], None, None).expect("B"); - - let cap = vault_capacity(&handle); - assert_eq!(cap.total_bytes, capacity); - assert_eq!(cap.segment_count, 2); - // used + free_list + unallocated should account for total capacity - assert_eq!( - cap.used_bytes + cap.free_list_bytes + cap.unallocated_bytes, - capacity - ); - - vault_close(handle).expect("close"); -} - -// -- Delete ------------------------------------------------------------- - -#[test] -fn test_delete_segment() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - vault_write(&mut handle, "tmp.txt".into(), b"temp data".to_vec(), None, None).expect("write"); - vault_delete(&mut handle, "tmp.txt".into()).expect("delete"); - - let result = vault_read(&mut handle, "tmp.txt".into()); - assert!(matches!(result, Err(CryptoError::SegmentNotFound(_)))); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_delete_secure_erase() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - vault_write( - &mut handle, - "secret.txt".into(), - b"sensitive data".to_vec(), - None, - None, - ) - .expect("write"); - - // Capture encrypted bytes at the segment offset - let entry = handle.index.find("secret.txt").expect("find"); - let disk_offset = format::data_region_offset(MIN_INDEX_PAD_SIZE) + entry.offset; - let size = entry.size as usize; - handle - .file - .seek(SeekFrom::Start(disk_offset)) - .expect("seek"); - let mut old_bytes = vec![0u8; size]; - handle.file.read_exact(&mut old_bytes).expect("read"); - let saved_offset = disk_offset; - - vault_delete(&mut handle, "secret.txt".into()).expect("delete"); - - // Same region should now contain different bytes (CSPRNG overwrite) - handle - .file - .seek(SeekFrom::Start(saved_offset)) - .expect("seek"); - let mut new_bytes = vec![0u8; size]; - handle.file.read_exact(&mut new_bytes).expect("read"); - assert_ne!(old_bytes, new_bytes); - - vault_close(handle).expect("close"); -} - -// -- Capacity ----------------------------------------------------------- - -#[test] -fn test_vault_full() { - let dir = tempfile::tempdir().expect("tempdir"); - // Tiny vault: 256 bytes capacity - let mut handle = create_test_vault(&dir, 256); - - // First write (encrypted data ~ 28 + plaintext bytes) - vault_write(&mut handle, "a.txt".into(), vec![0xAA; 200], None, None).expect("A"); - - // Second write should fail — not enough space - let result = vault_write(&mut handle, "b.txt".into(), vec![0xBB; 200], None, None); - assert!(matches!(result, Err(CryptoError::VaultFull { .. }))); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_vault_full_with_free_list() { - let dir = tempfile::tempdir().expect("tempdir"); - // Small vault - let mut handle = create_test_vault(&dir, 512); - - // Fill vault with A - vault_write(&mut handle, "a.txt".into(), vec![0xAA; 400], None, None).expect("A"); - - // Delete A (space returned to free list) - vault_delete(&mut handle, "a.txt".into()).expect("del A"); - - // Write B using freed space — should succeed - vault_write(&mut handle, "b.txt".into(), vec![0xBB; 400], None, None).expect("B from free list"); - - let data = vault_read(&mut handle, "b.txt".into()).expect("read B").data; - assert_eq!(data, vec![0xBB; 400]); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_capacity_info() { - let dir = tempfile::tempdir().expect("tempdir"); - let capacity = 1_048_576u64; - let mut handle = create_test_vault(&dir, capacity); - - let cap = vault_capacity(&handle); - assert_eq!(cap.total_bytes, capacity); - assert_eq!(cap.used_bytes, 0); - assert_eq!(cap.free_list_bytes, 0); - assert_eq!(cap.unallocated_bytes, capacity); - assert_eq!(cap.segment_count, 0); - - vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None, None).expect("write"); - - let cap = vault_capacity(&handle); - assert_eq!(cap.segment_count, 1); - assert!(cap.used_bytes > 0); - assert_eq!( - cap.used_bytes + cap.free_list_bytes + cap.unallocated_bytes, - capacity - ); - - vault_close(handle).expect("close"); -} - -// -- Locking ------------------------------------------------------------ - -#[test] -fn test_concurrent_open_fails() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = vault_path(&dir); - - let _handle = - vault_create(path.clone(), test_key(), "aes-256-gcm".into(), 1_048_576).expect("create"); - - let result = vault_open(path, test_key()); - assert!(matches!(result, Err(CryptoError::VaultLocked))); -} - -// -- Persistence across close/open -------------------------------------- - -#[test] -fn test_write_close_open_read() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = vault_path(&dir); - - { - let mut handle = vault_create( - path.clone(), - test_key(), - "chacha20-poly1305".into(), - 1_048_576, - ) - .expect("create"); - vault_write( - &mut handle, - "persist.txt".into(), - b"survives close".to_vec(), - None, - None, - ) - .expect("write"); - vault_close(handle).expect("close"); - } - - { - let mut handle = vault_open(path, test_key()).expect("open"); - let data = vault_read(&mut handle, "persist.txt".into()).expect("read").data; - assert_eq!(data, b"survives close"); - vault_close(handle).expect("close"); - } -} - -// -- Shadow index fallback ---------------------------------------------- - -#[test] -fn test_corrupted_primary_falls_back_to_shadow() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = vault_path(&dir); - - { - let mut handle = vault_create(path.clone(), test_key(), "aes-256-gcm".into(), 1_048_576) - .expect("create"); - vault_write(&mut handle, "doc.txt".into(), b"shadow test".to_vec(), None, None).expect("write"); - vault_close(handle).expect("close"); - } - - // Corrupt primary index on disk - { - let mut f = OpenOptions::new().write(true).open(&path).expect("open"); - f.seek(SeekFrom::Start(PRIMARY_INDEX_OFFSET)).expect("seek"); - f.write_all(&[0xFF; 100]).expect("corrupt"); - } - - // Open should succeed via shadow - let mut handle = vault_open(path, test_key()).expect("open via shadow"); - let data = vault_read(&mut handle, "doc.txt".into()).expect("read").data; - assert_eq!(data, b"shadow test"); - - vault_close(handle).expect("close"); -} - -// -- Resize ------------------------------------------------------------- -const SIZE_MB: u64 = 0x100000; - -#[test] -fn test_resize_grow_then_write_in_new_space() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, SIZE_MB); - - // Fill most of the original 1MB capacity - let filler = vec![0xAA; 900_000]; - vault_write(&mut handle, "filler.bin".into(), filler.clone(), None, None).expect("write filler"); - - // Grow to 2MB - vault_resize(&mut handle, 2 * SIZE_MB).expect("grow to 2MB"); - - // Write a segment that requires space in the new region - let big = vec![0xBB; 900_000]; - vault_write(&mut handle, "big.bin".into(), big.clone(), None, None).expect("write in new space"); - - // Read both back - let filler_read = vault_read(&mut handle, "filler.bin".into()).expect("read filler").data; - assert_eq!(filler_read, filler); - let big_read = vault_read(&mut handle, "big.bin".into()).expect("read big").data; - assert_eq!(big_read, big); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_resize_shrink_after_consolidation() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = vault_path(&dir); - - // Create a 2MB vault and write ~500KB of data. - { - let mut handle = vault_create(path.clone(), test_key(), "aes-256-gcm".into(), 2 * SIZE_MB) - .expect("create 2MB"); - let data = vec![0xCC; 500_000]; - vault_write(&mut handle, "doc.bin".into(), data, None, None).expect("write 500KB"); - vault_close(handle).expect("close"); - } - - // Reopen, shrink to 1MB. Data is at the beginning so it should fit. - { - let mut handle = vault_open(path.clone(), test_key()).expect("reopen"); - vault_resize(&mut handle, SIZE_MB).expect("shrink to 1MB"); - - // Verify all data is still readable. - let data = vault_read(&mut handle, "doc.bin".into()).expect("read after shrink").data; - assert_eq!(data, vec![0xCC; 500_000]); - - // Verify capacity reflects the new size. - let cap = vault_capacity(&handle); - assert_eq!(cap.total_bytes, SIZE_MB); - - vault_close(handle).expect("close"); - } - - // Reopen again to confirm persistence across close/open. - { - let mut handle = vault_open(path, test_key()).expect("reopen again"); - let data = vault_read(&mut handle, "doc.bin".into()).expect("read persisted").data; - assert_eq!(data, vec![0xCC; 500_000]); - vault_close(handle).expect("close"); - } -} - -#[test] -fn test_resize_shrink_below_used_space_fails() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, SIZE_MB); - - // Write enough data so that shrinking below used space is impossible. - vault_write(&mut handle, "a.bin".into(), vec![0xDD; 600_000], None, None).expect("write"); - - // Attempt to shrink to 256KB — should fail with VaultFull. - let result = vault_resize(&mut handle, 256 * 1024); - assert!( - matches!(result, Err(CryptoError::VaultFull { .. })), - "expected VaultFull, got: {result:?}" - ); - - // Vault should still be usable after failed shrink. - let data = vault_read(&mut handle, "a.bin".into()).expect("read after failed shrink").data; - assert_eq!(data, vec![0xDD; 600_000]); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_resize_grow_updates_capacity() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = vault_path(&dir); - - { - let mut handle = - vault_create(path.clone(), test_key(), "aes-256-gcm".into(), SIZE_MB).expect("create"); - - let cap_before = vault_capacity(&handle); - assert_eq!(cap_before.total_bytes, SIZE_MB); - - vault_resize(&mut handle, 2 * SIZE_MB).expect("grow"); - - let cap_after = vault_capacity(&handle); - assert_eq!(cap_after.total_bytes, 2 * SIZE_MB); - // Unallocated should have grown by ~1MB. - assert!(cap_after.unallocated_bytes > cap_before.unallocated_bytes); - - vault_close(handle).expect("close"); - } - - // Verify persistence: reopen and check capacity. - { - let handle = vault_open(path, test_key()).expect("reopen"); - let cap = vault_capacity(&handle); - assert_eq!(cap.total_bytes, 2 * SIZE_MB); - vault_close(handle).expect("close"); - } -} - -#[test] -fn test_resize_grow_crash_recovery() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = vault_path(&dir); - - // Create vault with one segment. - { - let mut handle = - vault_create(path.clone(), test_key(), "aes-256-gcm".into(), SIZE_MB).expect("create"); - vault_write(&mut handle, "a.txt".into(), b"before grow".to_vec(), None, None).expect("write A"); - vault_close(handle).expect("close"); - } - - // Save the "good" encrypted index (1MB capacity, contains A). - let good_encrypted = { - let mut f = File::open(&path).expect("open"); - read_encrypted_index( - &mut f, - PRIMARY_INDEX_OFFSET, - encrypted_index_size(MIN_INDEX_PAD_SIZE), - ) - .expect("read index") - }; - - // Perform a real grow to 2MB. - { - let mut handle = vault_open(path.clone(), test_key()).expect("open"); - vault_resize(&mut handle, 2 * SIZE_MB).expect("grow"); - vault_write(&mut handle, "b.txt".into(), b"after grow".to_vec(), None, None).expect("write B"); - vault_close(handle).expect("close"); - } - - // Simulate crash: write an uncommitted WAL entry that restores the - // pre-grow index (1MB capacity, only A). This simulates a crash - // that occurred mid-grow before commit. - { - let mut wal = WriteAheadLog::open(&path).expect("wal"); - wal.begin(WalOp::UpdateIndex, &good_encrypted) - .expect("begin"); - // Don't commit — simulates crash. - } - - // Reopen — WAL recovery should roll back to the A-only / 1MB index. - let mut handle = vault_open(path, test_key()).expect("open after recovery"); - - // A should be readable. - let data = vault_read(&mut handle, "a.txt".into()).expect("read A").data; - assert_eq!(data, b"before grow"); - - // B should NOT be in the recovered index. - let result = vault_read(&mut handle, "b.txt".into()); - assert!(matches!(result, Err(CryptoError::SegmentNotFound(_)))); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_resize_same_capacity_is_noop() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, SIZE_MB); - - vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None, None).expect("write"); - let cap_before = vault_capacity(&handle); - - // Resize to same capacity — should return Ok without I/O - vault_resize(&mut handle, SIZE_MB).expect("noop resize"); - - let cap_after = vault_capacity(&handle); - assert_eq!(cap_before.total_bytes, cap_after.total_bytes); - assert_eq!(cap_before.used_bytes, cap_after.used_bytes); - - let data = vault_read(&mut handle, "a.txt".into()).expect("read").data; - assert_eq!(data, b"hello"); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_resize_shrink_after_defrag_reclaims_max_space() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 2 * SIZE_MB); - - // Write three segments, delete the first two to create gaps - vault_write(&mut handle, "a.bin".into(), vec![0xAA; 200_000], None, None).expect("A"); - vault_write(&mut handle, "b.bin".into(), vec![0xBB; 200_000], None, None).expect("B"); - vault_write(&mut handle, "keep.bin".into(), vec![0xCC; 100_000], None, None).expect("keep"); - - vault_delete(&mut handle, "a.bin".into()).expect("del A"); - vault_delete(&mut handle, "b.bin".into()).expect("del B"); - - // Before defrag: free regions exist, shrink is limited - assert!(handle.index.needs_defrag()); - - // Defrag compacts keep.bin to offset 0 - vault_defragment(&mut handle).expect("defrag"); - assert!(!handle.index.needs_defrag()); - - // After defrag: used space is only keep.bin's encrypted size - let used = handle.index.used_bytes(); - assert!(used < 200_000, "used {used} should be less than 200KB"); - - // Shrink to just above used space (round up to 256KB boundary) - let new_cap = ((used / (256 * 1024)) + 1) * (256 * 1024); - vault_resize(&mut handle, new_cap).expect("shrink after defrag"); - - let cap = vault_capacity(&handle); - assert_eq!(cap.total_bytes, new_cap); - - // Data intact after shrink - let data = vault_read(&mut handle, "keep.bin".into()).expect("read keep").data; - assert_eq!(data, vec![0xCC; 100_000]); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_resize_grow_crash_midway_recovers() { - use crate::core::evfs::segment; - - let dir = tempfile::tempdir().expect("tempdir"); - let path = vault_path(&dir); - - // Create vault and write a segment. - let good_encrypted; - { - let mut handle = - vault_create(path.clone(), test_key(), "aes-256-gcm".into(), SIZE_MB).expect("create"); - vault_write(&mut handle, "a.txt".into(), b"safe data".to_vec(), None, None).expect("write A"); - vault_close(handle).expect("close"); - } - - // Capture the good encrypted index before simulating a partial grow. - { - let mut f = File::open(&path).expect("open"); - good_encrypted = read_encrypted_index( - &mut f, - PRIMARY_INDEX_OFFSET, - encrypted_index_size(MIN_INDEX_PAD_SIZE), - ) - .expect("read idx"); - } - - // Simulate a crash mid-grow: extend the file and CSPRNG-fill - // (destroying the old shadow), but leave an uncommitted WAL entry. - { - let mut f = std::fs::OpenOptions::new() - .read(true) - .write(true) - .open(&path) - .expect("open rw"); - let new_total = format::total_vault_size(2 * SIZE_MB, MIN_INDEX_PAD_SIZE).expect("size"); - f.set_len(new_total).expect("extend"); - // CSPRNG-fill overwrites old shadow position at format::data_region_offset(MIN_INDEX_PAD_SIZE) + SIZE_MB - segment::secure_erase_region( - &mut f, - format::data_region_offset(MIN_INDEX_PAD_SIZE) + SIZE_MB, - SIZE_MB, - ) - .expect("fill"); - - let mut wal = WriteAheadLog::open(&path).expect("wal"); - wal.begin(WalOp::UpdateIndex, &good_encrypted) - .expect("begin"); - // Don't commit — simulates crash - } - - // Reopen — recovery should restore 1MB index, fix file size, fix shadow - let mut handle = vault_open(path.clone(), test_key()).expect("open after crash"); - - // Capacity should be restored to 1MB - assert_eq!(vault_capacity(&handle).total_bytes, SIZE_MB); - - // File size should match restored capacity - let actual_size = handle.file.seek(SeekFrom::End(0)).expect("seek"); - let expected_size = format::total_vault_size(SIZE_MB, MIN_INDEX_PAD_SIZE).expect("size"); - assert_eq!(actual_size, expected_size); - - // Data intact - let data = vault_read(&mut handle, "a.txt".into()).expect("read A").data; - assert_eq!(data, b"safe data"); - - // Vault usable after recovery — write + read works - vault_write(&mut handle, "b.txt".into(), b"post recovery".to_vec(), None, None).expect("write B"); - let b_data = vault_read(&mut handle, "b.txt".into()).expect("read B").data; - assert_eq!(b_data, b"post recovery"); - - vault_close(handle).expect("close"); -} - -// -- Defragmentation ---------------------------------------------------- - -#[test] -fn test_defragment_basic() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - vault_write(&mut handle, "a.txt".into(), vec![0xAA; 200], None, None).expect("A"); - vault_write(&mut handle, "b.txt".into(), vec![0xBB; 200], None, None).expect("B"); - vault_write(&mut handle, "c.txt".into(), vec![0xCC; 200], None, None).expect("C"); - - let c_offset_before = handle.index.find("c.txt").expect("C").offset; - - // Delete B — creates free region between A and C - vault_delete(&mut handle, "b.txt".into()).expect("del B"); - assert_eq!(handle.index.free_regions.len(), 1); - - let result = vault_defragment(&mut handle).expect("defrag"); - assert_eq!(result.segments_moved, 1); // C moved left - assert_eq!(result.free_regions_before, 1); - assert!(result.bytes_reclaimed > 0); - - // Free list should be empty, all free space is unallocated - let cap = vault_capacity(&handle); - assert_eq!(cap.free_list_bytes, 0); - assert!(cap.unallocated_bytes > 0); - assert_eq!(cap.segment_count, 2); - - // next_free_offset == sum of all segment sizes - let total_used: u64 = handle.index.entries.iter().map(|e| e.size).sum(); - assert_eq!(handle.index.next_free_offset, total_used); - assert_eq!(handle.index.free_regions.len(), 0); - - // C moved left - let c_offset_after = handle.index.find("c.txt").expect("C").offset; - assert!(c_offset_after < c_offset_before); - - // Data integrity preserved - assert_eq!( - vault_read(&mut handle, "a.txt".into()).expect("read A").data, - vec![0xAA; 200] - ); - assert_eq!( - vault_read(&mut handle, "c.txt".into()).expect("read C").data, - vec![0xCC; 200] - ); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_defragment_multiple_gaps() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - vault_write(&mut handle, "a.txt".into(), vec![0xAA; 100], None, None).expect("A"); - vault_write(&mut handle, "b.txt".into(), vec![0xBB; 100], None, None).expect("B"); - vault_write(&mut handle, "c.txt".into(), vec![0xCC; 100], None, None).expect("C"); - vault_write(&mut handle, "d.txt".into(), vec![0xDD; 100], None, None).expect("D"); - vault_write(&mut handle, "e.txt".into(), vec![0xEE; 100], None, None).expect("E"); - - // Delete B and D — two gaps - vault_delete(&mut handle, "b.txt".into()).expect("del B"); - vault_delete(&mut handle, "d.txt".into()).expect("del D"); - assert_eq!(handle.index.free_regions.len(), 2); - - let result = vault_defragment(&mut handle).expect("defrag"); - assert_eq!(result.segments_moved, 2); // C and E moved - assert_eq!(result.free_regions_before, 2); - - // All surviving segments readable - assert_eq!( - vault_read(&mut handle, "a.txt".into()).expect("A").data, - vec![0xAA; 100] - ); - assert_eq!( - vault_read(&mut handle, "c.txt".into()).expect("C").data, - vec![0xCC; 100] - ); - assert_eq!( - vault_read(&mut handle, "e.txt".into()).expect("E").data, - vec![0xEE; 100] - ); - - assert_eq!(vault_capacity(&handle).free_list_bytes, 0); - vault_close(handle).expect("close"); -} - -#[test] -fn test_defragment_already_compact() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - vault_write(&mut handle, "a.txt".into(), vec![0xAA; 100], None, None).expect("A"); - vault_write(&mut handle, "b.txt".into(), vec![0xBB; 100], None, None).expect("B"); - vault_write(&mut handle, "c.txt".into(), vec![0xCC; 100], None, None).expect("C"); - - let result = vault_defragment(&mut handle).expect("defrag"); - assert_eq!(result.segments_moved, 0); - assert_eq!(result.bytes_reclaimed, 0); - assert_eq!(result.free_regions_before, 0); - - // All segments still readable - assert_eq!( - vault_read(&mut handle, "a.txt".into()).expect("A").data, - vec![0xAA; 100] - ); - assert_eq!( - vault_read(&mut handle, "b.txt".into()).expect("B").data, - vec![0xBB; 100] - ); - assert_eq!( - vault_read(&mut handle, "c.txt".into()).expect("C").data, - vec![0xCC; 100] - ); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_defragment_empty_vault() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - let result = vault_defragment(&mut handle).expect("defrag"); - assert_eq!(result.segments_moved, 0); - assert_eq!(result.bytes_reclaimed, 0); - assert_eq!(result.free_regions_before, 0); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_defragment_single_segment_at_gap() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - vault_write(&mut handle, "a.txt".into(), vec![0xAA; 200], None, None).expect("A"); - vault_write(&mut handle, "b.txt".into(), vec![0xBB; 200], None, None).expect("B"); - - // Delete A — gap at start, B remains at higher offset - vault_delete(&mut handle, "a.txt".into()).expect("del A"); - - let result = vault_defragment(&mut handle).expect("defrag"); - assert_eq!(result.segments_moved, 1); - assert_eq!(result.free_regions_before, 1); - - // B moved to offset 0 - assert_eq!(handle.index.find("b.txt").expect("B").offset, 0); - assert_eq!( - vault_read(&mut handle, "b.txt".into()).expect("read B").data, - vec![0xBB; 200] - ); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_defragment_crash_recovery() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = vault_path(&dir); - - // Create vault with A, B, C. Delete B to create a gap. - { - let mut handle = vault_create(path.clone(), test_key(), "aes-256-gcm".into(), 1_048_576) - .expect("create"); - vault_write(&mut handle, "a.txt".into(), vec![0xAA; 100], None, None).expect("A"); - vault_write(&mut handle, "b.txt".into(), vec![0xBB; 100], None, None).expect("B"); - vault_write(&mut handle, "c.txt".into(), vec![0xCC; 100], None, None).expect("C"); - vault_delete(&mut handle, "b.txt".into()).expect("del B"); - vault_close(handle).expect("close"); - } - - // Save pre-defrag encrypted index (the "good" state) - let pre_defrag_index = { - let mut f = File::open(&path).expect("open"); - read_encrypted_index( - &mut f, - PRIMARY_INDEX_OFFSET, - encrypted_index_size(MIN_INDEX_PAD_SIZE), - ) - .expect("read index") - }; - - // Simulate crash mid-defrag: write uncommitted WAL entry - { - let mut wal = WriteAheadLog::open(&path).expect("wal"); - wal.begin(WalOp::WriteSegment, &pre_defrag_index) - .expect("begin"); - // Don't commit — simulates crash during defrag - } - - // Reopen — WAL recovery should restore pre-defrag index - let mut handle = vault_open(path, test_key()).expect("open after crash"); - - // C should still be at its original (pre-defrag) offset, readable - assert_eq!( - vault_read(&mut handle, "c.txt".into()).expect("read C").data, - vec![0xCC; 100] - ); - assert_eq!( - vault_read(&mut handle, "a.txt".into()).expect("read A").data, - vec![0xAA; 100] - ); - - // Free region from deleted B should still exist - assert!(!handle.index.free_regions.is_empty()); - - // Now run actual defrag — should succeed normally - let result = vault_defragment(&mut handle).expect("defrag"); - assert!(result.segments_moved > 0); - - assert_eq!( - vault_read(&mut handle, "a.txt".into()).expect("A").data, - vec![0xAA; 100] - ); - assert_eq!( - vault_read(&mut handle, "c.txt".into()).expect("C").data, - vec![0xCC; 100] - ); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_defragment_crash_overlapping_move_recovers() { - // Exercises the overlapping-move crash path: segment B is larger than - // the gap created by deleting A, so moving B left overwrites part of - // B's old position. A crash before WAL commit should still recover. - let dir = tempfile::tempdir().expect("tempdir"); - let path = vault_path(&dir); - - // Create: [A: 100B] [B: 10KB] — deleting A creates a small gap - // so B's move from offset(A.size) to 0 overlaps. - { - let mut handle = - vault_create(path.clone(), test_key(), "aes-256-gcm".into(), SIZE_MB).expect("create"); - vault_write(&mut handle, "a.txt".into(), vec![0xAA; 100], None, None).expect("A"); - vault_write(&mut handle, "b.txt".into(), vec![0xBB; 10_000], None, None).expect("B"); - vault_delete(&mut handle, "a.txt".into()).expect("del A"); - vault_close(handle).expect("close"); - } - - // Save pre-defrag index and get B's offset/size - let (pre_defrag_index, b_old_offset, b_size) = { - let handle = vault_open(path.clone(), test_key()).expect("open"); - let mut f = File::open(&path).expect("open file"); - let idx = read_encrypted_index( - &mut f, - PRIMARY_INDEX_OFFSET, - encrypted_index_size(MIN_INDEX_PAD_SIZE), - ) - .expect("read idx"); - let entry = handle.index.find("b.txt").expect("B"); - let off = entry.offset; - let sz = entry.size; - vault_close(handle).expect("close"); - (idx, off, sz) - }; - - // Verify this IS an overlapping move: gap < size - assert!(b_old_offset < b_size, "should be overlapping move"); - - // Simulate crash mid-defrag: perform the overlapping write but don't commit WAL - { - let mut f = std::fs::OpenOptions::new() - .read(true) - .write(true) - .open(&path) - .expect("open rw"); - - // Read B from old position - let mut buf = vec![0u8; b_size as usize]; - f.seek(SeekFrom::Start( - format::data_region_offset(MIN_INDEX_PAD_SIZE) + b_old_offset, - )) - .expect("seek"); - f.read_exact(&mut buf).expect("read B"); - - // Write defrag backup (simulating what vault_defragment does) - let backup_path = format!("{path}.defrag"); - let mut backup = File::create(&backup_path).expect("create backup"); - backup.write_all(&b_old_offset.to_le_bytes()).expect("off"); - backup.write_all(&b_size.to_le_bytes()).expect("sz"); - backup.write_all(&buf).expect("data"); - backup.sync_all().expect("sync backup"); - - // Write B to new position (offset 0) — corrupts overlap zone - f.seek(SeekFrom::Start(format::data_region_offset( - MIN_INDEX_PAD_SIZE, - ))) - .expect("seek 0"); - f.write_all(&buf).expect("write B to 0"); - f.sync_all().expect("sync"); - - // Write uncommitted WAL entry - let mut wal = WriteAheadLog::open(&path).expect("wal"); - wal.begin(WalOp::WriteSegment, &pre_defrag_index) - .expect("begin"); - // Don't commit — crash - } - - // Reopen — defrag backup + WAL recovery should restore to consistent state - let mut handle = vault_open(path, test_key()).expect("open after crash"); - - // B should be readable at old position (overlap zone restored) - let b_data = vault_read(&mut handle, "b.txt".into()).expect("read B").data; - assert_eq!(b_data, vec![0xBB; 10_000]); - - // Defrag should still work after recovery - let result = vault_defragment(&mut handle).expect("defrag after recovery"); - assert!(result.segments_moved > 0); - let b_data = vault_read(&mut handle, "b.txt".into()).expect("read B post-defrag").data; - assert_eq!(b_data, vec![0xBB; 10_000]); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_defragment_preserves_compression() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - let zstd_data = b"zstd compressible data ".repeat(50); - let raw_data = b"raw segment no compression".to_vec(); - - let zstd_conf = CompressionConfig { - algorithm: CompressionAlgorithm::Zstd, - level: None, - }; - vault_write( - &mut handle, - "text.txt".into(), - zstd_data.clone(), - Some(zstd_conf), - None, - ) - .expect("zstd"); - vault_write(&mut handle, "spacer.bin".into(), vec![0xFF; 300], None, None).expect("spacer"); - vault_write(&mut handle, "raw.dat".into(), raw_data.clone(), None, None).expect("raw"); - - // Delete spacer — gap between text.txt and raw.dat - vault_delete(&mut handle, "spacer.bin".into()).expect("del spacer"); - - let result = vault_defragment(&mut handle).expect("defrag"); - assert_eq!(result.segments_moved, 1); // raw.dat moved - - // Compression metadata preserved - let text_entry = handle.index.find("text.txt").expect("text"); - assert_eq!(text_entry.compression, CompressionAlgorithm::Zstd); - let raw_entry = handle.index.find("raw.dat").expect("raw"); - assert_eq!(raw_entry.compression, CompressionAlgorithm::None); - - // Data integrity — decompression works after defrag - assert_eq!( - vault_read(&mut handle, "text.txt".into()).expect("text").data, - zstd_data - ); - assert_eq!( - vault_read(&mut handle, "raw.dat".into()).expect("raw").data, - raw_data - ); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_defragment_preserves_generation() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - vault_write(&mut handle, "a.txt".into(), vec![0xAA; 100], None, None).expect("A"); - // Overwrite A to bump its generation - vault_write(&mut handle, "a.txt".into(), vec![0xAA; 100], None, None).expect("A v2"); - vault_write(&mut handle, "b.txt".into(), vec![0xBB; 100], None, None).expect("B"); - - let b_gen_before = handle.index.find("b.txt").expect("B").generation; - - // Delete A — B needs to move - vault_delete(&mut handle, "a.txt".into()).expect("del A"); - - let result = vault_defragment(&mut handle).expect("defrag"); - assert_eq!(result.segments_moved, 1); - - // Generation unchanged - let b_gen_after = handle.index.find("b.txt").expect("B").generation; - assert_eq!(b_gen_before, b_gen_after); - - // Nonce derivation still works (read succeeds) - assert_eq!( - vault_read(&mut handle, "b.txt".into()).expect("read B").data, - vec![0xBB; 100] - ); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_defragment_large_gap_at_start() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - vault_write(&mut handle, "a.txt".into(), vec![0xAA; 300], None, None).expect("A"); - vault_write(&mut handle, "b.txt".into(), vec![0xBB; 300], None, None).expect("B"); - vault_write(&mut handle, "c.txt".into(), vec![0xCC; 300], None, None).expect("C"); - - let ab_size = - handle.index.find("a.txt").expect("A").size + handle.index.find("b.txt").expect("B").size; - - // Delete A and B — large gap at start, C alone at end - vault_delete(&mut handle, "a.txt".into()).expect("del A"); - vault_delete(&mut handle, "b.txt".into()).expect("del B"); - - let result = vault_defragment(&mut handle).expect("defrag"); - assert_eq!(result.segments_moved, 1); // C moved to 0 - assert_eq!(result.bytes_reclaimed, ab_size); - - assert_eq!(handle.index.find("c.txt").expect("C").offset, 0); - assert_eq!( - vault_read(&mut handle, "c.txt".into()).expect("C").data, - vec![0xCC; 300] - ); - assert_eq!(vault_capacity(&handle).free_list_bytes, 0); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_defragment_write_after_defrag() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - vault_write(&mut handle, "a.txt".into(), vec![0xAA; 200], None, None).expect("A"); - vault_write(&mut handle, "b.txt".into(), vec![0xBB; 200], None, None).expect("B"); - vault_write(&mut handle, "c.txt".into(), vec![0xCC; 200], None, None).expect("C"); - - vault_delete(&mut handle, "b.txt".into()).expect("del B"); - vault_defragment(&mut handle).expect("defrag"); - - // Allocator should work: new writes go after the packed segments - vault_write(&mut handle, "d.txt".into(), vec![0xDD; 500], None, None).expect("D after defrag"); - - // All segments readable - assert_eq!( - vault_read(&mut handle, "a.txt".into()).expect("A").data, - vec![0xAA; 200] - ); - assert_eq!( - vault_read(&mut handle, "c.txt".into()).expect("C").data, - vec![0xCC; 200] - ); - assert_eq!( - vault_read(&mut handle, "d.txt".into()).expect("D").data, - vec![0xDD; 500] - ); - - // D should be allocated at the end (after A and C) - let a_end = - handle.index.find("a.txt").expect("A").offset + handle.index.find("a.txt").expect("A").size; - let c_end = - handle.index.find("c.txt").expect("C").offset + handle.index.find("c.txt").expect("C").size; - let d_offset = handle.index.find("d.txt").expect("D").offset; - let packed_end = std::cmp::max(a_end, c_end); - assert!(d_offset >= packed_end); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_defragment_ten_segments_delete_odd() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - // Write 10 segments (1KB each) - for i in 0..10 { - let name = format!("seg{i}.bin"); - let data = vec![(i as u8) | 0x10; 1024]; - vault_write(&mut handle, name, data, None, None).expect("write"); - } - - // Delete odd-numbered segments (1, 3, 5, 7, 9) — 5 gaps - for i in (1..10).step_by(2) { - let name = format!("seg{i}.bin"); - vault_delete(&mut handle, name).expect("delete"); - } - assert_eq!(handle.index.entries.len(), 5); - assert!(!handle.index.free_regions.is_empty()); - - let result = vault_defragment(&mut handle).expect("defrag"); - - // 4 of 5 even segments need to move (seg0 stays at 0) - assert_eq!(result.segments_moved, 4); - assert_eq!(handle.index.free_regions.len(), 0); - - // next_free_offset == sum of all segment sizes - let total_used: u64 = handle.index.entries.iter().map(|e| e.size).sum(); - assert_eq!(handle.index.next_free_offset, total_used); - - // Segments are contiguous: sorted offsets form a gapless sequence - let mut offsets: Vec<(u64, u64)> = handle - .index - .entries - .iter() - .map(|e| (e.offset, e.size)) - .collect(); - offsets.sort_by_key(|&(off, _)| off); - assert_eq!(offsets[0].0, 0); - for w in offsets.windows(2) { - assert_eq!(w[0].0 + w[0].1, w[1].0, "gap between segments"); - } - - // All 5 surviving segments readable with correct data - for i in (0..10).step_by(2) { - let name = format!("seg{i}.bin"); - let expected = vec![(i as u8) | 0x10; 1024]; - let data = vault_read(&mut handle, name).expect("read").data; - assert_eq!(data, expected); - } - - vault_close(handle).expect("close"); -} - -#[test] -fn test_vault_health_empty() { - let dir = tempfile::tempdir().expect("tempdir"); - let handle = create_test_vault(&dir, 1_048_576); - - let h = vault_health(&handle); - assert_eq!(h.segment_count, 0); - assert_eq!(h.free_region_count, 0); - assert_eq!(h.fragmentation_ratio, 0.0); - assert_eq!(h.total_bytes, handle.index.capacity); - assert_eq!(h.used_bytes, 0); - assert_eq!(h.unallocated_bytes, handle.index.capacity); -} - -#[test] -fn test_vault_health_after_write_delete_and_defrag() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - vault_write(&mut handle, "a".into(), vec![0xAA; 200], None, None).expect("A"); - vault_write(&mut handle, "b".into(), vec![0xBB; 500], None, None).expect("B"); - vault_write(&mut handle, "c".into(), vec![0xCC; 300], None, None).expect("C"); - - let before = vault_health(&handle); - assert!(before.used_bytes > 0); - assert_eq!( - before.unallocated_bytes + before.free_list_bytes + before.used_bytes, - handle.index.capacity - ); - - vault_delete(&mut handle, "b".into()).expect("delete B"); - - let after_delete = vault_health(&handle); - assert_eq!(after_delete.free_region_count, 1); - assert!(after_delete.fragmentation_ratio > 0.0); - assert!(after_delete.largest_free_block > 0); - assert_eq!( - after_delete.used_bytes + after_delete.free_list_bytes + after_delete.unallocated_bytes, - handle.index.capacity - ); - - vault_defragment(&mut handle).expect("defrag"); - - let after_defrag = vault_health(&handle); - assert_eq!(after_defrag.free_region_count, 0); - assert_eq!(after_defrag.fragmentation_ratio, 0.0); - assert_eq!( - after_defrag.largest_free_block, - after_defrag.unallocated_bytes - ); - assert_eq!( - after_defrag.used_bytes + after_defrag.free_list_bytes + after_defrag.unallocated_bytes, - handle.index.capacity - ); -} - -#[test] -fn test_largest_free_block_accounts_for_tail() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1024u64); - - vault_write(&mut handle, "s".into(), vec![0xAA; 100], None, None).expect("write"); - - let h = vault_health(&handle); - let free_list_max = handle - .index - .free_regions - .iter() - .map(|r| r.size) - .max() - .unwrap_or(0); - let tail = handle - .index - .capacity - .saturating_sub(handle.index.next_free_offset); - - assert_eq!(h.largest_free_block, std::cmp::max(free_list_max, tail)); -} - -#[test] -fn test_vault_health_full_capacity() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - // Fill vault until full - let mut i = 0; - loop { - let name = format!("seg_{i}"); - if vault_write(&mut handle, name, vec![0xAA; 8192], None, None).is_err() { - break; - } - i += 1; - } - - let h = vault_health(&handle); - assert_eq!(h.free_region_count, 0); - assert_eq!(h.fragmentation_ratio, 0.0); - assert_eq!( - h.used_bytes + h.free_list_bytes + h.unallocated_bytes, - h.total_bytes - ); -} - -// -- Streaming Read & Interop Tests ------------------------------------- -#[test] -fn test_oneshot_write_oneshot_read_interop() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - let data = b"monolithic data".to_vec(); - vault_write(&mut handle, "mono.txt".into(), data.clone(), None, None).expect("write"); - - let entry = handle.index.find("mono.txt").expect("find"); - assert_eq!(entry.chunk_count, 0, "Should be written as monolithic"); - - let read_back = vault_read(&mut handle, "mono.txt".into()).expect("read").data; - assert_eq!(read_back, data); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_stream_write_oneshot_read_matches_interop() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 5_000_000); - - let chunk_size = crate::core::streaming::CHUNK_SIZE; - // 3 full chunks, 1 partial padded chunk - let data = vec![0x77; chunk_size * 3 + 1234]; - - // Write using stream - let chunks: Vec> = data.chunks(chunk_size).map(|c| c.to_vec()).collect(); - vault_write_stream( - &mut handle, - "streamed.bin".into(), - data.len() as u64, - chunks.into_iter(), - None, - ) - .expect("stream write"); - - let entry = handle.index.find("streamed.bin").expect("find"); - assert!(entry.chunk_count > 0, "Should be written as chunked"); - - // Read using one-shot (interop) testing the chunk-assembly loop - let read_back = vault_read(&mut handle, "streamed.bin".into()).expect("read").data; - assert_eq!(read_back, data, "Data should match byte-for-byte"); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_tamper_with_chunk_detected() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - let chunk_size = crate::core::streaming::CHUNK_SIZE; - let data = vec![0xAA; chunk_size * 2]; - - let chunks = vec![data[..chunk_size].to_vec(), data[chunk_size..].to_vec()]; - vault_write_stream( - &mut handle, - "streamed.txt".into(), - data.len() as u64, - chunks.into_iter(), - None, - ) - .expect("stream write"); - - let entry = handle.index.find("streamed.txt").expect("find"); - let disk_offset = - crate::core::evfs::format::data_region_offset(handle.index_pad_size) + entry.offset; - - // Seek past nonce (12 bytes) and flip a ciphertext byte in the first chunk - handle - .file - .seek(SeekFrom::Start(disk_offset + 13)) - .expect("seek"); - handle.file.write_all(&[0xFF]).expect("tamper"); - handle.file.sync_all().expect("sync"); - - let result = vault_read(&mut handle, "streamed.txt".into()); - assert!( - matches!(result, Err(CryptoError::AuthenticationFailed)), - "Should detect chunk tampering via independent AEAD" - ); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_reordered_chunks_detected() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - let chunk_size = crate::core::streaming::CHUNK_SIZE; - let enc_chunk_size = crate::core::streaming::ENCRYPTED_CHUNK_SIZE as u64; - let data = vec![0xBB; chunk_size * 2]; - - let chunks = vec![data[..chunk_size].to_vec(), data[chunk_size..].to_vec()]; - vault_write_stream( - &mut handle, - "reorder.bin".into(), - data.len() as u64, - chunks.into_iter(), - None, - ) - .expect("stream write"); - - let entry = handle.index.find("reorder.bin").expect("find"); - let disk_offset = - crate::core::evfs::format::data_region_offset(handle.index_pad_size) + entry.offset; - - // Read chunk 0 and chunk 1 - let mut c0 = vec![0u8; enc_chunk_size as usize]; - let mut c1 = vec![0u8; enc_chunk_size as usize]; - - handle - .file - .seek(SeekFrom::Start(disk_offset)) - .expect("seek"); - handle.file.read_exact(&mut c0).expect("read c0"); - handle.file.read_exact(&mut c1).expect("read c1"); - - // Swap them on disk - handle - .file - .seek(SeekFrom::Start(disk_offset)) - .expect("seek"); - handle.file.write_all(&c1).expect("write c1"); - handle.file.write_all(&c0).expect("write c0"); - handle.file.sync_all().expect("sync"); - - let result = vault_read(&mut handle, "reorder.bin".into()); - assert!( - matches!(result, Err(CryptoError::AuthenticationFailed)), - "Should detect chunk reordering due to index mismatch in AAD" - ); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_integrity_checksum_failure_on_stream() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - let data = vec![0xCC; 1000]; - vault_write_stream( - &mut handle, - "checksum.bin".into(), - data.len() as u64, - vec![data].into_iter(), - None, - ) - .expect("write"); - - // Manually corrupt the checksum stored in the Segment Index - let entry = handle.index.find_mut("checksum.bin").expect("find"); - entry.checksum[0] ^= 0xFF; - - let result = vault_read(&mut handle, "checksum.bin".into()); - assert!( - matches!(result, Err(CryptoError::VaultCorrupted(_))), - "Should detect BLAKE3 checksum mismatch even if AEAD passes" - ); - - vault_close(handle).expect("close"); -} - -// -- Streaming Write -------------------------------------------------------- - -/// Helper: stream-write data in fixed-size pieces. -fn stream_write_chunks( - handle: &mut VaultHandle, - name: &str, - data: &[u8], - piece_size: usize, -) -> Result<(), CryptoError> { - let chunks: Vec> = data.chunks(piece_size).map(|c| c.to_vec()).collect(); - vault_write_stream( - handle, - name.to_string(), - data.len() as u64, - chunks.into_iter(), - None, - ) -} - -#[test] -fn test_stream_write_read_roundtrip() { - let dir = tempfile::tempdir().expect("tempdir"); - // 2MB vault to fit the streaming overhead - let mut handle = create_test_vault(&dir, 2 * 1024 * 1024); - - let data = vec![0x42u8; 200_000]; // ~3 chunks - stream_write_chunks(&mut handle, "video.bin", &data, 4096).expect("stream write"); - - let readback = vault_read(&mut handle, "video.bin".into()).expect("read").data; - assert_eq!(readback, data); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_stream_write_single_byte() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - let data = vec![0xAA; 1]; - stream_write_chunks(&mut handle, "tiny.bin", &data, 1).expect("stream write"); - - let readback = vault_read(&mut handle, "tiny.bin".into()).expect("read").data; - assert_eq!(readback, data); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_stream_write_empty_segment() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - // 0 bytes — still produces 1 padded chunk - vault_write_stream(&mut handle, "empty.bin".into(), 0, std::iter::empty(), None) - .expect("stream write empty"); - - let readback = vault_read(&mut handle, "empty.bin".into()).expect("read").data; - assert!(readback.is_empty()); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_stream_write_exact_chunk_boundary() { - use crate::core::streaming::CHUNK_SIZE; - - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 2 * 1024 * 1024); - - // Exactly CHUNK_SIZE bytes — triggers the extra empty padded chunk - let data = vec![0xBB; CHUNK_SIZE]; - stream_write_chunks(&mut handle, "aligned.bin", &data, CHUNK_SIZE).expect("stream write"); - - let readback = vault_read(&mut handle, "aligned.bin".into()).expect("read").data; - assert_eq!(readback, data); - - // Verify chunk_count = 2 (1 full + 1 empty padded) - let entry = handle.index.find("aligned.bin").expect("entry"); - assert_eq!(entry.chunk_count, 2); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_stream_write_overwrite_existing() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 2 * 1024 * 1024); - - // Write original (monolithic) - vault_write( - &mut handle, - "doc.txt".into(), - b"original data".to_vec(), - None, - None, - ) - .expect("write"); - - // Overwrite with streaming (larger) - let new_data = vec![0xCC; 100_000]; - stream_write_chunks(&mut handle, "doc.txt", &new_data, 8192).expect("stream overwrite"); - - let readback = vault_read(&mut handle, "doc.txt".into()).expect("read").data; - assert_eq!(readback, new_data); - - // Verify it's now a streaming segment - let entry = handle.index.find("doc.txt").expect("entry"); - assert!(entry.is_streaming()); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_stream_write_overwrite_with_smaller() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 2 * 1024 * 1024); - - // Write original (streaming, large) - let original = vec![0xAA; 200_000]; - stream_write_chunks(&mut handle, "file.bin", &original, 4096).expect("write large"); - - // Overwrite with smaller streaming segment - let smaller = vec![0xBB; 1000]; - stream_write_chunks(&mut handle, "file.bin", &smaller, 500).expect("write small"); - - let readback = vault_read(&mut handle, "file.bin".into()).expect("read").data; - assert_eq!(readback, smaller); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_stream_write_wrong_size_too_few_bytes() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - // Claim 1000 bytes but provide only 500 - let data = vec![0xAA; 500]; - let result = vault_write_stream(&mut handle, "bad.bin".into(), 1000, vec![data].into_iter(), None); - assert!(result.is_err()); - let err = result - .expect_err("expected an error (underflow)") - .to_string(); - assert!(err.contains("underflow"), "Expected underflow error: {err}"); -} - -#[test] -fn test_stream_write_wrong_size_too_many_bytes() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - // Claim 500 bytes but provide 1000 - let data = vec![0xAA; 1000]; - let result = vault_write_stream(&mut handle, "bad.bin".into(), 500, vec![data].into_iter(), None); - assert!(result.is_err()); - let err = result - .expect_err("expected an error (exceeded)") - .to_string(); - assert!(err.contains("exceeded"), "Expected exceeded error: {err}"); -} - -#[test] -fn test_stream_write_persist_reopen() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = vault_path(&dir); - - let data = vec![0xDD; 150_000]; - { - let mut handle = vault_create( - path.clone(), - test_key(), - "aes-256-gcm".into(), - 2 * 1024 * 1024, - ) - .expect("create"); - stream_write_chunks(&mut handle, "persist.bin", &data, 4096).expect("stream write"); - vault_close(handle).expect("close"); - } - - // Reopen and verify - let mut handle = vault_open(path, test_key()).expect("open"); - let readback = vault_read(&mut handle, "persist.bin".into()).expect("read").data; - assert_eq!(readback, data); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_stream_write_chacha20() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir - .path() - .join("chacha.vault") - .to_str() - .expect("path") - .to_string(); - let mut handle = vault_create( - path, - test_key(), - "chacha20-poly1305".into(), - 2 * 1024 * 1024, - ) - .expect("create"); - - let data = vec![0xEE; 100_000]; - stream_write_chunks(&mut handle, "chacha.bin", &data, 8192).expect("stream write"); - - let readback = vault_read(&mut handle, "chacha.bin".into()).expect("read").data; - assert_eq!(readback, data); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_stream_write_arbitrary_input_sizes() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 2 * 1024 * 1024); - - // Provide data in irregular chunks (1, 7, 100, 50000, 3, ...) - let total = 100_000usize; - let data: Vec = (0..total).map(|i| (i % 256) as u8).collect(); - - let pieces = vec![ - data[..1].to_vec(), - data[1..8].to_vec(), - data[8..108].to_vec(), - data[108..50108].to_vec(), - data[50108..50111].to_vec(), - data[50111..].to_vec(), - ]; - - vault_write_stream( - &mut handle, - "irregular.bin".into(), - total as u64, - pieces.into_iter(), - None, - ) - .expect("stream write"); - - let readback = vault_read(&mut handle, "irregular.bin".into()).expect("read").data; - assert_eq!(readback, data); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_stream_write_coexists_with_monolithic() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 2 * 1024 * 1024); - - // Write monolithic - vault_write(&mut handle, "mono.txt".into(), b"mono data".to_vec(), None, None).expect("write mono"); - - // Write streaming - let stream_data = vec![0xFF; 80_000]; - stream_write_chunks(&mut handle, "stream.bin", &stream_data, 4096).expect("stream write"); - - // Read both back - let mono = vault_read(&mut handle, "mono.txt".into()).expect("read mono").data; - assert_eq!(mono, b"mono data"); - - let stream = vault_read(&mut handle, "stream.bin".into()).expect("read stream").data; - assert_eq!(stream, stream_data); - - // Verify types - assert!(!handle - .index - .find("mono.txt") - .expect("mono.txt missing") - .is_streaming()); - assert!(handle - .index - .find("stream.bin") - .expect("stream.bin missing") - .is_streaming()); - - vault_close(handle).expect("close"); -} - -// -- Streaming Read (via decrypt_streaming_chunks) -------------------------- - -/// Helper: stream-read all chunks into a Vec, returning (data, chunk_indices, checksum). -#[allow(clippy::type_complexity)] -fn stream_read_chunks( - handle: &mut VaultHandle, - name: &str, -) -> Result<(Vec, Vec, [u8; 32]), CryptoError> { - let entry = handle - .index - .find(name) - .ok_or_else(|| CryptoError::SegmentNotFound(name.into()))?; - - let seg_offset = entry.offset; - let seg_gen = entry.generation; - let seg_compression = entry.compression; - let chunk_count = entry.chunk_count; - - let mut collected = Vec::new(); - let mut indices = Vec::new(); - - let checksum = decrypt_streaming_chunks( - &mut handle.file, - handle.mmap.as_ref(), - handle.keys.cipher_key.as_bytes(), - handle.keys.nonce_key.as_bytes(), - handle.algorithm, - handle.index_pad_size, - seg_offset, - seg_gen, - seg_compression, - chunk_count, - |data, i| { - collected.extend_from_slice(&data); - indices.push(i); - Ok(()) - }, - )?; - - Ok((collected, indices, checksum)) -} - -#[test] -fn test_stream_read_matches_oneshot_read() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 2 * 1024 * 1024); - - let data = vec![0x42u8; 200_000]; - stream_write_chunks(&mut handle, "video.bin", &data, 4096).expect("stream write"); - - let oneshot = vault_read(&mut handle, "video.bin".into()).expect("oneshot read").data; - let (streamed, _, _) = stream_read_chunks(&mut handle, "video.bin").expect("stream read"); - - assert_eq!( - streamed, oneshot, - "streaming and one-shot must be byte-identical" - ); - assert_eq!(streamed, data); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_stream_read_checksum_matches_stored() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 2 * 1024 * 1024); - - let data = vec![0xAA; 150_000]; - stream_write_chunks(&mut handle, "file.bin", &data, 8192).expect("stream write"); - - let stored_checksum = handle.index.find("file.bin").expect("find").checksum; - let (_, _, computed_checksum) = - stream_read_chunks(&mut handle, "file.bin").expect("stream read"); - - assert_eq!(computed_checksum, stored_checksum); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_stream_read_progress_indices() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 2 * 1024 * 1024); - - let chunk_size = crate::core::streaming::CHUNK_SIZE; - let data = vec![0xBB; chunk_size * 3 + 1234]; - stream_write_chunks(&mut handle, "prog.bin", &data, chunk_size).expect("stream write"); - - let chunk_count = handle.index.find("prog.bin").expect("find").chunk_count; - let (collected, indices, _) = stream_read_chunks(&mut handle, "prog.bin").expect("stream read"); - - assert_eq!(collected, data); - assert_eq!(indices.len(), chunk_count as usize); - let expected: Vec = (0..chunk_count).collect(); - assert_eq!(indices, expected); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_stream_read_single_byte() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - let data = vec![0xCC; 1]; - stream_write_chunks(&mut handle, "tiny.bin", &data, 1).expect("stream write"); - - let (collected, indices, _) = stream_read_chunks(&mut handle, "tiny.bin").expect("stream read"); - - assert_eq!(collected, data); - assert_eq!(indices.len(), 1); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_stream_read_empty_segment() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - vault_write_stream(&mut handle, "empty.bin".into(), 0, std::iter::empty(), None) - .expect("stream write empty"); - - let (collected, indices, _) = - stream_read_chunks(&mut handle, "empty.bin").expect("stream read"); - - assert!(collected.is_empty()); - assert_eq!(indices.len(), 1); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_stream_read_exact_chunk_boundary() { - use crate::core::streaming::CHUNK_SIZE; - - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 2 * 1024 * 1024); - - let data = vec![0xDD; CHUNK_SIZE]; - stream_write_chunks(&mut handle, "aligned.bin", &data, CHUNK_SIZE).expect("stream write"); - - let (collected, _, checksum) = - stream_read_chunks(&mut handle, "aligned.bin").expect("stream read"); - - assert_eq!(collected, data); - let stored = handle.index.find("aligned.bin").expect("find").checksum; - assert_eq!(checksum, stored); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_stream_read_large_segment() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 12 * 1024 * 1024); - - let data: Vec = (0..10_000_000).map(|i| (i % 251) as u8).collect(); - stream_write_chunks(&mut handle, "big.bin", &data, 65536).expect("stream write"); - - let (collected, indices, checksum) = - stream_read_chunks(&mut handle, "big.bin").expect("stream read"); - - assert_eq!(collected.len(), data.len()); - assert_eq!(collected, data); - assert!(!indices.is_empty()); - assert_eq!( - checksum, - handle.index.find("big.bin").expect("find").checksum - ); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_stream_read_tamper_detected() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - let chunk_size = crate::core::streaming::CHUNK_SIZE; - let data = vec![0xEE; chunk_size * 2]; - stream_write_chunks(&mut handle, "tamper.bin", &data, chunk_size).expect("stream write"); - - let entry = handle.index.find("tamper.bin").expect("find"); - let disk_offset = - crate::core::evfs::format::data_region_offset(handle.index_pad_size) + entry.offset; - - handle - .file - .seek(std::io::SeekFrom::Start(disk_offset + 13)) - .expect("seek"); - handle.file.write_all(&[0xFF]).expect("tamper"); - handle.file.sync_all().expect("sync"); - - let result = stream_read_chunks(&mut handle, "tamper.bin"); - assert!( - matches!(result, Err(CryptoError::AuthenticationFailed)), - "Should detect chunk tampering via streaming read" - ); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_stream_read_reorder_detected() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - let chunk_size = crate::core::streaming::CHUNK_SIZE; - let enc_chunk_size = crate::core::streaming::ENCRYPTED_CHUNK_SIZE as u64; - let data = vec![0xFF; chunk_size * 2]; - stream_write_chunks(&mut handle, "reorder.bin", &data, chunk_size).expect("stream write"); - - let entry = handle.index.find("reorder.bin").expect("find"); - let disk_offset = - crate::core::evfs::format::data_region_offset(handle.index_pad_size) + entry.offset; - - let mut c0 = vec![0u8; enc_chunk_size as usize]; - let mut c1 = vec![0u8; enc_chunk_size as usize]; - handle - .file - .seek(std::io::SeekFrom::Start(disk_offset)) - .expect("seek"); - handle.file.read_exact(&mut c0).expect("read c0"); - handle.file.read_exact(&mut c1).expect("read c1"); - handle - .file - .seek(std::io::SeekFrom::Start(disk_offset)) - .expect("seek"); - handle.file.write_all(&c1).expect("write c1"); - handle.file.write_all(&c0).expect("write c0"); - handle.file.sync_all().expect("sync"); - - let result = stream_read_chunks(&mut handle, "reorder.bin"); - assert!( - matches!(result, Err(CryptoError::AuthenticationFailed)), - "Should detect chunk reordering via streaming read" - ); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_stream_read_chacha20() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir - .path() - .join("test.vault") - .to_str() - .expect("path") - .to_string(); - let mut handle = vault_create( - path, - test_key(), - "chacha20-poly1305".into(), - 2 * 1024 * 1024, - ) - .expect("create"); - - let data = vec![0x77; 200_000]; - stream_write_chunks(&mut handle, "chacha.bin", &data, 4096).expect("stream write"); - - let (collected, _, _) = stream_read_chunks(&mut handle, "chacha.bin").expect("stream read"); - assert_eq!(collected, data); - - vault_close(handle).expect("close"); -} - -// -- vault_write_file (FRB wrapper) ----------------------------------------- - -/// Helper: write `data` to a temp file and return the path. -fn write_temp_file(dir: &tempfile::TempDir, name: &str, data: &[u8]) -> String { - let path = dir.path().join(name); - std::fs::write(&path, data).expect("write temp file"); - path.to_str().expect("path").to_string() -} - -/// Fake progress sink that collects values (vault_write_file can't use StreamSink in tests, -/// but the underlying vault_write_stream is what's actually tested here). -#[test] -fn test_write_file_roundtrip() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 2 * 1024 * 1024); - - let data = vec![0x42u8; 200_000]; - let file_path = write_temp_file(&dir, "input.bin", &data); - - // vault_write_file needs StreamSink — test the underlying path instead: - // read file → feed to vault_write_stream → read back - use crate::core::streaming::CHUNK_SIZE; - let file_data = std::fs::read(&file_path).expect("read file"); - let chunks: Vec> = file_data.chunks(CHUNK_SIZE).map(|c| c.to_vec()).collect(); - vault_write_stream( - &mut handle, - "from_file.bin".into(), - file_data.len() as u64, - chunks.into_iter(), - None, - ) - .expect("write stream"); - - let readback = vault_read(&mut handle, "from_file.bin".into()).expect("read").data; - assert_eq!(readback, data); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_write_file_stream_read_interop() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 2 * 1024 * 1024); - - let data: Vec = (0..150_000).map(|i| (i % 199) as u8).collect(); - let file_path = write_temp_file(&dir, "interop.bin", &data); - - use crate::core::streaming::CHUNK_SIZE; - let file_data = std::fs::read(&file_path).expect("read file"); - let chunks: Vec> = file_data.chunks(CHUNK_SIZE).map(|c| c.to_vec()).collect(); - vault_write_stream( - &mut handle, - "interop.bin".into(), - file_data.len() as u64, - chunks.into_iter(), - None, - ) - .expect("write stream"); - - // Read back via streaming read helper - let (collected, _, checksum) = - stream_read_chunks(&mut handle, "interop.bin").expect("stream read"); - assert_eq!(collected, data); - assert_eq!( - checksum, - handle.index.find("interop.bin").expect("find").checksum - ); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_write_file_large() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 12 * 1024 * 1024); - - let data: Vec = (0..5_000_000).map(|i| (i % 251) as u8).collect(); - let file_path = write_temp_file(&dir, "large.bin", &data); - - use crate::core::streaming::CHUNK_SIZE; - let file_data = std::fs::read(&file_path).expect("read file"); - let chunks: Vec> = file_data.chunks(CHUNK_SIZE).map(|c| c.to_vec()).collect(); - vault_write_stream( - &mut handle, - "large.bin".into(), - file_data.len() as u64, - chunks.into_iter(), - None, - ) - .expect("write stream"); - - let readback = vault_read(&mut handle, "large.bin".into()).expect("read").data; - assert_eq!(readback.len(), data.len()); - assert_eq!(readback, data); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_write_file_empty() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - let file_path = write_temp_file(&dir, "empty.bin", &[]); - - use crate::core::streaming::CHUNK_SIZE; - let file_data = std::fs::read(&file_path).expect("read file"); - let chunks: Vec> = file_data.chunks(CHUNK_SIZE).map(|c| c.to_vec()).collect(); - vault_write_stream(&mut handle, "empty.bin".into(), 0, chunks.into_iter(), None) - .expect("write stream"); - - let readback = vault_read(&mut handle, "empty.bin".into()).expect("read").data; - assert!(readback.is_empty()); - - vault_close(handle).expect("close"); -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -// -- Key Rotation ----------------------------------------------------------- - -#[test] -fn test_rotate_key_basic() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None, None).expect("write a"); - vault_write(&mut handle, "b.txt".into(), b"world".to_vec(), None, None).expect("write b"); - - let mut handle = vault_rotate_key(handle, test_key2()).expect("rotate"); - - assert_eq!( - vault_read(&mut handle, "a.txt".into()).expect("read a").data, - b"hello" - ); - assert_eq!( - vault_read(&mut handle, "b.txt".into()).expect("read b").data, - b"world" - ); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_rotate_key_old_key_fails() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = vault_path(&dir); - - { - let mut handle = vault_create(path.clone(), test_key(), "aes-256-gcm".into(), 1_048_576) - .expect("create"); - vault_write( - &mut handle, - "secret.txt".into(), - b"top secret".to_vec(), - None, - None, - ) - .expect("write"); - let handle = vault_rotate_key(handle, test_key2()).expect("rotate"); - vault_close(handle).expect("close"); - } - - // Old key must be rejected - assert!(vault_open(path.clone(), test_key()).is_err()); - - // New key must still work and data must be intact - let mut handle = vault_open(path, test_key2()).expect("open with new key"); - assert_eq!( - vault_read(&mut handle, "secret.txt".into()).expect("read").data, - b"top secret" - ); - vault_close(handle).expect("close"); -} - -#[test] -fn test_rotate_key_streaming_segment_survives() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 2 * 1024 * 1024); - - let data = vec![0x42u8; 200_000]; // spans multiple chunks - stream_write_chunks(&mut handle, "video.bin", &data, 4096).expect("stream write"); - - let mut handle = vault_rotate_key(handle, test_key2()).expect("rotate"); - - let readback = vault_read(&mut handle, "video.bin".into()).expect("read after rotate").data; - assert_eq!(readback, data); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_rotate_key_compressed_segment_survives() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - let data = b"compressible repeated payload ".repeat(100); - let config = CompressionConfig { - algorithm: CompressionAlgorithm::Zstd, - level: None, - }; - vault_write(&mut handle, "data.bin".into(), data.clone(), Some(config), None).expect("write"); - - let mut handle = vault_rotate_key(handle, test_key2()).expect("rotate"); - - let readback = vault_read(&mut handle, "data.bin".into()).expect("read after rotate").data; - assert_eq!(readback, data); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_rotate_key_checksum_preserved() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - vault_write( - &mut handle, - "doc.txt".into(), - b"checksum test data".to_vec(), - None, - None, - ) - .expect("write"); - - let checksum_before = handle.index.find("doc.txt").expect("find before").checksum; - - let handle = vault_rotate_key(handle, test_key2()).expect("rotate"); - - let checksum_after = handle.index.find("doc.txt").expect("find after").checksum; - - assert_eq!( - checksum_before, checksum_after, - "BLAKE3 checksum must be identical before and after rotation" - ); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_rotate_key_crash_recovery_rotating_cleaned_up() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = vault_path(&dir); - - { - let handle = vault_create(path.clone(), test_key(), "aes-256-gcm".into(), 1_048_576) - .expect("create"); - vault_close(handle).expect("close"); - } - - // Plant a stale .rotating file to simulate a crash mid-rotation. - let rotating_path = format!("{path}.rotating"); - std::fs::write(&rotating_path, b"stale junk").expect("plant stale file"); - assert!(std::path::Path::new(&rotating_path).exists()); - - // vault_open must silently remove the orphan and succeed normally. - let handle = vault_open(path, test_key()).expect("open after simulated crash"); - vault_close(handle).expect("close"); - - assert!( - !std::path::Path::new(&rotating_path).exists(), - ".rotating file must be cleaned up by vault_open" - ); -} - -#[test] -fn test_rotate_key_empty_vault() { - let dir = tempfile::tempdir().expect("tempdir"); - let handle = create_test_vault(&dir, 1_048_576); - - // Rotate with no segments written at all. - let handle = vault_rotate_key(handle, test_key2()).expect("rotate empty vault"); - - assert!( - vault_list(&handle).is_empty(), - "rotated empty vault must have no segments" - ); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_rotate_key_chacha20() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir - .path() - .join("chacha.vault") - .to_str() - .expect("path") - .to_string(); - - let mut handle = - vault_create(path, test_key(), "chacha20-poly1305".into(), 1_048_576).expect("create"); - vault_write( - &mut handle, - "msg.txt".into(), - b"chacha payload".to_vec(), - None, - None, - ) - .expect("write"); - - let mut handle = vault_rotate_key(handle, test_key2()).expect("rotate chacha20 vault"); - - let readback = vault_read(&mut handle, "msg.txt".into()).expect("read after rotate").data; - assert_eq!(readback, b"chacha payload"); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_rotate_key_multiple_rotations() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - vault_write( - &mut handle, - "doc.txt".into(), - b"original data".to_vec(), - None, - None, - ) - .expect("write"); - - // First rotation: test_key → new_key - let handle = vault_rotate_key(handle, test_key2()).expect("first rotation"); - // Second rotation: new_key → wrong_key - let mut handle = vault_rotate_key(handle, wrong_key()).expect("second rotation"); - - let readback = vault_read(&mut handle, "doc.txt".into()).expect("read after two rotations").data; - assert_eq!(readback, b"original data"); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_rotate_key_empty_new_key_rejected() { - let dir = tempfile::tempdir().expect("tempdir"); - let handle = create_test_vault(&dir, 1_048_576); - - let result = vault_rotate_key(handle, vec![]); - assert!( - result.is_err(), - "rotation with an empty new key must return an error" - ); -} - -// -- Export ---------------------------------------------------------------- - -fn wrapping_key() -> Vec { - vec![0xCC; 32] -} - -fn export_path(dir: &tempfile::TempDir) -> String { - dir.path() - .join("export.mvex") - .to_str() - .expect("path") - .to_string() -} - -/// Parse an exported `.mvex` archive, returning (header, wrapped_key, records, trailer_checksum). -fn parse_archive( - path: &str, -) -> ( - crate::core::evfs::archive::ArchiveHeader, - Vec, - Vec, - [u8; 32], -) { - use crate::core::evfs::archive::*; - let data = std::fs::read(path).expect("read archive"); - assert!(data.len() >= ARCHIVE_HEADER_SIZE + WRAPPED_KEY_SIZE + ARCHIVE_TRAILER_SIZE); - - let header = ArchiveHeader::from_bytes( - data[..ARCHIVE_HEADER_SIZE] - .try_into() - .expect("header slice"), - ) - .expect("parse header"); - - let wk_start = ARCHIVE_HEADER_SIZE; - let wrapped_key = data[wk_start..wk_start + WRAPPED_KEY_SIZE].to_vec(); - - let mut pos = wk_start + WRAPPED_KEY_SIZE; - let mut records = Vec::new(); - - for _ in 0..header.segment_count { - let (name, compression, checksum, data_len, hdr_size) = - SegmentRecord::read_header(&data[pos..]).expect("parse record header"); - pos += hdr_size; - let enc_data = data[pos..pos + data_len as usize].to_vec(); - pos += data_len as usize; - records.push(SegmentRecord { - name, - compression, - checksum, - encrypted_data: enc_data, - }); - } - - let trailer_bytes: [u8; ARCHIVE_TRAILER_SIZE] = data[pos..pos + ARCHIVE_TRAILER_SIZE] - .try_into() - .expect("trailer slice"); - let trailer = ArchiveTrailer::from_bytes(&trailer_bytes).expect("parse trailer"); - - // Verify BLAKE3 trailer covers everything before the trailer - let computed = blake3::hash(&data[..pos]); - assert_eq!( - trailer.checksum, - <[u8; 32]>::from(computed), - "trailer checksum mismatch" - ); - - (header, wrapped_key, records, trailer.checksum) -} - -/// Unwrap the export key and decrypt a segment record. -fn decrypt_record( - record: &crate::core::evfs::archive::SegmentRecord, - wrapped_key: &[u8], - wk: &[u8], - algorithm: crate::core::format::Algorithm, -) -> Vec { - use crate::core::evfs::archive::KEY_WRAP_AAD; - use crate::core::evfs::segment; - - let export_key = - segment::aead_decrypt_with_stored_nonce(wk, wrapped_key, KEY_WRAP_AAD, algorithm) - .expect("unwrap export key"); - segment::aead_decrypt_with_stored_nonce( - &export_key, - &record.encrypted_data, - record.name.as_bytes(), - algorithm, - ) - .expect("decrypt record") -} - -// -- Archive format unit tests --------------------------------------------- - -#[test] -fn test_archive_header_roundtrip() { - use crate::core::evfs::archive::*; - - let header = ArchiveHeader::new(0x01, 42); - let bytes = header.to_bytes(); - let parsed = ArchiveHeader::from_bytes(&bytes).expect("parse"); - assert_eq!(parsed.version, ARCHIVE_VERSION); - assert_eq!(parsed.algorithm, 0x01); - assert_eq!(parsed.segment_count, 42); - assert_eq!(parsed.flags, 0); -} - -#[test] -fn test_archive_header_bad_magic_rejected() { - use crate::core::evfs::archive::*; - - let mut bytes = ArchiveHeader::new(0x01, 1).to_bytes(); - bytes[0] = b'X'; // corrupt magic - assert!(ArchiveHeader::from_bytes(&bytes).is_err()); -} - -#[test] -fn test_archive_header_bad_version_rejected() { - use crate::core::evfs::archive::*; - - let mut bytes = ArchiveHeader::new(0x01, 1).to_bytes(); - bytes[4] = 99; // unsupported version - assert!(ArchiveHeader::from_bytes(&bytes).is_err()); -} - -#[test] -fn test_archive_trailer_roundtrip() { - use crate::core::evfs::archive::*; - - let checksum = [0xAB; 32]; - let trailer = ArchiveTrailer { checksum }; - let bytes = trailer.to_bytes(); - let parsed = ArchiveTrailer::from_bytes(&bytes).expect("parse"); - assert_eq!(parsed.checksum, checksum); -} - -#[test] -fn test_archive_trailer_bad_magic_rejected() { - use crate::core::evfs::archive::*; - - let mut bytes = (ArchiveTrailer { checksum: [0; 32] }).to_bytes(); - bytes[35] = b'Z'; // corrupt reverse magic - assert!(ArchiveTrailer::from_bytes(&bytes).is_err()); -} - -#[test] -fn test_segment_record_header_roundtrip() { - use crate::core::evfs::archive::*; - - let record = SegmentRecord { - name: "hello.txt".into(), - compression: 0x01, - checksum: [0xDD; 32], - encrypted_data: vec![0xFF; 100], - }; - let header = record.write_header().expect("write header"); - let (name, comp, cksum, data_len, consumed) = - SegmentRecord::read_header(&header).expect("read header"); - assert_eq!(name, "hello.txt"); - assert_eq!(comp, 0x01); - assert_eq!(cksum, [0xDD; 32]); - assert_eq!(data_len, 100); - assert_eq!(consumed, header.len()); -} - -#[test] -fn test_wrapped_key_size() { - use crate::core::evfs::archive::WRAPPED_KEY_SIZE; - // nonce(12) + ciphertext(32) + tag(16) = 60 - assert_eq!(WRAPPED_KEY_SIZE, 60); -} - -#[test] -fn test_archive_trailer_size() { - use crate::core::evfs::archive::ARCHIVE_TRAILER_SIZE; - // blake3(32) + reverse_magic(4) = 36 - assert_eq!(ARCHIVE_TRAILER_SIZE, 36); -} - -// -- Export integration tests --------------------------------------------- - -#[test] -fn test_export_produces_valid_archive() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None, None).expect("write"); - vault_write(&mut handle, "b.txt".into(), b"world".to_vec(), None, None).expect("write"); - - let epath = export_path(&dir); - vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); - - let (header, wrapped_key, records, _) = parse_archive(&epath); - assert_eq!(header.segment_count, 2); - assert_eq!(header.version, 1); - assert_eq!(records.len(), 2); - - // Decrypt and verify contents - let algo = crate::core::format::Algorithm::AesGcm; - for record in &records { - let plaintext = decrypt_record(record, &wrapped_key, &wrapping_key(), algo); - match record.name.as_str() { - "a.txt" => assert_eq!(plaintext, b"hello"), - "b.txt" => assert_eq!(plaintext, b"world"), - other => panic!("unexpected segment: {other}"), - } - // Verify BLAKE3 checksum in record matches plaintext - let expected = crate::core::evfs::segment::compute_checksum(&plaintext); - assert_eq!(record.checksum, expected); - } - - vault_close(handle).expect("close"); -} - -#[test] -fn test_export_empty_vault() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - let epath = export_path(&dir); - vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); - - let (header, _, records, _) = parse_archive(&epath); - assert_eq!(header.segment_count, 0); - assert!(records.is_empty()); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_export_with_streaming_segments() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 4_194_304); - - // Write a streaming segment (> 64KB to trigger chunking) - let data: Vec = (0..200_000).map(|i| (i % 251) as u8).collect(); - let chunks: Vec> = data.chunks(65536).map(|c| c.to_vec()).collect(); - vault_write_stream( - &mut handle, - "big.bin".into(), - data.len() as u64, - chunks.into_iter(), - None, - ) - .expect("stream write"); - - // Also write a monolithic segment - vault_write(&mut handle, "small.txt".into(), b"tiny".to_vec(), None, None).expect("write"); - - let epath = export_path(&dir); - vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); - - let (header, wrapped_key, records, _) = parse_archive(&epath); - assert_eq!(header.segment_count, 2); - - let algo = crate::core::format::Algorithm::AesGcm; - for record in &records { - let plaintext = decrypt_record(record, &wrapped_key, &wrapping_key(), algo); - match record.name.as_str() { - "big.bin" => assert_eq!(plaintext, data), - "small.txt" => assert_eq!(plaintext, b"tiny"), - other => panic!("unexpected segment: {other}"), - } - } - - vault_close(handle).expect("close"); -} - -#[test] -fn test_export_with_compressed_segments() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - let data = b"compress me please ".repeat(100); - let config = CompressionConfig { - algorithm: CompressionAlgorithm::Zstd, - level: None, - }; - vault_write( - &mut handle, - "compressed.txt".into(), - data.clone(), - Some(config), - None, - ) - .expect("write"); - - let epath = export_path(&dir); - vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); - - let (_, wrapped_key, records, _) = parse_archive(&epath); - assert_eq!(records.len(), 1); - - let algo = crate::core::format::Algorithm::AesGcm; - let plaintext = decrypt_record(&records[0], &wrapped_key, &wrapping_key(), algo); - assert_eq!(plaintext, data); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_export_does_not_modify_vault() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - vault_write(&mut handle, "a.txt".into(), b"data-A".to_vec(), None, None).expect("write"); - vault_write(&mut handle, "b.txt".into(), b"data-B".to_vec(), None, None).expect("write"); - - // Snapshot state before export - let names_before: Vec = vault_list(&handle).into_iter().collect(); - let health_before = vault_health(&handle); - - let epath = export_path(&dir); - vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); - - // Verify vault unchanged - let names_after: Vec = vault_list(&handle).into_iter().collect(); - let health_after = vault_health(&handle); - assert_eq!(names_before, names_after); - assert_eq!(health_before, health_after); - - // Verify data still readable - let a = vault_read(&mut handle, "a.txt".into()).expect("read a").data; - assert_eq!(a, b"data-A"); - let b = vault_read(&mut handle, "b.txt".into()).expect("read b").data; - assert_eq!(b, b"data-B"); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_export_wrong_wrapping_key_cannot_decrypt() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - vault_write( - &mut handle, - "secret.txt".into(), - b"top secret".to_vec(), - None, - None, - ) - .expect("write"); - - let epath = export_path(&dir); - vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); - - let (_, wrapped_key, _records, _) = parse_archive(&epath); - let algo = crate::core::format::Algorithm::AesGcm; - - // Try to unwrap with wrong key - let wrong_wk = vec![0xEE; 32]; - let result = crate::core::evfs::segment::aead_decrypt_with_stored_nonce( - &wrong_wk, - &wrapped_key, - crate::core::evfs::archive::KEY_WRAP_AAD, - algo, - ); - assert!( - result.is_err(), - "wrong wrapping key must fail to unwrap export key" - ); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_export_invalid_wrapping_key_length_rejected() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - let epath = export_path(&dir); - let result = vault_export(&mut handle, vec![0xAA; 16], epath); // 16 bytes, not 32 - assert!(result.is_err()); - - vault_close(handle).expect("close"); -} - -// -- Import integration tests --------------------------------------------- - -fn import_dest_path(dir: &tempfile::TempDir) -> String { - dir.path() - .join("imported.vault") - .to_str() - .expect("path") - .to_string() -} - -#[test] -fn test_import_full_roundtrip() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 2_097_152); - - vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None, None).expect("write"); - vault_write(&mut handle, "b.txt".into(), b"world".to_vec(), None, None).expect("write"); - - let epath = export_path(&dir); - vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); - vault_close(handle).expect("close"); - - let dest_path = import_dest_path(&dir); - let mut imported = vault_import( - epath, - wrapping_key(), - dest_path.clone(), - test_key2(), - "aes-256-gcm".into(), - 2_097_152, - ) - .expect("import"); - - assert_eq!( - vault_read(&mut imported, "a.txt".into()).expect("read a").data, - b"hello" - ); - assert_eq!( - vault_read(&mut imported, "b.txt".into()).expect("read b").data, - b"world" - ); - - vault_close(imported).expect("close"); -} - -#[test] -fn test_import_wrong_wrapping_key_fails() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None, None).expect("write"); - let epath = export_path(&dir); - vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); - vault_close(handle).expect("close"); - - let dest_path = import_dest_path(&dir); - let wrong_wk = vec![0xEE; 32]; - let result = vault_import( - epath, - wrong_wk, - dest_path, - test_key2(), - "aes-256-gcm".into(), - 1_048_576, - ); - - assert!(matches!(result, Err(CryptoError::ImportFailed(_)))); -} - -#[test] -fn test_import_truncated_archive() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None, None).expect("write"); - let epath = export_path(&dir); - vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); - vault_close(handle).expect("close"); - - // Truncate the archive midway - let mut archive = std::fs::read(&epath).expect("read"); - archive.truncate(archive.len() - 10); - std::fs::write(&epath, archive).expect("write"); - - let dest_path = import_dest_path(&dir); - let result = vault_import( - epath, - wrapping_key(), - dest_path.clone(), - test_key2(), - "aes-256-gcm".into(), - 1_048_576, - ); - - assert!(matches!(result, Err(CryptoError::ImportFailed(_)))); - assert!( - !std::path::Path::new(&dest_path).exists(), - "Partial vault must be deleted" - ); -} - -#[test] -fn test_import_tampered_segment_data() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None, None).expect("write"); - let epath = export_path(&dir); - vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); - vault_close(handle).expect("close"); - - // Tamper with the encrypted data part of the segment record - let mut archive = std::fs::read(&epath).expect("read"); - let pos = archive.len() - 36 - 10; // slightly before the trailer - archive[pos] ^= 0xFF; - std::fs::write(&epath, archive).expect("write"); - - let dest_path = import_dest_path(&dir); - let result = vault_import( - epath, - wrapping_key(), - dest_path.clone(), - test_key2(), - "aes-256-gcm".into(), - 1_048_576, - ); - - assert!(matches!(result, Err(CryptoError::ImportFailed(_)))); -} - -#[test] -fn test_import_tampered_trailer_checksum() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None, None).expect("write"); - let epath = export_path(&dir); - vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); - vault_close(handle).expect("close"); - - // Tamper with the trailer checksum - let mut archive = std::fs::read(&epath).expect("read"); - let pos = archive.len() - 36 + 5; // within the 32 byte checksum - archive[pos] ^= 0xFF; - std::fs::write(&epath, archive).expect("write"); - - let dest_path = import_dest_path(&dir); - let result = vault_import( - epath, - wrapping_key(), - dest_path.clone(), - test_key2(), - "aes-256-gcm".into(), - 1_048_576, - ); - - assert!(matches!(result, Err(CryptoError::ImportFailed(_)))); -} - -#[test] -fn test_import_insufficient_capacity() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 5_000_000); - vault_write(&mut handle, "big.txt".into(), vec![0xBB; 2_000_000], None, None).expect("write"); - let epath = export_path(&dir); - vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); - vault_close(handle).expect("close"); - - let dest_path = import_dest_path(&dir); - let result = vault_import( - epath, - wrapping_key(), - dest_path.clone(), - test_key2(), - "aes-256-gcm".into(), - 1_048_576, // Bound restriction triggers EVFS index allocation rejection - ); - - assert!(matches!(result, Err(CryptoError::VaultFull { .. }))); -} - -#[test] -fn test_import_streaming_segment() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 4_194_304); - - let data: Vec = (0..200_000).map(|i| (i % 251) as u8).collect(); - let chunks: Vec> = data.chunks(65536).map(|c| c.to_vec()).collect(); - vault_write_stream( - &mut handle, - "stream.bin".into(), - data.len() as u64, - chunks.into_iter(), - None, - ) - .expect("stream write"); - - let epath = export_path(&dir); - vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); - vault_close(handle).expect("close"); - - let dest_path = import_dest_path(&dir); - let mut imported = vault_import( - epath, - wrapping_key(), - dest_path.clone(), - test_key2(), - "aes-256-gcm".into(), - 4_194_304, - ) - .expect("import"); - - let readback = vault_read(&mut imported, "stream.bin".into()).expect("read").data; - assert_eq!(readback, data); - - vault_close(imported).expect("close"); -} - -#[test] -fn test_import_compressed_segment() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - let data = b"compress me please ".repeat(100); - let config = CompressionConfig { - algorithm: CompressionAlgorithm::Zstd, - level: None, - }; - vault_write( - &mut handle, - "compressed.txt".into(), - data.clone(), - Some(config), - None, - ) - .expect("write"); - - let epath = export_path(&dir); - vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); - vault_close(handle).expect("close"); - - let dest_path = import_dest_path(&dir); - let mut imported = vault_import( - epath, - wrapping_key(), - dest_path.clone(), - test_key2(), - "aes-256-gcm".into(), - 1_048_576, - ) - .expect("import"); - - let entry = imported.index.find("compressed.txt").expect("find"); - assert_eq!(entry.compression, CompressionAlgorithm::Zstd); - - let readback = vault_read(&mut imported, "compressed.txt".into()).expect("read").data; - assert_eq!(readback, data); - - vault_close(imported).expect("close"); -} - -#[test] -fn test_import_chacha20() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = vault_create( - vault_path(&dir), - test_key(), - "chacha20-poly1305".into(), - 1_048_576, - ) - .expect("create"); - - vault_write(&mut handle, "c.txt".into(), b"chacha".to_vec(), None, None).expect("write"); - let epath = export_path(&dir); - vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); - vault_close(handle).expect("close"); - - let dest_path = import_dest_path(&dir); - let mut imported = vault_import( - epath, - wrapping_key(), - dest_path.clone(), - test_key2(), - "chacha20-poly1305".into(), - 1_048_576, - ) - .expect("import"); - - assert_eq!( - imported.algorithm, - crate::core::format::Algorithm::ChaCha20Poly1305 - ); - assert_eq!( - vault_read(&mut imported, "c.txt".into()).expect("read c").data, - b"chacha" - ); - - vault_close(imported).expect("close"); -} - -// -- Index caching / dirty flag ------------------------------------------ - -#[test] -fn test_index_dirty_false_after_create() { - let dir = tempfile::tempdir().expect("tempdir"); - let handle = create_test_vault(&dir, 1_048_576); - assert!(!handle.index_dirty); - vault_close(handle).expect("close"); -} - -#[test] -fn test_index_dirty_false_after_open() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = vault_path(&dir); - let handle = - vault_create(path.clone(), test_key(), "aes-256-gcm".into(), 1_048_576).expect("create"); - vault_close(handle).expect("close"); - - let handle = vault_open(path, test_key()).expect("open"); - assert!(!handle.index_dirty); - vault_close(handle).expect("close"); -} - -#[test] -fn test_index_clean_after_write() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None, None).expect("write"); - // After write completes, dirty flag should be cleared (flushed) - assert!(!handle.index_dirty); - vault_close(handle).expect("close"); -} - -#[test] -fn test_index_clean_after_delete() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None, None).expect("write"); - vault_delete(&mut handle, "a.txt".into()).expect("delete"); - assert!(!handle.index_dirty); - vault_close(handle).expect("close"); -} - -#[test] -fn test_read_does_not_set_dirty() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None, None).expect("write"); - assert!(!handle.index_dirty); - - let _ = vault_read(&mut handle, "a.txt".into()).expect("read").data; - assert!(!handle.index_dirty); - - let _ = vault_list(&handle); - assert!(!handle.index_dirty); - - let _ = vault_capacity(&handle); - assert!(!handle.index_dirty); - - let _ = vault_health(&handle); - assert!(!handle.index_dirty); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_vault_flush_noop_when_clean() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - assert!(!handle.index_dirty); - // Should be a no-op — no error, no disk I/O - vault_flush(&mut handle).expect("flush clean"); - assert!(!handle.index_dirty); - vault_close(handle).expect("close"); -} - -#[test] -fn test_vault_flush_persists_and_survives_reopen() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = vault_path(&dir); - let mut handle = - vault_create(path.clone(), test_key(), "aes-256-gcm".into(), 1_048_576).expect("create"); - vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None, None).expect("write"); - // Explicit flush then close — data must survive reopen - vault_flush(&mut handle).expect("flush"); - assert!(!handle.index_dirty); - vault_close(handle).expect("close"); - - let mut reopened = vault_open(path, test_key()).expect("reopen"); - assert_eq!( - vault_read(&mut reopened, "a.txt".into()).expect("read").data, - b"hello" - ); - vault_close(reopened).expect("close"); -} - -#[test] -fn test_vault_close_flushes_dirty_and_persists() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = vault_path(&dir); - let mut handle = - vault_create(path.clone(), test_key(), "aes-256-gcm".into(), 1_048_576).expect("create"); - vault_write(&mut handle, "b.txt".into(), b"world".to_vec(), None, None).expect("write"); - // Simulate a dirty handle at close time by manually setting the flag. - // This exercises the vault_close dirty-flush path. - handle.index_dirty = true; - vault_close(handle).expect("close"); - - let mut reopened = vault_open(path, test_key()).expect("reopen"); - assert_eq!( - vault_read(&mut reopened, "b.txt".into()).expect("read").data, - b"world" - ); - vault_close(reopened).expect("close"); -} - -#[test] -fn test_index_dirty_false_after_rotate_key() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = vault_path(&dir); - let handle = - vault_create(path.clone(), test_key(), "aes-256-gcm".into(), 1_048_576).expect("create"); - let rotated = vault_rotate_key(handle, test_key2()).expect("rotate"); - assert!(!rotated.index_dirty); - vault_close(rotated).expect("close"); -} - -#[test] -fn test_index_clean_after_defragment() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - vault_write(&mut handle, "a.txt".into(), b"aaa".to_vec(), None, None).expect("write a"); - vault_write(&mut handle, "b.txt".into(), b"bbb".to_vec(), None, None).expect("write b"); - vault_delete(&mut handle, "a.txt".into()).expect("delete a"); - vault_defragment(&mut handle).expect("defrag"); - assert!(!handle.index_dirty); - vault_close(handle).expect("close"); -} - -#[test] -fn test_index_clean_after_resize() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - vault_resize(&mut handle, 2_097_152).expect("grow"); - assert!(!handle.index_dirty); - vault_resize(&mut handle, 1_048_576).expect("shrink"); - assert!(!handle.index_dirty); - vault_close(handle).expect("close"); -} - -// -- Parallel reads ------------------------------------------------------- - -// Compile-time assertion: VaultHandle must be Sync for &VaultHandle across rayon threads -#[allow(dead_code)] -trait AssertSync: Sync {} -impl AssertSync for super::types::VaultHandle {} - -#[test] -fn test_parallel_read_matches_sequential() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 4_194_304); - - vault_write(&mut handle, "a.txt".into(), b"alpha".to_vec(), None, None).expect("write a"); - vault_write(&mut handle, "b.txt".into(), b"bravo".to_vec(), None, None).expect("write b"); - vault_write(&mut handle, "c.txt".into(), b"charlie".to_vec(), None, None).expect("write c"); - - let seq_a = vault_read(&mut handle, "a.txt".into()).expect("read a").data; - let seq_b = vault_read(&mut handle, "b.txt".into()).expect("read b").data; - let seq_c = vault_read(&mut handle, "c.txt".into()).expect("read c").data; - - let results = vault_read_parallel( - &handle, - vec!["a.txt".into(), "b.txt".into(), "c.txt".into()], - ); - - assert_eq!(results.len(), 3); - assert!(results[0].error.is_none()); - assert_eq!(results[0].data, seq_a); - assert_eq!(results[1].data, seq_b); - assert_eq!(results[2].data, seq_c); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_parallel_read_10_segments() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 4_194_304); - - let mut expected = Vec::new(); - for i in 0..10u8 { - let name = format!("seg_{i}"); - let data = vec![i; 1024]; - vault_write(&mut handle, name, data.clone(), None, None).expect("write"); - expected.push(data); - } - - let names: Vec = (0..10).map(|i| format!("seg_{i}")).collect(); - let results = vault_read_parallel(&handle, names); - - assert_eq!(results.len(), 10); - for (i, sr) in results.iter().enumerate() { - assert!(sr.error.is_none(), "segment {i} had error: {:?}", sr.error); - assert_eq!(sr.name, format!("seg_{i}")); - assert_eq!(sr.data, expected[i]); - } - - vault_close(handle).expect("close"); -} - -#[test] -fn test_parallel_read_mixed_monolithic_and_streaming() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 4_194_304); - - let mono_data = vec![0xAA; 512]; - vault_write(&mut handle, "mono".into(), mono_data.clone(), None, None).expect("write mono"); - - let stream_data: Vec = (0..=255u8).cycle().take(100_000).collect(); - let stream_iter = stream_data.chunks(8192).map(|c| c.to_vec()); - vault_write_stream( - &mut handle, - "stream".into(), - stream_data.len() as u64, - stream_iter, - None, - ) - .expect("write stream"); - - let results = vault_read_parallel(&handle, vec!["mono".into(), "stream".into()]); - - assert_eq!(results.len(), 2); - assert!(results[0].error.is_none()); - assert_eq!(results[0].data, mono_data); - assert!(results[1].error.is_none()); - assert_eq!(results[1].data, stream_data); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_parallel_read_missing_segment() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - vault_write(&mut handle, "exists".into(), vec![1, 2, 3], None, None).expect("write"); - - let results = vault_read_parallel(&handle, vec!["exists".into(), "missing".into()]); - - assert_eq!(results.len(), 2); - assert!(results[0].error.is_none()); - assert_eq!(results[0].data, vec![1, 2, 3]); - assert!(results[1].error.is_some()); - assert!( - results[1] - .error - .as_ref() - .expect("error") - .contains("missing"), - "error should mention segment name" - ); - assert!(results[1].data.is_empty()); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_parallel_read_empty_names() { - let dir = tempfile::tempdir().expect("tempdir"); - let handle = create_test_vault(&dir, 1_048_576); - - let results = vault_read_parallel(&handle, vec![]); - assert!(results.is_empty()); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_parallel_read_single_name() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - vault_write(&mut handle, "only".into(), b"solo".to_vec(), None, None).expect("write"); - - let seq = vault_read(&mut handle, "only".into()).expect("sequential").data; - let results = vault_read_parallel(&handle, vec!["only".into()]); - - assert_eq!(results.len(), 1); - assert!(results[0].error.is_none()); - assert_eq!(results[0].data, seq); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_parallel_read_preserves_order() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 4_194_304); - - for i in 0..10u8 { - vault_write(&mut handle, format!("seg_{i}"), vec![i; 256], None, None).expect("write"); - } - - let names: Vec = (0..10).rev().map(|i| format!("seg_{i}")).collect(); - let results = vault_read_parallel(&handle, names.clone()); - - assert_eq!(results.len(), 10); - for (i, sr) in results.iter().enumerate() { - assert!(sr.error.is_none()); - assert_eq!(sr.name, names[i]); - } - - vault_close(handle).expect("close"); -} - -#[test] -fn test_parallel_read_chacha20() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = vault_path(&dir); - - let mut handle = - vault_create(path, test_key(), "chacha20-poly1305".into(), 4_194_304).expect("create"); - - vault_write(&mut handle, "x".into(), b"chacha-data".to_vec(), None, None).expect("write"); - vault_write(&mut handle, "y".into(), b"poly1305-data".to_vec(), None, None).expect("write"); - - let results = vault_read_parallel(&handle, vec!["x".into(), "y".into()]); - - assert!(results[0].error.is_none()); - assert_eq!(results[0].data, b"chacha-data"); - assert!(results[1].error.is_none()); - assert_eq!(results[1].data, b"poly1305-data"); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_parallel_read_fallback_sequential() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 4_194_304); - - vault_write(&mut handle, "a".into(), b"alpha".to_vec(), None, None).expect("write a"); - vault_write(&mut handle, "b".into(), b"bravo".to_vec(), None, None).expect("write b"); - - // Force no-mmap fallback - handle.mmap = None; - - let results = vault_read_parallel(&handle, vec!["a".into(), "b".into()]); - - assert_eq!(results.len(), 2); - assert!(results[0].error.is_none()); - assert_eq!(results[0].data, b"alpha"); - assert!(results[1].error.is_none()); - assert_eq!(results[1].data, b"bravo"); - - vault_close(handle).expect("close"); -} - -// -- Rename Segment Tests ----------------------------------------------- - -#[test] -fn test_rename_read_success() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - vault_write(&mut handle, "old.txt".into(), b"data".to_vec(), None, None).expect("write"); - vault_rename_segment(&mut handle, "old.txt".into(), "new.txt".into()).expect("rename"); - - let data = vault_read(&mut handle, "new.txt".into()).expect("read new").data; - assert_eq!(data, b"data"); - - let result = vault_read(&mut handle, "old.txt".into()); - assert!(matches!(result, Err(CryptoError::SegmentNotFound(_)))); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_rename_duplicate_name_fails() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - vault_write(&mut handle, "a.txt".into(), b"a".to_vec(), None, None).expect("write a"); - vault_write(&mut handle, "b.txt".into(), b"b".to_vec(), None, None).expect("write b"); - - let result = vault_rename_segment(&mut handle, "a.txt".into(), "b.txt".into()); - assert!(matches!(result, Err(CryptoError::DuplicateSegment(_)))); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_rename_nonexistent_fails() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - let result = vault_rename_segment(&mut handle, "missing.txt".into(), "new.txt".into()); - assert!(matches!(result, Err(CryptoError::SegmentNotFound(_)))); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_rename_preserves_metadata() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - vault_write(&mut handle, "old.txt".into(), b"data".to_vec(), None, None).expect("write"); - let old_entry = handle.index.find("old.txt").expect("find").clone(); - - vault_rename_segment(&mut handle, "old.txt".into(), "new.txt".into()).expect("rename"); - let new_entry = handle.index.find("new.txt").expect("find"); - - assert_eq!(old_entry.offset, new_entry.offset); - assert_eq!(old_entry.size, new_entry.size); - assert_eq!(old_entry.generation, new_entry.generation); - assert_eq!(old_entry.checksum, new_entry.checksum); - assert_eq!(old_entry.compression, new_entry.compression); - assert_eq!(old_entry.chunk_count, new_entry.chunk_count); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_rename_multiple_sequence() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - vault_write(&mut handle, "1.txt".into(), b"sequence".to_vec(), None, None).expect("write"); - vault_rename_segment(&mut handle, "1.txt".into(), "2.txt".into()).expect("r1"); - vault_rename_segment(&mut handle, "2.txt".into(), "3.txt".into()).expect("r2"); - - assert!(handle.index.find("1.txt").is_none()); - assert!(handle.index.find("2.txt").is_none()); - - let data = vault_read(&mut handle, "3.txt".into()).expect("read").data; - assert_eq!(data, b"sequence"); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_rename_streaming_segment() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 2_097_152); - - let data = vec![0x77; 150_000]; - let chunks: Vec> = data.chunks(65536).map(|c| c.to_vec()).collect(); - vault_write_stream( - &mut handle, - "stream.bin".into(), - data.len() as u64, - chunks.into_iter(), - None, - ) - .expect("write stream"); - - vault_rename_segment( - &mut handle, - "stream.bin".into(), - "renamed_stream.bin".into(), - ) - .expect("rename"); - - // Interop read on streaming segment - let readback = vault_read(&mut handle, "renamed_stream.bin".into()).expect("read").data; - assert_eq!(readback, data); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_rename_and_defragment() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - vault_write(&mut handle, "a.txt".into(), vec![0xAA; 100], None, None).expect("A"); - vault_write(&mut handle, "b.txt".into(), vec![0xBB; 100], None, None).expect("B"); - vault_write(&mut handle, "c.txt".into(), vec![0xCC; 100], None, None).expect("C"); - - vault_delete(&mut handle, "b.txt".into()).expect("del B"); - vault_rename_segment(&mut handle, "c.txt".into(), "c_renamed.txt".into()).expect("rename"); - - vault_defragment(&mut handle).expect("defrag"); - - let data = vault_read(&mut handle, "c_renamed.txt".into()).expect("read").data; - assert_eq!(data, vec![0xCC; 100]); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_rename_chacha20() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = vault_path(&dir); - let mut handle = - vault_create(path, test_key(), "chacha20-poly1305".into(), 1_048_576).expect("create"); - - vault_write( - &mut handle, - "old_chacha.txt".into(), - b"chacha".to_vec(), - None, - None, - ) - .expect("write"); - vault_rename_segment( - &mut handle, - "old_chacha.txt".into(), - "new_chacha.txt".into(), - ) - .expect("rename"); - - let data = vault_read(&mut handle, "new_chacha.txt".into()).expect("read").data; - assert_eq!(data, b"chacha"); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_rename_to_same_name_is_noop() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - vault_write(&mut handle, "same.txt".into(), b"data".to_vec(), None, None).expect("write"); - vault_rename_segment(&mut handle, "same.txt".into(), "same.txt".into()).expect("noop rename"); - - let data = vault_read(&mut handle, "same.txt".into()).expect("read").data; - assert_eq!(data, b"data"); - - vault_close(handle).expect("close"); -} - -// -- Segment Metadata ------------------------------------------------------ - -#[test] -fn test_metadata_write_read_roundtrip() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - let mut meta = std::collections::HashMap::new(); - meta.insert("mime".to_string(), "text/plain".to_string()); - meta.insert("created".to_string(), "2026-04-08".to_string()); - - vault_write( - &mut handle, - "doc.txt".into(), - b"hello metadata".to_vec(), - None, - Some(meta.clone()), - ) - .expect("write"); - - let result = vault_read(&mut handle, "doc.txt".into()).expect("read"); - assert_eq!(result.data, b"hello metadata"); - assert_eq!(result.metadata, meta); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_metadata_none_returns_empty_map() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - vault_write(&mut handle, "plain.txt".into(), b"no meta".to_vec(), None, None).expect("write"); - - let result = vault_read(&mut handle, "plain.txt".into()).expect("read"); - assert_eq!(result.data, b"no meta"); - assert!(result.metadata.is_empty()); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_metadata_overwrite_replaces_old() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - let mut meta_v1 = std::collections::HashMap::new(); - meta_v1.insert("version".to_string(), "1".to_string()); - - vault_write( - &mut handle, - "doc.txt".into(), - b"v1".to_vec(), - None, - Some(meta_v1), - ) - .expect("write v1"); - - let mut meta_v2 = std::collections::HashMap::new(); - meta_v2.insert("version".to_string(), "2".to_string()); - meta_v2.insert("author".to_string(), "test".to_string()); - - vault_write( - &mut handle, - "doc.txt".into(), - b"v2".to_vec(), - None, - Some(meta_v2.clone()), - ) - .expect("write v2"); - - let result = vault_read(&mut handle, "doc.txt".into()).expect("read"); - assert_eq!(result.data, b"v2"); - assert_eq!(result.metadata, meta_v2); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_metadata_independent_per_segment() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - let mut meta_a = std::collections::HashMap::new(); - meta_a.insert("type".to_string(), "image".to_string()); - let mut meta_b = std::collections::HashMap::new(); - meta_b.insert("type".to_string(), "document".to_string()); - - vault_write(&mut handle, "a.png".into(), b"img".to_vec(), None, Some(meta_a.clone())) - .expect("write a"); - vault_write(&mut handle, "b.pdf".into(), b"pdf".to_vec(), None, Some(meta_b.clone())) - .expect("write b"); - - let result_a = vault_read(&mut handle, "a.png".into()).expect("read a"); - let result_b = vault_read(&mut handle, "b.pdf".into()).expect("read b"); - - assert_eq!(result_a.metadata, meta_a); - assert_eq!(result_b.metadata, meta_b); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_metadata_empty_keys_and_values() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - let mut meta = std::collections::HashMap::new(); - meta.insert("".to_string(), "empty key".to_string()); - meta.insert("empty_val".to_string(), "".to_string()); - - vault_write( - &mut handle, - "edge.bin".into(), - b"data".to_vec(), - None, - Some(meta.clone()), - ) - .expect("write"); - - let result = vault_read(&mut handle, "edge.bin".into()).expect("read"); - assert_eq!(result.metadata, meta); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_metadata_survives_close_reopen() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = vault_path(&dir); - - let mut meta = std::collections::HashMap::new(); - meta.insert("persist".to_string(), "yes".to_string()); - - { - let mut handle = - vault_create(path.clone(), test_key(), "aes-256-gcm".into(), 1_048_576).expect("create"); - vault_write( - &mut handle, - "persist.txt".into(), - b"durable".to_vec(), - None, - Some(meta.clone()), - ) - .expect("write"); - vault_close(handle).expect("close"); - } - - let mut handle = vault_open(path, test_key()).expect("reopen"); - let result = vault_read(&mut handle, "persist.txt".into()).expect("read"); - assert_eq!(result.data, b"durable"); - assert_eq!(result.metadata, meta); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_metadata_survives_defragment() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - let mut meta = std::collections::HashMap::new(); - meta.insert("tag".to_string(), "keep".to_string()); - - vault_write(&mut handle, "a.txt".into(), vec![0xAA; 200], None, None).expect("write a"); - vault_write( - &mut handle, - "b.txt".into(), - vec![0xBB; 200], - None, - Some(meta.clone()), - ) - .expect("write b"); - - vault_delete(&mut handle, "a.txt".into()).expect("delete a"); - vault_defragment(&mut handle).expect("defrag"); - - let result = vault_read(&mut handle, "b.txt".into()).expect("read b"); - assert_eq!(result.data, vec![0xBB; 200]); - assert_eq!(result.metadata, meta); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_metadata_survives_resize() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - let mut meta = std::collections::HashMap::new(); - meta.insert("resize".to_string(), "test".to_string()); - - vault_write( - &mut handle, - "doc.txt".into(), - b"data".to_vec(), - None, - Some(meta.clone()), - ) - .expect("write"); - - vault_resize(&mut handle, 2_097_152).expect("grow"); - - let result = vault_read(&mut handle, "doc.txt".into()).expect("read"); - assert_eq!(result.data, b"data"); - assert_eq!(result.metadata, meta); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_metadata_survives_key_rotation() { - let dir = tempfile::tempdir().expect("tempdir"); - - let mut meta = std::collections::HashMap::new(); - meta.insert("mime".to_string(), "application/pdf".to_string()); - - let mut handle = create_test_vault(&dir, 1_048_576); - vault_write( - &mut handle, - "file.pdf".into(), - b"pdf content".to_vec(), - None, - Some(meta.clone()), - ) - .expect("write"); - - let mut handle = vault_rotate_key(handle, test_key2()).expect("rotate"); - - let result = vault_read(&mut handle, "file.pdf".into()).expect("read"); - assert_eq!(result.data, b"pdf content"); - assert_eq!(result.metadata, meta); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_metadata_large_near_index_limit() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - let mut meta = std::collections::HashMap::new(); - for i in 0..100 { - meta.insert(format!("key_{i:03}"), format!("value_{i:03}_padding")); - } - - vault_write( - &mut handle, - "big_meta.bin".into(), - b"data".to_vec(), - None, - Some(meta.clone()), - ) - .expect("write"); - - let result = vault_read(&mut handle, "big_meta.bin".into()).expect("read"); - assert_eq!(result.data, b"data"); - assert_eq!(result.metadata, meta); - - vault_close(handle).expect("close"); -} - -#[test] -fn test_metadata_rename_preserves() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut handle = create_test_vault(&dir, 1_048_576); - - let mut meta = std::collections::HashMap::new(); - meta.insert("tag".to_string(), "renamed".to_string()); - - vault_write( - &mut handle, - "old_name.txt".into(), - b"data".to_vec(), - None, - Some(meta.clone()), - ) - .expect("write"); - - vault_rename_segment(&mut handle, "old_name.txt".into(), "new_name.txt".into()) - .expect("rename"); - - let result = vault_read(&mut handle, "new_name.txt".into()).expect("read"); - assert_eq!(result.data, b"data"); - assert_eq!(result.metadata, meta); - - vault_close(handle).expect("close"); -} diff --git a/rust/src/api/evfs/tests/allocator.rs b/rust/src/api/evfs/tests/allocator.rs new file mode 100644 index 0000000..6e92177 --- /dev/null +++ b/rust/src/api/evfs/tests/allocator.rs @@ -0,0 +1,167 @@ +use super::*; + +// -- Space reclamation (free list) -------------------------------------- + +#[test] +fn test_delete_returns_space_to_free_list() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write( + &mut handle, + "a.txt".into(), + b"some data here".to_vec(), + None, + None, + ) + .expect("write"); + let seg_size = handle.index.find("a.txt").expect("find").size; + + vault_delete(&mut handle, "a.txt".into()).expect("delete"); + + let cap = vault_capacity(&handle); + assert_eq!(cap.free_list_bytes, seg_size); + assert_eq!(cap.segment_count, 0); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_write_reuses_deleted_space() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + // Write A — note the offset and size + vault_write(&mut handle, "a.txt".into(), vec![0xAA; 200], None, None).expect("write A"); + let a_offset = handle.index.find("a.txt").expect("A").offset; + let a_size = handle.index.find("a.txt").expect("A").size; + + // Delete A + vault_delete(&mut handle, "a.txt".into()).expect("delete A"); + + // Write B (smaller) — should reuse A's space + vault_write(&mut handle, "b.txt".into(), vec![0xBB; 50], None, None).expect("write B"); + let b_offset = handle.index.find("b.txt").expect("B").offset; + let b_size = handle.index.find("b.txt").expect("B").size; + + assert_eq!(b_offset, a_offset); + assert!(b_size < a_size); + + // Free list should have leftover from A's region + let cap = vault_capacity(&handle); + assert_eq!(cap.free_list_bytes, a_size - b_size); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_write_after_delete_exact_fit() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + // Write and capture the encrypted size + vault_write(&mut handle, "a.txt".into(), vec![0xAA; 200], None, None).expect("write A"); + let a_offset = handle.index.find("a.txt").expect("A").offset; + let a_size = handle.index.find("a.txt").expect("A").size; + vault_delete(&mut handle, "a.txt".into()).expect("delete A"); + + // Write B with same plaintext size — encrypted size should match exactly + vault_write(&mut handle, "b.txt".into(), vec![0xBB; 200], None, None).expect("write B"); + let b_offset = handle.index.find("b.txt").expect("B").offset; + let b_size = handle.index.find("b.txt").expect("B").size; + + assert_eq!(b_offset, a_offset); + assert_eq!(b_size, a_size); + assert_eq!(vault_capacity(&handle).free_list_bytes, 0); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_overwrite_reclaims_then_allocates() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write(&mut handle, "doc.txt".into(), vec![0xAA; 500], None, None).expect("write big"); + let old_size = handle.index.find("doc.txt").expect("old").size; + + // Overwrite with smaller data + vault_write(&mut handle, "doc.txt".into(), vec![0xBB; 100], None, None).expect("overwrite small"); + let new_size = handle.index.find("doc.txt").expect("new").size; + + assert!(new_size < old_size); + // Free list should have leftover from reclaimed space + let cap = vault_capacity(&handle); + assert!(cap.free_list_bytes > 0 || cap.unallocated_bytes > 0); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_delete_multiple_merges_adjacent() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + // Write A, B, C contiguously + vault_write(&mut handle, "a.txt".into(), vec![0xAA; 100], None, None).expect("A"); + vault_write(&mut handle, "b.txt".into(), vec![0xBB; 100], None, None).expect("B"); + vault_write(&mut handle, "c.txt".into(), vec![0xCC; 100], None, None).expect("C"); + + let b_size = handle.index.find("b.txt").expect("B").size; + let c_size = handle.index.find("c.txt").expect("C").size; + + // Delete B then C — should merge into one free region + vault_delete(&mut handle, "b.txt".into()).expect("del B"); + vault_delete(&mut handle, "c.txt".into()).expect("del C"); + + assert_eq!(handle.index.free_regions.len(), 1); + assert_eq!(handle.index.free_regions[0].size, b_size + c_size); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_free_list_falls_back_to_append() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + // Write A (small), delete it + vault_write(&mut handle, "a.txt".into(), vec![0xAA; 50], None, None).expect("A"); + let a_size = handle.index.find("a.txt").expect("A").size; + vault_delete(&mut handle, "a.txt".into()).expect("del A"); + + // Write B (much larger) — won't fit in A's free region + vault_write(&mut handle, "b.txt".into(), vec![0xBB; 5000], None, None).expect("B"); + let b_offset = handle.index.find("b.txt").expect("B").offset; + + // B should be appended (offset > A's region) + assert!(b_offset >= a_size); + + // Free list should still have A's old region + let cap = vault_capacity(&handle); + assert_eq!(cap.free_list_bytes, a_size); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_capacity_reflects_free_list() { + let dir = tempfile::tempdir().expect("tempdir"); + let capacity = 1_048_576u64; + let mut handle = create_test_vault(&dir, capacity); + + vault_write(&mut handle, "a.txt".into(), vec![0xAA; 200], None, None).expect("A"); + vault_write(&mut handle, "b.txt".into(), vec![0xBB; 300], None, None).expect("B"); + + let cap = vault_capacity(&handle); + assert_eq!(cap.total_bytes, capacity); + assert_eq!(cap.segment_count, 2); + // used + free_list + unallocated should account for total capacity + assert_eq!( + cap.used_bytes + cap.free_list_bytes + cap.unallocated_bytes, + capacity + ); + + vault_close(handle).expect("close"); +} + diff --git a/rust/src/api/evfs/tests/compression.rs b/rust/src/api/evfs/tests/compression.rs new file mode 100644 index 0000000..5c63433 --- /dev/null +++ b/rust/src/api/evfs/tests/compression.rs @@ -0,0 +1,166 @@ +use super::*; + +// -- Compression integration -------------------------------------------- + +#[test] +fn test_write_read_zstd_roundtrip() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + let data = b"compressible data repeated ".repeat(100); + + let config = CompressionConfig { + algorithm: CompressionAlgorithm::Zstd, + level: None, + }; + vault_write(&mut handle, "data.txt".into(), data.clone(), Some(config), None).expect("write"); + let read_back = vault_read(&mut handle, "data.txt".into()).expect("read").data; + assert_eq!(read_back, data); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_write_read_brotli_roundtrip() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + let data = b"brotli compressible data ".repeat(80); + + let config = CompressionConfig { + algorithm: CompressionAlgorithm::Brotli, + level: None, + }; + vault_write(&mut handle, "notes.md".into(), data.clone(), Some(config), None).expect("write"); + let read_back = vault_read(&mut handle, "notes.md".into()).expect("read").data; + assert_eq!(read_back, data); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_write_read_no_compression() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + let data = b"uncompressed payload".to_vec(); + + let config = CompressionConfig { + algorithm: CompressionAlgorithm::None, + level: None, + }; + vault_write(&mut handle, "raw.bin".into(), data.clone(), Some(config), None).expect("write"); + let read_back = vault_read(&mut handle, "raw.bin".into()).expect("read").data; + assert_eq!(read_back, data); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_write_jpg_skips_compression() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + let config = CompressionConfig { + algorithm: CompressionAlgorithm::Zstd, + level: None, + }; + vault_write( + &mut handle, + "photo.jpg".into(), + b"fake jpeg".to_vec(), + Some(config), + None, + ) + .expect("write"); + + let entry = handle.index.find("photo.jpg").expect("find"); + assert_eq!(entry.compression, CompressionAlgorithm::None); + + let data = vault_read(&mut handle, "photo.jpg".into()).expect("read").data; + assert_eq!(data, b"fake jpeg"); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_read_decompresses_automatically() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + let data = b"auto-decompress test data ".repeat(50); + + let config = CompressionConfig { + algorithm: CompressionAlgorithm::Zstd, + level: None, + }; + vault_write(&mut handle, "auto.txt".into(), data.clone(), Some(config), None).expect("write"); + + // Read back — decompression is automatic (no config needed) + let read_back = vault_read(&mut handle, "auto.txt".into()).expect("read").data; + assert_eq!(read_back, data); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_mixed_compression_segments() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + let text = b"text data ".repeat(50); + let binary = vec![0xABu8; 500]; + let raw = b"raw data no compress".to_vec(); + + let zstd_conf = CompressionConfig { + algorithm: CompressionAlgorithm::Zstd, + level: None, + }; + let brotli_conf = CompressionConfig { + algorithm: CompressionAlgorithm::Brotli, + level: None, + }; + + vault_write( + &mut handle, + "text.txt".into(), + text.clone(), + Some(zstd_conf), + None, + ) + .expect("zstd"); + vault_write( + &mut handle, + "data.bin".into(), + binary.clone(), + Some(brotli_conf), + None, + ) + .expect("brotli"); + vault_write(&mut handle, "raw.dat".into(), raw.clone(), None, None).expect("none"); + + assert_eq!(vault_read(&mut handle, "text.txt".into()).expect("r").data, text); + assert_eq!( + vault_read(&mut handle, "data.bin".into()).expect("r").data, + binary + ); + assert_eq!(vault_read(&mut handle, "raw.dat".into()).expect("r").data, raw); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_checksum_on_original_plaintext() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + let data = b"checksum covers original ".repeat(50); + let config = CompressionConfig { + algorithm: CompressionAlgorithm::Zstd, + level: None, + }; + vault_write(&mut handle, "check.txt".into(), data.clone(), Some(config), None).expect("write"); + + // Verify the stored checksum matches original plaintext (not compressed form) + let entry = handle.index.find("check.txt").expect("find"); + assert!(segment::verify_checksum(&data, &entry.checksum)); + + vault_close(handle).expect("close"); +} + diff --git a/rust/src/api/evfs/tests/crud.rs b/rust/src/api/evfs/tests/crud.rs new file mode 100644 index 0000000..3c9e741 --- /dev/null +++ b/rust/src/api/evfs/tests/crud.rs @@ -0,0 +1,199 @@ +use super::*; + +// -- Create / Open ------------------------------------------------------ + +#[test] +fn test_create_and_open() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = vault_path(&dir); + + { + let handle = vault_create(path.clone(), test_key(), "aes-256-gcm".into(), 1_048_576) + .expect("create"); + let names = vault_list(&handle); + assert!(names.is_empty()); + vault_close(handle).expect("close"); + } + + { + let handle = vault_open(path, test_key()).expect("open"); + let names = vault_list(&handle); + assert!(names.is_empty()); + vault_close(handle).expect("close"); + } +} + +#[test] +fn test_open_wrong_key_fails() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = vault_path(&dir); + + { + let handle = vault_create(path.clone(), test_key(), "aes-256-gcm".into(), 1_048_576) + .expect("create"); + vault_close(handle).expect("close"); + } + + let result = vault_open(path, wrong_key()); + assert!(result.is_err()); +} + +#[test] +fn test_open_runs_wal_recovery() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = vault_path(&dir); + + // Create vault with segment A + { + let mut handle = vault_create(path.clone(), test_key(), "aes-256-gcm".into(), 1_048_576) + .expect("create"); + vault_write(&mut handle, "a.txt".into(), b"data-A".to_vec(), None, None).expect("write A"); + vault_close(handle).expect("close"); + } + + // Save the "good" encrypted index (containing only A) + let good_encrypted = { + let mut f = File::open(&path).expect("open"); + read_encrypted_index( + &mut f, + PRIMARY_INDEX_OFFSET, + encrypted_index_size(MIN_INDEX_PAD_SIZE), + ) + .expect("read index") + }; + + // Add segment B normally (both index and data on disk) + { + let mut handle = vault_open(path.clone(), test_key()).expect("open"); + vault_write(&mut handle, "b.txt".into(), b"data-B".to_vec(), None, None).expect("write B"); + vault_close(handle).expect("close"); + } + + // Simulate crash: uncommitted WAL entry restoring the A-only index + { + let mut wal = WriteAheadLog::open(&path).expect("wal"); + wal.begin(WalOp::WriteSegment, &good_encrypted) + .expect("begin"); + // Don't commit — simulates crash + } + + // Reopen — WAL recovery should roll back to A-only index + let mut handle = vault_open(path, test_key()).expect("open after recovery"); + let data = vault_read(&mut handle, "a.txt".into()).expect("read A").data; + assert_eq!(data, b"data-A"); + + let result = vault_read(&mut handle, "b.txt".into()); + assert!(matches!(result, Err(CryptoError::SegmentNotFound(_)))); + + vault_close(handle).expect("close"); +} + +// -- Write / Read ------------------------------------------------------- + +#[test] +fn test_write_read_roundtrip() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write(&mut handle, "doc.txt".into(), b"hello vault".to_vec(), None, None).expect("write"); + + let data = vault_read(&mut handle, "doc.txt".into()).expect("read").data; + assert_eq!(data, b"hello vault"); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_write_read_multiple_segments() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + for i in 0..5 { + let name = format!("seg{i}.bin"); + let data = format!("data for segment {i}").into_bytes(); + vault_write(&mut handle, name, data, None, None).expect("write"); + } + + for i in 0..5 { + let name = format!("seg{i}.bin"); + let expected = format!("data for segment {i}").into_bytes(); + let data = vault_read(&mut handle, name).expect("read").data; + assert_eq!(data, expected); + } + + assert_eq!(vault_list(&handle).len(), 5); + vault_close(handle).expect("close"); +} + +#[test] +fn test_write_overwrite() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write(&mut handle, "doc.txt".into(), b"version 1".to_vec(), None, None).expect("write v1"); + vault_write(&mut handle, "doc.txt".into(), b"version 2".to_vec(), None, None).expect("write v2"); + + let data = vault_read(&mut handle, "doc.txt".into()).expect("read").data; + assert_eq!(data, b"version 2"); + assert_eq!(vault_list(&handle).len(), 1); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_write_overwrite_increments_generation() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write(&mut handle, "doc.txt".into(), b"v1".to_vec(), None, None).expect("write v1"); + let gen1 = handle.index.find("doc.txt").expect("find").generation; + + vault_write(&mut handle, "doc.txt".into(), b"v2".to_vec(), None, None).expect("write v2"); + let gen2 = handle.index.find("doc.txt").expect("find").generation; + + assert!(gen2 > gen1); + vault_close(handle).expect("close"); +} + +#[test] +fn test_read_nonexistent_segment() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + let result = vault_read(&mut handle, "nope.txt".into()); + assert!(matches!(result, Err(CryptoError::SegmentNotFound(_)))); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_read_tampered_segment() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write( + &mut handle, + "secret.txt".into(), + b"important data".to_vec(), + None, + None, + ) + .expect("write"); + + // Tamper with encrypted data on disk + let entry = handle.index.find("secret.txt").expect("find"); + let disk_offset = format::data_region_offset(MIN_INDEX_PAD_SIZE) + entry.offset; + // Flip a byte in the ciphertext (after the 12-byte nonce) + handle + .file + .seek(SeekFrom::Start(disk_offset + 13)) + .expect("seek"); + handle.file.write_all(&[0xFF]).expect("tamper"); + handle.file.sync_all().expect("sync"); + + let result = vault_read(&mut handle, "secret.txt".into()); + assert!(result.is_err()); + + vault_close(handle).expect("close"); +} + diff --git a/rust/src/api/evfs/tests/defragment.rs b/rust/src/api/evfs/tests/defragment.rs new file mode 100644 index 0000000..dc405b3 --- /dev/null +++ b/rust/src/api/evfs/tests/defragment.rs @@ -0,0 +1,627 @@ +use super::*; + +// -- Defragmentation ---------------------------------------------------- + +#[test] +fn test_defragment_basic() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write(&mut handle, "a.txt".into(), vec![0xAA; 200], None, None).expect("A"); + vault_write(&mut handle, "b.txt".into(), vec![0xBB; 200], None, None).expect("B"); + vault_write(&mut handle, "c.txt".into(), vec![0xCC; 200], None, None).expect("C"); + + let c_offset_before = handle.index.find("c.txt").expect("C").offset; + + // Delete B — creates free region between A and C + vault_delete(&mut handle, "b.txt".into()).expect("del B"); + assert_eq!(handle.index.free_regions.len(), 1); + + let result = vault_defragment(&mut handle).expect("defrag"); + assert_eq!(result.segments_moved, 1); // C moved left + assert_eq!(result.free_regions_before, 1); + assert!(result.bytes_reclaimed > 0); + + // Free list should be empty, all free space is unallocated + let cap = vault_capacity(&handle); + assert_eq!(cap.free_list_bytes, 0); + assert!(cap.unallocated_bytes > 0); + assert_eq!(cap.segment_count, 2); + + // next_free_offset == sum of all segment sizes + let total_used: u64 = handle.index.entries.iter().map(|e| e.size).sum(); + assert_eq!(handle.index.next_free_offset, total_used); + assert_eq!(handle.index.free_regions.len(), 0); + + // C moved left + let c_offset_after = handle.index.find("c.txt").expect("C").offset; + assert!(c_offset_after < c_offset_before); + + // Data integrity preserved + assert_eq!( + vault_read(&mut handle, "a.txt".into()).expect("read A").data, + vec![0xAA; 200] + ); + assert_eq!( + vault_read(&mut handle, "c.txt".into()).expect("read C").data, + vec![0xCC; 200] + ); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_defragment_multiple_gaps() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write(&mut handle, "a.txt".into(), vec![0xAA; 100], None, None).expect("A"); + vault_write(&mut handle, "b.txt".into(), vec![0xBB; 100], None, None).expect("B"); + vault_write(&mut handle, "c.txt".into(), vec![0xCC; 100], None, None).expect("C"); + vault_write(&mut handle, "d.txt".into(), vec![0xDD; 100], None, None).expect("D"); + vault_write(&mut handle, "e.txt".into(), vec![0xEE; 100], None, None).expect("E"); + + // Delete B and D — two gaps + vault_delete(&mut handle, "b.txt".into()).expect("del B"); + vault_delete(&mut handle, "d.txt".into()).expect("del D"); + assert_eq!(handle.index.free_regions.len(), 2); + + let result = vault_defragment(&mut handle).expect("defrag"); + assert_eq!(result.segments_moved, 2); // C and E moved + assert_eq!(result.free_regions_before, 2); + + // All surviving segments readable + assert_eq!( + vault_read(&mut handle, "a.txt".into()).expect("A").data, + vec![0xAA; 100] + ); + assert_eq!( + vault_read(&mut handle, "c.txt".into()).expect("C").data, + vec![0xCC; 100] + ); + assert_eq!( + vault_read(&mut handle, "e.txt".into()).expect("E").data, + vec![0xEE; 100] + ); + + assert_eq!(vault_capacity(&handle).free_list_bytes, 0); + vault_close(handle).expect("close"); +} + +#[test] +fn test_defragment_already_compact() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write(&mut handle, "a.txt".into(), vec![0xAA; 100], None, None).expect("A"); + vault_write(&mut handle, "b.txt".into(), vec![0xBB; 100], None, None).expect("B"); + vault_write(&mut handle, "c.txt".into(), vec![0xCC; 100], None, None).expect("C"); + + let result = vault_defragment(&mut handle).expect("defrag"); + assert_eq!(result.segments_moved, 0); + assert_eq!(result.bytes_reclaimed, 0); + assert_eq!(result.free_regions_before, 0); + + // All segments still readable + assert_eq!( + vault_read(&mut handle, "a.txt".into()).expect("A").data, + vec![0xAA; 100] + ); + assert_eq!( + vault_read(&mut handle, "b.txt".into()).expect("B").data, + vec![0xBB; 100] + ); + assert_eq!( + vault_read(&mut handle, "c.txt".into()).expect("C").data, + vec![0xCC; 100] + ); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_defragment_empty_vault() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + let result = vault_defragment(&mut handle).expect("defrag"); + assert_eq!(result.segments_moved, 0); + assert_eq!(result.bytes_reclaimed, 0); + assert_eq!(result.free_regions_before, 0); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_defragment_single_segment_at_gap() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write(&mut handle, "a.txt".into(), vec![0xAA; 200], None, None).expect("A"); + vault_write(&mut handle, "b.txt".into(), vec![0xBB; 200], None, None).expect("B"); + + // Delete A — gap at start, B remains at higher offset + vault_delete(&mut handle, "a.txt".into()).expect("del A"); + + let result = vault_defragment(&mut handle).expect("defrag"); + assert_eq!(result.segments_moved, 1); + assert_eq!(result.free_regions_before, 1); + + // B moved to offset 0 + assert_eq!(handle.index.find("b.txt").expect("B").offset, 0); + assert_eq!( + vault_read(&mut handle, "b.txt".into()).expect("read B").data, + vec![0xBB; 200] + ); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_defragment_crash_recovery() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = vault_path(&dir); + + // Create vault with A, B, C. Delete B to create a gap. + { + let mut handle = vault_create(path.clone(), test_key(), "aes-256-gcm".into(), 1_048_576) + .expect("create"); + vault_write(&mut handle, "a.txt".into(), vec![0xAA; 100], None, None).expect("A"); + vault_write(&mut handle, "b.txt".into(), vec![0xBB; 100], None, None).expect("B"); + vault_write(&mut handle, "c.txt".into(), vec![0xCC; 100], None, None).expect("C"); + vault_delete(&mut handle, "b.txt".into()).expect("del B"); + vault_close(handle).expect("close"); + } + + // Save pre-defrag encrypted index (the "good" state) + let pre_defrag_index = { + let mut f = File::open(&path).expect("open"); + read_encrypted_index( + &mut f, + PRIMARY_INDEX_OFFSET, + encrypted_index_size(MIN_INDEX_PAD_SIZE), + ) + .expect("read index") + }; + + // Simulate crash mid-defrag: write uncommitted WAL entry + { + let mut wal = WriteAheadLog::open(&path).expect("wal"); + wal.begin(WalOp::WriteSegment, &pre_defrag_index) + .expect("begin"); + // Don't commit — simulates crash during defrag + } + + // Reopen — WAL recovery should restore pre-defrag index + let mut handle = vault_open(path, test_key()).expect("open after crash"); + + // C should still be at its original (pre-defrag) offset, readable + assert_eq!( + vault_read(&mut handle, "c.txt".into()).expect("read C").data, + vec![0xCC; 100] + ); + assert_eq!( + vault_read(&mut handle, "a.txt".into()).expect("read A").data, + vec![0xAA; 100] + ); + + // Free region from deleted B should still exist + assert!(!handle.index.free_regions.is_empty()); + + // Now run actual defrag — should succeed normally + let result = vault_defragment(&mut handle).expect("defrag"); + assert!(result.segments_moved > 0); + + assert_eq!( + vault_read(&mut handle, "a.txt".into()).expect("A").data, + vec![0xAA; 100] + ); + assert_eq!( + vault_read(&mut handle, "c.txt".into()).expect("C").data, + vec![0xCC; 100] + ); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_defragment_crash_overlapping_move_recovers() { + // Exercises the overlapping-move crash path: segment B is larger than + // the gap created by deleting A, so moving B left overwrites part of + // B's old position. A crash before WAL commit should still recover. + let dir = tempfile::tempdir().expect("tempdir"); + let path = vault_path(&dir); + + // Create: [A: 100B] [B: 10KB] — deleting A creates a small gap + // so B's move from offset(A.size) to 0 overlaps. + { + let mut handle = + vault_create(path.clone(), test_key(), "aes-256-gcm".into(), SIZE_MB).expect("create"); + vault_write(&mut handle, "a.txt".into(), vec![0xAA; 100], None, None).expect("A"); + vault_write(&mut handle, "b.txt".into(), vec![0xBB; 10_000], None, None).expect("B"); + vault_delete(&mut handle, "a.txt".into()).expect("del A"); + vault_close(handle).expect("close"); + } + + // Save pre-defrag index and get B's offset/size + let (pre_defrag_index, b_old_offset, b_size) = { + let handle = vault_open(path.clone(), test_key()).expect("open"); + let mut f = File::open(&path).expect("open file"); + let idx = read_encrypted_index( + &mut f, + PRIMARY_INDEX_OFFSET, + encrypted_index_size(MIN_INDEX_PAD_SIZE), + ) + .expect("read idx"); + let entry = handle.index.find("b.txt").expect("B"); + let off = entry.offset; + let sz = entry.size; + vault_close(handle).expect("close"); + (idx, off, sz) + }; + + // Verify this IS an overlapping move: gap < size + assert!(b_old_offset < b_size, "should be overlapping move"); + + // Simulate crash mid-defrag: perform the overlapping write but don't commit WAL + { + let mut f = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .expect("open rw"); + + // Read B from old position + let mut buf = vec![0u8; b_size as usize]; + f.seek(SeekFrom::Start( + format::data_region_offset(MIN_INDEX_PAD_SIZE) + b_old_offset, + )) + .expect("seek"); + f.read_exact(&mut buf).expect("read B"); + + // Write defrag backup (simulating what vault_defragment does) + let backup_path = format!("{path}.defrag"); + let mut backup = File::create(&backup_path).expect("create backup"); + backup.write_all(&b_old_offset.to_le_bytes()).expect("off"); + backup.write_all(&b_size.to_le_bytes()).expect("sz"); + backup.write_all(&buf).expect("data"); + backup.sync_all().expect("sync backup"); + + // Write B to new position (offset 0) — corrupts overlap zone + f.seek(SeekFrom::Start(format::data_region_offset( + MIN_INDEX_PAD_SIZE, + ))) + .expect("seek 0"); + f.write_all(&buf).expect("write B to 0"); + f.sync_all().expect("sync"); + + // Write uncommitted WAL entry + let mut wal = WriteAheadLog::open(&path).expect("wal"); + wal.begin(WalOp::WriteSegment, &pre_defrag_index) + .expect("begin"); + // Don't commit — crash + } + + // Reopen — defrag backup + WAL recovery should restore to consistent state + let mut handle = vault_open(path, test_key()).expect("open after crash"); + + // B should be readable at old position (overlap zone restored) + let b_data = vault_read(&mut handle, "b.txt".into()).expect("read B").data; + assert_eq!(b_data, vec![0xBB; 10_000]); + + // Defrag should still work after recovery + let result = vault_defragment(&mut handle).expect("defrag after recovery"); + assert!(result.segments_moved > 0); + let b_data = vault_read(&mut handle, "b.txt".into()).expect("read B post-defrag").data; + assert_eq!(b_data, vec![0xBB; 10_000]); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_defragment_preserves_compression() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + let zstd_data = b"zstd compressible data ".repeat(50); + let raw_data = b"raw segment no compression".to_vec(); + + let zstd_conf = CompressionConfig { + algorithm: CompressionAlgorithm::Zstd, + level: None, + }; + vault_write( + &mut handle, + "text.txt".into(), + zstd_data.clone(), + Some(zstd_conf), + None, + ) + .expect("zstd"); + vault_write(&mut handle, "spacer.bin".into(), vec![0xFF; 300], None, None).expect("spacer"); + vault_write(&mut handle, "raw.dat".into(), raw_data.clone(), None, None).expect("raw"); + + // Delete spacer — gap between text.txt and raw.dat + vault_delete(&mut handle, "spacer.bin".into()).expect("del spacer"); + + let result = vault_defragment(&mut handle).expect("defrag"); + assert_eq!(result.segments_moved, 1); // raw.dat moved + + // Compression metadata preserved + let text_entry = handle.index.find("text.txt").expect("text"); + assert_eq!(text_entry.compression, CompressionAlgorithm::Zstd); + let raw_entry = handle.index.find("raw.dat").expect("raw"); + assert_eq!(raw_entry.compression, CompressionAlgorithm::None); + + // Data integrity — decompression works after defrag + assert_eq!( + vault_read(&mut handle, "text.txt".into()).expect("text").data, + zstd_data + ); + assert_eq!( + vault_read(&mut handle, "raw.dat".into()).expect("raw").data, + raw_data + ); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_defragment_preserves_generation() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write(&mut handle, "a.txt".into(), vec![0xAA; 100], None, None).expect("A"); + // Overwrite A to bump its generation + vault_write(&mut handle, "a.txt".into(), vec![0xAA; 100], None, None).expect("A v2"); + vault_write(&mut handle, "b.txt".into(), vec![0xBB; 100], None, None).expect("B"); + + let b_gen_before = handle.index.find("b.txt").expect("B").generation; + + // Delete A — B needs to move + vault_delete(&mut handle, "a.txt".into()).expect("del A"); + + let result = vault_defragment(&mut handle).expect("defrag"); + assert_eq!(result.segments_moved, 1); + + // Generation unchanged + let b_gen_after = handle.index.find("b.txt").expect("B").generation; + assert_eq!(b_gen_before, b_gen_after); + + // Nonce derivation still works (read succeeds) + assert_eq!( + vault_read(&mut handle, "b.txt".into()).expect("read B").data, + vec![0xBB; 100] + ); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_defragment_large_gap_at_start() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write(&mut handle, "a.txt".into(), vec![0xAA; 300], None, None).expect("A"); + vault_write(&mut handle, "b.txt".into(), vec![0xBB; 300], None, None).expect("B"); + vault_write(&mut handle, "c.txt".into(), vec![0xCC; 300], None, None).expect("C"); + + let ab_size = + handle.index.find("a.txt").expect("A").size + handle.index.find("b.txt").expect("B").size; + + // Delete A and B — large gap at start, C alone at end + vault_delete(&mut handle, "a.txt".into()).expect("del A"); + vault_delete(&mut handle, "b.txt".into()).expect("del B"); + + let result = vault_defragment(&mut handle).expect("defrag"); + assert_eq!(result.segments_moved, 1); // C moved to 0 + assert_eq!(result.bytes_reclaimed, ab_size); + + assert_eq!(handle.index.find("c.txt").expect("C").offset, 0); + assert_eq!( + vault_read(&mut handle, "c.txt".into()).expect("C").data, + vec![0xCC; 300] + ); + assert_eq!(vault_capacity(&handle).free_list_bytes, 0); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_defragment_write_after_defrag() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write(&mut handle, "a.txt".into(), vec![0xAA; 200], None, None).expect("A"); + vault_write(&mut handle, "b.txt".into(), vec![0xBB; 200], None, None).expect("B"); + vault_write(&mut handle, "c.txt".into(), vec![0xCC; 200], None, None).expect("C"); + + vault_delete(&mut handle, "b.txt".into()).expect("del B"); + vault_defragment(&mut handle).expect("defrag"); + + // Allocator should work: new writes go after the packed segments + vault_write(&mut handle, "d.txt".into(), vec![0xDD; 500], None, None).expect("D after defrag"); + + // All segments readable + assert_eq!( + vault_read(&mut handle, "a.txt".into()).expect("A").data, + vec![0xAA; 200] + ); + assert_eq!( + vault_read(&mut handle, "c.txt".into()).expect("C").data, + vec![0xCC; 200] + ); + assert_eq!( + vault_read(&mut handle, "d.txt".into()).expect("D").data, + vec![0xDD; 500] + ); + + // D should be allocated at the end (after A and C) + let a_end = + handle.index.find("a.txt").expect("A").offset + handle.index.find("a.txt").expect("A").size; + let c_end = + handle.index.find("c.txt").expect("C").offset + handle.index.find("c.txt").expect("C").size; + let d_offset = handle.index.find("d.txt").expect("D").offset; + let packed_end = std::cmp::max(a_end, c_end); + assert!(d_offset >= packed_end); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_defragment_ten_segments_delete_odd() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + // Write 10 segments (1KB each) + for i in 0..10 { + let name = format!("seg{i}.bin"); + let data = vec![(i as u8) | 0x10; 1024]; + vault_write(&mut handle, name, data, None, None).expect("write"); + } + + // Delete odd-numbered segments (1, 3, 5, 7, 9) — 5 gaps + for i in (1..10).step_by(2) { + let name = format!("seg{i}.bin"); + vault_delete(&mut handle, name).expect("delete"); + } + assert_eq!(handle.index.entries.len(), 5); + assert!(!handle.index.free_regions.is_empty()); + + let result = vault_defragment(&mut handle).expect("defrag"); + + // 4 of 5 even segments need to move (seg0 stays at 0) + assert_eq!(result.segments_moved, 4); + assert_eq!(handle.index.free_regions.len(), 0); + + // next_free_offset == sum of all segment sizes + let total_used: u64 = handle.index.entries.iter().map(|e| e.size).sum(); + assert_eq!(handle.index.next_free_offset, total_used); + + // Segments are contiguous: sorted offsets form a gapless sequence + let mut offsets: Vec<(u64, u64)> = handle + .index + .entries + .iter() + .map(|e| (e.offset, e.size)) + .collect(); + offsets.sort_by_key(|&(off, _)| off); + assert_eq!(offsets[0].0, 0); + for w in offsets.windows(2) { + assert_eq!(w[0].0 + w[0].1, w[1].0, "gap between segments"); + } + + // All 5 surviving segments readable with correct data + for i in (0..10).step_by(2) { + let name = format!("seg{i}.bin"); + let expected = vec![(i as u8) | 0x10; 1024]; + let data = vault_read(&mut handle, name).expect("read").data; + assert_eq!(data, expected); + } + + vault_close(handle).expect("close"); +} + +#[test] +fn test_vault_health_empty() { + let dir = tempfile::tempdir().expect("tempdir"); + let handle = create_test_vault(&dir, 1_048_576); + + let h = vault_health(&handle); + assert_eq!(h.segment_count, 0); + assert_eq!(h.free_region_count, 0); + assert_eq!(h.fragmentation_ratio, 0.0); + assert_eq!(h.total_bytes, handle.index.capacity); + assert_eq!(h.used_bytes, 0); + assert_eq!(h.unallocated_bytes, handle.index.capacity); +} + +#[test] +fn test_vault_health_after_write_delete_and_defrag() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write(&mut handle, "a".into(), vec![0xAA; 200], None, None).expect("A"); + vault_write(&mut handle, "b".into(), vec![0xBB; 500], None, None).expect("B"); + vault_write(&mut handle, "c".into(), vec![0xCC; 300], None, None).expect("C"); + + let before = vault_health(&handle); + assert!(before.used_bytes > 0); + assert_eq!( + before.unallocated_bytes + before.free_list_bytes + before.used_bytes, + handle.index.capacity + ); + + vault_delete(&mut handle, "b".into()).expect("delete B"); + + let after_delete = vault_health(&handle); + assert_eq!(after_delete.free_region_count, 1); + assert!(after_delete.fragmentation_ratio > 0.0); + assert!(after_delete.largest_free_block > 0); + assert_eq!( + after_delete.used_bytes + after_delete.free_list_bytes + after_delete.unallocated_bytes, + handle.index.capacity + ); + + vault_defragment(&mut handle).expect("defrag"); + + let after_defrag = vault_health(&handle); + assert_eq!(after_defrag.free_region_count, 0); + assert_eq!(after_defrag.fragmentation_ratio, 0.0); + assert_eq!( + after_defrag.largest_free_block, + after_defrag.unallocated_bytes + ); + assert_eq!( + after_defrag.used_bytes + after_defrag.free_list_bytes + after_defrag.unallocated_bytes, + handle.index.capacity + ); +} + +#[test] +fn test_largest_free_block_accounts_for_tail() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1024u64); + + vault_write(&mut handle, "s".into(), vec![0xAA; 100], None, None).expect("write"); + + let h = vault_health(&handle); + let free_list_max = handle + .index + .free_regions + .iter() + .map(|r| r.size) + .max() + .unwrap_or(0); + let tail = handle + .index + .capacity + .saturating_sub(handle.index.next_free_offset); + + assert_eq!(h.largest_free_block, std::cmp::max(free_list_max, tail)); +} + +#[test] +fn test_vault_health_full_capacity() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + // Fill vault until full + let mut i = 0; + loop { + let name = format!("seg_{i}"); + if vault_write(&mut handle, name, vec![0xAA; 8192], None, None).is_err() { + break; + } + i += 1; + } + + let h = vault_health(&handle); + assert_eq!(h.free_region_count, 0); + assert_eq!(h.fragmentation_ratio, 0.0); + assert_eq!( + h.used_bytes + h.free_list_bytes + h.unallocated_bytes, + h.total_bytes + ); +} + diff --git a/rust/src/api/evfs/tests/delete.rs b/rust/src/api/evfs/tests/delete.rs new file mode 100644 index 0000000..3146aef --- /dev/null +++ b/rust/src/api/evfs/tests/delete.rs @@ -0,0 +1,200 @@ +use super::*; + +// -- Delete ------------------------------------------------------------- + +#[test] +fn test_delete_segment() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write(&mut handle, "tmp.txt".into(), b"temp data".to_vec(), None, None).expect("write"); + vault_delete(&mut handle, "tmp.txt".into()).expect("delete"); + + let result = vault_read(&mut handle, "tmp.txt".into()); + assert!(matches!(result, Err(CryptoError::SegmentNotFound(_)))); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_delete_secure_erase() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write( + &mut handle, + "secret.txt".into(), + b"sensitive data".to_vec(), + None, + None, + ) + .expect("write"); + + // Capture encrypted bytes at the segment offset + let entry = handle.index.find("secret.txt").expect("find"); + let disk_offset = format::data_region_offset(MIN_INDEX_PAD_SIZE) + entry.offset; + let size = entry.size as usize; + handle + .file + .seek(SeekFrom::Start(disk_offset)) + .expect("seek"); + let mut old_bytes = vec![0u8; size]; + handle.file.read_exact(&mut old_bytes).expect("read"); + let saved_offset = disk_offset; + + vault_delete(&mut handle, "secret.txt".into()).expect("delete"); + + // Same region should now contain different bytes (CSPRNG overwrite) + handle + .file + .seek(SeekFrom::Start(saved_offset)) + .expect("seek"); + let mut new_bytes = vec![0u8; size]; + handle.file.read_exact(&mut new_bytes).expect("read"); + assert_ne!(old_bytes, new_bytes); + + vault_close(handle).expect("close"); +} + +// -- Capacity ----------------------------------------------------------- + +#[test] +fn test_vault_full() { + let dir = tempfile::tempdir().expect("tempdir"); + // Tiny vault: 256 bytes capacity + let mut handle = create_test_vault(&dir, 256); + + // First write (encrypted data ~ 28 + plaintext bytes) + vault_write(&mut handle, "a.txt".into(), vec![0xAA; 200], None, None).expect("A"); + + // Second write should fail — not enough space + let result = vault_write(&mut handle, "b.txt".into(), vec![0xBB; 200], None, None); + assert!(matches!(result, Err(CryptoError::VaultFull { .. }))); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_vault_full_with_free_list() { + let dir = tempfile::tempdir().expect("tempdir"); + // Small vault + let mut handle = create_test_vault(&dir, 512); + + // Fill vault with A + vault_write(&mut handle, "a.txt".into(), vec![0xAA; 400], None, None).expect("A"); + + // Delete A (space returned to free list) + vault_delete(&mut handle, "a.txt".into()).expect("del A"); + + // Write B using freed space — should succeed + vault_write(&mut handle, "b.txt".into(), vec![0xBB; 400], None, None).expect("B from free list"); + + let data = vault_read(&mut handle, "b.txt".into()).expect("read B").data; + assert_eq!(data, vec![0xBB; 400]); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_capacity_info() { + let dir = tempfile::tempdir().expect("tempdir"); + let capacity = 1_048_576u64; + let mut handle = create_test_vault(&dir, capacity); + + let cap = vault_capacity(&handle); + assert_eq!(cap.total_bytes, capacity); + assert_eq!(cap.used_bytes, 0); + assert_eq!(cap.free_list_bytes, 0); + assert_eq!(cap.unallocated_bytes, capacity); + assert_eq!(cap.segment_count, 0); + + vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None, None).expect("write"); + + let cap = vault_capacity(&handle); + assert_eq!(cap.segment_count, 1); + assert!(cap.used_bytes > 0); + assert_eq!( + cap.used_bytes + cap.free_list_bytes + cap.unallocated_bytes, + capacity + ); + + vault_close(handle).expect("close"); +} + +// -- Locking ------------------------------------------------------------ + +#[test] +fn test_concurrent_open_fails() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = vault_path(&dir); + + let _handle = + vault_create(path.clone(), test_key(), "aes-256-gcm".into(), 1_048_576).expect("create"); + + let result = vault_open(path, test_key()); + assert!(matches!(result, Err(CryptoError::VaultLocked))); +} + +// -- Persistence across close/open -------------------------------------- + +#[test] +fn test_write_close_open_read() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = vault_path(&dir); + + { + let mut handle = vault_create( + path.clone(), + test_key(), + "chacha20-poly1305".into(), + 1_048_576, + ) + .expect("create"); + vault_write( + &mut handle, + "persist.txt".into(), + b"survives close".to_vec(), + None, + None, + ) + .expect("write"); + vault_close(handle).expect("close"); + } + + { + let mut handle = vault_open(path, test_key()).expect("open"); + let data = vault_read(&mut handle, "persist.txt".into()).expect("read").data; + assert_eq!(data, b"survives close"); + vault_close(handle).expect("close"); + } +} + +// -- Shadow index fallback ---------------------------------------------- + +#[test] +fn test_corrupted_primary_falls_back_to_shadow() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = vault_path(&dir); + + { + let mut handle = vault_create(path.clone(), test_key(), "aes-256-gcm".into(), 1_048_576) + .expect("create"); + vault_write(&mut handle, "doc.txt".into(), b"shadow test".to_vec(), None, None).expect("write"); + vault_close(handle).expect("close"); + } + + // Corrupt primary index on disk + { + let mut f = OpenOptions::new().write(true).open(&path).expect("open"); + f.seek(SeekFrom::Start(PRIMARY_INDEX_OFFSET)).expect("seek"); + f.write_all(&[0xFF; 100]).expect("corrupt"); + } + + // Open should succeed via shadow + let mut handle = vault_open(path, test_key()).expect("open via shadow"); + let data = vault_read(&mut handle, "doc.txt".into()).expect("read").data; + assert_eq!(data, b"shadow test"); + + vault_close(handle).expect("close"); +} + diff --git a/rust/src/api/evfs/tests/export_import.rs b/rust/src/api/evfs/tests/export_import.rs new file mode 100644 index 0000000..cb5801c --- /dev/null +++ b/rust/src/api/evfs/tests/export_import.rs @@ -0,0 +1,677 @@ +use super::*; + +// -- Export ---------------------------------------------------------------- + +fn wrapping_key() -> Vec { + vec![0xCC; 32] +} + +fn export_path(dir: &tempfile::TempDir) -> String { + dir.path() + .join("export.mvex") + .to_str() + .expect("path") + .to_string() +} + +/// Parse an exported `.mvex` archive, returning (header, wrapped_key, records, trailer_checksum). +fn parse_archive( + path: &str, +) -> ( + crate::core::evfs::archive::ArchiveHeader, + Vec, + Vec, + [u8; 32], +) { + use crate::core::evfs::archive::*; + let data = std::fs::read(path).expect("read archive"); + assert!(data.len() >= ARCHIVE_HEADER_SIZE + WRAPPED_KEY_SIZE + ARCHIVE_TRAILER_SIZE); + + let header = ArchiveHeader::from_bytes( + data[..ARCHIVE_HEADER_SIZE] + .try_into() + .expect("header slice"), + ) + .expect("parse header"); + + let wk_start = ARCHIVE_HEADER_SIZE; + let wrapped_key = data[wk_start..wk_start + WRAPPED_KEY_SIZE].to_vec(); + + let mut pos = wk_start + WRAPPED_KEY_SIZE; + let mut records = Vec::new(); + + for _ in 0..header.segment_count { + let (name, compression, checksum, data_len, hdr_size) = + SegmentRecord::read_header(&data[pos..]).expect("parse record header"); + pos += hdr_size; + let enc_data = data[pos..pos + data_len as usize].to_vec(); + pos += data_len as usize; + records.push(SegmentRecord { + name, + compression, + checksum, + encrypted_data: enc_data, + }); + } + + let trailer_bytes: [u8; ARCHIVE_TRAILER_SIZE] = data[pos..pos + ARCHIVE_TRAILER_SIZE] + .try_into() + .expect("trailer slice"); + let trailer = ArchiveTrailer::from_bytes(&trailer_bytes).expect("parse trailer"); + + // Verify BLAKE3 trailer covers everything before the trailer + let computed = blake3::hash(&data[..pos]); + assert_eq!( + trailer.checksum, + <[u8; 32]>::from(computed), + "trailer checksum mismatch" + ); + + (header, wrapped_key, records, trailer.checksum) +} + +/// Unwrap the export key and decrypt a segment record. +fn decrypt_record( + record: &crate::core::evfs::archive::SegmentRecord, + wrapped_key: &[u8], + wk: &[u8], + algorithm: crate::core::format::Algorithm, +) -> Vec { + use crate::core::evfs::archive::KEY_WRAP_AAD; + use crate::core::evfs::segment; + + let export_key = + segment::aead_decrypt_with_stored_nonce(wk, wrapped_key, KEY_WRAP_AAD, algorithm) + .expect("unwrap export key"); + segment::aead_decrypt_with_stored_nonce( + &export_key, + &record.encrypted_data, + record.name.as_bytes(), + algorithm, + ) + .expect("decrypt record") +} + +// -- Archive format unit tests --------------------------------------------- + +#[test] +fn test_archive_header_roundtrip() { + use crate::core::evfs::archive::*; + + let header = ArchiveHeader::new(0x01, 42); + let bytes = header.to_bytes(); + let parsed = ArchiveHeader::from_bytes(&bytes).expect("parse"); + assert_eq!(parsed.version, ARCHIVE_VERSION); + assert_eq!(parsed.algorithm, 0x01); + assert_eq!(parsed.segment_count, 42); + assert_eq!(parsed.flags, 0); +} + +#[test] +fn test_archive_header_bad_magic_rejected() { + use crate::core::evfs::archive::*; + + let mut bytes = ArchiveHeader::new(0x01, 1).to_bytes(); + bytes[0] = b'X'; // corrupt magic + assert!(ArchiveHeader::from_bytes(&bytes).is_err()); +} + +#[test] +fn test_archive_header_bad_version_rejected() { + use crate::core::evfs::archive::*; + + let mut bytes = ArchiveHeader::new(0x01, 1).to_bytes(); + bytes[4] = 99; // unsupported version + assert!(ArchiveHeader::from_bytes(&bytes).is_err()); +} + +#[test] +fn test_archive_trailer_roundtrip() { + use crate::core::evfs::archive::*; + + let checksum = [0xAB; 32]; + let trailer = ArchiveTrailer { checksum }; + let bytes = trailer.to_bytes(); + let parsed = ArchiveTrailer::from_bytes(&bytes).expect("parse"); + assert_eq!(parsed.checksum, checksum); +} + +#[test] +fn test_archive_trailer_bad_magic_rejected() { + use crate::core::evfs::archive::*; + + let mut bytes = (ArchiveTrailer { checksum: [0; 32] }).to_bytes(); + bytes[35] = b'Z'; // corrupt reverse magic + assert!(ArchiveTrailer::from_bytes(&bytes).is_err()); +} + +#[test] +fn test_segment_record_header_roundtrip() { + use crate::core::evfs::archive::*; + + let record = SegmentRecord { + name: "hello.txt".into(), + compression: 0x01, + checksum: [0xDD; 32], + encrypted_data: vec![0xFF; 100], + }; + let header = record.write_header().expect("write header"); + let (name, comp, cksum, data_len, consumed) = + SegmentRecord::read_header(&header).expect("read header"); + assert_eq!(name, "hello.txt"); + assert_eq!(comp, 0x01); + assert_eq!(cksum, [0xDD; 32]); + assert_eq!(data_len, 100); + assert_eq!(consumed, header.len()); +} + +#[test] +fn test_wrapped_key_size() { + use crate::core::evfs::archive::WRAPPED_KEY_SIZE; + // nonce(12) + ciphertext(32) + tag(16) = 60 + assert_eq!(WRAPPED_KEY_SIZE, 60); +} + +#[test] +fn test_archive_trailer_size() { + use crate::core::evfs::archive::ARCHIVE_TRAILER_SIZE; + // blake3(32) + reverse_magic(4) = 36 + assert_eq!(ARCHIVE_TRAILER_SIZE, 36); +} + +// -- Export integration tests --------------------------------------------- + +#[test] +fn test_export_produces_valid_archive() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None, None).expect("write"); + vault_write(&mut handle, "b.txt".into(), b"world".to_vec(), None, None).expect("write"); + + let epath = export_path(&dir); + vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); + + let (header, wrapped_key, records, _) = parse_archive(&epath); + assert_eq!(header.segment_count, 2); + assert_eq!(header.version, 1); + assert_eq!(records.len(), 2); + + // Decrypt and verify contents + let algo = crate::core::format::Algorithm::AesGcm; + for record in &records { + let plaintext = decrypt_record(record, &wrapped_key, &wrapping_key(), algo); + match record.name.as_str() { + "a.txt" => assert_eq!(plaintext, b"hello"), + "b.txt" => assert_eq!(plaintext, b"world"), + other => panic!("unexpected segment: {other}"), + } + // Verify BLAKE3 checksum in record matches plaintext + let expected = crate::core::evfs::segment::compute_checksum(&plaintext); + assert_eq!(record.checksum, expected); + } + + vault_close(handle).expect("close"); +} + +#[test] +fn test_export_empty_vault() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + let epath = export_path(&dir); + vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); + + let (header, _, records, _) = parse_archive(&epath); + assert_eq!(header.segment_count, 0); + assert!(records.is_empty()); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_export_with_streaming_segments() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 4_194_304); + + // Write a streaming segment (> 64KB to trigger chunking) + let data: Vec = (0..200_000).map(|i| (i % 251) as u8).collect(); + let chunks: Vec> = data.chunks(65536).map(|c| c.to_vec()).collect(); + vault_write_stream( + &mut handle, + "big.bin".into(), + data.len() as u64, + chunks.into_iter(), + None, + ) + .expect("stream write"); + + // Also write a monolithic segment + vault_write(&mut handle, "small.txt".into(), b"tiny".to_vec(), None, None).expect("write"); + + let epath = export_path(&dir); + vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); + + let (header, wrapped_key, records, _) = parse_archive(&epath); + assert_eq!(header.segment_count, 2); + + let algo = crate::core::format::Algorithm::AesGcm; + for record in &records { + let plaintext = decrypt_record(record, &wrapped_key, &wrapping_key(), algo); + match record.name.as_str() { + "big.bin" => assert_eq!(plaintext, data), + "small.txt" => assert_eq!(plaintext, b"tiny"), + other => panic!("unexpected segment: {other}"), + } + } + + vault_close(handle).expect("close"); +} + +#[test] +fn test_export_with_compressed_segments() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + let data = b"compress me please ".repeat(100); + let config = CompressionConfig { + algorithm: CompressionAlgorithm::Zstd, + level: None, + }; + vault_write( + &mut handle, + "compressed.txt".into(), + data.clone(), + Some(config), + None, + ) + .expect("write"); + + let epath = export_path(&dir); + vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); + + let (_, wrapped_key, records, _) = parse_archive(&epath); + assert_eq!(records.len(), 1); + + let algo = crate::core::format::Algorithm::AesGcm; + let plaintext = decrypt_record(&records[0], &wrapped_key, &wrapping_key(), algo); + assert_eq!(plaintext, data); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_export_does_not_modify_vault() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write(&mut handle, "a.txt".into(), b"data-A".to_vec(), None, None).expect("write"); + vault_write(&mut handle, "b.txt".into(), b"data-B".to_vec(), None, None).expect("write"); + + // Snapshot state before export + let names_before: Vec = vault_list(&handle).into_iter().collect(); + let health_before = vault_health(&handle); + + let epath = export_path(&dir); + vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); + + // Verify vault unchanged + let names_after: Vec = vault_list(&handle).into_iter().collect(); + let health_after = vault_health(&handle); + assert_eq!(names_before, names_after); + assert_eq!(health_before, health_after); + + // Verify data still readable + let a = vault_read(&mut handle, "a.txt".into()).expect("read a").data; + assert_eq!(a, b"data-A"); + let b = vault_read(&mut handle, "b.txt".into()).expect("read b").data; + assert_eq!(b, b"data-B"); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_export_wrong_wrapping_key_cannot_decrypt() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write( + &mut handle, + "secret.txt".into(), + b"top secret".to_vec(), + None, + None, + ) + .expect("write"); + + let epath = export_path(&dir); + vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); + + let (_, wrapped_key, _records, _) = parse_archive(&epath); + let algo = crate::core::format::Algorithm::AesGcm; + + // Try to unwrap with wrong key + let wrong_wk = vec![0xEE; 32]; + let result = crate::core::evfs::segment::aead_decrypt_with_stored_nonce( + &wrong_wk, + &wrapped_key, + crate::core::evfs::archive::KEY_WRAP_AAD, + algo, + ); + assert!( + result.is_err(), + "wrong wrapping key must fail to unwrap export key" + ); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_export_invalid_wrapping_key_length_rejected() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + let epath = export_path(&dir); + let result = vault_export(&mut handle, vec![0xAA; 16], epath); // 16 bytes, not 32 + assert!(result.is_err()); + + vault_close(handle).expect("close"); +} + +// -- Import integration tests --------------------------------------------- + +fn import_dest_path(dir: &tempfile::TempDir) -> String { + dir.path() + .join("imported.vault") + .to_str() + .expect("path") + .to_string() +} + +#[test] +fn test_import_full_roundtrip() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 2_097_152); + + vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None, None).expect("write"); + vault_write(&mut handle, "b.txt".into(), b"world".to_vec(), None, None).expect("write"); + + let epath = export_path(&dir); + vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); + vault_close(handle).expect("close"); + + let dest_path = import_dest_path(&dir); + let mut imported = vault_import( + epath, + wrapping_key(), + dest_path.clone(), + test_key2(), + "aes-256-gcm".into(), + 2_097_152, + ) + .expect("import"); + + assert_eq!( + vault_read(&mut imported, "a.txt".into()).expect("read a").data, + b"hello" + ); + assert_eq!( + vault_read(&mut imported, "b.txt".into()).expect("read b").data, + b"world" + ); + + vault_close(imported).expect("close"); +} + +#[test] +fn test_import_wrong_wrapping_key_fails() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None, None).expect("write"); + let epath = export_path(&dir); + vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); + vault_close(handle).expect("close"); + + let dest_path = import_dest_path(&dir); + let wrong_wk = vec![0xEE; 32]; + let result = vault_import( + epath, + wrong_wk, + dest_path, + test_key2(), + "aes-256-gcm".into(), + 1_048_576, + ); + + assert!(matches!(result, Err(CryptoError::ImportFailed(_)))); +} + +#[test] +fn test_import_truncated_archive() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None, None).expect("write"); + let epath = export_path(&dir); + vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); + vault_close(handle).expect("close"); + + // Truncate the archive midway + let mut archive = std::fs::read(&epath).expect("read"); + archive.truncate(archive.len() - 10); + std::fs::write(&epath, archive).expect("write"); + + let dest_path = import_dest_path(&dir); + let result = vault_import( + epath, + wrapping_key(), + dest_path.clone(), + test_key2(), + "aes-256-gcm".into(), + 1_048_576, + ); + + assert!(matches!(result, Err(CryptoError::ImportFailed(_)))); + assert!( + !std::path::Path::new(&dest_path).exists(), + "Partial vault must be deleted" + ); +} + +#[test] +fn test_import_tampered_segment_data() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None, None).expect("write"); + let epath = export_path(&dir); + vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); + vault_close(handle).expect("close"); + + // Tamper with the encrypted data part of the segment record + let mut archive = std::fs::read(&epath).expect("read"); + let pos = archive.len() - 36 - 10; // slightly before the trailer + archive[pos] ^= 0xFF; + std::fs::write(&epath, archive).expect("write"); + + let dest_path = import_dest_path(&dir); + let result = vault_import( + epath, + wrapping_key(), + dest_path.clone(), + test_key2(), + "aes-256-gcm".into(), + 1_048_576, + ); + + assert!(matches!(result, Err(CryptoError::ImportFailed(_)))); +} + +#[test] +fn test_import_tampered_trailer_checksum() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None, None).expect("write"); + let epath = export_path(&dir); + vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); + vault_close(handle).expect("close"); + + // Tamper with the trailer checksum + let mut archive = std::fs::read(&epath).expect("read"); + let pos = archive.len() - 36 + 5; // within the 32 byte checksum + archive[pos] ^= 0xFF; + std::fs::write(&epath, archive).expect("write"); + + let dest_path = import_dest_path(&dir); + let result = vault_import( + epath, + wrapping_key(), + dest_path.clone(), + test_key2(), + "aes-256-gcm".into(), + 1_048_576, + ); + + assert!(matches!(result, Err(CryptoError::ImportFailed(_)))); +} + +#[test] +fn test_import_insufficient_capacity() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 5_000_000); + vault_write(&mut handle, "big.txt".into(), vec![0xBB; 2_000_000], None, None).expect("write"); + let epath = export_path(&dir); + vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); + vault_close(handle).expect("close"); + + let dest_path = import_dest_path(&dir); + let result = vault_import( + epath, + wrapping_key(), + dest_path.clone(), + test_key2(), + "aes-256-gcm".into(), + 1_048_576, // Bound restriction triggers EVFS index allocation rejection + ); + + assert!(matches!(result, Err(CryptoError::VaultFull { .. }))); +} + +#[test] +fn test_import_streaming_segment() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 4_194_304); + + let data: Vec = (0..200_000).map(|i| (i % 251) as u8).collect(); + let chunks: Vec> = data.chunks(65536).map(|c| c.to_vec()).collect(); + vault_write_stream( + &mut handle, + "stream.bin".into(), + data.len() as u64, + chunks.into_iter(), + None, + ) + .expect("stream write"); + + let epath = export_path(&dir); + vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); + vault_close(handle).expect("close"); + + let dest_path = import_dest_path(&dir); + let mut imported = vault_import( + epath, + wrapping_key(), + dest_path.clone(), + test_key2(), + "aes-256-gcm".into(), + 4_194_304, + ) + .expect("import"); + + let readback = vault_read(&mut imported, "stream.bin".into()).expect("read").data; + assert_eq!(readback, data); + + vault_close(imported).expect("close"); +} + +#[test] +fn test_import_compressed_segment() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + let data = b"compress me please ".repeat(100); + let config = CompressionConfig { + algorithm: CompressionAlgorithm::Zstd, + level: None, + }; + vault_write( + &mut handle, + "compressed.txt".into(), + data.clone(), + Some(config), + None, + ) + .expect("write"); + + let epath = export_path(&dir); + vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); + vault_close(handle).expect("close"); + + let dest_path = import_dest_path(&dir); + let mut imported = vault_import( + epath, + wrapping_key(), + dest_path.clone(), + test_key2(), + "aes-256-gcm".into(), + 1_048_576, + ) + .expect("import"); + + let entry = imported.index.find("compressed.txt").expect("find"); + assert_eq!(entry.compression, CompressionAlgorithm::Zstd); + + let readback = vault_read(&mut imported, "compressed.txt".into()).expect("read").data; + assert_eq!(readback, data); + + vault_close(imported).expect("close"); +} + +#[test] +fn test_import_chacha20() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = vault_create( + vault_path(&dir), + test_key(), + "chacha20-poly1305".into(), + 1_048_576, + ) + .expect("create"); + + vault_write(&mut handle, "c.txt".into(), b"chacha".to_vec(), None, None).expect("write"); + let epath = export_path(&dir); + vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); + vault_close(handle).expect("close"); + + let dest_path = import_dest_path(&dir); + let mut imported = vault_import( + epath, + wrapping_key(), + dest_path.clone(), + test_key2(), + "chacha20-poly1305".into(), + 1_048_576, + ) + .expect("import"); + + assert_eq!( + imported.algorithm, + crate::core::format::Algorithm::ChaCha20Poly1305 + ); + assert_eq!( + vault_read(&mut imported, "c.txt".into()).expect("read c").data, + b"chacha" + ); + + vault_close(imported).expect("close"); +} + diff --git a/rust/src/api/evfs/tests/index_cache.rs b/rust/src/api/evfs/tests/index_cache.rs new file mode 100644 index 0000000..b908516 --- /dev/null +++ b/rust/src/api/evfs/tests/index_cache.rs @@ -0,0 +1,152 @@ +use super::*; + +// -- Index caching / dirty flag ------------------------------------------ + +#[test] +fn test_index_dirty_false_after_create() { + let dir = tempfile::tempdir().expect("tempdir"); + let handle = create_test_vault(&dir, 1_048_576); + assert!(!handle.index_dirty); + vault_close(handle).expect("close"); +} + +#[test] +fn test_index_dirty_false_after_open() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = vault_path(&dir); + let handle = + vault_create(path.clone(), test_key(), "aes-256-gcm".into(), 1_048_576).expect("create"); + vault_close(handle).expect("close"); + + let handle = vault_open(path, test_key()).expect("open"); + assert!(!handle.index_dirty); + vault_close(handle).expect("close"); +} + +#[test] +fn test_index_clean_after_write() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None, None).expect("write"); + // After write completes, dirty flag should be cleared (flushed) + assert!(!handle.index_dirty); + vault_close(handle).expect("close"); +} + +#[test] +fn test_index_clean_after_delete() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None, None).expect("write"); + vault_delete(&mut handle, "a.txt".into()).expect("delete"); + assert!(!handle.index_dirty); + vault_close(handle).expect("close"); +} + +#[test] +fn test_read_does_not_set_dirty() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None, None).expect("write"); + assert!(!handle.index_dirty); + + let _ = vault_read(&mut handle, "a.txt".into()).expect("read").data; + assert!(!handle.index_dirty); + + let _ = vault_list(&handle); + assert!(!handle.index_dirty); + + let _ = vault_capacity(&handle); + assert!(!handle.index_dirty); + + let _ = vault_health(&handle); + assert!(!handle.index_dirty); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_vault_flush_noop_when_clean() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + assert!(!handle.index_dirty); + // Should be a no-op — no error, no disk I/O + vault_flush(&mut handle).expect("flush clean"); + assert!(!handle.index_dirty); + vault_close(handle).expect("close"); +} + +#[test] +fn test_vault_flush_persists_and_survives_reopen() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = vault_path(&dir); + let mut handle = + vault_create(path.clone(), test_key(), "aes-256-gcm".into(), 1_048_576).expect("create"); + vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None, None).expect("write"); + // Explicit flush then close — data must survive reopen + vault_flush(&mut handle).expect("flush"); + assert!(!handle.index_dirty); + vault_close(handle).expect("close"); + + let mut reopened = vault_open(path, test_key()).expect("reopen"); + assert_eq!( + vault_read(&mut reopened, "a.txt".into()).expect("read").data, + b"hello" + ); + vault_close(reopened).expect("close"); +} + +#[test] +fn test_vault_close_flushes_dirty_and_persists() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = vault_path(&dir); + let mut handle = + vault_create(path.clone(), test_key(), "aes-256-gcm".into(), 1_048_576).expect("create"); + vault_write(&mut handle, "b.txt".into(), b"world".to_vec(), None, None).expect("write"); + // Simulate a dirty handle at close time by manually setting the flag. + // This exercises the vault_close dirty-flush path. + handle.index_dirty = true; + vault_close(handle).expect("close"); + + let mut reopened = vault_open(path, test_key()).expect("reopen"); + assert_eq!( + vault_read(&mut reopened, "b.txt".into()).expect("read").data, + b"world" + ); + vault_close(reopened).expect("close"); +} + +#[test] +fn test_index_dirty_false_after_rotate_key() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = vault_path(&dir); + let handle = + vault_create(path.clone(), test_key(), "aes-256-gcm".into(), 1_048_576).expect("create"); + let rotated = vault_rotate_key(handle, test_key2()).expect("rotate"); + assert!(!rotated.index_dirty); + vault_close(rotated).expect("close"); +} + +#[test] +fn test_index_clean_after_defragment() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + vault_write(&mut handle, "a.txt".into(), b"aaa".to_vec(), None, None).expect("write a"); + vault_write(&mut handle, "b.txt".into(), b"bbb".to_vec(), None, None).expect("write b"); + vault_delete(&mut handle, "a.txt".into()).expect("delete a"); + vault_defragment(&mut handle).expect("defrag"); + assert!(!handle.index_dirty); + vault_close(handle).expect("close"); +} + +#[test] +fn test_index_clean_after_resize() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + vault_resize(&mut handle, 2_097_152).expect("grow"); + assert!(!handle.index_dirty); + vault_resize(&mut handle, 1_048_576).expect("shrink"); + assert!(!handle.index_dirty); + vault_close(handle).expect("close"); +} + diff --git a/rust/src/api/evfs/tests/metadata.rs b/rust/src/api/evfs/tests/metadata.rs new file mode 100644 index 0000000..973de84 --- /dev/null +++ b/rust/src/api/evfs/tests/metadata.rs @@ -0,0 +1,290 @@ +use super::*; + +// -- Segment Metadata ------------------------------------------------------ + +#[test] +fn test_metadata_write_read_roundtrip() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + let mut meta = std::collections::HashMap::new(); + meta.insert("mime".to_string(), "text/plain".to_string()); + meta.insert("created".to_string(), "2026-04-08".to_string()); + + vault_write( + &mut handle, + "doc.txt".into(), + b"hello metadata".to_vec(), + None, + Some(meta.clone()), + ) + .expect("write"); + + let result = vault_read(&mut handle, "doc.txt".into()).expect("read"); + assert_eq!(result.data, b"hello metadata"); + assert_eq!(result.metadata, meta); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_metadata_none_returns_empty_map() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write(&mut handle, "plain.txt".into(), b"no meta".to_vec(), None, None).expect("write"); + + let result = vault_read(&mut handle, "plain.txt".into()).expect("read"); + assert_eq!(result.data, b"no meta"); + assert!(result.metadata.is_empty()); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_metadata_overwrite_replaces_old() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + let mut meta_v1 = std::collections::HashMap::new(); + meta_v1.insert("version".to_string(), "1".to_string()); + + vault_write( + &mut handle, + "doc.txt".into(), + b"v1".to_vec(), + None, + Some(meta_v1), + ) + .expect("write v1"); + + let mut meta_v2 = std::collections::HashMap::new(); + meta_v2.insert("version".to_string(), "2".to_string()); + meta_v2.insert("author".to_string(), "test".to_string()); + + vault_write( + &mut handle, + "doc.txt".into(), + b"v2".to_vec(), + None, + Some(meta_v2.clone()), + ) + .expect("write v2"); + + let result = vault_read(&mut handle, "doc.txt".into()).expect("read"); + assert_eq!(result.data, b"v2"); + assert_eq!(result.metadata, meta_v2); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_metadata_independent_per_segment() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + let mut meta_a = std::collections::HashMap::new(); + meta_a.insert("type".to_string(), "image".to_string()); + let mut meta_b = std::collections::HashMap::new(); + meta_b.insert("type".to_string(), "document".to_string()); + + vault_write(&mut handle, "a.png".into(), b"img".to_vec(), None, Some(meta_a.clone())) + .expect("write a"); + vault_write(&mut handle, "b.pdf".into(), b"pdf".to_vec(), None, Some(meta_b.clone())) + .expect("write b"); + + let result_a = vault_read(&mut handle, "a.png".into()).expect("read a"); + let result_b = vault_read(&mut handle, "b.pdf".into()).expect("read b"); + + assert_eq!(result_a.metadata, meta_a); + assert_eq!(result_b.metadata, meta_b); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_metadata_empty_keys_and_values() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + let mut meta = std::collections::HashMap::new(); + meta.insert("".to_string(), "empty key".to_string()); + meta.insert("empty_val".to_string(), "".to_string()); + + vault_write( + &mut handle, + "edge.bin".into(), + b"data".to_vec(), + None, + Some(meta.clone()), + ) + .expect("write"); + + let result = vault_read(&mut handle, "edge.bin".into()).expect("read"); + assert_eq!(result.metadata, meta); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_metadata_survives_close_reopen() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = vault_path(&dir); + + let mut meta = std::collections::HashMap::new(); + meta.insert("persist".to_string(), "yes".to_string()); + + { + let mut handle = + vault_create(path.clone(), test_key(), "aes-256-gcm".into(), 1_048_576).expect("create"); + vault_write( + &mut handle, + "persist.txt".into(), + b"durable".to_vec(), + None, + Some(meta.clone()), + ) + .expect("write"); + vault_close(handle).expect("close"); + } + + let mut handle = vault_open(path, test_key()).expect("reopen"); + let result = vault_read(&mut handle, "persist.txt".into()).expect("read"); + assert_eq!(result.data, b"durable"); + assert_eq!(result.metadata, meta); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_metadata_survives_defragment() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + let mut meta = std::collections::HashMap::new(); + meta.insert("tag".to_string(), "keep".to_string()); + + vault_write(&mut handle, "a.txt".into(), vec![0xAA; 200], None, None).expect("write a"); + vault_write( + &mut handle, + "b.txt".into(), + vec![0xBB; 200], + None, + Some(meta.clone()), + ) + .expect("write b"); + + vault_delete(&mut handle, "a.txt".into()).expect("delete a"); + vault_defragment(&mut handle).expect("defrag"); + + let result = vault_read(&mut handle, "b.txt".into()).expect("read b"); + assert_eq!(result.data, vec![0xBB; 200]); + assert_eq!(result.metadata, meta); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_metadata_survives_resize() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + let mut meta = std::collections::HashMap::new(); + meta.insert("resize".to_string(), "test".to_string()); + + vault_write( + &mut handle, + "doc.txt".into(), + b"data".to_vec(), + None, + Some(meta.clone()), + ) + .expect("write"); + + vault_resize(&mut handle, 2_097_152).expect("grow"); + + let result = vault_read(&mut handle, "doc.txt".into()).expect("read"); + assert_eq!(result.data, b"data"); + assert_eq!(result.metadata, meta); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_metadata_survives_key_rotation() { + let dir = tempfile::tempdir().expect("tempdir"); + + let mut meta = std::collections::HashMap::new(); + meta.insert("mime".to_string(), "application/pdf".to_string()); + + let mut handle = create_test_vault(&dir, 1_048_576); + vault_write( + &mut handle, + "file.pdf".into(), + b"pdf content".to_vec(), + None, + Some(meta.clone()), + ) + .expect("write"); + + let mut handle = vault_rotate_key(handle, test_key2()).expect("rotate"); + + let result = vault_read(&mut handle, "file.pdf".into()).expect("read"); + assert_eq!(result.data, b"pdf content"); + assert_eq!(result.metadata, meta); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_metadata_large_near_index_limit() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + let mut meta = std::collections::HashMap::new(); + for i in 0..100 { + meta.insert(format!("key_{i:03}"), format!("value_{i:03}_padding")); + } + + vault_write( + &mut handle, + "big_meta.bin".into(), + b"data".to_vec(), + None, + Some(meta.clone()), + ) + .expect("write"); + + let result = vault_read(&mut handle, "big_meta.bin".into()).expect("read"); + assert_eq!(result.data, b"data"); + assert_eq!(result.metadata, meta); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_metadata_rename_preserves() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + let mut meta = std::collections::HashMap::new(); + meta.insert("tag".to_string(), "renamed".to_string()); + + vault_write( + &mut handle, + "old_name.txt".into(), + b"data".to_vec(), + None, + Some(meta.clone()), + ) + .expect("write"); + + vault_rename_segment(&mut handle, "old_name.txt".into(), "new_name.txt".into()) + .expect("rename"); + + let result = vault_read(&mut handle, "new_name.txt".into()).expect("read"); + assert_eq!(result.data, b"data"); + assert_eq!(result.metadata, meta); + + vault_close(handle).expect("close"); +} diff --git a/rust/src/api/evfs/tests/mod.rs b/rust/src/api/evfs/tests/mod.rs new file mode 100644 index 0000000..93b81cc --- /dev/null +++ b/rust/src/api/evfs/tests/mod.rs @@ -0,0 +1,65 @@ +use super::*; +use crate::core::evfs::format::{encrypted_index_size, MIN_INDEX_PAD_SIZE}; + +pub(super) fn test_key() -> Vec { + vec![0xAA; 32] +} + +pub(super) fn test_key2() -> Vec { + vec![0xAB; 32] +} + +pub(super) fn wrong_key() -> Vec { + vec![0xBB; 32] +} + +pub(super) fn create_test_vault(dir: &tempfile::TempDir, capacity: u64) -> VaultHandle { + let path = dir + .path() + .join("test.vault") + .to_str() + .expect("path") + .to_string(); + vault_create(path, test_key(), "aes-256-gcm".into(), capacity).expect("create vault") +} + +pub(super) fn vault_path(dir: &tempfile::TempDir) -> String { + dir.path() + .join("test.vault") + .to_str() + .expect("path") + .to_string() +} + +pub(super) const SIZE_MB: u64 = 0x100000; + +/// Helper: stream-write data in fixed-size pieces. +pub(super) fn stream_write_chunks( + handle: &mut VaultHandle, + name: &str, + data: &[u8], + piece_size: usize, +) -> Result<(), CryptoError> { + let chunks: Vec> = data.chunks(piece_size).map(|c| c.to_vec()).collect(); + vault_write_stream( + handle, + name.to_string(), + data.len() as u64, + chunks.into_iter(), + None, + ) +} + +mod crud; +mod compression; +mod allocator; +mod delete; +mod resize; +mod defragment; +mod streaming; +mod rotation; +mod export_import; +mod index_cache; +mod parallel; +mod rename; +mod metadata; diff --git a/rust/src/api/evfs/tests/parallel.rs b/rust/src/api/evfs/tests/parallel.rs new file mode 100644 index 0000000..1da1032 --- /dev/null +++ b/rust/src/api/evfs/tests/parallel.rs @@ -0,0 +1,210 @@ +use super::*; + +// -- Parallel reads ------------------------------------------------------- + +// Compile-time assertion: VaultHandle must be Sync for &VaultHandle across rayon threads +#[allow(dead_code)] +trait AssertSync: Sync {} +impl AssertSync for super::types::VaultHandle {} + +#[test] +fn test_parallel_read_matches_sequential() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 4_194_304); + + vault_write(&mut handle, "a.txt".into(), b"alpha".to_vec(), None, None).expect("write a"); + vault_write(&mut handle, "b.txt".into(), b"bravo".to_vec(), None, None).expect("write b"); + vault_write(&mut handle, "c.txt".into(), b"charlie".to_vec(), None, None).expect("write c"); + + let seq_a = vault_read(&mut handle, "a.txt".into()).expect("read a").data; + let seq_b = vault_read(&mut handle, "b.txt".into()).expect("read b").data; + let seq_c = vault_read(&mut handle, "c.txt".into()).expect("read c").data; + + let results = vault_read_parallel( + &handle, + vec!["a.txt".into(), "b.txt".into(), "c.txt".into()], + ); + + assert_eq!(results.len(), 3); + assert!(results[0].error.is_none()); + assert_eq!(results[0].data, seq_a); + assert_eq!(results[1].data, seq_b); + assert_eq!(results[2].data, seq_c); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_parallel_read_10_segments() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 4_194_304); + + let mut expected = Vec::new(); + for i in 0..10u8 { + let name = format!("seg_{i}"); + let data = vec![i; 1024]; + vault_write(&mut handle, name, data.clone(), None, None).expect("write"); + expected.push(data); + } + + let names: Vec = (0..10).map(|i| format!("seg_{i}")).collect(); + let results = vault_read_parallel(&handle, names); + + assert_eq!(results.len(), 10); + for (i, sr) in results.iter().enumerate() { + assert!(sr.error.is_none(), "segment {i} had error: {:?}", sr.error); + assert_eq!(sr.name, format!("seg_{i}")); + assert_eq!(sr.data, expected[i]); + } + + vault_close(handle).expect("close"); +} + +#[test] +fn test_parallel_read_mixed_monolithic_and_streaming() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 4_194_304); + + let mono_data = vec![0xAA; 512]; + vault_write(&mut handle, "mono".into(), mono_data.clone(), None, None).expect("write mono"); + + let stream_data: Vec = (0..=255u8).cycle().take(100_000).collect(); + let stream_iter = stream_data.chunks(8192).map(|c| c.to_vec()); + vault_write_stream( + &mut handle, + "stream".into(), + stream_data.len() as u64, + stream_iter, + None, + ) + .expect("write stream"); + + let results = vault_read_parallel(&handle, vec!["mono".into(), "stream".into()]); + + assert_eq!(results.len(), 2); + assert!(results[0].error.is_none()); + assert_eq!(results[0].data, mono_data); + assert!(results[1].error.is_none()); + assert_eq!(results[1].data, stream_data); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_parallel_read_missing_segment() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write(&mut handle, "exists".into(), vec![1, 2, 3], None, None).expect("write"); + + let results = vault_read_parallel(&handle, vec!["exists".into(), "missing".into()]); + + assert_eq!(results.len(), 2); + assert!(results[0].error.is_none()); + assert_eq!(results[0].data, vec![1, 2, 3]); + assert!(results[1].error.is_some()); + assert!( + results[1] + .error + .as_ref() + .expect("error") + .contains("missing"), + "error should mention segment name" + ); + assert!(results[1].data.is_empty()); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_parallel_read_empty_names() { + let dir = tempfile::tempdir().expect("tempdir"); + let handle = create_test_vault(&dir, 1_048_576); + + let results = vault_read_parallel(&handle, vec![]); + assert!(results.is_empty()); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_parallel_read_single_name() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write(&mut handle, "only".into(), b"solo".to_vec(), None, None).expect("write"); + + let seq = vault_read(&mut handle, "only".into()).expect("sequential").data; + let results = vault_read_parallel(&handle, vec!["only".into()]); + + assert_eq!(results.len(), 1); + assert!(results[0].error.is_none()); + assert_eq!(results[0].data, seq); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_parallel_read_preserves_order() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 4_194_304); + + for i in 0..10u8 { + vault_write(&mut handle, format!("seg_{i}"), vec![i; 256], None, None).expect("write"); + } + + let names: Vec = (0..10).rev().map(|i| format!("seg_{i}")).collect(); + let results = vault_read_parallel(&handle, names.clone()); + + assert_eq!(results.len(), 10); + for (i, sr) in results.iter().enumerate() { + assert!(sr.error.is_none()); + assert_eq!(sr.name, names[i]); + } + + vault_close(handle).expect("close"); +} + +#[test] +fn test_parallel_read_chacha20() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = vault_path(&dir); + + let mut handle = + vault_create(path, test_key(), "chacha20-poly1305".into(), 4_194_304).expect("create"); + + vault_write(&mut handle, "x".into(), b"chacha-data".to_vec(), None, None).expect("write"); + vault_write(&mut handle, "y".into(), b"poly1305-data".to_vec(), None, None).expect("write"); + + let results = vault_read_parallel(&handle, vec!["x".into(), "y".into()]); + + assert!(results[0].error.is_none()); + assert_eq!(results[0].data, b"chacha-data"); + assert!(results[1].error.is_none()); + assert_eq!(results[1].data, b"poly1305-data"); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_parallel_read_fallback_sequential() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 4_194_304); + + vault_write(&mut handle, "a".into(), b"alpha".to_vec(), None, None).expect("write a"); + vault_write(&mut handle, "b".into(), b"bravo".to_vec(), None, None).expect("write b"); + + // Force no-mmap fallback + handle.mmap = None; + + let results = vault_read_parallel(&handle, vec!["a".into(), "b".into()]); + + assert_eq!(results.len(), 2); + assert!(results[0].error.is_none()); + assert_eq!(results[0].data, b"alpha"); + assert!(results[1].error.is_none()); + assert_eq!(results[1].data, b"bravo"); + + vault_close(handle).expect("close"); +} + diff --git a/rust/src/api/evfs/tests/rename.rs b/rust/src/api/evfs/tests/rename.rs new file mode 100644 index 0000000..f6cd4ab --- /dev/null +++ b/rust/src/api/evfs/tests/rename.rs @@ -0,0 +1,177 @@ +use super::*; + +// -- Rename Segment Tests ----------------------------------------------- + +#[test] +fn test_rename_read_success() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write(&mut handle, "old.txt".into(), b"data".to_vec(), None, None).expect("write"); + vault_rename_segment(&mut handle, "old.txt".into(), "new.txt".into()).expect("rename"); + + let data = vault_read(&mut handle, "new.txt".into()).expect("read new").data; + assert_eq!(data, b"data"); + + let result = vault_read(&mut handle, "old.txt".into()); + assert!(matches!(result, Err(CryptoError::SegmentNotFound(_)))); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_rename_duplicate_name_fails() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write(&mut handle, "a.txt".into(), b"a".to_vec(), None, None).expect("write a"); + vault_write(&mut handle, "b.txt".into(), b"b".to_vec(), None, None).expect("write b"); + + let result = vault_rename_segment(&mut handle, "a.txt".into(), "b.txt".into()); + assert!(matches!(result, Err(CryptoError::DuplicateSegment(_)))); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_rename_nonexistent_fails() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + let result = vault_rename_segment(&mut handle, "missing.txt".into(), "new.txt".into()); + assert!(matches!(result, Err(CryptoError::SegmentNotFound(_)))); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_rename_preserves_metadata() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write(&mut handle, "old.txt".into(), b"data".to_vec(), None, None).expect("write"); + let old_entry = handle.index.find("old.txt").expect("find").clone(); + + vault_rename_segment(&mut handle, "old.txt".into(), "new.txt".into()).expect("rename"); + let new_entry = handle.index.find("new.txt").expect("find"); + + assert_eq!(old_entry.offset, new_entry.offset); + assert_eq!(old_entry.size, new_entry.size); + assert_eq!(old_entry.generation, new_entry.generation); + assert_eq!(old_entry.checksum, new_entry.checksum); + assert_eq!(old_entry.compression, new_entry.compression); + assert_eq!(old_entry.chunk_count, new_entry.chunk_count); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_rename_multiple_sequence() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write(&mut handle, "1.txt".into(), b"sequence".to_vec(), None, None).expect("write"); + vault_rename_segment(&mut handle, "1.txt".into(), "2.txt".into()).expect("r1"); + vault_rename_segment(&mut handle, "2.txt".into(), "3.txt".into()).expect("r2"); + + assert!(handle.index.find("1.txt").is_none()); + assert!(handle.index.find("2.txt").is_none()); + + let data = vault_read(&mut handle, "3.txt".into()).expect("read").data; + assert_eq!(data, b"sequence"); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_rename_streaming_segment() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 2_097_152); + + let data = vec![0x77; 150_000]; + let chunks: Vec> = data.chunks(65536).map(|c| c.to_vec()).collect(); + vault_write_stream( + &mut handle, + "stream.bin".into(), + data.len() as u64, + chunks.into_iter(), + None, + ) + .expect("write stream"); + + vault_rename_segment( + &mut handle, + "stream.bin".into(), + "renamed_stream.bin".into(), + ) + .expect("rename"); + + // Interop read on streaming segment + let readback = vault_read(&mut handle, "renamed_stream.bin".into()).expect("read").data; + assert_eq!(readback, data); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_rename_and_defragment() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write(&mut handle, "a.txt".into(), vec![0xAA; 100], None, None).expect("A"); + vault_write(&mut handle, "b.txt".into(), vec![0xBB; 100], None, None).expect("B"); + vault_write(&mut handle, "c.txt".into(), vec![0xCC; 100], None, None).expect("C"); + + vault_delete(&mut handle, "b.txt".into()).expect("del B"); + vault_rename_segment(&mut handle, "c.txt".into(), "c_renamed.txt".into()).expect("rename"); + + vault_defragment(&mut handle).expect("defrag"); + + let data = vault_read(&mut handle, "c_renamed.txt".into()).expect("read").data; + assert_eq!(data, vec![0xCC; 100]); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_rename_chacha20() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = vault_path(&dir); + let mut handle = + vault_create(path, test_key(), "chacha20-poly1305".into(), 1_048_576).expect("create"); + + vault_write( + &mut handle, + "old_chacha.txt".into(), + b"chacha".to_vec(), + None, + None, + ) + .expect("write"); + vault_rename_segment( + &mut handle, + "old_chacha.txt".into(), + "new_chacha.txt".into(), + ) + .expect("rename"); + + let data = vault_read(&mut handle, "new_chacha.txt".into()).expect("read").data; + assert_eq!(data, b"chacha"); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_rename_to_same_name_is_noop() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write(&mut handle, "same.txt".into(), b"data".to_vec(), None, None).expect("write"); + vault_rename_segment(&mut handle, "same.txt".into(), "same.txt".into()).expect("noop rename"); + + let data = vault_read(&mut handle, "same.txt".into()).expect("read").data; + assert_eq!(data, b"data"); + + vault_close(handle).expect("close"); +} + diff --git a/rust/src/api/evfs/tests/resize.rs b/rust/src/api/evfs/tests/resize.rs new file mode 100644 index 0000000..c0873b6 --- /dev/null +++ b/rust/src/api/evfs/tests/resize.rs @@ -0,0 +1,310 @@ +use super::*; + +// -- Resize ------------------------------------------------------------- + +#[test] +fn test_resize_grow_then_write_in_new_space() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, SIZE_MB); + + // Fill most of the original 1MB capacity + let filler = vec![0xAA; 900_000]; + vault_write(&mut handle, "filler.bin".into(), filler.clone(), None, None).expect("write filler"); + + // Grow to 2MB + vault_resize(&mut handle, 2 * SIZE_MB).expect("grow to 2MB"); + + // Write a segment that requires space in the new region + let big = vec![0xBB; 900_000]; + vault_write(&mut handle, "big.bin".into(), big.clone(), None, None).expect("write in new space"); + + // Read both back + let filler_read = vault_read(&mut handle, "filler.bin".into()).expect("read filler").data; + assert_eq!(filler_read, filler); + let big_read = vault_read(&mut handle, "big.bin".into()).expect("read big").data; + assert_eq!(big_read, big); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_resize_shrink_after_consolidation() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = vault_path(&dir); + + // Create a 2MB vault and write ~500KB of data. + { + let mut handle = vault_create(path.clone(), test_key(), "aes-256-gcm".into(), 2 * SIZE_MB) + .expect("create 2MB"); + let data = vec![0xCC; 500_000]; + vault_write(&mut handle, "doc.bin".into(), data, None, None).expect("write 500KB"); + vault_close(handle).expect("close"); + } + + // Reopen, shrink to 1MB. Data is at the beginning so it should fit. + { + let mut handle = vault_open(path.clone(), test_key()).expect("reopen"); + vault_resize(&mut handle, SIZE_MB).expect("shrink to 1MB"); + + // Verify all data is still readable. + let data = vault_read(&mut handle, "doc.bin".into()).expect("read after shrink").data; + assert_eq!(data, vec![0xCC; 500_000]); + + // Verify capacity reflects the new size. + let cap = vault_capacity(&handle); + assert_eq!(cap.total_bytes, SIZE_MB); + + vault_close(handle).expect("close"); + } + + // Reopen again to confirm persistence across close/open. + { + let mut handle = vault_open(path, test_key()).expect("reopen again"); + let data = vault_read(&mut handle, "doc.bin".into()).expect("read persisted").data; + assert_eq!(data, vec![0xCC; 500_000]); + vault_close(handle).expect("close"); + } +} + +#[test] +fn test_resize_shrink_below_used_space_fails() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, SIZE_MB); + + // Write enough data so that shrinking below used space is impossible. + vault_write(&mut handle, "a.bin".into(), vec![0xDD; 600_000], None, None).expect("write"); + + // Attempt to shrink to 256KB — should fail with VaultFull. + let result = vault_resize(&mut handle, 256 * 1024); + assert!( + matches!(result, Err(CryptoError::VaultFull { .. })), + "expected VaultFull, got: {result:?}" + ); + + // Vault should still be usable after failed shrink. + let data = vault_read(&mut handle, "a.bin".into()).expect("read after failed shrink").data; + assert_eq!(data, vec![0xDD; 600_000]); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_resize_grow_updates_capacity() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = vault_path(&dir); + + { + let mut handle = + vault_create(path.clone(), test_key(), "aes-256-gcm".into(), SIZE_MB).expect("create"); + + let cap_before = vault_capacity(&handle); + assert_eq!(cap_before.total_bytes, SIZE_MB); + + vault_resize(&mut handle, 2 * SIZE_MB).expect("grow"); + + let cap_after = vault_capacity(&handle); + assert_eq!(cap_after.total_bytes, 2 * SIZE_MB); + // Unallocated should have grown by ~1MB. + assert!(cap_after.unallocated_bytes > cap_before.unallocated_bytes); + + vault_close(handle).expect("close"); + } + + // Verify persistence: reopen and check capacity. + { + let handle = vault_open(path, test_key()).expect("reopen"); + let cap = vault_capacity(&handle); + assert_eq!(cap.total_bytes, 2 * SIZE_MB); + vault_close(handle).expect("close"); + } +} + +#[test] +fn test_resize_grow_crash_recovery() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = vault_path(&dir); + + // Create vault with one segment. + { + let mut handle = + vault_create(path.clone(), test_key(), "aes-256-gcm".into(), SIZE_MB).expect("create"); + vault_write(&mut handle, "a.txt".into(), b"before grow".to_vec(), None, None).expect("write A"); + vault_close(handle).expect("close"); + } + + // Save the "good" encrypted index (1MB capacity, contains A). + let good_encrypted = { + let mut f = File::open(&path).expect("open"); + read_encrypted_index( + &mut f, + PRIMARY_INDEX_OFFSET, + encrypted_index_size(MIN_INDEX_PAD_SIZE), + ) + .expect("read index") + }; + + // Perform a real grow to 2MB. + { + let mut handle = vault_open(path.clone(), test_key()).expect("open"); + vault_resize(&mut handle, 2 * SIZE_MB).expect("grow"); + vault_write(&mut handle, "b.txt".into(), b"after grow".to_vec(), None, None).expect("write B"); + vault_close(handle).expect("close"); + } + + // Simulate crash: write an uncommitted WAL entry that restores the + // pre-grow index (1MB capacity, only A). This simulates a crash + // that occurred mid-grow before commit. + { + let mut wal = WriteAheadLog::open(&path).expect("wal"); + wal.begin(WalOp::UpdateIndex, &good_encrypted) + .expect("begin"); + // Don't commit — simulates crash. + } + + // Reopen — WAL recovery should roll back to the A-only / 1MB index. + let mut handle = vault_open(path, test_key()).expect("open after recovery"); + + // A should be readable. + let data = vault_read(&mut handle, "a.txt".into()).expect("read A").data; + assert_eq!(data, b"before grow"); + + // B should NOT be in the recovered index. + let result = vault_read(&mut handle, "b.txt".into()); + assert!(matches!(result, Err(CryptoError::SegmentNotFound(_)))); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_resize_same_capacity_is_noop() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, SIZE_MB); + + vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None, None).expect("write"); + let cap_before = vault_capacity(&handle); + + // Resize to same capacity — should return Ok without I/O + vault_resize(&mut handle, SIZE_MB).expect("noop resize"); + + let cap_after = vault_capacity(&handle); + assert_eq!(cap_before.total_bytes, cap_after.total_bytes); + assert_eq!(cap_before.used_bytes, cap_after.used_bytes); + + let data = vault_read(&mut handle, "a.txt".into()).expect("read").data; + assert_eq!(data, b"hello"); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_resize_shrink_after_defrag_reclaims_max_space() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 2 * SIZE_MB); + + // Write three segments, delete the first two to create gaps + vault_write(&mut handle, "a.bin".into(), vec![0xAA; 200_000], None, None).expect("A"); + vault_write(&mut handle, "b.bin".into(), vec![0xBB; 200_000], None, None).expect("B"); + vault_write(&mut handle, "keep.bin".into(), vec![0xCC; 100_000], None, None).expect("keep"); + + vault_delete(&mut handle, "a.bin".into()).expect("del A"); + vault_delete(&mut handle, "b.bin".into()).expect("del B"); + + // Before defrag: free regions exist, shrink is limited + assert!(handle.index.needs_defrag()); + + // Defrag compacts keep.bin to offset 0 + vault_defragment(&mut handle).expect("defrag"); + assert!(!handle.index.needs_defrag()); + + // After defrag: used space is only keep.bin's encrypted size + let used = handle.index.used_bytes(); + assert!(used < 200_000, "used {used} should be less than 200KB"); + + // Shrink to just above used space (round up to 256KB boundary) + let new_cap = ((used / (256 * 1024)) + 1) * (256 * 1024); + vault_resize(&mut handle, new_cap).expect("shrink after defrag"); + + let cap = vault_capacity(&handle); + assert_eq!(cap.total_bytes, new_cap); + + // Data intact after shrink + let data = vault_read(&mut handle, "keep.bin".into()).expect("read keep").data; + assert_eq!(data, vec![0xCC; 100_000]); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_resize_grow_crash_midway_recovers() { + use crate::core::evfs::segment; + + let dir = tempfile::tempdir().expect("tempdir"); + let path = vault_path(&dir); + + // Create vault and write a segment. + let good_encrypted; + { + let mut handle = + vault_create(path.clone(), test_key(), "aes-256-gcm".into(), SIZE_MB).expect("create"); + vault_write(&mut handle, "a.txt".into(), b"safe data".to_vec(), None, None).expect("write A"); + vault_close(handle).expect("close"); + } + + // Capture the good encrypted index before simulating a partial grow. + { + let mut f = File::open(&path).expect("open"); + good_encrypted = read_encrypted_index( + &mut f, + PRIMARY_INDEX_OFFSET, + encrypted_index_size(MIN_INDEX_PAD_SIZE), + ) + .expect("read idx"); + } + + // Simulate a crash mid-grow: extend the file and CSPRNG-fill + // (destroying the old shadow), but leave an uncommitted WAL entry. + { + let mut f = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .expect("open rw"); + let new_total = format::total_vault_size(2 * SIZE_MB, MIN_INDEX_PAD_SIZE).expect("size"); + f.set_len(new_total).expect("extend"); + // CSPRNG-fill overwrites old shadow position at format::data_region_offset(MIN_INDEX_PAD_SIZE) + SIZE_MB + segment::secure_erase_region( + &mut f, + format::data_region_offset(MIN_INDEX_PAD_SIZE) + SIZE_MB, + SIZE_MB, + ) + .expect("fill"); + + let mut wal = WriteAheadLog::open(&path).expect("wal"); + wal.begin(WalOp::UpdateIndex, &good_encrypted) + .expect("begin"); + // Don't commit — simulates crash + } + + // Reopen — recovery should restore 1MB index, fix file size, fix shadow + let mut handle = vault_open(path.clone(), test_key()).expect("open after crash"); + + // Capacity should be restored to 1MB + assert_eq!(vault_capacity(&handle).total_bytes, SIZE_MB); + + // File size should match restored capacity + let actual_size = handle.file.seek(SeekFrom::End(0)).expect("seek"); + let expected_size = format::total_vault_size(SIZE_MB, MIN_INDEX_PAD_SIZE).expect("size"); + assert_eq!(actual_size, expected_size); + + // Data intact + let data = vault_read(&mut handle, "a.txt".into()).expect("read A").data; + assert_eq!(data, b"safe data"); + + // Vault usable after recovery — write + read works + vault_write(&mut handle, "b.txt".into(), b"post recovery".to_vec(), None, None).expect("write B"); + let b_data = vault_read(&mut handle, "b.txt".into()).expect("read B").data; + assert_eq!(b_data, b"post recovery"); + + vault_close(handle).expect("close"); +} + diff --git a/rust/src/api/evfs/tests/rotation.rs b/rust/src/api/evfs/tests/rotation.rs new file mode 100644 index 0000000..1dac888 --- /dev/null +++ b/rust/src/api/evfs/tests/rotation.rs @@ -0,0 +1,230 @@ +use super::*; + +// -- Key Rotation ----------------------------------------------------------- + +#[test] +fn test_rotate_key_basic() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None, None).expect("write a"); + vault_write(&mut handle, "b.txt".into(), b"world".to_vec(), None, None).expect("write b"); + + let mut handle = vault_rotate_key(handle, test_key2()).expect("rotate"); + + assert_eq!( + vault_read(&mut handle, "a.txt".into()).expect("read a").data, + b"hello" + ); + assert_eq!( + vault_read(&mut handle, "b.txt".into()).expect("read b").data, + b"world" + ); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_rotate_key_old_key_fails() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = vault_path(&dir); + + { + let mut handle = vault_create(path.clone(), test_key(), "aes-256-gcm".into(), 1_048_576) + .expect("create"); + vault_write( + &mut handle, + "secret.txt".into(), + b"top secret".to_vec(), + None, + None, + ) + .expect("write"); + let handle = vault_rotate_key(handle, test_key2()).expect("rotate"); + vault_close(handle).expect("close"); + } + + // Old key must be rejected + assert!(vault_open(path.clone(), test_key()).is_err()); + + // New key must still work and data must be intact + let mut handle = vault_open(path, test_key2()).expect("open with new key"); + assert_eq!( + vault_read(&mut handle, "secret.txt".into()).expect("read").data, + b"top secret" + ); + vault_close(handle).expect("close"); +} + +#[test] +fn test_rotate_key_streaming_segment_survives() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 2 * 1024 * 1024); + + let data = vec![0x42u8; 200_000]; // spans multiple chunks + stream_write_chunks(&mut handle, "video.bin", &data, 4096).expect("stream write"); + + let mut handle = vault_rotate_key(handle, test_key2()).expect("rotate"); + + let readback = vault_read(&mut handle, "video.bin".into()).expect("read after rotate").data; + assert_eq!(readback, data); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_rotate_key_compressed_segment_survives() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + let data = b"compressible repeated payload ".repeat(100); + let config = CompressionConfig { + algorithm: CompressionAlgorithm::Zstd, + level: None, + }; + vault_write(&mut handle, "data.bin".into(), data.clone(), Some(config), None).expect("write"); + + let mut handle = vault_rotate_key(handle, test_key2()).expect("rotate"); + + let readback = vault_read(&mut handle, "data.bin".into()).expect("read after rotate").data; + assert_eq!(readback, data); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_rotate_key_checksum_preserved() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write( + &mut handle, + "doc.txt".into(), + b"checksum test data".to_vec(), + None, + None, + ) + .expect("write"); + + let checksum_before = handle.index.find("doc.txt").expect("find before").checksum; + + let handle = vault_rotate_key(handle, test_key2()).expect("rotate"); + + let checksum_after = handle.index.find("doc.txt").expect("find after").checksum; + + assert_eq!( + checksum_before, checksum_after, + "BLAKE3 checksum must be identical before and after rotation" + ); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_rotate_key_crash_recovery_rotating_cleaned_up() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = vault_path(&dir); + + { + let handle = vault_create(path.clone(), test_key(), "aes-256-gcm".into(), 1_048_576) + .expect("create"); + vault_close(handle).expect("close"); + } + + // Plant a stale .rotating file to simulate a crash mid-rotation. + let rotating_path = format!("{path}.rotating"); + std::fs::write(&rotating_path, b"stale junk").expect("plant stale file"); + assert!(std::path::Path::new(&rotating_path).exists()); + + // vault_open must silently remove the orphan and succeed normally. + let handle = vault_open(path, test_key()).expect("open after simulated crash"); + vault_close(handle).expect("close"); + + assert!( + !std::path::Path::new(&rotating_path).exists(), + ".rotating file must be cleaned up by vault_open" + ); +} + +#[test] +fn test_rotate_key_empty_vault() { + let dir = tempfile::tempdir().expect("tempdir"); + let handle = create_test_vault(&dir, 1_048_576); + + // Rotate with no segments written at all. + let handle = vault_rotate_key(handle, test_key2()).expect("rotate empty vault"); + + assert!( + vault_list(&handle).is_empty(), + "rotated empty vault must have no segments" + ); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_rotate_key_chacha20() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir + .path() + .join("chacha.vault") + .to_str() + .expect("path") + .to_string(); + + let mut handle = + vault_create(path, test_key(), "chacha20-poly1305".into(), 1_048_576).expect("create"); + vault_write( + &mut handle, + "msg.txt".into(), + b"chacha payload".to_vec(), + None, + None, + ) + .expect("write"); + + let mut handle = vault_rotate_key(handle, test_key2()).expect("rotate chacha20 vault"); + + let readback = vault_read(&mut handle, "msg.txt".into()).expect("read after rotate").data; + assert_eq!(readback, b"chacha payload"); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_rotate_key_multiple_rotations() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write( + &mut handle, + "doc.txt".into(), + b"original data".to_vec(), + None, + None, + ) + .expect("write"); + + // First rotation: test_key → new_key + let handle = vault_rotate_key(handle, test_key2()).expect("first rotation"); + // Second rotation: new_key → wrong_key + let mut handle = vault_rotate_key(handle, wrong_key()).expect("second rotation"); + + let readback = vault_read(&mut handle, "doc.txt".into()).expect("read after two rotations").data; + assert_eq!(readback, b"original data"); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_rotate_key_empty_new_key_rejected() { + let dir = tempfile::tempdir().expect("tempdir"); + let handle = create_test_vault(&dir, 1_048_576); + + let result = vault_rotate_key(handle, vec![]); + assert!( + result.is_err(), + "rotation with an empty new key must return an error" + ); +} + diff --git a/rust/src/api/evfs/tests/streaming.rs b/rust/src/api/evfs/tests/streaming.rs new file mode 100644 index 0000000..24511ce --- /dev/null +++ b/rust/src/api/evfs/tests/streaming.rs @@ -0,0 +1,822 @@ +use super::*; + +// -- Streaming Read & Interop Tests ------------------------------------- +#[test] +fn test_oneshot_write_oneshot_read_interop() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + let data = b"monolithic data".to_vec(); + vault_write(&mut handle, "mono.txt".into(), data.clone(), None, None).expect("write"); + + let entry = handle.index.find("mono.txt").expect("find"); + assert_eq!(entry.chunk_count, 0, "Should be written as monolithic"); + + let read_back = vault_read(&mut handle, "mono.txt".into()).expect("read").data; + assert_eq!(read_back, data); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_stream_write_oneshot_read_matches_interop() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 5_000_000); + + let chunk_size = crate::core::streaming::CHUNK_SIZE; + // 3 full chunks, 1 partial padded chunk + let data = vec![0x77; chunk_size * 3 + 1234]; + + // Write using stream + let chunks: Vec> = data.chunks(chunk_size).map(|c| c.to_vec()).collect(); + vault_write_stream( + &mut handle, + "streamed.bin".into(), + data.len() as u64, + chunks.into_iter(), + None, + ) + .expect("stream write"); + + let entry = handle.index.find("streamed.bin").expect("find"); + assert!(entry.chunk_count > 0, "Should be written as chunked"); + + // Read using one-shot (interop) testing the chunk-assembly loop + let read_back = vault_read(&mut handle, "streamed.bin".into()).expect("read").data; + assert_eq!(read_back, data, "Data should match byte-for-byte"); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_tamper_with_chunk_detected() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + let chunk_size = crate::core::streaming::CHUNK_SIZE; + let data = vec![0xAA; chunk_size * 2]; + + let chunks = vec![data[..chunk_size].to_vec(), data[chunk_size..].to_vec()]; + vault_write_stream( + &mut handle, + "streamed.txt".into(), + data.len() as u64, + chunks.into_iter(), + None, + ) + .expect("stream write"); + + let entry = handle.index.find("streamed.txt").expect("find"); + let disk_offset = + crate::core::evfs::format::data_region_offset(handle.index_pad_size) + entry.offset; + + // Seek past nonce (12 bytes) and flip a ciphertext byte in the first chunk + handle + .file + .seek(SeekFrom::Start(disk_offset + 13)) + .expect("seek"); + handle.file.write_all(&[0xFF]).expect("tamper"); + handle.file.sync_all().expect("sync"); + + let result = vault_read(&mut handle, "streamed.txt".into()); + assert!( + matches!(result, Err(CryptoError::AuthenticationFailed)), + "Should detect chunk tampering via independent AEAD" + ); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_reordered_chunks_detected() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + let chunk_size = crate::core::streaming::CHUNK_SIZE; + let enc_chunk_size = crate::core::streaming::ENCRYPTED_CHUNK_SIZE as u64; + let data = vec![0xBB; chunk_size * 2]; + + let chunks = vec![data[..chunk_size].to_vec(), data[chunk_size..].to_vec()]; + vault_write_stream( + &mut handle, + "reorder.bin".into(), + data.len() as u64, + chunks.into_iter(), + None, + ) + .expect("stream write"); + + let entry = handle.index.find("reorder.bin").expect("find"); + let disk_offset = + crate::core::evfs::format::data_region_offset(handle.index_pad_size) + entry.offset; + + // Read chunk 0 and chunk 1 + let mut c0 = vec![0u8; enc_chunk_size as usize]; + let mut c1 = vec![0u8; enc_chunk_size as usize]; + + handle + .file + .seek(SeekFrom::Start(disk_offset)) + .expect("seek"); + handle.file.read_exact(&mut c0).expect("read c0"); + handle.file.read_exact(&mut c1).expect("read c1"); + + // Swap them on disk + handle + .file + .seek(SeekFrom::Start(disk_offset)) + .expect("seek"); + handle.file.write_all(&c1).expect("write c1"); + handle.file.write_all(&c0).expect("write c0"); + handle.file.sync_all().expect("sync"); + + let result = vault_read(&mut handle, "reorder.bin".into()); + assert!( + matches!(result, Err(CryptoError::AuthenticationFailed)), + "Should detect chunk reordering due to index mismatch in AAD" + ); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_integrity_checksum_failure_on_stream() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + let data = vec![0xCC; 1000]; + vault_write_stream( + &mut handle, + "checksum.bin".into(), + data.len() as u64, + vec![data].into_iter(), + None, + ) + .expect("write"); + + // Manually corrupt the checksum stored in the Segment Index + let entry = handle.index.find_mut("checksum.bin").expect("find"); + entry.checksum[0] ^= 0xFF; + + let result = vault_read(&mut handle, "checksum.bin".into()); + assert!( + matches!(result, Err(CryptoError::VaultCorrupted(_))), + "Should detect BLAKE3 checksum mismatch even if AEAD passes" + ); + + vault_close(handle).expect("close"); +} + +// -- Streaming Write -------------------------------------------------------- + +#[test] +fn test_stream_write_read_roundtrip() { + let dir = tempfile::tempdir().expect("tempdir"); + // 2MB vault to fit the streaming overhead + let mut handle = create_test_vault(&dir, 2 * 1024 * 1024); + + let data = vec![0x42u8; 200_000]; // ~3 chunks + stream_write_chunks(&mut handle, "video.bin", &data, 4096).expect("stream write"); + + let readback = vault_read(&mut handle, "video.bin".into()).expect("read").data; + assert_eq!(readback, data); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_stream_write_single_byte() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + let data = vec![0xAA; 1]; + stream_write_chunks(&mut handle, "tiny.bin", &data, 1).expect("stream write"); + + let readback = vault_read(&mut handle, "tiny.bin".into()).expect("read").data; + assert_eq!(readback, data); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_stream_write_empty_segment() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + // 0 bytes — still produces 1 padded chunk + vault_write_stream(&mut handle, "empty.bin".into(), 0, std::iter::empty(), None) + .expect("stream write empty"); + + let readback = vault_read(&mut handle, "empty.bin".into()).expect("read").data; + assert!(readback.is_empty()); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_stream_write_exact_chunk_boundary() { + use crate::core::streaming::CHUNK_SIZE; + + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 2 * 1024 * 1024); + + // Exactly CHUNK_SIZE bytes — triggers the extra empty padded chunk + let data = vec![0xBB; CHUNK_SIZE]; + stream_write_chunks(&mut handle, "aligned.bin", &data, CHUNK_SIZE).expect("stream write"); + + let readback = vault_read(&mut handle, "aligned.bin".into()).expect("read").data; + assert_eq!(readback, data); + + // Verify chunk_count = 2 (1 full + 1 empty padded) + let entry = handle.index.find("aligned.bin").expect("entry"); + assert_eq!(entry.chunk_count, 2); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_stream_write_overwrite_existing() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 2 * 1024 * 1024); + + // Write original (monolithic) + vault_write( + &mut handle, + "doc.txt".into(), + b"original data".to_vec(), + None, + None, + ) + .expect("write"); + + // Overwrite with streaming (larger) + let new_data = vec![0xCC; 100_000]; + stream_write_chunks(&mut handle, "doc.txt", &new_data, 8192).expect("stream overwrite"); + + let readback = vault_read(&mut handle, "doc.txt".into()).expect("read").data; + assert_eq!(readback, new_data); + + // Verify it's now a streaming segment + let entry = handle.index.find("doc.txt").expect("entry"); + assert!(entry.is_streaming()); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_stream_write_overwrite_with_smaller() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 2 * 1024 * 1024); + + // Write original (streaming, large) + let original = vec![0xAA; 200_000]; + stream_write_chunks(&mut handle, "file.bin", &original, 4096).expect("write large"); + + // Overwrite with smaller streaming segment + let smaller = vec![0xBB; 1000]; + stream_write_chunks(&mut handle, "file.bin", &smaller, 500).expect("write small"); + + let readback = vault_read(&mut handle, "file.bin".into()).expect("read").data; + assert_eq!(readback, smaller); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_stream_write_wrong_size_too_few_bytes() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + // Claim 1000 bytes but provide only 500 + let data = vec![0xAA; 500]; + let result = vault_write_stream(&mut handle, "bad.bin".into(), 1000, vec![data].into_iter(), None); + assert!(result.is_err()); + let err = result + .expect_err("expected an error (underflow)") + .to_string(); + assert!(err.contains("underflow"), "Expected underflow error: {err}"); +} + +#[test] +fn test_stream_write_wrong_size_too_many_bytes() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + // Claim 500 bytes but provide 1000 + let data = vec![0xAA; 1000]; + let result = vault_write_stream(&mut handle, "bad.bin".into(), 500, vec![data].into_iter(), None); + assert!(result.is_err()); + let err = result + .expect_err("expected an error (exceeded)") + .to_string(); + assert!(err.contains("exceeded"), "Expected exceeded error: {err}"); +} + +#[test] +fn test_stream_write_persist_reopen() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = vault_path(&dir); + + let data = vec![0xDD; 150_000]; + { + let mut handle = vault_create( + path.clone(), + test_key(), + "aes-256-gcm".into(), + 2 * 1024 * 1024, + ) + .expect("create"); + stream_write_chunks(&mut handle, "persist.bin", &data, 4096).expect("stream write"); + vault_close(handle).expect("close"); + } + + // Reopen and verify + let mut handle = vault_open(path, test_key()).expect("open"); + let readback = vault_read(&mut handle, "persist.bin".into()).expect("read").data; + assert_eq!(readback, data); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_stream_write_chacha20() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir + .path() + .join("chacha.vault") + .to_str() + .expect("path") + .to_string(); + let mut handle = vault_create( + path, + test_key(), + "chacha20-poly1305".into(), + 2 * 1024 * 1024, + ) + .expect("create"); + + let data = vec![0xEE; 100_000]; + stream_write_chunks(&mut handle, "chacha.bin", &data, 8192).expect("stream write"); + + let readback = vault_read(&mut handle, "chacha.bin".into()).expect("read").data; + assert_eq!(readback, data); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_stream_write_arbitrary_input_sizes() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 2 * 1024 * 1024); + + // Provide data in irregular chunks (1, 7, 100, 50000, 3, ...) + let total = 100_000usize; + let data: Vec = (0..total).map(|i| (i % 256) as u8).collect(); + + let pieces = vec![ + data[..1].to_vec(), + data[1..8].to_vec(), + data[8..108].to_vec(), + data[108..50108].to_vec(), + data[50108..50111].to_vec(), + data[50111..].to_vec(), + ]; + + vault_write_stream( + &mut handle, + "irregular.bin".into(), + total as u64, + pieces.into_iter(), + None, + ) + .expect("stream write"); + + let readback = vault_read(&mut handle, "irregular.bin".into()).expect("read").data; + assert_eq!(readback, data); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_stream_write_coexists_with_monolithic() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 2 * 1024 * 1024); + + // Write monolithic + vault_write(&mut handle, "mono.txt".into(), b"mono data".to_vec(), None, None).expect("write mono"); + + // Write streaming + let stream_data = vec![0xFF; 80_000]; + stream_write_chunks(&mut handle, "stream.bin", &stream_data, 4096).expect("stream write"); + + // Read both back + let mono = vault_read(&mut handle, "mono.txt".into()).expect("read mono").data; + assert_eq!(mono, b"mono data"); + + let stream = vault_read(&mut handle, "stream.bin".into()).expect("read stream").data; + assert_eq!(stream, stream_data); + + // Verify types + assert!(!handle + .index + .find("mono.txt") + .expect("mono.txt missing") + .is_streaming()); + assert!(handle + .index + .find("stream.bin") + .expect("stream.bin missing") + .is_streaming()); + + vault_close(handle).expect("close"); +} + +// -- Streaming Read (via decrypt_streaming_chunks) -------------------------- + +/// Helper: stream-read all chunks into a Vec, returning (data, chunk_indices, checksum). +#[allow(clippy::type_complexity)] +fn stream_read_chunks( + handle: &mut VaultHandle, + name: &str, +) -> Result<(Vec, Vec, [u8; 32]), CryptoError> { + let entry = handle + .index + .find(name) + .ok_or_else(|| CryptoError::SegmentNotFound(name.into()))?; + + let seg_offset = entry.offset; + let seg_gen = entry.generation; + let seg_compression = entry.compression; + let chunk_count = entry.chunk_count; + + let mut collected = Vec::new(); + let mut indices = Vec::new(); + + let checksum = decrypt_streaming_chunks( + &mut handle.file, + handle.mmap.as_ref(), + handle.keys.cipher_key.as_bytes(), + handle.keys.nonce_key.as_bytes(), + handle.algorithm, + handle.index_pad_size, + seg_offset, + seg_gen, + seg_compression, + chunk_count, + |data, i| { + collected.extend_from_slice(&data); + indices.push(i); + Ok(()) + }, + )?; + + Ok((collected, indices, checksum)) +} + +#[test] +fn test_stream_read_matches_oneshot_read() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 2 * 1024 * 1024); + + let data = vec![0x42u8; 200_000]; + stream_write_chunks(&mut handle, "video.bin", &data, 4096).expect("stream write"); + + let oneshot = vault_read(&mut handle, "video.bin".into()).expect("oneshot read").data; + let (streamed, _, _) = stream_read_chunks(&mut handle, "video.bin").expect("stream read"); + + assert_eq!( + streamed, oneshot, + "streaming and one-shot must be byte-identical" + ); + assert_eq!(streamed, data); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_stream_read_checksum_matches_stored() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 2 * 1024 * 1024); + + let data = vec![0xAA; 150_000]; + stream_write_chunks(&mut handle, "file.bin", &data, 8192).expect("stream write"); + + let stored_checksum = handle.index.find("file.bin").expect("find").checksum; + let (_, _, computed_checksum) = + stream_read_chunks(&mut handle, "file.bin").expect("stream read"); + + assert_eq!(computed_checksum, stored_checksum); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_stream_read_progress_indices() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 2 * 1024 * 1024); + + let chunk_size = crate::core::streaming::CHUNK_SIZE; + let data = vec![0xBB; chunk_size * 3 + 1234]; + stream_write_chunks(&mut handle, "prog.bin", &data, chunk_size).expect("stream write"); + + let chunk_count = handle.index.find("prog.bin").expect("find").chunk_count; + let (collected, indices, _) = stream_read_chunks(&mut handle, "prog.bin").expect("stream read"); + + assert_eq!(collected, data); + assert_eq!(indices.len(), chunk_count as usize); + let expected: Vec = (0..chunk_count).collect(); + assert_eq!(indices, expected); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_stream_read_single_byte() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + let data = vec![0xCC; 1]; + stream_write_chunks(&mut handle, "tiny.bin", &data, 1).expect("stream write"); + + let (collected, indices, _) = stream_read_chunks(&mut handle, "tiny.bin").expect("stream read"); + + assert_eq!(collected, data); + assert_eq!(indices.len(), 1); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_stream_read_empty_segment() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write_stream(&mut handle, "empty.bin".into(), 0, std::iter::empty(), None) + .expect("stream write empty"); + + let (collected, indices, _) = + stream_read_chunks(&mut handle, "empty.bin").expect("stream read"); + + assert!(collected.is_empty()); + assert_eq!(indices.len(), 1); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_stream_read_exact_chunk_boundary() { + use crate::core::streaming::CHUNK_SIZE; + + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 2 * 1024 * 1024); + + let data = vec![0xDD; CHUNK_SIZE]; + stream_write_chunks(&mut handle, "aligned.bin", &data, CHUNK_SIZE).expect("stream write"); + + let (collected, _, checksum) = + stream_read_chunks(&mut handle, "aligned.bin").expect("stream read"); + + assert_eq!(collected, data); + let stored = handle.index.find("aligned.bin").expect("find").checksum; + assert_eq!(checksum, stored); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_stream_read_large_segment() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 12 * 1024 * 1024); + + let data: Vec = (0..10_000_000).map(|i| (i % 251) as u8).collect(); + stream_write_chunks(&mut handle, "big.bin", &data, 65536).expect("stream write"); + + let (collected, indices, checksum) = + stream_read_chunks(&mut handle, "big.bin").expect("stream read"); + + assert_eq!(collected.len(), data.len()); + assert_eq!(collected, data); + assert!(!indices.is_empty()); + assert_eq!( + checksum, + handle.index.find("big.bin").expect("find").checksum + ); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_stream_read_tamper_detected() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + let chunk_size = crate::core::streaming::CHUNK_SIZE; + let data = vec![0xEE; chunk_size * 2]; + stream_write_chunks(&mut handle, "tamper.bin", &data, chunk_size).expect("stream write"); + + let entry = handle.index.find("tamper.bin").expect("find"); + let disk_offset = + crate::core::evfs::format::data_region_offset(handle.index_pad_size) + entry.offset; + + handle + .file + .seek(std::io::SeekFrom::Start(disk_offset + 13)) + .expect("seek"); + handle.file.write_all(&[0xFF]).expect("tamper"); + handle.file.sync_all().expect("sync"); + + let result = stream_read_chunks(&mut handle, "tamper.bin"); + assert!( + matches!(result, Err(CryptoError::AuthenticationFailed)), + "Should detect chunk tampering via streaming read" + ); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_stream_read_reorder_detected() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + let chunk_size = crate::core::streaming::CHUNK_SIZE; + let enc_chunk_size = crate::core::streaming::ENCRYPTED_CHUNK_SIZE as u64; + let data = vec![0xFF; chunk_size * 2]; + stream_write_chunks(&mut handle, "reorder.bin", &data, chunk_size).expect("stream write"); + + let entry = handle.index.find("reorder.bin").expect("find"); + let disk_offset = + crate::core::evfs::format::data_region_offset(handle.index_pad_size) + entry.offset; + + let mut c0 = vec![0u8; enc_chunk_size as usize]; + let mut c1 = vec![0u8; enc_chunk_size as usize]; + handle + .file + .seek(std::io::SeekFrom::Start(disk_offset)) + .expect("seek"); + handle.file.read_exact(&mut c0).expect("read c0"); + handle.file.read_exact(&mut c1).expect("read c1"); + handle + .file + .seek(std::io::SeekFrom::Start(disk_offset)) + .expect("seek"); + handle.file.write_all(&c1).expect("write c1"); + handle.file.write_all(&c0).expect("write c0"); + handle.file.sync_all().expect("sync"); + + let result = stream_read_chunks(&mut handle, "reorder.bin"); + assert!( + matches!(result, Err(CryptoError::AuthenticationFailed)), + "Should detect chunk reordering via streaming read" + ); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_stream_read_chacha20() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir + .path() + .join("test.vault") + .to_str() + .expect("path") + .to_string(); + let mut handle = vault_create( + path, + test_key(), + "chacha20-poly1305".into(), + 2 * 1024 * 1024, + ) + .expect("create"); + + let data = vec![0x77; 200_000]; + stream_write_chunks(&mut handle, "chacha.bin", &data, 4096).expect("stream write"); + + let (collected, _, _) = stream_read_chunks(&mut handle, "chacha.bin").expect("stream read"); + assert_eq!(collected, data); + + vault_close(handle).expect("close"); +} + +// -- vault_write_file (FRB wrapper) ----------------------------------------- + +/// Helper: write `data` to a temp file and return the path. +fn write_temp_file(dir: &tempfile::TempDir, name: &str, data: &[u8]) -> String { + let path = dir.path().join(name); + std::fs::write(&path, data).expect("write temp file"); + path.to_str().expect("path").to_string() +} + +/// Fake progress sink that collects values (vault_write_file can't use StreamSink in tests, +/// but the underlying vault_write_stream is what's actually tested here). +#[test] +fn test_write_file_roundtrip() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 2 * 1024 * 1024); + + let data = vec![0x42u8; 200_000]; + let file_path = write_temp_file(&dir, "input.bin", &data); + + // vault_write_file needs StreamSink — test the underlying path instead: + // read file → feed to vault_write_stream → read back + use crate::core::streaming::CHUNK_SIZE; + let file_data = std::fs::read(&file_path).expect("read file"); + let chunks: Vec> = file_data.chunks(CHUNK_SIZE).map(|c| c.to_vec()).collect(); + vault_write_stream( + &mut handle, + "from_file.bin".into(), + file_data.len() as u64, + chunks.into_iter(), + None, + ) + .expect("write stream"); + + let readback = vault_read(&mut handle, "from_file.bin".into()).expect("read").data; + assert_eq!(readback, data); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_write_file_stream_read_interop() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 2 * 1024 * 1024); + + let data: Vec = (0..150_000).map(|i| (i % 199) as u8).collect(); + let file_path = write_temp_file(&dir, "interop.bin", &data); + + use crate::core::streaming::CHUNK_SIZE; + let file_data = std::fs::read(&file_path).expect("read file"); + let chunks: Vec> = file_data.chunks(CHUNK_SIZE).map(|c| c.to_vec()).collect(); + vault_write_stream( + &mut handle, + "interop.bin".into(), + file_data.len() as u64, + chunks.into_iter(), + None, + ) + .expect("write stream"); + + // Read back via streaming read helper + let (collected, _, checksum) = + stream_read_chunks(&mut handle, "interop.bin").expect("stream read"); + assert_eq!(collected, data); + assert_eq!( + checksum, + handle.index.find("interop.bin").expect("find").checksum + ); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_write_file_large() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 12 * 1024 * 1024); + + let data: Vec = (0..5_000_000).map(|i| (i % 251) as u8).collect(); + let file_path = write_temp_file(&dir, "large.bin", &data); + + use crate::core::streaming::CHUNK_SIZE; + let file_data = std::fs::read(&file_path).expect("read file"); + let chunks: Vec> = file_data.chunks(CHUNK_SIZE).map(|c| c.to_vec()).collect(); + vault_write_stream( + &mut handle, + "large.bin".into(), + file_data.len() as u64, + chunks.into_iter(), + None, + ) + .expect("write stream"); + + let readback = vault_read(&mut handle, "large.bin".into()).expect("read").data; + assert_eq!(readback.len(), data.len()); + assert_eq!(readback, data); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_write_file_empty() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + let file_path = write_temp_file(&dir, "empty.bin", &[]); + + use crate::core::streaming::CHUNK_SIZE; + let file_data = std::fs::read(&file_path).expect("read file"); + let chunks: Vec> = file_data.chunks(CHUNK_SIZE).map(|c| c.to_vec()).collect(); + vault_write_stream(&mut handle, "empty.bin".into(), 0, chunks.into_iter(), None) + .expect("write stream"); + + let readback = vault_read(&mut handle, "empty.bin".into()).expect("read").data; + assert!(readback.is_empty()); + + vault_close(handle).expect("close"); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + From f5d1edd0b7bfceb2421d0b2162de7607f0676611 Mon Sep 17 00:00:00 2001 From: Adel-Ayoub Date: Wed, 8 Apr 2026 21:48:21 +0100 Subject: [PATCH 20/32] fix(evfs): version index format for backward-compatible metadata deserialization --- rust/src/api/evfs/helpers.rs | 5 +- rust/src/core/evfs/format.rs | 93 ++++++++++++++++++++++-------------- 2 files changed, 61 insertions(+), 37 deletions(-) diff --git a/rust/src/api/evfs/helpers.rs b/rust/src/api/evfs/helpers.rs index 03184f2..4dfdc38 100644 --- a/rust/src/api/evfs/helpers.rs +++ b/rust/src/api/evfs/helpers.rs @@ -70,10 +70,13 @@ pub(crate) fn read_encrypted_index( } /// Decrypt an encrypted index blob into a SegmentIndex. +/// +/// `vault_version` selects the wire format: v1 = no metadata, v2+ = with metadata. pub(crate) fn decrypt_index_blob( encrypted: &[u8], keys: &VaultKeys, algorithm: Algorithm, + vault_version: u8, ) -> Result { let plaintext = segment::aead_decrypt_with_stored_nonce( keys.index_key.as_bytes(), @@ -81,7 +84,7 @@ pub(crate) fn decrypt_index_blob( &[], algorithm, )?; - SegmentIndex::from_bytes(&plaintext) + SegmentIndex::from_bytes(&plaintext, vault_version) } /// Compute vault data capacity from file size. diff --git a/rust/src/core/evfs/format.rs b/rust/src/core/evfs/format.rs index dee0c62..0fa899c 100644 --- a/rust/src/core/evfs/format.rs +++ b/rust/src/core/evfs/format.rs @@ -9,7 +9,7 @@ use std::collections::HashMap; // --------------------------------------------------------------------------- pub const VAULT_MAGIC: &[u8; 4] = b"MVLT"; -pub const VAULT_VERSION: u8 = 1; +pub const VAULT_VERSION: u8 = 2; pub const VAULT_HEADER_SIZE: usize = 32; /// Max segment name length in bytes (UTF-8). @@ -188,7 +188,7 @@ impl VaultHeader { )); } let version = data[4]; - if version != VAULT_VERSION { + if version != 1 && version != VAULT_VERSION { return Err(CryptoError::VaultCorrupted(format!( "unsupported vault version: {version}" ))); @@ -435,7 +435,14 @@ impl SegmentIndex { let meta_count = u16::try_from(entry.metadata.len()).map_err(|_| { CryptoError::InvalidParameter("too many metadata pairs for u16".into()) })?; + if meta_count > MAX_METADATA_PAIRS { + return Err(CryptoError::InvalidParameter(format!( + "segment '{}': {} metadata pairs exceeds limit {MAX_METADATA_PAIRS}", + entry.name, meta_count + ))); + } put_u16(&mut buf, meta_count); + let meta_start = buf.len(); for (key, val) in &entry.metadata { let klen = u16::try_from(key.len()).map_err(|_| { CryptoError::InvalidParameter("metadata key too long for u16".into()) @@ -448,6 +455,12 @@ impl SegmentIndex { put_u16(&mut buf, vlen); buf.extend_from_slice(val.as_bytes()); } + if buf.len() - meta_start > MAX_METADATA_BYTES { + return Err(CryptoError::InvalidParameter(format!( + "segment '{}': metadata exceeds {MAX_METADATA_BYTES} byte limit", + entry.name + ))); + } } for region in &self.free_regions { @@ -467,7 +480,10 @@ impl SegmentIndex { } /// Deserialize index from decrypted bytes. - pub fn from_bytes(data: &[u8]) -> Result { + /// + /// `vault_version` controls the wire format: v1 entries have no metadata + /// field, v2+ entries include `[meta_count: u16]` after `chunk_count`. + pub fn from_bytes(data: &[u8], vault_version: u8) -> Result { if data.len() < 32 { return Err(CryptoError::VaultCorrupted("index too short".into())); } @@ -534,34 +550,39 @@ impl SegmentIndex { } } - // Metadata: [meta_count: u16] ([key_len: u16][key][val_len: u16][val])* - let meta_count = read_u16(data, &mut off)?; - if meta_count > MAX_METADATA_PAIRS { - return Err(CryptoError::VaultCorrupted(format!( - "segment '{name}': metadata count {meta_count} exceeds limit {MAX_METADATA_PAIRS}" - ))); - } - let mut metadata = HashMap::with_capacity(meta_count as usize); - let meta_start = off; - for _ in 0..meta_count { - let klen = read_u16(data, &mut off)? as usize; - let key_bytes = read_bytes(data, &mut off, klen)?; - let key = String::from_utf8(key_bytes).map_err(|_| { - CryptoError::VaultCorrupted("metadata key is not valid UTF-8".into()) - })?; - let vlen = read_u16(data, &mut off)? as usize; - let val_bytes = read_bytes(data, &mut off, vlen)?; - let val = String::from_utf8(val_bytes).map_err(|_| { - CryptoError::VaultCorrupted("metadata value is not valid UTF-8".into()) - })?; - // OOM guard: cap total metadata bytes consumed - if off - meta_start > MAX_METADATA_BYTES { + // Metadata: v2+ only. v1 entries have no metadata on disk. + let metadata = if vault_version >= 2 { + let meta_count = read_u16(data, &mut off)?; + if meta_count > MAX_METADATA_PAIRS { return Err(CryptoError::VaultCorrupted(format!( - "segment '{name}': metadata exceeds {MAX_METADATA_BYTES} byte limit" + "segment '{name}': metadata count {meta_count} exceeds limit {MAX_METADATA_PAIRS}" ))); } - metadata.insert(key, val); - } + let mut map = HashMap::with_capacity(meta_count as usize); + let meta_start = off; + for _ in 0..meta_count { + // OOM guard: check before allocating the next pair + if off - meta_start > MAX_METADATA_BYTES { + return Err(CryptoError::VaultCorrupted(format!( + "segment '{name}': metadata exceeds {MAX_METADATA_BYTES} byte limit" + ))); + } + let klen = read_u16(data, &mut off)? as usize; + let key_bytes = read_bytes(data, &mut off, klen)?; + let key = String::from_utf8(key_bytes).map_err(|_| { + CryptoError::VaultCorrupted("metadata key is not valid UTF-8".into()) + })?; + let vlen = read_u16(data, &mut off)? as usize; + let val_bytes = read_bytes(data, &mut off, vlen)?; + let val = String::from_utf8(val_bytes).map_err(|_| { + CryptoError::VaultCorrupted("metadata value is not valid UTF-8".into()) + })?; + map.insert(key, val); + } + map + } else { + HashMap::new() + }; entries.push(SegmentEntry { name, @@ -972,7 +993,7 @@ mod tests { let bytes = idx .to_bytes(compute_index_size(idx.capacity)) .expect("serialize"); - let parsed = SegmentIndex::from_bytes(&bytes).expect("parse"); + let parsed = SegmentIndex::from_bytes(&bytes, VAULT_VERSION).expect("parse"); assert_eq!(parsed.entries.len(), 2); assert_eq!(parsed.entries[0].name, "hello.txt"); @@ -1038,7 +1059,7 @@ mod tests { let bytes = idx .to_bytes(compute_index_size(idx.capacity)) .expect("serialize"); - let parsed = SegmentIndex::from_bytes(&bytes).expect("parse"); + let parsed = SegmentIndex::from_bytes(&bytes, VAULT_VERSION).expect("parse"); assert_eq!(parsed.entries[0].metadata, meta); assert!(parsed.entries[1].metadata.is_empty()); @@ -1064,7 +1085,7 @@ mod tests { let bytes = idx .to_bytes(compute_index_size(idx.capacity)) .expect("serialize"); - let parsed = SegmentIndex::from_bytes(&bytes).expect("parse"); + let parsed = SegmentIndex::from_bytes(&bytes, VAULT_VERSION).expect("parse"); assert_eq!(parsed.next_generation, 42); assert_eq!(parsed.entries[0].generation, 41); } @@ -1097,7 +1118,7 @@ mod tests { let bytes = idx .to_bytes(compute_index_size(idx.capacity)) .expect("serialize"); - let parsed = SegmentIndex::from_bytes(&bytes).expect("parse"); + let parsed = SegmentIndex::from_bytes(&bytes, VAULT_VERSION).expect("parse"); assert_eq!(parsed.entries[0].compression, CompressionAlgorithm::Zstd); assert_eq!(parsed.entries[1].compression, CompressionAlgorithm::Brotli); assert_eq!(parsed.entries[2].compression, CompressionAlgorithm::None); @@ -1121,7 +1142,7 @@ mod tests { let bytes = idx .to_bytes(compute_index_size(idx.capacity)) .expect("serialize"); - let parsed = SegmentIndex::from_bytes(&bytes).expect("parse"); + let parsed = SegmentIndex::from_bytes(&bytes, VAULT_VERSION).expect("parse"); assert_eq!(parsed.free_regions.len(), 3); assert_eq!( parsed.free_regions[0], @@ -1775,7 +1796,7 @@ mod tests { let bytes = idx .to_bytes(compute_index_size(idx.capacity)) .expect("serialize"); - let parsed = SegmentIndex::from_bytes(&bytes).expect("parse"); + let parsed = SegmentIndex::from_bytes(&bytes, VAULT_VERSION).expect("parse"); assert_eq!(parsed.entries[0].chunk_count, 0); assert!(!parsed.entries[0].is_streaming()); @@ -1803,7 +1824,7 @@ mod tests { let bytes = idx .to_bytes(compute_index_size(idx.capacity)) .expect("serialize"); - let result = SegmentIndex::from_bytes(&bytes); + let result = SegmentIndex::from_bytes(&bytes, VAULT_VERSION); assert!(result.is_err()); let err = result.expect_err("should fail").to_string(); assert!(err.contains("chunk_count"), "Error: {err}"); @@ -1823,7 +1844,7 @@ mod tests { let bytes2 = idx2 .to_bytes(compute_index_size(idx2.capacity)) .expect("serialize"); - assert!(SegmentIndex::from_bytes(&bytes2).is_ok()); + assert!(SegmentIndex::from_bytes(&bytes2, VAULT_VERSION).is_ok()); } #[test] From 73cca1b8eacde7e9886a6daed0d7abfafbc685a9 Mon Sep 17 00:00:00 2001 From: Adel-Ayoub Date: Wed, 8 Apr 2026 21:48:28 +0100 Subject: [PATCH 21/32] feat(evfs): preserve segment metadata through export/import --- rust/src/api/evfs/mod.rs | 65 ++++++++++++++++++--- rust/src/api/evfs/tests/export_import.rs | 55 +++++++++++++++++- rust/src/core/evfs/archive.rs | 74 ++++++++++++++++++++++-- 3 files changed, 180 insertions(+), 14 deletions(-) diff --git a/rust/src/api/evfs/mod.rs b/rust/src/api/evfs/mod.rs index 3d68541..9cfb15d 100644 --- a/rust/src/api/evfs/mod.rs +++ b/rust/src/api/evfs/mod.rs @@ -195,12 +195,12 @@ pub fn vault_open(path: String, mut key: Vec) -> Result idx, Err(_) => { let shadow_off = format::shadow_index_offset(capacity, index_pad_size)?; let shadow_bytes = read_encrypted_index(&mut file, shadow_off, enc_idx_size)?; - let idx = decrypt_index_blob(&shadow_bytes, &keys, algorithm).map_err(|_| { + let idx = decrypt_index_blob(&shadow_bytes, &keys, algorithm, header.version).map_err(|_| { CryptoError::VaultCorrupted( "both primary and shadow index are corrupted".into(), ) @@ -1033,6 +1033,9 @@ pub fn vault_health(handle: &VaultHandle) -> VaultHealthInfo { /// be mindful of total memory when reading many large segments at once. /// /// Falls back to sequential file-based reads when mmap is unavailable. +/// +/// **Note:** This API does not return per-segment metadata. Use [`vault_read`] +/// if you need the `metadata` field from [`SegmentReadResult`]. #[cfg(feature = "compression")] pub fn vault_read_parallel(handle: &VaultHandle, names: Vec) -> Vec { if names.is_empty() { @@ -1810,7 +1813,7 @@ fn vault_export_write( let vault_capacity = handle.index.capacity; - for (name, offset, size, generation, compression, checksum, chunk_count, _metadata) in &entries { + for (name, offset, size, generation, compression, checksum, chunk_count, metadata) in &entries { // Decrypt the segment plaintext (handles both monolithic and streaming) let plaintext: Zeroizing> = if *chunk_count > 0 { let mut full_plaintext: Zeroizing> = Zeroizing::new(Vec::new()); @@ -1885,14 +1888,18 @@ fn vault_export_write( compression: compression.to_u8(), checksum: record_checksum, encrypted_data, + metadata: metadata.clone(), }; let record_header = record.write_header()?; + let record_meta = record.write_metadata()?; out.write_all(&record_header)?; out.write_all(&record.encrypted_data)?; + out.write_all(&record_meta)?; archive_hasher.update(&record_header); archive_hasher.update(&record.encrypted_data); + archive_hasher.update(&record_meta); } // Write BLAKE3 trailer + fsync @@ -1924,6 +1931,9 @@ pub fn vault_close(mut handle: VaultHandle) -> Result<(), CryptoError> { } /// Unwraps export key, creates new vault at dest_path, writes all segments under new_master_key. +/// +/// Archives with version < 2 do not carry per-segment metadata; imported +/// segments from those archives will have empty metadata. #[cfg(feature = "compression")] pub fn vault_import( archive_path: String, @@ -1934,8 +1944,8 @@ pub fn vault_import( capacity_bytes: u64, ) -> Result { use crate::core::evfs::archive::{ - ArchiveHeader, ArchiveTrailer, ARCHIVE_HEADER_SIZE, ARCHIVE_TRAILER_SIZE, KEY_WRAP_AAD, - WRAPPED_KEY_SIZE, + ArchiveHeader, ArchiveTrailer, SegmentRecord, ARCHIVE_HEADER_SIZE, ARCHIVE_TRAILER_SIZE, + KEY_WRAP_AAD, WRAPPED_KEY_SIZE, }; // Ensure automatic zeroization on drop @@ -2041,6 +2051,46 @@ pub fn vault_import( CryptoError::ImportFailed("Truncated segment encrypted data".into()) })?; + // v2: read metadata after encrypted_data + let seg_metadata = if header.version >= 2 { + // Read meta_count first to know how many bytes to expect + let mut mc_buf = [0u8; 2]; + archive.read_exact(&mut mc_buf).map_err(|_| { + CryptoError::ImportFailed("truncated segment metadata count".into()) + })?; + let meta_count = u16::from_le_bytes(mc_buf) as usize; + // Read remaining metadata pairs as raw bytes, then parse + let mut meta_bytes = Vec::from(mc_buf.as_slice()); + for _ in 0..meta_count { + let mut klen_buf = [0u8; 2]; + archive.read_exact(&mut klen_buf).map_err(|_| { + CryptoError::ImportFailed("truncated metadata key length".into()) + })?; + let klen = u16::from_le_bytes(klen_buf) as usize; + meta_bytes.extend_from_slice(&klen_buf); + let mut key_buf = vec![0u8; klen]; + archive.read_exact(&mut key_buf).map_err(|_| { + CryptoError::ImportFailed("truncated metadata key".into()) + })?; + meta_bytes.extend_from_slice(&key_buf); + let mut vlen_buf = [0u8; 2]; + archive.read_exact(&mut vlen_buf).map_err(|_| { + CryptoError::ImportFailed("truncated metadata value length".into()) + })?; + let vlen = u16::from_le_bytes(vlen_buf) as usize; + meta_bytes.extend_from_slice(&vlen_buf); + let mut val_buf = vec![0u8; vlen]; + archive.read_exact(&mut val_buf).map_err(|_| { + CryptoError::ImportFailed("truncated metadata value".into()) + })?; + meta_bytes.extend_from_slice(&val_buf); + } + let (map, _) = SegmentRecord::read_metadata(&meta_bytes)?; + (map, meta_bytes) + } else { + (std::collections::HashMap::new(), Vec::new()) + }; + // Feed global trailer hasher let mut header_bytes = Vec::new(); header_bytes.extend_from_slice(&name_len_buf); @@ -2051,6 +2101,7 @@ pub fn vault_import( archive_hasher.update(&header_bytes); archive_hasher.update(&encrypted_data); + archive_hasher.update(&seg_metadata.1); // 4. Decrypt using export_key let mut plaintext = segment::aead_decrypt_with_stored_nonce( @@ -2084,7 +2135,7 @@ pub fn vault_import( }; // vault_write takes ownership of plaintext and handles its zeroization internally - // NOTE: metadata is not preserved in .mvex v1 — imported segments get empty metadata + let import_meta = if seg_metadata.0.is_empty() { None } else { Some(seg_metadata.0) }; vault_write( &mut dest_vault, name, @@ -2093,7 +2144,7 @@ pub fn vault_import( algorithm: comp_algo, level: None, }), - None, + import_meta, )?; } diff --git a/rust/src/api/evfs/tests/export_import.rs b/rust/src/api/evfs/tests/export_import.rs index cb5801c..4c25e69 100644 --- a/rust/src/api/evfs/tests/export_import.rs +++ b/rust/src/api/evfs/tests/export_import.rs @@ -46,11 +46,19 @@ fn parse_archive( pos += hdr_size; let enc_data = data[pos..pos + data_len as usize].to_vec(); pos += data_len as usize; + // v2: read metadata after encrypted_data + let (seg_meta, meta_consumed) = if header.version >= 2 { + SegmentRecord::read_metadata(&data[pos..]).expect("parse metadata") + } else { + (std::collections::HashMap::new(), 0) + }; + pos += meta_consumed; records.push(SegmentRecord { name, compression, checksum, encrypted_data: enc_data, + metadata: seg_meta, }); } @@ -154,6 +162,7 @@ fn test_segment_record_header_roundtrip() { compression: 0x01, checksum: [0xDD; 32], encrypted_data: vec![0xFF; 100], + metadata: std::collections::HashMap::new(), }; let header = record.write_header().expect("write header"); let (name, comp, cksum, data_len, consumed) = @@ -194,7 +203,7 @@ fn test_export_produces_valid_archive() { let (header, wrapped_key, records, _) = parse_archive(&epath); assert_eq!(header.segment_count, 2); - assert_eq!(header.version, 1); + assert_eq!(header.version, 2); assert_eq!(records.len(), 2); // Decrypt and verify contents @@ -675,3 +684,47 @@ fn test_import_chacha20() { vault_close(imported).expect("close"); } +#[test] +fn test_export_import_preserves_metadata() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + let mut meta = std::collections::HashMap::new(); + meta.insert("mime".to_string(), "text/plain".to_string()); + meta.insert("author".to_string(), "test".to_string()); + + vault_write( + &mut handle, + "doc.txt".into(), + b"hello".to_vec(), + None, + Some(meta.clone()), + ) + .expect("write"); + vault_write(&mut handle, "bare.txt".into(), b"no meta".to_vec(), None, None).expect("write"); + + let epath = export_path(&dir); + vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); + vault_close(handle).expect("close"); + + let dest = dir + .path() + .join("imported.vault") + .to_str() + .expect("path") + .to_string(); + + vault_import(epath, wrapping_key(), dest.clone(), test_key2(), "aes-256-gcm".into(), 1_048_576).expect("import"); + + let mut imported = vault_open(dest, test_key2()).expect("open"); + + let result = vault_read(&mut imported, "doc.txt".into()).expect("read doc"); + assert_eq!(result.data, b"hello"); + assert_eq!(result.metadata, meta); + + let bare = vault_read(&mut imported, "bare.txt".into()).expect("read bare"); + assert!(bare.metadata.is_empty()); + + vault_close(imported).expect("close"); +} + diff --git a/rust/src/core/evfs/archive.rs b/rust/src/core/evfs/archive.rs index e7bb374..581b6e3 100644 --- a/rust/src/core/evfs/archive.rs +++ b/rust/src/core/evfs/archive.rs @@ -1,6 +1,7 @@ //! `.mvex` portable encrypted archive format — types, constants, and serialization. use crate::core::error::CryptoError; +use std::collections::HashMap; // --------------------------------------------------------------------------- // Constants @@ -8,7 +9,7 @@ use crate::core::error::CryptoError; pub const ARCHIVE_MAGIC: &[u8; 4] = b"MVEX"; pub const REVERSE_MAGIC: &[u8; 4] = b"XEVM"; -pub const ARCHIVE_VERSION: u8 = 1; +pub const ARCHIVE_VERSION: u8 = 2; pub const ARCHIVE_HEADER_SIZE: usize = 32; pub const WRAPPED_KEY_SIZE: usize = 60; // 12 nonce + 32 ciphertext + 16 tag @@ -64,7 +65,7 @@ impl ArchiveHeader { return Err(CryptoError::ExportFailed("invalid archive magic".into())); } let version = buf[4]; - if version != ARCHIVE_VERSION { + if version != 1 && version != ARCHIVE_VERSION { return Err(CryptoError::ExportFailed(format!( "unsupported archive version: {version}" ))); @@ -93,12 +94,14 @@ impl ArchiveHeader { /// [checksum: 32 bytes BLAKE3] /// [data_len: u64 LE] (length of encrypted_data: nonce + ciphertext + tag) /// [encrypted_data: data_len bytes] +/// v2: [meta_count: u16] ([key_len: u16][key][val_len: u16][val])* /// ``` pub struct SegmentRecord { pub name: String, pub compression: u8, pub checksum: [u8; 32], pub encrypted_data: Vec, + pub metadata: HashMap, } impl SegmentRecord { @@ -111,9 +114,7 @@ impl SegmentRecord { })?; let data_len = self.encrypted_data.len() as u64; - // name_len(2) + name + compression(1) + checksum(32) + data_len(8) - let header_size = 2 + name_bytes.len() + 1 + 32 + 8; - let mut buf = Vec::with_capacity(header_size); + let mut buf = Vec::with_capacity(128); buf.extend_from_slice(&name_len.to_le_bytes()); buf.extend_from_slice(name_bytes); @@ -124,7 +125,29 @@ impl SegmentRecord { Ok(buf) } - /// Read a segment record header from a byte slice, returning (record_minus_data, bytes_consumed). + /// Serialize metadata to bytes (appended after encrypted_data in the archive). + pub fn write_metadata(&self) -> Result, CryptoError> { + let meta_count = u16::try_from(self.metadata.len()).map_err(|_| { + CryptoError::ExportFailed("too many metadata pairs for archive".into()) + })?; + let mut buf = Vec::new(); + buf.extend_from_slice(&meta_count.to_le_bytes()); + for (key, val) in &self.metadata { + let klen = u16::try_from(key.len()).map_err(|_| { + CryptoError::ExportFailed("metadata key too long".into()) + })?; + let vlen = u16::try_from(val.len()).map_err(|_| { + CryptoError::ExportFailed("metadata value too long".into()) + })?; + buf.extend_from_slice(&klen.to_le_bytes()); + buf.extend_from_slice(key.as_bytes()); + buf.extend_from_slice(&vlen.to_le_bytes()); + buf.extend_from_slice(val.as_bytes()); + } + Ok(buf) + } + + /// Read a segment record header from a byte slice, returning (name, compression, checksum, data_len, bytes_consumed). /// The caller must then read `data_len` bytes of encrypted_data. pub fn read_header(data: &[u8]) -> Result<(String, u8, [u8; 32], u64, usize), CryptoError> { if data.len() < 2 { @@ -158,6 +181,45 @@ impl SegmentRecord { Ok((name, compression, checksum, data_len, pos)) } + + /// Read metadata from a byte slice (after encrypted_data in v2 archives). + /// Returns the metadata map and the number of bytes consumed. + pub fn read_metadata(data: &[u8]) -> Result<(HashMap, usize), CryptoError> { + if data.len() < 2 { + return Err(CryptoError::ExportFailed("truncated metadata".into())); + } + let meta_count = u16::from_le_bytes([data[0], data[1]]) as usize; + let mut pos = 2; + let mut metadata = HashMap::with_capacity(meta_count); + for _ in 0..meta_count { + if pos + 2 > data.len() { + return Err(CryptoError::ExportFailed("truncated metadata key length".into())); + } + let klen = u16::from_le_bytes([data[pos], data[pos + 1]]) as usize; + pos += 2; + if pos + klen > data.len() { + return Err(CryptoError::ExportFailed("truncated metadata key".into())); + } + let key = std::str::from_utf8(&data[pos..pos + klen]) + .map_err(|_| CryptoError::ExportFailed("metadata key not valid UTF-8".into()))? + .to_string(); + pos += klen; + if pos + 2 > data.len() { + return Err(CryptoError::ExportFailed("truncated metadata value length".into())); + } + let vlen = u16::from_le_bytes([data[pos], data[pos + 1]]) as usize; + pos += 2; + if pos + vlen > data.len() { + return Err(CryptoError::ExportFailed("truncated metadata value".into())); + } + let val = std::str::from_utf8(&data[pos..pos + vlen]) + .map_err(|_| CryptoError::ExportFailed("metadata value not valid UTF-8".into()))? + .to_string(); + pos += vlen; + metadata.insert(key, val); + } + Ok((metadata, pos)) + } } // --------------------------------------------------------------------------- From a5a198ec962548d00696429d07c85eb065417e00 Mon Sep 17 00:00:00 2001 From: Adel-Ayoub Date: Thu, 9 Apr 2026 15:13:35 +0100 Subject: [PATCH 22/32] build(frb): regenerate bindings for metadata, rename, flush, and parallel read APIs --- lib/src/rust/api/evfs.dart | 49 +- lib/src/rust/api/evfs/types.dart | 45 +- lib/src/rust/core/error.dart | 2 + lib/src/rust/core/error.freezed.dart | 96 +++- lib/src/rust/frb_generated.dart | 403 ++++++++++++++- lib/src/rust/frb_generated.io.dart | 343 ++++++++++++- lib/src/rust/frb_generated.web.dart | 216 +++++++- rust/src/frb_generated.rs | 713 ++++++++++++++++++++++++++- 8 files changed, 1802 insertions(+), 65 deletions(-) diff --git a/lib/src/rust/api/evfs.dart b/lib/src/rust/api/evfs.dart index 9bcdd41..6d2161d 100644 --- a/lib/src/rust/api/evfs.dart +++ b/lib/src/rust/api/evfs.dart @@ -9,7 +9,7 @@ import 'compression.dart'; import 'evfs/types.dart'; import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; -// These functions are ignored because they are not marked as `pub`: `chunk_abs_offset`, `vault_export_write`, `vault_resize_grow_impl`, `vault_resize_shrink_impl`, `write_encrypted_chunk` +// These functions are ignored because they are not marked as `pub`: `chunk_abs_offset`, `read_segment_from_mmap_inner`, `read_segment_from_mmap`, `read_segment_with_file_inner`, `read_segment_with_file`, `vault_export_write`, `vault_resize_grow_impl`, `vault_resize_shrink_impl`, `write_encrypted_chunk` // These functions have error during generation (see debug logs or enable `stop_on_error: true` for more details): `vault_write_stream` /// Create a new vault file at `path` with the given capacity. @@ -41,16 +41,18 @@ Future vaultWrite({ required String name, required List data, CompressionConfig? compression, + Map? metadata, }) => RustLib.instance.api.crateApiEvfsVaultWrite( handle: handle, name: name, data: data, compression: compression, + metadata: metadata, ); /// Read a named segment. Handles both monolithic and streaming segments. /// Decompression is automatic for monolithic segments. -Future vaultRead({ +Future vaultRead({ required VaultHandle handle, required String name, }) => RustLib.instance.api.crateApiEvfsVaultRead(handle: handle, name: name); @@ -84,6 +86,18 @@ Stream vaultWriteFile({ filePath: filePath, ); +/// Renames an existing segment in the vault index without modifying its encrypted data. +/// Index-only operation — no data I/O, no re-encryption. +Future vaultRenameSegment({ + required VaultHandle handle, + required String oldName, + required String newName, +}) => RustLib.instance.api.crateApiEvfsVaultRenameSegment( + handle: handle, + oldName: oldName, + newName: newName, +); + /// Delete a named segment. The region is secure-erased and returned to the /// free list for reuse by future writes. Future vaultDelete({required VaultHandle handle, required String name}) => @@ -114,6 +128,32 @@ Future vaultCapacity({required VaultHandle handle}) => Future vaultHealth({required VaultHandle handle}) => RustLib.instance.api.crateApiEvfsVaultHealth(handle: handle); +/// Read multiple segments concurrently using mmap zero-copy + rayon. +/// +/// Returns results in the same order as `names`. On success, `data` contains +/// decrypted plaintext and `error` is `None`. On failure, `data` is empty and +/// `error` describes the problem (e.g. segment not found). +/// +/// All segments are decrypted into memory simultaneously — callers should +/// be mindful of total memory when reading many large segments at once. +/// +/// Falls back to sequential file-based reads when mmap is unavailable. +/// +/// **Note:** This API does not return per-segment metadata. Use [`vault_read`] +/// if you need the `metadata` field from [`SegmentReadResult`]. +Future> vaultReadParallel({ + required VaultHandle handle, + required List names, +}) => RustLib.instance.api.crateApiEvfsVaultReadParallel( + handle: handle, + names: names, +); + +/// Explicitly flush the in-memory index to disk if it has been modified. +/// No-op if the index is clean (no mutations since last flush). +Future vaultFlush({required VaultHandle handle}) => + RustLib.instance.api.crateApiEvfsVaultFlush(handle: handle); + /// Defragment the vault: compact all segments toward the data region start, /// coalesce free space into a single contiguous block at the end. /// @@ -149,11 +189,14 @@ Future vaultExport({ exportPath: exportPath, ); -/// Close the vault — checkpoint WAL, release lock, zeroize keys on drop. +/// Close the vault — flush dirty index, checkpoint WAL, release lock, zeroize keys on drop. Future vaultClose({required VaultHandle handle}) => RustLib.instance.api.crateApiEvfsVaultClose(handle: handle); /// Unwraps export key, creates new vault at dest_path, writes all segments under new_master_key. +/// +/// Archives with version < 2 do not carry per-segment metadata; imported +/// segments from those archives will have empty metadata. Future vaultImport({ required String archivePath, required List wrappingKey, diff --git a/lib/src/rust/api/evfs/types.dart b/lib/src/rust/api/evfs/types.dart index f84c208..2de91c6 100644 --- a/lib/src/rust/api/evfs/types.dart +++ b/lib/src/rust/api/evfs/types.dart @@ -8,7 +8,7 @@ import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; // These functions are ignored because they are not marked as `pub`: `new`, `refresh_mmap`, `slice` // These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `VaultMmap` -// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone`, `drop`, `eq`, `fmt` +// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone`, `clone`, `drop`, `eq`, `eq`, `fmt`, `fmt` // Rust type: RustOpaqueNom> abstract class VaultHandle implements RustOpaqueInterface { @@ -49,6 +49,49 @@ class DefragResult { freeRegionsBefore == other.freeRegionsBefore; } +/// Result of reading a segment — includes decrypted data and metadata. +class SegmentReadResult { + final Uint8List data; + final Map metadata; + + const SegmentReadResult({required this.data, required this.metadata}); + + @override + int get hashCode => data.hashCode ^ metadata.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SegmentReadResult && + runtimeType == other.runtimeType && + data == other.data && + metadata == other.metadata; +} + +/// Result of a single segment read from `vault_read_parallel`. +/// +/// On success, `data` contains the decrypted plaintext and `error` is `None`. +/// On failure, `data` is empty and `error` contains the error description. +class SegmentResult { + final String name; + final Uint8List data; + final String? error; + + const SegmentResult({required this.name, required this.data, this.error}); + + @override + int get hashCode => name.hashCode ^ data.hashCode ^ error.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SegmentResult && + runtimeType == other.runtimeType && + name == other.name && + data == other.data && + error == other.error; +} + /// Capacity info returned to callers. class VaultCapacityInfo { final BigInt totalBytes; diff --git a/lib/src/rust/core/error.dart b/lib/src/rust/core/error.dart index 56f448f..598c300 100644 --- a/lib/src/rust/core/error.dart +++ b/lib/src/rust/core/error.dart @@ -37,6 +37,8 @@ sealed class CryptoError with _$CryptoError implements FrbException { const factory CryptoError.vaultLocked() = CryptoError_VaultLocked; const factory CryptoError.segmentNotFound(String field0) = CryptoError_SegmentNotFound; + const factory CryptoError.duplicateSegment(String field0) = + CryptoError_DuplicateSegment; const factory CryptoError.vaultCorrupted(String field0) = CryptoError_VaultCorrupted; const factory CryptoError.keyRotationFailed(String field0) = diff --git a/lib/src/rust/core/error.freezed.dart b/lib/src/rust/core/error.freezed.dart index e28b9dc..7f4197f 100644 --- a/lib/src/rust/core/error.freezed.dart +++ b/lib/src/rust/core/error.freezed.dart @@ -55,7 +55,7 @@ extension CryptoErrorPatterns on CryptoError { /// } /// ``` -@optionalTypeArgs TResult maybeMap({TResult Function( CryptoError_InvalidKeyLength value)? invalidKeyLength,TResult Function( CryptoError_InvalidNonce value)? invalidNonce,TResult Function( CryptoError_EncryptionFailed value)? encryptionFailed,TResult Function( CryptoError_DecryptionFailed value)? decryptionFailed,TResult Function( CryptoError_HashingFailed value)? hashingFailed,TResult Function( CryptoError_KdfFailed value)? kdfFailed,TResult Function( CryptoError_IoError value)? ioError,TResult Function( CryptoError_InvalidParameter value)? invalidParameter,TResult Function( CryptoError_CompressionFailed value)? compressionFailed,TResult Function( CryptoError_AuthenticationFailed value)? authenticationFailed,TResult Function( CryptoError_VaultFull value)? vaultFull,TResult Function( CryptoError_VaultLocked value)? vaultLocked,TResult Function( CryptoError_SegmentNotFound value)? segmentNotFound,TResult Function( CryptoError_VaultCorrupted value)? vaultCorrupted,TResult Function( CryptoError_KeyRotationFailed value)? keyRotationFailed,TResult Function( CryptoError_ExportFailed value)? exportFailed,TResult Function( CryptoError_ImportFailed value)? importFailed,required TResult orElse(),}){ +@optionalTypeArgs TResult maybeMap({TResult Function( CryptoError_InvalidKeyLength value)? invalidKeyLength,TResult Function( CryptoError_InvalidNonce value)? invalidNonce,TResult Function( CryptoError_EncryptionFailed value)? encryptionFailed,TResult Function( CryptoError_DecryptionFailed value)? decryptionFailed,TResult Function( CryptoError_HashingFailed value)? hashingFailed,TResult Function( CryptoError_KdfFailed value)? kdfFailed,TResult Function( CryptoError_IoError value)? ioError,TResult Function( CryptoError_InvalidParameter value)? invalidParameter,TResult Function( CryptoError_CompressionFailed value)? compressionFailed,TResult Function( CryptoError_AuthenticationFailed value)? authenticationFailed,TResult Function( CryptoError_VaultFull value)? vaultFull,TResult Function( CryptoError_VaultLocked value)? vaultLocked,TResult Function( CryptoError_SegmentNotFound value)? segmentNotFound,TResult Function( CryptoError_DuplicateSegment value)? duplicateSegment,TResult Function( CryptoError_VaultCorrupted value)? vaultCorrupted,TResult Function( CryptoError_KeyRotationFailed value)? keyRotationFailed,TResult Function( CryptoError_ExportFailed value)? exportFailed,TResult Function( CryptoError_ImportFailed value)? importFailed,required TResult orElse(),}){ final _that = this; switch (_that) { case CryptoError_InvalidKeyLength() when invalidKeyLength != null: @@ -71,7 +71,8 @@ return compressionFailed(_that);case CryptoError_AuthenticationFailed() when aut return authenticationFailed(_that);case CryptoError_VaultFull() when vaultFull != null: return vaultFull(_that);case CryptoError_VaultLocked() when vaultLocked != null: return vaultLocked(_that);case CryptoError_SegmentNotFound() when segmentNotFound != null: -return segmentNotFound(_that);case CryptoError_VaultCorrupted() when vaultCorrupted != null: +return segmentNotFound(_that);case CryptoError_DuplicateSegment() when duplicateSegment != null: +return duplicateSegment(_that);case CryptoError_VaultCorrupted() when vaultCorrupted != null: return vaultCorrupted(_that);case CryptoError_KeyRotationFailed() when keyRotationFailed != null: return keyRotationFailed(_that);case CryptoError_ExportFailed() when exportFailed != null: return exportFailed(_that);case CryptoError_ImportFailed() when importFailed != null: @@ -93,7 +94,7 @@ return importFailed(_that);case _: /// } /// ``` -@optionalTypeArgs TResult map({required TResult Function( CryptoError_InvalidKeyLength value) invalidKeyLength,required TResult Function( CryptoError_InvalidNonce value) invalidNonce,required TResult Function( CryptoError_EncryptionFailed value) encryptionFailed,required TResult Function( CryptoError_DecryptionFailed value) decryptionFailed,required TResult Function( CryptoError_HashingFailed value) hashingFailed,required TResult Function( CryptoError_KdfFailed value) kdfFailed,required TResult Function( CryptoError_IoError value) ioError,required TResult Function( CryptoError_InvalidParameter value) invalidParameter,required TResult Function( CryptoError_CompressionFailed value) compressionFailed,required TResult Function( CryptoError_AuthenticationFailed value) authenticationFailed,required TResult Function( CryptoError_VaultFull value) vaultFull,required TResult Function( CryptoError_VaultLocked value) vaultLocked,required TResult Function( CryptoError_SegmentNotFound value) segmentNotFound,required TResult Function( CryptoError_VaultCorrupted value) vaultCorrupted,required TResult Function( CryptoError_KeyRotationFailed value) keyRotationFailed,required TResult Function( CryptoError_ExportFailed value) exportFailed,required TResult Function( CryptoError_ImportFailed value) importFailed,}){ +@optionalTypeArgs TResult map({required TResult Function( CryptoError_InvalidKeyLength value) invalidKeyLength,required TResult Function( CryptoError_InvalidNonce value) invalidNonce,required TResult Function( CryptoError_EncryptionFailed value) encryptionFailed,required TResult Function( CryptoError_DecryptionFailed value) decryptionFailed,required TResult Function( CryptoError_HashingFailed value) hashingFailed,required TResult Function( CryptoError_KdfFailed value) kdfFailed,required TResult Function( CryptoError_IoError value) ioError,required TResult Function( CryptoError_InvalidParameter value) invalidParameter,required TResult Function( CryptoError_CompressionFailed value) compressionFailed,required TResult Function( CryptoError_AuthenticationFailed value) authenticationFailed,required TResult Function( CryptoError_VaultFull value) vaultFull,required TResult Function( CryptoError_VaultLocked value) vaultLocked,required TResult Function( CryptoError_SegmentNotFound value) segmentNotFound,required TResult Function( CryptoError_DuplicateSegment value) duplicateSegment,required TResult Function( CryptoError_VaultCorrupted value) vaultCorrupted,required TResult Function( CryptoError_KeyRotationFailed value) keyRotationFailed,required TResult Function( CryptoError_ExportFailed value) exportFailed,required TResult Function( CryptoError_ImportFailed value) importFailed,}){ final _that = this; switch (_that) { case CryptoError_InvalidKeyLength(): @@ -109,7 +110,8 @@ return compressionFailed(_that);case CryptoError_AuthenticationFailed(): return authenticationFailed(_that);case CryptoError_VaultFull(): return vaultFull(_that);case CryptoError_VaultLocked(): return vaultLocked(_that);case CryptoError_SegmentNotFound(): -return segmentNotFound(_that);case CryptoError_VaultCorrupted(): +return segmentNotFound(_that);case CryptoError_DuplicateSegment(): +return duplicateSegment(_that);case CryptoError_VaultCorrupted(): return vaultCorrupted(_that);case CryptoError_KeyRotationFailed(): return keyRotationFailed(_that);case CryptoError_ExportFailed(): return exportFailed(_that);case CryptoError_ImportFailed(): @@ -127,7 +129,7 @@ return importFailed(_that);} /// } /// ``` -@optionalTypeArgs TResult? mapOrNull({TResult? Function( CryptoError_InvalidKeyLength value)? invalidKeyLength,TResult? Function( CryptoError_InvalidNonce value)? invalidNonce,TResult? Function( CryptoError_EncryptionFailed value)? encryptionFailed,TResult? Function( CryptoError_DecryptionFailed value)? decryptionFailed,TResult? Function( CryptoError_HashingFailed value)? hashingFailed,TResult? Function( CryptoError_KdfFailed value)? kdfFailed,TResult? Function( CryptoError_IoError value)? ioError,TResult? Function( CryptoError_InvalidParameter value)? invalidParameter,TResult? Function( CryptoError_CompressionFailed value)? compressionFailed,TResult? Function( CryptoError_AuthenticationFailed value)? authenticationFailed,TResult? Function( CryptoError_VaultFull value)? vaultFull,TResult? Function( CryptoError_VaultLocked value)? vaultLocked,TResult? Function( CryptoError_SegmentNotFound value)? segmentNotFound,TResult? Function( CryptoError_VaultCorrupted value)? vaultCorrupted,TResult? Function( CryptoError_KeyRotationFailed value)? keyRotationFailed,TResult? Function( CryptoError_ExportFailed value)? exportFailed,TResult? Function( CryptoError_ImportFailed value)? importFailed,}){ +@optionalTypeArgs TResult? mapOrNull({TResult? Function( CryptoError_InvalidKeyLength value)? invalidKeyLength,TResult? Function( CryptoError_InvalidNonce value)? invalidNonce,TResult? Function( CryptoError_EncryptionFailed value)? encryptionFailed,TResult? Function( CryptoError_DecryptionFailed value)? decryptionFailed,TResult? Function( CryptoError_HashingFailed value)? hashingFailed,TResult? Function( CryptoError_KdfFailed value)? kdfFailed,TResult? Function( CryptoError_IoError value)? ioError,TResult? Function( CryptoError_InvalidParameter value)? invalidParameter,TResult? Function( CryptoError_CompressionFailed value)? compressionFailed,TResult? Function( CryptoError_AuthenticationFailed value)? authenticationFailed,TResult? Function( CryptoError_VaultFull value)? vaultFull,TResult? Function( CryptoError_VaultLocked value)? vaultLocked,TResult? Function( CryptoError_SegmentNotFound value)? segmentNotFound,TResult? Function( CryptoError_DuplicateSegment value)? duplicateSegment,TResult? Function( CryptoError_VaultCorrupted value)? vaultCorrupted,TResult? Function( CryptoError_KeyRotationFailed value)? keyRotationFailed,TResult? Function( CryptoError_ExportFailed value)? exportFailed,TResult? Function( CryptoError_ImportFailed value)? importFailed,}){ final _that = this; switch (_that) { case CryptoError_InvalidKeyLength() when invalidKeyLength != null: @@ -143,7 +145,8 @@ return compressionFailed(_that);case CryptoError_AuthenticationFailed() when aut return authenticationFailed(_that);case CryptoError_VaultFull() when vaultFull != null: return vaultFull(_that);case CryptoError_VaultLocked() when vaultLocked != null: return vaultLocked(_that);case CryptoError_SegmentNotFound() when segmentNotFound != null: -return segmentNotFound(_that);case CryptoError_VaultCorrupted() when vaultCorrupted != null: +return segmentNotFound(_that);case CryptoError_DuplicateSegment() when duplicateSegment != null: +return duplicateSegment(_that);case CryptoError_VaultCorrupted() when vaultCorrupted != null: return vaultCorrupted(_that);case CryptoError_KeyRotationFailed() when keyRotationFailed != null: return keyRotationFailed(_that);case CryptoError_ExportFailed() when exportFailed != null: return exportFailed(_that);case CryptoError_ImportFailed() when importFailed != null: @@ -164,7 +167,7 @@ return importFailed(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen({TResult Function( BigInt expected, BigInt actual)? invalidKeyLength,TResult Function()? invalidNonce,TResult Function( String field0)? encryptionFailed,TResult Function()? decryptionFailed,TResult Function( String field0)? hashingFailed,TResult Function( String field0)? kdfFailed,TResult Function( String field0)? ioError,TResult Function( String field0)? invalidParameter,TResult Function( String field0)? compressionFailed,TResult Function()? authenticationFailed,TResult Function( BigInt needed, BigInt available)? vaultFull,TResult Function()? vaultLocked,TResult Function( String field0)? segmentNotFound,TResult Function( String field0)? vaultCorrupted,TResult Function( String field0)? keyRotationFailed,TResult Function( String field0)? exportFailed,TResult Function( String field0)? importFailed,required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen({TResult Function( BigInt expected, BigInt actual)? invalidKeyLength,TResult Function()? invalidNonce,TResult Function( String field0)? encryptionFailed,TResult Function()? decryptionFailed,TResult Function( String field0)? hashingFailed,TResult Function( String field0)? kdfFailed,TResult Function( String field0)? ioError,TResult Function( String field0)? invalidParameter,TResult Function( String field0)? compressionFailed,TResult Function()? authenticationFailed,TResult Function( BigInt needed, BigInt available)? vaultFull,TResult Function()? vaultLocked,TResult Function( String field0)? segmentNotFound,TResult Function( String field0)? duplicateSegment,TResult Function( String field0)? vaultCorrupted,TResult Function( String field0)? keyRotationFailed,TResult Function( String field0)? exportFailed,TResult Function( String field0)? importFailed,required TResult orElse(),}) {final _that = this; switch (_that) { case CryptoError_InvalidKeyLength() when invalidKeyLength != null: return invalidKeyLength(_that.expected,_that.actual);case CryptoError_InvalidNonce() when invalidNonce != null: @@ -179,7 +182,8 @@ return compressionFailed(_that.field0);case CryptoError_AuthenticationFailed() w return authenticationFailed();case CryptoError_VaultFull() when vaultFull != null: return vaultFull(_that.needed,_that.available);case CryptoError_VaultLocked() when vaultLocked != null: return vaultLocked();case CryptoError_SegmentNotFound() when segmentNotFound != null: -return segmentNotFound(_that.field0);case CryptoError_VaultCorrupted() when vaultCorrupted != null: +return segmentNotFound(_that.field0);case CryptoError_DuplicateSegment() when duplicateSegment != null: +return duplicateSegment(_that.field0);case CryptoError_VaultCorrupted() when vaultCorrupted != null: return vaultCorrupted(_that.field0);case CryptoError_KeyRotationFailed() when keyRotationFailed != null: return keyRotationFailed(_that.field0);case CryptoError_ExportFailed() when exportFailed != null: return exportFailed(_that.field0);case CryptoError_ImportFailed() when importFailed != null: @@ -201,7 +205,7 @@ return importFailed(_that.field0);case _: /// } /// ``` -@optionalTypeArgs TResult when({required TResult Function( BigInt expected, BigInt actual) invalidKeyLength,required TResult Function() invalidNonce,required TResult Function( String field0) encryptionFailed,required TResult Function() decryptionFailed,required TResult Function( String field0) hashingFailed,required TResult Function( String field0) kdfFailed,required TResult Function( String field0) ioError,required TResult Function( String field0) invalidParameter,required TResult Function( String field0) compressionFailed,required TResult Function() authenticationFailed,required TResult Function( BigInt needed, BigInt available) vaultFull,required TResult Function() vaultLocked,required TResult Function( String field0) segmentNotFound,required TResult Function( String field0) vaultCorrupted,required TResult Function( String field0) keyRotationFailed,required TResult Function( String field0) exportFailed,required TResult Function( String field0) importFailed,}) {final _that = this; +@optionalTypeArgs TResult when({required TResult Function( BigInt expected, BigInt actual) invalidKeyLength,required TResult Function() invalidNonce,required TResult Function( String field0) encryptionFailed,required TResult Function() decryptionFailed,required TResult Function( String field0) hashingFailed,required TResult Function( String field0) kdfFailed,required TResult Function( String field0) ioError,required TResult Function( String field0) invalidParameter,required TResult Function( String field0) compressionFailed,required TResult Function() authenticationFailed,required TResult Function( BigInt needed, BigInt available) vaultFull,required TResult Function() vaultLocked,required TResult Function( String field0) segmentNotFound,required TResult Function( String field0) duplicateSegment,required TResult Function( String field0) vaultCorrupted,required TResult Function( String field0) keyRotationFailed,required TResult Function( String field0) exportFailed,required TResult Function( String field0) importFailed,}) {final _that = this; switch (_that) { case CryptoError_InvalidKeyLength(): return invalidKeyLength(_that.expected,_that.actual);case CryptoError_InvalidNonce(): @@ -216,7 +220,8 @@ return compressionFailed(_that.field0);case CryptoError_AuthenticationFailed(): return authenticationFailed();case CryptoError_VaultFull(): return vaultFull(_that.needed,_that.available);case CryptoError_VaultLocked(): return vaultLocked();case CryptoError_SegmentNotFound(): -return segmentNotFound(_that.field0);case CryptoError_VaultCorrupted(): +return segmentNotFound(_that.field0);case CryptoError_DuplicateSegment(): +return duplicateSegment(_that.field0);case CryptoError_VaultCorrupted(): return vaultCorrupted(_that.field0);case CryptoError_KeyRotationFailed(): return keyRotationFailed(_that.field0);case CryptoError_ExportFailed(): return exportFailed(_that.field0);case CryptoError_ImportFailed(): @@ -234,7 +239,7 @@ return importFailed(_that.field0);} /// } /// ``` -@optionalTypeArgs TResult? whenOrNull({TResult? Function( BigInt expected, BigInt actual)? invalidKeyLength,TResult? Function()? invalidNonce,TResult? Function( String field0)? encryptionFailed,TResult? Function()? decryptionFailed,TResult? Function( String field0)? hashingFailed,TResult? Function( String field0)? kdfFailed,TResult? Function( String field0)? ioError,TResult? Function( String field0)? invalidParameter,TResult? Function( String field0)? compressionFailed,TResult? Function()? authenticationFailed,TResult? Function( BigInt needed, BigInt available)? vaultFull,TResult? Function()? vaultLocked,TResult? Function( String field0)? segmentNotFound,TResult? Function( String field0)? vaultCorrupted,TResult? Function( String field0)? keyRotationFailed,TResult? Function( String field0)? exportFailed,TResult? Function( String field0)? importFailed,}) {final _that = this; +@optionalTypeArgs TResult? whenOrNull({TResult? Function( BigInt expected, BigInt actual)? invalidKeyLength,TResult? Function()? invalidNonce,TResult? Function( String field0)? encryptionFailed,TResult? Function()? decryptionFailed,TResult? Function( String field0)? hashingFailed,TResult? Function( String field0)? kdfFailed,TResult? Function( String field0)? ioError,TResult? Function( String field0)? invalidParameter,TResult? Function( String field0)? compressionFailed,TResult? Function()? authenticationFailed,TResult? Function( BigInt needed, BigInt available)? vaultFull,TResult? Function()? vaultLocked,TResult? Function( String field0)? segmentNotFound,TResult? Function( String field0)? duplicateSegment,TResult? Function( String field0)? vaultCorrupted,TResult? Function( String field0)? keyRotationFailed,TResult? Function( String field0)? exportFailed,TResult? Function( String field0)? importFailed,}) {final _that = this; switch (_that) { case CryptoError_InvalidKeyLength() when invalidKeyLength != null: return invalidKeyLength(_that.expected,_that.actual);case CryptoError_InvalidNonce() when invalidNonce != null: @@ -249,7 +254,8 @@ return compressionFailed(_that.field0);case CryptoError_AuthenticationFailed() w return authenticationFailed();case CryptoError_VaultFull() when vaultFull != null: return vaultFull(_that.needed,_that.available);case CryptoError_VaultLocked() when vaultLocked != null: return vaultLocked();case CryptoError_SegmentNotFound() when segmentNotFound != null: -return segmentNotFound(_that.field0);case CryptoError_VaultCorrupted() when vaultCorrupted != null: +return segmentNotFound(_that.field0);case CryptoError_DuplicateSegment() when duplicateSegment != null: +return duplicateSegment(_that.field0);case CryptoError_VaultCorrupted() when vaultCorrupted != null: return vaultCorrupted(_that.field0);case CryptoError_KeyRotationFailed() when keyRotationFailed != null: return keyRotationFailed(_that.field0);case CryptoError_ExportFailed() when exportFailed != null: return exportFailed(_that.field0);case CryptoError_ImportFailed() when importFailed != null: @@ -990,6 +996,72 @@ as String, /// @nodoc +class CryptoError_DuplicateSegment extends CryptoError { + const CryptoError_DuplicateSegment(this.field0): super._(); + + + final String field0; + +/// Create a copy of CryptoError +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$CryptoError_DuplicateSegmentCopyWith get copyWith => _$CryptoError_DuplicateSegmentCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is CryptoError_DuplicateSegment&&(identical(other.field0, field0) || other.field0 == field0)); +} + + +@override +int get hashCode => Object.hash(runtimeType,field0); + +@override +String toString() { + return 'CryptoError.duplicateSegment(field0: $field0)'; +} + + +} + +/// @nodoc +abstract mixin class $CryptoError_DuplicateSegmentCopyWith<$Res> implements $CryptoErrorCopyWith<$Res> { + factory $CryptoError_DuplicateSegmentCopyWith(CryptoError_DuplicateSegment value, $Res Function(CryptoError_DuplicateSegment) _then) = _$CryptoError_DuplicateSegmentCopyWithImpl; +@useResult +$Res call({ + String field0 +}); + + + + +} +/// @nodoc +class _$CryptoError_DuplicateSegmentCopyWithImpl<$Res> + implements $CryptoError_DuplicateSegmentCopyWith<$Res> { + _$CryptoError_DuplicateSegmentCopyWithImpl(this._self, this._then); + + final CryptoError_DuplicateSegment _self; + final $Res Function(CryptoError_DuplicateSegment) _then; + +/// Create a copy of CryptoError +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? field0 = null,}) { + return _then(CryptoError_DuplicateSegment( +null == field0 ? _self.field0 : field0 // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc + + class CryptoError_VaultCorrupted extends CryptoError { const CryptoError_VaultCorrupted(this.field0): super._(); diff --git a/lib/src/rust/frb_generated.dart b/lib/src/rust/frb_generated.dart index 3334316..c252c15 100644 --- a/lib/src/rust/frb_generated.dart +++ b/lib/src/rust/frb_generated.dart @@ -74,7 +74,7 @@ class RustLib extends BaseEntrypoint { String get codegenVersion => '2.11.1'; @override - int get rustContentHash => 1455605677; + int get rustContentHash => 15652661; static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig( @@ -260,6 +260,8 @@ abstract class RustLibApi extends BaseApi { required String exportPath, }); + Future crateApiEvfsVaultFlush({required VaultHandle handle}); + Future crateApiEvfsVaultHealth({ required VaultHandle handle, }); @@ -280,11 +282,16 @@ abstract class RustLibApi extends BaseApi { required List key, }); - Future crateApiEvfsVaultRead({ + Future crateApiEvfsVaultRead({ required VaultHandle handle, required String name, }); + Future> crateApiEvfsVaultReadParallel({ + required VaultHandle handle, + required List names, + }); + Future crateApiEvfsVaultReadStream({ required VaultHandle handle, required String name, @@ -293,6 +300,12 @@ abstract class RustLibApi extends BaseApi { required RustStreamSink onProgress, }); + Future crateApiEvfsVaultRenameSegment({ + required VaultHandle handle, + required String oldName, + required String newName, + }); + Future crateApiEvfsVaultResize({ required VaultHandle handle, required BigInt newCapacity, @@ -308,6 +321,7 @@ abstract class RustLibApi extends BaseApi { required String name, required List data, CompressionConfig? compression, + Map? metadata, }); Stream crateApiEvfsVaultWriteFile({ @@ -1653,6 +1667,31 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["handle", "wrappingKey", "exportPath"], ); + @override + Future crateApiEvfsVaultFlush({required VaultHandle handle}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + var arg0 = + cst_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerVaultHandle( + handle, + ); + return wire.wire__crate__api__evfs__vault_flush(port_, arg0); + }, + codec: DcoCodec( + decodeSuccessData: dco_decode_unit, + decodeErrorData: dco_decode_crypto_error, + ), + constMeta: kCrateApiEvfsVaultFlushConstMeta, + argValues: [handle], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiEvfsVaultFlushConstMeta => + const TaskConstMeta(debugName: "vault_flush", argNames: ["handle"]); + @override Future crateApiEvfsVaultHealth({ required VaultHandle handle, @@ -1792,7 +1831,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { const TaskConstMeta(debugName: "vault_open", argNames: ["path", "key"]); @override - Future crateApiEvfsVaultRead({ + Future crateApiEvfsVaultRead({ required VaultHandle handle, required String name, }) { @@ -1807,7 +1846,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return wire.wire__crate__api__evfs__vault_read(port_, arg0, arg1); }, codec: DcoCodec( - decodeSuccessData: dco_decode_list_prim_u_8_strict, + decodeSuccessData: dco_decode_segment_read_result, decodeErrorData: dco_decode_crypto_error, ), constMeta: kCrateApiEvfsVaultReadConstMeta, @@ -1822,6 +1861,42 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["handle", "name"], ); + @override + Future> crateApiEvfsVaultReadParallel({ + required VaultHandle handle, + required List names, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + var arg0 = + cst_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerVaultHandle( + handle, + ); + var arg1 = cst_encode_list_String(names); + return wire.wire__crate__api__evfs__vault_read_parallel( + port_, + arg0, + arg1, + ); + }, + codec: DcoCodec( + decodeSuccessData: dco_decode_list_segment_result, + decodeErrorData: null, + ), + constMeta: kCrateApiEvfsVaultReadParallelConstMeta, + argValues: [handle, names], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiEvfsVaultReadParallelConstMeta => + const TaskConstMeta( + debugName: "vault_read_parallel", + argNames: ["handle", "names"], + ); + @override Future crateApiEvfsVaultReadStream({ required VaultHandle handle, @@ -1867,6 +1942,45 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["handle", "name", "verifyChecksum", "sink", "onProgress"], ); + @override + Future crateApiEvfsVaultRenameSegment({ + required VaultHandle handle, + required String oldName, + required String newName, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + var arg0 = + cst_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerVaultHandle( + handle, + ); + var arg1 = cst_encode_String(oldName); + var arg2 = cst_encode_String(newName); + return wire.wire__crate__api__evfs__vault_rename_segment( + port_, + arg0, + arg1, + arg2, + ); + }, + codec: DcoCodec( + decodeSuccessData: dco_decode_unit, + decodeErrorData: dco_decode_crypto_error, + ), + constMeta: kCrateApiEvfsVaultRenameSegmentConstMeta, + argValues: [handle, oldName, newName], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiEvfsVaultRenameSegmentConstMeta => + const TaskConstMeta( + debugName: "vault_rename_segment", + argNames: ["handle", "oldName", "newName"], + ); + @override Future crateApiEvfsVaultResize({ required VaultHandle handle, @@ -1940,6 +2054,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { required String name, required List data, CompressionConfig? compression, + Map? metadata, }) { return handler.executeNormal( NormalTask( @@ -1951,12 +2066,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { var arg1 = cst_encode_String(name); var arg2 = cst_encode_list_prim_u_8_loose(data); var arg3 = cst_encode_opt_box_autoadd_compression_config(compression); + var arg4 = cst_encode_opt_Map_String_String_None(metadata); return wire.wire__crate__api__evfs__vault_write( port_, arg0, arg1, arg2, arg3, + arg4, ); }, codec: DcoCodec( @@ -1964,7 +2081,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { decodeErrorData: dco_decode_crypto_error, ), constMeta: kCrateApiEvfsVaultWriteConstMeta, - argValues: [handle, name, data, compression], + argValues: [handle, name, data, compression, metadata], apiImpl: this, ), ); @@ -1972,7 +2089,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { TaskConstMeta get kCrateApiEvfsVaultWriteConstMeta => const TaskConstMeta( debugName: "vault_write", - argNames: ["handle", "name", "data", "compression"], + argNames: ["handle", "name", "data", "compression", "metadata"], ); @override @@ -2112,6 +2229,16 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return VaultHandleImpl.frbInternalDcoDecode(raw as List); } + @protected + Map dco_decode_Map_String_String_None(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return Map.fromEntries( + dco_decode_list_record_string_string( + raw, + ).map((e) => MapEntry(e.$1, e.$2)), + ); + } + @protected CipherHandle dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCipherHandle( @@ -2238,12 +2365,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { case 12: return CryptoError_SegmentNotFound(dco_decode_String(raw[1])); case 13: - return CryptoError_VaultCorrupted(dco_decode_String(raw[1])); + return CryptoError_DuplicateSegment(dco_decode_String(raw[1])); case 14: - return CryptoError_KeyRotationFailed(dco_decode_String(raw[1])); + return CryptoError_VaultCorrupted(dco_decode_String(raw[1])); case 15: - return CryptoError_ExportFailed(dco_decode_String(raw[1])); + return CryptoError_KeyRotationFailed(dco_decode_String(raw[1])); case 16: + return CryptoError_ExportFailed(dco_decode_String(raw[1])); + case 17: return CryptoError_ImportFailed(dco_decode_String(raw[1])); default: throw Exception("unreachable"); @@ -2293,6 +2422,30 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return raw as Uint8List; } + @protected + List<(String, String)> dco_decode_list_record_string_string(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return (raw as List).map(dco_decode_record_string_string).toList(); + } + + @protected + List dco_decode_list_segment_result(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return (raw as List).map(dco_decode_segment_result).toList(); + } + + @protected + Map? dco_decode_opt_Map_String_String_None(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw == null ? null : dco_decode_Map_String_String_None(raw); + } + + @protected + String? dco_decode_opt_String(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw == null ? null : dco_decode_String(raw); + } + @protected CompressionConfig? dco_decode_opt_box_autoadd_compression_config( dynamic raw, @@ -2313,6 +2466,41 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return raw == null ? null : dco_decode_list_prim_u_8_strict(raw); } + @protected + (String, String) dco_decode_record_string_string(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 2) { + throw Exception('Expected 2 elements, got ${arr.length}'); + } + return (dco_decode_String(arr[0]), dco_decode_String(arr[1])); + } + + @protected + SegmentReadResult dco_decode_segment_read_result(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 2) + throw Exception('unexpected arr length: expect 2 but see ${arr.length}'); + return SegmentReadResult( + data: dco_decode_list_prim_u_8_strict(arr[0]), + metadata: dco_decode_Map_String_String_None(arr[1]), + ); + } + + @protected + SegmentResult dco_decode_segment_result(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 3) + throw Exception('unexpected arr length: expect 3 but see ${arr.length}'); + return SegmentResult( + name: dco_decode_String(arr[0]), + data: dco_decode_list_prim_u_8_strict(arr[1]), + error: dco_decode_opt_String(arr[2]), + ); + } + @protected int dco_decode_u_32(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -2468,6 +2656,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); } + @protected + Map sse_decode_Map_String_String_None( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + var inner = sse_decode_list_record_string_string(deserializer); + return Map.fromEntries(inner.map((e) => MapEntry(e.$1, e.$2))); + } + @protected CipherHandle sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCipherHandle( @@ -2624,14 +2821,17 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return CryptoError_SegmentNotFound(var_field0); case 13: var var_field0 = sse_decode_String(deserializer); - return CryptoError_VaultCorrupted(var_field0); + return CryptoError_DuplicateSegment(var_field0); case 14: var var_field0 = sse_decode_String(deserializer); - return CryptoError_KeyRotationFailed(var_field0); + return CryptoError_VaultCorrupted(var_field0); case 15: var var_field0 = sse_decode_String(deserializer); - return CryptoError_ExportFailed(var_field0); + return CryptoError_KeyRotationFailed(var_field0); case 16: + var var_field0 = sse_decode_String(deserializer); + return CryptoError_ExportFailed(var_field0); + case 17: var var_field0 = sse_decode_String(deserializer); return CryptoError_ImportFailed(var_field0); default: @@ -2690,6 +2890,58 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return deserializer.buffer.getUint8List(len_); } + @protected + List<(String, String)> sse_decode_list_record_string_string( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + + var len_ = sse_decode_i_32(deserializer); + var ans_ = <(String, String)>[]; + for (var idx_ = 0; idx_ < len_; ++idx_) { + ans_.add(sse_decode_record_string_string(deserializer)); + } + return ans_; + } + + @protected + List sse_decode_list_segment_result( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + + var len_ = sse_decode_i_32(deserializer); + var ans_ = []; + for (var idx_ = 0; idx_ < len_; ++idx_) { + ans_.add(sse_decode_segment_result(deserializer)); + } + return ans_; + } + + @protected + Map? sse_decode_opt_Map_String_String_None( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + + if (sse_decode_bool(deserializer)) { + return (sse_decode_Map_String_String_None(deserializer)); + } else { + return null; + } + } + + @protected + String? sse_decode_opt_String(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + if (sse_decode_bool(deserializer)) { + return (sse_decode_String(deserializer)); + } else { + return null; + } + } + @protected CompressionConfig? sse_decode_opt_box_autoadd_compression_config( SseDeserializer deserializer, @@ -2725,6 +2977,35 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { } } + @protected + (String, String) sse_decode_record_string_string( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + var var_field0 = sse_decode_String(deserializer); + var var_field1 = sse_decode_String(deserializer); + return (var_field0, var_field1); + } + + @protected + SegmentReadResult sse_decode_segment_read_result( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + var var_data = sse_decode_list_prim_u_8_strict(deserializer); + var var_metadata = sse_decode_Map_String_String_None(deserializer); + return SegmentReadResult(data: var_data, metadata: var_metadata); + } + + @protected + SegmentResult sse_decode_segment_result(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var var_name = sse_decode_String(deserializer); + var var_data = sse_decode_list_prim_u_8_strict(deserializer); + var var_error = sse_decode_opt_String(deserializer); + return SegmentResult(name: var_name, data: var_data, error: var_error); + } + @protected int sse_decode_u_32(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -3046,6 +3327,18 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); } + @protected + void sse_encode_Map_String_String_None( + Map self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_list_record_string_string( + self.entries.map((e) => (e.key, e.value)).toList(), + serializer, + ); + } + @protected void sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCipherHandle( @@ -3218,18 +3511,21 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { case CryptoError_SegmentNotFound(field0: final field0): sse_encode_i_32(12, serializer); sse_encode_String(field0, serializer); - case CryptoError_VaultCorrupted(field0: final field0): + case CryptoError_DuplicateSegment(field0: final field0): sse_encode_i_32(13, serializer); sse_encode_String(field0, serializer); - case CryptoError_KeyRotationFailed(field0: final field0): + case CryptoError_VaultCorrupted(field0: final field0): sse_encode_i_32(14, serializer); sse_encode_String(field0, serializer); - case CryptoError_ExportFailed(field0: final field0): + case CryptoError_KeyRotationFailed(field0: final field0): sse_encode_i_32(15, serializer); sse_encode_String(field0, serializer); - case CryptoError_ImportFailed(field0: final field0): + case CryptoError_ExportFailed(field0: final field0): sse_encode_i_32(16, serializer); sse_encode_String(field0, serializer); + case CryptoError_ImportFailed(field0: final field0): + sse_encode_i_32(17, serializer); + sse_encode_String(field0, serializer); } } @@ -3284,6 +3580,53 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { serializer.buffer.putUint8List(self); } + @protected + void sse_encode_list_record_string_string( + List<(String, String)> self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.length, serializer); + for (final item in self) { + sse_encode_record_string_string(item, serializer); + } + } + + @protected + void sse_encode_list_segment_result( + List self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.length, serializer); + for (final item in self) { + sse_encode_segment_result(item, serializer); + } + } + + @protected + void sse_encode_opt_Map_String_String_None( + Map? self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + + sse_encode_bool(self != null, serializer); + if (self != null) { + sse_encode_Map_String_String_None(self, serializer); + } + } + + @protected + void sse_encode_opt_String(String? self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + sse_encode_bool(self != null, serializer); + if (self != null) { + sse_encode_String(self, serializer); + } + } + @protected void sse_encode_opt_box_autoadd_compression_config( CompressionConfig? self, @@ -3320,6 +3663,34 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { } } + @protected + void sse_encode_record_string_string( + (String, String) self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_String(self.$1, serializer); + sse_encode_String(self.$2, serializer); + } + + @protected + void sse_encode_segment_read_result( + SegmentReadResult self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_list_prim_u_8_strict(self.data, serializer); + sse_encode_Map_String_String_None(self.metadata, serializer); + } + + @protected + void sse_encode_segment_result(SegmentResult self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_String(self.name, serializer); + sse_encode_list_prim_u_8_strict(self.data, serializer); + sse_encode_opt_String(self.error, serializer); + } + @protected void sse_encode_u_32(int self, SseSerializer serializer) { // Codec=Sse (Serialization based), see doc to use other codecs diff --git a/lib/src/rust/frb_generated.io.dart b/lib/src/rust/frb_generated.io.dart index 1de4593..c0a62b0 100644 --- a/lib/src/rust/frb_generated.io.dart +++ b/lib/src/rust/frb_generated.io.dart @@ -85,6 +85,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { dynamic raw, ); + @protected + Map dco_decode_Map_String_String_None(dynamic raw); + @protected CipherHandle dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCipherHandle( @@ -153,6 +156,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected Uint8List dco_decode_list_prim_u_8_strict(dynamic raw); + @protected + List<(String, String)> dco_decode_list_record_string_string(dynamic raw); + + @protected + List dco_decode_list_segment_result(dynamic raw); + + @protected + Map? dco_decode_opt_Map_String_String_None(dynamic raw); + + @protected + String? dco_decode_opt_String(dynamic raw); + @protected CompressionConfig? dco_decode_opt_box_autoadd_compression_config(dynamic raw); @@ -162,6 +177,15 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected Uint8List? dco_decode_opt_list_prim_u_8_strict(dynamic raw); + @protected + (String, String) dco_decode_record_string_string(dynamic raw); + + @protected + SegmentReadResult dco_decode_segment_read_result(dynamic raw); + + @protected + SegmentResult dco_decode_segment_result(dynamic raw); + @protected int dco_decode_u_32(dynamic raw); @@ -228,6 +252,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseDeserializer deserializer, ); + @protected + Map sse_decode_Map_String_String_None( + SseDeserializer deserializer, + ); + @protected CipherHandle sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCipherHandle( @@ -302,6 +331,24 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer); + @protected + List<(String, String)> sse_decode_list_record_string_string( + SseDeserializer deserializer, + ); + + @protected + List sse_decode_list_segment_result( + SseDeserializer deserializer, + ); + + @protected + Map? sse_decode_opt_Map_String_String_None( + SseDeserializer deserializer, + ); + + @protected + String? sse_decode_opt_String(SseDeserializer deserializer); + @protected CompressionConfig? sse_decode_opt_box_autoadd_compression_config( SseDeserializer deserializer, @@ -313,6 +360,19 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected Uint8List? sse_decode_opt_list_prim_u_8_strict(SseDeserializer deserializer); + @protected + (String, String) sse_decode_record_string_string( + SseDeserializer deserializer, + ); + + @protected + SegmentReadResult sse_decode_segment_read_result( + SseDeserializer deserializer, + ); + + @protected + SegmentResult sse_decode_segment_result(SseDeserializer deserializer); + @protected int sse_decode_u_32(SseDeserializer deserializer); @@ -344,6 +404,15 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { throw UnimplementedError(); } + @protected + ffi.Pointer + cst_encode_Map_String_String_None(Map raw) { + // Codec=Cst (C-struct based), see doc to use other codecs + return cst_encode_list_record_string_string( + raw.entries.map((e) => (e.key, e.value)).toList(), + ); + } + @protected ffi.Pointer cst_encode_StreamSink_f_64_Dco( RustStreamSink raw, @@ -426,6 +495,44 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { return ans; } + @protected + ffi.Pointer + cst_encode_list_record_string_string(List<(String, String)> raw) { + // Codec=Cst (C-struct based), see doc to use other codecs + final ans = wire.cst_new_list_record_string_string(raw.length); + for (var i = 0; i < raw.length; ++i) { + cst_api_fill_to_wire_record_string_string(raw[i], ans.ref.ptr[i]); + } + return ans; + } + + @protected + ffi.Pointer cst_encode_list_segment_result( + List raw, + ) { + // Codec=Cst (C-struct based), see doc to use other codecs + final ans = wire.cst_new_list_segment_result(raw.length); + for (var i = 0; i < raw.length; ++i) { + cst_api_fill_to_wire_segment_result(raw[i], ans.ref.ptr[i]); + } + return ans; + } + + @protected + ffi.Pointer + cst_encode_opt_Map_String_String_None(Map? raw) { + // Codec=Cst (C-struct based), see doc to use other codecs + return raw == null ? ffi.nullptr : cst_encode_Map_String_String_None(raw); + } + + @protected + ffi.Pointer cst_encode_opt_String( + String? raw, + ) { + // Codec=Cst (C-struct based), see doc to use other codecs + return raw == null ? ffi.nullptr : cst_encode_String(raw); + } + @protected ffi.Pointer cst_encode_opt_box_autoadd_compression_config(CompressionConfig? raw) { @@ -556,27 +663,33 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { wireObj.kind.SegmentNotFound.field0 = pre_field0; return; } - if (apiObj is CryptoError_VaultCorrupted) { + if (apiObj is CryptoError_DuplicateSegment) { var pre_field0 = cst_encode_String(apiObj.field0); wireObj.tag = 13; + wireObj.kind.DuplicateSegment.field0 = pre_field0; + return; + } + if (apiObj is CryptoError_VaultCorrupted) { + var pre_field0 = cst_encode_String(apiObj.field0); + wireObj.tag = 14; wireObj.kind.VaultCorrupted.field0 = pre_field0; return; } if (apiObj is CryptoError_KeyRotationFailed) { var pre_field0 = cst_encode_String(apiObj.field0); - wireObj.tag = 14; + wireObj.tag = 15; wireObj.kind.KeyRotationFailed.field0 = pre_field0; return; } if (apiObj is CryptoError_ExportFailed) { var pre_field0 = cst_encode_String(apiObj.field0); - wireObj.tag = 15; + wireObj.tag = 16; wireObj.kind.ExportFailed.field0 = pre_field0; return; } if (apiObj is CryptoError_ImportFailed) { var pre_field0 = cst_encode_String(apiObj.field0); - wireObj.tag = 16; + wireObj.tag = 17; wireObj.kind.ImportFailed.field0 = pre_field0; return; } @@ -592,6 +705,34 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { wireObj.free_regions_before = cst_encode_u_32(apiObj.freeRegionsBefore); } + @protected + void cst_api_fill_to_wire_record_string_string( + (String, String) apiObj, + wire_cst_record_string_string wireObj, + ) { + wireObj.field0 = cst_encode_String(apiObj.$1); + wireObj.field1 = cst_encode_String(apiObj.$2); + } + + @protected + void cst_api_fill_to_wire_segment_read_result( + SegmentReadResult apiObj, + wire_cst_segment_read_result wireObj, + ) { + wireObj.data = cst_encode_list_prim_u_8_strict(apiObj.data); + wireObj.metadata = cst_encode_Map_String_String_None(apiObj.metadata); + } + + @protected + void cst_api_fill_to_wire_segment_result( + SegmentResult apiObj, + wire_cst_segment_result wireObj, + ) { + wireObj.name = cst_encode_String(apiObj.name); + wireObj.data = cst_encode_list_prim_u_8_strict(apiObj.data); + wireObj.error = cst_encode_opt_String(apiObj.error); + } + @protected void cst_api_fill_to_wire_vault_capacity_info( VaultCapacityInfo apiObj, @@ -759,6 +900,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_Map_String_String_None( + Map self, + SseSerializer serializer, + ); + @protected void sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCipherHandle( @@ -846,6 +993,27 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_list_record_string_string( + List<(String, String)> self, + SseSerializer serializer, + ); + + @protected + void sse_encode_list_segment_result( + List self, + SseSerializer serializer, + ); + + @protected + void sse_encode_opt_Map_String_String_None( + Map? self, + SseSerializer serializer, + ); + + @protected + void sse_encode_opt_String(String? self, SseSerializer serializer); + @protected void sse_encode_opt_box_autoadd_compression_config( CompressionConfig? self, @@ -861,6 +1029,21 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_record_string_string( + (String, String) self, + SseSerializer serializer, + ); + + @protected + void sse_encode_segment_read_result( + SegmentReadResult self, + SseSerializer serializer, + ); + + @protected + void sse_encode_segment_result(SegmentResult self, SseSerializer serializer); + @protected void sse_encode_u_32(int self, SseSerializer serializer); @@ -1912,6 +2095,18 @@ class RustLibWire implements BaseWire { ) >(); + void wire__crate__api__evfs__vault_flush(int port_, int handle) { + return _wire__crate__api__evfs__vault_flush(port_, handle); + } + + late final _wire__crate__api__evfs__vault_flushPtr = + _lookup>( + 'frbgen_m_security_wire__crate__api__evfs__vault_flush', + ); + late final _wire__crate__api__evfs__vault_flush = + _wire__crate__api__evfs__vault_flushPtr + .asFunction(); + void wire__crate__api__evfs__vault_health(int port_, int handle) { return _wire__crate__api__evfs__vault_health(port_, handle); } @@ -2036,6 +2231,30 @@ class RustLibWire implements BaseWire { void Function(int, int, ffi.Pointer) >(); + void wire__crate__api__evfs__vault_read_parallel( + int port_, + int handle, + ffi.Pointer names, + ) { + return _wire__crate__api__evfs__vault_read_parallel(port_, handle, names); + } + + late final _wire__crate__api__evfs__vault_read_parallelPtr = + _lookup< + ffi.NativeFunction< + ffi.Void Function( + ffi.Int64, + ffi.UintPtr, + ffi.Pointer, + ) + > + >('frbgen_m_security_wire__crate__api__evfs__vault_read_parallel'); + late final _wire__crate__api__evfs__vault_read_parallel = + _wire__crate__api__evfs__vault_read_parallelPtr + .asFunction< + void Function(int, int, ffi.Pointer) + >(); + void wire__crate__api__evfs__vault_read_stream( int port_, int handle, @@ -2080,6 +2299,42 @@ class RustLibWire implements BaseWire { ) >(); + void wire__crate__api__evfs__vault_rename_segment( + int port_, + int handle, + ffi.Pointer old_name, + ffi.Pointer new_name, + ) { + return _wire__crate__api__evfs__vault_rename_segment( + port_, + handle, + old_name, + new_name, + ); + } + + late final _wire__crate__api__evfs__vault_rename_segmentPtr = + _lookup< + ffi.NativeFunction< + ffi.Void Function( + ffi.Int64, + ffi.UintPtr, + ffi.Pointer, + ffi.Pointer, + ) + > + >('frbgen_m_security_wire__crate__api__evfs__vault_rename_segment'); + late final _wire__crate__api__evfs__vault_rename_segment = + _wire__crate__api__evfs__vault_rename_segmentPtr + .asFunction< + void Function( + int, + int, + ffi.Pointer, + ffi.Pointer, + ) + >(); + void wire__crate__api__evfs__vault_resize( int port_, int handle, @@ -2128,6 +2383,7 @@ class RustLibWire implements BaseWire { ffi.Pointer name, ffi.Pointer data, ffi.Pointer compression, + ffi.Pointer metadata, ) { return _wire__crate__api__evfs__vault_write( port_, @@ -2135,6 +2391,7 @@ class RustLibWire implements BaseWire { name, data, compression, + metadata, ); } @@ -2147,6 +2404,7 @@ class RustLibWire implements BaseWire { ffi.Pointer, ffi.Pointer, ffi.Pointer, + ffi.Pointer, ) > >('frbgen_m_security_wire__crate__api__evfs__vault_write'); @@ -2159,6 +2417,7 @@ class RustLibWire implements BaseWire { ffi.Pointer, ffi.Pointer, ffi.Pointer, + ffi.Pointer, ) >(); @@ -2371,6 +2630,38 @@ class RustLibWire implements BaseWire { late final _cst_new_list_prim_u_8_strict = _cst_new_list_prim_u_8_strictPtr .asFunction Function(int)>(); + ffi.Pointer + cst_new_list_record_string_string(int len) { + return _cst_new_list_record_string_string(len); + } + + late final _cst_new_list_record_string_stringPtr = + _lookup< + ffi.NativeFunction< + ffi.Pointer Function(ffi.Int32) + > + >('frbgen_m_security_cst_new_list_record_string_string'); + late final _cst_new_list_record_string_string = + _cst_new_list_record_string_stringPtr + .asFunction< + ffi.Pointer Function(int) + >(); + + ffi.Pointer cst_new_list_segment_result( + int len, + ) { + return _cst_new_list_segment_result(len); + } + + late final _cst_new_list_segment_resultPtr = + _lookup< + ffi.NativeFunction< + ffi.Pointer Function(ffi.Int32) + > + >('frbgen_m_security_cst_new_list_segment_result'); + late final _cst_new_list_segment_result = _cst_new_list_segment_resultPtr + .asFunction Function(int)>(); + int dummy_method_to_enforce_bundling() { return _dummy_method_to_enforce_bundling(); } @@ -2420,6 +2711,34 @@ final class wire_cst_list_String extends ffi.Struct { external int len; } +final class wire_cst_record_string_string extends ffi.Struct { + external ffi.Pointer field0; + + external ffi.Pointer field1; +} + +final class wire_cst_list_record_string_string extends ffi.Struct { + external ffi.Pointer ptr; + + @ffi.Int32() + external int len; +} + +final class wire_cst_segment_result extends ffi.Struct { + external ffi.Pointer name; + + external ffi.Pointer data; + + external ffi.Pointer error; +} + +final class wire_cst_list_segment_result extends ffi.Struct { + external ffi.Pointer ptr; + + @ffi.Int32() + external int len; +} + final class wire_cst_CryptoError_InvalidKeyLength extends ffi.Struct { @ffi.UintPtr() external int expected; @@ -2464,6 +2783,10 @@ final class wire_cst_CryptoError_SegmentNotFound extends ffi.Struct { external ffi.Pointer field0; } +final class wire_cst_CryptoError_DuplicateSegment extends ffi.Struct { + external ffi.Pointer field0; +} + final class wire_cst_CryptoError_VaultCorrupted extends ffi.Struct { external ffi.Pointer field0; } @@ -2499,6 +2822,8 @@ final class CryptoErrorKind extends ffi.Union { external wire_cst_CryptoError_SegmentNotFound SegmentNotFound; + external wire_cst_CryptoError_DuplicateSegment DuplicateSegment; + external wire_cst_CryptoError_VaultCorrupted VaultCorrupted; external wire_cst_CryptoError_KeyRotationFailed KeyRotationFailed; @@ -2526,6 +2851,12 @@ final class wire_cst_defrag_result extends ffi.Struct { external int free_regions_before; } +final class wire_cst_segment_read_result extends ffi.Struct { + external ffi.Pointer data; + + external ffi.Pointer metadata; +} + final class wire_cst_vault_capacity_info extends ffi.Struct { @ffi.Uint64() external int total_bytes; @@ -2578,7 +2909,7 @@ const int MIN_LEVEL = 1; const int MAX_LEVEL = 22; -const int ARCHIVE_VERSION = 1; +const int ARCHIVE_VERSION = 2; const int ARCHIVE_HEADER_SIZE = 32; @@ -2586,7 +2917,7 @@ const int WRAPPED_KEY_SIZE = 60; const int ARCHIVE_TRAILER_SIZE = 36; -const int VAULT_VERSION = 1; +const int VAULT_VERSION = 2; const int VAULT_HEADER_SIZE = 32; diff --git a/lib/src/rust/frb_generated.web.dart b/lib/src/rust/frb_generated.web.dart index 4178554..66ab3c2 100644 --- a/lib/src/rust/frb_generated.web.dart +++ b/lib/src/rust/frb_generated.web.dart @@ -87,6 +87,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { dynamic raw, ); + @protected + Map dco_decode_Map_String_String_None(dynamic raw); + @protected CipherHandle dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCipherHandle( @@ -155,6 +158,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected Uint8List dco_decode_list_prim_u_8_strict(dynamic raw); + @protected + List<(String, String)> dco_decode_list_record_string_string(dynamic raw); + + @protected + List dco_decode_list_segment_result(dynamic raw); + + @protected + Map? dco_decode_opt_Map_String_String_None(dynamic raw); + + @protected + String? dco_decode_opt_String(dynamic raw); + @protected CompressionConfig? dco_decode_opt_box_autoadd_compression_config(dynamic raw); @@ -164,6 +179,15 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected Uint8List? dco_decode_opt_list_prim_u_8_strict(dynamic raw); + @protected + (String, String) dco_decode_record_string_string(dynamic raw); + + @protected + SegmentReadResult dco_decode_segment_read_result(dynamic raw); + + @protected + SegmentResult dco_decode_segment_result(dynamic raw); + @protected int dco_decode_u_32(dynamic raw); @@ -230,6 +254,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseDeserializer deserializer, ); + @protected + Map sse_decode_Map_String_String_None( + SseDeserializer deserializer, + ); + @protected CipherHandle sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCipherHandle( @@ -304,6 +333,24 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer); + @protected + List<(String, String)> sse_decode_list_record_string_string( + SseDeserializer deserializer, + ); + + @protected + List sse_decode_list_segment_result( + SseDeserializer deserializer, + ); + + @protected + Map? sse_decode_opt_Map_String_String_None( + SseDeserializer deserializer, + ); + + @protected + String? sse_decode_opt_String(SseDeserializer deserializer); + @protected CompressionConfig? sse_decode_opt_box_autoadd_compression_config( SseDeserializer deserializer, @@ -315,6 +362,19 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected Uint8List? sse_decode_opt_list_prim_u_8_strict(SseDeserializer deserializer); + @protected + (String, String) sse_decode_record_string_string( + SseDeserializer deserializer, + ); + + @protected + SegmentReadResult sse_decode_segment_read_result( + SseDeserializer deserializer, + ); + + @protected + SegmentResult sse_decode_segment_result(SseDeserializer deserializer); + @protected int sse_decode_u_32(SseDeserializer deserializer); @@ -344,6 +404,14 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { throw UnimplementedError(); } + @protected + JSAny cst_encode_Map_String_String_None(Map raw) { + // Codec=Cst (C-struct based), see doc to use other codecs + return cst_encode_list_record_string_string( + raw.entries.map((e) => (e.key, e.value)).toList(), + ); + } + @protected String cst_encode_StreamSink_f_64_Dco(RustStreamSink raw) { // Codec=Cst (C-struct based), see doc to use other codecs @@ -449,18 +517,21 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { if (raw is CryptoError_SegmentNotFound) { return [12, cst_encode_String(raw.field0)].jsify()!; } - if (raw is CryptoError_VaultCorrupted) { + if (raw is CryptoError_DuplicateSegment) { return [13, cst_encode_String(raw.field0)].jsify()!; } - if (raw is CryptoError_KeyRotationFailed) { + if (raw is CryptoError_VaultCorrupted) { return [14, cst_encode_String(raw.field0)].jsify()!; } - if (raw is CryptoError_ExportFailed) { + if (raw is CryptoError_KeyRotationFailed) { return [15, cst_encode_String(raw.field0)].jsify()!; } - if (raw is CryptoError_ImportFailed) { + if (raw is CryptoError_ExportFailed) { return [16, cst_encode_String(raw.field0)].jsify()!; } + if (raw is CryptoError_ImportFailed) { + return [17, cst_encode_String(raw.field0)].jsify()!; + } throw Exception('unreachable'); } @@ -493,6 +564,30 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { return raw.jsify()!; } + @protected + JSAny cst_encode_list_record_string_string(List<(String, String)> raw) { + // Codec=Cst (C-struct based), see doc to use other codecs + return raw.map(cst_encode_record_string_string).toList().jsify()!; + } + + @protected + JSAny cst_encode_list_segment_result(List raw) { + // Codec=Cst (C-struct based), see doc to use other codecs + return raw.map(cst_encode_segment_result).toList().jsify()!; + } + + @protected + JSAny? cst_encode_opt_Map_String_String_None(Map? raw) { + // Codec=Cst (C-struct based), see doc to use other codecs + return raw == null ? null : cst_encode_Map_String_String_None(raw); + } + + @protected + String? cst_encode_opt_String(String? raw) { + // Codec=Cst (C-struct based), see doc to use other codecs + return raw == null ? null : cst_encode_String(raw); + } + @protected JSAny? cst_encode_opt_box_autoadd_compression_config(CompressionConfig? raw) { // Codec=Cst (C-struct based), see doc to use other codecs @@ -511,6 +606,31 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { return raw == null ? null : cst_encode_list_prim_u_8_strict(raw); } + @protected + JSAny cst_encode_record_string_string((String, String) raw) { + // Codec=Cst (C-struct based), see doc to use other codecs + return [cst_encode_String(raw.$1), cst_encode_String(raw.$2)].jsify()!; + } + + @protected + JSAny cst_encode_segment_read_result(SegmentReadResult raw) { + // Codec=Cst (C-struct based), see doc to use other codecs + return [ + cst_encode_list_prim_u_8_strict(raw.data), + cst_encode_Map_String_String_None(raw.metadata), + ].jsify()!; + } + + @protected + JSAny cst_encode_segment_result(SegmentResult raw) { + // Codec=Cst (C-struct based), see doc to use other codecs + return [ + cst_encode_String(raw.name), + cst_encode_list_prim_u_8_strict(raw.data), + cst_encode_opt_String(raw.error), + ].jsify()!; + } + @protected JSAny cst_encode_u_64(BigInt raw) { // Codec=Cst (C-struct based), see doc to use other codecs @@ -690,6 +810,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_Map_String_String_None( + Map self, + SseSerializer serializer, + ); + @protected void sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCipherHandle( @@ -777,6 +903,27 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_list_record_string_string( + List<(String, String)> self, + SseSerializer serializer, + ); + + @protected + void sse_encode_list_segment_result( + List self, + SseSerializer serializer, + ); + + @protected + void sse_encode_opt_Map_String_String_None( + Map? self, + SseSerializer serializer, + ); + + @protected + void sse_encode_opt_String(String? self, SseSerializer serializer); + @protected void sse_encode_opt_box_autoadd_compression_config( CompressionConfig? self, @@ -792,6 +939,21 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_record_string_string( + (String, String) self, + SseSerializer serializer, + ); + + @protected + void sse_encode_segment_read_result( + SegmentReadResult self, + SseSerializer serializer, + ); + + @protected + void sse_encode_segment_result(SegmentResult self, SseSerializer serializer); + @protected void sse_encode_u_32(int self, SseSerializer serializer); @@ -1152,6 +1314,9 @@ class RustLibWire implements BaseWire { export_path, ); + void wire__crate__api__evfs__vault_flush(NativePortType port_, int handle) => + wasmModule.wire__crate__api__evfs__vault_flush(port_, handle); + void wire__crate__api__evfs__vault_health(NativePortType port_, int handle) => wasmModule.wire__crate__api__evfs__vault_health(port_, handle); @@ -1188,6 +1353,16 @@ class RustLibWire implements BaseWire { String name, ) => wasmModule.wire__crate__api__evfs__vault_read(port_, handle, name); + void wire__crate__api__evfs__vault_read_parallel( + NativePortType port_, + int handle, + JSAny names, + ) => wasmModule.wire__crate__api__evfs__vault_read_parallel( + port_, + handle, + names, + ); + void wire__crate__api__evfs__vault_read_stream( NativePortType port_, int handle, @@ -1204,6 +1379,18 @@ class RustLibWire implements BaseWire { on_progress, ); + void wire__crate__api__evfs__vault_rename_segment( + NativePortType port_, + int handle, + String old_name, + String new_name, + ) => wasmModule.wire__crate__api__evfs__vault_rename_segment( + port_, + handle, + old_name, + new_name, + ); + void wire__crate__api__evfs__vault_resize( NativePortType port_, int handle, @@ -1230,12 +1417,14 @@ class RustLibWire implements BaseWire { String name, JSAny data, JSAny? compression, + JSAny? metadata, ) => wasmModule.wire__crate__api__evfs__vault_write( port_, handle, name, data, compression, + metadata, ); void wire__crate__api__evfs__vault_write_file( @@ -1536,6 +1725,11 @@ extension type RustLibWasmModule._(JSObject _) implements JSObject { String export_path, ); + external void wire__crate__api__evfs__vault_flush( + NativePortType port_, + int handle, + ); + external void wire__crate__api__evfs__vault_health( NativePortType port_, int handle, @@ -1568,6 +1762,12 @@ extension type RustLibWasmModule._(JSObject _) implements JSObject { String name, ); + external void wire__crate__api__evfs__vault_read_parallel( + NativePortType port_, + int handle, + JSAny names, + ); + external void wire__crate__api__evfs__vault_read_stream( NativePortType port_, int handle, @@ -1577,6 +1777,13 @@ extension type RustLibWasmModule._(JSObject _) implements JSObject { String on_progress, ); + external void wire__crate__api__evfs__vault_rename_segment( + NativePortType port_, + int handle, + String old_name, + String new_name, + ); + external void wire__crate__api__evfs__vault_resize( NativePortType port_, int handle, @@ -1595,6 +1802,7 @@ extension type RustLibWasmModule._(JSObject _) implements JSObject { String name, JSAny data, JSAny? compression, + JSAny? metadata, ); external void wire__crate__api__evfs__vault_write_file( diff --git a/rust/src/frb_generated.rs b/rust/src/frb_generated.rs index a488699..f2706b2 100644 --- a/rust/src/frb_generated.rs +++ b/rust/src/frb_generated.rs @@ -40,7 +40,7 @@ flutter_rust_bridge::frb_generated_boilerplate!( default_rust_auto_opaque = RustAutoOpaqueNom, ); pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.11.1"; -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1455605677; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 15652661; // Section: executor @@ -1337,6 +1337,45 @@ fn wire__crate__api__evfs__vault_export_impl( }, ) } +fn wire__crate__api__evfs__vault_flush_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + handle: impl CstDecode< + RustOpaqueNom>, + >, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "vault_flush", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let api_handle = handle.cst_decode(); + move |context| { + transform_result_dco::<_, _, crate::core::error::CryptoError>((move || { + let mut api_handle_guard = None; + let decode_indices_ = + flutter_rust_bridge::for_generated::lockable_compute_decode_order(vec![ + flutter_rust_bridge::for_generated::LockableOrderInfo::new( + &api_handle, + 0, + true, + ), + ]); + for i in decode_indices_ { + match i { + 0 => api_handle_guard = Some(api_handle.lockable_decode_sync_ref_mut()), + _ => unreachable!(), + } + } + let mut api_handle_guard = api_handle_guard.unwrap(); + let output_ok = crate::api::evfs::vault_flush(&mut *api_handle_guard)?; + Ok(output_ok) + })()) + } + }, + ) +} fn wire__crate__api__evfs__vault_health_impl( port_: flutter_rust_bridge::for_generated::MessagePort, handle: impl CstDecode< @@ -1513,7 +1552,51 @@ fn wire__crate__api__evfs__vault_read_impl( } let mut api_handle_guard = api_handle_guard.unwrap(); let output_ok = crate::api::evfs::vault_read(&mut *api_handle_guard, api_name)?; - Ok(output_ok.data) + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__evfs__vault_read_parallel_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + handle: impl CstDecode< + RustOpaqueNom>, + >, + names: impl CstDecode>, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "vault_read_parallel", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let api_handle = handle.cst_decode(); + let api_names = names.cst_decode(); + move |context| { + transform_result_dco::<_, _, ()>((move || { + let mut api_handle_guard = None; + let decode_indices_ = + flutter_rust_bridge::for_generated::lockable_compute_decode_order(vec![ + flutter_rust_bridge::for_generated::LockableOrderInfo::new( + &api_handle, + 0, + false, + ), + ]); + for i in decode_indices_ { + match i { + 0 => api_handle_guard = Some(api_handle.lockable_decode_sync_ref()), + _ => unreachable!(), + } + } + let api_handle_guard = api_handle_guard.unwrap(); + let output_ok = Result::<_, ()>::Ok(crate::api::evfs::vault_read_parallel( + &*api_handle_guard, + api_names, + ))?; + Ok(output_ok) })()) } }, @@ -1572,6 +1655,53 @@ fn wire__crate__api__evfs__vault_read_stream_impl( }, ) } +fn wire__crate__api__evfs__vault_rename_segment_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + handle: impl CstDecode< + RustOpaqueNom>, + >, + old_name: impl CstDecode, + new_name: impl CstDecode, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "vault_rename_segment", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let api_handle = handle.cst_decode(); + let api_old_name = old_name.cst_decode(); + let api_new_name = new_name.cst_decode(); + move |context| { + transform_result_dco::<_, _, crate::core::error::CryptoError>((move || { + let mut api_handle_guard = None; + let decode_indices_ = + flutter_rust_bridge::for_generated::lockable_compute_decode_order(vec![ + flutter_rust_bridge::for_generated::LockableOrderInfo::new( + &api_handle, + 0, + true, + ), + ]); + for i in decode_indices_ { + match i { + 0 => api_handle_guard = Some(api_handle.lockable_decode_sync_ref_mut()), + _ => unreachable!(), + } + } + let mut api_handle_guard = api_handle_guard.unwrap(); + let output_ok = crate::api::evfs::vault_rename_segment( + &mut *api_handle_guard, + api_old_name, + api_new_name, + )?; + Ok(output_ok) + })()) + } + }, + ) +} fn wire__crate__api__evfs__vault_resize_impl( port_: flutter_rust_bridge::for_generated::MessagePort, handle: impl CstDecode< @@ -1645,6 +1775,7 @@ fn wire__crate__api__evfs__vault_write_impl( name: impl CstDecode, data: impl CstDecode>, compression: impl CstDecode>, + metadata: impl CstDecode>>, ) { FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( flutter_rust_bridge::for_generated::TaskInfo { @@ -1657,6 +1788,7 @@ fn wire__crate__api__evfs__vault_write_impl( let api_name = name.cst_decode(); let api_data = data.cst_decode(); let api_compression = compression.cst_decode(); + let api_metadata = metadata.cst_decode(); move |context| { transform_result_dco::<_, _, crate::core::error::CryptoError>((move || { let mut api_handle_guard = None; @@ -1680,7 +1812,7 @@ fn wire__crate__api__evfs__vault_write_impl( api_name, api_data, api_compression, - None, + api_metadata, )?; Ok(output_ok) })()) @@ -1842,6 +1974,14 @@ impl SseDecode for VaultHandle { } } +impl SseDecode for std::collections::HashMap { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = >::sse_decode(deserializer); + return inner.into_iter().collect(); + } +} + impl SseDecode for RustOpaqueNom> { @@ -2004,17 +2144,21 @@ impl SseDecode for crate::core::error::CryptoError { } 13 => { let mut var_field0 = ::sse_decode(deserializer); - return crate::core::error::CryptoError::VaultCorrupted(var_field0); + return crate::core::error::CryptoError::DuplicateSegment(var_field0); } 14 => { let mut var_field0 = ::sse_decode(deserializer); - return crate::core::error::CryptoError::KeyRotationFailed(var_field0); + return crate::core::error::CryptoError::VaultCorrupted(var_field0); } 15 => { let mut var_field0 = ::sse_decode(deserializer); - return crate::core::error::CryptoError::ExportFailed(var_field0); + return crate::core::error::CryptoError::KeyRotationFailed(var_field0); } 16 => { + let mut var_field0 = ::sse_decode(deserializer); + return crate::core::error::CryptoError::ExportFailed(var_field0); + } + 17 => { let mut var_field0 = ::sse_decode(deserializer); return crate::core::error::CryptoError::ImportFailed(var_field0); } @@ -2077,6 +2221,56 @@ impl SseDecode for Vec { } } +impl SseDecode for Vec<(String, String)> { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut len_ = ::sse_decode(deserializer); + let mut ans_ = vec![]; + for idx_ in 0..len_ { + ans_.push(<(String, String)>::sse_decode(deserializer)); + } + return ans_; + } +} + +impl SseDecode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut len_ = ::sse_decode(deserializer); + let mut ans_ = vec![]; + for idx_ in 0..len_ { + ans_.push(::sse_decode( + deserializer, + )); + } + return ans_; + } +} + +impl SseDecode for Option> { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + if (::sse_decode(deserializer)) { + return Some(>::sse_decode( + deserializer, + )); + } else { + return None; + } + } +} + +impl SseDecode for Option { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + if (::sse_decode(deserializer)) { + return Some(::sse_decode(deserializer)); + } else { + return None; + } + } +} + impl SseDecode for Option { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -2112,6 +2306,42 @@ impl SseDecode for Option> { } } +impl SseDecode for (String, String) { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_field0 = ::sse_decode(deserializer); + let mut var_field1 = ::sse_decode(deserializer); + return (var_field0, var_field1); + } +} + +impl SseDecode for crate::api::evfs::types::SegmentReadResult { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_data = >::sse_decode(deserializer); + let mut var_metadata = + >::sse_decode(deserializer); + return crate::api::evfs::types::SegmentReadResult { + data: var_data, + metadata: var_metadata, + }; + } +} + +impl SseDecode for crate::api::evfs::types::SegmentResult { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_name = ::sse_decode(deserializer); + let mut var_data = >::sse_decode(deserializer); + let mut var_error = >::sse_decode(deserializer); + return crate::api::evfs::types::SegmentResult { + name: var_name, + data: var_data, + error: var_error, + }; + } +} + impl SseDecode for u32 { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -2366,18 +2596,21 @@ impl flutter_rust_bridge::IntoDart for crate::core::error::CryptoError { crate::core::error::CryptoError::SegmentNotFound(field0) => { [12.into_dart(), field0.into_into_dart().into_dart()].into_dart() } - crate::core::error::CryptoError::VaultCorrupted(field0) => { + crate::core::error::CryptoError::DuplicateSegment(field0) => { [13.into_dart(), field0.into_into_dart().into_dart()].into_dart() } - crate::core::error::CryptoError::KeyRotationFailed(field0) => { + crate::core::error::CryptoError::VaultCorrupted(field0) => { [14.into_dart(), field0.into_into_dart().into_dart()].into_dart() } - crate::core::error::CryptoError::ExportFailed(field0) => { + crate::core::error::CryptoError::KeyRotationFailed(field0) => { [15.into_dart(), field0.into_into_dart().into_dart()].into_dart() } - crate::core::error::CryptoError::ImportFailed(field0) => { + crate::core::error::CryptoError::ExportFailed(field0) => { [16.into_dart(), field0.into_into_dart().into_dart()].into_dart() } + crate::core::error::CryptoError::ImportFailed(field0) => { + [17.into_dart(), field0.into_into_dart().into_dart()].into_dart() + } _ => { unimplemented!(""); } @@ -2418,6 +2651,49 @@ impl flutter_rust_bridge::IntoIntoDart } } // Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::evfs::types::SegmentReadResult { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.data.into_into_dart().into_dart(), + self.metadata.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::evfs::types::SegmentReadResult +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::evfs::types::SegmentReadResult +{ + fn into_into_dart(self) -> crate::api::evfs::types::SegmentReadResult { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::evfs::types::SegmentResult { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.name.into_into_dart().into_dart(), + self.data.into_into_dart().into_dart(), + self.error.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::evfs::types::SegmentResult +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::evfs::types::SegmentResult +{ + fn into_into_dart(self) -> crate::api::evfs::types::SegmentResult { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs impl flutter_rust_bridge::IntoDart for crate::api::evfs::types::VaultCapacityInfo { fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { [ @@ -2498,6 +2774,13 @@ impl SseEncode for VaultHandle { } } +impl SseEncode for std::collections::HashMap { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + >::sse_encode(self.into_iter().collect(), serializer); + } +} + impl SseEncode for RustOpaqueNom> { @@ -2654,22 +2937,26 @@ impl SseEncode for crate::core::error::CryptoError { ::sse_encode(12, serializer); ::sse_encode(field0, serializer); } - crate::core::error::CryptoError::VaultCorrupted(field0) => { + crate::core::error::CryptoError::DuplicateSegment(field0) => { ::sse_encode(13, serializer); ::sse_encode(field0, serializer); } - crate::core::error::CryptoError::KeyRotationFailed(field0) => { + crate::core::error::CryptoError::VaultCorrupted(field0) => { ::sse_encode(14, serializer); ::sse_encode(field0, serializer); } - crate::core::error::CryptoError::ExportFailed(field0) => { + crate::core::error::CryptoError::KeyRotationFailed(field0) => { ::sse_encode(15, serializer); ::sse_encode(field0, serializer); } - crate::core::error::CryptoError::ImportFailed(field0) => { + crate::core::error::CryptoError::ExportFailed(field0) => { ::sse_encode(16, serializer); ::sse_encode(field0, serializer); } + crate::core::error::CryptoError::ImportFailed(field0) => { + ::sse_encode(17, serializer); + ::sse_encode(field0, serializer); + } _ => { unimplemented!(""); } @@ -2720,6 +3007,46 @@ impl SseEncode for Vec { } } +impl SseEncode for Vec<(String, String)> { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.len() as _, serializer); + for item in self { + <(String, String)>::sse_encode(item, serializer); + } + } +} + +impl SseEncode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.len() as _, serializer); + for item in self { + ::sse_encode(item, serializer); + } + } +} + +impl SseEncode for Option> { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.is_some(), serializer); + if let Some(value) = self { + >::sse_encode(value, serializer); + } + } +} + +impl SseEncode for Option { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.is_some(), serializer); + if let Some(value) = self { + ::sse_encode(value, serializer); + } + } +} + impl SseEncode for Option { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -2750,6 +3077,31 @@ impl SseEncode for Option> { } } +impl SseEncode for (String, String) { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.0, serializer); + ::sse_encode(self.1, serializer); + } +} + +impl SseEncode for crate::api::evfs::types::SegmentReadResult { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + >::sse_encode(self.data, serializer); + >::sse_encode(self.metadata, serializer); + } +} + +impl SseEncode for crate::api::evfs::types::SegmentResult { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.name, serializer); + >::sse_encode(self.data, serializer); + >::sse_encode(self.error, serializer); + } +} + impl SseEncode for u32 { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -2877,6 +3229,15 @@ mod io { )) } } + impl CstDecode> + for *mut wire_cst_list_record_string_string + { + // Codec=Cst (C-struct based), see doc to use other codecs + fn cst_decode(self) -> std::collections::HashMap { + let vec: Vec<(String, String)> = self.cst_decode(); + vec.into_iter().collect() + } + } impl CstDecode< RustOpaqueNom>, @@ -3014,18 +3375,22 @@ mod io { crate::core::error::CryptoError::SegmentNotFound(ans.field0.cst_decode()) } 13 => { + let ans = unsafe { self.kind.DuplicateSegment }; + crate::core::error::CryptoError::DuplicateSegment(ans.field0.cst_decode()) + } + 14 => { let ans = unsafe { self.kind.VaultCorrupted }; crate::core::error::CryptoError::VaultCorrupted(ans.field0.cst_decode()) } - 14 => { + 15 => { let ans = unsafe { self.kind.KeyRotationFailed }; crate::core::error::CryptoError::KeyRotationFailed(ans.field0.cst_decode()) } - 15 => { + 16 => { let ans = unsafe { self.kind.ExportFailed }; crate::core::error::CryptoError::ExportFailed(ans.field0.cst_decode()) } - 16 => { + 17 => { let ans = unsafe { self.kind.ImportFailed }; crate::core::error::CryptoError::ImportFailed(ans.field0.cst_decode()) } @@ -3071,6 +3436,51 @@ mod io { } } } + impl CstDecode> for *mut wire_cst_list_record_string_string { + // Codec=Cst (C-struct based), see doc to use other codecs + fn cst_decode(self) -> Vec<(String, String)> { + let vec = unsafe { + let wrap = flutter_rust_bridge::for_generated::box_from_leak_ptr(self); + flutter_rust_bridge::for_generated::vec_from_leak_ptr(wrap.ptr, wrap.len) + }; + vec.into_iter().map(CstDecode::cst_decode).collect() + } + } + impl CstDecode> for *mut wire_cst_list_segment_result { + // Codec=Cst (C-struct based), see doc to use other codecs + fn cst_decode(self) -> Vec { + let vec = unsafe { + let wrap = flutter_rust_bridge::for_generated::box_from_leak_ptr(self); + flutter_rust_bridge::for_generated::vec_from_leak_ptr(wrap.ptr, wrap.len) + }; + vec.into_iter().map(CstDecode::cst_decode).collect() + } + } + impl CstDecode<(String, String)> for wire_cst_record_string_string { + // Codec=Cst (C-struct based), see doc to use other codecs + fn cst_decode(self) -> (String, String) { + (self.field0.cst_decode(), self.field1.cst_decode()) + } + } + impl CstDecode for wire_cst_segment_read_result { + // Codec=Cst (C-struct based), see doc to use other codecs + fn cst_decode(self) -> crate::api::evfs::types::SegmentReadResult { + crate::api::evfs::types::SegmentReadResult { + data: self.data.cst_decode(), + metadata: self.metadata.cst_decode(), + } + } + } + impl CstDecode for wire_cst_segment_result { + // Codec=Cst (C-struct based), see doc to use other codecs + fn cst_decode(self) -> crate::api::evfs::types::SegmentResult { + crate::api::evfs::types::SegmentResult { + name: self.name.cst_decode(), + data: self.data.cst_decode(), + error: self.error.cst_decode(), + } + } + } impl CstDecode for wire_cst_vault_capacity_info { // Codec=Cst (C-struct based), see doc to use other codecs fn cst_decode(self) -> crate::api::evfs::types::VaultCapacityInfo { @@ -3139,6 +3549,46 @@ mod io { Self::new_with_null_ptr() } } + impl NewWithNullPtr for wire_cst_record_string_string { + fn new_with_null_ptr() -> Self { + Self { + field0: core::ptr::null_mut(), + field1: core::ptr::null_mut(), + } + } + } + impl Default for wire_cst_record_string_string { + fn default() -> Self { + Self::new_with_null_ptr() + } + } + impl NewWithNullPtr for wire_cst_segment_read_result { + fn new_with_null_ptr() -> Self { + Self { + data: core::ptr::null_mut(), + metadata: core::ptr::null_mut(), + } + } + } + impl Default for wire_cst_segment_read_result { + fn default() -> Self { + Self::new_with_null_ptr() + } + } + impl NewWithNullPtr for wire_cst_segment_result { + fn new_with_null_ptr() -> Self { + Self { + name: core::ptr::null_mut(), + data: core::ptr::null_mut(), + error: core::ptr::null_mut(), + } + } + } + impl Default for wire_cst_segment_result { + fn default() -> Self { + Self::new_with_null_ptr() + } + } impl NewWithNullPtr for wire_cst_vault_capacity_info { fn new_with_null_ptr() -> Self { Self { @@ -3556,6 +4006,14 @@ mod io { wire__crate__api__evfs__vault_export_impl(port_, handle, wrapping_key, export_path) } + #[unsafe(no_mangle)] + pub extern "C" fn frbgen_m_security_wire__crate__api__evfs__vault_flush( + port_: i64, + handle: usize, + ) { + wire__crate__api__evfs__vault_flush_impl(port_, handle) + } + #[unsafe(no_mangle)] pub extern "C" fn frbgen_m_security_wire__crate__api__evfs__vault_health( port_: i64, @@ -3611,6 +4069,15 @@ mod io { wire__crate__api__evfs__vault_read_impl(port_, handle, name) } + #[unsafe(no_mangle)] + pub extern "C" fn frbgen_m_security_wire__crate__api__evfs__vault_read_parallel( + port_: i64, + handle: usize, + names: *mut wire_cst_list_String, + ) { + wire__crate__api__evfs__vault_read_parallel_impl(port_, handle, names) + } + #[unsafe(no_mangle)] pub extern "C" fn frbgen_m_security_wire__crate__api__evfs__vault_read_stream( port_: i64, @@ -3630,6 +4097,16 @@ mod io { ) } + #[unsafe(no_mangle)] + pub extern "C" fn frbgen_m_security_wire__crate__api__evfs__vault_rename_segment( + port_: i64, + handle: usize, + old_name: *mut wire_cst_list_prim_u_8_strict, + new_name: *mut wire_cst_list_prim_u_8_strict, + ) { + wire__crate__api__evfs__vault_rename_segment_impl(port_, handle, old_name, new_name) + } + #[unsafe(no_mangle)] pub extern "C" fn frbgen_m_security_wire__crate__api__evfs__vault_resize( port_: i64, @@ -3655,8 +4132,9 @@ mod io { name: *mut wire_cst_list_prim_u_8_strict, data: *mut wire_cst_list_prim_u_8_loose, compression: *mut wire_cst_compression_config, + metadata: *mut wire_cst_list_record_string_string, ) { - wire__crate__api__evfs__vault_write_impl(port_, handle, name, data, compression) + wire__crate__api__evfs__vault_write_impl(port_, handle, name, data, compression, metadata) } #[unsafe(no_mangle)] @@ -3771,6 +4249,34 @@ mod io { flutter_rust_bridge::for_generated::new_leak_box_ptr(ans) } + #[unsafe(no_mangle)] + pub extern "C" fn frbgen_m_security_cst_new_list_record_string_string( + len: i32, + ) -> *mut wire_cst_list_record_string_string { + let wrap = wire_cst_list_record_string_string { + ptr: flutter_rust_bridge::for_generated::new_leak_vec_ptr( + ::new_with_null_ptr(), + len, + ), + len, + }; + flutter_rust_bridge::for_generated::new_leak_box_ptr(wrap) + } + + #[unsafe(no_mangle)] + pub extern "C" fn frbgen_m_security_cst_new_list_segment_result( + len: i32, + ) -> *mut wire_cst_list_segment_result { + let wrap = wire_cst_list_segment_result { + ptr: flutter_rust_bridge::for_generated::new_leak_vec_ptr( + ::new_with_null_ptr(), + len, + ), + len, + }; + flutter_rust_bridge::for_generated::new_leak_box_ptr(wrap) + } + #[repr(C)] #[derive(Clone, Copy)] pub struct wire_cst_compression_config { @@ -3795,6 +4301,7 @@ mod io { CompressionFailed: wire_cst_CryptoError_CompressionFailed, VaultFull: wire_cst_CryptoError_VaultFull, SegmentNotFound: wire_cst_CryptoError_SegmentNotFound, + DuplicateSegment: wire_cst_CryptoError_DuplicateSegment, VaultCorrupted: wire_cst_CryptoError_VaultCorrupted, KeyRotationFailed: wire_cst_CryptoError_KeyRotationFailed, ExportFailed: wire_cst_CryptoError_ExportFailed, @@ -3850,6 +4357,11 @@ mod io { } #[repr(C)] #[derive(Clone, Copy)] + pub struct wire_cst_CryptoError_DuplicateSegment { + field0: *mut wire_cst_list_prim_u_8_strict, + } + #[repr(C)] + #[derive(Clone, Copy)] pub struct wire_cst_CryptoError_VaultCorrupted { field0: *mut wire_cst_list_prim_u_8_strict, } @@ -3895,6 +4407,37 @@ mod io { } #[repr(C)] #[derive(Clone, Copy)] + pub struct wire_cst_list_record_string_string { + ptr: *mut wire_cst_record_string_string, + len: i32, + } + #[repr(C)] + #[derive(Clone, Copy)] + pub struct wire_cst_list_segment_result { + ptr: *mut wire_cst_segment_result, + len: i32, + } + #[repr(C)] + #[derive(Clone, Copy)] + pub struct wire_cst_record_string_string { + field0: *mut wire_cst_list_prim_u_8_strict, + field1: *mut wire_cst_list_prim_u_8_strict, + } + #[repr(C)] + #[derive(Clone, Copy)] + pub struct wire_cst_segment_read_result { + data: *mut wire_cst_list_prim_u_8_strict, + metadata: *mut wire_cst_list_record_string_string, + } + #[repr(C)] + #[derive(Clone, Copy)] + pub struct wire_cst_segment_result { + name: *mut wire_cst_list_prim_u_8_strict, + data: *mut wire_cst_list_prim_u_8_strict, + error: *mut wire_cst_list_prim_u_8_strict, + } + #[repr(C)] + #[derive(Clone, Copy)] pub struct wire_cst_vault_capacity_info { total_bytes: u64, used_bytes: u64, @@ -4015,10 +4558,11 @@ mod web { }, 11 => crate::core::error::CryptoError::VaultLocked, 12 => crate::core::error::CryptoError::SegmentNotFound(self_.get(1).cst_decode()), - 13 => crate::core::error::CryptoError::VaultCorrupted(self_.get(1).cst_decode()), - 14 => crate::core::error::CryptoError::KeyRotationFailed(self_.get(1).cst_decode()), - 15 => crate::core::error::CryptoError::ExportFailed(self_.get(1).cst_decode()), - 16 => crate::core::error::CryptoError::ImportFailed(self_.get(1).cst_decode()), + 13 => crate::core::error::CryptoError::DuplicateSegment(self_.get(1).cst_decode()), + 14 => crate::core::error::CryptoError::VaultCorrupted(self_.get(1).cst_decode()), + 15 => crate::core::error::CryptoError::KeyRotationFailed(self_.get(1).cst_decode()), + 16 => crate::core::error::CryptoError::ExportFailed(self_.get(1).cst_decode()), + 17 => crate::core::error::CryptoError::ImportFailed(self_.get(1).cst_decode()), _ => unreachable!(), } } @@ -4060,12 +4604,98 @@ mod web { self.into_vec() } } + impl CstDecode> + for flutter_rust_bridge::for_generated::wasm_bindgen::JsValue + { + // Codec=Cst (C-struct based), see doc to use other codecs + fn cst_decode(self) -> Vec<(String, String)> { + self.dyn_into::() + .unwrap() + .iter() + .map(CstDecode::cst_decode) + .collect() + } + } + impl CstDecode> + for flutter_rust_bridge::for_generated::wasm_bindgen::JsValue + { + // Codec=Cst (C-struct based), see doc to use other codecs + fn cst_decode(self) -> Vec { + self.dyn_into::() + .unwrap() + .iter() + .map(CstDecode::cst_decode) + .collect() + } + } + impl CstDecode> for Option { + // Codec=Cst (C-struct based), see doc to use other codecs + fn cst_decode(self) -> Option { + self.map(CstDecode::cst_decode) + } + } impl CstDecode>> for Option> { // Codec=Cst (C-struct based), see doc to use other codecs fn cst_decode(self) -> Option> { self.map(CstDecode::cst_decode) } } + impl CstDecode<(String, String)> for flutter_rust_bridge::for_generated::wasm_bindgen::JsValue { + // Codec=Cst (C-struct based), see doc to use other codecs + fn cst_decode(self) -> (String, String) { + let self_ = self + .dyn_into::() + .unwrap(); + assert_eq!( + self_.length(), + 2, + "Expected 2 elements, got {}", + self_.length() + ); + (self_.get(0).cst_decode(), self_.get(1).cst_decode()) + } + } + impl CstDecode + for flutter_rust_bridge::for_generated::wasm_bindgen::JsValue + { + // Codec=Cst (C-struct based), see doc to use other codecs + fn cst_decode(self) -> crate::api::evfs::types::SegmentReadResult { + let self_ = self + .dyn_into::() + .unwrap(); + assert_eq!( + self_.length(), + 2, + "Expected 2 elements, got {}", + self_.length() + ); + crate::api::evfs::types::SegmentReadResult { + data: self_.get(0).cst_decode(), + metadata: self_.get(1).cst_decode(), + } + } + } + impl CstDecode + for flutter_rust_bridge::for_generated::wasm_bindgen::JsValue + { + // Codec=Cst (C-struct based), see doc to use other codecs + fn cst_decode(self) -> crate::api::evfs::types::SegmentResult { + let self_ = self + .dyn_into::() + .unwrap(); + assert_eq!( + self_.length(), + 3, + "Expected 3 elements, got {}", + self_.length() + ); + crate::api::evfs::types::SegmentResult { + name: self_.get(0).cst_decode(), + data: self_.get(1).cst_decode(), + error: self_.get(2).cst_decode(), + } + } + } impl CstDecode for flutter_rust_bridge::for_generated::wasm_bindgen::JsValue { @@ -4158,6 +4788,15 @@ mod web { )) } } + impl CstDecode> + for flutter_rust_bridge::for_generated::wasm_bindgen::JsValue + { + // Codec=Cst (C-struct based), see doc to use other codecs + fn cst_decode(self) -> std::collections::HashMap { + let vec: Vec<(String, String)> = self.cst_decode(); + vec.into_iter().collect() + } + } impl CstDecode< RustOpaqueNom>, @@ -4682,6 +5321,14 @@ mod web { wire__crate__api__evfs__vault_export_impl(port_, handle, wrapping_key, export_path) } + #[wasm_bindgen] + pub fn wire__crate__api__evfs__vault_flush( + port_: flutter_rust_bridge::for_generated::MessagePort, + handle: flutter_rust_bridge::for_generated::wasm_bindgen::JsValue, + ) { + wire__crate__api__evfs__vault_flush_impl(port_, handle) + } + #[wasm_bindgen] pub fn wire__crate__api__evfs__vault_health( port_: flutter_rust_bridge::for_generated::MessagePort, @@ -4737,6 +5384,15 @@ mod web { wire__crate__api__evfs__vault_read_impl(port_, handle, name) } + #[wasm_bindgen] + pub fn wire__crate__api__evfs__vault_read_parallel( + port_: flutter_rust_bridge::for_generated::MessagePort, + handle: flutter_rust_bridge::for_generated::wasm_bindgen::JsValue, + names: flutter_rust_bridge::for_generated::wasm_bindgen::JsValue, + ) { + wire__crate__api__evfs__vault_read_parallel_impl(port_, handle, names) + } + #[wasm_bindgen] pub fn wire__crate__api__evfs__vault_read_stream( port_: flutter_rust_bridge::for_generated::MessagePort, @@ -4756,6 +5412,16 @@ mod web { ) } + #[wasm_bindgen] + pub fn wire__crate__api__evfs__vault_rename_segment( + port_: flutter_rust_bridge::for_generated::MessagePort, + handle: flutter_rust_bridge::for_generated::wasm_bindgen::JsValue, + old_name: String, + new_name: String, + ) { + wire__crate__api__evfs__vault_rename_segment_impl(port_, handle, old_name, new_name) + } + #[wasm_bindgen] pub fn wire__crate__api__evfs__vault_resize( port_: flutter_rust_bridge::for_generated::MessagePort, @@ -4781,8 +5447,9 @@ mod web { name: String, data: Box<[u8]>, compression: flutter_rust_bridge::for_generated::wasm_bindgen::JsValue, + metadata: flutter_rust_bridge::for_generated::wasm_bindgen::JsValue, ) { - wire__crate__api__evfs__vault_write_impl(port_, handle, name, data, compression) + wire__crate__api__evfs__vault_write_impl(port_, handle, name, data, compression, metadata) } #[wasm_bindgen] From a9ea3be1b4b69dbaf304b6142f22ec0c9a82f53c Mon Sep 17 00:00:00 2001 From: Adel-Ayoub Date: Thu, 9 Apr 2026 15:14:02 +0100 Subject: [PATCH 23/32] feat(evfs): add Dart wrappers for renameSegment, flush, readParallel, and metadata support --- lib/src/evfs/vault_service.dart | 46 ++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/lib/src/evfs/vault_service.dart b/lib/src/evfs/vault_service.dart index c5b92a0..452bf10 100644 --- a/lib/src/evfs/vault_service.dart +++ b/lib/src/evfs/vault_service.dart @@ -45,17 +45,22 @@ class VaultService { /// [compression] is optional — defaults to no compression. /// MIME-aware skip: if [name] has an already-compressed extension /// (e.g., ".jpg"), compression is bypassed automatically. + /// + /// [metadata] is optional key-value tags stored alongside the segment + /// (encrypted in the index). Retrieved via [read]. static Future write({ required rust_types.VaultHandle handle, required String name, required Uint8List data, CompressionConfig? compression, + Map? metadata, }) { return rust_evfs.vaultWrite( handle: handle, name: name, data: data, compression: compression, + metadata: metadata, ); } @@ -130,7 +135,9 @@ class VaultService { } /// Read a named segment. Decompression is automatic. - static Future read({ + /// + /// Returns [SegmentReadResult] with decrypted data and metadata. + static Future read({ required rust_types.VaultHandle handle, required String name, }) { @@ -313,6 +320,43 @@ class VaultService { ); } + /// Rename a segment without re-encryption (index-only operation). + /// + /// Throws [DuplicateSegment] if [newName] already exists. + /// Throws [SegmentNotFound] if [oldName] does not exist. + static Future renameSegment({ + required rust_types.VaultHandle handle, + required String oldName, + required String newName, + }) { + return rust_evfs.vaultRenameSegment( + handle: handle, + oldName: oldName, + newName: newName, + ); + } + + /// Explicitly flush the in-memory index to disk. + /// + /// No-op if the index has not been modified since the last flush. + static Future flush({required rust_types.VaultHandle handle}) { + return rust_evfs.vaultFlush(handle: handle); + } + + /// Read multiple segments concurrently. + /// + /// Returns one [SegmentResult] per name in the same order as [names]. + /// On per-segment failure, the result's [error] field describes the problem + /// (e.g. segment not found) and [data] is empty. + /// + /// Does not return per-segment metadata. Use [read] if metadata is needed. + static Future> readParallel({ + required rust_types.VaultHandle handle, + required List names, + }) { + return rust_evfs.vaultReadParallel(handle: handle, names: names); + } + /// Close the vault (release lock, zeroize keys). static Future close({required rust_types.VaultHandle handle}) { return rust_evfs.vaultClose(handle: handle); From 3bd885ab71a2c0d58e06d869e61432ba31b18f91 Mon Sep 17 00:00:00 2001 From: Adel-Ayoub Date: Thu, 9 Apr 2026 15:14:36 +0100 Subject: [PATCH 24/32] fix(evfs): update existing callers for SegmentReadResult return type --- example/lib/main.dart | 6 ++-- integration_test/evfs_test.dart | 36 +++++++++---------- .../vault_key_management_test.dart | 20 +++++------ 3 files changed, 31 insertions(+), 31 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index d2966c5..d1e57a6 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -711,12 +711,12 @@ class _VaultTabState extends State<_VaultTab> { if (!_vaultOpen || _handle == null) return; setState(() => _loading = true); try { - final data = await VaultService.read(handle: _handle!, name: name); + final result = await VaultService.read(handle: _handle!, name: name); // Try decoding as UTF-8, fallback to hex try { - _readResult = '[$name] ${utf8.decode(data)}'; + _readResult = '[$name] ${utf8.decode(result.data)}'; } catch (_) { - _readResult = '[$name] ${_hex(data)}'; + _readResult = '[$name] ${_hex(result.data)}'; } } catch (e) { _readResult = 'Error reading "$name": $e'; diff --git a/integration_test/evfs_test.dart b/integration_test/evfs_test.dart index ebd4521..3afe9b2 100644 --- a/integration_test/evfs_test.dart +++ b/integration_test/evfs_test.dart @@ -49,7 +49,7 @@ void main() { handle: reopened, name: 'test.bin', ); - expect(result, data); + expect(result.data, data); //close vault await VaultService.close(handle: reopened); @@ -92,9 +92,9 @@ void main() { name: 'file3.dat', ); - expect(result1, data1); - expect(result2, data2); - expect(result3, data3); + expect(result1.data, data1); + expect(result2.data, data2); + expect(result3.data, data3); await VaultService.close(handle: handle); }); @@ -125,8 +125,8 @@ void main() { // Read back final result = await VaultService.read(handle: handle, name: 'data.bin'); - expect(result, updated); - expect(result, isNot(original)); + expect(result.data, updated); + expect(result.data, isNot(original)); await VaultService.close(handle: handle); }); @@ -166,7 +166,7 @@ void main() { //verify it exists final before = await VaultService.read(handle: handle, name: 'temp.dat'); - expect(before, data); + expect(before.data, data); //delete it await VaultService.delete(handle: handle, name: 'temp.dat'); @@ -340,7 +340,7 @@ void main() { ), ); final result = await VaultService.read(handle: handle, name: 'notes.txt'); - expect(result, data); + expect(result.data, data); await VaultService.close(handle: handle); }); @@ -364,7 +364,7 @@ void main() { ), ); final result = await VaultService.read(handle: handle, name: 'notes.txt'); - expect(result, data); + expect(result.data, data); await VaultService.close(handle: handle); }); @@ -396,7 +396,7 @@ void main() { // Read back final result = await VaultService.read(handle: handle, name: 'photo.jpg'); - expect(result, jpegData); + expect(result.data, jpegData); // Verify: if compression was applied, capacity used would be less // Since it was skipped, capacity used ≈ original size @@ -469,17 +469,17 @@ void main() { // Verify all three match original data expect( - resultA, + resultA.data, dataA, reason: 'Zstd segment should decompress correctly', ); expect( - resultB, + resultB.data, dataB, reason: 'Brotli segment should decompress correctly', ); expect( - resultC, + resultC.data, dataC, reason: 'Uncompressed segment should read correctly', ); @@ -567,7 +567,7 @@ void main() { handle: reopened, name: 'initial.bin', ); - expect(result, data1); + expect(result.data, data1); // Write more data after recovery final data2 = Uint8List.fromList([6, 7, 8, 9]); @@ -591,8 +591,8 @@ void main() { name: 'after_recovery.bin', ); - expect(result1, data1); - expect(result2, data2); + expect(result1.data, data1); + expect(result2.data, data2); await VaultService.close(handle: reopened2); }); @@ -646,7 +646,7 @@ void main() { name: 'important.bin', ); expect( - result, + result.data, originalData, reason: 'Shadow index should have preserved the segment', ); @@ -659,7 +659,7 @@ void main() { handle: reopened2, name: 'important.bin', ); - expect(result2, originalData); + expect(result2.data, originalData); await VaultService.close(handle: reopened2); }); diff --git a/integration_test/vault_key_management_test.dart b/integration_test/vault_key_management_test.dart index bd052ac..ea7265b 100644 --- a/integration_test/vault_key_management_test.dart +++ b/integration_test/vault_key_management_test.dart @@ -41,8 +41,8 @@ void main() { handle = await VaultService.rotateKey(handle: handle, newKey: newKey); // All segments readable with new handle - expect(await VaultService.read(handle: handle, name: 'a.bin'), dataA); - expect(await VaultService.read(handle: handle, name: 'b.bin'), dataB); + expect((await VaultService.read(handle: handle, name: 'a.bin')).data, dataA); + expect((await VaultService.read(handle: handle, name: 'b.bin')).data, dataB); await VaultService.close(handle: handle); }); @@ -77,7 +77,7 @@ void main() { // New key works final reopened = await VaultService.open(path: path, key: newKey); expect( - await VaultService.read(handle: reopened, name: 'secret.bin'), + (await VaultService.read(handle: reopened, name: 'secret.bin')).data, Uint8List.fromList([1, 2, 3]), ); await VaultService.close(handle: reopened); @@ -123,8 +123,8 @@ void main() { capacityBytes: 2 * 1024 * 1024, ); - expect(await VaultService.read(handle: imported, name: 'file1.dat'), data1); - expect(await VaultService.read(handle: imported, name: 'file2.dat'), data2); + expect((await VaultService.read(handle: imported, name: 'file1.dat')).data, data1); + expect((await VaultService.read(handle: imported, name: 'file2.dat')).data, data2); await VaultService.close(handle: imported); }); @@ -202,7 +202,7 @@ void main() { capacityBytes: 5 * 1024 * 1024, ); - expect(await VaultService.read(handle: imported, name: 'big.bin'), bigData); + expect((await VaultService.read(handle: imported, name: 'big.bin')).data, bigData); await VaultService.close(handle: imported); }); @@ -224,16 +224,16 @@ void main() { // Rotate twice handle = await VaultService.rotateKey(handle: handle, newKey: key2); - expect(await VaultService.read(handle: handle, name: 'data.bin'), data); + expect((await VaultService.read(handle: handle, name: 'data.bin')).data, data); handle = await VaultService.rotateKey(handle: handle, newKey: key3); - expect(await VaultService.read(handle: handle, name: 'data.bin'), data); + expect((await VaultService.read(handle: handle, name: 'data.bin')).data, data); await VaultService.close(handle: handle); // Only key3 works final reopened = await VaultService.open(path: path, key: key3); - expect(await VaultService.read(handle: reopened, name: 'data.bin'), data); + expect((await VaultService.read(handle: reopened, name: 'data.bin')).data, data); await VaultService.close(handle: reopened); }); @@ -278,7 +278,7 @@ void main() { ); expect( - await VaultService.read(handle: imported, name: 'payload.bin'), + (await VaultService.read(handle: imported, name: 'payload.bin')).data, data, ); await VaultService.close(handle: imported); From d0f8965c682edd1766ccf3f2599481966cf65dc4 Mon Sep 17 00:00:00 2001 From: Adel-Ayoub Date: Thu, 9 Apr 2026 15:14:48 +0100 Subject: [PATCH 25/32] test(evfs): add integration tests for metadata, rename, flush, and parallel reads --- integration_test/evfs_enhancements_test.dart | 546 +++++++++++++++++++ 1 file changed, 546 insertions(+) create mode 100644 integration_test/evfs_enhancements_test.dart diff --git a/integration_test/evfs_enhancements_test.dart b/integration_test/evfs_enhancements_test.dart new file mode 100644 index 0000000..94660de --- /dev/null +++ b/integration_test/evfs_enhancements_test.dart @@ -0,0 +1,546 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:m_security/src/rust/api/encryption.dart'; +import 'package:m_security/src/rust/frb_generated.dart'; +import 'package:m_security/src/evfs/vault_service.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(() async => await RustLib.init()); + + group('Segment Metadata', () { + late Directory tempDir; + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('meta_test'); + }); + tearDown(() async { + await tempDir.delete(recursive: true); + }); + + test('write with metadata, read back matches', () async { + final path = '${tempDir.path}/meta.vault'; + final key = await generateAes256GcmKey(); + + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 2 * 1024 * 1024, + ); + + final data = Uint8List.fromList([1, 2, 3, 4, 5]); + final metadata = {'mime': 'application/octet-stream', 'author': 'test'}; + + await VaultService.write( + handle: handle, + name: 'doc.bin', + data: data, + metadata: metadata, + ); + + final result = await VaultService.read(handle: handle, name: 'doc.bin'); + expect(result.data, data); + expect(result.metadata, metadata); + + await VaultService.close(handle: handle); + }); + + test('write without metadata returns empty map', () async { + final path = '${tempDir.path}/nometa.vault'; + final key = await generateAes256GcmKey(); + + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 2 * 1024 * 1024, + ); + + final data = Uint8List.fromList([10, 20, 30]); + await VaultService.write(handle: handle, name: 'plain.bin', data: data); + + final result = await VaultService.read( + handle: handle, + name: 'plain.bin', + ); + expect(result.data, data); + expect(result.metadata, isEmpty); + + await VaultService.close(handle: handle); + }); + + test('overwrite with different metadata replaces old', () async { + final path = '${tempDir.path}/overwrite_meta.vault'; + final key = await generateAes256GcmKey(); + + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 2 * 1024 * 1024, + ); + + final data = Uint8List.fromList([1, 2, 3]); + await VaultService.write( + handle: handle, + name: 'file.bin', + data: data, + metadata: {'version': '1'}, + ); + + // Overwrite with new metadata + final newData = Uint8List.fromList([4, 5, 6]); + await VaultService.write( + handle: handle, + name: 'file.bin', + data: newData, + metadata: {'version': '2', 'updated': 'true'}, + ); + + final result = await VaultService.read(handle: handle, name: 'file.bin'); + expect(result.data, newData); + expect(result.metadata, {'version': '2', 'updated': 'true'}); + + await VaultService.close(handle: handle); + }); + + test('metadata survives close and reopen', () async { + final path = '${tempDir.path}/persist_meta.vault'; + final key = await generateAes256GcmKey(); + + var handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 2 * 1024 * 1024, + ); + + final data = Uint8List.fromList([7, 8, 9]); + await VaultService.write( + handle: handle, + name: 'persist.bin', + data: data, + metadata: {'created': '2026-04-09'}, + ); + await VaultService.close(handle: handle); + + // Reopen and verify + handle = await VaultService.open(path: path, key: key); + final result = await VaultService.read( + handle: handle, + name: 'persist.bin', + ); + expect(result.data, data); + expect(result.metadata['created'], '2026-04-09'); + + await VaultService.close(handle: handle); + }); + }); + + group('Segment Rename', () { + late Directory tempDir; + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('rename_test'); + }); + tearDown(() async { + await tempDir.delete(recursive: true); + }); + + test('rename then read under new name', () async { + final path = '${tempDir.path}/rename.vault'; + final key = await generateAes256GcmKey(); + + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 2 * 1024 * 1024, + ); + + final data = Uint8List.fromList([1, 2, 3, 4, 5]); + await VaultService.write(handle: handle, name: 'old.bin', data: data); + + await VaultService.renameSegment( + handle: handle, + oldName: 'old.bin', + newName: 'new.bin', + ); + + // Readable under new name + final result = await VaultService.read(handle: handle, name: 'new.bin'); + expect(result.data, data); + + // Old name gone + expect( + () async => + await VaultService.read(handle: handle, name: 'old.bin'), + throwsA(isA()), + ); + + // List reflects rename + final names = await VaultService.list(handle: handle); + expect(names, contains('new.bin')); + expect(names, isNot(contains('old.bin'))); + + await VaultService.close(handle: handle); + }); + + test('rename to existing name throws DuplicateSegment', () async { + final path = '${tempDir.path}/dup.vault'; + final key = await generateAes256GcmKey(); + + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 2 * 1024 * 1024, + ); + + await VaultService.write( + handle: handle, + name: 'a.bin', + data: Uint8List(10), + ); + await VaultService.write( + handle: handle, + name: 'b.bin', + data: Uint8List(10), + ); + + expect( + () async => await VaultService.renameSegment( + handle: handle, + oldName: 'a.bin', + newName: 'b.bin', + ), + throwsA( + predicate((e) => e.toString().contains('duplicateSegment')), + ), + ); + + await VaultService.close(handle: handle); + }); + + test('rename nonexistent segment throws SegmentNotFound', () async { + final path = '${tempDir.path}/notfound.vault'; + final key = await generateAes256GcmKey(); + + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 1024 * 1024, + ); + + expect( + () async => await VaultService.renameSegment( + handle: handle, + oldName: 'ghost.bin', + newName: 'new.bin', + ), + throwsA( + predicate((e) => e.toString().contains('segmentNotFound')), + ), + ); + + await VaultService.close(handle: handle); + }); + + test('rename preserves metadata', () async { + final path = '${tempDir.path}/rename_meta.vault'; + final key = await generateAes256GcmKey(); + + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 2 * 1024 * 1024, + ); + + final data = Uint8List.fromList([42]); + await VaultService.write( + handle: handle, + name: 'before.bin', + data: data, + metadata: {'tag': 'important'}, + ); + + await VaultService.renameSegment( + handle: handle, + oldName: 'before.bin', + newName: 'after.bin', + ); + + final result = await VaultService.read(handle: handle, name: 'after.bin'); + expect(result.data, data); + expect(result.metadata['tag'], 'important'); + + await VaultService.close(handle: handle); + }); + }); + + group('Flush', () { + late Directory tempDir; + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('flush_test'); + }); + tearDown(() async { + await tempDir.delete(recursive: true); + }); + + test('explicit flush after write succeeds', () async { + final path = '${tempDir.path}/flush.vault'; + final key = await generateAes256GcmKey(); + + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 2 * 1024 * 1024, + ); + + await VaultService.write( + handle: handle, + name: 'data.bin', + data: Uint8List.fromList([1, 2, 3]), + ); + + // Flush should not throw + await VaultService.flush(handle: handle); + + // Data still readable + final result = await VaultService.read(handle: handle, name: 'data.bin'); + expect(result.data, Uint8List.fromList([1, 2, 3])); + + await VaultService.close(handle: handle); + }); + + test('flush on clean handle is no-op', () async { + final path = '${tempDir.path}/noop_flush.vault'; + final key = await generateAes256GcmKey(); + + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 1024 * 1024, + ); + + // No writes — flush should be a safe no-op + await VaultService.flush(handle: handle); + + await VaultService.close(handle: handle); + }); + }); + + group('Parallel Read', () { + late Directory tempDir; + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('parallel_test'); + }); + tearDown(() async { + await tempDir.delete(recursive: true); + }); + + test('parallel read 5 segments matches sequential', () async { + final path = '${tempDir.path}/parallel.vault'; + final key = await generateAes256GcmKey(); + + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 5 * 1024 * 1024, + ); + + // Write 5 segments + final segments = {}; + for (var i = 0; i < 5; i++) { + final name = 'seg_$i.bin'; + final data = Uint8List.fromList( + List.generate(1000 + i * 100, (j) => (j * (i + 1)) % 256), + ); + segments[name] = data; + await VaultService.write(handle: handle, name: name, data: data); + } + + // Parallel read all + final results = await VaultService.readParallel( + handle: handle, + names: segments.keys.toList(), + ); + + expect(results.length, 5); + for (final r in results) { + expect(r.error, isNull, reason: 'segment ${r.name} had error: ${r.error}'); + expect(r.data, segments[r.name], reason: 'data mismatch for ${r.name}'); + } + + await VaultService.close(handle: handle); + }); + + test('parallel read with missing name returns per-segment error', () async { + final path = '${tempDir.path}/partial.vault'; + final key = await generateAes256GcmKey(); + + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 2 * 1024 * 1024, + ); + + await VaultService.write( + handle: handle, + name: 'exists.bin', + data: Uint8List.fromList([1, 2, 3]), + ); + + final results = await VaultService.readParallel( + handle: handle, + names: ['exists.bin', 'missing.bin'], + ); + + expect(results.length, 2); + + final existing = results.firstWhere((r) => r.name == 'exists.bin'); + expect(existing.error, isNull); + expect(existing.data, Uint8List.fromList([1, 2, 3])); + + final missing = results.firstWhere((r) => r.name == 'missing.bin'); + expect(missing.error, isNotNull); + expect(missing.data, isEmpty); + + await VaultService.close(handle: handle); + }); + + test('parallel read empty list returns empty result', () async { + final path = '${tempDir.path}/empty.vault'; + final key = await generateAes256GcmKey(); + + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 1024 * 1024, + ); + + final results = await VaultService.readParallel( + handle: handle, + names: [], + ); + expect(results, isEmpty); + + await VaultService.close(handle: handle); + }); + }); + + group('Combined Workflows', () { + late Directory tempDir; + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('combined_test'); + }); + tearDown(() async { + await tempDir.delete(recursive: true); + }); + + test('write with metadata, rename, parallel read — metadata preserved', + () async { + final path = '${tempDir.path}/combined.vault'; + final key = await generateAes256GcmKey(); + + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 5 * 1024 * 1024, + ); + + final data = Uint8List.fromList(List.generate(500, (i) => i % 256)); + + // Write with metadata + await VaultService.write( + handle: handle, + name: 'original.bin', + data: data, + metadata: {'type': 'document', 'priority': 'high'}, + ); + + // Rename + await VaultService.renameSegment( + handle: handle, + oldName: 'original.bin', + newName: 'renamed.bin', + ); + + // Write another segment (no metadata) + await VaultService.write( + handle: handle, + name: 'extra.bin', + data: Uint8List.fromList([99]), + ); + + // Parallel read both + final results = await VaultService.readParallel( + handle: handle, + names: ['renamed.bin', 'extra.bin'], + ); + + expect(results.length, 2); + final renamed = results.firstWhere((r) => r.name == 'renamed.bin'); + expect(renamed.data, data); + expect(renamed.error, isNull); + + // Verify metadata via single read (parallel read doesn't return metadata) + final detailed = await VaultService.read( + handle: handle, + name: 'renamed.bin', + ); + expect(detailed.metadata['type'], 'document'); + expect(detailed.metadata['priority'], 'high'); + + await VaultService.close(handle: handle); + }); + + test('write 10 segments, parallel read all, verify data', () async { + final path = '${tempDir.path}/bulk.vault'; + final key = await generateAes256GcmKey(); + + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 10 * 1024 * 1024, + ); + + final segments = {}; + for (var i = 0; i < 10; i++) { + final name = 'bulk_$i.dat'; + final data = Uint8List.fromList( + List.generate(2048, (j) => (j + i * 17) % 256), + ); + segments[name] = data; + await VaultService.write(handle: handle, name: name, data: data); + } + + // Flush before parallel read + await VaultService.flush(handle: handle); + + final results = await VaultService.readParallel( + handle: handle, + names: segments.keys.toList(), + ); + + expect(results.length, 10); + for (final r in results) { + expect(r.error, isNull, reason: '${r.name} failed: ${r.error}'); + expect(r.data, segments[r.name], reason: 'data mismatch: ${r.name}'); + } + + await VaultService.close(handle: handle); + }); + }); +} From 1bc809a9059a2c8c74cd836cc208389f2f725ae0 Mon Sep 17 00:00:00 2001 From: Adel-Ayoub Date: Thu, 9 Apr 2026 15:35:47 +0100 Subject: [PATCH 26/32] fix(evfs): await async error assertions in rename tests --- integration_test/evfs_enhancements_test.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/integration_test/evfs_enhancements_test.dart b/integration_test/evfs_enhancements_test.dart index 94660de..9b43c0b 100644 --- a/integration_test/evfs_enhancements_test.dart +++ b/integration_test/evfs_enhancements_test.dart @@ -210,8 +210,8 @@ void main() { data: Uint8List(10), ); - expect( - () async => await VaultService.renameSegment( + await expectLater( + () => VaultService.renameSegment( handle: handle, oldName: 'a.bin', newName: 'b.bin', @@ -235,8 +235,8 @@ void main() { capacityBytes: 1024 * 1024, ); - expect( - () async => await VaultService.renameSegment( + await expectLater( + () => VaultService.renameSegment( handle: handle, oldName: 'ghost.bin', newName: 'new.bin', From 1e1dcc9b7b7d91ca4d138a4beda7655b9370c081 Mon Sep 17 00:00:00 2001 From: Adel-Ayoub Date: Thu, 9 Apr 2026 15:36:07 +0100 Subject: [PATCH 27/32] feat(evfs): add metadata support to writeStream and improve API docs --- lib/src/evfs/vault_service.dart | 7 +++++++ lib/src/rust/api/evfs.dart | 2 ++ lib/src/rust/frb_generated.dart | 8 ++++++-- lib/src/rust/frb_generated.io.dart | 4 ++++ lib/src/rust/frb_generated.web.dart | 3 +++ rust/src/api/evfs/mod.rs | 3 ++- rust/src/frb_generated.rs | 23 +++++++++++++++++++++-- 7 files changed, 45 insertions(+), 5 deletions(-) diff --git a/lib/src/evfs/vault_service.dart b/lib/src/evfs/vault_service.dart index 452bf10..9de941c 100644 --- a/lib/src/evfs/vault_service.dart +++ b/lib/src/evfs/vault_service.dart @@ -70,12 +70,15 @@ class VaultService { /// is bounded to a single chunk. [totalSize] must equal the exact number /// of bytes that [data] will emit. /// + /// [metadata] is optional key-value tags stored alongside the segment. + /// /// [onProgress] is called with values in (0.0, 1.0] as chunks are encrypted. static Future writeStream({ required rust_types.VaultHandle handle, required String name, required int totalSize, required Stream data, + Map? metadata, void Function(double progress)? onProgress, }) async { final tempDir = await Directory.systemTemp.createTemp('vault_write_stream'); @@ -119,6 +122,7 @@ class VaultService { handle: handle, name: name, filePath: tempFile.path, + metadata: metadata, ), ); @@ -349,6 +353,9 @@ class VaultService { /// On per-segment failure, the result's [error] field describes the problem /// (e.g. segment not found) and [data] is empty. /// + /// All segments are decrypted into memory simultaneously — callers should + /// be mindful of total memory when reading many large segments at once. + /// /// Does not return per-segment metadata. Use [read] if metadata is needed. static Future> readParallel({ required rust_types.VaultHandle handle, diff --git a/lib/src/rust/api/evfs.dart b/lib/src/rust/api/evfs.dart index 6d2161d..a46a72d 100644 --- a/lib/src/rust/api/evfs.dart +++ b/lib/src/rust/api/evfs.dart @@ -80,10 +80,12 @@ Stream vaultWriteFile({ required VaultHandle handle, required String name, required String filePath, + Map? metadata, }) => RustLib.instance.api.crateApiEvfsVaultWriteFile( handle: handle, name: name, filePath: filePath, + metadata: metadata, ); /// Renames an existing segment in the vault index without modifying its encrypted data. diff --git a/lib/src/rust/frb_generated.dart b/lib/src/rust/frb_generated.dart index c252c15..a74f5cf 100644 --- a/lib/src/rust/frb_generated.dart +++ b/lib/src/rust/frb_generated.dart @@ -328,6 +328,7 @@ abstract class RustLibApi extends BaseApi { required VaultHandle handle, required String name, required String filePath, + Map? metadata, }); RustArcIncrementStrongCountFnType @@ -2097,6 +2098,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { required VaultHandle handle, required String name, required String filePath, + Map? metadata, }) { final onProgress = RustStreamSink(); unawaited( @@ -2110,12 +2112,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { var arg1 = cst_encode_String(name); var arg2 = cst_encode_String(filePath); var arg3 = cst_encode_StreamSink_f_64_Dco(onProgress); + var arg4 = cst_encode_opt_Map_String_String_None(metadata); return wire.wire__crate__api__evfs__vault_write_file( port_, arg0, arg1, arg2, arg3, + arg4, ); }, codec: DcoCodec( @@ -2123,7 +2127,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { decodeErrorData: dco_decode_crypto_error, ), constMeta: kCrateApiEvfsVaultWriteFileConstMeta, - argValues: [handle, name, filePath, onProgress], + argValues: [handle, name, filePath, onProgress, metadata], apiImpl: this, ), ), @@ -2133,7 +2137,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { TaskConstMeta get kCrateApiEvfsVaultWriteFileConstMeta => const TaskConstMeta( debugName: "vault_write_file", - argNames: ["handle", "name", "filePath", "onProgress"], + argNames: ["handle", "name", "filePath", "onProgress", "metadata"], ); RustArcIncrementStrongCountFnType diff --git a/lib/src/rust/frb_generated.io.dart b/lib/src/rust/frb_generated.io.dart index c0a62b0..5e79858 100644 --- a/lib/src/rust/frb_generated.io.dart +++ b/lib/src/rust/frb_generated.io.dart @@ -2427,6 +2427,7 @@ class RustLibWire implements BaseWire { ffi.Pointer name, ffi.Pointer file_path, ffi.Pointer on_progress, + ffi.Pointer metadata, ) { return _wire__crate__api__evfs__vault_write_file( port_, @@ -2434,6 +2435,7 @@ class RustLibWire implements BaseWire { name, file_path, on_progress, + metadata, ); } @@ -2446,6 +2448,7 @@ class RustLibWire implements BaseWire { ffi.Pointer, ffi.Pointer, ffi.Pointer, + ffi.Pointer, ) > >('frbgen_m_security_wire__crate__api__evfs__vault_write_file'); @@ -2458,6 +2461,7 @@ class RustLibWire implements BaseWire { ffi.Pointer, ffi.Pointer, ffi.Pointer, + ffi.Pointer, ) >(); diff --git a/lib/src/rust/frb_generated.web.dart b/lib/src/rust/frb_generated.web.dart index 66ab3c2..f2557a1 100644 --- a/lib/src/rust/frb_generated.web.dart +++ b/lib/src/rust/frb_generated.web.dart @@ -1433,12 +1433,14 @@ class RustLibWire implements BaseWire { String name, String file_path, String on_progress, + JSAny? metadata, ) => wasmModule.wire__crate__api__evfs__vault_write_file( port_, handle, name, file_path, on_progress, + metadata, ); void @@ -1811,6 +1813,7 @@ extension type RustLibWasmModule._(JSObject _) implements JSObject { String name, String file_path, String on_progress, + JSAny? metadata, ); external void diff --git a/rust/src/api/evfs/mod.rs b/rust/src/api/evfs/mod.rs index 9cfb15d..985c749 100644 --- a/rust/src/api/evfs/mod.rs +++ b/rust/src/api/evfs/mod.rs @@ -667,6 +667,7 @@ pub fn vault_write_file( name: String, file_path: String, on_progress: StreamSink, + metadata: Option>, ) -> Result<(), CryptoError> { use crate::core::streaming::CHUNK_SIZE; @@ -700,7 +701,7 @@ pub fn vault_write_file( } }); - let result = vault_write_stream(handle, name, file_size, data_stream, None); + let result = vault_write_stream(handle, name, file_size, data_stream, metadata); // Surface the real I/O error instead of a misleading "stream underflow" if let Some(io_err) = read_error { diff --git a/rust/src/frb_generated.rs b/rust/src/frb_generated.rs index f2706b2..3dcd740 100644 --- a/rust/src/frb_generated.rs +++ b/rust/src/frb_generated.rs @@ -1828,6 +1828,7 @@ fn wire__crate__api__evfs__vault_write_file_impl( name: impl CstDecode, file_path: impl CstDecode, on_progress: impl CstDecode>, + metadata: impl CstDecode>>, ) { FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( flutter_rust_bridge::for_generated::TaskInfo { @@ -1840,6 +1841,7 @@ fn wire__crate__api__evfs__vault_write_file_impl( let api_name = name.cst_decode(); let api_file_path = file_path.cst_decode(); let api_on_progress = on_progress.cst_decode(); + let api_metadata = metadata.cst_decode(); move |context| { transform_result_dco::<_, _, crate::core::error::CryptoError>((move || { let mut api_handle_guard = None; @@ -1863,6 +1865,7 @@ fn wire__crate__api__evfs__vault_write_file_impl( api_name, api_file_path, api_on_progress, + api_metadata, )?; Ok(output_ok) })()) @@ -4144,8 +4147,16 @@ mod io { name: *mut wire_cst_list_prim_u_8_strict, file_path: *mut wire_cst_list_prim_u_8_strict, on_progress: *mut wire_cst_list_prim_u_8_strict, + metadata: *mut wire_cst_list_record_string_string, ) { - wire__crate__api__evfs__vault_write_file_impl(port_, handle, name, file_path, on_progress) + wire__crate__api__evfs__vault_write_file_impl( + port_, + handle, + name, + file_path, + on_progress, + metadata, + ) } #[unsafe(no_mangle)] @@ -5459,8 +5470,16 @@ mod web { name: String, file_path: String, on_progress: String, + metadata: flutter_rust_bridge::for_generated::wasm_bindgen::JsValue, ) { - wire__crate__api__evfs__vault_write_file_impl(port_, handle, name, file_path, on_progress) + wire__crate__api__evfs__vault_write_file_impl( + port_, + handle, + name, + file_path, + on_progress, + metadata, + ) } #[wasm_bindgen] From 6efc692436e58b860ab2adec78d999dbad428077 Mon Sep 17 00:00:00 2001 From: Adel-Ayoub Date: Thu, 9 Apr 2026 16:05:25 +0100 Subject: [PATCH 28/32] feat(example): add metadata, rename, flush, and parallel read to vault demo UI --- example/lib/main.dart | 133 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 129 insertions(+), 4 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index d1e57a6..1bf9832 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -607,12 +607,14 @@ class _VaultTabState extends State<_VaultTab> { final _segName = TextEditingController(text: 'secret.txt'); final _segData = TextEditingController(text: 'Vault data here'); final _streamSizeKb = TextEditingController(text: '512'); + final _metadataCtrl = TextEditingController(text: 'mime=text/plain'); String _status = ''; List _segments = []; String _readResult = ''; String _capacityInfo = ''; String _healthInfo = ''; String _defragInfo = ''; + String _parallelInfo = ''; double _streamProgress = 0; bool _loading = false; bool _vaultOpen = false; @@ -692,13 +694,27 @@ class _VaultTabState extends State<_VaultTab> { } else if (_compAlgo == 'Brotli') { comp = const CompressionConfig(algorithm: CompressionAlgorithm.brotli); } + // Parse "key=value, key2=value2" into metadata map + Map? meta; + if (_metadataCtrl.text.trim().isNotEmpty) { + meta = {}; + for (final pair in _metadataCtrl.text.split(',')) { + final kv = pair.split('='); + if (kv.length == 2) { + meta[kv[0].trim()] = kv[1].trim(); + } + } + if (meta.isEmpty) meta = null; + } await VaultService.write( handle: _handle!, name: _segName.text, data: data, compression: comp, + metadata: meta, ); - _status = 'Wrote "${_segName.text}" (${data.length}B, $_compAlgo)'; + final metaLabel = meta != null ? ', ${meta.length} tags' : ''; + _status = 'Wrote "${_segName.text}" (${data.length}B, $_compAlgo$metaLabel)'; await _refreshList(); await _refreshCapacity(); } catch (e) { @@ -712,12 +728,16 @@ class _VaultTabState extends State<_VaultTab> { setState(() => _loading = true); try { final result = await VaultService.read(handle: _handle!, name: name); - // Try decoding as UTF-8, fallback to hex + String dataStr; try { - _readResult = '[$name] ${utf8.decode(result.data)}'; + dataStr = utf8.decode(result.data); } catch (_) { - _readResult = '[$name] ${_hex(result.data)}'; + dataStr = _hex(result.data); } + final metaStr = result.metadata.isNotEmpty + ? '\nMetadata: ${result.metadata.entries.map((e) => '${e.key}=${e.value}').join(', ')}' + : ''; + _readResult = '[$name] $dataStr$metaStr'; } catch (e) { _readResult = 'Error reading "$name": $e'; } @@ -825,6 +845,81 @@ class _VaultTabState extends State<_VaultTab> { setState(() => _loading = false); } + Future _renameSegment(String oldName) async { + if (!_vaultOpen || _handle == null) return; + final controller = TextEditingController(text: oldName); + final newName = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Rename Segment'), + content: TextField( + controller: controller, + autofocus: true, + decoration: const InputDecoration( + labelText: 'New name', + border: OutlineInputBorder(), + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancel')), + FilledButton( + onPressed: () => Navigator.pop(ctx, controller.text), + child: const Text('Rename'), + ), + ], + ), + ); + if (newName == null || newName.isEmpty || newName == oldName) return; + setState(() => _loading = true); + try { + await VaultService.renameSegment( + handle: _handle!, + oldName: oldName, + newName: newName, + ); + _status = 'Renamed "$oldName" → "$newName"'; + await _refreshList(); + } catch (e) { + _status = 'Rename error: $e'; + } + setState(() => _loading = false); + } + + Future _flushIndex() async { + if (!_vaultOpen || _handle == null) return; + setState(() => _loading = true); + try { + await VaultService.flush(handle: _handle!); + _status = 'Index flushed to disk'; + } catch (e) { + _status = 'Flush error: $e'; + } + setState(() => _loading = false); + } + + Future _parallelReadAll() async { + if (!_vaultOpen || _handle == null || _segments.isEmpty) return; + setState(() => _loading = true); + try { + final results = await VaultService.readParallel( + handle: _handle!, + names: _segments, + ); + final ok = results.where((r) => r.error == null).length; + final fail = results.where((r) => r.error != null).length; + final totalBytes = results.fold(0, (sum, r) => sum + r.data.length); + final details = results.map((r) { + if (r.error != null) return ' ${r.name}: ERROR ${r.error}'; + return ' ${r.name}: ${_fmtBytes(BigInt.from(r.data.length))}'; + }).join('\n'); + _parallelInfo = '$ok OK, $fail failed — ${_fmtBytes(BigInt.from(totalBytes))} total\n$details'; + _status = 'Parallel read: $ok/${results.length} segments'; + } catch (e) { + _parallelInfo = 'Error: $e'; + } + setState(() => _loading = false); + } + Future _refreshList() async { if (!_vaultOpen || _handle == null) return; _segments = await VaultService.list(handle: _handle!); @@ -1089,6 +1184,15 @@ class _VaultTabState extends State<_VaultTab> { onSelectionChanged: (s) => setState(() => _compAlgo = s.first), ), const SizedBox(height: 8), + TextField( + controller: _metadataCtrl, + decoration: const InputDecoration( + labelText: 'Metadata (key=val, key2=val2)', + border: OutlineInputBorder(), + isDense: true, + ), + ), + const SizedBox(height: 8), FilledButton.icon( onPressed: _loading ? null : _writeSegment, icon: const Icon(Icons.save, size: 18), @@ -1122,6 +1226,11 @@ class _VaultTabState extends State<_VaultTab> { tooltip: 'Read', onPressed: () => _readSegment(name), ), + IconButton( + icon: const Icon(Icons.drive_file_rename_outline, size: 20), + tooltip: 'Rename', + onPressed: () => _renameSegment(name), + ), IconButton( icon: const Icon(Icons.delete, size: 20, color: Colors.red), tooltip: 'Delete', @@ -1152,6 +1261,13 @@ class _VaultTabState extends State<_VaultTab> { child: const Text('Defrag'), ), ), + const SizedBox(width: 6), + Expanded( + child: FilledButton.tonal( + onPressed: _loading ? null : _flushIndex, + child: const Text('Flush'), + ), + ), ], ), const SizedBox(height: 8), @@ -1177,6 +1293,15 @@ class _VaultTabState extends State<_VaultTab> { ), if (_healthInfo.isNotEmpty) _ResultCard('Health', _healthInfo), if (_defragInfo.isNotEmpty) _ResultCard('Defrag', _defragInfo), + const SizedBox(height: 8), + FilledButton.tonalIcon( + onPressed: _loading || _segments.isEmpty + ? null + : _parallelReadAll, + icon: const Icon(Icons.read_more, size: 18), + label: const Text('Parallel Read All'), + ), + if (_parallelInfo.isNotEmpty) _ResultCard('Parallel Read', _parallelInfo), const Divider(height: 24), From d83720656f5ceae385971ec3423b823b89261ad2 Mon Sep 17 00:00:00 2001 From: Adel-Ayoub Date: Thu, 9 Apr 2026 16:35:00 +0100 Subject: [PATCH 29/32] feat(example): add metadata, rename, flush, and parallel read to vault demo UI --- .../evfs_enhancements_test.dart | 546 ++++++++++++++++++ example/integration_test/evfs_test.dart | 46 +- 2 files changed, 569 insertions(+), 23 deletions(-) create mode 100644 example/integration_test/evfs_enhancements_test.dart diff --git a/example/integration_test/evfs_enhancements_test.dart b/example/integration_test/evfs_enhancements_test.dart new file mode 100644 index 0000000..9b43c0b --- /dev/null +++ b/example/integration_test/evfs_enhancements_test.dart @@ -0,0 +1,546 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:m_security/src/rust/api/encryption.dart'; +import 'package:m_security/src/rust/frb_generated.dart'; +import 'package:m_security/src/evfs/vault_service.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(() async => await RustLib.init()); + + group('Segment Metadata', () { + late Directory tempDir; + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('meta_test'); + }); + tearDown(() async { + await tempDir.delete(recursive: true); + }); + + test('write with metadata, read back matches', () async { + final path = '${tempDir.path}/meta.vault'; + final key = await generateAes256GcmKey(); + + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 2 * 1024 * 1024, + ); + + final data = Uint8List.fromList([1, 2, 3, 4, 5]); + final metadata = {'mime': 'application/octet-stream', 'author': 'test'}; + + await VaultService.write( + handle: handle, + name: 'doc.bin', + data: data, + metadata: metadata, + ); + + final result = await VaultService.read(handle: handle, name: 'doc.bin'); + expect(result.data, data); + expect(result.metadata, metadata); + + await VaultService.close(handle: handle); + }); + + test('write without metadata returns empty map', () async { + final path = '${tempDir.path}/nometa.vault'; + final key = await generateAes256GcmKey(); + + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 2 * 1024 * 1024, + ); + + final data = Uint8List.fromList([10, 20, 30]); + await VaultService.write(handle: handle, name: 'plain.bin', data: data); + + final result = await VaultService.read( + handle: handle, + name: 'plain.bin', + ); + expect(result.data, data); + expect(result.metadata, isEmpty); + + await VaultService.close(handle: handle); + }); + + test('overwrite with different metadata replaces old', () async { + final path = '${tempDir.path}/overwrite_meta.vault'; + final key = await generateAes256GcmKey(); + + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 2 * 1024 * 1024, + ); + + final data = Uint8List.fromList([1, 2, 3]); + await VaultService.write( + handle: handle, + name: 'file.bin', + data: data, + metadata: {'version': '1'}, + ); + + // Overwrite with new metadata + final newData = Uint8List.fromList([4, 5, 6]); + await VaultService.write( + handle: handle, + name: 'file.bin', + data: newData, + metadata: {'version': '2', 'updated': 'true'}, + ); + + final result = await VaultService.read(handle: handle, name: 'file.bin'); + expect(result.data, newData); + expect(result.metadata, {'version': '2', 'updated': 'true'}); + + await VaultService.close(handle: handle); + }); + + test('metadata survives close and reopen', () async { + final path = '${tempDir.path}/persist_meta.vault'; + final key = await generateAes256GcmKey(); + + var handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 2 * 1024 * 1024, + ); + + final data = Uint8List.fromList([7, 8, 9]); + await VaultService.write( + handle: handle, + name: 'persist.bin', + data: data, + metadata: {'created': '2026-04-09'}, + ); + await VaultService.close(handle: handle); + + // Reopen and verify + handle = await VaultService.open(path: path, key: key); + final result = await VaultService.read( + handle: handle, + name: 'persist.bin', + ); + expect(result.data, data); + expect(result.metadata['created'], '2026-04-09'); + + await VaultService.close(handle: handle); + }); + }); + + group('Segment Rename', () { + late Directory tempDir; + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('rename_test'); + }); + tearDown(() async { + await tempDir.delete(recursive: true); + }); + + test('rename then read under new name', () async { + final path = '${tempDir.path}/rename.vault'; + final key = await generateAes256GcmKey(); + + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 2 * 1024 * 1024, + ); + + final data = Uint8List.fromList([1, 2, 3, 4, 5]); + await VaultService.write(handle: handle, name: 'old.bin', data: data); + + await VaultService.renameSegment( + handle: handle, + oldName: 'old.bin', + newName: 'new.bin', + ); + + // Readable under new name + final result = await VaultService.read(handle: handle, name: 'new.bin'); + expect(result.data, data); + + // Old name gone + expect( + () async => + await VaultService.read(handle: handle, name: 'old.bin'), + throwsA(isA()), + ); + + // List reflects rename + final names = await VaultService.list(handle: handle); + expect(names, contains('new.bin')); + expect(names, isNot(contains('old.bin'))); + + await VaultService.close(handle: handle); + }); + + test('rename to existing name throws DuplicateSegment', () async { + final path = '${tempDir.path}/dup.vault'; + final key = await generateAes256GcmKey(); + + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 2 * 1024 * 1024, + ); + + await VaultService.write( + handle: handle, + name: 'a.bin', + data: Uint8List(10), + ); + await VaultService.write( + handle: handle, + name: 'b.bin', + data: Uint8List(10), + ); + + await expectLater( + () => VaultService.renameSegment( + handle: handle, + oldName: 'a.bin', + newName: 'b.bin', + ), + throwsA( + predicate((e) => e.toString().contains('duplicateSegment')), + ), + ); + + await VaultService.close(handle: handle); + }); + + test('rename nonexistent segment throws SegmentNotFound', () async { + final path = '${tempDir.path}/notfound.vault'; + final key = await generateAes256GcmKey(); + + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 1024 * 1024, + ); + + await expectLater( + () => VaultService.renameSegment( + handle: handle, + oldName: 'ghost.bin', + newName: 'new.bin', + ), + throwsA( + predicate((e) => e.toString().contains('segmentNotFound')), + ), + ); + + await VaultService.close(handle: handle); + }); + + test('rename preserves metadata', () async { + final path = '${tempDir.path}/rename_meta.vault'; + final key = await generateAes256GcmKey(); + + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 2 * 1024 * 1024, + ); + + final data = Uint8List.fromList([42]); + await VaultService.write( + handle: handle, + name: 'before.bin', + data: data, + metadata: {'tag': 'important'}, + ); + + await VaultService.renameSegment( + handle: handle, + oldName: 'before.bin', + newName: 'after.bin', + ); + + final result = await VaultService.read(handle: handle, name: 'after.bin'); + expect(result.data, data); + expect(result.metadata['tag'], 'important'); + + await VaultService.close(handle: handle); + }); + }); + + group('Flush', () { + late Directory tempDir; + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('flush_test'); + }); + tearDown(() async { + await tempDir.delete(recursive: true); + }); + + test('explicit flush after write succeeds', () async { + final path = '${tempDir.path}/flush.vault'; + final key = await generateAes256GcmKey(); + + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 2 * 1024 * 1024, + ); + + await VaultService.write( + handle: handle, + name: 'data.bin', + data: Uint8List.fromList([1, 2, 3]), + ); + + // Flush should not throw + await VaultService.flush(handle: handle); + + // Data still readable + final result = await VaultService.read(handle: handle, name: 'data.bin'); + expect(result.data, Uint8List.fromList([1, 2, 3])); + + await VaultService.close(handle: handle); + }); + + test('flush on clean handle is no-op', () async { + final path = '${tempDir.path}/noop_flush.vault'; + final key = await generateAes256GcmKey(); + + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 1024 * 1024, + ); + + // No writes — flush should be a safe no-op + await VaultService.flush(handle: handle); + + await VaultService.close(handle: handle); + }); + }); + + group('Parallel Read', () { + late Directory tempDir; + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('parallel_test'); + }); + tearDown(() async { + await tempDir.delete(recursive: true); + }); + + test('parallel read 5 segments matches sequential', () async { + final path = '${tempDir.path}/parallel.vault'; + final key = await generateAes256GcmKey(); + + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 5 * 1024 * 1024, + ); + + // Write 5 segments + final segments = {}; + for (var i = 0; i < 5; i++) { + final name = 'seg_$i.bin'; + final data = Uint8List.fromList( + List.generate(1000 + i * 100, (j) => (j * (i + 1)) % 256), + ); + segments[name] = data; + await VaultService.write(handle: handle, name: name, data: data); + } + + // Parallel read all + final results = await VaultService.readParallel( + handle: handle, + names: segments.keys.toList(), + ); + + expect(results.length, 5); + for (final r in results) { + expect(r.error, isNull, reason: 'segment ${r.name} had error: ${r.error}'); + expect(r.data, segments[r.name], reason: 'data mismatch for ${r.name}'); + } + + await VaultService.close(handle: handle); + }); + + test('parallel read with missing name returns per-segment error', () async { + final path = '${tempDir.path}/partial.vault'; + final key = await generateAes256GcmKey(); + + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 2 * 1024 * 1024, + ); + + await VaultService.write( + handle: handle, + name: 'exists.bin', + data: Uint8List.fromList([1, 2, 3]), + ); + + final results = await VaultService.readParallel( + handle: handle, + names: ['exists.bin', 'missing.bin'], + ); + + expect(results.length, 2); + + final existing = results.firstWhere((r) => r.name == 'exists.bin'); + expect(existing.error, isNull); + expect(existing.data, Uint8List.fromList([1, 2, 3])); + + final missing = results.firstWhere((r) => r.name == 'missing.bin'); + expect(missing.error, isNotNull); + expect(missing.data, isEmpty); + + await VaultService.close(handle: handle); + }); + + test('parallel read empty list returns empty result', () async { + final path = '${tempDir.path}/empty.vault'; + final key = await generateAes256GcmKey(); + + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 1024 * 1024, + ); + + final results = await VaultService.readParallel( + handle: handle, + names: [], + ); + expect(results, isEmpty); + + await VaultService.close(handle: handle); + }); + }); + + group('Combined Workflows', () { + late Directory tempDir; + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('combined_test'); + }); + tearDown(() async { + await tempDir.delete(recursive: true); + }); + + test('write with metadata, rename, parallel read — metadata preserved', + () async { + final path = '${tempDir.path}/combined.vault'; + final key = await generateAes256GcmKey(); + + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 5 * 1024 * 1024, + ); + + final data = Uint8List.fromList(List.generate(500, (i) => i % 256)); + + // Write with metadata + await VaultService.write( + handle: handle, + name: 'original.bin', + data: data, + metadata: {'type': 'document', 'priority': 'high'}, + ); + + // Rename + await VaultService.renameSegment( + handle: handle, + oldName: 'original.bin', + newName: 'renamed.bin', + ); + + // Write another segment (no metadata) + await VaultService.write( + handle: handle, + name: 'extra.bin', + data: Uint8List.fromList([99]), + ); + + // Parallel read both + final results = await VaultService.readParallel( + handle: handle, + names: ['renamed.bin', 'extra.bin'], + ); + + expect(results.length, 2); + final renamed = results.firstWhere((r) => r.name == 'renamed.bin'); + expect(renamed.data, data); + expect(renamed.error, isNull); + + // Verify metadata via single read (parallel read doesn't return metadata) + final detailed = await VaultService.read( + handle: handle, + name: 'renamed.bin', + ); + expect(detailed.metadata['type'], 'document'); + expect(detailed.metadata['priority'], 'high'); + + await VaultService.close(handle: handle); + }); + + test('write 10 segments, parallel read all, verify data', () async { + final path = '${tempDir.path}/bulk.vault'; + final key = await generateAes256GcmKey(); + + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 10 * 1024 * 1024, + ); + + final segments = {}; + for (var i = 0; i < 10; i++) { + final name = 'bulk_$i.dat'; + final data = Uint8List.fromList( + List.generate(2048, (j) => (j + i * 17) % 256), + ); + segments[name] = data; + await VaultService.write(handle: handle, name: name, data: data); + } + + // Flush before parallel read + await VaultService.flush(handle: handle); + + final results = await VaultService.readParallel( + handle: handle, + names: segments.keys.toList(), + ); + + expect(results.length, 10); + for (final r in results) { + expect(r.error, isNull, reason: '${r.name} failed: ${r.error}'); + expect(r.data, segments[r.name], reason: 'data mismatch: ${r.name}'); + } + + await VaultService.close(handle: handle); + }); + }); +} diff --git a/example/integration_test/evfs_test.dart b/example/integration_test/evfs_test.dart index eb6d778..cc11787 100644 --- a/example/integration_test/evfs_test.dart +++ b/example/integration_test/evfs_test.dart @@ -49,7 +49,7 @@ void main() { handle: reopened, name: 'test.bin', ); - expect(result, data); + expect(result.data, data); //close vault await VaultService.close(handle: reopened); @@ -92,9 +92,9 @@ void main() { name: 'file3.dat', ); - expect(result1, data1); - expect(result2, data2); - expect(result3, data3); + expect(result1.data, data1); + expect(result2.data, data2); + expect(result3.data, data3); await VaultService.close(handle: handle); }); @@ -125,8 +125,8 @@ void main() { // Read back final result = await VaultService.read(handle: handle, name: 'data.bin'); - expect(result, updated); - expect(result, isNot(original)); + expect(result.data, updated); + expect(result.data, isNot(original)); await VaultService.close(handle: handle); }); @@ -166,7 +166,7 @@ void main() { //verify it exists final before = await VaultService.read(handle: handle, name: 'temp.dat'); - expect(before, data); + expect(before.data, data); //delete it await VaultService.delete(handle: handle, name: 'temp.dat'); @@ -340,7 +340,7 @@ void main() { ), ); final result = await VaultService.read(handle: handle, name: 'notes.txt'); - expect(result, data); + expect(result.data, data); await VaultService.close(handle: handle); }); @@ -364,7 +364,7 @@ void main() { ), ); final result = await VaultService.read(handle: handle, name: 'notes.txt'); - expect(result, data); + expect(result.data, data); await VaultService.close(handle: handle); }); @@ -396,7 +396,7 @@ void main() { // Read back final result = await VaultService.read(handle: handle, name: 'photo.jpg'); - expect(result, jpegData); + expect(result.data, jpegData); // Verify: if compression was applied, capacity used would be less // Since it was skipped, capacity used ≈ original size @@ -469,17 +469,17 @@ void main() { // Verify all three match original data expect( - resultA, + resultA.data, dataA, reason: 'Zstd segment should decompress correctly', ); expect( - resultB, + resultB.data, dataB, reason: 'Brotli segment should decompress correctly', ); expect( - resultC, + resultC.data, dataC, reason: 'Uncompressed segment should read correctly', ); @@ -567,7 +567,7 @@ void main() { handle: reopened, name: 'initial.bin', ); - expect(result, data1); + expect(result.data, data1); // Write more data after recovery final data2 = Uint8List.fromList([6, 7, 8, 9]); @@ -591,8 +591,8 @@ void main() { name: 'after_recovery.bin', ); - expect(result1, data1); - expect(result2, data2); + expect(result1.data, data1); + expect(result2.data, data2); await VaultService.close(handle: reopened2); }); @@ -640,8 +640,8 @@ void main() { expect(afterHealth.fragmentationRatio, 0.0); // Data still readable - expect(await VaultService.read(handle: handle, name: 'a.txt'), dataA); - expect(await VaultService.read(handle: handle, name: 'c.txt'), dataC); + expect((await VaultService.read(handle: handle, name: 'a.txt')).data, dataA); + expect((await VaultService.read(handle: handle, name: 'c.txt')).data, dataC); await VaultService.close(handle: handle); }); @@ -688,7 +688,7 @@ void main() { await VaultService.write(handle: handle, name: 'big.bin', data: bigData); final result = await VaultService.read(handle: handle, name: 'big.bin'); - expect(result, bigData); + expect(result.data, bigData); await VaultService.close(handle: handle); }); @@ -718,7 +718,7 @@ void main() { expect(health.totalBytes, BigInt.from(512 * 1024)); // Data still readable - expect(await VaultService.read(handle: handle, name: 'small.bin'), data); + expect((await VaultService.read(handle: handle, name: 'small.bin')).data, data); await VaultService.close(handle: handle); }); @@ -876,7 +876,7 @@ void main() { name: 'important.bin', ); expect( - result, + result.data, originalData, reason: 'Shadow index should have preserved the segment', ); @@ -889,7 +889,7 @@ void main() { handle: reopened2, name: 'important.bin', ); - expect(result2, originalData); + expect(result2.data, originalData); await VaultService.close(handle: reopened2); }); @@ -975,7 +975,7 @@ void main() { handle: handle, name: 'interop.bin', ); - expect(result, sourceData); + expect(result.data, sourceData); await VaultService.close(handle: handle); }); From ce4656adcb96240209e9e3ada13a40d00f5e29ba Mon Sep 17 00:00:00 2001 From: Adel-Ayoub Date: Thu, 9 Apr 2026 16:42:30 +0100 Subject: [PATCH 30/32] docs: update CHANGELOG and README for v0.3.5 release --- CHANGELOG.md | 33 +++++++++++++++++++++++++++++++++ README.md | 32 ++++++++++++++++++++++++++++---- pubspec.yaml | 2 +- 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8049cf..2da02f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,39 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] — v0.3.5 + +### Added + +- Per-segment metadata: `vault_write()` and `vault_write_stream()` accept optional `HashMap` metadata, encrypted within the index block. `vault_read()` returns `SegmentReadResult { data, metadata }`. +- Segment rename: `vault_rename_segment(old_name, new_name)` updates the index without re-encryption. WAL-protected for crash safety. `DuplicateSegment` error variant added. +- Index caching: in-memory dirty flag tracks index mutations. Read-only operations skip redundant index encryption/flush. New `vault_flush()` API for explicit durability control. +- Parallel reads: `vault_read_parallel(handle, names)` decrypts multiple segments concurrently via `rayon` + mmap zero-copy. Returns per-segment `SegmentResult { name, data, error }`. +- Dart `VaultService` wrappers: `renameSegment()`, `flush()`, `readParallel()`. Updated `write()`/`writeStream()` to accept optional `metadata` parameter. Updated `read()` to return `SegmentReadResult`. +- 15 Dart integration tests covering metadata (4), rename (4), flush (2), parallel read (3), and combined workflows (2). +- Rust test suite refactored from monolithic `tests.rs` into 13 focused modules. 407 total Rust tests. +- Example app: metadata input field, rename dialog, flush button, parallel read all button. + +### Changed + +- `VAULT_VERSION` bumped 1 → 2 for backward-compatible metadata deserialization (v1 vaults return empty metadata). +- `ARCHIVE_VERSION` bumped 1 → 2 for metadata preservation in export/import (v1 archives import with empty metadata). +- `vault_write_file` now passes metadata through to `vault_write_stream` (was hardcoded to `None`). +- `vault_close()` flushes dirty index before releasing lock, with best-effort WAL checkpoint on error. + +### Security + +- Metadata encrypted within the index block (AEAD with index key), not in the segment cipher — no plaintext leakage. +- OOM guards: `MAX_METADATA_PAIRS` (1024) and `MAX_METADATA_BYTES` (64 KB) enforced on both read and write paths. +- Parallel reads use immutable `&VaultHandle` — no shared mutable state between threads. Compile-time `Sync` assertion. +- Checked offset arithmetic in parallel mmap slicing (`checked_add` instead of bare `+`). +- Plaintext zeroized on all checksum failure paths in parallel reads. + +### Fixed + +- Async error assertions in Dart rename tests changed from `expect()` to `await expectLater()`. +- Example app `evfs_test.dart` updated for `SegmentReadResult` return type. + ## [v0.3.4](https://github.com/MicroClub-USTHB/M-Security/releases/tag/v0.3.4) - 2026-04-05 ### Added diff --git a/README.md b/README.md index 71df783..c39ac5d 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Built and maintained by the **Dev Department** of [MicroClub](https://github.com | **Password Hashing** | Argon2id | PHC winner, Mobile and Desktop presets | | **Key Derivation** | HKDF-SHA256 | RFC 5869, extract-then-expand with domain separation | | **Encrypted VFS (EVFS)** | `.vault` container | Named segments, WAL recovery, shadow index, secure deletion | +| **Segment Enhancements** | Metadata, rename, parallel | Per-segment key-value tags, rename without re-encryption, concurrent reads | | **Key Management** | Rotation, export/import | Atomic re-encryption, `.mvex` portable archives | | **Zero-Copy I/O** | mmap + DCO codec | Memory-mapped vault reads, zero-copy Rust-to-Dart transfers | @@ -49,7 +50,7 @@ Add to your `pubspec.yaml`: ```yaml dependencies: - m_security: ^0.3.4 + m_security: ^0.3.5 ``` Then run: @@ -208,16 +209,19 @@ final handle = await VaultService.create( capacityBytes: 10 * 1024 * 1024, ); -// Write a segment (with optional compression) +// Write a segment (with optional compression and metadata) await VaultService.write( handle: handle, name: 'secret.txt', data: utf8.encode('confidential'), compression: CompressionConfig(algorithm: CompressionAlgorithm.zstd), + metadata: {'mime': 'text/plain', 'author': 'alice'}, ); -// Read it back (decompression is automatic) -final data = await VaultService.read(handle: handle, name: 'secret.txt'); +// Read it back (decompression is automatic, metadata included) +final result = await VaultService.read(handle: handle, name: 'secret.txt'); +print(result.data); // decrypted bytes +print(result.metadata); // {'mime': 'text/plain', 'author': 'alice'} // List segments, delete, close final segments = await VaultService.list(handle: handle); @@ -250,6 +254,26 @@ final imported = await VaultService.importVault( ); ``` +#### Segment Enhancements + +```dart +// Rename a segment (index-only, no re-encryption) +await VaultService.renameSegment(handle: handle, oldName: 'draft.txt', newName: 'final.txt'); + +// Explicit flush — persist in-memory index to disk +await VaultService.flush(handle: handle); + +// Parallel read — decrypt multiple segments concurrently +final results = await VaultService.readParallel( + handle: handle, + names: ['file1.bin', 'file2.bin', 'file3.bin'], +); +for (final r in results) { + if (r.error != null) print('${r.name}: failed — ${r.error}'); + else print('${r.name}: ${r.data.length} bytes'); +} +``` + #### Vault Maintenance ```dart diff --git a/pubspec.yaml b/pubspec.yaml index cd4ba7d..ea2143c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: m_security description: >- A native Rust cryptographic SDK for Flutter via FFI. AES-256-GCM, ChaCha20-Poly1305, BLAKE3, SHA-3, Argon2id, HKDF, streaming encryption, and encrypted virtual file system. -version: 0.3.4 +version: 0.3.5 homepage: https://github.com/MicroClub-USTHB/M-Security repository: https://github.com/MicroClub-USTHB/M-Security issue_tracker: https://github.com/MicroClub-USTHB/M-Security/issues From 04f80b168d8185c248ecf422a0c9425787791f79 Mon Sep 17 00:00:00 2001 From: Adel-Ayoub Date: Thu, 9 Apr 2026 16:59:14 +0100 Subject: [PATCH 31/32] fix(evfs): allow clippy too_many_arguments on SegmentEntry::new --- rust/src/core/evfs/format.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/rust/src/core/evfs/format.rs b/rust/src/core/evfs/format.rs index 0fa899c..c0ce8a4 100644 --- a/rust/src/core/evfs/format.rs +++ b/rust/src/core/evfs/format.rs @@ -252,6 +252,7 @@ pub struct SegmentEntry { } impl SegmentEntry { + #[allow(clippy::too_many_arguments)] pub fn new( name: &str, offset: u64, From 0d6951b057a70c27e6f1084333bc6b99cbae1a80 Mon Sep 17 00:00:00 2001 From: Adel HB Date: Fri, 10 Apr 2026 01:35:45 +0100 Subject: [PATCH 32/32] chore(release): finalized v0.3.5 --- CHANGELOG.md | 3 ++- example/lib/main.dart | 30 ++++++++++++++++++------------ example/pubspec.lock | 2 +- ios/m_security.podspec | 2 +- macos/m_security.podspec | 2 +- rust/Cargo.lock | 2 +- rust/Cargo.toml | 2 +- 7 files changed, 25 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50e05ca..8a0dbee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] — v0.3.5 +## [v0.3.5](https://github.com/MicroClub-USTHB/M-Security/releases/tag/v0.3.5) - 2026-04-10 ### Added @@ -37,6 +37,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Async error assertions in Dart rename tests changed from `expect()` to `await expectLater()`. - Example app `evfs_test.dart` updated for `SegmentReadResult` return type. + ## [v0.3.4](https://github.com/MicroClub-USTHB/M-Security/releases/tag/v0.3.4) - 2026-04-05 ### Added diff --git a/example/lib/main.dart b/example/lib/main.dart index 1bf9832..fd6320a 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -18,7 +18,7 @@ class ExampleApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( - title: 'M-Security v0.3.4', + title: 'M-Security v0.3.5', theme: ThemeData(colorSchemeSeed: Colors.blue, useMaterial3: true), home: const DemoHome(), ); @@ -714,7 +714,8 @@ class _VaultTabState extends State<_VaultTab> { metadata: meta, ); final metaLabel = meta != null ? ', ${meta.length} tags' : ''; - _status = 'Wrote "${_segName.text}" (${data.length}B, $_compAlgo$metaLabel)'; + _status = + 'Wrote "${_segName.text}" (${data.length}B, $_compAlgo$metaLabel)'; await _refreshList(); await _refreshCapacity(); } catch (e) { @@ -861,7 +862,10 @@ class _VaultTabState extends State<_VaultTab> { ), ), actions: [ - TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancel')), + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Cancel'), + ), FilledButton( onPressed: () => Navigator.pop(ctx, controller.text), child: const Text('Rename'), @@ -908,11 +912,14 @@ class _VaultTabState extends State<_VaultTab> { final ok = results.where((r) => r.error == null).length; final fail = results.where((r) => r.error != null).length; final totalBytes = results.fold(0, (sum, r) => sum + r.data.length); - final details = results.map((r) { - if (r.error != null) return ' ${r.name}: ERROR ${r.error}'; - return ' ${r.name}: ${_fmtBytes(BigInt.from(r.data.length))}'; - }).join('\n'); - _parallelInfo = '$ok OK, $fail failed — ${_fmtBytes(BigInt.from(totalBytes))} total\n$details'; + final details = results + .map((r) { + if (r.error != null) return ' ${r.name}: ERROR ${r.error}'; + return ' ${r.name}: ${_fmtBytes(BigInt.from(r.data.length))}'; + }) + .join('\n'); + _parallelInfo = + '$ok OK, $fail failed — ${_fmtBytes(BigInt.from(totalBytes))} total\n$details'; _status = 'Parallel read: $ok/${results.length} segments'; } catch (e) { _parallelInfo = 'Error: $e'; @@ -1295,13 +1302,12 @@ class _VaultTabState extends State<_VaultTab> { if (_defragInfo.isNotEmpty) _ResultCard('Defrag', _defragInfo), const SizedBox(height: 8), FilledButton.tonalIcon( - onPressed: _loading || _segments.isEmpty - ? null - : _parallelReadAll, + onPressed: _loading || _segments.isEmpty ? null : _parallelReadAll, icon: const Icon(Icons.read_more, size: 18), label: const Text('Parallel Read All'), ), - if (_parallelInfo.isNotEmpty) _ResultCard('Parallel Read', _parallelInfo), + if (_parallelInfo.isNotEmpty) + _ResultCard('Parallel Read', _parallelInfo), const Divider(height: 24), diff --git a/example/pubspec.lock b/example/pubspec.lock index 563dfac..0cfd965 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -176,7 +176,7 @@ packages: path: ".." relative: true source: path - version: "0.3.4" + version: "0.3.5" matcher: dependency: transitive description: diff --git a/ios/m_security.podspec b/ios/m_security.podspec index 3810b1f..fc81b5b 100644 --- a/ios/m_security.podspec +++ b/ios/m_security.podspec @@ -4,7 +4,7 @@ # Pod::Spec.new do |s| s.name = 'm_security' - s.version = '0.3.4' + s.version = '0.3.5' s.summary = 'A high-performance cryptographic SDK for Flutter powered by native Rust via FFI.' s.description = <<-DESC A high-performance cryptographic SDK for Flutter powered by native Rust via FFI. diff --git a/macos/m_security.podspec b/macos/m_security.podspec index 3321b45..4205372 100644 --- a/macos/m_security.podspec +++ b/macos/m_security.podspec @@ -4,7 +4,7 @@ # Pod::Spec.new do |s| s.name = 'm_security' - s.version = '0.3.4' + s.version = '0.3.5' s.summary = 'A high-performance cryptographic SDK for Flutter powered by native Rust via FFI.' s.description = <<-DESC A high-performance cryptographic SDK for Flutter powered by native Rust via FFI. diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 5ec27cf..abea3f7 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -762,7 +762,7 @@ checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" [[package]] name = "m_security" -version = "0.3.4" +version = "0.3.5" dependencies = [ "aes-gcm", "argon2", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 677d331..56f91b4 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "m_security" -version = "0.3.4" +version = "0.3.5" edition = "2021" [lib]