diff --git a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs index 55fd7584..0acee656 100644 --- a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs +++ b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs @@ -310,7 +310,7 @@ public static class CultDocumentMessagePackSerialization /// /// Reads a store file: exactly one complete MessagePack array of three slots (header, schema catalog, records), or an empty /// array, which is an empty store. A truncated file, bytes after the array, a value that is not an array, a first slot that - /// is not a header string, a store with fewer or more slots, a record whose schema the catalog does not publish, or a body + /// is not a header string, a store with fewer or more slots, a record whose schema the catalog does not publish, two records under one key, or a body /// that does not decode are refused with /// , so a reader never takes part of a file for the whole and never skips a slot /// it does not understand. The rewriting paths use this same reader as their verdict on whether a file may be replaced. @@ -366,6 +366,7 @@ public static class CultDocumentMessagePackSerialization } RequirePublishedSchemas(snapshot); + RequireUniqueKeys(snapshot); return (snapshot, catalogBytes, recordBytes); } catch (CultStoreUnreadableException) @@ -397,6 +398,17 @@ public static class CultDocumentMessagePackSerialization } } + // A key names one record: identity is the key string alone, compared by ordinal code units (no case folding, no normalisation). + private static void RequireUniqueKeys(CultPersistedStoreSnapshot snapshot) + { + var seen = new HashSet(StringComparer.Ordinal); + foreach (var record in snapshot.Records) + { + if (!seen.Add(record.Key)) + throw new CultStoreUnreadableException($"Record '{record.Key}' appears more than once."); + } + } + // Slot 0 of a non-empty store, read before anything after it: a binary or nil stamp is refused as such, and a string stamp is // judged by the caller's rule when it passes one. Bytes that do not open with an array and a string are left to the full read. private static void JudgeStamp(byte[] payload, Action? requireReadable) diff --git a/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs b/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs index 4aef72a1..3ee64e07 100644 --- a/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs +++ b/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs @@ -74,6 +74,18 @@ namespace GameCult.Caching.Tests return path; } + [Test] + public void ADuplicateKeyIsRefusedNamingTheKeyAndTheFile() + { + var path = Path.Combine(_directory, "dup.cc"); + File.WriteAllBytes(path, File.ReadAllBytes(Path.Combine(VectorRoot(), "dup-key-same-schema-v3.bin"))); + + Assert.That(() => CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }), + Throws.TypeOf() + .With.Property(nameof(CultStoreUnreadableException.Path)).EqualTo(path) + .And.Message.Contains("more than once")); + } + [TestCaseSource(nameof(Vectors))] public void OpenReadsExactlyTheFilesTheVectorsSayItReads(string vector, bool reads) {