From e561170a0d5e1b70eb3b85fb2f1da541c6d4bbab Mon Sep 17 00:00:00 2001 From: Heimdall Date: Wed, 11 Nov 2026 18:25:28 +0000 Subject: [PATCH] Desk door: strict reply decoding (control characters, closed typed maps, duplicate keys, strict base64, no floats or binary) MessagePack is walked into JSON by the reader, so every control character is escaped and keys, order and integers survive. Every contract map (list, entry, record, answer, refusal) is closed and typed; list.mind must equal the call's Mind; a key written twice anywhere, a float, a binary, trailing bytes, or non-canonical base64 is a DeskDoorException, as is every malformed variant (null elements, non-map values). Co-Authored-By: Claude Sonnet 5.5 --- .../Features/Desk/DeskDocuments.cs | 3 +- .../Features/Desk/UnixSocketDeskDoor.cs | 257 ++++++++++++---- .../Support/FakeHuginnSocket.cs | 40 ++- .../UnixSocketDeskDoorStrictTests.cs | 281 ++++++++++++++++++ .../UnixSocketDeskDoorTests.cs | 12 +- 5 files changed, 517 insertions(+), 76 deletions(-) create mode 100644 tests/Bifrost.Web.Tests/UnixSocketDeskDoorStrictTests.cs diff --git a/src/Bifrost.Web/Features/Desk/DeskDocuments.cs b/src/Bifrost.Web/Features/Desk/DeskDocuments.cs index f78e5f4..6f38e34 100644 --- a/src/Bifrost.Web/Features/Desk/DeskDocuments.cs +++ b/src/Bifrost.Web/Features/Desk/DeskDocuments.cs @@ -9,7 +9,8 @@ namespace Bifrost.Web.Features.Desk; // DeskAnswerDocument, ForkList and AnswerResult are Huginn's documents, typed here so they pass through unchanged. // The card (gamecult.fork.card.v0) is deliberately NOT typed: the Huginn door owns what a reader's card holds (the // decider view carries ids, the non-decider view withholds them and keeps decider_count), and Bifrost neither reads -// nor edits it, so it is an opaque JsonElement that keeps every key, number and key order. +// nor edits it, so it is an opaque JsonElement that keeps every key, key order and integer. The door refuses a float, +// a binary or an extension value anywhere in a reply (the contract has none), so no number is ever rewritten. public static class DeskSchemas { diff --git a/src/Bifrost.Web/Features/Desk/UnixSocketDeskDoor.cs b/src/Bifrost.Web/Features/Desk/UnixSocketDeskDoor.cs index afa5878..6891a66 100644 --- a/src/Bifrost.Web/Features/Desk/UnixSocketDeskDoor.cs +++ b/src/Bifrost.Web/Features/Desk/UnixSocketDeskDoor.cs @@ -14,14 +14,17 @@ namespace Bifrost.Web.Features.Desk; // `unavailable`; this is the one type the client throws for every one of those, so a failure is never an empty page. public sealed class DeskDoorException(string message, Exception? inner = null) : Exception(message, inner); -// The production IDeskDoor: a client of Huginn's desk door (Huginn docs/desk-feed-door.md at 9dde93e). One new +// The production IDeskDoor: a client of Huginn's desk door (Huginn docs/desk-feed-door.md at 79d954c). One new // connection per call, one request frame, one reply frame, closed after, all inside one deadline. The client holds no // connection state, so a dead socket fails only the calls addressed to that Mind. // // frame = u32 big-endian length || CultNet message (MessagePack map), at most 65536 bytes each way // payload = standard base64 of the named MessagePack of Huginn's externally tagged DeskRequest / DeskResponse // -// The reply is decoded by key, never by position. The card inside an entry is not read: it stays an opaque JsonElement. +// The reply is decoded by key, never by position, and strictly: every contract document is a closed map whose keys are +// all present with the right type, a key written twice anywhere is a failure, and so is a float, a binary or an +// extension value (the contract has none; integers keep every digit). The card inside an entry is not read beyond +// being a map: it stays an opaque JsonElement, so whatever keys and integers Huginn wrote leave unchanged. public sealed partial class UnixSocketDeskDoor : IDeskDoor { public const int MaxFrameBytes = 65536; @@ -76,11 +79,7 @@ public sealed partial class UnixSocketDeskDoor : IDeskDoor switch (variant) { case "Answered": - var answer = Document(value, "answer"); - if (answer is null || answer.SchemaVersion != DeskSchemas.DeskAnswer) - { - throw new DeskDoorException("the answered document is not gamecult.desk_answer.v0"); - } + var answer = AnswerDocument(value, "answer"); // Huginn's reply carries no card: a stale_fork answer gets the current card from the edge's read. return new DeskAnswerOutcome.Answered(new AnswerResult( @@ -114,13 +113,7 @@ public sealed partial class UnixSocketDeskDoor : IDeskDoor switch (variant) { case "Fork": - var entry = Document(value, "entry"); - if (entry is null || entry.SchemaVersion != "gamecult.fork.entry.v0" || !IsWholeEntry(entry)) - { - throw new DeskDoorException("the entry is not gamecult.fork.entry.v0"); - } - - return new DeskReadOutcome.Found(entry); + return new DeskReadOutcome.Found(Entry(Required(value, "entry"), "entry")); case "NotStored": var code = RefusalCode(value); // The route's fork id, never the refusal's informational text. @@ -155,14 +148,10 @@ public sealed partial class UnixSocketDeskDoor : IDeskDoor switch (variant) { case "Forks": - var list = Document(value, "list"); - if (list is null || list.SchemaVersion != "gamecult.fork.list.v0" || list.Mind is null || list.Stamp is null - || list.Forks is null || !list.Forks.All(IsWholeEntry)) - { - throw new DeskDoorException("the list is not gamecult.fork.list.v0"); - } - - return new DeskListOutcome.Listed(list); + var list = List(Required(value, "list")); + return list.Mind == context.Mind + ? new DeskListOutcome.Listed(list) + : throw new DeskDoorException("the list is for another Mind"); case "NotStored": var code = RefusalCode(value); if (code != DeskRefusals.ForkTooLarge) @@ -188,21 +177,113 @@ public sealed partial class UnixSocketDeskDoor : IDeskDoor [GeneratedRegex(@"\Afork_too_large: fork (?\S+) at order (?0|[1-9][0-9]*) is past the page budget(?:[ ;][^\r\n]*)?\z", RegexOptions.CultureInvariant)] private static partial Regex ListTooLarge(); - private static bool IsWholeEntry(ForkEntry entry) => - entry.Card.ValueKind == JsonValueKind.Object && entry.Record is { Answers: not null }; + // ---- strict documents: a closed map, every key present with its type, then bound to the typed record ---- + + private enum Kind + { + Text, + TextOrNull, + Flag, + Count, + Map, + Items, + } + + private static readonly (string, Kind)[] AnswerKeys = + [ + ("schemaVersion", Kind.Text), ("answer_id", Kind.Text), ("fork_id", Kind.Text), ("outcome", Kind.Text), + ("of", Kind.TextOrNull), ("retracts", Kind.TextOrNull), ("code", Kind.TextOrNull), ("options_hash", Kind.Text), + ("choice", Kind.TextOrNull), ("note", Kind.TextOrNull), ("answered_at", Kind.Text), ("received_at", Kind.Text), + ("claim_in_future", Kind.Flag), ("answerer", Kind.TextOrNull), ("authority", Kind.Text), ("request_id", Kind.Text), + ]; + + private static void Closed(JsonElement map, string what, string schema, params (string Key, Kind Kind)[] keys) + { + if (map.ValueKind != JsonValueKind.Object || map.EnumerateObject().Count() != keys.Length) + { + throw new DeskDoorException($"{what} is not a closed map of {keys.Length} keys"); + } + + foreach (var (key, kind) in keys) + { + var ok = Property(map, key) is { } v && kind switch + { + Kind.Text => v.ValueKind == JsonValueKind.String, + Kind.TextOrNull => v.ValueKind is JsonValueKind.String or JsonValueKind.Null, + Kind.Flag => v.ValueKind is JsonValueKind.True or JsonValueKind.False, + Kind.Count => v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var n) && n >= 0, + Kind.Map => v.ValueKind == JsonValueKind.Object, + _ => v.ValueKind == JsonValueKind.Array, + }; + if (!ok) + { + throw new DeskDoorException($"{what}.{key} is missing or not the contract's type"); + } + } + + if (schema.Length > 0 && Property(map, "schemaVersion")!.Value.GetString() != schema) + { + throw new DeskDoorException($"{what} is not {schema}"); + } + } + + private static DeskAnswerDocument AnswerDocument(JsonElement value, string what) + { + var map = Required(value, what); + Closed(map, what, DeskSchemas.DeskAnswer, AnswerKeys); + return Bind(map, what); + } + + private static ForkEntry Entry(JsonElement map, string what) + { + Closed(map, what, "gamecult.fork.entry.v0", ("schemaVersion", Kind.Text), ("card", Kind.Map), ("record", Kind.Map)); + var record = Property(map, "record")!.Value; + Closed(record, what + ".record", "gamecult.fork.record.v0", + ("schemaVersion", Kind.Text), ("fork_id", Kind.Text), ("options_hash", Kind.Text), ("counted_answer_id", Kind.TextOrNull), + ("answerer_count", Kind.Count), ("answers", Kind.Items)); + foreach (var answer in Property(record, "answers")!.Value.EnumerateArray()) + { + Closed(answer, what + ".record.answers[]", DeskSchemas.DeskAnswer, AnswerKeys); + } + + return Bind(map, what); + } + + private static ForkList List(JsonElement map) + { + Closed(map, "list", "gamecult.fork.list.v0", + ("schemaVersion", Kind.Text), ("mind", Kind.Text), ("stamp", Kind.Text), ("open_count", Kind.Count), + ("recorded_count", Kind.Count), ("forks", Kind.Items), ("next_cursor", Kind.TextOrNull)); + foreach (var entry in Property(map, "forks")!.Value.EnumerateArray()) + { + Entry(entry, "list.forks[]"); + } + + return Bind(map, "list"); + } + + private static T Bind(JsonElement map, string what) + { + try + { + return map.Deserialize(DeskJson.Options) ?? throw new DeskDoorException($"{what} does not decode"); + } + catch (JsonException e) + { + throw new DeskDoorException($"{what} does not decode", e); + } + } + + private static JsonElement Required(JsonElement map, string name) => + Property(map, name) ?? throw new DeskDoorException($"{name} is missing"); private static DeskDoorException Unexpected(string variant) => new($"the door answered with {(variant == "Refused" ? "a Mind refusal" : "a variant this call does not take")}"); private static string RefusalCode(JsonElement refusal) { - if (RequiredString(refusal, "schemaVersion") != DeskSchemas.Refusal) - { - throw new DeskDoorException("the refusal is not gamecult.fork.refusal.v0"); - } - + Closed(refusal, "the refusal", DeskSchemas.Refusal, ("schemaVersion", Kind.Text), ("code", Kind.Text), ("error", Kind.Text)); var code = RequiredString(refusal, "code"); - _ = RequiredString(refusal, "error"); return code is DeskRefusals.ForkUnknown or DeskRefusals.BodyInvalid or DeskRefusals.RequestIdConflict or DeskRefusals.ForkTooLarge ? code : throw new DeskDoorException("the refusal code is not one the contract names"); @@ -363,13 +444,24 @@ public sealed partial class UnixSocketDeskDoor : IDeskDoor throw new DeskDoorException("the reply carries no desk response"); } - var payload = new byte[(RequiredString(root, "payload").Length * 3 / 4) + 3]; - if (!Convert.TryFromBase64String(RequiredString(root, "payload"), payload, out var written)) + // Strict standard base64: Convert would skip whitespace, so the text must be the canonical encoding of what it decodes to. + var text = RequiredString(root, "payload"); + byte[] payload; + try { - throw new DeskDoorException("the payload is not base64"); + payload = Convert.FromBase64String(text); + } + catch (FormatException e) + { + throw new DeskDoorException("the payload is not base64", e); } - using var body = ToJson(payload.AsMemory(0, written)); + if (Convert.ToBase64String(payload) != text) + { + throw new DeskDoorException("the payload is not strict standard base64"); + } + + using var body = ToJson(payload); if (body.RootElement.ValueKind != JsonValueKind.Object) { throw new DeskDoorException("the payload is not a tagged variant"); @@ -407,45 +499,100 @@ public sealed partial class UnixSocketDeskDoor : IDeskDoor } } + // MessagePack to JSON text by walking the reader: the JSON writer escapes every control character, keys keep their + // order, integers keep every digit. A map key that is not text, a key written twice, a float, a binary, an + // extension, invalid UTF-8 or nesting past 64 is a failure; none is in the contract. private static JsonDocument ToJson(ReadOnlyMemory messagePack) { var reader = new MessagePackReader(messagePack); - var json = new StringWriter(CultureInfo.InvariantCulture); - MessagePackSerializer.ConvertToJson(ref reader, json); + var json = new ArrayBufferWriter(); + using (var writer = new Utf8JsonWriter(json, new JsonWriterOptions { MaxDepth = 64 })) + { + Walk(ref reader, writer, 0); + } + if (!reader.End) { throw new DeskDoorException("bytes follow the message"); } - return JsonDocument.Parse(json.ToString(), new JsonDocumentOptions { MaxDepth = 64 }); + return JsonDocument.Parse(json.WrittenMemory, new JsonDocumentOptions { MaxDepth = 64 }); } - private static T? Document(JsonElement map, string name) + private static void Walk(ref MessagePackReader reader, Utf8JsonWriter writer, int depth) { - try + if (depth > 64) { - return (Property(map, name) ?? throw new DeskDoorException($"{name} is missing")).Deserialize(DeskJson.Options); + throw new DeskDoorException("the message nests past 64"); } - catch (JsonException e) + + switch (reader.NextMessagePackType) { - throw new DeskDoorException($"{name} does not decode", e); + case MessagePackType.Nil: + reader.ReadNil(); + writer.WriteNullValue(); + break; + case MessagePackType.Boolean: + writer.WriteBooleanValue(reader.ReadBoolean()); + break; + case MessagePackType.Integer: + if (reader.NextCode == MessagePackCode.UInt64) + { + writer.WriteNumberValue(reader.ReadUInt64()); + } + else + { + writer.WriteNumberValue(reader.ReadInt64()); + } + + break; + case MessagePackType.String: + writer.WriteStringValue(Text(ref reader)); + break; + case MessagePackType.Array: + var items = reader.ReadArrayHeader(); + writer.WriteStartArray(); + for (var i = 0; i < items; i++) + { + Walk(ref reader, writer, depth + 1); + } + + writer.WriteEndArray(); + break; + case MessagePackType.Map: + var pairs = reader.ReadMapHeader(); + var seen = new HashSet(StringComparer.Ordinal); + writer.WriteStartObject(); + for (var i = 0; i < pairs; i++) + { + if (reader.NextMessagePackType != MessagePackType.String) + { + throw new DeskDoorException("a map key is not text"); + } + + var key = Text(ref reader); + if (!seen.Add(key)) + { + throw new DeskDoorException($"{key} is written twice"); + } + + writer.WritePropertyName(key); + Walk(ref reader, writer, depth + 1); + } + + writer.WriteEndObject(); + break; + default: + throw new DeskDoorException("the message holds a float, binary or extension value, which the contract does not use"); } } - // A key's value by name; a key written twice is a fault, not a choice. - private static JsonElement? Property(JsonElement map, string name) - { - JsonElement? found = null; - foreach (var property in map.EnumerateObject()) - { - if (property.Name == name) - { - found = found is null ? property.Value : throw new DeskDoorException($"{name} is written twice"); - } - } + private static string Text(ref MessagePackReader reader) => + StrictUtf8.GetString(reader.ReadStringSequence() ?? throw new DeskDoorException("text is nil")); - return found; - } + // A key's value by name; a key written twice never gets here (the walk refuses it). + private static JsonElement? Property(JsonElement map, string name) => + map.ValueKind == JsonValueKind.Object && map.TryGetProperty(name, out var value) ? value : null; private static string RequiredString(JsonElement map, string name) => Property(map, name) is { ValueKind: JsonValueKind.String } value diff --git a/tests/Bifrost.Web.Tests/Support/FakeHuginnSocket.cs b/tests/Bifrost.Web.Tests/Support/FakeHuginnSocket.cs index 0207e84..30b8b15 100644 --- a/tests/Bifrost.Web.Tests/Support/FakeHuginnSocket.cs +++ b/tests/Bifrost.Web.Tests/Support/FakeHuginnSocket.cs @@ -50,6 +50,15 @@ public sealed class FakeHuginnSocket : IDisposable await socket.SendAsync(reply, SocketFlags.None, token); }; + // Answers each request with a frame the test builds from the request's message id and operation. + public static Func Build(Func frameOf) => + async (socket, request, token) => + { + using var envelope = DeskWire.Envelope(request); + var root = envelope.RootElement; + await socket.SendAsync(frameOf(root.GetProperty("messageId").GetString()!, root.GetProperty("operation").GetString()!), SocketFlags.None, token); + }; + private async Task AcceptLoopAsync() { while (!stop.IsCancellationRequested) @@ -143,21 +152,26 @@ public static class DeskWire return JsonDocument.Parse(json.ToString()); } - // A reply frame in operation-message key order. `mutate` edits the text fields before they are written. + // A reply frame in operation-message key order. `mutate` edits the text fields (a pair can be added twice) before + // they are written; `trailing` rides inside the frame's one message, after the map. public static byte[] ResponseFrame( - string messageId, string operation, string status, string variantJson, Action>? mutate = null) + string messageId, string operation, string status, string variantJson, Action>? mutate = null) => + ResponseFrame(messageId, operation, status, MessagePackSerializer.ConvertFromJson(variantJson), mutate); + + public static byte[] ResponseFrame( + string messageId, string operation, string status, byte[] payload, Action>? mutate = null, byte[]? trailing = null) { - var fields = new SortedDictionary(StringComparer.Ordinal) + var fields = new List<(string Key, string Value)> { - ["messageId"] = messageId, - ["operation"] = operation, - ["payload"] = Convert.ToBase64String(MessagePackSerializer.ConvertFromJson(variantJson)), - ["payloadEncoding"] = "messagepack-base64", - ["payloadSchema"] = "huginn.desk_response.v1", - ["schemaVersion"] = "cultnet.operation_response.v0", - ["serviceId"] = "huginn.desk", - ["sourceRuntimeId"] = "huginn-test", - ["status"] = status, + ("messageId", messageId), + ("operation", operation), + ("payload", Convert.ToBase64String(payload)), + ("payloadEncoding", "messagepack-base64"), + ("payloadSchema", "huginn.desk_response.v1"), + ("schemaVersion", "cultnet.operation_response.v0"), + ("serviceId", "huginn.desk"), + ("sourceRuntimeId", "huginn-test"), + ("status", status), }; mutate?.Invoke(fields); var buffer = new ArrayBufferWriter(); @@ -170,7 +184,7 @@ public static class DeskWire } w.Flush(); - return Frame(buffer.WrittenSpan); + return Frame([.. buffer.WrittenSpan, .. trailing ?? []]); } public static byte[] Frame(ReadOnlySpan message) diff --git a/tests/Bifrost.Web.Tests/UnixSocketDeskDoorStrictTests.cs b/tests/Bifrost.Web.Tests/UnixSocketDeskDoorStrictTests.cs new file mode 100644 index 0000000..b8a034b --- /dev/null +++ b/tests/Bifrost.Web.Tests/UnixSocketDeskDoorStrictTests.cs @@ -0,0 +1,281 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Bifrost.Web.Features.Desk; +using Bifrost.Web.Tests.Support; +using MessagePack; + +namespace Bifrost.Web.Tests; + +// The door reads Huginn's reply strictly: text survives whatever it holds, every contract map is closed and typed, +// a key written twice or a float is a failure, and every malformed reply is a DeskDoorException (never a null +// reference, never an invalid-operation). +public sealed class UnixSocketDeskDoorStrictTests +{ + private static readonly DeskDoorContext Context = new("eureka", "Member", "5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6f", ["5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6f", "0b9d7e3a-6c21-4f58-a0d4-9e8f7a6b5c4d"]); + + private const string Card = """{"schemaVersion":"gamecult.fork.card.v0","fork_id":"x:question:a","title":"t","order":1,"options_hash":"ab"}"""; + + private static string AnswerJson(string note = "null", string answerer = "\"a1\"") => + $$$"""{"schemaVersion":"gamecult.desk_answer.v0","answer_id":"n1","fork_id":"x:question:a","outcome":"Counted","of":null,"retracts":null,"code":null,"options_hash":"ab","choice":"a","note":{{{note}}},"answered_at":"2026-11-11T09:00:00Z","received_at":"2026-11-11T09:00:01Z","claim_in_future":false,"answerer":{{{answerer}}},"authority":"Member","request_id":"r"}"""; + + private static string RecordJson(string answers = "[]", string extra = "") => + $$$"""{"schemaVersion":"gamecult.fork.record.v0","fork_id":"x:question:a","options_hash":"ab","counted_answer_id":null,"answerer_count":0,"answers":{{{answers}}}{{{extra}}}}"""; + + private static string EntryJson(string card = Card, string? record = null, string extra = "") => + $$$"""{"schemaVersion":"gamecult.fork.entry.v0","card":{{{card}}},"record":{{{record ?? RecordJson()}}}{{{extra}}}}"""; + + private static string ListJson(string forks, string mind = "\"eureka\"", string keys = "\"open_count\":1,\"recorded_count\":0,") => + $$$"""{"schemaVersion":"gamecult.fork.list.v0","mind":{{{mind}}},"stamp":"s1",{{{keys}}}"forks":{{{forks}}},"next_cursor":null}"""; + + private static string Forks(string list) => $$$"""{"Forks":{"list":{{{list}}},"as_of":1}}"""; + + private static Task List(FakeHuginnSocket huginn) => + new UnixSocketDeskDoor(() => "m-1").ListAsync(huginn.Path, Context, new DeskListRequest("eureka", "open", null, 50), CancellationToken.None); + + private static Task Read(FakeHuginnSocket huginn) => + new UnixSocketDeskDoor(() => "m-1").ReadAsync(huginn.Path, Context, "x:question:a", CancellationToken.None); + + private static FakeHuginnSocket Replying(string variantJson, string status = "accepted") => + new(FakeHuginnSocket.Answer((_, _) => variantJson, status)); + + private static FakeHuginnSocket ReplyingBytes(byte[] payload, Action>? mutate = null, byte[]? trailing = null, string operation = "forks") => + new(FakeHuginnSocket.Build((id, _) => DeskWire.ResponseFrame(id, operation, "accepted", payload, mutate, trailing))); + + private static async Task Faults(Func call) => await Assert.ThrowsAsync(call); + + // ---- F1: control characters in text ---- + + [Fact] + public async Task Text_with_control_characters_survives_the_door_and_the_edge() + { + const string Note = "n\\u0000\\u0007\\u000b\\u001b\\u001f\\u0008\\t\\n\\\"\\\\ end"; + var card = Card.Replace("\"t\"", "\"title \\u001b\\u0000\"", StringComparison.Ordinal); + var entry = EntryJson(card, RecordJson($"[{AnswerJson($"\"{Note}\"")}]").Replace("\"answerer_count\":0", "\"answerer_count\":1", StringComparison.Ordinal)); + using var huginn = Replying(Forks(ListJson($"[{entry}]"))); + + var listed = Assert.IsType(await List(huginn)); + + var expected = "n\0\a\v\u001b\u001f\b\t\n\"\\ end"; + Assert.Equal(expected, Assert.Single(Assert.Single(listed.List.Forks).Record.Answers).Note); + Assert.Equal("title \u001b\0", Assert.Single(listed.List.Forks).Card.GetProperty("title").GetString()); + + // The edge writes it back as JSON a client can parse, the decider's note whole. + using var chat = Replying(Forks(ListJson($"[{entry}]", "\"gamecult-chat\""))); + var config = DeskWebApplicationFactory.DefaultConfiguration(); + foreach (var key in config.Keys.Where(k => k.EndsWith(":Id", StringComparison.Ordinal)).ToList()) + { + config[key[..^2] + "Socket"] = config[key] == "nibu" ? FakeHuginnSocket.Absent() : chat.Path; + } + + using var factory = new DeskWebApplicationFactory(config, fakeDoor: false); + await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true); + var reply = await factory.SendAsync("GET", "/api/desk/gamecult-chat/forks", token: factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.read")); + + Assert.Equal(200, reply.Status); + Assert.Equal(expected, (string?)JsonNode.Parse(reply.Text)!["forks"]![0]!["record"]!["answers"]![0]!["note"]); + } + + // ---- F2 / F3: strict documents ---- + + public static TheoryData MalformedLists() + { + var one = EntryJson(); + return new TheoryData + { + { "missing open_count", Forks(ListJson($"[{one}]", keys: "\"recorded_count\":0,")) }, + { "missing recorded_count", Forks(ListJson($"[{one}]", keys: "\"open_count\":1,")) }, + { "missing both counts", Forks(ListJson($"[{one}]", keys: "")) }, + { "negative count", Forks(ListJson($"[{one}]", keys: "\"open_count\":-1,\"recorded_count\":0,")) }, + { "mind is another Mind's", Forks(ListJson($"[{one}]", "\"nibu\"")) }, + { "mind is not text", Forks(ListJson($"[{one}]", "7")) }, + { "missing next_cursor", Forks(ListJson($"[{one}]").Replace(",\"next_cursor\":null", "", StringComparison.Ordinal)) }, + { "unknown list key", Forks(ListJson($"[{one}]").Replace("\"stamp\"", "\"extra\":1,\"stamp\"", StringComparison.Ordinal)) }, + { "forks is null", Forks(ListJson("null")) }, + { "forks:[null]", Forks(ListJson("[null]")) }, + { "forks:[5]", Forks(ListJson("[5]")) }, + { "card is not a map", Forks(ListJson($"[{EntryJson("[]")}]")) }, + { "card is null", Forks(ListJson($"[{EntryJson("null")}]")) }, + { "record missing schemaVersion", Forks(ListJson($"[{EntryJson(record: RecordJson().Replace("\"schemaVersion\":\"gamecult.fork.record.v0\",", "", StringComparison.Ordinal))}]")) }, + { "record missing fork_id", Forks(ListJson($"[{EntryJson(record: RecordJson().Replace("\"fork_id\":\"x:question:a\",", "", StringComparison.Ordinal))}]")) }, + { "record missing options_hash", Forks(ListJson($"[{EntryJson(record: RecordJson().Replace("\"options_hash\":\"ab\",", "", StringComparison.Ordinal))}]")) }, + { "record missing answerer_count", Forks(ListJson($"[{EntryJson(record: RecordJson().Replace("\"answerer_count\":0,", "", StringComparison.Ordinal))}]")) }, + { "record answerer_count is a string", Forks(ListJson($"[{EntryJson(record: RecordJson().Replace("\"answerer_count\":0", "\"answerer_count\":\"0\"", StringComparison.Ordinal))}]")) }, + { "answers:[null]", Forks(ListJson($"[{EntryJson(record: RecordJson("[null]"))}]")) }, + { "answers is null", Forks(ListJson($"[{EntryJson(record: RecordJson("null"))}]")) }, + { "answer with only schemaVersion", Forks(ListJson($"[{EntryJson(record: RecordJson("[{\"schemaVersion\":\"gamecult.desk_answer.v0\"}]"))}]")) }, + { "answer missing answerer", Forks(ListJson($"[{EntryJson(record: RecordJson($"[{AnswerJson().Replace(",\"answerer\":\"a1\"", "", StringComparison.Ordinal)}]"))}]")) }, + { "answer claim_in_future is text", Forks(ListJson($"[{EntryJson(record: RecordJson($"[{AnswerJson().Replace("\"claim_in_future\":false", "\"claim_in_future\":\"no\"", StringComparison.Ordinal)}]"))}]")) }, + { "unknown record key", Forks(ListJson($"[{EntryJson(record: RecordJson(extra: ",\"viewer\":1"))}]")) }, + { "unknown entry key", Forks(ListJson($"[{EntryJson(extra: ",\"viewer\":1")}]")) }, + { "unknown answer key", Forks(ListJson($"[{EntryJson(record: RecordJson($"[{AnswerJson().TrimEnd('}')},\"viewer\":1}}]"))}]")) }, + { "duplicate key in the record", Forks(ListJson($"[{EntryJson(record: RecordJson(extra: ",\"fork_id\":\"other\""))}]")) }, + { "duplicate key in the card", Forks(ListJson($"[{EntryJson(Card.TrimEnd('}') + ",\"title\":\"again\"}")}]")) }, + { "duplicate key in the list", Forks(ListJson($"[{one}]").TrimEnd('}') + ",\"stamp\":\"s2\"}") }, + { "float in the card", Forks(ListJson($"[{EntryJson(Card.TrimEnd('}') + ",\"weight\":1.0}")}]")) }, + { "float in the record", Forks(ListJson($"[{EntryJson(record: RecordJson(extra: ",\"weight\":1.5"))}]")) }, + { "Forks value is a bare number", """{"Forks":5}""" }, + { "Forks value is null", """{"Forks":null}""" }, + { "Forks without list", """{"Forks":{"as_of":1}}""" }, + { "NotStored is text", """{"NotStored":"x"}""" }, + { "NotStored is null", """{"NotStored":null}""" }, + { "NotStored refusal has an extra key", """{"NotStored":{"schemaVersion":"gamecult.fork.refusal.v0","code":"fork_unknown","error":"x","z":1}}""" }, + }; + } + + [Theory] + [MemberData(nameof(MalformedLists))] + public async Task A_malformed_list_reply_is_a_door_failure(string label, string reply) + { + _ = label; + using var huginn = Replying(reply, reply.StartsWith("{\"NotStored", StringComparison.Ordinal) ? "rejected" : "accepted"); + await Faults(() => List(huginn)); + } + + public static TheoryData MalformedReads() => new() + { + { "Fork is a number", """{"Fork":5}""" }, + { "Fork is null", """{"Fork":null}""" }, + { "Fork without entry", """{"Fork":{"as_of":1}}""" }, + { "entry holds answers:[null]", $$$"""{"Fork":{"entry":{{{EntryJson(record: RecordJson("[null]"))}}},"as_of":1}}""" }, + { "entry with a nil card", $$$"""{"Fork":{"entry":{{{EntryJson("null")}}},"as_of":1}}""" }, + }; + + [Theory] + [MemberData(nameof(MalformedReads))] + public async Task A_malformed_read_reply_is_a_door_failure(string label, string reply) + { + _ = label; + using var huginn = Replying(reply); + await Faults(() => Read(huginn)); + } + + [Fact] + public async Task Answered_with_only_a_schemaVersion_or_a_missing_counted_id_is_a_failure() + { + foreach (var reply in new[] + { + """{"Answered":{"answer":{"schemaVersion":"gamecult.desk_answer.v0"},"counted_answer_id":null,"as_of":1}}""", + """{"Answered":{"answer":null,"counted_answer_id":null,"as_of":1}}""", + """{"Answered":5}""", + $$$"""{"Answered":{"answer":{{{AnswerJson()}}},"as_of":1}}""", + }) + { + using var huginn = Replying(reply); + await Faults(() => new UnixSocketDeskDoor().AnswerAsync(huginn.Path, Context, DeskReplay.AnswerBody("x:question:a"), CancellationToken.None)); + } + } + + [Fact] + public async Task fork_too_large_is_never_an_answer_refusal() + { + using var huginn = Replying("""{"NotStored":{"schemaVersion":"gamecult.fork.refusal.v0","code":"fork_too_large","error":"fork_too_large: fork x:question:a is past the response bound by itself"}}""", "rejected"); + + await Faults(() => new UnixSocketDeskDoor().AnswerAsync(huginn.Path, Context, DeskReplay.AnswerBody("x:question:a"), CancellationToken.None)); + } + + [Fact] + public async Task A_nil_answerer_and_big_integers_come_through_whole() + { + var card = Card.TrimEnd('}') + ",\"big\":18446744073709551615,\"low\":-9223372036854775808,\"n\":null}"; + var entry = EntryJson(card, RecordJson($"[{AnswerJson(answerer: "null")}]").Replace("\"answerer_count\":0", "\"answerer_count\":1", StringComparison.Ordinal)); + using var huginn = Replying(Forks(ListJson($"[{entry}]"))); + + var listed = Assert.IsType(await List(huginn)); + + var fork = Assert.Single(listed.List.Forks); + Assert.Equal(card, JsonSerializer.Serialize(fork.Card, DeskJson.Options)); + Assert.Null(Assert.Single(fork.Record.Answers).Answerer); + } + + // ---- F4 / the survivors: the envelope and the bytes around it ---- + + private static byte[] ListPayload() => MessagePackSerializer.ConvertFromJson(Forks(ListJson($"[{EntryJson()}]"))); + + [Fact] + public async Task A_well_formed_reply_built_the_same_way_decodes() + { + using var huginn = ReplyingBytes(ListPayload()); + Assert.IsType(await List(huginn)); + } + + [Fact] + public async Task Payload_base64_must_be_strict_standard_base64() + { + foreach (var mutate in new Action>[] + { + fields => fields[fields.FindIndex(f => f.Key == "payload")] = ("payload", Insert(fields, "\n")), + fields => fields[fields.FindIndex(f => f.Key == "payload")] = ("payload", Insert(fields, " ")), + fields => fields[fields.FindIndex(f => f.Key == "payload")] = ("payload", fields.First(f => f.Key == "payload").Value.Replace('+', '-').Replace('/', '_') + "A"), + fields => fields[fields.FindIndex(f => f.Key == "payload")] = ("payload", fields.First(f => f.Key == "payload").Value.TrimEnd('=')), + }) + { + using var huginn = ReplyingBytes(ListPayload(), mutate); + await Faults(() => List(huginn)); + } + } + + private static string Insert(List<(string Key, string Value)> fields, string text) + { + var value = fields.First(f => f.Key == "payload").Value; + return value[..8] + text + value[8..]; + } + + [Fact] + public async Task Bytes_after_the_payload_message_or_after_the_frame_message_are_a_failure() + { + using var afterPayload = ReplyingBytes([.. ListPayload(), 0xc0]); + await Faults(() => List(afterPayload)); + + using var afterFrame = ReplyingBytes(ListPayload(), trailing: [0xc0]); + await Faults(() => List(afterFrame)); + } + + [Fact] + public async Task A_key_written_twice_in_the_envelope_or_the_variant_is_a_failure() + { + using var envelope = ReplyingBytes(ListPayload(), fields => fields.Add(("status", "rejected"))); + await Faults(() => List(envelope)); + + using var variant = ReplyingBytes(MessagePackSerializer.ConvertFromJson(Forks(ListJson("[]")).Replace("\"as_of\":1", "\"as_of\":1,\"as_of\":2", StringComparison.Ordinal))); + await Faults(() => List(variant)); + } + + private static byte[] BinaryList() + { + var w = new System.Buffers.ArrayBufferWriter(); + var writer = new MessagePackWriter(w); + writer.WriteMapHeader(1); + writer.Write("Forks"); + writer.WriteMapHeader(2); + writer.Write("list"); + writer.Write(new byte[] { 1, 2, 3 }); + writer.Write("as_of"); + writer.Write(1); + writer.Flush(); + return w.WrittenSpan.ToArray(); + } + + [Fact] + public async Task A_binary_anywhere_is_a_failure_not_a_rewritten_value() + { + using var bin = ReplyingBytes(BinaryList()); + await Faults(() => List(bin)); + } + + // ---- the request, value by value ---- + + [Fact] + public async Task An_answer_request_carries_the_contexts_mind_subject_authority_and_deciders_and_the_body_as_a_map() + { + using var huginn = Replying("""{"NotStored":{"schemaVersion":"gamecult.fork.refusal.v0","code":"fork_unknown","error":"x"}}""", "rejected"); + + await new UnixSocketDeskDoor().AnswerAsync(huginn.Path, Context, DeskReplay.AnswerBody("x:question:a"), CancellationToken.None); + + using var envelope = DeskWire.Envelope(Assert.Single(huginn.Requests)); + using var payload = DeskWire.Payload(envelope); + var answer = payload.RootElement.GetProperty("Answer"); + Assert.Equal(("eureka", "5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6f", "Member"), (answer.GetProperty("mind").GetString(), answer.GetProperty("subject").GetString(), answer.GetProperty("authority").GetString())); + Assert.Equal(Context.Deciders, answer.GetProperty("deciders").EnumerateArray().Select(d => d.GetString())); + Assert.Equal("x:question:a", answer.GetProperty("request").GetProperty("fork_id").GetString()); + } +} diff --git a/tests/Bifrost.Web.Tests/UnixSocketDeskDoorTests.cs b/tests/Bifrost.Web.Tests/UnixSocketDeskDoorTests.cs index b0e10b0..9c8d9e8 100644 --- a/tests/Bifrost.Web.Tests/UnixSocketDeskDoorTests.cs +++ b/tests/Bifrost.Web.Tests/UnixSocketDeskDoorTests.cs @@ -285,8 +285,8 @@ public sealed class UnixSocketDeskDoorTests private static string EntryOf(string card, JsonObject record) => $$$"""{"schemaVersion":"gamecult.fork.entry.v0","card":{{{card}}},"record":{{{record.ToJsonString(DeskJson.Options)}}}}"""; - private static string ListOf(string entry) => - $$$"""{"Forks":{"list":{"schemaVersion":"gamecult.fork.list.v0","mind":"gamecult-chat","stamp":"s7","open_count":1,"recorded_count":0,"forks":[{{{entry}}}],"next_cursor":null},"as_of":7}}"""; + private static string ListOf(string entry, string mind = "eureka") => + $$$"""{"Forks":{"list":{"schemaVersion":"gamecult.fork.list.v0","mind":"{{{mind}}}","stamp":"s7","open_count":1,"recorded_count":0,"forks":[{{{entry}}}],"next_cursor":null},"as_of":7}}"""; [Fact] public void Every_account_id_in_the_fixtures_is_a_lowercase_D_format_uuid_and_the_non_decider_fragments_name_none() @@ -330,10 +330,8 @@ public sealed class UnixSocketDeskDoorTests [Fact] public async Task A_decider_record_keeps_its_ids_and_a_non_decider_record_comes_through_with_nil_answerers_and_the_count() { - var chat = DeskWebApplicationFactory.SocketOf("gamecult-chat"); - _ = chat; // the edge below talks to a real socket, not the fake door's table var nonDecider = Fragment("record-non-decider.proposed"); - using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Answer((_, _) => ListOf(EntryOf(ViewCard("card-viewers-non-decider.synthetic"), nonDecider)))); + using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Answer((_, _) => ListOf(EntryOf(ViewCard("card-viewers-non-decider.synthetic"), nonDecider), "gamecult-chat"))); using var factory = new DeskWebApplicationFactory(ConfigurationWith(huginn.Path, FakeHuginnSocket.Absent()), fakeDoor: false); await factory.EnsureAccountAsync(DeskWebApplicationFactory.Member, true); var reply = await factory.SendAsync("GET", "/api/desk/gamecult-chat/forks", token: factory.Token(DeskWebApplicationFactory.Member, "bifrost-desk", "desk.read")); @@ -355,7 +353,7 @@ public sealed class UnixSocketDeskDoorTests // The decider's view, as the contract spells it, keeps every id and every note. var decider = DeciderRecord(); - using var huginn2 = new FakeHuginnSocket(FakeHuginnSocket.Answer((_, _) => ListOf(EntryOf(ViewCard("card-viewers-decider.synthetic"), decider)))); + using var huginn2 = new FakeHuginnSocket(FakeHuginnSocket.Answer((_, _) => ListOf(EntryOf(ViewCard("card-viewers-decider.synthetic"), decider), "gamecult-chat"))); using var factory2 = new DeskWebApplicationFactory(ConfigurationWith(huginn2.Path, FakeHuginnSocket.Absent()), fakeDoor: false); await factory2.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true); var own = await factory2.SendAsync("GET", "/api/desk/gamecult-chat/forks", token: factory2.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.read")); @@ -376,7 +374,7 @@ public sealed class UnixSocketDeskDoorTests var failure = await Assert.ThrowsAsync(() => List(new UnixSocketDeskDoor(), huginn.Path)); - Assert.Contains("does not decode", failure.Message); + Assert.Contains("closed map", failure.Message); } // ---- (d) one dead Mind fails only itself, through the HTTP edge ----