From 1fb9f62760099e0ae653fcbfef60bbc1c60b7968 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Wed, 11 Nov 2026 19:19:04 +0000 Subject: [PATCH] Desk door tests: a binary or invalid UTF-8 anywhere in a reply is a door failure Pins bin in note, answerer, next_cursor, a card position (value, nil position, key name) and a record id, and 0xff 0xfe as text in the same places, through list, read and answer. The door already refuses both (Walk's default branch throws on Binary; the strict UTF-8 decoder throws on bad bytes), so no source changes: the mutants (Binary written as null; StrictUtf8 = new(false,false)) go red. Co-Authored-By: Claude Sonnet 5.5 --- tests/Bifrost.Web.Tests/Support/DeskServe.cs | 40 ++++++++ .../UnixSocketDeskDoorBytesTests.cs | 95 +++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 tests/Bifrost.Web.Tests/UnixSocketDeskDoorBytesTests.cs diff --git a/tests/Bifrost.Web.Tests/Support/DeskServe.cs b/tests/Bifrost.Web.Tests/Support/DeskServe.cs index 64ec12e..fdc1377 100644 --- a/tests/Bifrost.Web.Tests/Support/DeskServe.cs +++ b/tests/Bifrost.Web.Tests/Support/DeskServe.cs @@ -109,4 +109,44 @@ public static class DeskServe } public static byte[] FromJson(JsonNode node) => MessagePackSerializer.ConvertFromJson(node.ToJsonString(DeskJson.Options)); + + // A list page of one entry, as JSON (the shape the door takes), from a vendored CultLib entry file. + public static JsonObject PageOf(JsonNode entry, string mind = "gamecult-chat") => + JsonNode.Parse($$"""{"schemaVersion":"gamecult.fork.list.v0","mind":"{{mind}}","stamp":"s7","open_count":1,"recorded_count":0,"forks":[{{entry.ToJsonString(DeskJson.Options)}}],"next_cursor":null}""")!.AsObject(); + + public static JsonObject Entry(string file) => + JsonNode.Parse(MessagePackSerializer.ConvertToJson(File.ReadAllBytes(Path.Combine(DeskFeedFixturePinTests.Directory, "cultlib-3a51bbab", file + ".msgpack"))))!.AsObject(); + + // The reply payload exactly as given, asked as a list for "gamecult-chat"; throws what the door throws. + public static async Task ListPayloadAsync(byte[] payload) + { + using var huginn = Socket(payload); + return await new UnixSocketDeskDoor().ListAsync(huginn.Path, Context, new DeskListRequest("gamecult-chat", "open", null, 50), CancellationToken.None); + } + + // The reply payload exactly as given, asked as a read. + public static async Task ReadPayloadAsync(byte[] payload) + { + using var huginn = Socket(payload); + return await new UnixSocketDeskDoor().ReadAsync(huginn.Path, Context, "gamecult-chat:question:slot-mark-consent", CancellationToken.None); + } + + public static async Task AnswerPayloadAsync(byte[] payload) + { + using var huginn = Socket(payload); + return await new UnixSocketDeskDoor().AnswerAsync(huginn.Path, Context, DeskReplay.AnswerBody(DeskReplay.ChatFork), CancellationToken.None); + } + + // A JSON text becomes MessagePack with the one str `marker` (at most 31 bytes) replaced by `with`: a bin, bytes that + // are not UTF-8. JSON cannot spell either, so the marker stands where the test wants one. + public static byte[] WithMarkerReplaced(JsonNode json, string marker, byte[] with) + { + var bytes = MessagePackSerializer.ConvertFromJson(json.ToJsonString(DeskJson.Options)); + var needle = new byte[] { (byte)(0xa0 | marker.Length) }.Concat(System.Text.Encoding.ASCII.GetBytes(marker)).ToArray(); + var at = bytes.AsSpan().IndexOf(needle); + Assert.True(at >= 0 && bytes.AsSpan(at + 1).IndexOf(needle) < 0, "the marker is in the bytes exactly once"); + return [.. bytes[..at], .. with, .. bytes[(at + needle.Length)..]]; + } + + public static byte[] Bin(int length) => [0xc4, (byte)length, .. Enumerable.Repeat((byte)'x', length)]; } diff --git a/tests/Bifrost.Web.Tests/UnixSocketDeskDoorBytesTests.cs b/tests/Bifrost.Web.Tests/UnixSocketDeskDoorBytesTests.cs new file mode 100644 index 0000000..2272696 --- /dev/null +++ b/tests/Bifrost.Web.Tests/UnixSocketDeskDoorBytesTests.cs @@ -0,0 +1,95 @@ +using System.Text.Json.Nodes; +using Bifrost.Web.Features.Desk; +using Bifrost.Web.Tests.Support; + +namespace Bifrost.Web.Tests; + +// The reply's bytes that JSON cannot spell: a MessagePack bin where the contract has text or nil, and text that is not +// UTF-8. Either is a door failure wherever it sits, never a nil or a replacement character Bifrost made up. +public sealed class UnixSocketDeskDoorBytesTests +{ + private const string Marker = "@@MARK@@"; + + // Where a value can stand: a nullable key (note, answerer, next_cursor), a text key, and a card position. + public static TheoryData Positions() => new() + { + "note", "answerer", "next_cursor", "card.raised_at", "card.title", "card.options[0].text", "record.counted_answer_id", "mind", "card_key_name", + }; + + private static JsonObject PageWithMarkerAt(string position) + { + var page = DeskServe.PageOf(DeskServe.Entry("entry-open-counted")); + var entry = page["forks"]![0]!.AsObject(); + switch (position) + { + case "note": entry["record"]!["answers"]![0]!["note"] = Marker; break; + case "answerer": entry["record"]!["answers"]![0]!["answerer"] = Marker; break; + case "next_cursor": page["next_cursor"] = Marker; break; + case "card.raised_at": entry["card"]!["raised_at"] = Marker; break; + case "card.title": entry["card"]!["title"] = Marker; break; + case "card.options[0].text": entry["card"]!["options"]![0]!["text"] = Marker; break; + case "record.counted_answer_id": entry["record"]!["counted_answer_id"] = Marker; break; + case "mind": page["mind"] = Marker; break; + case "card_key_name": + var card = entry["card"]!.AsObject(); + var rest = card.ToList(); + card.Clear(); + foreach (var (key, value) in rest) + { + card[key == "title" ? Marker : key] = value; + } + + break; + default: throw new ArgumentOutOfRangeException(nameof(position)); + } + + return page; + } + + [Theory] + [MemberData(nameof(Positions))] + public async Task A_binary_in_a_nullable_text_or_card_position_is_a_door_failure(string position) + { + var bytes = DeskServe.WithMarkerReplaced(PageWithMarkerAt(position), Marker, DeskServe.Bin(3)); + + await Assert.ThrowsAsync(() => DeskServe.ListPayloadAsync(DeskServe.Forks(bytes))); + } + + [Theory] + [MemberData(nameof(Positions))] + public async Task Invalid_utf8_in_text_is_a_door_failure(string position) + { + // 0xff 0xfe as a str of 2 bytes; Bifrost writes no replacement character. + var bytes = DeskServe.WithMarkerReplaced(PageWithMarkerAt(position), Marker, [0xa2, 0xff, 0xfe]); + + await Assert.ThrowsAsync(() => DeskServe.ListPayloadAsync(DeskServe.Forks(bytes))); + } + + [Fact] + public async Task The_same_marker_as_valid_text_decodes_so_the_failures_above_are_the_bytes_not_the_page() + { + foreach (var position in new[] { "note", "answerer", "next_cursor", "card.raised_at", "card.title" }) + { + var bytes = DeskServe.WithMarkerReplaced(PageWithMarkerAt(position), Marker, [.. new byte[] { 0xa2 }, .. "ok"u8.ToArray()]); + + Assert.IsType(await DeskServe.ListPayloadAsync(DeskServe.Forks(bytes))); + } + } + + [Fact] + public async Task A_binary_or_invalid_utf8_in_a_read_or_an_answer_is_a_door_failure_too() + { + var entry = DeskServe.Entry("entry-open-counted"); + entry["record"]!["answers"]![0]!["note"] = Marker; + foreach (var with in new[] { DeskServe.Bin(2), new byte[] { 0xa2, 0xff, 0xfe } }) + { + var read = DeskServe.WithMarkerReplaced(entry, Marker, with); + await Assert.ThrowsAsync(() => DeskServe.ReadPayloadAsync(DeskServe.Fork(read))); + + var answer = DeskServe.Entry("entry-open-counted")["record"]!["answers"]![0]!; + answer["note"] = Marker; + var bytes = DeskServe.WithMarkerReplaced(answer, Marker, with); + await Assert.ThrowsAsync(() => DeskServe.AnswerPayloadAsync(DeskServe.Answered(bytes, "n1"))); + } + } +}