diff --git a/.gitignore b/.gitignore
index f6c3abc1..2e47de1c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -425,3 +425,5 @@ dist/
# SonarScanner local analysis artifacts
.sonarqube/
.scannerwork/
+
+*.scdb
diff --git a/global.json b/global.json
new file mode 100644
index 00000000..d249a65c
--- /dev/null
+++ b/global.json
@@ -0,0 +1,6 @@
+{
+ "test": {
+ "runner": "Microsoft.Testing.Platform"
+ }
+}
+
diff --git a/src/SharpCoreDB/DatabaseExtensions.cs b/src/SharpCoreDB/DatabaseExtensions.cs
index 354161d0..86985cb8 100644
--- a/src/SharpCoreDB/DatabaseExtensions.cs
+++ b/src/SharpCoreDB/DatabaseExtensions.cs
@@ -1,4 +1,4 @@
-//
+// src\SharpCoreDB\DatabaseExtensions.cs
// Copyright (c) 2025-2026 MPCoreDeveloper and GitHub Copilot. All rights reserved.
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
//
@@ -81,6 +81,7 @@ public IDatabase CreateWithOptions(string dbPath, string masterPassword, Databas
return options.StorageMode switch
{
+ // ✅ ENCRYPTION: Pass 'this' to access instance method (needed for DI resolution)
StorageMode.SingleFile => CreateSingleFileDatabase(dbPath, masterPassword, options),
StorageMode.Directory => CreateDirectoryDatabase(dbPath, masterPassword, options),
_ => throw new ArgumentException($"Invalid storage mode: {options.StorageMode}")
@@ -94,7 +95,8 @@ private IDatabase CreateDirectoryDatabase(string dbPath, string masterPassword,
return new Database(services, dbPath, masterPassword, options.IsReadOnly, config);
}
- private static IDatabase CreateSingleFileDatabase(string dbPath, string masterPassword, DatabaseOptions options)
+ // ✅ ENCRYPTION: Changed from static to instance method to access DI services
+ private IDatabase CreateSingleFileDatabase(string dbPath, string masterPassword, DatabaseOptions options)
{
if (options.DatabaseConfig is not null)
{
@@ -102,7 +104,19 @@ private static IDatabase CreateSingleFileDatabase(string dbPath, string masterPa
}
options.WalBufferSizePages = options.WalBufferSizePages > 0 ? options.WalBufferSizePages : 2048;
options.FileShareMode = System.IO.FileShare.ReadWrite;
- var provider = SingleFileStorageProvider.Open(dbPath, options);
+
+ // ✅ ENCRYPTION: Resolve ICryptoService from DI and pass to provider
+ SharpCoreDB.Interfaces.ICryptoService? cryptoService = null;
+ if (options.EnableEncryption)
+ {
+ cryptoService = services.GetService();
+ if (cryptoService is null)
+ throw new InvalidOperationException(
+ "ICryptoService must be registered in DI when EnableEncryption is true. " +
+ "Call services.AddSharpCoreDB() or register ICryptoService manually.");
+ }
+
+ var provider = SingleFileStorageProvider.Open(dbPath, options, cryptoService);
return new SingleFileDatabase(provider, dbPath, masterPassword, options);
}
diff --git a/src/SharpCoreDB/DatabaseOptions.cs b/src/SharpCoreDB/DatabaseOptions.cs
index aa3de735..648f8636 100644
--- a/src/SharpCoreDB/DatabaseOptions.cs
+++ b/src/SharpCoreDB/DatabaseOptions.cs
@@ -1,4 +1,4 @@
-//
+// src\SharpCoreDB\DatabaseOptions.cs
// Copyright (c) 2025-2026 MPCoreDeveloper and GitHub Copilot. All rights reserved.
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
//
diff --git a/src/SharpCoreDB/SingleFileTable.cs b/src/SharpCoreDB/SingleFileTable.cs
index 04343db8..233b5234 100644
--- a/src/SharpCoreDB/SingleFileTable.cs
+++ b/src/SharpCoreDB/SingleFileTable.cs
@@ -1,4 +1,4 @@
-//
+// src\SharpCoreDB\SingleFileTable.cs
// Copyright (c) 2025-2026 MPCoreDeveloper and GitHub Copilot. All rights reserved.
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
//
diff --git a/src/SharpCoreDB/Storage/Scdb/SingleFileDatabase.Batch.cs b/src/SharpCoreDB/Storage/Scdb/SingleFileDatabase.Batch.cs
index 3db5eff5..3032a7bc 100644
--- a/src/SharpCoreDB/Storage/Scdb/SingleFileDatabase.Batch.cs
+++ b/src/SharpCoreDB/Storage/Scdb/SingleFileDatabase.Batch.cs
@@ -1,4 +1,4 @@
-//
+// src\SharpCoreDB\Storage\Scdb\SingleFileDatabase.Batch.cs
// Copyright (c) 2025-2026 MPCoreDeveloper and GitHub Copilot. All rights reserved.
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
//
diff --git a/src/SharpCoreDB/Storage/SingleFileStorageProvider.cs b/src/SharpCoreDB/Storage/SingleFileStorageProvider.cs
index 724f1594..22c061c2 100644
--- a/src/SharpCoreDB/Storage/SingleFileStorageProvider.cs
+++ b/src/SharpCoreDB/Storage/SingleFileStorageProvider.cs
@@ -1,4 +1,4 @@
-//
+// src\SharpCoreDB\Storage\SingleFileStorageProvider.cs
// Copyright (c) 2025-2026 MPCoreDeveloper and GitHub Copilot. All rights reserved.
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
//
@@ -96,6 +96,11 @@ public sealed class SingleFileStorageProvider : IStorageProvider
private const int WRITE_BATCH_SIZE = 200; // Batch 200 writes together (increased from 50)
private const int WRITE_BATCH_TIMEOUT_MS = 200; // Or flush after 200ms (increased from 50ms)
+ // ✅ ENCRYPTION: AES-256-GCM instance for block-level encryption at rest
+ // Null when encryption is disabled. When non-null, all block reads/writes
+ // pass through Encrypt/Decrypt to ensure data is encrypted on disk.
+ private readonly SharpCoreDB.Services.AesGcmEncryption? _encryption;
+
private bool _isInTransaction;
private bool _disposed;
private ScdbFileHeader _header;
@@ -109,13 +114,14 @@ public sealed class SingleFileStorageProvider : IStorageProvider
/// Optional memory-mapped file
/// File header structure
private SingleFileStorageProvider(string filePath, DatabaseOptions options, FileStream fileStream,
- MemoryMappedFile? mmf, ScdbFileHeader header)
+ MemoryMappedFile? mmf, ScdbFileHeader header, SharpCoreDB.Services.AesGcmEncryption? encryption = null)
{
_filePath = filePath;
_options = options;
_fileStream = fileStream;
_memoryMappedFile = mmf;
_header = header;
+ _encryption = encryption;
_blockCache = new ConcurrentDictionary();
// Initialize subsystems
@@ -134,13 +140,27 @@ private SingleFileStorageProvider(string filePath, DatabaseOptions options, File
/// Path to .scdb file
/// Database options
/// Initialized provider
- public static SingleFileStorageProvider Open(string filePath, DatabaseOptions options)
+ public static SingleFileStorageProvider Open(string filePath, DatabaseOptions options, SharpCoreDB.Interfaces.ICryptoService? cryptoService = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
ArgumentNullException.ThrowIfNull(options);
options.Validate();
+ // ✅ ENCRYPTION: Initialize AES-256-GCM if encryption is requested
+ SharpCoreDB.Services.AesGcmEncryption? encryption = null;
+ if (options.EnableEncryption)
+ {
+ if (cryptoService is null)
+ throw new InvalidOperationException(
+ "ICryptoService must be registered in DI when EnableEncryption is true. " +
+ "Call services.AddSharpCoreDB() or register ICryptoService manually.");
+ if (options.EncryptionKey is null || options.EncryptionKey.Length != 32)
+ throw new InvalidOperationException(
+ "EncryptionKey must be exactly 32 bytes (256 bits) when EnableEncryption is true.");
+ encryption = cryptoService.GetAesGcmEncryption(options.EncryptionKey);
+ }
+
// Ensure .scdb extension
if (!filePath.EndsWith(".scdb", StringComparison.OrdinalIgnoreCase))
{
@@ -200,7 +220,7 @@ public static SingleFileStorageProvider Open(string filePath, DatabaseOptions op
}
}
- return new SingleFileStorageProvider(filePath, options, fileStream, mmf, header);
+ return new SingleFileStorageProvider(filePath, options, fileStream, mmf, header, encryption);
}
///
@@ -280,7 +300,19 @@ public bool BlockExists(string blockName)
return null;
}
- // Create a sub-stream view of the block
+ // ✅ ENCRYPTION: If encryption is enabled, read the full block, decrypt,
+ // and return a read-only MemoryStream over the plaintext.
+ // BlockStream cannot decrypt in-place, so we must materialize the block.
+ if (_encryption is not null)
+ {
+ var encryptedData = new byte[(int)entry.Length];
+ _fileStream.Position = (long)entry.Offset;
+ _fileStream.ReadExactly(encryptedData);
+ var plaintextData = _encryption.Decrypt(encryptedData);
+ return new MemoryStream(plaintextData, index: 0, count: plaintextData.Length, writable: false);
+ }
+
+ // Create a sub-stream view of the block (unencrypted path)
return new BlockStream(_fileStream, entry.Offset, entry.Length, FileAccess.Read);
}
@@ -307,12 +339,19 @@ public unsafe ReadOnlySpan GetReadSpan(string blockName)
var buffer = new byte[checked((int)Math.Min(entry.Length, (ulong)int.MaxValue))];
_fileStream.Position = (long)entry.Offset;
_fileStream.ReadExactly(buffer);
+ // ✅ ENCRYPTION: Decrypt if encryption is enabled
+ if (_encryption is not null)
+ {
+ return _encryption.Decrypt(buffer);
+ }
return buffer;
}
// Use memory-mapped file for zero-copy access
- if (_memoryMappedFile != null)
+ if (_memoryMappedFile != null && _encryption is null)
{
+ // ✅ ENCRYPTION: Only use zero-copy mmap when encryption is disabled.
+ // Encrypted data must be read into a buffer and decrypted.
try
{
var viewOffset = checked((long)entry.Offset);
@@ -341,6 +380,12 @@ public unsafe ReadOnlySpan GetReadSpan(string blockName)
var buffer2 = new byte[(int)entry.Length];
_fileStream.Position = (long)entry.Offset;
_fileStream.ReadExactly(buffer2);
+
+ // ✅ ENCRYPTION: Decrypt block data after reading from disk
+ if (_encryption is not null)
+ {
+ return _encryption.Decrypt(buffer2);
+ }
return buffer2;
}
@@ -461,6 +506,21 @@ public async Task WriteBlockAsync(string blockName, ReadOnlyMemory data, C
// ✅ Convert to array immediately (before async operations)
var checksumArray = checksumSpan.ToArray();
+ // ✅ ENCRYPTION: Encrypt block data before writing to disk
+ // Checksum is computed on PLAINTEXT (above) so we can verify integrity after decryption.
+ // The on-disk bytes are [Nonce][Ciphertext][Tag] when encryption is enabled.
+ byte[] dataToStore;
+ if (_encryption is not null)
+ {
+ dataToStore = _encryption.Encrypt(data.ToArray());
+ // Update entry length to reflect encrypted size (plaintext + 12 nonce + 16 tag)
+ entry = entry with { Length = (ulong)dataToStore.Length, Flags = entry.Flags | (uint)BlockFlags.Dirty };
+ }
+ else
+ {
+ dataToStore = data.ToArray();
+ }
+
// Write to WAL first (crash safety)
if (_isInTransaction)
{
@@ -468,11 +528,10 @@ public async Task WriteBlockAsync(string blockName, ReadOnlyMemory data, C
}
// ✅ Phase 1 Task 1.3: Queue write instead of direct I/O
- // Copy data to array (required for safe batching)
var writeOp = new WriteOperation
{
BlockName = blockName,
- Data = data.ToArray(),
+ Data = dataToStore,
Checksum = checksumArray,
Offset = offset,
Entry = SetChecksum(entry, checksumArray)
@@ -712,11 +771,25 @@ public async Task UpdateBlockAsync(
_fileStream.Position = (long)entry.Offset;
await _fileStream.ReadExactlyAsync(buffer, cancellationToken).ConfigureAwait(false);
- // Validate checksum; if mismatch, attempt self-heal
- if (!ValidateChecksum(entry, buffer.Span))
+ // ✅ ENCRYPTION: Decrypt block data after reading from disk
+ // The on-disk bytes are [Nonce][Ciphertext][Tag] when encryption is enabled.
+ // We decrypt first, THEN validate the checksum against the plaintext.
+ byte[] plaintextBytes;
+ if (_encryption is not null)
+ {
+ plaintextBytes = _encryption.Decrypt(buffer.ToArray());
+ }
+ else
+ {
+ plaintextBytes = new byte[entry.Length];
+ buffer.Span.CopyTo(plaintextBytes);
+ }
+
+ // Validate checksum against PLAINTEXT; if mismatch, attempt self-heal
+ if (!ValidateChecksum(entry, plaintextBytes))
{
Console.WriteLine($"[SingleFileStorageProvider] Checksum mismatch for block '{blockName}', attempting self-heal");
- var repairedEntry = SetChecksum(entry, SHA256.HashData(buffer.Span));
+ var repairedEntry = SetChecksum(entry, SHA256.HashData(plaintextBytes));
_blockRegistry.AddOrUpdateBlock(blockName, repairedEntry);
await _blockRegistry.FlushAsync(cancellationToken).ConfigureAwait(false);
@@ -736,10 +809,8 @@ public async Task UpdateBlockAsync(
};
}
- // ✅ Phase 3.3: Copy to result array (caller owns this memory)
- var result = new byte[entry.Length];
- buffer.Span.CopyTo(result);
- return result;
+ // Return the decrypted plaintext to the caller
+ return plaintextBytes;
}
finally
{
diff --git a/tests/SharpCoreDB.Tests/Security/SingleFileEncryptionTests.cs b/tests/SharpCoreDB.Tests/Security/SingleFileEncryptionTests.cs
new file mode 100644
index 00000000..0bb64d91
--- /dev/null
+++ b/tests/SharpCoreDB.Tests/Security/SingleFileEncryptionTests.cs
@@ -0,0 +1,342 @@
+// tests/SharpCoreDB.Tests/Security/SingleFileEncryptionTests.cs
+// Regression tests for SingleFile (.scdb) encryption via DatabaseOptions.EncryptionKey.
+// Validates the fix where SingleFileStorageProvider was not applying AES-256-GCM
+// encryption to block reads/writes, resulting in plaintext data on disk.
+
+using System;
+using System.IO;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Text;
+using System.Threading.Tasks;
+using Microsoft.Extensions.DependencyInjection;
+using SharpCoreDB;
+using SharpCoreDB.Interfaces;
+using SharpCoreDB.Services;
+using Xunit;
+
+namespace SharpCoreDB.Tests.Security;
+
+public sealed class SingleFileEncryptionTests : IAsyncLifetime
+{
+ private string _testDir = null!;
+ private ServiceProvider _serviceProvider = null!;
+ private DatabaseFactory _factory = null!;
+
+ private const string SecretData = "classified-payload-regression-test-7f3a9c";
+ private const string DummyPassword = "unused-in-singlefile-mode";
+
+ public ValueTask InitializeAsync()
+ {
+ _testDir = Path.Combine(Path.GetTempPath(), "SharpCoreDB_EncTests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_testDir);
+
+ var services = new ServiceCollection();
+ services.AddSharpCoreDB();
+ _serviceProvider = services.BuildServiceProvider();
+ _factory = _serviceProvider.GetRequiredService();
+
+ return ValueTask.CompletedTask;
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await _serviceProvider.DisposeAsync();
+
+ try
+ {
+ if (Directory.Exists(_testDir))
+ Directory.Delete(_testDir, recursive: true);
+ }
+ catch
+ {
+ // Best-effort cleanup on Windows where file handles may linger.
+ }
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────────────
+
+ private static byte[] GenerateKey()
+ {
+ var key = new byte[32];
+ RandomNumberGenerator.Fill(key);
+ return key;
+ }
+
+ private string DbPath(string name) => Path.Combine(_testDir, $"{name}.scdb");
+
+ private DatabaseOptions EncryptedOptions(byte[] key) => new()
+ {
+ StorageMode = StorageMode.SingleFile,
+ EnableEncryption = true,
+ EncryptionKey = key,
+ CreateImmediately = true
+ };
+
+ private static bool FileContainsBytes(string filePath, byte[] needle)
+ {
+ var haystack = File.ReadAllBytes(filePath);
+ for (int i = 0; i <= haystack.Length - needle.Length; i++)
+ {
+ bool match = true;
+ for (int j = 0; j < needle.Length; j++)
+ {
+ if (haystack[i + j] != needle[j]) { match = false; break; }
+ }
+ if (match) return true;
+ }
+ return false;
+ }
+
+ private static string ComputeSha256(string filePath)
+ {
+ using var stream = File.OpenRead(filePath);
+ var hash = SHA256.HashData(stream);
+ return Convert.ToHexString(hash);
+ }
+
+ private async Task CreateEncryptedDbWithSecret(string path, byte[] key)
+ {
+ var db = _factory.CreateWithOptions(path, DummyPassword, EncryptedOptions(key));
+ try
+ {
+ db.ExecuteSQL("CREATE TABLE Secrets (Id INT, Data TEXT)");
+ db.ExecuteSQL($"INSERT INTO Secrets VALUES (1, '{SecretData}')");
+ db.ForceSave();
+ }
+ finally
+ {
+ await db.DisposeAsync();
+ }
+ }
+
+ // ── 1. Plaintext-at-rest ─────────────────────────────────────────────
+
+ ///
+ /// The secret must NOT appear verbatim in the .scdb file when encryption is enabled.
+ /// This is the primary regression test for the original bug where SingleFile mode
+ /// wrote all block data as plaintext regardless of EnableEncryption.
+ ///
+ [Fact]
+ public async Task EncryptedData_IsNotPlaintext_OnDisk()
+ {
+ var path = DbPath("atrest");
+ var key = GenerateKey();
+
+ await CreateEncryptedDbWithSecret(path, key);
+
+ var secretBytes = Encoding.UTF8.GetBytes(SecretData);
+ Assert.False(FileContainsBytes(path, secretBytes),
+ "Secret data was found in plaintext on disk. Encryption is not being applied to SingleFile blocks.");
+ }
+
+ // ── 2. Correct-key roundtrip ─────────────────────────────────────────
+
+ ///
+ /// Data written with a key must be readable when the database is reopened
+ /// with the same key.
+ ///
+ [Fact]
+ public async Task CorrectKey_CanRoundtrip_Data()
+ {
+ var path = DbPath("roundtrip");
+ var key = GenerateKey();
+
+ await CreateEncryptedDbWithSecret(path, key);
+
+ // Reopen with the same key
+ var db = _factory.CreateWithOptions(path, DummyPassword, EncryptedOptions(key));
+ try
+ {
+ var rows = db.ExecuteQuery("SELECT * FROM Secrets");
+ Assert.Single(rows);
+ Assert.Equal(SecretData, rows[0]["Data"]?.ToString());
+ }
+ finally
+ {
+ await db.DisposeAsync();
+ }
+ }
+
+ // ── 3. Wrong-key data inaccessibility ────────────────────────────────
+
+ ///
+ /// Opening with a different 32-byte key must NOT return the original data.
+ /// The current implementation surfaces this as "Table Secrets does not exist"
+ /// because the table directory decrypts to garbage and no tables are loaded.
+ /// A future improvement should throw an explicit AuthenticationException.
+ ///
+ [Fact]
+ public async Task WrongKey_CannotAccess_Data()
+ {
+ var path = DbPath("wrongkey");
+ var correctKey = GenerateKey();
+ var wrongKey = GenerateKey();
+
+ await CreateEncryptedDbWithSecret(path, correctKey);
+
+ var db = _factory.CreateWithOptions(path, DummyPassword, EncryptedOptions(wrongKey));
+ try
+ {
+ // The original table must not be accessible.
+ // Current behavior: table directory decrypts to garbage → table not found.
+ var tables = db.GetTables();
+ Assert.DoesNotContain(tables, t => t.Name.Equals("Secrets", StringComparison.OrdinalIgnoreCase));
+ }
+ finally
+ {
+ await db.DisposeAsync();
+ }
+ }
+
+ // ── 4. Wrong-key file integrity ──────────────────────────────────────
+
+ ///
+ /// Opening with a wrong key must NOT mutate the file on disk.
+ /// The file hash before and after a wrong-key open must be identical.
+ /// This guards against a wrong-key session accidentally overwriting
+ /// encrypted blocks with garbage or plaintext.
+ ///
+ [Fact]
+ public async Task WrongKey_DoesNotMutate_File()
+ {
+ var path = DbPath("fileintegrity");
+ var correctKey = GenerateKey();
+ var wrongKey = GenerateKey();
+
+ await CreateEncryptedDbWithSecret(path, correctKey);
+
+ var hashBefore = ComputeSha256(path);
+
+ // Open with wrong key, perform a read attempt, then dispose
+ var db = _factory.CreateWithOptions(path, DummyPassword, EncryptedOptions(wrongKey));
+ try
+ {
+ // Attempt to query (will fail to find the table, but must not write)
+ try { db.ExecuteQuery("SELECT * FROM Secrets"); }
+ catch { /* Expected: table not found or decryption garbage */ }
+ }
+ finally
+ {
+ await db.DisposeAsync();
+ }
+
+ var hashAfter = ComputeSha256(path);
+ Assert.Equal(hashBefore, hashAfter);
+ }
+
+ // ── 5. Wrong-key reopen survival ─────────────────────────────────────
+
+ ///
+ /// After a wrong-key open-and-close cycle, reopening with the CORRECT key
+ /// must still return the original data. This ensures the wrong-key session
+ /// did not corrupt the encrypted blocks or the table directory.
+ ///
+ [Fact]
+ public async Task WrongKey_OriginalData_SurvivesReopen()
+ {
+ var path = DbPath("survival");
+ var correctKey = GenerateKey();
+ var wrongKey = GenerateKey();
+
+ await CreateEncryptedDbWithSecret(path, correctKey);
+
+ // Wrong-key open and close
+ var wrongDb = _factory.CreateWithOptions(path, DummyPassword, EncryptedOptions(wrongKey));
+ await wrongDb.DisposeAsync();
+
+ // Correct-key reopen must still work
+ var db = _factory.CreateWithOptions(path, DummyPassword, EncryptedOptions(correctKey));
+ try
+ {
+ var rows = db.ExecuteQuery("SELECT * FROM Secrets");
+ Assert.Single(rows);
+ Assert.Equal(SecretData, rows[0]["Data"]?.ToString());
+ }
+ finally
+ {
+ await db.DisposeAsync();
+ }
+ }
+
+ // ── 6. Key length validation ─────────────────────────────────────────
+
+ ///
+ /// DatabaseOptions.Validate() must reject EncryptionKey values that are
+ /// not exactly 32 bytes when EnableEncryption is true.
+ ///
+ [Theory]
+ [InlineData(0)]
+ [InlineData(16)]
+ [InlineData(31)]
+ [InlineData(33)]
+ [InlineData(64)]
+ public void EncryptionKey_MustBe32Bytes(int keyLength)
+ {
+ var options = new DatabaseOptions
+ {
+ StorageMode = StorageMode.SingleFile,
+ EnableEncryption = true,
+ EncryptionKey = new byte[keyLength]
+ };
+
+ Assert.Throws(() => options.Validate());
+ }
+
+ // ── 7. DI requirement ────────────────────────────────────────────────
+
+ ///
+ /// When EnableEncryption is true, ICryptoService must be registered in DI.
+ /// Creating a SingleFile database without ICryptoService must throw.
+ ///
+ [Fact]
+ public void EncryptionKey_RequiresCryptoService_InDI()
+ {
+ // Build a provider WITHOUT ICryptoService
+ var emptyServices = new ServiceCollection();
+ using var emptyProvider = emptyServices.BuildServiceProvider();
+ var emptyFactory = new DatabaseFactory(emptyProvider);
+
+ var path = DbPath("nodiservice");
+ var key = GenerateKey();
+
+ Assert.Throws(() =>
+ emptyFactory.CreateWithOptions(path, DummyPassword, EncryptedOptions(key)));
+ }
+
+ // ── 8. Control: no encryption means plaintext ────────────────────────
+
+ ///
+ /// When EnableEncryption is false, data IS stored in plaintext.
+ /// This is the control test that confirms the plaintext scan in test 1
+ /// is meaningful (i.e., the scan technique works).
+ ///
+ [Fact]
+ public async Task NoEncryption_DataIsPlaintext_Control()
+ {
+ var path = DbPath("control");
+
+ var options = new DatabaseOptions
+ {
+ StorageMode = StorageMode.SingleFile,
+ EnableEncryption = false,
+ CreateImmediately = true
+ };
+
+ var db = _factory.CreateWithOptions(path, DummyPassword, options);
+ try
+ {
+ db.ExecuteSQL("CREATE TABLE Secrets (Id INT, Data TEXT)");
+ db.ExecuteSQL($"INSERT INTO Secrets VALUES (1, '{SecretData}')");
+ db.ForceSave();
+ }
+ finally
+ {
+ await db.DisposeAsync();
+ }
+
+ var secretBytes = Encoding.UTF8.GetBytes(SecretData);
+ Assert.True(FileContainsBytes(path, secretBytes),
+ "Control test: secret should be plaintext when encryption is disabled.");
+ }
+}