Skip to content

Commit d3d19f6

Browse files
committed
Merge branch 'fix/crypto-vault-disk-persistence' into develop
Fix on-disk crypto vault: ZipFileSystem.ListAsync no longer crashes on ZipArchiveEntry.Length in update mode, and CryptoFileSystem.Dispose flushes its inner filesystem so RegisterCryptoVault persists to disk.
2 parents 49c9d0e + b0c7c06 commit d3d19f6

6 files changed

Lines changed: 166 additions & 23 deletions

File tree

docs/tutorials/vfs.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,14 @@ The same `IVirtualFileSystem` API works regardless of the backend. `ReadAllBytes
3131
### 3. Encrypted vault round-trip
3232

3333
`CryptoFileSystem` encrypts every entry over a backend filesystem. The lifecycle is
34-
unlock → write → lock; locking zeroes the key and flushes the encrypted index into the backend,
35-
so re-opening the same backend with the passphrase decrypts the data at rest.
36-
37-
This sample drives `CryptoFileSystem` over an **in-memory** backend to demonstrate the full
38-
lifecycle. The DI helper `RegisterCryptoVault` wires a vault over a single on-disk zip file, but
39-
that `ZipFileSystem` backend currently cannot be locked/persisted (a known limitation — its
40-
`List` reads `ZipArchiveEntry.Length`, which .NET marks unavailable in `ZipArchiveMode.Update`).
41-
On-disk vault persistence is therefore not yet supported.
34+
unlock → write → dispose; disposing locks the vault (zeroing the key and flushing the encrypted
35+
index) and disposes the backend, so a `ZipFileSystem` backend flushes its archive to disk.
36+
Re-opening a fresh instance over the same file with the passphrase decrypts the data at rest.
37+
38+
This sample backs the vault with a single on-disk zip file via `ZipFileSystem`, writes a secret,
39+
disposes the vault, then re-opens a brand-new instance over the same file to prove on-disk
40+
persistence. The DI helper `RegisterCryptoVault` wires exactly this — a vault over a single-file
41+
zip — as a lockable singleton.
4242

4343
[!code-csharp[](../../samples/SquidStd.Samples.Vfs/Program.cs#step-3)]
4444

samples/SquidStd.Samples.Vfs/Program.cs

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -38,30 +38,27 @@
3838

3939
#region step-3
4040

41-
// Encrypted vault lifecycle: unlock -> write -> lock, then re-open and read.
42-
// RegisterCryptoVault wires a DI vault over a single on-disk zip file, but that ZipFileSystem
43-
// backend currently cannot be locked/persisted (its List reads ZipArchiveEntry.Length, which
44-
// .NET marks unavailable in ZipArchiveMode.Update), so this sample drives CryptoFileSystem over
45-
// an in-memory backend to demonstrate the full lifecycle without that limitation.
46-
var backend = new InMemoryFileSystem();
47-
48-
using (var vault = new CryptoFileSystem(backend))
41+
// Encrypted vault on a single on-disk zip file: unlock -> write -> dispose (flushes to disk),
42+
// then re-open a brand-new instance over the same file to prove the data round-trips at rest.
43+
var vaultPath = Path.Combine(Path.GetTempPath(), "squidstd-sample.vault");
44+
45+
using (var vault = new CryptoFileSystem(new ZipFileSystem(vaultPath)))
4946
{
5047
vault.Unlock("vault passphrase");
5148
await vault.WriteAllBytesAsync("secret.txt", Encoding.UTF8.GetBytes("top secret"));
52-
vault.Lock(); // zeroes the key and flushes the encrypted index into the backend
53-
}
49+
} // Dispose -> Lock (zeroes the key, flushes the encrypted index) -> flushes the zip to disk
5450

55-
// Re-open the same encrypted backend with the passphrase to prove the data round-trips at rest.
56-
using (var reopened = new CryptoFileSystem(backend))
51+
// Re-open the same encrypted file with the passphrase; only the right passphrase decrypts it.
52+
using (var reopened = new CryptoFileSystem(new ZipFileSystem(vaultPath)))
5753
{
5854
reopened.Unlock("vault passphrase");
5955

60-
// The secret was written above, so it is present.
6156
var secret = await reopened.ReadAllBytesAsync("secret.txt");
6257
Console.WriteLine($"Vault read after reopen: {Encoding.UTF8.GetString(secret!)}");
6358
}
6459

60+
File.Delete(vaultPath);
61+
6562
#endregion
6663

6764
await bootstrap.StopAsync();

src/SquidStd.Crypto/Vfs/Services/CryptoFileSystem.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,18 @@ private void EnsureUnlocked()
232232
public void Dispose()
233233
{
234234
Lock();
235+
236+
// The vault owns its inner filesystem; disposing it flushes backends such as ZipFileSystem
237+
// (which only writes its archive to disk on dispose) so the vault persists.
238+
switch (_inner)
239+
{
240+
case IDisposable disposable:
241+
disposable.Dispose();
242+
break;
243+
case IAsyncDisposable asyncDisposable:
244+
asyncDisposable.DisposeAsync().AsTask().GetAwaiter().GetResult();
245+
break;
246+
}
235247
}
236248

237249
private sealed class VaultWriteStream : MemoryStream

src/SquidStd.Vfs/Services/ZipFileSystem.cs

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ public sealed class ZipFileSystem : IVirtualFileSystem, IAsyncDisposable, IDispo
1111
{
1212
private readonly ZipArchive _archive;
1313
private readonly FileStream _file;
14+
private readonly Dictionary<string, long> _writtenLengths = new(StringComparer.Ordinal);
1415

1516
public ZipFileSystem(string path)
1617
{
@@ -50,6 +51,10 @@ public async ValueTask WriteAllBytesAsync(
5051

5152
await using var stream = entry.Open();
5253
await stream.WriteAsync(data, cancellationToken).ConfigureAwait(false);
54+
55+
// ZipArchiveEntry.Length is unavailable in update mode once an entry has been opened for
56+
// writing, so track the uncompressed size ourselves for ListAsync.
57+
_writtenLengths[name] = data.Length;
5358
}
5459

5560
public async Task<Stream> OpenReadAsync(string path, CancellationToken cancellationToken = default)
@@ -67,14 +72,16 @@ public Task<Stream> OpenWriteAsync(string path, CancellationToken cancellationTo
6772

6873
public ValueTask<bool> DeleteAsync(string path, CancellationToken cancellationToken = default)
6974
{
70-
var entry = _archive.GetEntry(VfsPath.Normalize(path));
75+
var name = VfsPath.Normalize(path);
76+
var entry = _archive.GetEntry(name);
7177

7278
if (entry is null)
7379
{
7480
return ValueTask.FromResult(false);
7581
}
7682

7783
entry.Delete();
84+
_writtenLengths.Remove(name);
7885

7986
return ValueTask.FromResult(true);
8087
}
@@ -92,12 +99,31 @@ public async IAsyncEnumerable<VfsEntry> ListAsync(
9299
continue;
93100
}
94101

95-
yield return new VfsEntry(entry.FullName, entry.Length, entry.LastWriteTime);
102+
yield return new VfsEntry(entry.FullName, EntrySize(entry), entry.LastWriteTime);
96103

97104
await Task.CompletedTask;
98105
}
99106
}
100107

108+
private long EntrySize(ZipArchiveEntry entry)
109+
{
110+
if (_writtenLengths.TryGetValue(entry.FullName, out var length))
111+
{
112+
return length;
113+
}
114+
115+
try
116+
{
117+
return entry.Length;
118+
}
119+
catch (InvalidOperationException)
120+
{
121+
// Unavailable in update mode for an entry opened for writing this session that we did
122+
// not track (should not happen, but never let listing throw).
123+
return 0;
124+
}
125+
}
126+
101127
public async ValueTask DisposeAsync()
102128
{
103129
_archive.Dispose();
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
using System.Text;
2+
using SquidStd.Crypto.Vfs.Data;
3+
using SquidStd.Crypto.Vfs.Services;
4+
using SquidStd.Vfs.Services;
5+
6+
namespace SquidStd.Tests.Crypto.Vfs;
7+
8+
public class CryptoFileSystemDiskTests
9+
{
10+
private static CryptoVaultOptions FastOptions()
11+
{
12+
return new CryptoVaultOptions { Argon2MemoryKib = 8192, Argon2Iterations = 1 };
13+
}
14+
15+
[Fact]
16+
public async Task Lock_AfterWrites_OverZipBackend_DoesNotThrow()
17+
{
18+
var path = Path.Combine(Path.GetTempPath(), "squidstd-vault-" + Guid.NewGuid().ToString("N") + ".vault");
19+
20+
try
21+
{
22+
var vault = new CryptoFileSystem(new ZipFileSystem(path), FastOptions());
23+
vault.Unlock("pw");
24+
await vault.WriteAllBytesAsync("secret.txt", Encoding.UTF8.GetBytes("top secret"));
25+
26+
// PruneOrphans() lists the zip backend, which previously threw because ZipArchiveEntry.Length
27+
// is unavailable in update mode after a same-session write.
28+
vault.Lock();
29+
30+
Assert.False(vault.IsUnlocked);
31+
}
32+
finally
33+
{
34+
if (File.Exists(path))
35+
{
36+
File.Delete(path);
37+
}
38+
}
39+
}
40+
41+
[Fact]
42+
public async Task Vault_PersistsToDisk_AcrossInstances()
43+
{
44+
var path = Path.Combine(Path.GetTempPath(), "squidstd-vault-" + Guid.NewGuid().ToString("N") + ".vault");
45+
46+
try
47+
{
48+
using (var vault = new CryptoFileSystem(new ZipFileSystem(path), FastOptions()))
49+
{
50+
vault.Unlock("correct horse");
51+
await vault.WriteAllBytesAsync("notes/plan.txt", Encoding.UTF8.GetBytes("attack at dawn"));
52+
} // Dispose -> Lock -> flush the inner zip to disk
53+
54+
Assert.True(new FileInfo(path).Length > 0, "the vault file must be written to disk");
55+
56+
using (var reopened = new CryptoFileSystem(new ZipFileSystem(path), FastOptions()))
57+
{
58+
reopened.Unlock("correct horse");
59+
var data = await reopened.ReadAllBytesAsync("notes/plan.txt");
60+
61+
Assert.NotNull(data);
62+
Assert.Equal("attack at dawn", Encoding.UTF8.GetString(data!));
63+
}
64+
}
65+
finally
66+
{
67+
if (File.Exists(path))
68+
{
69+
File.Delete(path);
70+
}
71+
}
72+
}
73+
}

tests/SquidStd.Tests/Vfs/ZipFileSystemTests.cs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,39 @@ public async Task Write_Reopen_Read_Delete_RoundTrips()
3939
}
4040
}
4141
}
42+
43+
[Fact]
44+
public async Task ListAsync_AfterWritingInSameSession_DoesNotThrow_AndReportsSizes()
45+
{
46+
var path = Path.Combine(Path.GetTempPath(), "squidstd-vfs-" + Guid.NewGuid().ToString("N") + ".zip");
47+
48+
try
49+
{
50+
await using var fs = new ZipFileSystem(path);
51+
await fs.WriteAllBytesAsync("a.txt", Encoding.UTF8.GetBytes("hello"));
52+
await fs.WriteAllBytesAsync("b.txt", Encoding.UTF8.GetBytes("hi"));
53+
54+
var entries = new List<string>();
55+
long totalSize = 0;
56+
57+
// In ZipArchiveMode.Update, ZipArchiveEntry.Length throws for entries written this
58+
// session; ListAsync must not surface that.
59+
await foreach (var entry in fs.ListAsync())
60+
{
61+
entries.Add(entry.Path);
62+
totalSize += entry.Size;
63+
}
64+
65+
Assert.Contains("a.txt", entries);
66+
Assert.Contains("b.txt", entries);
67+
Assert.Equal(7, totalSize); // 5 + 2 uncompressed bytes
68+
}
69+
finally
70+
{
71+
if (File.Exists(path))
72+
{
73+
File.Delete(path);
74+
}
75+
}
76+
}
4277
}

0 commit comments

Comments
 (0)