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 ----