Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
310 changes: 310 additions & 0 deletions rust/src/api/evfs/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,142 @@ pub fn vault_delete(
Ok(())
}

/// Resize vault data region capacity.
///
/// - Grow: extend file, CSPRNG-fill new space, relocate shadow index + WAL
/// - Shrink: validate segments fit, relocate shadow + WAL, truncate file
/// - Returns VaultFull if shrink would lose data
#[cfg(feature = "compression")]
pub fn vault_resize(handle: &mut VaultHandle, new_capacity: u64) -> Result<(), CryptoError> {
let old_capacity = handle.index.capacity;
if old_capacity == new_capacity {
Ok(())
} else if old_capacity > new_capacity {
vault_resize_shrink_impl(handle, old_capacity, new_capacity)
} else {
vault_resize_grow_impl(handle, old_capacity, new_capacity)
}
}

#[cfg(feature = "compression")]
fn vault_resize_grow_impl(
handle: &mut VaultHandle,
old_capacity: u64,
new_capacity: u64,
) -> Result<(), CryptoError> {
let old_encrypted_index = read_encrypted_index(&mut handle.file, PRIMARY_INDEX_OFFSET)?;
handle.wal.begin(WalOp::UpdateIndex, &old_encrypted_index)?;

let old_shadow_off = format::shadow_index_offset(old_capacity)?;
let shadow_bytes = read_encrypted_index(&mut handle.file, old_shadow_off)?;

// Extend the file to the new total size
let new_total = format::total_vault_size(new_capacity)?;
handle.file.set_len(new_total)?;
// And fill the old_cap..new_cap region with CSPRNG data.
let fill_offset = DATA_REGION_OFFSET + old_capacity;
let fill_size = new_capacity - old_capacity;
segment::secure_erase_region(&mut handle.file, fill_offset, fill_size)?;

// 5. Write shadow index at new position.
let new_shadow_off = format::shadow_index_offset(new_capacity)?;
handle.file.seek(SeekFrom::Start(new_shadow_off))?;
handle.file.write_all(&shadow_bytes)?;
handle.file.sync_all()?;

// Update handle data.
handle.wal = WriteAheadLog::open(&handle.path)?;
handle.index.capacity = new_capacity;
flush_index(
&mut handle.file,
&handle.index,
&handle.keys,
handle.algorithm,
new_capacity,
)?;

handle.wal.commit()?;
handle.wal.checkpoint()?;

Ok(())
}

#[cfg(feature = "compression")]
fn vault_resize_shrink_impl(
handle: &mut VaultHandle,
old_capacity: u64,
new_capacity: u64,
) -> Result<(), CryptoError> {
// Validate: every live segment must fit within the new capacity.
let max_used = handle
.index
.entries
.iter()
.map(|e| e.offset.saturating_add(e.size))
.max()
.unwrap_or(0);
if max_used > new_capacity {
return Err(CryptoError::VaultFull {
needed: max_used,
available: new_capacity,
});
}

// WAL begin — journal the current encrypted index for crash recovery.
let old_encrypted_index = read_encrypted_index(&mut handle.file, PRIMARY_INDEX_OFFSET)?;
handle.wal.begin(WalOp::UpdateIndex, &old_encrypted_index)?;

// Write shadow index at new (closer) position.
let new_shadow_off = format::shadow_index_offset(new_capacity)?;
let old_shadow_off = format::shadow_index_offset(old_capacity)?;
let shadow_bytes = read_encrypted_index(&mut handle.file, old_shadow_off)?;
handle.file.seek(SeekFrom::Start(new_shadow_off))?;
handle.file.write_all(&shadow_bytes)?;
handle.file.sync_all()?;

// Update handle
handle.wal = WriteAheadLog::open(&handle.path)?;
handle.index.capacity = new_capacity;
// Clamp next_free_offset to new_capacity (it cannot point beyond).
if handle.index.next_free_offset > new_capacity {
handle.index.next_free_offset = new_capacity;
}

// Remove free regions that fall entirely beyond the new boundary,
// and truncate any that partially overlap.
handle.index.free_regions.retain_mut(|r| {
if r.offset >= new_capacity {
// Entirely beyond — drop.
return false;
}
let end = r.offset + r.size;
if end > new_capacity {
// Partially beyond — truncate.
r.size = new_capacity - r.offset;
}
true
});

// Flush index (primary + shadow at new position).
flush_index(
&mut handle.file,
&handle.index,
&handle.keys,
handle.algorithm,
new_capacity,
)?;

// Truncate file to new total size.
let new_total = format::total_vault_size(new_capacity)?;
handle.file.set_len(new_total)?;
handle.file.sync_all()?;

handle.wal.commit()?;
handle.wal.checkpoint()?;

Ok(())
}

/// List all segment names in the vault.
pub fn vault_list(handle: &VaultHandle) -> Vec<String> {
handle.index.entries.iter().map(|e| e.name.clone()).collect()
Expand Down Expand Up @@ -1198,4 +1334,178 @@ mod tests {

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

// Read both back
let filler_read = vault_read(&mut handle, "filler.bin".into()).expect("read filler");
assert_eq!(filler_read, filler);
let big_read = vault_read(&mut handle, "big.bin".into()).expect("read big");
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).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");
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");
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).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");
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)
.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).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)
.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");
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");
}
}
Loading