diff --git a/example/integration_test/evfs_test.dart b/example/integration_test/evfs_test.dart new file mode 100644 index 0000000..2541596 --- /dev/null +++ b/example/integration_test/evfs_test.dart @@ -0,0 +1,611 @@ +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/compression.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('EVFS', () { + // Helper: create a temp vault path + late Directory tempDir; + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('evfs_test'); + }); + tearDown(() async { + await tempDir.delete(recursive: true); + }); + + test('create, write, close, open, read roundtrip', () async { + final path = '${tempDir.path}/test.vault'; + final key = await generateAes256GcmKey(); + + //Create vault + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 1024 * 1024, + ); + + //segment + final data = Uint8List.fromList([1, 2, 3, 4, 5]); + await VaultService.write(handle: handle, name: 'test.bin', data: data); + + //close vault + await VaultService.close(handle: handle); + + //reopen vault + final reopened = await VaultService.open(path: path, key: key); + + //read segment + final result = await VaultService.read( + handle: reopened, + name: 'test.bin', + ); + expect(result, data); + + //close vault + await VaultService.close(handle: reopened); + }); + + test('write and read multiple segments', () async { + final path = '${tempDir.path}/multi.vault'; + final key = await generateAes256GcmKey(); + + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 5 * 1024 * 1024, //5MB + ); + + final data1 = Uint8List.fromList(List.generate(1000, (i) => i % 256)); + final data2 = Uint8List.fromList( + List.generate(2000, (i) => (i * 2) % 256), + ); + final data3 = Uint8List.fromList( + List.generate(500, (i) => (i * 3) % 256), + ); + + await VaultService.write(handle: handle, name: 'file1.dat', data: data1); + await VaultService.write(handle: handle, name: 'file2.dat', data: data2); + await VaultService.write(handle: handle, name: 'file3.dat', data: data3); + + // Read all back + final result1 = await VaultService.read( + handle: handle, + name: 'file1.dat', + ); + final result2 = await VaultService.read( + handle: handle, + name: 'file2.dat', + ); + final result3 = await VaultService.read( + handle: handle, + name: 'file3.dat', + ); + + expect(result1, data1); + expect(result2, data2); + expect(result3, data3); + + await VaultService.close(handle: handle); + }); + + test('overwrite segment returns new data', () async { + final path = '${tempDir.path}/overwrite.vault'; + final key = await generateAes256GcmKey(); + + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 2 * 1024 * 1024, + ); + + // Write original + final original = Uint8List.fromList([1, 2, 3]); + await VaultService.write( + handle: handle, + name: 'data.bin', + data: original, + ); + + // Overwrite + final updated = Uint8List.fromList([4, 5, 6, 7, 8]); + await VaultService.write(handle: handle, name: 'data.bin', data: updated); + + // Read back + final result = await VaultService.read(handle: handle, name: 'data.bin'); + + expect(result, updated); + expect(result, isNot(original)); + + await VaultService.close(handle: handle); + }); + test('read 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.read(handle: handle, name: 'missing.txt'), + throwsA(isA()), + ); + await VaultService.close(handle: handle); + }); + + test('delete segment removes it', () async { + final path = '${tempDir.path}/delete.vault'; + final key = await generateAes256GcmKey(); + + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 2 * 1024 * 1024, + ); + + //write a segment + final data = Uint8List.fromList([1, 2, 3, 4, 5]); + await VaultService.write(handle: handle, name: 'temp.dat', data: data); + + //verify it exists + final before = await VaultService.read(handle: handle, name: 'temp.dat'); + expect(before, data); + + //delete it + await VaultService.delete(handle: handle, name: 'temp.dat'); + + //verify it's gone + expect( + () async => await VaultService.read(handle: handle, name: 'temp.dat'), + throwsA(isA()), + ); + await VaultService.close(handle: handle); + }); + + test('vault full returns VaultFull error', () async { + final path = '${tempDir.path}/full.vault'; + final key = await generateAes256GcmKey(); + + //create small vault (only 1MB) + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 1024 * 1024, // 1MB total + ); + + //Try to write 1MB of data (will fail because index takes space too) + final hugeData = Uint8List(900 * 1024); // 900KB + + await VaultService.write(handle: handle, name: 'big.bin', data: hugeData); + + //Try to write another big file ->should fail + expect( + () async => await VaultService.write( + handle: handle, + name: 'big2.bin', + data: hugeData, + ), + throwsA(predicate((e) => e.toString().contains('VaultFull'))), + ); + + await VaultService.close(handle: handle); + }); + test('wrong key fails to open', () async { + final path = '${tempDir.path}/test.vault'; + final keyA = await generateAes256GcmKey(); + final keyB = await generateAes256GcmKey(); + + //Create vault with keyA + final handle = await VaultService.create( + path: path, + key: keyA, + algorithm: 'aes-256-gcm', + capacityBytes: 1024 * 1024, + ); + await VaultService.close(handle: handle); + + //try to open with keyB + expect( + () async => await VaultService.open(path: path, key: keyB), + throwsA(isA()), + ); + }); + test('concurrent open returns VaultLocked', () async { + final path = '${tempDir.path}/locked.vault'; + final key = await generateAes256GcmKey(); + + //create vault and keep it open + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 1024 * 1024, + ); + + //try to open again without closing first + expect( + () async => await VaultService.open(path: path, key: key), + throwsA(predicate((e) => e.toString().contains('VaultLocked'))), + ); + + await VaultService.close(handle: handle); + + //now it should work! + final handle2 = await VaultService.open(path: path, key: key); + await VaultService.close(handle: handle2); + }); + + test('list returns all segment names', () async { + final path = '${tempDir.path}/list.vault'; + final key = await generateAes256GcmKey(); + + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 2 * 1024 * 1024, + ); + //write 3 segments + await VaultService.write( + handle: handle, + name: 'a.txt', + data: Uint8List(10), + ); + await VaultService.write( + handle: handle, + name: 'b.txt', + data: Uint8List(20), + ); + await VaultService.write( + handle: handle, + name: 'c.txt', + data: Uint8List(30), + ); + //returns all segments + final names = await VaultService.list(handle: handle); + + expect(names, contains(['a.txt', 'b.txt', 'c.txt'])); + + await VaultService.close(handle: handle); + }); + + test('capacity info is consistent', () async { + final path = '${tempDir.path}/capacity.vault'; + final key = await generateAes256GcmKey(); + + final totalCapacity = 2 * 1024 * 1024; // 2MB + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: totalCapacity, + ); + + // Check initial capacity + final before = await VaultService.capacity(handle: handle); + expect(before.totalBytes, BigInt.from(totalCapacity)); + + // Write some data + final data = Uint8List(100 * 1024); // 100KB + await VaultService.write(handle: handle, name: 'data.bin', data: data); + + // Check again + final after = await VaultService.capacity(handle: handle); + expect(after.usedBytes, greaterThan(before.usedBytes)); + expect(after.unallocatedBytes, lessThan(before.unallocatedBytes)); + + // Total should remain the same + expect(after.totalBytes, before.totalBytes); + + await VaultService.close(handle: handle); + }); + + // Compression integration + test('write with zstd, read decompresses automatically', () async { + final path = '${tempDir.path}/test.vault'; + final key = await generateAes256GcmKey(); + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 1024 * 1024, + ); + final data = Uint8List.fromList(List.generate(10000, (i) => i % 256)); + await VaultService.write( + handle: handle, + name: 'notes.txt', + data: data, + compression: const CompressionConfig( + algorithm: CompressionAlgorithm.zstd, + ), + ); + final result = await VaultService.read(handle: handle, name: 'notes.txt'); + expect(result, data); + await VaultService.close(handle: handle); + }); + + test('write with brotli, read decompresses automatically', () async { + final path = '${tempDir.path}/test.vault'; + final key = await generateAes256GcmKey(); + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 1024 * 1024, + ); + + final data = Uint8List.fromList(List.generate(10000, (i) => i % 256)); + await VaultService.write( + handle: handle, + name: 'notes.txt', + data: data, + compression: const CompressionConfig( + algorithm: CompressionAlgorithm.brotli, + ), + ); + final result = await VaultService.read(handle: handle, name: 'notes.txt'); + expect(result, data); + await VaultService.close(handle: handle); + }); + + test('jpg segment skips compression automatically', () async { + // Write "photo.jpg" with Zstd config — should auto-skip + // Read back — data is identical + final path = '${tempDir.path}/mime.vault'; + final key = await generateAes256GcmKey(); + + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 2 * 1024 * 1024, + ); + + // Fake JPEG data + final jpegData = Uint8List(1000); + + // Write with Zstd config BUT name is .jpg + await VaultService.write( + handle: handle, + name: 'photo.jpg', // ← Extension triggers MIME skip + data: jpegData, + compression: const CompressionConfig( + algorithm: CompressionAlgorithm.zstd, // Requested but will be ignored + ), + ); + + // Read back + final result = await VaultService.read(handle: handle, name: 'photo.jpg'); + expect(result, jpegData); + + // Verify: if compression was applied, capacity used would be less + // Since it was skipped, capacity used ≈ original size + + await VaultService.close(handle: handle); + }); + + test('mixed compression segments in same vault', () async { + // Write segment A with Zstd, segment B with Brotli, segment C with None + // Read all three back, verify identical data + final path = '${tempDir.path}/mixed.vault'; + final key = await generateAes256GcmKey(); + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 5 * 1024 * 1024, // 5MB + ); + + // Prepare test data (compressible - lots of repeats) + final dataA = Uint8List.fromList(List.filled(5000, 42)); + final dataB = Uint8List.fromList(List.filled(5000, 99)); + final dataC = Uint8List.fromList(List.filled(5000, 123)); + + // Write segment A with Zstd + await VaultService.write( + handle: handle, + name: 'zstd_segment.txt', + data: dataA, + compression: const CompressionConfig( + algorithm: CompressionAlgorithm.zstd, + level: 3, + ), + ); + + // Write segment B with Brotli + await VaultService.write( + handle: handle, + name: 'brotli_segment.txt', + data: dataB, + compression: const CompressionConfig( + algorithm: CompressionAlgorithm.brotli, + level: 4, + ), + ); + + // Write segment C with None (no compression) + await VaultService.write( + handle: handle, + name: 'uncompressed_segment.txt', + data: dataC, + compression: const CompressionConfig( + algorithm: CompressionAlgorithm.none, + ), + ); + + // Read all three back + final resultA = await VaultService.read(handle: handle, name: 'zstd_segment.txt'); + final resultB = await VaultService.read(handle: handle, name: 'brotli_segment.txt'); + final resultC = await VaultService.read(handle: handle, name: 'uncompressed_segment.txt'); + + // Verify all three match original data + expect(resultA, dataA, reason: 'Zstd segment should decompress correctly'); + expect(resultB, dataB, reason: 'Brotli segment should decompress correctly'); + expect(resultC, dataC, reason: 'Uncompressed segment should read correctly'); + + // Verify all three segments are in the list + final names = await VaultService.list(handle: handle); + expect(names.length, 3); + expect(names, containsAll(['zstd_segment.txt', 'brotli_segment.txt', 'uncompressed_segment.txt'])); + + await VaultService.close(handle: handle); + }); + + test('tampered segment detected on read', () async { + final path = '${tempDir.path}/tamper.vault'; + final key = await generateAes256GcmKey(); + + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 1024 * 1024, + ); + + // Write data + final data = Uint8List.fromList([1, 2, 3, 4, 5]); + await VaultService.write(handle: handle, name: 'test.bin', data: data); + + await VaultService.close(handle: handle); + + // Manually corrupt the vault file + final file = File(path); + final bytes = await file.readAsBytes(); + + // Flip a bit somewhere in the data region (not header) + bytes[1000] ^= 0xFF; + + await file.writeAsBytes(bytes); + + // Reopen + final reopened = await VaultService.open(path: path, key: key); + + // Try to read → should detect tampering via checksum + expect( + () async => await VaultService.read(handle: reopened, name: 'test.bin'), + throwsA(isA()), + ); + + await VaultService.close(handle: reopened); + }); + test('crash recovery via WAL', () async { + final path = '${tempDir.path}/wal.vault'; + final key = await generateAes256GcmKey(); + + // Create vault + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 2 * 1024 * 1024, + ); + + // Write some data + final data1 = Uint8List.fromList([1, 2, 3, 4, 5]); + await VaultService.write(handle: handle, name: 'initial.bin', data: data1); + + // Close properly (commits WAL) + await VaultService.close(handle: handle); + + // Reopen (WAL recovery runs but finds everything committed) + final reopened = await VaultService.open(path: path, key: key); + + // Verify data survived + final result = await VaultService.read(handle: reopened, name: 'initial.bin'); + expect(result, data1); + + // Write more data after recovery + final data2 = Uint8List.fromList([6, 7, 8, 9]); + await VaultService.write(handle: reopened, name: 'after_recovery.bin', data: data2); + + // Close and reopen again + await VaultService.close(handle: reopened); + final reopened2 = await VaultService.open(path: path, key: key); + + // Both segments should exist + final result1 = await VaultService.read(handle: reopened2, name: 'initial.bin'); + final result2 = await VaultService.read(handle: reopened2, name: 'after_recovery.bin'); + + expect(result1, data1); + expect(result2, data2); + + await VaultService.close(handle: reopened2); + }); + test('corrupted primary index falls back to shadow', () async { + final path = '${tempDir.path}/shadow.vault'; + final key = await generateAes256GcmKey(); + + // Create vault and write data + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 2 * 1024 * 1024, + ); + + final originalData = Uint8List.fromList([1, 2, 3, 4, 5]); + await VaultService.write(handle: handle, name: 'important.bin', data: originalData); + + await VaultService.close(handle: handle); + + // At this point: + // - Primary index has 1 segment + // - Shadow index has 1 segment (backup) + + // Manually corrupt the PRIMARY index + final file = File(path); + final bytes = await file.readAsBytes(); + + // Primary index starts at byte 32 (after header) + // Shadow index starts at byte 32 + 64KB + final primaryIndexStart = 32; + //final primaryIndexEnd = primaryIndexStart + (64 * 1024); + + // Corrupt some bytes in the primary index region + // (but leave shadow index intact) + for (int i = primaryIndexStart; i < primaryIndexStart + 100; i++) { + bytes[i] = 0xFF; // Corrupt + } + + await file.writeAsBytes(bytes); + + // Try to reopen + // Rust should: + // 1. Try to decrypt primary index → fail (corrupted) + // 2. Fall back to shadow index → succeed + // 3. Restore primary index from shadow + final reopened = await VaultService.open(path: path, key: key); + + // Read data - should work because shadow index has it + final result = await VaultService.read(handle: reopened, name: 'important.bin'); + expect(result, originalData, reason: 'Shadow index should have preserved the segment'); + + await VaultService.close(handle: reopened); + + // Reopen again - primary should be restored now + final reopened2 = await VaultService.open(path: path, key: key); + final result2 = await VaultService.read(handle: reopened2, name: 'important.bin'); + expect(result2, originalData); + + await VaultService.close(handle: reopened2); + }); + }); +} diff --git a/integration_test/evfs_test.dart b/integration_test/evfs_test.dart new file mode 100644 index 0000000..a8c01b0 --- /dev/null +++ b/integration_test/evfs_test.dart @@ -0,0 +1,607 @@ +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/compression.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('EVFS', () { + // Helper: create a temp vault path + late Directory tempDir; + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('evfs_test'); + }); + tearDown(() async { + await tempDir.delete(recursive: true); + }); + + test('create, write, close, open, read roundtrip', () async { + final path = '${tempDir.path}/test.vault'; + final key = await generateAes256GcmKey(); + + //Create vault + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 1024 * 1024, + ); + + //segment + final data = Uint8List.fromList([1, 2, 3, 4, 5]); + await VaultService.write(handle: handle, name: 'test.bin', data: data); + + //close vault + await VaultService.close(handle: handle); + + //reopen vault + final reopened = await VaultService.open(path: path, key: key); + + //read segment + final result = await VaultService.read( + handle: reopened, + name: 'test.bin', + ); + expect(result, data); + + //close vault + await VaultService.close(handle: reopened); + }); + + test('write and read multiple segments', () async { + final path = '${tempDir.path}/multi.vault'; + final key = await generateAes256GcmKey(); + + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 5 * 1024 * 1024, //5MB + ); + + final data1 = Uint8List.fromList(List.generate(1000, (i) => i % 256)); + final data2 = Uint8List.fromList( + List.generate(2000, (i) => (i * 2) % 256), + ); + final data3 = Uint8List.fromList( + List.generate(500, (i) => (i * 3) % 256), + ); + + await VaultService.write(handle: handle, name: 'file1.dat', data: data1); + await VaultService.write(handle: handle, name: 'file2.dat', data: data2); + await VaultService.write(handle: handle, name: 'file3.dat', data: data3); + + // Read all back + final result1 = await VaultService.read( + handle: handle, + name: 'file1.dat', + ); + final result2 = await VaultService.read( + handle: handle, + name: 'file2.dat', + ); + final result3 = await VaultService.read( + handle: handle, + name: 'file3.dat', + ); + + expect(result1, data1); + expect(result2, data2); + expect(result3, data3); + + await VaultService.close(handle: handle); + }); + + test('overwrite segment returns new data', () async { + final path = '${tempDir.path}/overwrite.vault'; + final key = await generateAes256GcmKey(); + + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 2 * 1024 * 1024, + ); + + // Write original + final original = Uint8List.fromList([1, 2, 3]); + await VaultService.write( + handle: handle, + name: 'data.bin', + data: original, + ); + + // Overwrite + final updated = Uint8List.fromList([4, 5, 6, 7, 8]); + await VaultService.write(handle: handle, name: 'data.bin', data: updated); + + // Read back + final result = await VaultService.read(handle: handle, name: 'data.bin'); + + expect(result, updated); + expect(result, isNot(original)); + + await VaultService.close(handle: handle); + }); + test('read 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.read(handle: handle, name: 'missing.txt'), + throwsA(isA()), + ); + await VaultService.close(handle: handle); + }); + + test('delete segment removes it', () async { + final path = '${tempDir.path}/delete.vault'; + final key = await generateAes256GcmKey(); + + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 2 * 1024 * 1024, + ); + + //write a segment + final data = Uint8List.fromList([1, 2, 3, 4, 5]); + await VaultService.write(handle: handle, name: 'temp.dat', data: data); + + //verify it exists + final before = await VaultService.read(handle: handle, name: 'temp.dat'); + expect(before, data); + + //delete it + await VaultService.delete(handle: handle, name: 'temp.dat'); + + //verify it's gone + expect( + () async => await VaultService.read(handle: handle, name: 'temp.dat'), + throwsA(isA()), + ); + await VaultService.close(handle: handle); + }); + + test('vault full returns VaultFull error', () async { + final path = '${tempDir.path}/full.vault'; + final key = await generateAes256GcmKey(); + + //create small vault (only 1MB) + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 1024 * 1024, // 1MB total + ); + + //Try to write 1MB of data (will fail because index takes space too) + final hugeData = Uint8List(900 * 1024); // 900KB + + await VaultService.write(handle: handle, name: 'big.bin', data: hugeData); + + //Try to write another big file ->should fail + expect( + () async => await VaultService.write( + handle: handle, + name: 'big2.bin', + data: hugeData, + ), + throwsA(predicate((e) => e.toString().contains('VaultFull'))), + ); + + await VaultService.close(handle: handle); + }); + test('wrong key fails to open', () async { + final path = '${tempDir.path}/test.vault'; + final keyA = await generateAes256GcmKey(); + final keyB = await generateAes256GcmKey(); + + //Create vault with keyA + final handle = await VaultService.create( + path: path, + key: keyA, + algorithm: 'aes-256-gcm', + capacityBytes: 1024 * 1024, + ); + await VaultService.close(handle: handle); + + //try to open with keyB + expect( + () async => await VaultService.open(path: path, key: keyB), + throwsA(isA()), + ); + }); + test('concurrent open returns VaultLocked', () async { + final path = '${tempDir.path}/locked.vault'; + final key = await generateAes256GcmKey(); + + //create vault and keep it open + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 1024 * 1024, + ); + + //try to open again without closing first + expect( + () async => await VaultService.open(path: path, key: key), + throwsA(predicate((e) => e.toString().contains('VaultLocked'))), + ); + + await VaultService.close(handle: handle); + + //now it should work! + final handle2 = await VaultService.open(path: path, key: key); + await VaultService.close(handle: handle2); + }); + + test('list returns all segment names', () async { + final path = '${tempDir.path}/list.vault'; + final key = await generateAes256GcmKey(); + + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 2 * 1024 * 1024, + ); + //write 3 segments + await VaultService.write( + handle: handle, + name: 'a.txt', + data: Uint8List(10), + ); + await VaultService.write( + handle: handle, + name: 'b.txt', + data: Uint8List(20), + ); + await VaultService.write( + handle: handle, + name: 'c.txt', + data: Uint8List(30), + ); + //returns all segments + final names = await VaultService.list(handle: handle); + + expect(names, containsAll(['a.txt', 'b.txt', 'c.txt'])); + + await VaultService.close(handle: handle); + }); + + test('capacity info is consistent', () async { + final path = '${tempDir.path}/capacity.vault'; + final key = await generateAes256GcmKey(); + + final totalCapacity = 2 * 1024 * 1024; // 2MB + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: totalCapacity, + ); + + // Check initial capacity + final before = await VaultService.capacity(handle: handle); + expect(before.totalBytes, BigInt.from(totalCapacity)); + + // Write some data + final data = Uint8List(100 * 1024); // 100KB + await VaultService.write(handle: handle, name: 'data.bin', data: data); + + // Check again + final after = await VaultService.capacity(handle: handle); + expect(after.usedBytes, greaterThan(before.usedBytes)); + expect(after.unallocatedBytes, lessThan(before.unallocatedBytes)); + + // Total should remain the same + expect(after.totalBytes, before.totalBytes); + + await VaultService.close(handle: handle); + }); + + // Compression integration + test('write with zstd, read decompresses automatically', () async { + final path = '${tempDir.path}/test.vault'; + final key = await generateAes256GcmKey(); + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 1024 * 1024, + ); + final data = Uint8List.fromList(List.generate(10000, (i) => i % 256)); + await VaultService.write( + handle: handle, + name: 'notes.txt', + data: data, + compression: const CompressionConfig( + algorithm: CompressionAlgorithm.zstd, + ), + ); + final result = await VaultService.read(handle: handle, name: 'notes.txt'); + expect(result, data); + await VaultService.close(handle: handle); + }); + + test('write with brotli, read decompresses automatically', () async { + final path = '${tempDir.path}/test.vault'; + final key = await generateAes256GcmKey(); + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 1024 * 1024, + ); + + final data = Uint8List.fromList(List.generate(10000, (i) => i % 256)); + await VaultService.write( + handle: handle, + name: 'notes.txt', + data: data, + compression: const CompressionConfig( + algorithm: CompressionAlgorithm.brotli, + ), + ); + final result = await VaultService.read(handle: handle, name: 'notes.txt'); + expect(result, data); + await VaultService.close(handle: handle); + }); + + test('jpg segment skips compression automatically', () async { + // Write "photo.jpg" with Zstd config — should auto-skip + // Read back — data is identical + final path = '${tempDir.path}/mime.vault'; + final key = await generateAes256GcmKey(); + + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 2 * 1024 * 1024, + ); + + // Fake JPEG data + final jpegData = Uint8List(1000); + + // Write with Zstd config BUT name is .jpg + await VaultService.write( + handle: handle, + name: 'photo.jpg', // ← Extension triggers MIME skip + data: jpegData, + compression: const CompressionConfig( + algorithm: CompressionAlgorithm.zstd, // Requested but will be ignored + ), + ); + + // Read back + final result = await VaultService.read(handle: handle, name: 'photo.jpg'); + expect(result, jpegData); + + // Verify: if compression was applied, capacity used would be less + // Since it was skipped, capacity used ≈ original size + + await VaultService.close(handle: handle); + }); + + test('mixed compression segments in same vault', () async { + // Write segment A with Zstd, segment B with Brotli, segment C with None + // Read all three back, verify identical data + final path = '${tempDir.path}/mixed.vault'; + final key = await generateAes256GcmKey(); + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 5 * 1024 * 1024, // 5MB + ); + + // Prepare test data (compressible - lots of repeats) + final dataA = Uint8List.fromList(List.filled(5000, 42)); + final dataB = Uint8List.fromList(List.filled(5000, 99)); + final dataC = Uint8List.fromList(List.filled(5000, 123)); + + // Write segment A with Zstd + await VaultService.write( + handle: handle, + name: 'zstd_segment.txt', + data: dataA, + compression: const CompressionConfig( + algorithm: CompressionAlgorithm.zstd, + level: 3, + ), + ); + + // Write segment B with Brotli + await VaultService.write( + handle: handle, + name: 'brotli_segment.txt', + data: dataB, + compression: const CompressionConfig( + algorithm: CompressionAlgorithm.brotli, + level: 4, + ), + ); + + // Write segment C with None (no compression) + await VaultService.write( + handle: handle, + name: 'uncompressed_segment.txt', + data: dataC, + compression: const CompressionConfig( + algorithm: CompressionAlgorithm.none, + ), + ); + + // Read all three back + final resultA = await VaultService.read(handle: handle, name: 'zstd_segment.txt'); + final resultB = await VaultService.read(handle: handle, name: 'brotli_segment.txt'); + final resultC = await VaultService.read(handle: handle, name: 'uncompressed_segment.txt'); + + // Verify all three match original data + expect(resultA, dataA, reason: 'Zstd segment should decompress correctly'); + expect(resultB, dataB, reason: 'Brotli segment should decompress correctly'); + expect(resultC, dataC, reason: 'Uncompressed segment should read correctly'); + + // Verify all three segments are in the list + final names = await VaultService.list(handle: handle); + expect(names.length, 3); + expect(names, containsAll(['zstd_segment.txt', 'brotli_segment.txt', 'uncompressed_segment.txt'])); + + await VaultService.close(handle: handle); + }); + + test('tampered segment detected on read', () async { + final path = '${tempDir.path}/tamper.vault'; + final key = await generateAes256GcmKey(); + + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 1024 * 1024, + ); + + // Write data + final data = Uint8List.fromList([1, 2, 3, 4, 5]); + await VaultService.write(handle: handle, name: 'test.bin', data: data); + + await VaultService.close(handle: handle); + + // Manually corrupt the vault file + final file = File(path); + final bytes = await file.readAsBytes(); + + // Flip a bit somewhere in the data region (not header) + bytes[1000] ^= 0xFF; + + await file.writeAsBytes(bytes); + + // Reopen + final reopened = await VaultService.open(path: path, key: key); + + // Try to read → should detect tampering via checksum + expect( + () async => await VaultService.read(handle: reopened, name: 'test.bin'), + throwsA(isA()), + ); + + await VaultService.close(handle: reopened); + }); + test('crash recovery via WAL', () async { + final path = '${tempDir.path}/wal.vault'; + final key = await generateAes256GcmKey(); + + // Create vault + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 2 * 1024 * 1024, + ); + + // Write some data + final data1 = Uint8List.fromList([1, 2, 3, 4, 5]); + await VaultService.write(handle: handle, name: 'initial.bin', data: data1); + + // Close properly (commits WAL) + await VaultService.close(handle: handle); + + // Reopen (WAL recovery runs but finds everything committed) + final reopened = await VaultService.open(path: path, key: key); + + // Verify data survived + final result = await VaultService.read(handle: reopened, name: 'initial.bin'); + expect(result, data1); + + // Write more data after recovery + final data2 = Uint8List.fromList([6, 7, 8, 9]); + await VaultService.write(handle: reopened, name: 'after_recovery.bin', data: data2); + + // Close and reopen again + await VaultService.close(handle: reopened); + final reopened2 = await VaultService.open(path: path, key: key); + + // Both segments should exist + final result1 = await VaultService.read(handle: reopened2, name: 'initial.bin'); + final result2 = await VaultService.read(handle: reopened2, name: 'after_recovery.bin'); + + expect(result1, data1); + expect(result2, data2); + + await VaultService.close(handle: reopened2); + }); + test('corrupted primary index falls back to shadow', () async { + final path = '${tempDir.path}/shadow.vault'; + final key = await generateAes256GcmKey(); + + // Create vault and write data + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 2 * 1024 * 1024, + ); + + final originalData = Uint8List.fromList([1, 2, 3, 4, 5]); + await VaultService.write(handle: handle, name: 'important.bin', data: originalData); + + await VaultService.close(handle: handle); + + + // primary index has 1 segment + // shadow index has 1 segment (backup) + + // Manually corrupt the PRIMARY index + final file = File(path); + final bytes = await file.readAsBytes(); + + // Primary index starts at byte 32 (after header) + // Shadow index starts at byte 32 + 64KB + final primaryIndexStart = 32; + //final primaryIndexEnd = primaryIndexStart + (64 * 1024); + + // Corrupt some bytes in the primary index region + // (but leave shadow index intact) + for (int i = primaryIndexStart; i < primaryIndexStart + 100; i++) { + bytes[i] = 0xFF; // Corrupt + } + + await file.writeAsBytes(bytes); + + // Try to reopen + final reopened = await VaultService.open(path: path, key: key); + + // Read data - should work because shadow index has it + final result = await VaultService.read(handle: reopened, name: 'important.bin'); + expect(result, originalData, reason: 'Shadow index should have preserved the segment'); + + await VaultService.close(handle: reopened); + + // Reopen again - primary should be restored now + final reopened2 = await VaultService.open(path: path, key: key); + final result2 = await VaultService.read(handle: reopened2, name: 'important.bin'); + expect(result2, originalData); + + await VaultService.close(handle: reopened2); + }); + }); +} diff --git a/lib/m_security.dart b/lib/m_security.dart index 5068bc3..95e2de1 100644 --- a/lib/m_security.dart +++ b/lib/m_security.dart @@ -4,4 +4,5 @@ export 'src/hashing/argon2.dart'; export 'src/kdf/hkdf.dart'; export 'src/rust/frb_generated.dart' show RustLib; export 'src/streaming/streaming_service.dart'; -export 'src/compression/compression_service.dart'; \ No newline at end of file +export 'src/compression/compression_service.dart'; +export 'src/evfs/vault_service.dart'; \ No newline at end of file diff --git a/lib/src/evfs/vault_service.dart b/lib/src/evfs/vault_service.dart new file mode 100644 index 0000000..768ff16 --- /dev/null +++ b/lib/src/evfs/vault_service.dart @@ -0,0 +1,92 @@ +import 'package:m_security/src/rust/api/evfs.dart' as rust_evfs; +import 'package:m_security/src/rust/api/compression.dart'; +import 'dart:typed_data'; + +/// Encrypted Virtual File System — named segment storage in a .vault container. +/// +/// Compression is optional on write (pass [CompressionConfig]) and +/// automatic on read (algorithm stored per-segment in the vault index). +class VaultService { + VaultService._(); + + /// Create a new vault file. + /// + /// [algorithm] must be "aes-256-gcm" or "chacha20-poly1305". + static Future create({ + required String path, + required Uint8List key, + required String algorithm, + required int capacityBytes, + }) { + return rust_evfs.vaultCreate( + path: path, + key: key, + algorithm: algorithm, + capacityBytes: BigInt.from(capacityBytes), + ); + } + + /// Open an existing vault (runs WAL recovery if needed). + static Future open({ + required String path, + required Uint8List key, + }) { + return rust_evfs.vaultOpen(path: path, key: key); + } + + /// Write (or overwrite) a named segment. + /// + /// [compression] is optional — defaults to no compression. + /// MIME-aware skip: if [name] has an already-compressed extension + /// (e.g., ".jpg"), compression is bypassed automatically. + static Future write({ + required rust_evfs.VaultHandle handle, + required String name, + required Uint8List data, + CompressionConfig? compression, + }) { + return rust_evfs.vaultWrite( + handle: handle, + name: name, + data: data, + compression: compression, + ); + } + + /// Read a named segment. Decompression is automatic. + static Future read({ + required rust_evfs.VaultHandle handle, + required String name, + }) { + return rust_evfs.vaultRead(handle: handle, name: name); + } + + /// Delete a named segment (securely erased from disk). + static Future delete({ + required rust_evfs.VaultHandle handle, + required String name, + }) { + return rust_evfs.vaultDelete(handle: handle, name: name); + } + + /// List all segment names. + static Future> list({ + required rust_evfs.VaultHandle handle, + }) { + return rust_evfs.vaultList(handle: handle); + } + + /// Get vault capacity info. + static Future capacity({ + required rust_evfs.VaultHandle handle, + }) { + return rust_evfs.vaultCapacity(handle: handle); + } + + /// Close the vault (release lock, zeroize keys). + static Future close({ + required rust_evfs.VaultHandle handle, + }) { + return rust_evfs.vaultClose(handle: handle); + } +} \ No newline at end of file diff --git a/rust/Cargo.lock b/rust/Cargo.lock index b86bcea..0aa1bea 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -328,6 +328,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -466,6 +475,16 @@ dependencies = [ "syn", ] +[[package]] +name = "fs4" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c29c30684418547d476f0b48e84f4821639119c483b1eccd566c8cd0cd05f521" +dependencies = [ + "rustix", + "windows-sys 0.52.0", +] + [[package]] name = "futures" version = "0.3.31" @@ -958,13 +977,16 @@ dependencies = [ "blake3", "brotli", "chacha20poly1305", + "crc32fast", "flutter_rust_bridge", + "fs4", "hex", "hkdf", "log", "rand", "sha2", "sha3", + "subtle", "tempfile", "thiserror", "zeroize", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index dc7601e..abdb83f 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -35,6 +35,15 @@ argon2 = "0.5" hkdf = "0.12" sha2 = "0.10" +# Constant-time comparison +subtle = "2.6" + +# CRC32 for WAL integrity +crc32fast = "1.4" + +# Advisory file locking +fs4 = "0.12" + # Compression zstd = { version = "0.13", optional = true } brotli = { version = "7.0", optional = true } diff --git a/rust/src/api/evfs/mod.rs b/rust/src/api/evfs/mod.rs new file mode 100644 index 0000000..0625bc7 --- /dev/null +++ b/rust/src/api/evfs/mod.rs @@ -0,0 +1,1201 @@ +//! Encrypted Virtual File System — .vault container format and operations. + +use crate::api::compression::{CompressionAlgorithm, CompressionConfig}; +use crate::core::error::CryptoError; +use crate::core::evfs::format::{ + self, SegmentEntry, SegmentIndex, VaultHeader, DATA_REGION_OFFSET, ENCRYPTED_INDEX_SIZE, + PRIMARY_INDEX_OFFSET, VAULT_HEADER_SIZE, +}; +use crate::core::evfs::segment::{self, SegmentCryptoParams, VaultKeys}; +use crate::core::evfs::wal::{VaultLock, WalOp, WriteAheadLog}; +use crate::core::format::Algorithm; +use flutter_rust_bridge::frb; +use zeroize::Zeroize; + +use std::fs::{File, OpenOptions}; +use std::io::{Read, Seek, SeekFrom, Write}; + +// --------------------------------------------------------------------------- +// VaultHandle +// --------------------------------------------------------------------------- + +/// Opaque handle for an open vault. +/// +/// Holds the open file, derived sub-keys, cached index, WAL, and file lock. +/// All key material uses SecretBuffer (ZeroizeOnDrop). +#[frb(opaque)] +pub struct VaultHandle { + #[allow(dead_code)] // used by Dart wrappers + path: String, + algorithm: Algorithm, + keys: VaultKeys, + index: SegmentIndex, + file: File, + wal: WriteAheadLog, + lock: VaultLock, +} + +/// Capacity info returned to callers. +pub struct VaultCapacityInfo { + pub total_bytes: u64, + pub used_bytes: u64, + pub free_list_bytes: u64, + pub unallocated_bytes: u64, + pub segment_count: usize, +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn parse_algorithm(s: &str) -> Result { + match s { + "aes-256-gcm" => Ok(Algorithm::AesGcm), + "chacha20-poly1305" => Ok(Algorithm::ChaCha20Poly1305), + _ => Err(CryptoError::InvalidParameter(format!( + "unsupported algorithm: '{s}' (expected 'aes-256-gcm' or 'chacha20-poly1305')" + ))), + } +} + +/// Encrypt the in-memory index and write to both primary and shadow locations. +fn flush_index( + file: &mut File, + index: &SegmentIndex, + keys: &VaultKeys, + algorithm: Algorithm, + capacity: u64, +) -> Result<(), CryptoError> { + let plaintext = index.to_bytes()?; + let encrypted = segment::aead_encrypt_random_nonce( + keys.index_key.as_bytes(), + &plaintext, + &[], + algorithm, + )?; + + // Primary + file.seek(SeekFrom::Start(PRIMARY_INDEX_OFFSET))?; + file.write_all(&encrypted)?; + + // Shadow + let shadow_off = format::shadow_index_offset(capacity)?; + file.seek(SeekFrom::Start(shadow_off))?; + file.write_all(&encrypted)?; + + file.sync_all()?; + Ok(()) +} + +/// Read raw encrypted index bytes from a given file offset. +fn read_encrypted_index(file: &mut File, offset: u64) -> Result, CryptoError> { + file.seek(SeekFrom::Start(offset))?; + let mut buf = vec![0u8; ENCRYPTED_INDEX_SIZE]; + file.read_exact(&mut buf)?; + Ok(buf) +} + +/// Decrypt an encrypted index blob into a SegmentIndex. +fn decrypt_index_blob( + encrypted: &[u8], + keys: &VaultKeys, + algorithm: Algorithm, +) -> Result { + let plaintext = segment::aead_decrypt_with_stored_nonce( + keys.index_key.as_bytes(), + encrypted, + &[], + algorithm, + )?; + SegmentIndex::from_bytes(&plaintext) +} + +/// Compute vault data capacity from file size. +fn capacity_from_file_size(file_size: u64) -> Result { + let overhead = VAULT_HEADER_SIZE as u64 + 2 * ENCRYPTED_INDEX_SIZE as u64; + file_size + .checked_sub(overhead) + .ok_or_else(|| CryptoError::VaultCorrupted("vault file too small".into())) +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Create a new vault file at `path` with the given capacity. +/// +/// The algorithm string must be "aes-256-gcm" or "chacha20-poly1305". +#[cfg(feature = "compression")] +pub fn vault_create( + path: String, + mut key: Vec, + algorithm: String, + capacity_bytes: u64, +) -> Result { + let algo = parse_algorithm(&algorithm)?; + let lock = VaultLock::acquire(&path)?; + let keys = segment::derive_vault_keys(&key)?; + key.zeroize(); + let total_size = format::total_vault_size(capacity_bytes)?; + + let mut file = OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .open(&path) + .map_err(|e| CryptoError::IoError(format!("cannot create vault: {e}")))?; + + // Pre-allocate with CSPRNG random fill + segment::preallocate_vault(&mut file, total_size)?; + + // Write header + let header = VaultHeader::new(algo.to_byte()); + file.seek(SeekFrom::Start(0))?; + file.write_all(&header.to_bytes())?; + + // Create empty index and flush to primary + shadow + let index = SegmentIndex::new(capacity_bytes); + flush_index(&mut file, &index, &keys, algo, capacity_bytes)?; + + // Create fresh WAL (checkpoint to clear any stale data) + let mut wal = WriteAheadLog::open(&path)?; + wal.checkpoint()?; + + Ok(VaultHandle { + path, + algorithm: algo, + keys, + index, + file, + wal, + lock, + }) +} + +/// Open an existing vault, running WAL recovery if needed. +#[cfg(feature = "compression")] +pub fn vault_open(path: String, mut key: Vec) -> Result { + let lock = VaultLock::acquire(&path)?; + + let mut file = OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .map_err(|e| CryptoError::IoError(format!("cannot open vault: {e}")))?; + + // Read header + let mut header_buf = [0u8; VAULT_HEADER_SIZE]; + file.seek(SeekFrom::Start(0))?; + file.read_exact(&mut header_buf)?; + let header = VaultHeader::from_bytes(&header_buf)?; + let algorithm = Algorithm::from_byte(header.algorithm)?; + + // Derive keys + let keys = segment::derive_vault_keys(&key)?; + key.zeroize(); + + // Compute capacity from file size + let file_size = file.seek(SeekFrom::End(0))?; + let capacity = capacity_from_file_size(file_size)?; + + // WAL recovery + let mut wal = WriteAheadLog::open(&path)?; + if let Some(old_encrypted_index) = wal.recover()? { + if old_encrypted_index.len() != ENCRYPTED_INDEX_SIZE { + return Err(CryptoError::VaultCorrupted(format!( + "WAL snapshot size {} != expected {ENCRYPTED_INDEX_SIZE}", + old_encrypted_index.len() + ))); + } + // Restore old index to primary + shadow + file.seek(SeekFrom::Start(PRIMARY_INDEX_OFFSET))?; + file.write_all(&old_encrypted_index)?; + let shadow_off = format::shadow_index_offset(capacity)?; + file.seek(SeekFrom::Start(shadow_off))?; + file.write_all(&old_encrypted_index)?; + file.sync_all()?; + } + wal.checkpoint()?; + + // Decrypt index (try primary, fall back to shadow) + let index = { + let primary_bytes = read_encrypted_index(&mut file, PRIMARY_INDEX_OFFSET)?; + match decrypt_index_blob(&primary_bytes, &keys, algorithm) { + Ok(idx) => idx, + Err(_) => { + let shadow_off = format::shadow_index_offset(capacity)?; + let shadow_bytes = read_encrypted_index(&mut file, shadow_off)?; + let idx = decrypt_index_blob(&shadow_bytes, &keys, algorithm).map_err(|_| { + CryptoError::VaultCorrupted( + "both primary and shadow index are corrupted".into(), + ) + })?; + // Restore primary from shadow + file.seek(SeekFrom::Start(PRIMARY_INDEX_OFFSET))?; + file.write_all(&shadow_bytes)?; + file.sync_all()?; + idx + } + } + }; + + Ok(VaultHandle { + path, + algorithm, + keys, + index, + file, + wal, + lock, + }) +} + +/// Write (or overwrite) a named segment. +/// +/// Compression is transparent: pass a `CompressionConfig` to compress before +/// encryption. MIME-aware skip applies when the segment name has an +/// already-compressed extension. +#[cfg(feature = "compression")] +pub fn vault_write( + handle: &mut VaultHandle, + name: String, + mut data: Vec, + compression: Option, +) -> Result<(), CryptoError> { + let config = compression.unwrap_or(CompressionConfig { + algorithm: CompressionAlgorithm::None, + level: None, + }); + + // 1. Checksum on original plaintext (pre-compression) + let checksum = segment::compute_checksum(&data); + + // 2. Compress-then-encrypt + let gen = handle.index.next_gen(); + let params = SegmentCryptoParams { + cipher_key: handle.keys.cipher_key.as_bytes(), + nonce_key: handle.keys.nonce_key.as_bytes(), + algorithm: handle.algorithm, + segment_index: 0, + generation: gen, + }; + let (encrypted, effective_algo) = + segment::encrypt_segment(¶ms, &data, &name, &config)?; + data.zeroize(); + + // 3. WAL journal old index + let old_encrypted_index = read_encrypted_index(&mut handle.file, PRIMARY_INDEX_OFFSET)?; + handle + .wal + .begin(WalOp::WriteSegment, &old_encrypted_index)?; + + // 4. If overwrite: secure-erase old region, deallocate + if let Some(old_entry) = handle.index.remove(&name) { + segment::secure_erase_region( + &mut handle.file, + DATA_REGION_OFFSET + old_entry.offset, + old_entry.size, + )?; + handle.index.deallocate(old_entry.offset, old_entry.size); + } + + // 5. Allocate space (free list first, then append) + let offset = handle.index.allocate(encrypted.len() as u64)?; + + // 6. Write encrypted segment at allocated offset + handle + .file + .seek(SeekFrom::Start(DATA_REGION_OFFSET + offset))?; + handle.file.write_all(&encrypted)?; + + // 7. Update index + let entry = SegmentEntry::new( + &name, + offset, + encrypted.len() as u64, + gen, + checksum, + effective_algo, + )?; + handle.index.add(entry)?; + + // 8. Flush index (primary + shadow) + flush_index( + &mut handle.file, + &handle.index, + &handle.keys, + handle.algorithm, + handle.index.capacity, + )?; + + // 9. WAL commit + handle.wal.commit()?; + + Ok(()) +} + +/// Read a named segment. Decompression is automatic. +#[cfg(feature = "compression")] +pub fn vault_read( + handle: &mut VaultHandle, + name: String, +) -> Result, CryptoError> { + let entry = handle + .index + .find(&name) + .ok_or_else(|| CryptoError::SegmentNotFound(name.clone()))?; + + let seg_offset = entry.offset; + let seg_size = entry.size; + let seg_gen = entry.generation; + let seg_compression = entry.compression; + let seg_checksum = entry.checksum; + + // Read encrypted data from disk + let read_len = usize::try_from(seg_size).map_err(|_| { + CryptoError::VaultCorrupted(format!( + "segment size {seg_size} exceeds platform address space" + )) + })?; + handle + .file + .seek(SeekFrom::Start(DATA_REGION_OFFSET + seg_offset))?; + let mut encrypted = vec![0u8; read_len]; + handle.file.read_exact(&mut encrypted)?; + + // Decrypt-then-decompress + let params = SegmentCryptoParams { + cipher_key: handle.keys.cipher_key.as_bytes(), + nonce_key: handle.keys.nonce_key.as_bytes(), + algorithm: handle.algorithm, + segment_index: 0, + generation: seg_gen, + }; + let plaintext = segment::decrypt_segment(¶ms, &encrypted, seg_compression)?; + + // Verify checksum on decompressed plaintext + if !segment::verify_checksum(&plaintext, &seg_checksum) { + return Err(CryptoError::VaultCorrupted(format!( + "integrity check failed for segment '{name}'" + ))); + } + + Ok(plaintext) +} + +/// Delete a named segment. The region is secure-erased and returned to the +/// free list for reuse by future writes. +#[cfg(feature = "compression")] +pub fn vault_delete( + handle: &mut VaultHandle, + name: String, +) -> Result<(), CryptoError> { + // WAL journal old index + let old_encrypted_index = read_encrypted_index(&mut handle.file, PRIMARY_INDEX_OFFSET)?; + handle + .wal + .begin(WalOp::DeleteSegment, &old_encrypted_index)?; + + let entry = handle + .index + .remove(&name) + .ok_or_else(|| CryptoError::SegmentNotFound(name.clone()))?; + + // Secure erase (CSPRNG overwrite + fsync) + segment::secure_erase_region( + &mut handle.file, + DATA_REGION_OFFSET + entry.offset, + entry.size, + )?; + + // Return space to free list + handle.index.deallocate(entry.offset, entry.size); + + // Flush index (primary + shadow) + flush_index( + &mut handle.file, + &handle.index, + &handle.keys, + handle.algorithm, + handle.index.capacity, + )?; + + // WAL commit + handle.wal.commit()?; + + Ok(()) +} + +/// List all segment names in the vault. +pub fn vault_list(handle: &VaultHandle) -> Vec { + handle.index.entries.iter().map(|e| e.name.clone()).collect() +} + +/// Get vault capacity info. +pub fn vault_capacity(handle: &VaultHandle) -> VaultCapacityInfo { + let used = handle.index.used_bytes(); + let free_list = handle.index.free_list_bytes(); + let unallocated = handle.index.capacity.saturating_sub(handle.index.next_free_offset); + VaultCapacityInfo { + total_bytes: handle.index.capacity, + used_bytes: used, + free_list_bytes: free_list, + unallocated_bytes: unallocated, + segment_count: handle.index.entries.len(), + } +} + +/// Close the vault — checkpoint WAL, release lock, zeroize keys on drop. +#[cfg(feature = "compression")] +pub fn vault_close(mut handle: VaultHandle) -> Result<(), CryptoError> { + handle.wal.checkpoint()?; + handle.lock.release()?; + // VaultKeys are zeroized on drop (ZeroizeOnDrop) + Ok(()) +} + +// =========================================================================== +// Tests +// =========================================================================== + +#[cfg(all(test, feature = "compression"))] +mod tests { + use super::*; + + fn test_key() -> Vec { + vec![0xAA; 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).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).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).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"); + 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, + ) + .expect("write"); + + let data = vault_read(&mut handle, "doc.txt".into()).expect("read"); + 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).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"); + 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).expect("write v1"); + vault_write(&mut handle, "doc.txt".into(), b"version 2".to_vec(), None).expect("write v2"); + + let data = vault_read(&mut handle, "doc.txt".into()).expect("read"); + 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).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"); + 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, + ) + .expect("write"); + + // Tamper with encrypted data on disk + let entry = handle.index.find("secret.txt").expect("find"); + let disk_offset = DATA_REGION_OFFSET + 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)).expect("write"); + let read_back = vault_read(&mut handle, "data.txt".into()).expect("read"); + 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)).expect("write"); + let read_back = vault_read(&mut handle, "notes.md".into()).expect("read"); + 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)).expect("write"); + let read_back = vault_read(&mut handle, "raw.bin".into()).expect("read"); + 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), + ) + .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"); + 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)).expect("write"); + + // Read back — decompression is automatic (no config needed) + let read_back = vault_read(&mut handle, "auto.txt".into()).expect("read"); + 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)).expect("zstd"); + vault_write( + &mut handle, + "data.bin".into(), + binary.clone(), + Some(brotli_conf), + ) + .expect("brotli"); + vault_write(&mut handle, "raw.dat".into(), raw.clone(), None).expect("none"); + + assert_eq!(vault_read(&mut handle, "text.txt".into()).expect("r"), text); + assert_eq!( + vault_read(&mut handle, "data.bin".into()).expect("r"), + binary + ); + assert_eq!(vault_read(&mut handle, "raw.dat".into()).expect("r"), 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), + ) + .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).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).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).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).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"); + 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).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"); + 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).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"); + + 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).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"); + 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).expect("A"); + vault_write(&mut handle, "b.txt".into(), vec![0xBB; 300], 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).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, + ) + .expect("write"); + + // Capture encrypted bytes at the segment offset + let entry = handle.index.find("secret.txt").expect("find"); + let disk_offset = DATA_REGION_OFFSET + 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).expect("A"); + + // Second write should fail — not enough space + let result = vault_write(&mut handle, "b.txt".into(), vec![0xBB; 200], 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).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"); + + let data = vault_read(&mut handle, "b.txt".into()).expect("read B"); + 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).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, + ) + .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"); + 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, + ) + .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"); + assert_eq!(data, b"shadow test"); + + vault_close(handle).expect("close"); + } +} diff --git a/rust/src/api/mod.rs b/rust/src/api/mod.rs index fa090d8..8b5e9ad 100644 --- a/rust/src/api/mod.rs +++ b/rust/src/api/mod.rs @@ -3,6 +3,7 @@ pub mod compression; pub mod encryption; pub mod error; +pub mod evfs; pub mod hashing; pub mod kdf; pub mod streaming; diff --git a/rust/src/core/error.rs b/rust/src/core/error.rs index fa74af7..3c1a3c4 100644 --- a/rust/src/core/error.rs +++ b/rust/src/core/error.rs @@ -34,6 +34,18 @@ pub enum CryptoError { #[error("Authentication failed")] AuthenticationFailed, + + #[error("Vault full: need {needed} bytes, {available} available")] + VaultFull { needed: u64, available: u64 }, + + #[error("Vault locked by another process")] + VaultLocked, + + #[error("Segment not found: {0}")] + SegmentNotFound(String), + + #[error("Vault corrupted: {0}")] + VaultCorrupted(String), } impl From for CryptoError { diff --git a/rust/src/core/evfs/format.rs b/rust/src/core/evfs/format.rs new file mode 100644 index 0000000..235417a --- /dev/null +++ b/rust/src/core/evfs/format.rs @@ -0,0 +1,938 @@ +//! On-disk `.vault` format: header, segment index, free-region list, layout constants. + +use crate::api::compression::CompressionAlgorithm; +use crate::core::error::CryptoError; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +pub const VAULT_MAGIC: &[u8; 4] = b"MVLT"; +pub const VAULT_VERSION: u8 = 1; +pub const VAULT_HEADER_SIZE: usize = 32; + +/// Max segment name length in bytes (UTF-8). +pub const MAX_SEGMENT_NAME_LEN: usize = 255; + +/// Index is always serialized to this size (zero-padded). +/// 64KB supports approximately 200 segments + free regions. +pub const INDEX_PAD_SIZE: usize = 64 * 1024; + +/// Encrypted index size on disk: padded plaintext + AEAD nonce (12) + tag (16). +pub const ENCRYPTED_INDEX_SIZE: usize = INDEX_PAD_SIZE + 12 + 16; + +/// Vault file layout offsets. +pub const PRIMARY_INDEX_OFFSET: u64 = VAULT_HEADER_SIZE as u64; +pub const DATA_REGION_OFFSET: u64 = PRIMARY_INDEX_OFFSET + ENCRYPTED_INDEX_SIZE as u64; + +/// Shadow index offset depends on vault capacity. +pub fn shadow_index_offset(capacity: u64) -> Result { + DATA_REGION_OFFSET + .checked_add(capacity) + .ok_or_else(|| CryptoError::InvalidParameter("vault capacity overflows layout".into())) +} + +/// WAL region starts after the shadow index. +pub fn wal_region_offset(capacity: u64) -> Result { + shadow_index_offset(capacity)? + .checked_add(ENCRYPTED_INDEX_SIZE as u64) + .ok_or_else(|| CryptoError::InvalidParameter("vault capacity overflows layout".into())) +} + +/// Total vault file size: header + 2 encrypted indices + data capacity. +pub fn total_vault_size(capacity: u64) -> Result { + let base = VAULT_HEADER_SIZE as u64 + 2 * ENCRYPTED_INDEX_SIZE as u64; + base.checked_add(capacity) + .ok_or_else(|| CryptoError::InvalidParameter("vault size overflows".into())) +} + +// --------------------------------------------------------------------------- +// VaultHeader +// --------------------------------------------------------------------------- + +/// On-disk vault header (32 bytes). +/// +/// Layout: `[MAGIC(4)] [VERSION(1)] [ALGORITHM(1)] [FLAGS(2)] [RESERVED(24)]` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VaultHeader { + pub version: u8, + /// AEAD algorithm ID (reuses `core::format::Algorithm` byte values). + pub algorithm: u8, + pub flags: u16, +} + +impl VaultHeader { + pub fn new(algorithm: u8) -> Self { + Self { + version: VAULT_VERSION, + algorithm, + flags: 0, + } + } + + pub fn to_bytes(&self) -> [u8; VAULT_HEADER_SIZE] { + let mut buf = [0u8; VAULT_HEADER_SIZE]; + buf[0..4].copy_from_slice(VAULT_MAGIC); + buf[4] = self.version; + buf[5] = self.algorithm; + buf[6..8].copy_from_slice(&self.flags.to_le_bytes()); + // bytes 8..32 reserved (zeros) + buf + } + + pub fn from_bytes(data: &[u8]) -> Result { + if data.len() < VAULT_HEADER_SIZE { + return Err(CryptoError::VaultCorrupted(format!( + "header too short: {} bytes, need {VAULT_HEADER_SIZE}", + data.len() + ))); + } + if &data[0..4] != VAULT_MAGIC { + return Err(CryptoError::VaultCorrupted( + "invalid magic bytes".to_string(), + )); + } + let version = data[4]; + if version != VAULT_VERSION { + return Err(CryptoError::VaultCorrupted(format!( + "unsupported vault version: {version}" + ))); + } + let algorithm = data[5]; + let flags = u16::from_le_bytes([data[6], data[7]]); + Ok(Self { + version, + algorithm, + flags, + }) + } +} + +// --------------------------------------------------------------------------- +// FreeRegion +// --------------------------------------------------------------------------- + +/// A free region in the data area available for reuse. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FreeRegion { + /// Byte offset relative to data region start. + pub offset: u64, + pub size: u64, +} + +// --------------------------------------------------------------------------- +// SegmentEntry +// --------------------------------------------------------------------------- + +/// Segment index entry — one per stored segment. +#[derive(Debug, Clone)] +pub struct SegmentEntry { + /// Segment name (1–255 bytes UTF-8). + pub name: String, + /// Byte offset in vault data region. + pub offset: u64, + /// Encrypted segment size (nonce + ciphertext + tag). + pub size: u64, + /// Write generation (increments on overwrite). + pub generation: u64, + /// BLAKE3 hash of original plaintext (pre-compression). + pub checksum: [u8; 32], + /// Algorithm used to compress this segment. + pub compression: CompressionAlgorithm, +} + +impl SegmentEntry { + pub fn new( + name: &str, + offset: u64, + size: u64, + generation: u64, + checksum: [u8; 32], + compression: CompressionAlgorithm, + ) -> Result { + if name.is_empty() || name.len() > MAX_SEGMENT_NAME_LEN { + return Err(CryptoError::InvalidParameter(format!( + "segment name must be 1\u{2013}{MAX_SEGMENT_NAME_LEN} bytes, got {}", + name.len() + ))); + } + Ok(Self { + name: name.to_string(), + offset, + size, + generation, + checksum, + compression, + }) + } +} + +// --------------------------------------------------------------------------- +// SegmentIndex +// --------------------------------------------------------------------------- + +/// Encrypted segment index — stored encrypted after the header. +/// +/// Tracks live segments, freed regions for space reclamation, and the +/// append cursor (`next_free_offset`) for when no free region fits. +#[derive(Debug, Clone)] +pub struct SegmentIndex { + pub entries: Vec, + /// Sorted by offset, adjacent regions merged. + pub free_regions: Vec, + /// Append cursor (relative to data region start). + pub next_free_offset: u64, + /// Total data region size in bytes. + pub capacity: u64, + /// Global generation counter. + pub next_generation: u64, +} + +// -- helpers for little-endian I/O ------------------------------------------ + +fn put_u16(buf: &mut Vec, v: u16) { + buf.extend_from_slice(&v.to_le_bytes()); +} + +fn put_u32(buf: &mut Vec, v: u32) { + buf.extend_from_slice(&v.to_le_bytes()); +} + +fn put_u64(buf: &mut Vec, v: u64) { + buf.extend_from_slice(&v.to_le_bytes()); +} + +fn read_u16(data: &[u8], off: &mut usize) -> Result { + let end = *off + 2; + if end > data.len() { + return Err(CryptoError::VaultCorrupted("index truncated (u16)".into())); + } + let v = u16::from_le_bytes([data[*off], data[*off + 1]]); + *off = end; + Ok(v) +} + +fn read_u32(data: &[u8], off: &mut usize) -> Result { + let end = *off + 4; + if end > data.len() { + return Err(CryptoError::VaultCorrupted("index truncated (u32)".into())); + } + let v = u32::from_le_bytes([data[*off], data[*off + 1], data[*off + 2], data[*off + 3]]); + *off = end; + Ok(v) +} + +fn read_u64(data: &[u8], off: &mut usize) -> Result { + let end = *off + 8; + if end > data.len() { + return Err(CryptoError::VaultCorrupted("index truncated (u64)".into())); + } + let v = u64::from_le_bytes([ + data[*off], + data[*off + 1], + data[*off + 2], + data[*off + 3], + data[*off + 4], + data[*off + 5], + data[*off + 6], + data[*off + 7], + ]); + *off = end; + Ok(v) +} + +fn read_bytes(data: &[u8], off: &mut usize, len: usize) -> Result, CryptoError> { + let end = *off + len; + if end > data.len() { + return Err(CryptoError::VaultCorrupted("index truncated (bytes)".into())); + } + let v = data[*off..end].to_vec(); + *off = end; + Ok(v) +} + +impl SegmentIndex { + /// Create an empty index for a new vault. + pub fn new(capacity: u64) -> Self { + Self { + entries: Vec::new(), + free_regions: Vec::new(), + next_free_offset: 0, + capacity, + next_generation: 0, + } + } + + /// Serialize index to bytes, padded to `INDEX_PAD_SIZE`. + /// + /// Wire format: + /// ```text + /// [entry_count: u32] + /// [free_region_count: u32] + /// [next_free_offset: u64] + /// [capacity: u64] + /// [next_generation: u64] + /// -- entries -- + /// per entry: + /// [name_len: u16] [name: UTF-8] [offset: u64] [size: u64] + /// [generation: u64] [checksum: 32B] [compression: u8] + /// -- free regions -- + /// per region: + /// [offset: u64] [size: u64] + /// -- zero padding to INDEX_PAD_SIZE -- + /// ``` + pub fn to_bytes(&self) -> Result, CryptoError> { + let mut buf = Vec::with_capacity(INDEX_PAD_SIZE); + + let entry_count = u32::try_from(self.entries.len()).map_err(|_| { + CryptoError::VaultCorrupted("too many segment entries".into()) + })?; + let free_count = u32::try_from(self.free_regions.len()).map_err(|_| { + CryptoError::VaultCorrupted("too many free regions".into()) + })?; + + put_u32(&mut buf, entry_count); + put_u32(&mut buf, free_count); + put_u64(&mut buf, self.next_free_offset); + put_u64(&mut buf, self.capacity); + put_u64(&mut buf, self.next_generation); + + for entry in &self.entries { + let name_bytes = entry.name.as_bytes(); + let name_len = u16::try_from(name_bytes.len()).map_err(|_| { + CryptoError::InvalidParameter("segment name too long for u16".into()) + })?; + put_u16(&mut buf, name_len); + buf.extend_from_slice(name_bytes); + put_u64(&mut buf, entry.offset); + put_u64(&mut buf, entry.size); + put_u64(&mut buf, entry.generation); + buf.extend_from_slice(&entry.checksum); + buf.push(entry.compression.to_u8()); + } + + for region in &self.free_regions { + put_u64(&mut buf, region.offset); + put_u64(&mut buf, region.size); + } + + if buf.len() > INDEX_PAD_SIZE { + return Err(CryptoError::VaultCorrupted(format!( + "index content ({} bytes) exceeds INDEX_PAD_SIZE ({INDEX_PAD_SIZE})", + buf.len() + ))); + } + + buf.resize(INDEX_PAD_SIZE, 0); + Ok(buf) + } + + /// Deserialize index from decrypted bytes. + pub fn from_bytes(data: &[u8]) -> Result { + if data.len() < 32 { + return Err(CryptoError::VaultCorrupted("index too short".into())); + } + + let mut off = 0; + let entry_count = read_u32(data, &mut off)? as usize; + let free_count = read_u32(data, &mut off)? as usize; + + // Sanity-cap: the smallest possible entry is ~59 bytes (1-byte name), + // free region is 16 bytes. Reject clearly corrupted counts early. + let max_entries = INDEX_PAD_SIZE / 59; + let max_free = INDEX_PAD_SIZE / 16; + if entry_count > max_entries { + return Err(CryptoError::VaultCorrupted(format!( + "entry count {entry_count} exceeds maximum {max_entries}" + ))); + } + if free_count > max_free { + return Err(CryptoError::VaultCorrupted(format!( + "free region count {free_count} exceeds maximum {max_free}" + ))); + } + + let next_free_offset = read_u64(data, &mut off)?; + let capacity = read_u64(data, &mut off)?; + let next_generation = read_u64(data, &mut off)?; + + let mut entries = Vec::with_capacity(entry_count); + for _ in 0..entry_count { + let name_len = read_u16(data, &mut off)? as usize; + if name_len == 0 || name_len > MAX_SEGMENT_NAME_LEN { + return Err(CryptoError::VaultCorrupted(format!( + "invalid segment name length: {name_len}" + ))); + } + let name_bytes = read_bytes(data, &mut off, name_len)?; + let name = String::from_utf8(name_bytes).map_err(|_| { + CryptoError::VaultCorrupted("segment name is not valid UTF-8".into()) + })?; + let offset = read_u64(data, &mut off)?; + let size = read_u64(data, &mut off)?; + let generation = read_u64(data, &mut off)?; + let checksum_bytes = read_bytes(data, &mut off, 32)?; + let mut checksum = [0u8; 32]; + checksum.copy_from_slice(&checksum_bytes); + let comp_byte = read_bytes(data, &mut off, 1)?; + let compression = CompressionAlgorithm::from_u8(comp_byte[0])?; + entries.push(SegmentEntry { + name, + offset, + size, + generation, + checksum, + compression, + }); + } + + let mut free_regions = Vec::with_capacity(free_count); + for _ in 0..free_count { + let offset = read_u64(data, &mut off)?; + let size = read_u64(data, &mut off)?; + free_regions.push(FreeRegion { offset, size }); + } + + Ok(Self { + entries, + free_regions, + next_free_offset, + capacity, + next_generation, + }) + } + + // -- lookup / mutation -------------------------------------------------- + + /// Add a segment entry. Rejects duplicate names. + pub fn add(&mut self, entry: SegmentEntry) -> Result<(), CryptoError> { + if self.entries.iter().any(|e| e.name == entry.name) { + return Err(CryptoError::InvalidParameter(format!( + "duplicate segment name: {}", + entry.name + ))); + } + self.entries.push(entry); + Ok(()) + } + + pub fn find(&self, name: &str) -> Option<&SegmentEntry> { + self.entries.iter().find(|e| e.name == name) + } + + pub fn find_mut(&mut self, name: &str) -> Option<&mut SegmentEntry> { + self.entries.iter_mut().find(|e| e.name == name) + } + + 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)) + } else { + None + } + } + + pub fn names(&self) -> Vec<&str> { + self.entries.iter().map(|e| e.name.as_str()).collect() + } + + // -- allocation --------------------------------------------------------- + + /// Allocate space for `size` bytes. Returns the data-region offset. + /// + /// Strategy: best-fit search in `free_regions` first (smallest region + /// that fits), then fall back to appending at `next_free_offset`. + pub fn allocate(&mut self, size: u64) -> Result { + if size == 0 { + return Err(CryptoError::InvalidParameter( + "cannot allocate zero bytes".into(), + )); + } + + // Best-fit search in free list + let best = self + .free_regions + .iter() + .enumerate() + .filter(|(_, r)| r.size >= size) + .min_by_key(|(_, r)| r.size); + + if let Some((idx, _)) = best { + let region = self.free_regions.remove(idx); + let offset = region.offset; + let leftover = region.size - size; + if leftover > 0 { + self.free_regions.push(FreeRegion { + offset: offset + size, + size: leftover, + }); + self.free_regions.sort_by_key(|r| r.offset); + } + return Ok(offset); + } + + // Fall back to append (checked arithmetic to prevent overflow) + let end = self.next_free_offset.checked_add(size).ok_or( + CryptoError::VaultFull { + needed: size, + available: self.capacity.saturating_sub(self.next_free_offset), + }, + )?; + if end > self.capacity { + return Err(CryptoError::VaultFull { + needed: size, + available: self.capacity.saturating_sub(self.next_free_offset), + }); + } + let offset = self.next_free_offset; + self.next_free_offset = end; + Ok(offset) + } + + /// Return a region to the free list. Merges adjacent regions. + pub fn deallocate(&mut self, offset: u64, size: u64) { + self.free_regions.push(FreeRegion { offset, size }); + self.free_regions.sort_by_key(|r| r.offset); + self.merge_adjacent(); + } + + fn merge_adjacent(&mut self) { + let mut i = 0; + while i + 1 < self.free_regions.len() { + let end = self.free_regions[i].offset + self.free_regions[i].size; + if end == self.free_regions[i + 1].offset { + self.free_regions[i].size += self.free_regions[i + 1].size; + self.free_regions.remove(i + 1); + } else { + i += 1; + } + } + } + + // -- generation --------------------------------------------------------- + + pub fn next_gen(&mut self) -> u64 { + let gen = self.next_generation; + self.next_generation += 1; + gen + } + + // -- stats -------------------------------------------------------------- + + pub fn used_bytes(&self) -> u64 { + self.entries.iter().map(|e| e.size).sum() + } + + pub fn free_list_bytes(&self) -> u64 { + self.free_regions.iter().map(|r| r.size).sum() + } +} + +// =========================================================================== +// Tests +// =========================================================================== + +#[cfg(test)] +mod tests { + use super::*; + + fn dummy_checksum(val: u8) -> [u8; 32] { + [val; 32] + } + + // -- VaultHeader -------------------------------------------------------- + + #[test] + fn test_vault_header_roundtrip() { + let header = VaultHeader::new(0x01); + let bytes = header.to_bytes(); + let parsed = VaultHeader::from_bytes(&bytes).expect("parse"); + assert_eq!(header, parsed); + } + + #[test] + fn test_vault_header_invalid_magic() { + let mut bytes = VaultHeader::new(0x01).to_bytes(); + bytes[0] = b'X'; + assert!(VaultHeader::from_bytes(&bytes).is_err()); + } + + #[test] + fn test_vault_header_invalid_version() { + let mut bytes = VaultHeader::new(0x01).to_bytes(); + bytes[4] = 99; + assert!(VaultHeader::from_bytes(&bytes).is_err()); + } + + // -- SegmentEntry ------------------------------------------------------- + + #[test] + fn test_segment_entry_valid_name() { + // 1 byte + let e = SegmentEntry::new("a", 0, 100, 0, dummy_checksum(0), CompressionAlgorithm::None); + assert!(e.is_ok()); + + // 255 bytes + let long = "a".repeat(MAX_SEGMENT_NAME_LEN); + let e = SegmentEntry::new(&long, 0, 100, 0, dummy_checksum(0), CompressionAlgorithm::None); + assert!(e.is_ok()); + } + + #[test] + fn test_segment_entry_name_too_long() { + let long = "a".repeat(MAX_SEGMENT_NAME_LEN + 1); + let e = SegmentEntry::new(&long, 0, 100, 0, dummy_checksum(0), CompressionAlgorithm::None); + assert!(e.is_err()); + } + + #[test] + fn test_segment_entry_name_empty() { + let e = SegmentEntry::new("", 0, 100, 0, dummy_checksum(0), CompressionAlgorithm::None); + assert!(e.is_err()); + } + + // -- SegmentIndex serialization ----------------------------------------- + + fn make_test_index() -> SegmentIndex { + let mut idx = SegmentIndex::new(1024 * 1024); + idx.add( + SegmentEntry::new("hello.txt", 0, 4096, 1, dummy_checksum(0xAA), CompressionAlgorithm::Zstd) + .expect("entry"), + ).expect("add"); + idx.add( + SegmentEntry::new("photo.jpg", 4096, 8192, 2, dummy_checksum(0xBB), CompressionAlgorithm::None) + .expect("entry"), + ).expect("add"); + idx.free_regions.push(FreeRegion { offset: 12288, size: 2048 }); + idx.next_free_offset = 14336; + idx.next_generation = 3; + idx + } + + #[test] + fn test_segment_index_roundtrip() { + let idx = make_test_index(); + let bytes = idx.to_bytes().expect("serialize"); + let parsed = SegmentIndex::from_bytes(&bytes).expect("parse"); + + assert_eq!(parsed.entries.len(), 2); + assert_eq!(parsed.entries[0].name, "hello.txt"); + assert_eq!(parsed.entries[0].offset, 0); + assert_eq!(parsed.entries[0].size, 4096); + assert_eq!(parsed.entries[0].generation, 1); + assert_eq!(parsed.entries[0].checksum, dummy_checksum(0xAA)); + assert_eq!(parsed.entries[0].compression, CompressionAlgorithm::Zstd); + + assert_eq!(parsed.entries[1].name, "photo.jpg"); + assert_eq!(parsed.entries[1].compression, CompressionAlgorithm::None); + + assert_eq!(parsed.free_regions.len(), 1); + assert_eq!(parsed.free_regions[0].offset, 12288); + assert_eq!(parsed.free_regions[0].size, 2048); + + assert_eq!(parsed.next_free_offset, 14336); + assert_eq!(parsed.capacity, 1024 * 1024); + assert_eq!(parsed.next_generation, 3); + } + + #[test] + fn test_segment_index_roundtrip_with_generation() { + let mut idx = SegmentIndex::new(1024); + idx.next_generation = 42; + idx.entries.push( + SegmentEntry::new("gen", 0, 64, 41, dummy_checksum(0), CompressionAlgorithm::None) + .expect("entry"), + ); + let bytes = idx.to_bytes().expect("serialize"); + let parsed = SegmentIndex::from_bytes(&bytes).expect("parse"); + assert_eq!(parsed.next_generation, 42); + assert_eq!(parsed.entries[0].generation, 41); + } + + #[test] + fn test_segment_index_compression_field() { + let mut idx = SegmentIndex::new(1024 * 1024); + for (i, algo) in [CompressionAlgorithm::Zstd, CompressionAlgorithm::Brotli, CompressionAlgorithm::None] + .iter() + .enumerate() + { + idx.entries.push( + SegmentEntry::new( + &format!("seg{i}"), + (i as u64) * 1024, + 1024, + 0, + dummy_checksum(i as u8), + *algo, + ) + .expect("entry"), + ); + } + let bytes = idx.to_bytes().expect("serialize"); + let parsed = SegmentIndex::from_bytes(&bytes).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); + } + + #[test] + fn test_segment_index_free_regions_roundtrip() { + let mut idx = SegmentIndex::new(1024 * 1024); + idx.free_regions.push(FreeRegion { offset: 0, size: 100 }); + idx.free_regions.push(FreeRegion { offset: 500, size: 200 }); + idx.free_regions.push(FreeRegion { offset: 1000, size: 300 }); + let bytes = idx.to_bytes().expect("serialize"); + let parsed = SegmentIndex::from_bytes(&bytes).expect("parse"); + assert_eq!(parsed.free_regions.len(), 3); + assert_eq!(parsed.free_regions[0], FreeRegion { offset: 0, size: 100 }); + assert_eq!(parsed.free_regions[1], FreeRegion { offset: 500, size: 200 }); + assert_eq!(parsed.free_regions[2], FreeRegion { offset: 1000, size: 300 }); + } + + #[test] + fn test_segment_index_padded_size() { + // Empty index + let idx = SegmentIndex::new(1024); + let bytes = idx.to_bytes().expect("serialize"); + assert_eq!(bytes.len(), INDEX_PAD_SIZE); + + // Index with entries + free regions + let idx = make_test_index(); + let bytes = idx.to_bytes().expect("serialize"); + assert_eq!(bytes.len(), INDEX_PAD_SIZE); + } + + #[test] + fn test_segment_index_overflow() { + let mut idx = SegmentIndex::new(u64::MAX); + // Each entry with a 255-byte name uses 2 + 255 + 8 + 8 + 8 + 32 + 1 = 314 bytes. + // Index header is 32 bytes. (65536 - 32) / 314 ≈ 208 entries max. + for i in 0..210 { + let name = format!("{:0>255}", i); + idx.entries.push( + SegmentEntry::new(&name, 0, 64, 0, dummy_checksum(0), CompressionAlgorithm::None) + .expect("entry"), + ); + } + assert!(idx.to_bytes().is_err()); + } + + // -- SegmentIndex lookup / mutation -------------------------------------- + + #[test] + fn test_segment_index_find() { + let idx = make_test_index(); + let found = idx.find("hello.txt"); + assert!(found.is_some()); + assert_eq!(found.expect("find").offset, 0); + + assert!(idx.find("nonexistent").is_none()); + } + + #[test] + fn test_segment_index_remove() { + let mut idx = make_test_index(); + let removed = idx.remove("hello.txt"); + assert!(removed.is_some()); + assert_eq!(removed.expect("remove").name, "hello.txt"); + assert!(idx.find("hello.txt").is_none()); + assert_eq!(idx.entries.len(), 1); + } + + // -- Allocation --------------------------------------------------------- + + #[test] + fn test_allocate_appends_when_no_free_regions() { + let mut idx = SegmentIndex::new(1024); + let off1 = idx.allocate(100).expect("alloc"); + assert_eq!(off1, 0); + assert_eq!(idx.next_free_offset, 100); + + let off2 = idx.allocate(200).expect("alloc"); + assert_eq!(off2, 100); + assert_eq!(idx.next_free_offset, 300); + } + + #[test] + fn test_allocate_reuses_free_region_exact_fit() { + let mut idx = SegmentIndex::new(1024); + idx.free_regions.push(FreeRegion { offset: 0, size: 100 }); + + let off = idx.allocate(100).expect("alloc"); + assert_eq!(off, 0); + assert!(idx.free_regions.is_empty()); + } + + #[test] + fn test_allocate_reuses_free_region_with_split() { + let mut idx = SegmentIndex::new(1024); + idx.free_regions.push(FreeRegion { offset: 0, size: 300 }); + + let off = idx.allocate(100).expect("alloc"); + assert_eq!(off, 0); + assert_eq!(idx.free_regions.len(), 1); + assert_eq!(idx.free_regions[0].offset, 100); + assert_eq!(idx.free_regions[0].size, 200); + } + + #[test] + fn test_allocate_best_fit() { + let mut idx = SegmentIndex::new(1024); + idx.free_regions.push(FreeRegion { offset: 0, size: 500 }); + idx.free_regions.push(FreeRegion { offset: 600, size: 150 }); + idx.free_regions.push(FreeRegion { offset: 800, size: 200 }); + + // Needs 150 — should pick region at 600 (exact fit 150) + let off = idx.allocate(150).expect("alloc"); + assert_eq!(off, 600); + // The 150-byte region was consumed exactly + assert_eq!(idx.free_regions.len(), 2); + } + + #[test] + fn test_allocate_falls_back_to_append() { + let mut idx = SegmentIndex::new(1024); + idx.free_regions.push(FreeRegion { offset: 0, size: 50 }); + idx.next_free_offset = 100; + + // Needs 80 — free region only has 50, so append at 100 + let off = idx.allocate(80).expect("alloc"); + assert_eq!(off, 100); + assert_eq!(idx.next_free_offset, 180); + // Free region untouched + assert_eq!(idx.free_regions.len(), 1); + } + + #[test] + fn test_allocate_vault_full() { + let mut idx = SegmentIndex::new(100); + idx.next_free_offset = 80; + + let err = idx.allocate(50).expect_err("should be VaultFull"); + match err { + CryptoError::VaultFull { needed, available } => { + assert_eq!(needed, 50); + assert_eq!(available, 20); + } + other => panic!("expected VaultFull, got {other:?}"), + } + } + + // -- Deallocation ------------------------------------------------------- + + #[test] + fn test_deallocate_adds_to_free_list() { + let mut idx = SegmentIndex::new(1024); + idx.deallocate(100, 50); + assert_eq!(idx.free_regions.len(), 1); + assert_eq!(idx.free_regions[0], FreeRegion { offset: 100, size: 50 }); + } + + #[test] + fn test_deallocate_merges_adjacent() { + let mut idx = SegmentIndex::new(1024); + idx.deallocate(100, 50); + idx.deallocate(150, 50); + assert_eq!(idx.free_regions.len(), 1); + assert_eq!(idx.free_regions[0], FreeRegion { offset: 100, size: 100 }); + } + + #[test] + fn test_deallocate_no_merge_non_adjacent() { + let mut idx = SegmentIndex::new(1024); + idx.deallocate(100, 50); + idx.deallocate(200, 50); + assert_eq!(idx.free_regions.len(), 2); + } + + #[test] + fn test_deallocate_triple_merge() { + let mut idx = SegmentIndex::new(1024); + // Free A, C first (gap at B), then free B to trigger triple merge + idx.deallocate(0, 100); + idx.deallocate(200, 100); + assert_eq!(idx.free_regions.len(), 2); + + // Free the middle gap + idx.deallocate(100, 100); + assert_eq!(idx.free_regions.len(), 1); + assert_eq!(idx.free_regions[0], FreeRegion { offset: 0, size: 300 }); + } + + #[test] + fn test_allocate_after_deallocate() { + let mut idx = SegmentIndex::new(1024); + // Allocate then free a region + let off = idx.allocate(200).expect("alloc"); + assert_eq!(off, 0); + idx.deallocate(0, 200); + + // Next allocation should reuse the freed space + let off = idx.allocate(200).expect("realloc"); + assert_eq!(off, 0); + assert!(idx.free_regions.is_empty()); + } + + // -- Generation counter ------------------------------------------------- + + #[test] + fn test_generation_counter_increments() { + let mut idx = SegmentIndex::new(1024); + assert_eq!(idx.next_gen(), 0); + assert_eq!(idx.next_gen(), 1); + assert_eq!(idx.next_gen(), 2); + } + + // -- Layout constants --------------------------------------------------- + + #[test] + fn test_layout_offsets() { + // Primary index immediately after header + assert_eq!(PRIMARY_INDEX_OFFSET, VAULT_HEADER_SIZE as u64); + + // Data region after primary index + assert_eq!(DATA_REGION_OFFSET, PRIMARY_INDEX_OFFSET + ENCRYPTED_INDEX_SIZE as u64); + + let cap = 10 * 1024 * 1024; // 10MB + let shadow = shadow_index_offset(cap).expect("shadow"); + let wal = wal_region_offset(cap).expect("wal"); + + // Shadow index after data region + assert_eq!(shadow, DATA_REGION_OFFSET + cap); + + // WAL after shadow index + assert_eq!(wal, shadow + ENCRYPTED_INDEX_SIZE as u64); + + // No overlapping regions + assert!(shadow > DATA_REGION_OFFSET); + assert!(wal > shadow); + } + + #[test] + fn test_layout_overflow() { + assert!(shadow_index_offset(u64::MAX).is_err()); + assert!(wal_region_offset(u64::MAX).is_err()); + } + + // -- Zero-size allocation ----------------------------------------------- + + #[test] + fn test_allocate_zero_rejected() { + let mut idx = SegmentIndex::new(1024); + assert!(idx.allocate(0).is_err()); + } + + // -- Duplicate names ---------------------------------------------------- + + #[test] + fn test_add_duplicate_name_rejected() { + let mut idx = SegmentIndex::new(1024); + let e1 = SegmentEntry::new("dup", 0, 64, 0, dummy_checksum(0), CompressionAlgorithm::None) + .expect("entry"); + let e2 = SegmentEntry::new("dup", 64, 64, 1, dummy_checksum(1), CompressionAlgorithm::None) + .expect("entry"); + idx.add(e1).expect("first add"); + assert!(idx.add(e2).is_err()); + assert_eq!(idx.entries.len(), 1); + } +} diff --git a/rust/src/core/evfs/mod.rs b/rust/src/core/evfs/mod.rs new file mode 100644 index 0000000..b37f6b9 --- /dev/null +++ b/rust/src/core/evfs/mod.rs @@ -0,0 +1,9 @@ +//! EVFS internal types — format, segment crypto, WAL, and file locking. +//! +//! These modules live under `core/` (not `api/`) so that FRB codegen +//! does not scan them. Only the public vault API in `api/evfs/` is +//! exposed to Flutter. + +pub mod format; +pub mod segment; +pub mod wal; diff --git a/rust/src/core/evfs/segment.rs b/rust/src/core/evfs/segment.rs new file mode 100644 index 0000000..3c0efa8 --- /dev/null +++ b/rust/src/core/evfs/segment.rs @@ -0,0 +1,909 @@ +//! Per-segment encryption, integrity, and secure deletion. + +use hkdf::Hkdf; +use sha2::Sha256; +use subtle::ConstantTimeEq; +use zeroize::Zeroize; + +use crate::core::error::CryptoError; +use crate::core::format::Algorithm; +use crate::core::secret::SecretBuffer; + +#[cfg(feature = "compression")] +use crate::api::compression::{self, CompressionAlgorithm, CompressionConfig}; + +use std::io::{Seek, SeekFrom, Write}; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const CIPHER_KEY_INFO: &[u8] = b"msec-vault-cipher-key"; +const NONCE_KEY_INFO: &[u8] = b"msec-vault-nonce-key"; +const INDEX_KEY_INFO: &[u8] = b"msec-vault-index-key"; +const INDEX_NONCE_INFO: &[u8] = b"msec-vault-index-nonce"; + +const SUB_KEY_LEN: usize = 32; +const NONCE_LEN: usize = 12; +const TAG_LEN: usize = 16; + +/// Chunk size for streaming pre-allocation and secure erase (64KB). +const IO_CHUNK_SIZE: usize = 64 * 1024; + +// --------------------------------------------------------------------------- +// VaultKeys +// --------------------------------------------------------------------------- + +/// Derived sub-keys from the master key. All fields use SecretBuffer (ZeroizeOnDrop). +pub struct VaultKeys { + pub cipher_key: SecretBuffer, + pub nonce_key: SecretBuffer, + pub index_key: SecretBuffer, +} + +/// Derive three domain-separated sub-keys from a master key via HKDF-SHA256. +pub fn derive_vault_keys(master_key: &[u8]) -> Result { + if master_key.is_empty() { + return Err(CryptoError::InvalidKeyLength { + expected: 32, + actual: 0, + }); + } + let hk = Hkdf::::new(None, master_key); + + let mut cipher_key = SecretBuffer::from_size(SUB_KEY_LEN); + hk.expand(CIPHER_KEY_INFO, cipher_key.as_bytes_mut()) + .map_err(|_| CryptoError::KdfFailed("HKDF expand failed for cipher_key".into()))?; + + let mut nonce_key = SecretBuffer::from_size(SUB_KEY_LEN); + hk.expand(NONCE_KEY_INFO, nonce_key.as_bytes_mut()) + .map_err(|_| CryptoError::KdfFailed("HKDF expand failed for nonce_key".into()))?; + + let mut index_key = SecretBuffer::from_size(SUB_KEY_LEN); + hk.expand(INDEX_KEY_INFO, index_key.as_bytes_mut()) + .map_err(|_| CryptoError::KdfFailed("HKDF expand failed for index_key".into()))?; + + Ok(VaultKeys { + cipher_key, + nonce_key, + index_key, + }) +} + +// --------------------------------------------------------------------------- +// Nonce derivation +// --------------------------------------------------------------------------- + +/// Derive a deterministic nonce for a segment. +/// +/// `info = segment_index(LE) || generation(LE)` +/// +/// Including the generation counter ensures overwriting a segment at +/// the same index always produces a different nonce. +pub fn derive_segment_nonce( + nonce_key: &[u8], + segment_index: u64, + generation: u64, + nonce_len: usize, +) -> Result, CryptoError> { + let hk = Hkdf::::from_prk(nonce_key) + .map_err(|_| CryptoError::KdfFailed("nonce_key too short for HKDF-PRK".into()))?; + + let mut info = [0u8; 16]; + info[..8].copy_from_slice(&segment_index.to_le_bytes()); + info[8..].copy_from_slice(&generation.to_le_bytes()); + + let mut nonce = vec![0u8; nonce_len]; + hk.expand(&info, &mut nonce) + .map_err(|_| CryptoError::KdfFailed("HKDF expand failed for nonce".into()))?; + Ok(nonce) +} + +// --------------------------------------------------------------------------- +// AEAD helpers (algorithm dispatch) +// --------------------------------------------------------------------------- + +fn aead_encrypt( + key: &[u8], + nonce: &[u8], + plaintext: &[u8], + aad: &[u8], + algorithm: Algorithm, +) -> Result, CryptoError> { + match algorithm { + Algorithm::AesGcm => aead_encrypt_aes_gcm(key, nonce, plaintext, aad), + Algorithm::ChaCha20Poly1305 => aead_encrypt_chacha(key, nonce, plaintext, aad), + Algorithm::XChaCha20Poly1305 => Err(CryptoError::InvalidParameter( + "XChaCha20-Poly1305 not yet supported for EVFS".into(), + )), + } +} + +fn aead_decrypt( + key: &[u8], + nonce: &[u8], + ciphertext: &[u8], + aad: &[u8], + algorithm: Algorithm, +) -> Result, CryptoError> { + match algorithm { + Algorithm::AesGcm => aead_decrypt_aes_gcm(key, nonce, ciphertext, aad), + Algorithm::ChaCha20Poly1305 => aead_decrypt_chacha(key, nonce, ciphertext, aad), + Algorithm::XChaCha20Poly1305 => Err(CryptoError::InvalidParameter( + "XChaCha20-Poly1305 not yet supported for EVFS".into(), + )), + } +} + +fn aead_encrypt_aes_gcm( + key: &[u8], + nonce: &[u8], + plaintext: &[u8], + aad: &[u8], +) -> Result, CryptoError> { + use aes_gcm::aead::{Aead, KeyInit, Payload}; + use aes_gcm::Aes256Gcm; + + let cipher = Aes256Gcm::new_from_slice(key) + .map_err(|e| CryptoError::EncryptionFailed(e.to_string()))?; + let nonce = aes_gcm::Nonce::from_slice(nonce); + let payload = Payload { msg: plaintext, aad }; + cipher + .encrypt(nonce, payload) + .map_err(|e| CryptoError::EncryptionFailed(e.to_string())) +} + +fn aead_decrypt_aes_gcm( + key: &[u8], + nonce: &[u8], + ciphertext: &[u8], + aad: &[u8], +) -> Result, CryptoError> { + use aes_gcm::aead::{Aead, KeyInit, Payload}; + use aes_gcm::Aes256Gcm; + + let cipher = + Aes256Gcm::new_from_slice(key).map_err(|_| CryptoError::DecryptionFailed)?; + let nonce = aes_gcm::Nonce::from_slice(nonce); + let payload = Payload { + msg: ciphertext, + aad, + }; + cipher + .decrypt(nonce, payload) + .map_err(|_| CryptoError::AuthenticationFailed) +} + +fn aead_encrypt_chacha( + key: &[u8], + nonce: &[u8], + plaintext: &[u8], + aad: &[u8], +) -> Result, CryptoError> { + use chacha20poly1305::aead::{Aead, KeyInit, Payload}; + use chacha20poly1305::ChaCha20Poly1305; + + let cipher = ChaCha20Poly1305::new_from_slice(key) + .map_err(|e| CryptoError::EncryptionFailed(e.to_string()))?; + let nonce = chacha20poly1305::Nonce::from_slice(nonce); + let payload = Payload { msg: plaintext, aad }; + cipher + .encrypt(nonce, payload) + .map_err(|e| CryptoError::EncryptionFailed(e.to_string())) +} + +fn aead_decrypt_chacha( + key: &[u8], + nonce: &[u8], + ciphertext: &[u8], + aad: &[u8], +) -> Result, CryptoError> { + use chacha20poly1305::aead::{Aead, KeyInit, Payload}; + use chacha20poly1305::ChaCha20Poly1305; + + let cipher = ChaCha20Poly1305::new_from_slice(key) + .map_err(|_| CryptoError::DecryptionFailed)?; + let nonce = chacha20poly1305::Nonce::from_slice(nonce); + let payload = Payload { + msg: ciphertext, + aad, + }; + cipher + .decrypt(nonce, payload) + .map_err(|_| CryptoError::AuthenticationFailed) +} + +// --------------------------------------------------------------------------- +// AEAD helpers for vault API (random nonce, stored nonce) +// --------------------------------------------------------------------------- + +/// Encrypt with a random nonce. Returns `nonce || ciphertext || tag`. +pub fn aead_encrypt_random_nonce( + key: &[u8], + plaintext: &[u8], + aad: &[u8], + algorithm: Algorithm, +) -> Result, CryptoError> { + use rand::{rngs::OsRng, RngCore}; + let mut nonce = vec![0u8; NONCE_LEN]; + OsRng.fill_bytes(&mut nonce); + let ct_tag = aead_encrypt(key, &nonce, plaintext, aad, algorithm)?; + let mut output = Vec::with_capacity(NONCE_LEN + ct_tag.len()); + output.extend_from_slice(&nonce); + output.extend_from_slice(&ct_tag); + Ok(output) +} + +/// Decrypt data where the nonce is stored as a prefix. +/// Input: `nonce || ciphertext || tag`. +pub fn aead_decrypt_with_stored_nonce( + key: &[u8], + encrypted: &[u8], + aad: &[u8], + algorithm: Algorithm, +) -> Result, CryptoError> { + if encrypted.len() < NONCE_LEN + TAG_LEN { + return Err(CryptoError::AuthenticationFailed); + } + let (nonce, ct_tag) = encrypted.split_at(NONCE_LEN); + aead_decrypt(key, nonce, ct_tag, aad, algorithm) +} + +// --------------------------------------------------------------------------- +// Segment encrypt / decrypt +// --------------------------------------------------------------------------- + +/// Parameters for segment encrypt/decrypt operations. +pub struct SegmentCryptoParams<'a> { + pub cipher_key: &'a [u8], + pub nonce_key: &'a [u8], + pub algorithm: Algorithm, + pub segment_index: u64, + pub generation: u64, +} + +/// Compress-then-encrypt a segment's plaintext data. +/// +/// Returns `(nonce || ciphertext || tag, effective_compression_algorithm)`. +/// +/// BLAKE3 checksum should be computed by the caller on the original plaintext +/// **before** calling this function. +#[cfg(feature = "compression")] +pub fn encrypt_segment( + params: &SegmentCryptoParams<'_>, + plaintext: &[u8], + segment_name: &str, + compression: &CompressionConfig, +) -> Result<(Vec, CompressionAlgorithm), CryptoError> { + // MIME-aware skip + let effective_algo = if compression.algorithm != CompressionAlgorithm::None + && compression::should_skip_compression(segment_name) + { + CompressionAlgorithm::None + } else { + compression.algorithm + }; + + // Compress + let mut data = if effective_algo != CompressionAlgorithm::None { + compression::compress( + plaintext, + &CompressionConfig { + algorithm: effective_algo, + level: compression.level, + }, + )? + } else { + plaintext.to_vec() + }; + + // Derive nonce and encrypt + let nonce = derive_segment_nonce( + params.nonce_key, + params.segment_index, + params.generation, + NONCE_LEN, + )?; + let result = aead_encrypt(params.cipher_key, &nonce, &data, &[], params.algorithm); + data.zeroize(); + let ct_tag = result?; + + // Wire format: nonce || ciphertext || tag + let mut output = Vec::with_capacity(NONCE_LEN + ct_tag.len()); + output.extend_from_slice(&nonce); + output.extend_from_slice(&ct_tag); + + Ok((output, effective_algo)) +} + +/// Decrypt-then-decompress a segment's encrypted data. +/// +/// The `compression` argument comes from `SegmentEntry.compression`. +#[cfg(feature = "compression")] +pub fn decrypt_segment( + params: &SegmentCryptoParams<'_>, + encrypted: &[u8], + compression: CompressionAlgorithm, +) -> Result, CryptoError> { + if encrypted.len() < NONCE_LEN + TAG_LEN { + return Err(CryptoError::AuthenticationFailed); + } + + let (stored_nonce, ct_tag) = encrypted.split_at(NONCE_LEN); + + // Derive expected nonce and verify it matches + let expected_nonce = derive_segment_nonce( + params.nonce_key, + params.segment_index, + params.generation, + NONCE_LEN, + )?; + if stored_nonce.ct_ne(&expected_nonce).into() { + return Err(CryptoError::AuthenticationFailed); + } + + let decrypted = aead_decrypt(params.cipher_key, stored_nonce, ct_tag, &[], params.algorithm)?; + + // Decompress if needed + if compression != CompressionAlgorithm::None { + compression::decompress(&decrypted, compression) + } else { + Ok(decrypted) + } +} + +// --------------------------------------------------------------------------- +// Index encrypt / decrypt +// --------------------------------------------------------------------------- + +/// Encrypt the segment index using the index sub-key. +/// +/// `generation` is the `SegmentIndex.next_generation` value at the time of +/// writing. It is included in nonce derivation so that every index write +/// produces a unique nonce (preventing nonce reuse on repeated saves). +pub fn encrypt_index( + index_key: &[u8], + algorithm: Algorithm, + generation: u64, + plaintext: &[u8], +) -> Result, CryptoError> { + let nonce = derive_index_nonce(index_key, generation)?; + let ct_tag = aead_encrypt(index_key, &nonce, plaintext, &[], algorithm)?; + + let mut output = Vec::with_capacity(NONCE_LEN + ct_tag.len()); + output.extend_from_slice(&nonce); + output.extend_from_slice(&ct_tag); + Ok(output) +} + +/// Decrypt the segment index using the index sub-key. +/// +/// `generation` must match the value used during encryption. +pub fn decrypt_index( + index_key: &[u8], + algorithm: Algorithm, + generation: u64, + encrypted: &[u8], +) -> Result, CryptoError> { + if encrypted.len() < NONCE_LEN + TAG_LEN { + return Err(CryptoError::AuthenticationFailed); + } + let (stored_nonce, ct_tag) = encrypted.split_at(NONCE_LEN); + + // Verify nonce matches the expected derivation + let expected_nonce = derive_index_nonce(index_key, generation)?; + if stored_nonce.ct_ne(&expected_nonce).into() { + return Err(CryptoError::AuthenticationFailed); + } + + aead_decrypt(index_key, stored_nonce, ct_tag, &[], algorithm) +} + +fn derive_index_nonce(index_key: &[u8], generation: u64) -> Result, CryptoError> { + let hk = Hkdf::::from_prk(index_key) + .map_err(|_| CryptoError::KdfFailed("index_key too short for HKDF-PRK".into()))?; + // info = fixed domain || generation(LE) + let mut info = Vec::with_capacity(INDEX_NONCE_INFO.len() + 8); + info.extend_from_slice(INDEX_NONCE_INFO); + info.extend_from_slice(&generation.to_le_bytes()); + let mut nonce = vec![0u8; NONCE_LEN]; + hk.expand(&info, &mut nonce) + .map_err(|_| CryptoError::KdfFailed("HKDF expand failed for index nonce".into()))?; + Ok(nonce) +} + +// --------------------------------------------------------------------------- +// BLAKE3 checksums +// --------------------------------------------------------------------------- + +/// Compute BLAKE3 checksum of plaintext data (pre-compression). +pub fn compute_checksum(data: &[u8]) -> [u8; 32] { + blake3::hash(data).into() +} + +/// Verify BLAKE3 checksum using constant-time comparison. +pub fn verify_checksum(data: &[u8], expected: &[u8; 32]) -> bool { + let actual = compute_checksum(data); + actual.ct_eq(expected).into() +} + +// --------------------------------------------------------------------------- +// Pre-allocation and secure erase +// --------------------------------------------------------------------------- + +/// Pre-allocate a vault file filled with CSPRNG random data. +/// +/// Writes in 64KB chunks to keep memory constant for large vaults. +pub fn preallocate_vault( + file: &mut std::fs::File, + total_size: u64, +) -> Result<(), CryptoError> { + use rand::{rngs::OsRng, RngCore}; + + let mut remaining = total_size; + let mut buf = vec![0u8; IO_CHUNK_SIZE]; + + while remaining > 0 { + let chunk_len = std::cmp::min(remaining, IO_CHUNK_SIZE as u64) as usize; + OsRng.fill_bytes(&mut buf[..chunk_len]); + file.write_all(&buf[..chunk_len])?; + remaining -= chunk_len as u64; + } + file.sync_all()?; + Ok(()) +} + +/// Securely erase a region of the vault file by overwriting with CSPRNG bytes. +/// +/// Writes random data in 64KB chunks, then fsyncs. +pub fn secure_erase_region( + file: &mut std::fs::File, + offset: u64, + size: u64, +) -> Result<(), CryptoError> { + use rand::{rngs::OsRng, RngCore}; + + file.seek(SeekFrom::Start(offset))?; + + let mut remaining = size; + let mut buf = vec![0u8; IO_CHUNK_SIZE]; + + while remaining > 0 { + let chunk_len = std::cmp::min(remaining, IO_CHUNK_SIZE as u64) as usize; + OsRng.fill_bytes(&mut buf[..chunk_len]); + file.write_all(&buf[..chunk_len])?; + remaining -= chunk_len as u64; + } + file.sync_all()?; + Ok(()) +} + +// =========================================================================== +// Tests +// =========================================================================== + +#[cfg(test)] +mod tests { + use super::*; + + fn test_master_key() -> Vec { + vec![0xAA; 32] + } + + fn other_master_key() -> Vec { + vec![0xBB; 32] + } + + // -- Key derivation ----------------------------------------------------- + + #[test] + fn test_derive_vault_keys_empty_master_rejected() { + assert!(derive_vault_keys(&[]).is_err()); + } + + #[test] + fn test_derive_vault_keys_deterministic() { + let k1 = derive_vault_keys(&test_master_key()).expect("derive"); + let k2 = derive_vault_keys(&test_master_key()).expect("derive"); + assert_eq!(k1.cipher_key.as_bytes(), k2.cipher_key.as_bytes()); + assert_eq!(k1.nonce_key.as_bytes(), k2.nonce_key.as_bytes()); + assert_eq!(k1.index_key.as_bytes(), k2.index_key.as_bytes()); + } + + #[test] + fn test_derive_vault_keys_domain_separation() { + let keys = derive_vault_keys(&test_master_key()).expect("derive"); + assert_ne!(keys.cipher_key.as_bytes(), keys.nonce_key.as_bytes()); + assert_ne!(keys.cipher_key.as_bytes(), keys.index_key.as_bytes()); + assert_ne!(keys.nonce_key.as_bytes(), keys.index_key.as_bytes()); + } + + #[test] + fn test_derive_vault_keys_different_master() { + let k1 = derive_vault_keys(&test_master_key()).expect("derive"); + let k2 = derive_vault_keys(&other_master_key()).expect("derive"); + assert_ne!(k1.cipher_key.as_bytes(), k2.cipher_key.as_bytes()); + assert_ne!(k1.nonce_key.as_bytes(), k2.nonce_key.as_bytes()); + assert_ne!(k1.index_key.as_bytes(), k2.index_key.as_bytes()); + } + + // -- Nonce derivation --------------------------------------------------- + + #[test] + fn test_nonce_deterministic() { + let keys = derive_vault_keys(&test_master_key()).expect("derive"); + let n1 = derive_segment_nonce(keys.nonce_key.as_bytes(), 0, 0, NONCE_LEN).expect("nonce"); + let n2 = derive_segment_nonce(keys.nonce_key.as_bytes(), 0, 0, NONCE_LEN).expect("nonce"); + assert_eq!(n1, n2); + } + + #[test] + fn test_nonce_unique_by_index() { + let keys = derive_vault_keys(&test_master_key()).expect("derive"); + let n0 = derive_segment_nonce(keys.nonce_key.as_bytes(), 0, 0, NONCE_LEN).expect("nonce"); + let n1 = derive_segment_nonce(keys.nonce_key.as_bytes(), 1, 0, NONCE_LEN).expect("nonce"); + assert_ne!(n0, n1); + } + + #[test] + fn test_nonce_unique_by_generation() { + let keys = derive_vault_keys(&test_master_key()).expect("derive"); + let n0 = derive_segment_nonce(keys.nonce_key.as_bytes(), 0, 0, NONCE_LEN).expect("nonce"); + let n1 = derive_segment_nonce(keys.nonce_key.as_bytes(), 0, 1, NONCE_LEN).expect("nonce"); + assert_ne!(n0, n1); + } + + #[test] + fn test_nonce_length_12() { + let keys = derive_vault_keys(&test_master_key()).expect("derive"); + let nonce = + derive_segment_nonce(keys.nonce_key.as_bytes(), 0, 0, NONCE_LEN).expect("nonce"); + assert_eq!(nonce.len(), 12); + } + + // -- Segment encrypt/decrypt (no compression) --------------------------- + + #[cfg(feature = "compression")] + fn no_compression_config() -> CompressionConfig { + CompressionConfig { + algorithm: CompressionAlgorithm::None, + level: None, + } + } + + #[cfg(feature = "compression")] + fn params<'a>( + keys: &'a VaultKeys, + algorithm: Algorithm, + segment_index: u64, + generation: u64, + ) -> SegmentCryptoParams<'a> { + SegmentCryptoParams { + cipher_key: keys.cipher_key.as_bytes(), + nonce_key: keys.nonce_key.as_bytes(), + algorithm, + segment_index, + generation, + } + } + + #[cfg(feature = "compression")] + #[test] + fn test_segment_encrypt_decrypt_roundtrip() { + let keys = derive_vault_keys(&test_master_key()).expect("derive"); + let plaintext = b"hello vault segment"; + let p = params(&keys, Algorithm::AesGcm, 0, 0); + + let (encrypted, effective) = + encrypt_segment(&p, plaintext, "test.txt", &no_compression_config()) + .expect("encrypt"); + assert_eq!(effective, CompressionAlgorithm::None); + + let decrypted = + decrypt_segment(&p, &encrypted, CompressionAlgorithm::None).expect("decrypt"); + assert_eq!(decrypted, plaintext); + } + + #[cfg(feature = "compression")] + #[test] + fn test_segment_wrong_generation_fails() { + let keys = derive_vault_keys(&test_master_key()).expect("derive"); + let p = params(&keys, Algorithm::AesGcm, 0, 0); + + let (encrypted, _) = + encrypt_segment(&p, b"data", "test.txt", &no_compression_config()).expect("encrypt"); + + // Decrypt with wrong generation + let wrong_p = params(&keys, Algorithm::AesGcm, 0, 1); + let result = decrypt_segment(&wrong_p, &encrypted, CompressionAlgorithm::None); + assert!(result.is_err()); + } + + #[cfg(feature = "compression")] + #[test] + fn test_segment_wrong_key_fails() { + let keys = derive_vault_keys(&test_master_key()).expect("derive"); + let wrong_keys = derive_vault_keys(&other_master_key()).expect("derive"); + let p = params(&keys, Algorithm::AesGcm, 0, 0); + + let (encrypted, _) = + encrypt_segment(&p, b"secret", "test.txt", &no_compression_config()).expect("encrypt"); + + let wrong_p = params(&wrong_keys, Algorithm::AesGcm, 0, 0); + let result = decrypt_segment(&wrong_p, &encrypted, CompressionAlgorithm::None); + assert!(result.is_err()); + } + + #[cfg(feature = "compression")] + #[test] + fn test_segment_tampered_ciphertext_fails() { + let keys = derive_vault_keys(&test_master_key()).expect("derive"); + let p = params(&keys, Algorithm::AesGcm, 0, 0); + + let (mut encrypted, _) = + encrypt_segment(&p, b"data", "test.txt", &no_compression_config()).expect("encrypt"); + encrypted[NONCE_LEN + 1] ^= 0xFF; + + let result = decrypt_segment(&p, &encrypted, CompressionAlgorithm::None); + assert!(result.is_err()); + } + + // -- Segment compress+encrypt/decrypt+decompress ------------------------ + + #[cfg(feature = "compression")] + #[test] + fn test_segment_zstd_roundtrip() { + let keys = derive_vault_keys(&test_master_key()).expect("derive"); + let plaintext = b"compressible data repeated ".repeat(100); + let config = CompressionConfig { + algorithm: CompressionAlgorithm::Zstd, + level: None, + }; + let p = params(&keys, Algorithm::ChaCha20Poly1305, 5, 3); + + let (encrypted, effective) = + encrypt_segment(&p, &plaintext, "data.txt", &config).expect("encrypt"); + assert_eq!(effective, CompressionAlgorithm::Zstd); + + let decrypted = + decrypt_segment(&p, &encrypted, CompressionAlgorithm::Zstd).expect("decrypt"); + assert_eq!(decrypted, plaintext); + } + + #[cfg(feature = "compression")] + #[test] + fn test_segment_brotli_roundtrip() { + let keys = derive_vault_keys(&test_master_key()).expect("derive"); + let plaintext = b"brotli test data ".repeat(80); + let config = CompressionConfig { + algorithm: CompressionAlgorithm::Brotli, + level: None, + }; + let p = params(&keys, Algorithm::AesGcm, 2, 1); + + let (encrypted, effective) = + encrypt_segment(&p, &plaintext, "notes.txt", &config).expect("encrypt"); + assert_eq!(effective, CompressionAlgorithm::Brotli); + + let decrypted = + decrypt_segment(&p, &encrypted, CompressionAlgorithm::Brotli).expect("decrypt"); + assert_eq!(decrypted, plaintext); + } + + #[cfg(feature = "compression")] + #[test] + fn test_segment_mime_skip() { + let keys = derive_vault_keys(&test_master_key()).expect("derive"); + let plaintext = b"fake jpeg data"; + let config = CompressionConfig { + algorithm: CompressionAlgorithm::Zstd, + level: None, + }; + let p = params(&keys, Algorithm::AesGcm, 0, 0); + + let (encrypted, effective) = + encrypt_segment(&p, plaintext, "photo.jpg", &config).expect("encrypt"); + assert_eq!(effective, CompressionAlgorithm::None); + + let decrypted = + decrypt_segment(&p, &encrypted, CompressionAlgorithm::None).expect("decrypt"); + assert_eq!(decrypted, plaintext); + } + + #[cfg(feature = "compression")] + #[test] + fn test_segment_compressed_smaller() { + let keys = derive_vault_keys(&test_master_key()).expect("derive"); + let plaintext = b"aaaa".repeat(1000); + + let p0 = params(&keys, Algorithm::AesGcm, 0, 0); + let (compressed_enc, _) = encrypt_segment( + &p0, + &plaintext, + "text.txt", + &CompressionConfig { + algorithm: CompressionAlgorithm::Zstd, + level: None, + }, + ) + .expect("compress+encrypt"); + + let p1 = params(&keys, Algorithm::AesGcm, 1, 0); + let (uncompressed_enc, _) = + encrypt_segment(&p1, &plaintext, "text.txt", &no_compression_config()) + .expect("encrypt only"); + + assert!(compressed_enc.len() < uncompressed_enc.len()); + } + + // -- Index encryption --------------------------------------------------- + + #[test] + fn test_index_encrypt_decrypt_roundtrip() { + let keys = derive_vault_keys(&test_master_key()).expect("derive"); + let plaintext = b"index payload bytes for testing roundtrip"; + + let encrypted = + encrypt_index(keys.index_key.as_bytes(), Algorithm::AesGcm, 0, plaintext) + .expect("encrypt"); + + let decrypted = + decrypt_index(keys.index_key.as_bytes(), Algorithm::AesGcm, 0, &encrypted) + .expect("decrypt"); + + assert_eq!(decrypted, plaintext); + } + + #[test] + fn test_index_wrong_generation_fails() { + let keys = derive_vault_keys(&test_master_key()).expect("derive"); + let plaintext = b"index data"; + + let encrypted = + encrypt_index(keys.index_key.as_bytes(), Algorithm::AesGcm, 5, plaintext) + .expect("encrypt"); + + // Decrypt with wrong generation + let result = decrypt_index(keys.index_key.as_bytes(), Algorithm::AesGcm, 6, &encrypted); + assert!(result.is_err()); + } + + #[test] + fn test_index_nonce_unique_per_generation() { + let keys = derive_vault_keys(&test_master_key()).expect("derive"); + let plaintext = b"same index data"; + + let enc0 = + encrypt_index(keys.index_key.as_bytes(), Algorithm::AesGcm, 0, plaintext) + .expect("encrypt gen 0"); + let enc1 = + encrypt_index(keys.index_key.as_bytes(), Algorithm::AesGcm, 1, plaintext) + .expect("encrypt gen 1"); + + // Nonce (first 12 bytes) must differ + assert_ne!(&enc0[..NONCE_LEN], &enc1[..NONCE_LEN]); + } + + // -- Checksums ---------------------------------------------------------- + + #[test] + fn test_checksum_roundtrip() { + let data = b"hello checksum"; + let checksum = compute_checksum(data); + assert!(verify_checksum(data, &checksum)); + } + + #[test] + fn test_checksum_tampered() { + let data = b"original data"; + let checksum = compute_checksum(data); + let mut tampered = data.to_vec(); + tampered[0] ^= 0xFF; + assert!(!verify_checksum(&tampered, &checksum)); + } + + #[cfg(feature = "compression")] + #[test] + fn test_checksum_covers_original_plaintext() { + let plaintext = b"checksum covers pre-compression data ".repeat(50); + let checksum = compute_checksum(&plaintext); + + // Compress the data + let compressed = compression::compress( + &plaintext, + &CompressionConfig { + algorithm: CompressionAlgorithm::Zstd, + level: None, + }, + ) + .expect("compress"); + + // Checksum should NOT match the compressed form + assert!(!verify_checksum(&compressed, &checksum)); + + // Decompress and verify against original plaintext + let decompressed = + compression::decompress(&compressed, CompressionAlgorithm::Zstd).expect("decompress"); + assert!(verify_checksum(&decompressed, &checksum)); + } + + // -- Pre-allocation and secure erase ------------------------------------ + + #[test] + fn test_preallocate_size() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("vault.test"); + let mut file = std::fs::File::create(&path).expect("create"); + + preallocate_vault(&mut file, 4096).expect("preallocate"); + + let meta = std::fs::metadata(&path).expect("metadata"); + assert_eq!(meta.len(), 4096); + } + + #[test] + fn test_preallocate_random_fill() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("vault.test"); + let mut file = std::fs::File::create(&path).expect("create"); + + preallocate_vault(&mut file, 4096).expect("preallocate"); + + let data = std::fs::read(&path).expect("read"); + // Extremely unlikely all zeros if filled with CSPRNG + assert!(data.iter().any(|&b| b != 0)); + } + + #[test] + fn test_preallocate_streaming() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("vault_10mb.test"); + let mut file = std::fs::File::create(&path).expect("create"); + + let size = 10 * 1024 * 1024; // 10MB + preallocate_vault(&mut file, size).expect("preallocate 10MB"); + + let meta = std::fs::metadata(&path).expect("metadata"); + assert_eq!(meta.len(), size); + } + + #[test] + fn test_secure_erase_region() { + use std::io::Read; + + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("erase.test"); + + // Write known pattern + let pattern = vec![0xAA; 1024]; + std::fs::write(&path, &pattern).expect("write"); + + // Erase a region in the middle + let mut file = std::fs::OpenOptions::new() + .write(true) + .read(true) + .open(&path) + .expect("open"); + + secure_erase_region(&mut file, 256, 512).expect("erase"); + + // Read back the erased region + file.seek(SeekFrom::Start(256)).expect("seek"); + let mut erased = vec![0u8; 512]; + file.read_exact(&mut erased).expect("read"); + + // Should no longer be the original 0xAA pattern + assert_ne!(erased, vec![0xAA; 512]); + + // Regions outside the erased area should be untouched + file.seek(SeekFrom::Start(0)).expect("seek"); + let mut before = vec![0u8; 256]; + file.read_exact(&mut before).expect("read"); + assert_eq!(before, vec![0xAA; 256]); + + file.seek(SeekFrom::Start(768)).expect("seek"); + let mut after = vec![0u8; 256]; + file.read_exact(&mut after).expect("read"); + assert_eq!(after, vec![0xAA; 256]); + } +} diff --git a/rust/src/core/evfs/wal.rs b/rust/src/core/evfs/wal.rs new file mode 100644 index 0000000..76b9a39 --- /dev/null +++ b/rust/src/core/evfs/wal.rs @@ -0,0 +1,650 @@ +//! Write-ahead log, crash recovery, and file locking. + +use crate::core::error::CryptoError; +use fs4::fs_std::FileExt; +use std::fs::File; +use std::io::{Read, Seek, SeekFrom, Write}; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/// WAL entry header: op(1) + data_len(4) = 5 bytes. +const ENTRY_HEADER_SIZE: usize = 5; + +/// WAL entry footer: crc32(4) + committed(1) = 5 bytes. +const ENTRY_FOOTER_SIZE: usize = 5; + +/// Max snapshot size (256KB). The encrypted index is ~65KB; this leaves +/// generous headroom while rejecting clearly corrupt `data_len` values +/// that would cause OOM allocations. +const MAX_SNAPSHOT_SIZE: usize = 256 * 1024; + +// --------------------------------------------------------------------------- +// WalOp +// --------------------------------------------------------------------------- + +/// WAL operation types (for logging/diagnostics). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum WalOp { + WriteSegment = 0x01, + DeleteSegment = 0x02, + UpdateIndex = 0x03, +} + +impl WalOp { + pub fn from_byte(b: u8) -> Result { + match b { + 0x01 => Ok(WalOp::WriteSegment), + 0x02 => Ok(WalOp::DeleteSegment), + 0x03 => Ok(WalOp::UpdateIndex), + _ => Err(CryptoError::VaultCorrupted(format!( + "unknown WAL op: 0x{b:02X}" + ))), + } + } +} + +// --------------------------------------------------------------------------- +// WalEntry +// --------------------------------------------------------------------------- + +/// A single WAL journal entry — undo record. +/// +/// On-disk layout: +/// `[op: u8] [data_len: u32 LE] [data: bytes] [crc32: u32 LE] [committed: u8]` +/// +/// CRC32 covers `op || data_len || data` (everything before the CRC field). +#[derive(Debug, Clone)] +pub struct WalEntry { + pub op: WalOp, + /// Encrypted index bytes captured before the mutation. + pub old_index_snapshot: Vec, + pub crc: u32, + pub committed: bool, +} + +impl WalEntry { + /// Create a new uncommitted WAL entry. CRC is computed automatically. + pub fn new(op: WalOp, old_index_snapshot: Vec) -> Self { + let crc = Self::compute_crc(op, &old_index_snapshot); + Self { + op, + old_index_snapshot, + crc, + committed: false, + } + } + + /// Serialize the entry for on-disk storage. + pub fn to_bytes(&self) -> Result, CryptoError> { + let data_len = u32::try_from(self.old_index_snapshot.len()).map_err(|_| { + CryptoError::InvalidParameter("WAL entry data too large for u32 length".into()) + })?; + + let total = ENTRY_HEADER_SIZE + self.old_index_snapshot.len() + ENTRY_FOOTER_SIZE; + let mut buf = Vec::with_capacity(total); + + buf.push(self.op as u8); + buf.extend_from_slice(&data_len.to_le_bytes()); + buf.extend_from_slice(&self.old_index_snapshot); + buf.extend_from_slice(&self.crc.to_le_bytes()); + buf.push(u8::from(self.committed)); + + Ok(buf) + } + + /// Deserialize an entry from on-disk bytes. + pub fn from_bytes(data: &[u8]) -> Result { + let min_size = ENTRY_HEADER_SIZE + ENTRY_FOOTER_SIZE; + if data.len() < min_size { + return Err(CryptoError::VaultCorrupted("WAL entry too short".into())); + } + + let op = WalOp::from_byte(data[0])?; + let data_len = + u32::from_le_bytes([data[1], data[2], data[3], data[4]]) as usize; + + if data_len > MAX_SNAPSHOT_SIZE { + return Err(CryptoError::VaultCorrupted(format!( + "WAL data_len {data_len} exceeds max {MAX_SNAPSHOT_SIZE}" + ))); + } + + // Safe from overflow: data_len <= MAX_SNAPSHOT_SIZE (256KB) so + // ENTRY_HEADER_SIZE + data_len + ENTRY_FOOTER_SIZE fits in usize + // on both 32-bit and 64-bit targets. + let expected_total = ENTRY_HEADER_SIZE + data_len + ENTRY_FOOTER_SIZE; + if data.len() < expected_total { + return Err(CryptoError::VaultCorrupted(format!( + "WAL entry truncated: need {expected_total} bytes, have {}", + data.len() + ))); + } + + let snapshot = data[ENTRY_HEADER_SIZE..ENTRY_HEADER_SIZE + data_len].to_vec(); + + let crc_off = ENTRY_HEADER_SIZE + data_len; + let crc = u32::from_le_bytes([ + data[crc_off], + data[crc_off + 1], + data[crc_off + 2], + data[crc_off + 3], + ]); + let committed = data[crc_off + 4] != 0; + + // Verify CRC + let expected_crc = Self::compute_crc(op, &snapshot); + if crc != expected_crc { + return Err(CryptoError::VaultCorrupted(format!( + "WAL CRC mismatch: stored 0x{crc:08X}, computed 0x{expected_crc:08X}" + ))); + } + + Ok(Self { + op, + old_index_snapshot: snapshot, + crc, + committed, + }) + } + + /// Total on-disk size of this entry. + pub fn on_disk_size(&self) -> usize { + ENTRY_HEADER_SIZE + self.old_index_snapshot.len() + ENTRY_FOOTER_SIZE + } + + /// CRC32 over `op || data_len(LE) || data`. + fn compute_crc(op: WalOp, data: &[u8]) -> u32 { + let mut hasher = crc32fast::Hasher::new(); + hasher.update(&[op as u8]); + let len = data.len() as u32; + hasher.update(&len.to_le_bytes()); + hasher.update(data); + hasher.finalize() + } +} + +// --------------------------------------------------------------------------- +// WriteAheadLog +// --------------------------------------------------------------------------- + +/// Write-ahead log for crash recovery. +/// +/// Before each vault mutation the current encrypted index is journaled with +/// `committed = false`. After the mutation completes the entry is marked +/// committed. On recovery any uncommitted entry triggers restoration of the +/// old index snapshot. +pub struct WriteAheadLog { + file: File, +} + +impl WriteAheadLog { + /// Create or open the WAL file at `{vault_path}.wal`. + pub fn open(vault_path: &str) -> Result { + let wal_path = format!("{vault_path}.wal"); + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&wal_path) + .map_err(|e| CryptoError::IoError(format!("cannot open WAL: {e}")))?; + Ok(Self { file }) + } + + /// Journal the current encrypted index before a mutation. + /// + /// Writes the entry with `committed = false`, then fsyncs. + pub fn begin(&mut self, op: WalOp, encrypted_index: &[u8]) -> Result<(), CryptoError> { + let entry = WalEntry::new(op, encrypted_index.to_vec()); + let bytes = entry.to_bytes()?; + + self.file.seek(SeekFrom::End(0))?; + self.file.write_all(&bytes)?; + self.file.sync_all()?; + Ok(()) + } + + /// Mark the last entry as committed and fsync. + /// + /// The committed byte is the final byte of the last entry, so we seek + /// to one byte before EOF and overwrite it with `0x01`. + /// + /// Caller contract: `begin()` must have been called exactly once since + /// the last `commit()` or `checkpoint()`. Calling `commit()` without a + /// preceding `begin()` returns an error. + pub fn commit(&mut self) -> Result<(), CryptoError> { + let end = self.file.seek(SeekFrom::End(0))?; + if end == 0 { + return Err(CryptoError::VaultCorrupted( + "WAL is empty, nothing to commit".into(), + )); + } + + // Verify the last entry is actually uncommitted + self.file.seek(SeekFrom::End(-1))?; + let mut flag = [0u8; 1]; + self.file.read_exact(&mut flag)?; + if flag[0] != 0 { + return Err(CryptoError::VaultCorrupted( + "WAL commit called but last entry is already committed".into(), + )); + } + + // Overwrite committed flag + self.file.seek(SeekFrom::End(-1))?; + self.file.write_all(&[1u8])?; + self.file.sync_all()?; + Ok(()) + } + + /// Recover: read all entries and find the last uncommitted one. + /// + /// Returns `Some(old_encrypted_index)` if recovery is needed (caller + /// should restore that index), `None` if the WAL is clean. + pub fn recover(&mut self) -> Result>, CryptoError> { + self.file.seek(SeekFrom::Start(0))?; + let mut all_bytes = Vec::new(); + self.file.read_to_end(&mut all_bytes)?; + + if all_bytes.is_empty() { + return Ok(None); + } + + let mut offset = 0; + let mut last_uncommitted: Option> = None; + + while offset < all_bytes.len() { + let remaining = &all_bytes[offset..]; + if remaining.len() < ENTRY_HEADER_SIZE + ENTRY_FOOTER_SIZE { + // SAFETY: truncated tail means begin() was interrupted before + // its fsync completed. The mutation hasn't started, so the + // on-disk index is still consistent. Safe to ignore. + break; + } + + let entry = WalEntry::from_bytes(remaining)?; + if !entry.committed { + last_uncommitted = Some(entry.old_index_snapshot.clone()); + } + offset += entry.on_disk_size(); + } + + Ok(last_uncommitted) + } + + /// Checkpoint: truncate the WAL file after successful operations. + pub fn checkpoint(&mut self) -> Result<(), CryptoError> { + self.file.set_len(0)?; + self.file.seek(SeekFrom::Start(0))?; + self.file.sync_all()?; + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// VaultLock +// --------------------------------------------------------------------------- + +/// Advisory file lock to prevent concurrent vault access. +/// +/// The lock is held for as long as the `VaultLock` exists. Dropping without +/// calling `release()` still releases the OS flock (the `File` is closed), +/// but the `.lock` file on disk is only cleaned up by `release()`. +pub struct VaultLock { + // Held open to keep the flock alive. + lock_file: File, + path: String, +} + +impl std::fmt::Debug for VaultLock { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("VaultLock") + .field("path", &self.path) + .finish() + } +} + +impl VaultLock { + /// Acquire an advisory flock on `{vault_path}.lock`. + /// + /// Returns `CryptoError::VaultLocked` if already held by another process. + pub fn acquire(vault_path: &str) -> Result { + let path = format!("{vault_path}.lock"); + let file = File::create(&path) + .map_err(|e| CryptoError::IoError(format!("cannot create lock file: {e}")))?; + file.try_lock_exclusive() + .map_err(|_| CryptoError::VaultLocked)?; + Ok(Self { + lock_file: file, + path, + }) + } + + /// Release the lock and remove the `.lock` file. + pub fn release(self) -> Result<(), CryptoError> { + self.lock_file + .unlock() + .map_err(|e| CryptoError::IoError(format!("unlock failed: {e}")))?; + let _ = std::fs::remove_file(&self.path); + Ok(()) + } +} + +// =========================================================================== +// Tests +// =========================================================================== + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_index_data() -> Vec { + vec![0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04] + } + + // -- WalEntry ----------------------------------------------------------- + + #[test] + fn test_wal_entry_roundtrip() { + let data = sample_index_data(); + let entry = WalEntry::new(WalOp::WriteSegment, data.clone()); + assert!(!entry.committed); + + let bytes = entry.to_bytes().expect("serialize"); + let parsed = WalEntry::from_bytes(&bytes).expect("parse"); + + assert_eq!(parsed.op, WalOp::WriteSegment); + assert_eq!(parsed.old_index_snapshot, data); + assert_eq!(parsed.crc, entry.crc); + assert!(!parsed.committed); + } + + #[test] + fn test_wal_entry_all_ops() { + for op in [WalOp::WriteSegment, WalOp::DeleteSegment, WalOp::UpdateIndex] { + let entry = WalEntry::new(op, vec![0x42]); + let bytes = entry.to_bytes().expect("serialize"); + let parsed = WalEntry::from_bytes(&bytes).expect("parse"); + assert_eq!(parsed.op, op); + } + } + + #[test] + fn test_wal_op_from_byte_invalid() { + assert!(WalOp::from_byte(0xFF).is_err()); + assert!(WalOp::from_byte(0x00).is_err()); + assert!(WalOp::from_byte(0x04).is_err()); + } + + #[test] + fn test_wal_entry_crc_corruption() { + let entry = WalEntry::new(WalOp::WriteSegment, sample_index_data()); + let mut bytes = entry.to_bytes().expect("serialize"); + + // Tamper with the CRC (4 bytes before the committed byte) + let crc_pos = bytes.len() - 5; + bytes[crc_pos] ^= 0xFF; + + let result = WalEntry::from_bytes(&bytes); + assert!(result.is_err()); + if let Err(CryptoError::VaultCorrupted(msg)) = result { + assert!(msg.contains("CRC mismatch")); + } + } + + #[test] + fn test_wal_entry_data_corruption_detected_by_crc() { + let entry = WalEntry::new(WalOp::WriteSegment, sample_index_data()); + let mut bytes = entry.to_bytes().expect("serialize"); + + // Tamper with the data portion + bytes[ENTRY_HEADER_SIZE] ^= 0xFF; + + let result = WalEntry::from_bytes(&bytes); + assert!(result.is_err()); + } + + #[test] + fn test_wal_entry_empty_data() { + let entry = WalEntry::new(WalOp::UpdateIndex, Vec::new()); + let bytes = entry.to_bytes().expect("serialize"); + let parsed = WalEntry::from_bytes(&bytes).expect("parse"); + assert!(parsed.old_index_snapshot.is_empty()); + assert_eq!(parsed.op, WalOp::UpdateIndex); + } + + #[test] + fn test_wal_entry_too_short() { + assert!(WalEntry::from_bytes(&[0x01]).is_err()); + assert!(WalEntry::from_bytes(&[]).is_err()); + } + + #[test] + fn test_wal_entry_oversized_data_len_rejected() { + // Craft a header claiming data_len > MAX_SNAPSHOT_SIZE + let mut buf = vec![0x01u8]; // op = WriteSegment + let huge_len = (MAX_SNAPSHOT_SIZE as u32) + 1; + buf.extend_from_slice(&huge_len.to_le_bytes()); + // Pad with enough bytes so it doesn't fail on truncation first + buf.resize(ENTRY_HEADER_SIZE + huge_len as usize + ENTRY_FOOTER_SIZE, 0); + let result = WalEntry::from_bytes(&buf); + assert!(result.is_err()); + if let Err(CryptoError::VaultCorrupted(msg)) = result { + assert!(msg.contains("exceeds max")); + } + } + + // -- WriteAheadLog ------------------------------------------------------ + + #[test] + fn test_wal_begin_commit() { + let dir = tempfile::tempdir().expect("tempdir"); + let vault_path = dir.path().join("test.vault"); + let vault_str = vault_path.to_str().expect("path"); + + let mut wal = WriteAheadLog::open(vault_str).expect("open"); + wal.begin(WalOp::WriteSegment, &sample_index_data()) + .expect("begin"); + wal.commit().expect("commit"); + + let recovery = wal.recover().expect("recover"); + assert!(recovery.is_none()); + } + + #[test] + fn test_wal_recover_uncommitted() { + let dir = tempfile::tempdir().expect("tempdir"); + let vault_path = dir.path().join("test.vault"); + let vault_str = vault_path.to_str().expect("path"); + + let data = sample_index_data(); + { + let mut wal = WriteAheadLog::open(vault_str).expect("open"); + wal.begin(WalOp::WriteSegment, &data).expect("begin"); + // No commit — simulates crash + } + + // Reopen and recover + let mut wal = WriteAheadLog::open(vault_str).expect("reopen"); + let recovery = wal.recover().expect("recover"); + assert!(recovery.is_some()); + assert_eq!(recovery.expect("snapshot"), data); + } + + #[test] + fn test_wal_recover_committed() { + let dir = tempfile::tempdir().expect("tempdir"); + let vault_path = dir.path().join("test.vault"); + let vault_str = vault_path.to_str().expect("path"); + + let mut wal = WriteAheadLog::open(vault_str).expect("open"); + wal.begin(WalOp::WriteSegment, &sample_index_data()) + .expect("begin"); + wal.commit().expect("commit"); + + let recovery = wal.recover().expect("recover"); + assert!(recovery.is_none()); + } + + #[test] + fn test_wal_crc_corruption_in_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let vault_path = dir.path().join("test.vault"); + let vault_str = vault_path.to_str().expect("path"); + + { + let mut wal = WriteAheadLog::open(vault_str).expect("open"); + wal.begin(WalOp::WriteSegment, &sample_index_data()) + .expect("begin"); + } + + // Tamper with the WAL file CRC + let wal_path = format!("{vault_str}.wal"); + let mut bytes = std::fs::read(&wal_path).expect("read"); + let crc_pos = bytes.len() - 5; + bytes[crc_pos] ^= 0xFF; + std::fs::write(&wal_path, &bytes).expect("write"); + + let mut wal = WriteAheadLog::open(vault_str).expect("reopen"); + let result = wal.recover(); + assert!(result.is_err()); + } + + #[test] + fn test_wal_checkpoint_truncates() { + let dir = tempfile::tempdir().expect("tempdir"); + let vault_path = dir.path().join("test.vault"); + let vault_str = vault_path.to_str().expect("path"); + + let mut wal = WriteAheadLog::open(vault_str).expect("open"); + wal.begin(WalOp::WriteSegment, &sample_index_data()) + .expect("begin"); + wal.commit().expect("commit"); + wal.checkpoint().expect("checkpoint"); + + // WAL file should be empty + let wal_path = format!("{vault_str}.wal"); + let meta = std::fs::metadata(&wal_path).expect("meta"); + assert_eq!(meta.len(), 0); + + // Recover should find nothing + let recovery = wal.recover().expect("recover"); + assert!(recovery.is_none()); + } + + #[test] + fn test_wal_double_commit_rejected() { + let dir = tempfile::tempdir().expect("tempdir"); + let vault_path = dir.path().join("test.vault"); + let vault_str = vault_path.to_str().expect("path"); + + let mut wal = WriteAheadLog::open(vault_str).expect("open"); + wal.begin(WalOp::WriteSegment, &sample_index_data()) + .expect("begin"); + wal.commit().expect("commit"); + + // Second commit without begin should fail + let result = wal.commit(); + assert!(result.is_err()); + } + + #[test] + fn test_wal_multiple_entries() { + let dir = tempfile::tempdir().expect("tempdir"); + let vault_path = dir.path().join("test.vault"); + let vault_str = vault_path.to_str().expect("path"); + + let mut wal = WriteAheadLog::open(vault_str).expect("open"); + + for i in 0u8..5 { + wal.begin(WalOp::WriteSegment, &[i; 16]) + .expect("begin"); + wal.commit().expect("commit"); + } + + let recovery = wal.recover().expect("recover"); + assert!(recovery.is_none()); + } + + #[test] + fn test_wal_multiple_entries_last_uncommitted() { + let dir = tempfile::tempdir().expect("tempdir"); + let vault_path = dir.path().join("test.vault"); + let vault_str = vault_path.to_str().expect("path"); + + let mut wal = WriteAheadLog::open(vault_str).expect("open"); + + // Two committed, then one uncommitted + wal.begin(WalOp::WriteSegment, &[0xAA; 16]) + .expect("begin"); + wal.commit().expect("commit"); + wal.begin(WalOp::DeleteSegment, &[0xBB; 16]) + .expect("begin"); + wal.commit().expect("commit"); + + let crash_data = vec![0xCC; 16]; + wal.begin(WalOp::UpdateIndex, &crash_data).expect("begin"); + // No commit — simulates crash + + let recovery = wal.recover().expect("recover"); + assert!(recovery.is_some()); + assert_eq!(recovery.expect("snapshot"), crash_data); + } + + // -- VaultLock ---------------------------------------------------------- + + #[test] + fn test_lock_acquire_release() { + let dir = tempfile::tempdir().expect("tempdir"); + let vault_path = dir.path().join("test.vault"); + let vault_str = vault_path.to_str().expect("path"); + + let lock = VaultLock::acquire(vault_str).expect("acquire"); + lock.release().expect("release"); + } + + #[test] + fn test_lock_double_acquire_fails() { + let dir = tempfile::tempdir().expect("tempdir"); + let vault_path = dir.path().join("test.vault"); + let vault_str = vault_path.to_str().expect("path"); + + let _lock = VaultLock::acquire(vault_str).expect("acquire"); + let result = VaultLock::acquire(vault_str); + assert!(result.is_err()); + match result { + Err(CryptoError::VaultLocked) => {} // expected + other => panic!("expected VaultLocked, got {other:?}"), + } + } + + #[test] + fn test_lock_release_allows_reacquire() { + let dir = tempfile::tempdir().expect("tempdir"); + let vault_path = dir.path().join("test.vault"); + let vault_str = vault_path.to_str().expect("path"); + + let lock = VaultLock::acquire(vault_str).expect("acquire"); + lock.release().expect("release"); + + let lock2 = VaultLock::acquire(vault_str).expect("reacquire"); + lock2.release().expect("release"); + } + + #[test] + fn test_lock_file_cleanup() { + let dir = tempfile::tempdir().expect("tempdir"); + let vault_path = dir.path().join("test.vault"); + let vault_str = vault_path.to_str().expect("path"); + let lock_path = format!("{vault_str}.lock"); + + let lock = VaultLock::acquire(vault_str).expect("acquire"); + assert!(std::path::Path::new(&lock_path).exists()); + + lock.release().expect("release"); + assert!(!std::path::Path::new(&lock_path).exists()); + } +} diff --git a/rust/src/core/mod.rs b/rust/src/core/mod.rs index 07318dd..f263170 100644 --- a/rust/src/core/mod.rs +++ b/rust/src/core/mod.rs @@ -5,6 +5,7 @@ pub mod compression; pub mod error; +pub mod evfs; pub mod format; pub mod rng; pub mod secret; diff --git a/rust/src/frb_generated.rs b/rust/src/frb_generated.rs index 4a70019..2cfd23f 100644 --- a/rust/src/frb_generated.rs +++ b/rust/src/frb_generated.rs @@ -26,6 +26,7 @@ // Section: imports use crate::api::encryption::*; +use crate::api::evfs::*; use crate::api::hashing::*; use flutter_rust_bridge::for_generated::byteorder::{NativeEndian, ReadBytesExt, WriteBytesExt}; use flutter_rust_bridge::for_generated::{transform_result_dco, Lifetimeable, Lockable}; @@ -39,7 +40,7 @@ flutter_rust_bridge::frb_generated_boilerplate!( default_rust_auto_opaque = RustAutoOpaqueMoi, ); pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.11.1"; -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1664706909; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 623065910; // Section: executor @@ -1480,6 +1481,383 @@ fn wire__crate__api__streaming__stream_hash_file_impl( }, ) } +fn wire__crate__api__evfs__vault_capacity_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "vault_capacity", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_handle = , + >>::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, ()>((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_capacity(&*api_handle_guard))?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__evfs__vault_close_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "vault_close", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_handle = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, crate::core::error::CryptoError>((move || { + let output_ok = crate::api::evfs::vault_close(api_handle)?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__evfs__vault_create_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "vault_create", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_path = ::sse_decode(&mut deserializer); + let api_key = >::sse_decode(&mut deserializer); + let api_algorithm = ::sse_decode(&mut deserializer); + let api_capacity_bytes = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, crate::core::error::CryptoError>((move || { + let output_ok = crate::api::evfs::vault_create( + api_path, + api_key, + api_algorithm, + api_capacity_bytes, + )?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__evfs__vault_delete_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "vault_delete", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_handle = , + >>::sse_decode(&mut deserializer); + let api_name = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, 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_delete(&mut *api_handle_guard, api_name)?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__evfs__vault_list_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "vault_list", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_handle = , + >>::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, ()>((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_list(&*api_handle_guard))?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__evfs__vault_open_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "vault_open", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_path = ::sse_decode(&mut deserializer); + let api_key = >::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, crate::core::error::CryptoError>((move || { + let output_ok = crate::api::evfs::vault_open(api_path, api_key)?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__evfs__vault_read_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "vault_read", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_handle = , + >>::sse_decode(&mut deserializer); + let api_name = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, 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_read(&mut *api_handle_guard, api_name)?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__evfs__vault_write_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "vault_write", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_handle = , + >>::sse_decode(&mut deserializer); + let api_name = ::sse_decode(&mut deserializer); + let api_data = >::sse_decode(&mut deserializer); + let api_compression = + >::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, 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_write( + &mut *api_handle_guard, + api_name, + api_data, + api_compression, + )?; + Ok(output_ok) + })()) + } + }, + ) +} // Section: related_funcs @@ -1489,6 +1867,9 @@ flutter_rust_bridge::frb_generated_moi_arc_impl_value!( flutter_rust_bridge::frb_generated_moi_arc_impl_value!( flutter_rust_bridge::for_generated::RustAutoOpaqueInner ); +flutter_rust_bridge::frb_generated_moi_arc_impl_value!( + flutter_rust_bridge::for_generated::RustAutoOpaqueInner +); // Section: dart2rust @@ -1520,6 +1901,16 @@ impl SseDecode for HasherHandle { } } +impl SseDecode for VaultHandle { + // 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 flutter_rust_bridge::for_generated::rust_auto_opaque_decode_owned(inner); + } +} + impl SseDecode for RustOpaqueMoi> { @@ -1540,6 +1931,16 @@ impl SseDecode } } +impl SseDecode + for RustOpaqueMoi> +{ + // 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 decode_rust_opaque_moi(inner); + } +} + impl SseDecode for StreamSink { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -1647,6 +2048,25 @@ impl SseDecode for crate::core::error::CryptoError { 9 => { return crate::core::error::CryptoError::AuthenticationFailed; } + 10 => { + let mut var_needed = ::sse_decode(deserializer); + let mut var_available = ::sse_decode(deserializer); + return crate::core::error::CryptoError::VaultFull { + needed: var_needed, + available: var_available, + }; + } + 11 => { + return crate::core::error::CryptoError::VaultLocked; + } + 12 => { + let mut var_field0 = ::sse_decode(deserializer); + return crate::core::error::CryptoError::SegmentNotFound(var_field0); + } + 13 => { + let mut var_field0 = ::sse_decode(deserializer); + return crate::core::error::CryptoError::VaultCorrupted(var_field0); + } _ => { unimplemented!(""); } @@ -1668,6 +2088,18 @@ impl SseDecode for i32 { } } +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 Vec { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -1680,6 +2112,19 @@ impl SseDecode for Vec { } } +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 { @@ -1702,6 +2147,13 @@ impl SseDecode for Option> { } } +impl SseDecode for u64 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_u64::().unwrap() + } +} + impl SseDecode for u8 { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -1721,6 +2173,24 @@ impl SseDecode for usize { } } +impl SseDecode for crate::api::evfs::VaultCapacityInfo { + // 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_totalBytes = ::sse_decode(deserializer); + let mut var_usedBytes = ::sse_decode(deserializer); + let mut var_freeListBytes = ::sse_decode(deserializer); + let mut var_unallocatedBytes = ::sse_decode(deserializer); + let mut var_segmentCount = ::sse_decode(deserializer); + return crate::api::evfs::VaultCapacityInfo { + total_bytes: var_totalBytes, + used_bytes: var_usedBytes, + free_list_bytes: var_freeListBytes, + unallocated_bytes: var_unallocatedBytes, + segment_count: var_segmentCount, + }; + } +} + fn pde_ffi_dispatcher_primary_impl( func_id: i32, port: flutter_rust_bridge::for_generated::MessagePort, @@ -1842,6 +2312,14 @@ fn pde_ffi_dispatcher_primary_impl( wire__crate__api__streaming__stream_encrypt_file_impl(port, ptr, rust_vec_len, data_len) } 34 => wire__crate__api__streaming__stream_hash_file_impl(port, ptr, rust_vec_len, data_len), + 35 => wire__crate__api__evfs__vault_capacity_impl(port, ptr, rust_vec_len, data_len), + 36 => wire__crate__api__evfs__vault_close_impl(port, ptr, rust_vec_len, data_len), + 37 => wire__crate__api__evfs__vault_create_impl(port, ptr, rust_vec_len, data_len), + 38 => wire__crate__api__evfs__vault_delete_impl(port, ptr, rust_vec_len, data_len), + 39 => wire__crate__api__evfs__vault_list_impl(port, ptr, rust_vec_len, data_len), + 40 => wire__crate__api__evfs__vault_open_impl(port, ptr, rust_vec_len, data_len), + 41 => wire__crate__api__evfs__vault_read_impl(port, ptr, rust_vec_len, data_len), + 42 => wire__crate__api__evfs__vault_write_impl(port, ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -1892,6 +2370,21 @@ impl flutter_rust_bridge::IntoIntoDart> for HasherHandl } } +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for FrbWrapper { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + flutter_rust_bridge::for_generated::rust_auto_opaque_encode::<_, MoiArc<_>>(self.0) + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive for FrbWrapper {} + +impl flutter_rust_bridge::IntoIntoDart> for VaultHandle { + fn into_into_dart(self) -> FrbWrapper { + self.into() + } +} + // Codec=Dco (DartCObject based), see doc to use other codecs impl flutter_rust_bridge::IntoDart for crate::api::hashing::argon2::Argon2Preset { fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { @@ -1987,6 +2480,19 @@ impl flutter_rust_bridge::IntoDart for crate::core::error::CryptoError { [8.into_dart(), field0.into_into_dart().into_dart()].into_dart() } crate::core::error::CryptoError::AuthenticationFailed => [9.into_dart()].into_dart(), + crate::core::error::CryptoError::VaultFull { needed, available } => [ + 10.into_dart(), + needed.into_into_dart().into_dart(), + available.into_into_dart().into_dart(), + ] + .into_dart(), + crate::core::error::CryptoError::VaultLocked => [11.into_dart()].into_dart(), + crate::core::error::CryptoError::SegmentNotFound(field0) => { + [12.into_dart(), field0.into_into_dart().into_dart()].into_dart() + } + crate::core::error::CryptoError::VaultCorrupted(field0) => { + [13.into_dart(), field0.into_into_dart().into_dart()].into_dart() + } _ => { unimplemented!(""); } @@ -2004,6 +2510,30 @@ impl flutter_rust_bridge::IntoIntoDart self } } +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::evfs::VaultCapacityInfo { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.total_bytes.into_into_dart().into_dart(), + self.used_bytes.into_into_dart().into_dart(), + self.free_list_bytes.into_into_dart().into_dart(), + self.unallocated_bytes.into_into_dart().into_dart(), + self.segment_count.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::evfs::VaultCapacityInfo +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::evfs::VaultCapacityInfo +{ + fn into_into_dart(self) -> crate::api::evfs::VaultCapacityInfo { + self + } +} impl SseEncode for flutter_rust_bridge::for_generated::anyhow::Error { // Codec=Sse (Serialization based), see doc to use other codecs @@ -2026,6 +2556,13 @@ impl SseEncode for HasherHandle { } } +impl SseEncode for VaultHandle { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + >>::sse_encode(flutter_rust_bridge::for_generated::rust_auto_opaque_encode::<_, MoiArc<_>>(self), serializer); + } +} + impl SseEncode for RustOpaqueMoi> { @@ -2048,6 +2585,17 @@ impl SseEncode } } +impl SseEncode + for RustOpaqueMoi> +{ + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + let (ptr, size) = self.sse_encode_raw(); + ::sse_encode(ptr, serializer); + ::sse_encode(size, serializer); + } +} + impl SseEncode for StreamSink { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -2152,6 +2700,22 @@ impl SseEncode for crate::core::error::CryptoError { crate::core::error::CryptoError::AuthenticationFailed => { ::sse_encode(9, serializer); } + crate::core::error::CryptoError::VaultFull { needed, available } => { + ::sse_encode(10, serializer); + ::sse_encode(needed, serializer); + ::sse_encode(available, serializer); + } + crate::core::error::CryptoError::VaultLocked => { + ::sse_encode(11, serializer); + } + crate::core::error::CryptoError::SegmentNotFound(field0) => { + ::sse_encode(12, serializer); + ::sse_encode(field0, serializer); + } + crate::core::error::CryptoError::VaultCorrupted(field0) => { + ::sse_encode(13, serializer); + ::sse_encode(field0, serializer); + } _ => { unimplemented!(""); } @@ -2173,6 +2737,16 @@ impl SseEncode for i32 { } } +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 Vec { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -2183,6 +2757,16 @@ impl SseEncode for Vec { } } +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) { @@ -2203,6 +2787,13 @@ impl SseEncode for Option> { } } +impl SseEncode for u64 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_u64::(self).unwrap(); + } +} + impl SseEncode for u8 { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -2225,6 +2816,17 @@ impl SseEncode for usize { } } +impl SseEncode for crate::api::evfs::VaultCapacityInfo { + // 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.total_bytes, serializer); + ::sse_encode(self.used_bytes, serializer); + ::sse_encode(self.free_list_bytes, serializer); + ::sse_encode(self.unallocated_bytes, serializer); + ::sse_encode(self.segment_count, serializer); + } +} + #[cfg(not(target_family = "wasm"))] mod io { // This file is automatically generated, so please do not edit it. @@ -2234,6 +2836,7 @@ mod io { use super::*; use crate::api::encryption::*; + use crate::api::evfs::*; use crate::api::hashing::*; use flutter_rust_bridge::for_generated::byteorder::{ NativeEndian, ReadBytesExt, WriteBytesExt, @@ -2272,6 +2875,20 @@ mod io { ) { MoiArc::>::decrement_strong_count(ptr as _); } + + #[unsafe(no_mangle)] + pub extern "C" fn frbgen_m_security_rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerVaultHandle( + ptr: *const std::ffi::c_void, + ) { + MoiArc::>::increment_strong_count(ptr as _); + } + + #[unsafe(no_mangle)] + pub extern "C" fn frbgen_m_security_rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerVaultHandle( + ptr: *const std::ffi::c_void, + ) { + MoiArc::>::decrement_strong_count(ptr as _); + } } #[cfg(not(target_family = "wasm"))] pub use io::*; @@ -2286,6 +2903,7 @@ mod web { use super::*; use crate::api::encryption::*; + use crate::api::evfs::*; use crate::api::hashing::*; use flutter_rust_bridge::for_generated::byteorder::{ NativeEndian, ReadBytesExt, WriteBytesExt, @@ -2326,6 +2944,20 @@ mod web { ) { MoiArc::>::decrement_strong_count(ptr as _); } + + #[wasm_bindgen] + pub fn rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerVaultHandle( + ptr: *const std::ffi::c_void, + ) { + MoiArc::>::increment_strong_count(ptr as _); + } + + #[wasm_bindgen] + pub fn rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerVaultHandle( + ptr: *const std::ffi::c_void, + ) { + MoiArc::>::decrement_strong_count(ptr as _); + } } #[cfg(target_family = "wasm")] pub use web::*;