diff --git a/src/SharpCoreDB/DataStructures/Table.CRUD.cs b/src/SharpCoreDB/DataStructures/Table.CRUD.cs index 0234bcc8..71b02178 100644 --- a/src/SharpCoreDB/DataStructures/Table.CRUD.cs +++ b/src/SharpCoreDB/DataStructures/Table.CRUD.cs @@ -2356,15 +2356,16 @@ private bool TryBulkUpdateContiguousFixedWidth( } // Narrow, conservative gate: fixed-width columnar table with an explicit PK, plaintext - // records only (a raw contiguous read must equal the logical record bytes), no buffered - // overwrites for this file, and no CHECK constraints (mirrors the generic fastPatch gate). + // records only (no per-record encryption magic — a raw contiguous read must equal the + // logical record bytes), no buffered overwrites for this file, and no CHECK constraints + // (mirrors the generic fastPatch gate). if (!_fixedWidthRecords || StorageMode != StorageMode.Columnar || this.PrimaryKeyIndex < 0 || this.TableCheckConstraints.Count > 0 || HasColumnCheckConstraints() || this.storage is null || - this._config is not { NoEncryptMode: true } || + this.storage.AreRecordsEncrypted(DataFile) || this.storage.HasBufferedOverwrite(DataFile)) { return false; @@ -3047,13 +3048,14 @@ private bool TryBulkDeleteContiguousFixedWidth(List whereConditions) } // Identical safety gate to the UPDATE fast path: fixed-width columnar table with an explicit - // PK, plaintext records only, and no buffered overwrites (a raw range read must equal the - // logical record bytes). DeleteMultiple loads every registered hash index before calling this. + // PK, plaintext records only (no per-record encryption magic — a raw range read must equal + // the logical record bytes), and no buffered overwrites. DeleteMultiple loads every + // registered hash index before calling this. if (!_fixedWidthRecords || StorageMode != StorageMode.Columnar || this.PrimaryKeyIndex < 0 || this.storage is null || - this._config is not { NoEncryptMode: true } || + this.storage.AreRecordsEncrypted(DataFile) || this.storage.HasBufferedOverwrite(DataFile)) { return false; diff --git a/src/SharpCoreDB/Interfaces/IStorage.cs b/src/SharpCoreDB/Interfaces/IStorage.cs index 94b4a70d..71184cd9 100644 --- a/src/SharpCoreDB/Interfaces/IStorage.cs +++ b/src/SharpCoreDB/Interfaces/IStorage.cs @@ -163,6 +163,13 @@ bool OverwriteRecordAtSameLength(string path, long offset, byte[] data) => /// byte[]? ReadBytesRange(string path, long offset, int length) => null; + /// + /// True when the file at stores per-record encrypted (ciphertext) + /// payloads (it carries the encrypted-table magic header). Raw range reads must never be used on + /// such files; the default returns false (plaintext / legacy layouts). + /// + bool AreRecordsEncrypted(string path) => false; + /// /// Enumerates every record in a table data file, yielding the literal file offset of the /// 4-byte length prefix (the offset returned by ) together with the diff --git a/src/SharpCoreDB/Services/Storage.Append.cs b/src/SharpCoreDB/Services/Storage.Append.cs index 07311664..a02d9cca 100644 --- a/src/SharpCoreDB/Services/Storage.Append.cs +++ b/src/SharpCoreDB/Services/Storage.Append.cs @@ -560,6 +560,9 @@ public bool OverwriteRecordAtSameLength(string path, long offset, byte[] data) public bool HasBufferedOverwrite(string path) => !bufferedOverwrites.IsEmpty && bufferedOverwrites.ContainsKey(path); + /// + public bool AreRecordsEncrypted(string path) => UseRecordEncryption && FileHasEncryptedHeader(path); + /// [MethodImpl(MethodImplOptions.AggressiveOptimization)] public long[] AppendBytesMultiple(string path, List dataBlocks) diff --git a/tests/SharpCoreDB.Tests/FixedWidthBulkUpdateTests.cs b/tests/SharpCoreDB.Tests/FixedWidthBulkUpdateTests.cs index c669e785..52b46696 100644 --- a/tests/SharpCoreDB.Tests/FixedWidthBulkUpdateTests.cs +++ b/tests/SharpCoreDB.Tests/FixedWidthBulkUpdateTests.cs @@ -42,6 +42,9 @@ public void Dispose() private IDatabase CreateDb(bool noEncrypt = true) => _factory.Create(_dirPath, "pw", isReadOnly: false, config: new DatabaseConfig { NoEncryptMode = noEncrypt }); + private IDatabase CreateEncryptedDb() => _factory.Create(_dirPath, "pw", isReadOnly: false, + config: new DatabaseConfig { NoEncryptMode = false, EnableAtRestRecordEncryption = true }); + private static Table TableOf(IDatabase db, string tableName) { Assert.True(db.TryGetTable(tableName, out var t)); @@ -209,9 +212,11 @@ public void IndexedColumnUpdate_FallsBack_AndRepointsIndex() } [Fact] - public void EncryptedOrDefaultConfig_FallsBack_AndStaysCorrect() + public void DefaultPlaintextConfig_EngagesBulkPath_AndStaysCorrect() { - var db = CreateDb(noEncrypt: false); // default config: the gate requires NoEncryptMode + // Default config (no NoEncryptMode) stores plaintext records unless per-record encryption + // is opted in, so the runtime magic-header gate now admits the fast path here too. + var db = CreateDb(noEncrypt: false); try { db.ExecuteSQL("CREATE TABLE docs (id INTEGER PRIMARY KEY, name TEXT, score REAL)"); @@ -221,7 +226,29 @@ public void EncryptedOrDefaultConfig_FallsBack_AndStaysCorrect() var table = TableOf(db, "docs"); db.ExecuteBatchSQL(BuildUpdates(1, 300, "score = 4.25")); db.Flush(); - Assert.Equal(0, table.BulkContiguousUpdateBatches); + Assert.Equal(1, table.BulkContiguousUpdateBatches); + + var rows = db.ExecuteQuery("SELECT score FROM docs WHERE id = 300"); + Assert.Single(rows); + Assert.Equal(4.25, Convert.ToDouble(rows[0]["score"])); + } + finally { (db as IDisposable)?.Dispose(); } + } + + [Fact] + public void PerRecordEncryption_FallsBackToGenericLoop_AndStaysCorrect() + { + var db = CreateEncryptedDb(); + try + { + db.ExecuteSQL("CREATE TABLE docs (id INTEGER PRIMARY KEY, name TEXT, score REAL)"); + InsertDocs(db, 1, 300); + db.Flush(); + + var table = TableOf(db, "docs"); + db.ExecuteBatchSQL(BuildUpdates(1, 300, "score = 4.25")); + db.Flush(); + Assert.Equal(0, table.BulkContiguousUpdateBatches); // ciphertext records → generic loop var rows = db.ExecuteQuery("SELECT score FROM docs WHERE id = 300"); Assert.Single(rows);