From b17436ce4f3e920077bba92dd3525bbad1fbad25 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 19:58:04 +0200 Subject: [PATCH 1/9] Concept subject registry and `concepts plan|apply` host command ConceptSubjectRegistry is the only writer of ConceptSubject. ConceptKey is the one key grammar (anchored with \z), shared with ConceptAppraisalService. Not yet built or tested at this commit. Co-Authored-By: Claude Opus 5.5 --- .../ConceptVotes/ConceptAppraisalService.cs | 5 +- .../Features/ConceptVotes/ConceptCommand.cs | 56 +++ .../Features/ConceptVotes/ConceptKey.cs | 13 + .../ConceptVotes/ConceptSubjectRegistry.cs | 225 +++++++++++ src/Bifrost.Web/Program.cs | 23 +- .../Bifrost.Web.Tests.csproj | 4 + .../ConceptSubjectRegistryTests.cs | 374 ++++++++++++++++++ .../Fixtures/mill-three-ships.json | 9 + 8 files changed, 701 insertions(+), 8 deletions(-) create mode 100644 src/Bifrost.Web/Features/ConceptVotes/ConceptCommand.cs create mode 100644 src/Bifrost.Web/Features/ConceptVotes/ConceptKey.cs create mode 100644 src/Bifrost.Web/Features/ConceptVotes/ConceptSubjectRegistry.cs create mode 100644 tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs create mode 100644 tests/Bifrost.Web.Tests/Fixtures/mill-three-ships.json diff --git a/src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs b/src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs index 11893ad..9e0132b 100644 --- a/src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs @@ -1,5 +1,4 @@ using System.Net; -using System.Text.RegularExpressions; using Bifrost.Web.Configuration; using Bifrost.Web.Data; using Bifrost.Web.Domain; @@ -40,8 +39,6 @@ public sealed class ConceptAppraisalService( { private const string UniqueViolation = "23505"; - private static readonly Regex Slug = new("^[a-z0-9-]{1,80}$", RegexOptions.CultureInvariant); - public async Task CastAsync( string collection, string key, @@ -260,7 +257,7 @@ public sealed class ConceptAppraisalService( } private Task FindSubjectAsync(string collection, string key, CancellationToken cancellationToken) => - Slug.IsMatch(collection) && Slug.IsMatch(key) + ConceptKey.IsValid(collection) && ConceptKey.IsValid(key) ? dbContext.ConceptSubjects .SingleOrDefaultAsync(x => x.Collection == collection && x.Key == key, cancellationToken) : Task.FromResult(null); diff --git a/src/Bifrost.Web/Features/ConceptVotes/ConceptCommand.cs b/src/Bifrost.Web/Features/ConceptVotes/ConceptCommand.cs new file mode 100644 index 0000000..6ff5567 --- /dev/null +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptCommand.cs @@ -0,0 +1,56 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Bifrost.Web.Features.ConceptVotes; + +// Host command: Bifrost.Web.dll concepts plan|apply [--retire k1,k2] +public static class ConceptCommand +{ + public const int Ok = 0; + public const int Refused = 1; + public const int Usage = 2; + + // args are the words after "concepts". + public static async Task RunAsync(string[] args, IServiceProvider services, TextWriter output) + { + var known = args.Length > 0 && args[0] is "plan" or "apply"; + var hasRetire = args.Length == 5 && args[3] == "--retire"; + if (!known || !(args.Length == 3 || hasRetire)) + { + await output.WriteLineAsync("usage: concepts plan|apply [--retire k1,k2]"); + return Usage; + } + + string[] retire = hasRetire ? args[4].Split(',', StringSplitOptions.RemoveEmptyEntries) : []; + try + { + string json; + try + { + json = await File.ReadAllTextAsync(args[2]); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + throw new ManifestRefusedException("manifest", "Unreadable"); + } + + var ships = ConceptSubjectRegistry.ParseManifest(json); + await using var scope = services.CreateAsyncScope(); + var registry = scope.ServiceProvider.GetRequiredService(); + var plan = args[0] == "apply" + ? await registry.ApplyAsync(args[1], ships, retire, default) + : await registry.PlanAsync(args[1], ships, retire, default); + + foreach (var entry in plan.Entries) + { + await output.WriteLineAsync($"{entry.Change.ToString().ToLowerInvariant()} {entry.Key}"); + } + + return Ok; + } + catch (ManifestRefusedException ex) + { + await output.WriteLineAsync($"refused {ex.Field} {ex.Code}"); + return Refused; + } + } +} diff --git a/src/Bifrost.Web/Features/ConceptVotes/ConceptKey.cs b/src/Bifrost.Web/Features/ConceptVotes/ConceptKey.cs new file mode 100644 index 0000000..2be54e9 --- /dev/null +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptKey.cs @@ -0,0 +1,13 @@ +using System.Text.RegularExpressions; + +namespace Bifrost.Web.Features.ConceptVotes; + +// The one grammar for a concept collection or key. \z, not $: $ admits a trailing line feed. +public static class ConceptKey +{ + public const int MaxLength = 80; + + private static readonly Regex Shape = new(@"^[a-z0-9-]{1,80}\z", RegexOptions.CultureInvariant); + + public static bool IsValid(string? value) => value is not null && Shape.IsMatch(value); +} diff --git a/src/Bifrost.Web/Features/ConceptVotes/ConceptSubjectRegistry.cs b/src/Bifrost.Web/Features/ConceptVotes/ConceptSubjectRegistry.cs new file mode 100644 index 0000000..ba4d171 --- /dev/null +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptSubjectRegistry.cs @@ -0,0 +1,225 @@ +using System.Text.Json; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Shared; +using Microsoft.EntityFrameworkCore; + +namespace Bifrost.Web.Features.ConceptVotes; + +public enum SubjectChange +{ + Add, + Rename, + Unchanged, + Revive, + Absent, + Retire +} + +public sealed record ManifestShip(string Key, string Title); + +public sealed record SubjectPlanEntry(SubjectChange Change, string Key, string Title); + +public sealed record SubjectPlan(string Collection, IReadOnlyList Entries); + +// A refusal names the field and a code, never the offending value. +public sealed class ManifestRefusedException(string field, string code) + : Exception($"{field}: {code}") +{ + public string Field { get; } = field; + + public string Code { get; } = code; +} + +// The only writer of ConceptSubject: decides which concept keys exist, their titles and their retirement. +public sealed class ConceptSubjectRegistry( + BifrostDbContext dbContext, + AuditTrailService auditTrail, + TimeProvider timeProvider) +{ + public const int MaxTitleLength = 240; + + // The Mill manifest is JSON from outside the Verse. Only ships[].id and ships[].name are read. + public static IReadOnlyList ParseManifest(string json) + { + JsonDocument document; + try + { + document = JsonDocument.Parse(json); + } + catch (JsonException) + { + throw new ManifestRefusedException("manifest", "NotJson"); + } + + using (document) + { + if (document.RootElement.ValueKind != JsonValueKind.Object || + !document.RootElement.TryGetProperty("ships", out var ships) || + ships.ValueKind != JsonValueKind.Array) + { + throw new ManifestRefusedException("ships", "Missing"); + } + + var result = new List(); + var seen = new HashSet(StringComparer.Ordinal); + foreach (var ship in ships.EnumerateArray()) + { + var at = $"ships[{result.Count}]"; + var key = ReadString(ship, "id", at); + var title = ReadString(ship, "name", at); + if (!ConceptKey.IsValid(key)) + { + throw new ManifestRefusedException($"{at}.id", "InvalidKey"); + } + + if (!seen.Add(key)) + { + throw new ManifestRefusedException($"{at}.id", "Duplicate"); + } + + if (string.IsNullOrWhiteSpace(title) || title.Any(char.IsControl)) + { + throw new ManifestRefusedException($"{at}.name", "InvalidTitle"); + } + + if (title.Length > MaxTitleLength) + { + throw new ManifestRefusedException($"{at}.name", "TooLong"); + } + + result.Add(new ManifestShip(key, title)); + } + + return result; + } + } + + public async Task PlanAsync( + string collection, + IReadOnlyList ships, + IReadOnlyCollection retire, + CancellationToken cancellationToken) + { + if (!ConceptKey.IsValid(collection)) + { + throw new ManifestRefusedException("collection", "InvalidKey"); + } + + var named = new HashSet(StringComparer.Ordinal); + foreach (var key in retire) + { + if (!ConceptKey.IsValid(key)) + { + throw new ManifestRefusedException("retire", "InvalidKey"); + } + + named.Add(key); + } + + var registered = await dbContext.ConceptSubjects.AsNoTracking() + .Where(x => x.Collection == collection) + .ToDictionaryAsync(x => x.Key, StringComparer.Ordinal, cancellationToken); + + var entries = new List(); + foreach (var ship in ships) + { + var change = !registered.TryGetValue(ship.Key, out var row) + ? SubjectChange.Add + : row.RetiredAtUtc is not null + ? SubjectChange.Revive + : row.Title != ship.Title ? SubjectChange.Rename : SubjectChange.Unchanged; + entries.Add(new SubjectPlanEntry(change, ship.Key, ship.Title)); + } + + var inManifest = ships.Select(x => x.Key).ToHashSet(StringComparer.Ordinal); + var absent = registered.Values + .Where(x => x.RetiredAtUtc is null && !inManifest.Contains(x.Key)) + .OrderBy(x => x.Key, StringComparer.Ordinal) + .ToList(); + foreach (var subject in absent) + { + entries.Add(new SubjectPlanEntry( + named.Remove(subject.Key) ? SubjectChange.Retire : SubjectChange.Absent, + subject.Key, + subject.Title)); + } + + // Whatever is still named is not a registered, live key missing from the manifest. + if (named.Count > 0) + { + throw new ManifestRefusedException("retire", "NotAbsent"); + } + + return new SubjectPlan(collection, entries); + } + + public async Task ApplyAsync( + string collection, + IReadOnlyList ships, + IReadOnlyCollection retire, + CancellationToken cancellationToken) + { + var plan = await PlanAsync(collection, ships, retire, cancellationToken); + var rows = await dbContext.ConceptSubjects + .Where(x => x.Collection == collection) + .ToDictionaryAsync(x => x.Key, StringComparer.Ordinal, cancellationToken); + + foreach (var entry in plan.Entries) + { + if (entry.Change is SubjectChange.Unchanged or SubjectChange.Absent) + { + continue; + } + + ConceptSubject subject; + if (entry.Change == SubjectChange.Add) + { + subject = new ConceptSubject + { + Collection = collection, + Key = entry.Key, + Title = entry.Title, + RegisteredAtUtc = timeProvider.GetUtcNow() + }; + dbContext.ConceptSubjects.Add(subject); + } + else + { + subject = rows[entry.Key]; + if (entry.Change == SubjectChange.Retire) + { + subject.RetiredAtUtc = timeProvider.GetUtcNow(); + } + else + { + subject.Title = entry.Title; + subject.RetiredAtUtc = null; + } + } + + // The audit write saves the subject change in the same SaveChanges. + await auditTrail.RecordAsync( + null, + nameof(ConceptSubject), + subject.Id, + entry.Change.ToString(), + $"actor=host-command collection={collection} key={entry.Key}", + cancellationToken); + } + + return plan; + } + + private static string ReadString(JsonElement ship, string name, string at) + { + if (ship.ValueKind != JsonValueKind.Object || !ship.TryGetProperty(name, out var value)) + { + throw new ManifestRefusedException($"{at}.{name}", "Missing"); + } + + return value.ValueKind == JsonValueKind.String + ? value.GetString()! + : throw new ManifestRefusedException($"{at}.{name}", "NotString"); + } +} diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index bec0271..8376009 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -24,6 +24,23 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using System.Net; +// Host command: registers Mill concept subjects with configuration and the database only; no web app, no migrations. +if (args.Length > 0 && args[0] == "concepts") +{ + var host = Host.CreateApplicationBuilder([]); + host.Services.AddDbContext(options => options.UseNpgsql(BifrostConnectionString(host.Configuration))); + host.Services.AddSingleton(TimeProvider.System); + host.Services.AddScoped(); + host.Services.AddScoped(); + using var commandHost = host.Build(); + Environment.ExitCode = await ConceptCommand.RunAsync(args[1..], commandHost.Services, Console.Out); + return; +} + +static string BifrostConnectionString(IConfiguration configuration) => + configuration.GetConnectionString("Bifrost") + ?? "Host=127.0.0.1;Database=bifrost;Username=postgres;Password=postgres"; + var builder = WebApplication.CreateBuilder(args); builder.Services @@ -70,10 +87,7 @@ builder.Services.ConfigureHttpJsonOptions(options => builder.Services.AddDbContext(options => { - var connectionString = builder.Configuration.GetConnectionString("Bifrost") - ?? "Host=127.0.0.1;Database=bifrost;Username=postgres;Password=postgres"; - - options.UseNpgsql(connectionString); + options.UseNpgsql(BifrostConnectionString(builder.Configuration)); }); builder.Services.AddHttpLogging(options => @@ -103,6 +117,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); diff --git a/tests/Bifrost.Web.Tests/Bifrost.Web.Tests.csproj b/tests/Bifrost.Web.Tests/Bifrost.Web.Tests.csproj index 355d11c..77670c1 100644 --- a/tests/Bifrost.Web.Tests/Bifrost.Web.Tests.csproj +++ b/tests/Bifrost.Web.Tests/Bifrost.Web.Tests.csproj @@ -20,6 +20,10 @@ + + + + diff --git a/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs b/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs new file mode 100644 index 0000000..cc26700 --- /dev/null +++ b/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs @@ -0,0 +1,374 @@ +using System.Net; +using Bifrost.Web.Configuration; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.ConceptVotes; +using Bifrost.Web.Features.Shared; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Bifrost.Web.Tests; + +public sealed class ConceptSubjectRegistryTests +{ + private const string Collection = "aetheria-concept-mill"; + private static readonly string[] None = []; + + private static string FixturePath => Path.Combine(AppContext.BaseDirectory, "Fixtures", "mill-three-ships.json"); + + private static IReadOnlyList Fixture() => + ConceptSubjectRegistry.ParseManifest(File.ReadAllText(FixturePath)); + + [Fact] + public async Task Rerun_of_the_same_manifest_is_a_no_op() + { + using var world = new World(); + + var first = await world.Apply(Fixture()); + Assert.All(first.Entries, e => Assert.Equal(SubjectChange.Add, e.Change)); + Assert.Equal(3, await world.Fresh(db => db.AuditEvents.CountAsync(x => x.EntityType == nameof(ConceptSubject)))); + + var before = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().OrderBy(x => x.Key).ToListAsync()); + world.Clock.Advance(TimeSpan.FromHours(1)); + var second = await world.Apply(Fixture()); + + Assert.All(second.Entries, e => Assert.Equal(SubjectChange.Unchanged, e.Change)); + Assert.Equal(3, await world.Fresh(db => db.AuditEvents.CountAsync())); + var after = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().OrderBy(x => x.Key).ToListAsync()); + Assert.Equal(before.Select(x => (x.Id, x.Title, x.RegisteredAtUtc, x.RetiredAtUtc)), + after.Select(x => (x.Id, x.Title, x.RegisteredAtUtc, x.RetiredAtUtc))); + } + + [Fact] + public async Task Apply_writes_one_host_command_audit_event_per_change_and_none_for_a_plan() + { + using var world = new World(); + + var plan = await world.Plan(Fixture()); + Assert.All(plan.Entries, e => Assert.Equal(SubjectChange.Add, e.Change)); + Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync())); + Assert.Empty(await world.Fresh(db => db.AuditEvents.ToListAsync())); + + await world.Apply(Fixture()); + + var events = await world.Fresh(db => db.AuditEvents.ToListAsync()); + var subjectIds = (await world.Fresh(db => db.ConceptSubjects.ToListAsync())).Select(x => x.Id.ToString()).ToHashSet(); + Assert.Equal(3, events.Count); + Assert.All(events, e => + { + Assert.Equal(nameof(ConceptSubject), e.EntityType); + Assert.Equal("Add", e.Action); + Assert.Null(e.ActorUserAccountId); + Assert.Contains("actor=host-command", e.Detail); + Assert.Contains(e.EntityId, subjectIds); + }); + } + + [Fact] + public async Task Rename_changes_title_and_keeps_votes() + { + using var world = new World(); + await world.Apply(Fixture()); + await world.Cast("bison", ConceptStance.Up); + await world.Cast("kestrel", ConceptStance.Down); + + var renamed = Fixture().Select(s => s.Key == "bison" ? s with { Title = "Bison Prime" } : s).ToList(); + var plan = await world.Apply(renamed); + + Assert.Equal(SubjectChange.Rename, plan.Entries.Single(e => e.Key == "bison").Change); + Assert.Equal(2, plan.Entries.Count(e => e.Change == SubjectChange.Unchanged)); + var subject = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "bison")); + Assert.Equal("Bison Prime", subject.Title); + Assert.Null(subject.RetiredAtUtc); + Assert.Equal(2, await world.Fresh(db => db.ConceptAppraisals.CountAsync())); + var view = await world.Fresh(db => world.Appraisals(db).GetSubjectAsync(Collection, "bison", null, default)); + Assert.Equal("Bison Prime", view!.Title); + Assert.Equal(new ConceptTally(1, 0, new StanceCounts(0, 0)), view.Tally); + Assert.Equal(1, await world.Fresh(db => db.AuditEvents.CountAsync(x => x.Action == "Rename"))); + } + + [Fact] + public async Task Absent_key_is_retired_only_when_named() + { + using var world = new World(); + await world.Apply(Fixture()); + await world.Cast("kestrel", ConceptStance.Up); + var smaller = Fixture().Where(s => s.Key != "kestrel").ToList(); + + var unnamed = await world.Apply(smaller); + Assert.Equal(SubjectChange.Absent, unnamed.Entries.Single(e => e.Key == "kestrel").Change); + Assert.Null((await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "kestrel"))).RetiredAtUtc); + Assert.Contains("kestrel", (await world.Fresh(db => world.Appraisals(db).GetTalliesAsync(Collection, default))).Keys); + + var named = await world.Apply(smaller, ["kestrel"]); + Assert.Equal(SubjectChange.Retire, named.Entries.Single(e => e.Key == "kestrel").Change); + var retired = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "kestrel")); + Assert.NotNull(retired.RetiredAtUtc); + Assert.Equal(1, await world.Fresh(db => db.ConceptAppraisals.CountAsync())); + Assert.DoesNotContain("kestrel", (await world.Fresh(db => world.Appraisals(db).GetTalliesAsync(Collection, default))).Keys); + Assert.Equal(CastResult.RetiredSubject, await world.Cast("kestrel", ConceptStance.Down)); + Assert.Equal(1, await world.Fresh(db => db.AuditEvents.CountAsync(x => x.Action == "Retire"))); + } + + [Theory] + [InlineData("bison")] + [InlineData("never-registered")] + public async Task Retire_naming_a_key_that_is_not_registered_live_and_absent_is_refused_whole(string named) + { + using var world = new World(); + await world.Apply(Fixture()); + var renamed = Fixture().Select(s => s.Key == "kestrel" ? s with { Title = "Kestrel II" } : s).ToList(); + + var refusal = await Assert.ThrowsAsync(() => world.Apply(renamed, [named])); + + Assert.Equal(("retire", "NotAbsent"), (refusal.Field, refusal.Code)); + Assert.DoesNotContain(named, refusal.Message); + Assert.Equal("Kestrel", (await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "kestrel"))).Title); + Assert.Equal(3, await world.Fresh(db => db.AuditEvents.CountAsync())); + } + + [Fact] + public async Task Naming_an_already_retired_key_is_refused() + { + using var world = new World(); + await world.Apply(Fixture()); + var smaller = Fixture().Where(s => s.Key != "kestrel").ToList(); + await world.Apply(smaller, ["kestrel"]); + + await Assert.ThrowsAsync(() => world.Apply(smaller, ["kestrel"])); + } + + [Fact] + public async Task Re_registering_a_retired_key_revives_it() + { + using var world = new World(); + await world.Apply(Fixture()); + await world.Cast("kestrel", ConceptStance.Up); + await world.Apply(Fixture().Where(s => s.Key != "kestrel").ToList(), ["kestrel"]); + + var plan = await world.Apply(Fixture()); + + Assert.Equal(SubjectChange.Revive, plan.Entries.Single(e => e.Key == "kestrel").Change); + var subject = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "kestrel")); + Assert.Null(subject.RetiredAtUtc); + var tallies = await world.Fresh(db => world.Appraisals(db).GetTalliesAsync(Collection, default)); + Assert.Equal(new ConceptTally(1, 0, new StanceCounts(0, 0)), tallies["kestrel"]); + Assert.Equal(1, await world.Fresh(db => db.AuditEvents.CountAsync(x => x.Action == "Revive"))); + } + + [Theory] + [InlineData("""{"ships":[{"id":"a","name":"A"},{"id":"a","name":"B"}]}""", "ships[1].id", "Duplicate")] + [InlineData("""{"ships":[{"id":"Upper","name":"A"}]}""", "ships[0].id", "InvalidKey")] + [InlineData("""{"ships":[{"id":"a/b","name":"A"}]}""", "ships[0].id", "InvalidKey")] + [InlineData("""{"ships":[{"id":"ship-a\n","name":"A"}]}""", "ships[0].id", "InvalidKey")] + [InlineData("""{"ships":[{"id":"","name":"A"}]}""", "ships[0].id", "InvalidKey")] + [InlineData("""{"ships":[{"id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","name":"A"}]}""", "ships[0].id", "InvalidKey")] + [InlineData("""{"ships":[{"id":"a"}]}""", "ships[0].name", "Missing")] + [InlineData("""{"ships":[{"id":"a","name":7}]}""", "ships[0].name", "NotString")] + [InlineData("""{"ships":[{"id":"a","name":" "}]}""", "ships[0].name", "InvalidTitle")] + [InlineData("""{"ships":[{"id":"a","name":"bad\ntitle"}]}""", "ships[0].name", "InvalidTitle")] + [InlineData("""{"ships":["a"]}""", "ships[0].id", "Missing")] + [InlineData("""{"fleet":[]}""", "ships", "Missing")] + [InlineData("not json", "manifest", "NotJson")] + public async Task Malformed_manifest_is_refused_whole(string json, string field, string code) + { + using var world = new World(); + var refusal = Assert.Throws(() => ConceptSubjectRegistry.ParseManifest(json)); + + Assert.Equal((field, code), (refusal.Field, refusal.Code)); + Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync())); + } + + [Fact] + public async Task A_bad_entry_after_good_ones_refuses_the_file_and_writes_no_row() + { + using var world = new World(); + var json = """{"ships":[{"id":"good","name":"Good"},{"id":"ship-a\n","name":"Bad"}]}"""; + + Assert.Throws(() => ConceptSubjectRegistry.ParseManifest(json)); + Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync())); + } + + [Fact] + public async Task Refusal_never_echoes_the_offending_value() + { + const string canary = "CANARY-secret/value"; + var json = $$"""{"ships":[{"id":"{{canary}}","name":"A"}]}"""; + + var refusal = Assert.Throws(() => ConceptSubjectRegistry.ParseManifest(json)); + Assert.DoesNotContain("CANARY", refusal.Message); + using var world = new World(); + var collection = await Assert.ThrowsAsync(() => world.Registry(world.NewDb()).PlanAsync(canary, [], None, default)); + Assert.DoesNotContain("CANARY", collection.Message); + var retire = await Assert.ThrowsAsync(() => world.Registry(world.NewDb()).PlanAsync(Collection, [], [canary], default)); + Assert.DoesNotContain("CANARY", retire.Message); + } + + [Theory] + [InlineData("ship-a\n")] + [InlineData("ship-a\r\n")] + [InlineData("ship-a ")] + public async Task The_key_grammar_rejects_a_trailing_line_feed_wherever_it_is_reused(string forged) + { + using var world = new World(); + await world.Apply(Fixture()); + await world.Registry(world.NewDb()).ApplyAsync(Collection, [new ManifestShip("ship-a", "A")], None, default); + + Assert.False(ConceptKey.IsValid(forged)); + Assert.False(ConceptKey.IsValid("key\n")); + Assert.True(ConceptKey.IsValid("ship-a")); + Assert.Equal(CastResult.UnknownSubject, await world.Cast(forged, ConceptStance.Up)); + Assert.Null(await world.Fresh(db => world.Appraisals(db).GetSubjectAsync(Collection, forged, null, default))); + await Assert.ThrowsAsync(() => + world.Registry(world.NewDb()).PlanAsync(Collection + "\n", [], None, default)); + await Assert.ThrowsAsync(() => + world.Registry(world.NewDb()).PlanAsync(Collection, [], ["ship-a\n"], default)); + Assert.Empty(await world.Fresh(db => db.ConceptAppraisals.ToListAsync())); + } + + [Fact] + public async Task Host_command_plans_and_applies_with_exit_codes() + { + using var world = new World(); + var services = world.Services(); + + var plan = new StringWriter(); + Assert.Equal(ConceptCommand.Ok, await ConceptCommand.RunAsync(["plan", Collection, FixturePath], services, plan)); + Assert.Contains("add bison", plan.ToString()); + Assert.Contains("add ibis-mk2", plan.ToString()); + Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync())); + Assert.Empty(await world.Fresh(db => db.AuditEvents.ToListAsync())); + + var apply = new StringWriter(); + Assert.Equal(ConceptCommand.Ok, await ConceptCommand.RunAsync(["apply", Collection, FixturePath], services, apply)); + Assert.Equal(3, await world.Fresh(db => db.ConceptSubjects.CountAsync())); + + var again = new StringWriter(); + Assert.Equal(ConceptCommand.Ok, await ConceptCommand.RunAsync(["apply", Collection, FixturePath], services, again)); + Assert.Contains("unchanged bison", again.ToString()); + Assert.Equal(3, await world.Fresh(db => db.AuditEvents.CountAsync())); + } + + [Fact] + public async Task Host_command_retire_flag_retires_the_named_absent_key() + { + using var world = new World(); + var services = world.Services(); + await ConceptCommand.RunAsync(["apply", Collection, FixturePath], services, new StringWriter()); + var smaller = Path.Combine(world.Dir, "two.json"); + await File.WriteAllTextAsync(smaller, """{"ships":[{"id":"bison","name":"Bison"},{"id":"ibis-mk2","name":"Ibis Mk II"}]}"""); + + var output = new StringWriter(); + Assert.Equal(ConceptCommand.Ok, await ConceptCommand.RunAsync(["apply", Collection, smaller, "--retire", "kestrel"], services, output)); + + Assert.Contains("retire kestrel", output.ToString()); + Assert.NotNull((await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "kestrel"))).RetiredAtUtc); + } + + [Fact] + public async Task Host_command_exits_non_zero_on_refusal_and_never_echoes_input() + { + using var world = new World(); + var services = world.Services(); + var bad = Path.Combine(world.Dir, "bad.json"); + await File.WriteAllTextAsync(bad, """{"ships":[{"id":"CANARY/Id","name":"A"}]}"""); + + var refused = new StringWriter(); + Assert.Equal(ConceptCommand.Refused, await ConceptCommand.RunAsync(["apply", Collection, bad], services, refused)); + Assert.Contains("ships[0].id", refused.ToString()); + Assert.DoesNotContain("CANARY", refused.ToString()); + + var missing = new StringWriter(); + Assert.Equal(ConceptCommand.Refused, await ConceptCommand.RunAsync(["apply", Collection, Path.Combine(world.Dir, "CANARY-missing.json")], services, missing)); + Assert.DoesNotContain("CANARY", missing.ToString()); + Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync())); + } + + [Theory] + [InlineData("frobnicate", "c", "m")] + [InlineData("plan", "c")] + [InlineData("apply", "c", "m", "--bogus", "x")] + [InlineData("apply", "c", "m", "--retire")] + public async Task Host_command_unknown_subcommand_or_shape_exits_2(params string[] args) + { + using var world = new World(); + + var output = new StringWriter(); + Assert.Equal(ConceptCommand.Usage, await ConceptCommand.RunAsync(args, world.Services(), output)); + Assert.StartsWith("usage:", output.ToString()); + Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync())); + Assert.Equal(ConceptCommand.Usage, await ConceptCommand.RunAsync([], world.Services(), new StringWriter())); + } + + private sealed class World : IDisposable + { + private readonly string _dbName = $"registry-{Guid.NewGuid():N}"; + + public World() + { + Dir = Path.Combine(Path.GetTempPath(), $"registry-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Dir); + } + + public string Dir { get; } + + public ManualClock Clock { get; } = new(new DateTimeOffset(2026, 10, 10, 12, 0, 0, TimeSpan.Zero)); + + public BifrostDbContext NewDb() => + new(new DbContextOptionsBuilder().UseInMemoryDatabase(_dbName).Options); + + public ConceptSubjectRegistry Registry(BifrostDbContext db) => + new(db, new AuditTrailService(db, Clock), Clock); + + public ConceptAppraisalService Appraisals(BifrostDbContext db) => + new(db, new AuditTrailService(db, Clock), Clock, + Options.Create(new ConceptVoteOptions { NetworkTagSecret = "test-tag-secret" })); + + public async Task Apply(IReadOnlyList ships, string[]? retire = null) + { + await using var db = NewDb(); + return await Registry(db).ApplyAsync(Collection, ships, retire ?? None, default); + } + + public async Task Plan(IReadOnlyList ships) + { + await using var db = NewDb(); + return await Registry(db).PlanAsync(Collection, ships, None, default); + } + + public async Task Cast(string key, ConceptStance stance) + { + await using var db = NewDb(); + var voter = VoterIdentity.For(CurrentBifrostActor.Anonymous, "device-" + key + stance)!; + return await Appraisals(db).CastAsync(Collection, key, voter, stance, IPAddress.Parse("203.0.113.7"), default); + } + + public async Task Fresh(Func> read) + { + await using var db = NewDb(); + return await read(db); + } + + public IServiceProvider Services() + { + var services = new ServiceCollection(); + services.AddDbContext(o => o.UseInMemoryDatabase(_dbName)); + services.AddSingleton(Clock); + services.AddScoped(); + services.AddScoped(); + return services.BuildServiceProvider(); + } + + public void Dispose() => Directory.Delete(Dir, true); + } + + private sealed class ManualClock(DateTimeOffset start) : TimeProvider + { + private DateTimeOffset _now = start; + + public override DateTimeOffset GetUtcNow() => _now; + + public void Advance(TimeSpan by) => _now += by; + } +} diff --git a/tests/Bifrost.Web.Tests/Fixtures/mill-three-ships.json b/tests/Bifrost.Web.Tests/Fixtures/mill-three-ships.json new file mode 100644 index 0000000..bde47d0 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/mill-three-ships.json @@ -0,0 +1,9 @@ +{ + "sections": [], + "votes": {}, + "ships": [ + { "id": "bison", "name": "Bison", "code": "AU", "role": "Swarm escort" }, + { "id": "ibis-mk2", "name": "Ibis Mk II", "code": "AU" }, + { "id": "kestrel", "name": "Kestrel" } + ] +} From 34325e0efda6b4ffbfa57e017585ba489aaf7bdd Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 20:00:04 +0200 Subject: [PATCH 2/9] Registry tests: the helper voter's device id no longer embeds the subject key Commit b17436c does not pass: the forged-key test failed on an invalid device id. Co-Authored-By: Claude Opus 5.5 --- tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs b/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs index cc26700..bbf8dd3 100644 --- a/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs @@ -340,7 +340,7 @@ public sealed class ConceptSubjectRegistryTests public async Task Cast(string key, ConceptStance stance) { await using var db = NewDb(); - var voter = VoterIdentity.For(CurrentBifrostActor.Anonymous, "device-" + key + stance)!; + var voter = VoterIdentity.For(CurrentBifrostActor.Anonymous, "device-" + stance)!; return await Appraisals(db).CastAsync(Collection, key, voter, stance, IPAddress.Parse("203.0.113.7"), default); } From 46d92200e5306369a82bf1bb9d65f72e28c987eb Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 20:34:03 +0200 Subject: [PATCH 3/9] Registry: parse and read through small helpers so definite assignment survives mutation Stryker flagged ParseManifest, ReadString and RunAsync as compile errors in safe mode (out vars and try-assigned locals), leaving them unmutated. Behaviour unchanged. Co-Authored-By: Claude Opus 5.5 --- .../Features/ConceptVotes/ConceptCommand.cs | 24 ++-- .../ConceptVotes/ConceptSubjectRegistry.cs | 103 ++++++++++-------- 2 files changed, 71 insertions(+), 56 deletions(-) diff --git a/src/Bifrost.Web/Features/ConceptVotes/ConceptCommand.cs b/src/Bifrost.Web/Features/ConceptVotes/ConceptCommand.cs index 6ff5567..9edab61 100644 --- a/src/Bifrost.Web/Features/ConceptVotes/ConceptCommand.cs +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptCommand.cs @@ -23,17 +23,7 @@ public static class ConceptCommand string[] retire = hasRetire ? args[4].Split(',', StringSplitOptions.RemoveEmptyEntries) : []; try { - string json; - try - { - json = await File.ReadAllTextAsync(args[2]); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - throw new ManifestRefusedException("manifest", "Unreadable"); - } - - var ships = ConceptSubjectRegistry.ParseManifest(json); + var ships = ConceptSubjectRegistry.ParseManifest(await ReadManifestAsync(args[2])); await using var scope = services.CreateAsyncScope(); var registry = scope.ServiceProvider.GetRequiredService(); var plan = args[0] == "apply" @@ -53,4 +43,16 @@ public static class ConceptCommand return Refused; } } + + private static async Task ReadManifestAsync(string path) + { + try + { + return await File.ReadAllTextAsync(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + throw new ManifestRefusedException("manifest", "Unreadable"); + } + } } diff --git a/src/Bifrost.Web/Features/ConceptVotes/ConceptSubjectRegistry.cs b/src/Bifrost.Web/Features/ConceptVotes/ConceptSubjectRegistry.cs index ba4d171..846f0d8 100644 --- a/src/Bifrost.Web/Features/ConceptVotes/ConceptSubjectRegistry.cs +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptSubjectRegistry.cs @@ -42,57 +42,65 @@ public sealed class ConceptSubjectRegistry( // The Mill manifest is JSON from outside the Verse. Only ships[].id and ships[].name are read. public static IReadOnlyList ParseManifest(string json) { - JsonDocument document; + using var document = ParseDocument(json); + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + throw new ManifestRefusedException("ships", "Missing"); + } + + if (!document.RootElement.TryGetProperty("ships", out var ships)) + { + throw new ManifestRefusedException("ships", "Missing"); + } + + if (ships.ValueKind != JsonValueKind.Array) + { + throw new ManifestRefusedException("ships", "Missing"); + } + + var result = new List(); + var seen = new HashSet(StringComparer.Ordinal); + foreach (var ship in ships.EnumerateArray()) + { + var at = $"ships[{result.Count}]"; + var key = ReadString(ship, "id", at); + var title = ReadString(ship, "name", at); + if (!ConceptKey.IsValid(key)) + { + throw new ManifestRefusedException($"{at}.id", "InvalidKey"); + } + + if (!seen.Add(key)) + { + throw new ManifestRefusedException($"{at}.id", "Duplicate"); + } + + if (string.IsNullOrWhiteSpace(title) || title.Any(char.IsControl)) + { + throw new ManifestRefusedException($"{at}.name", "InvalidTitle"); + } + + if (title.Length > MaxTitleLength) + { + throw new ManifestRefusedException($"{at}.name", "TooLong"); + } + + result.Add(new ManifestShip(key, title)); + } + + return result; + } + + private static JsonDocument ParseDocument(string json) + { try { - document = JsonDocument.Parse(json); + return JsonDocument.Parse(json); } catch (JsonException) { throw new ManifestRefusedException("manifest", "NotJson"); } - - using (document) - { - if (document.RootElement.ValueKind != JsonValueKind.Object || - !document.RootElement.TryGetProperty("ships", out var ships) || - ships.ValueKind != JsonValueKind.Array) - { - throw new ManifestRefusedException("ships", "Missing"); - } - - var result = new List(); - var seen = new HashSet(StringComparer.Ordinal); - foreach (var ship in ships.EnumerateArray()) - { - var at = $"ships[{result.Count}]"; - var key = ReadString(ship, "id", at); - var title = ReadString(ship, "name", at); - if (!ConceptKey.IsValid(key)) - { - throw new ManifestRefusedException($"{at}.id", "InvalidKey"); - } - - if (!seen.Add(key)) - { - throw new ManifestRefusedException($"{at}.id", "Duplicate"); - } - - if (string.IsNullOrWhiteSpace(title) || title.Any(char.IsControl)) - { - throw new ManifestRefusedException($"{at}.name", "InvalidTitle"); - } - - if (title.Length > MaxTitleLength) - { - throw new ManifestRefusedException($"{at}.name", "TooLong"); - } - - result.Add(new ManifestShip(key, title)); - } - - return result; - } } public async Task PlanAsync( @@ -213,7 +221,12 @@ public sealed class ConceptSubjectRegistry( private static string ReadString(JsonElement ship, string name, string at) { - if (ship.ValueKind != JsonValueKind.Object || !ship.TryGetProperty(name, out var value)) + if (ship.ValueKind != JsonValueKind.Object) + { + throw new ManifestRefusedException($"{at}.{name}", "Missing"); + } + + if (!ship.TryGetProperty(name, out var value)) { throw new ManifestRefusedException($"{at}.{name}", "Missing"); } From a43f90b48d6e4ec44e4ab9d4c9bcdf8df5f22767 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 21:39:04 +0200 Subject: [PATCH 4/9] Registry: one throw for a missing or non-array ships; tests pin refusal codes, title bound and plan order Co-Authored-By: Claude Opus 5.5 --- .../ConceptVotes/ConceptSubjectRegistry.cs | 21 ++++++++------- .../ConceptSubjectRegistryTests.cs | 27 +++++++++++++++++++ 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/Bifrost.Web/Features/ConceptVotes/ConceptSubjectRegistry.cs b/src/Bifrost.Web/Features/ConceptVotes/ConceptSubjectRegistry.cs index 846f0d8..9c9bd3e 100644 --- a/src/Bifrost.Web/Features/ConceptVotes/ConceptSubjectRegistry.cs +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptSubjectRegistry.cs @@ -43,16 +43,7 @@ public sealed class ConceptSubjectRegistry( public static IReadOnlyList ParseManifest(string json) { using var document = ParseDocument(json); - if (document.RootElement.ValueKind != JsonValueKind.Object) - { - throw new ManifestRefusedException("ships", "Missing"); - } - - if (!document.RootElement.TryGetProperty("ships", out var ships)) - { - throw new ManifestRefusedException("ships", "Missing"); - } - + var ships = ShipsOf(document.RootElement); if (ships.ValueKind != JsonValueKind.Array) { throw new ManifestRefusedException("ships", "Missing"); @@ -91,6 +82,16 @@ public sealed class ConceptSubjectRegistry( return result; } + private static JsonElement ShipsOf(JsonElement root) + { + if (root.ValueKind != JsonValueKind.Object) + { + return default; + } + + return root.TryGetProperty("ships", out var ships) ? ships : default; + } + private static JsonDocument ParseDocument(string json) { try diff --git a/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs b/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs index bbf8dd3..804b1ba 100644 --- a/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs @@ -170,6 +170,8 @@ public sealed class ConceptSubjectRegistryTests [InlineData("""{"ships":[{"id":"a","name":"bad\ntitle"}]}""", "ships[0].name", "InvalidTitle")] [InlineData("""{"ships":["a"]}""", "ships[0].id", "Missing")] [InlineData("""{"fleet":[]}""", "ships", "Missing")] + [InlineData("[]", "ships", "Missing")] + [InlineData("""{"ships":5}""", "ships", "Missing")] [InlineData("not json", "manifest", "NotJson")] public async Task Malformed_manifest_is_refused_whole(string json, string field, string code) { @@ -180,6 +182,28 @@ public sealed class ConceptSubjectRegistryTests Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync())); } + [Fact] + public void A_title_of_exactly_the_column_width_is_accepted_and_one_more_is_refused() + { + var fits = new string('t', ConceptSubjectRegistry.MaxTitleLength); + + Assert.Equal(fits, ConceptSubjectRegistry.ParseManifest($$"""{"ships":[{"id":"a","name":"{{fits}}"}]}""").Single().Title); + var refusal = Assert.Throws(() => + ConceptSubjectRegistry.ParseManifest($$"""{"ships":[{"id":"a","name":"{{fits}}x"}]}""")); + Assert.Equal(("ships[0].name", "TooLong"), (refusal.Field, refusal.Code)); + } + + [Fact] + public async Task Absent_keys_are_planned_in_key_order() + { + using var world = new World(); + await world.Apply(Fixture()); + + var plan = await world.Plan([new ManifestShip("zzz-new", "New")]); + + Assert.Equal(["zzz-new", "bison", "ibis-mk2", "kestrel"], plan.Entries.Select(e => e.Key)); + } + [Fact] public async Task A_bad_entry_after_good_ones_refuses_the_file_and_writes_no_row() { @@ -201,8 +225,10 @@ public sealed class ConceptSubjectRegistryTests using var world = new World(); var collection = await Assert.ThrowsAsync(() => world.Registry(world.NewDb()).PlanAsync(canary, [], None, default)); Assert.DoesNotContain("CANARY", collection.Message); + Assert.Equal(("collection", "InvalidKey"), (collection.Field, collection.Code)); var retire = await Assert.ThrowsAsync(() => world.Registry(world.NewDb()).PlanAsync(Collection, [], [canary], default)); Assert.DoesNotContain("CANARY", retire.Message); + Assert.Equal(("retire", "InvalidKey"), (retire.Field, retire.Code)); } [Theory] @@ -282,6 +308,7 @@ public sealed class ConceptSubjectRegistryTests var missing = new StringWriter(); Assert.Equal(ConceptCommand.Refused, await ConceptCommand.RunAsync(["apply", Collection, Path.Combine(world.Dir, "CANARY-missing.json")], services, missing)); Assert.DoesNotContain("CANARY", missing.ToString()); + Assert.Equal("refused manifest Unreadable", missing.ToString().Trim()); Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync())); } From 11df901cb6c0d2c8048eb062305855fee9d7079f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 22:35:46 +0200 Subject: [PATCH 5/9] Registry tests: pin the refusal message shape Co-Authored-By: Claude Opus 5.5 --- tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs b/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs index 804b1ba..92ab196 100644 --- a/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs @@ -179,6 +179,7 @@ public sealed class ConceptSubjectRegistryTests var refusal = Assert.Throws(() => ConceptSubjectRegistry.ParseManifest(json)); Assert.Equal((field, code), (refusal.Field, refusal.Code)); + Assert.Equal($"{field}: {code}", refusal.Message); Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync())); } From 0bb3b2f76cdd5c47bdff0ffda67cb0393faa0351 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 23:01:40 +0200 Subject: [PATCH 6/9] Registry: typed refusals for every input failure; strict manifest reader; retire rerun converges Empty, NUL and unreadable paths, malformed UTF-8 and lone surrogates refuse with the command's exit 1. The manifest is bounded (1 MiB, 1000 ships) and duplicate JSON properties are refused. Titles refuse format, line and paragraph separator characters and edge whitespace. Naming an already retired key is a recorded no-op, so a rerun converges. Co-Authored-By: Claude Opus 5.5 --- .../Features/ConceptVotes/ConceptCommand.cs | 18 +- .../ConceptVotes/ConceptSubjectRegistry.cs | 135 +++++++- .../ConceptSubjectRegistryTests.cs | 289 +++++++++++++++++- 3 files changed, 419 insertions(+), 23 deletions(-) diff --git a/src/Bifrost.Web/Features/ConceptVotes/ConceptCommand.cs b/src/Bifrost.Web/Features/ConceptVotes/ConceptCommand.cs index 9edab61..f81d1c0 100644 --- a/src/Bifrost.Web/Features/ConceptVotes/ConceptCommand.cs +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptCommand.cs @@ -44,13 +44,25 @@ public static class ConceptCommand } } - private static async Task ReadManifestAsync(string path) + // Reads at most one byte past the bound, so a wrong file is never read whole. + private static async Task ReadManifestAsync(string path) { try { - return await File.ReadAllTextAsync(path); + await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 1, true); + var buffer = new byte[ConceptSubjectRegistry.MaxManifestBytes + 1]; + var length = 0; + int read; + while (length < buffer.Length && (read = await stream.ReadAsync(buffer.AsMemory(length))) > 0) + { + length += read; + } + + return buffer[..length]; } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + // An empty or NUL path is an ArgumentException; a path the platform cannot name is NotSupported. + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException + or NotSupportedException or System.Security.SecurityException) { throw new ManifestRefusedException("manifest", "Unreadable"); } diff --git a/src/Bifrost.Web/Features/ConceptVotes/ConceptSubjectRegistry.cs b/src/Bifrost.Web/Features/ConceptVotes/ConceptSubjectRegistry.cs index 9c9bd3e..246926f 100644 --- a/src/Bifrost.Web/Features/ConceptVotes/ConceptSubjectRegistry.cs +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptSubjectRegistry.cs @@ -1,3 +1,5 @@ +using System.Globalization; +using System.Text; using System.Text.Json; using Bifrost.Web.Data; using Bifrost.Web.Domain; @@ -13,7 +15,8 @@ public enum SubjectChange Unchanged, Revive, Absent, - Retire + Retire, + Retired } public sealed record ManifestShip(string Key, string Title); @@ -39,16 +42,29 @@ public sealed class ConceptSubjectRegistry( { public const int MaxTitleLength = 240; + // Defaulted bounds (a Mill manifest is tens of ships): the whole file is refused above either. + public const int MaxManifestBytes = 1024 * 1024; + + public const int MaxShips = 1000; + + private static readonly UTF8Encoding StrictUtf8 = new(false, true); + // The Mill manifest is JSON from outside the Verse. Only ships[].id and ships[].name are read. - public static IReadOnlyList ParseManifest(string json) + // This is the one input boundary: every malformed input leaves as a ManifestRefusedException. + public static IReadOnlyList ParseManifest(ReadOnlyMemory utf8) { - using var document = ParseDocument(json); + using var document = ParseDocument(utf8); var ships = ShipsOf(document.RootElement); if (ships.ValueKind != JsonValueKind.Array) { throw new ManifestRefusedException("ships", "Missing"); } + if (ships.GetArrayLength() > MaxShips) + { + throw new ManifestRefusedException("ships", "TooMany"); + } + var result = new List(); var seen = new HashSet(StringComparer.Ordinal); foreach (var ship in ships.EnumerateArray()) @@ -66,7 +82,7 @@ public sealed class ConceptSubjectRegistry( throw new ManifestRefusedException($"{at}.id", "Duplicate"); } - if (string.IsNullOrWhiteSpace(title) || title.Any(char.IsControl)) + if (!IsValidTitle(title)) { throw new ManifestRefusedException($"{at}.name", "InvalidTitle"); } @@ -92,16 +108,92 @@ public sealed class ConceptSubjectRegistry( return root.TryGetProperty("ships", out var ships) ? ships : default; } - private static JsonDocument ParseDocument(string json) + // A displayed title: no control, format (bidi, zero-width, BOM), line or paragraph separator + // character, no leading or trailing whitespace, and at least one letter or digit. This is the + // character-class rule of the mind schema's Title type, kept here as the one copy in Bifrost. + private static bool IsValidTitle(string title) { + if (title.Length == 0 || title != title.Trim()) + { + return false; + } + + var hasLetterOrDigit = false; + foreach (var rune in title.EnumerateRunes()) + { + if (Rune.GetUnicodeCategory(rune) is UnicodeCategory.Control or UnicodeCategory.Format + or UnicodeCategory.LineSeparator or UnicodeCategory.ParagraphSeparator) + { + return false; + } + + hasLetterOrDigit |= Rune.IsLetterOrDigit(rune); + } + + return hasLetterOrDigit; + } + + private static JsonDocument ParseDocument(ReadOnlyMemory utf8) + { + if (utf8.Length > MaxManifestBytes) + { + throw new ManifestRefusedException("manifest", "TooLarge"); + } + try { - return JsonDocument.Parse(json); + StrictUtf8.GetCharCount(utf8.Span); + } + catch (DecoderFallbackException) + { + throw new ManifestRefusedException("manifest", "NotUtf8"); + } + + try + { + RejectDuplicateProperties(utf8.Span); + return JsonDocument.Parse(utf8); } catch (JsonException) { throw new ManifestRefusedException("manifest", "NotJson"); } + catch (InvalidOperationException) + { + // A property name holding a lone surrogate escape: not text. + throw new ManifestRefusedException("manifest", "BadText"); + } + } + + // System.Text.Json (net8.0) keeps the last of two equal property names; a manifest that says + // one thing twice is refused instead. + private static void RejectDuplicateProperties(ReadOnlySpan utf8) + { + var reader = new Utf8JsonReader(utf8); + var scopes = new Stack?>(); + while (reader.Read()) + { + switch (reader.TokenType) + { + case JsonTokenType.StartObject: + scopes.Push(new HashSet(StringComparer.Ordinal)); + break; + case JsonTokenType.StartArray: + scopes.Push(null); + break; + case JsonTokenType.EndObject: + case JsonTokenType.EndArray: + scopes.Pop(); + break; + case JsonTokenType.PropertyName: + if (!scopes.Peek()!.Add(reader.GetString()!)) + { + throw new ManifestRefusedException("manifest", "DuplicateProperty"); + } + + break; + } + } } public async Task PlanAsync( @@ -154,7 +246,17 @@ public sealed class ConceptSubjectRegistry( subject.Title)); } - // Whatever is still named is not a registered, live key missing from the manifest. + // A named key that an earlier run already retired is done: a rerun converges. + foreach (var key in named.OrderBy(x => x, StringComparer.Ordinal).ToList()) + { + if (registered.TryGetValue(key, out var done) && done.RetiredAtUtc is not null && !inManifest.Contains(key)) + { + entries.Add(new SubjectPlanEntry(SubjectChange.Retired, key, done.Title)); + named.Remove(key); + } + } + + // Whatever is still named is not a registered key missing from the manifest. if (named.Count > 0) { throw new ManifestRefusedException("retire", "NotAbsent"); @@ -176,7 +278,7 @@ public sealed class ConceptSubjectRegistry( foreach (var entry in plan.Entries) { - if (entry.Change is SubjectChange.Unchanged or SubjectChange.Absent) + if (entry.Change is SubjectChange.Unchanged or SubjectChange.Absent or SubjectChange.Retired) { continue; } @@ -232,8 +334,19 @@ public sealed class ConceptSubjectRegistry( throw new ManifestRefusedException($"{at}.{name}", "Missing"); } - return value.ValueKind == JsonValueKind.String - ? value.GetString()! - : throw new ManifestRefusedException($"{at}.{name}", "NotString"); + if (value.ValueKind != JsonValueKind.String) + { + throw new ManifestRefusedException($"{at}.{name}", "NotString"); + } + + try + { + return value.GetString()!; + } + catch (InvalidOperationException) + { + // A lone surrogate escape: not text. + throw new ManifestRefusedException($"{at}.{name}", "BadText"); + } } } diff --git a/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs b/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs index 92ab196..a67bf9a 100644 --- a/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs @@ -1,4 +1,8 @@ +using System.Diagnostics; using System.Net; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; using Bifrost.Web.Configuration; using Bifrost.Web.Data; using Bifrost.Web.Domain; @@ -17,8 +21,11 @@ public sealed class ConceptSubjectRegistryTests private static string FixturePath => Path.Combine(AppContext.BaseDirectory, "Fixtures", "mill-three-ships.json"); + private static IReadOnlyList Parse(string json) => + ConceptSubjectRegistry.ParseManifest(Encoding.UTF8.GetBytes(json)); + private static IReadOnlyList Fixture() => - ConceptSubjectRegistry.ParseManifest(File.ReadAllText(FixturePath)); + ConceptSubjectRegistry.ParseManifest(File.ReadAllBytes(FixturePath)); [Fact] public async Task Rerun_of_the_same_manifest_is_a_no_op() @@ -129,14 +136,56 @@ public sealed class ConceptSubjectRegistryTests } [Fact] - public async Task Naming_an_already_retired_key_is_refused() + public async Task Rerunning_the_same_retire_list_converges_and_writes_nothing() { using var world = new World(); await world.Apply(Fixture()); - var smaller = Fixture().Where(s => s.Key != "kestrel").ToList(); + var smaller = Fixture().Where(s => s.Key == "bison").ToList(); + + var first = await world.Apply(smaller, ["kestrel", "ibis-mk2"]); + Assert.Equal([SubjectChange.Unchanged, SubjectChange.Retire, SubjectChange.Retire], first.Entries.Select(e => e.Change)); + var before = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().OrderBy(x => x.Key).ToListAsync()); + var audits = await world.Fresh(db => db.AuditEvents.CountAsync()); + world.Clock.Advance(TimeSpan.FromHours(1)); + + var second = await world.Apply(smaller, ["kestrel", "ibis-mk2"]); + + Assert.Equal( + [("bison", SubjectChange.Unchanged), ("ibis-mk2", SubjectChange.Retired), ("kestrel", SubjectChange.Retired)], + second.Entries.OrderBy(e => e.Key, StringComparer.Ordinal).Select(e => (e.Key, e.Change))); + Assert.Equal(audits, await world.Fresh(db => db.AuditEvents.CountAsync())); + var after = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().OrderBy(x => x.Key).ToListAsync()); + Assert.Equal(before.Select(x => (x.Id, x.Title, x.RetiredAtUtc)), after.Select(x => (x.Id, x.Title, x.RetiredAtUtc))); + } + + [Fact] + public async Task Rerunning_a_retire_list_after_a_partial_apply_retires_the_rest() + { + using var world = new World(); + await world.Apply(Fixture()); + var smaller = Fixture().Where(s => s.Key == "bison").ToList(); await world.Apply(smaller, ["kestrel"]); - await Assert.ThrowsAsync(() => world.Apply(smaller, ["kestrel"])); + var plan = await world.Apply(smaller, ["kestrel", "ibis-mk2"]); + + Assert.Equal(SubjectChange.Retired, plan.Entries.Single(e => e.Key == "kestrel").Change); + Assert.Equal(SubjectChange.Retire, plan.Entries.Single(e => e.Key == "ibis-mk2").Change); + var rows = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().ToListAsync()); + Assert.Equal(["ibis-mk2", "kestrel"], rows.Where(r => r.RetiredAtUtc is not null).Select(r => r.Key).Order()); + Assert.Equal(2, await world.Fresh(db => db.AuditEvents.CountAsync(x => x.Action == "Retire"))); + } + + [Fact] + public async Task Naming_a_retired_key_that_the_manifest_lists_again_is_still_refused() + { + using var world = new World(); + await world.Apply(Fixture()); + await world.Apply(Fixture().Where(s => s.Key != "kestrel").ToList(), ["kestrel"]); + + var refusal = await Assert.ThrowsAsync(() => world.Apply(Fixture(), ["kestrel"])); + + Assert.Equal(("retire", "NotAbsent"), (refusal.Field, refusal.Code)); + Assert.NotNull((await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "kestrel"))).RetiredAtUtc); } [Fact] @@ -176,7 +225,7 @@ public sealed class ConceptSubjectRegistryTests public async Task Malformed_manifest_is_refused_whole(string json, string field, string code) { using var world = new World(); - var refusal = Assert.Throws(() => ConceptSubjectRegistry.ParseManifest(json)); + var refusal = Assert.Throws(() => Parse(json)); Assert.Equal((field, code), (refusal.Field, refusal.Code)); Assert.Equal($"{field}: {code}", refusal.Message); @@ -188,12 +237,129 @@ public sealed class ConceptSubjectRegistryTests { var fits = new string('t', ConceptSubjectRegistry.MaxTitleLength); - Assert.Equal(fits, ConceptSubjectRegistry.ParseManifest($$"""{"ships":[{"id":"a","name":"{{fits}}"}]}""").Single().Title); + Assert.Equal(fits, Parse($$"""{"ships":[{"id":"a","name":"{{fits}}"}]}""").Single().Title); var refusal = Assert.Throws(() => - ConceptSubjectRegistry.ParseManifest($$"""{"ships":[{"id":"a","name":"{{fits}}x"}]}""")); + Parse($$"""{"ships":[{"id":"a","name":"{{fits}}x"}]}""")); Assert.Equal(("ships[0].name", "TooLong"), (refusal.Field, refusal.Code)); } + private static readonly JsonSerializerOptions Raw = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; + + [Theory] + [InlineData("‮evil")] + [InlineData("a‪b")] + [InlineData("a⁦b")] + [InlineData("a⁩b")] + [InlineData("a؜b")] + [InlineData("a‎b")] + [InlineData("a‏b")] + [InlineData("a
b")] + [InlineData("a
b")] + [InlineData("a​b")] + [InlineData("a‌b")] + [InlineData("a‍b")] + [InlineData("a⁠b")] + [InlineData("ab")] + [InlineData("a\u0085b")] + [InlineData("a\tb")] + [InlineData(" Ibis")] + [InlineData("Ibis ")] + [InlineData(" Ibis")] + [InlineData("Ibis ")] + [InlineData(" Ibis")] + [InlineData("!!!")] + public void A_title_with_a_format_character_edge_whitespace_or_no_letter_is_refused(string title) + { + var json = """{"ships":[{"id":"a","name":""" + JsonSerializer.Serialize(title, Raw) + "}]}"; + + var refusal = Assert.Throws(() => Parse(json)); + + Assert.Equal(("ships[0].name", "InvalidTitle"), (refusal.Field, refusal.Code)); + } + + [Theory] + [InlineData("Ibis Mk II")] + [InlineData("Ünïcode Ship")] + [InlineData("Ship ★ 7")] + [InlineData("7")] + [InlineData("Kestrel-2: the \"Bold\"")] + public void An_ordinary_title_is_accepted(string title) + { + var json = """{"ships":[{"id":"a","name":""" + JsonSerializer.Serialize(title, Raw) + "}]}"; + + Assert.Equal(title, Parse(json).Single().Title); + } + + [Theory] + [InlineData("""{"ships":[{"id":"a","name":"x\ud800y"}]}""", "ships[0].name", "BadText")] + [InlineData("""{"ships":[{"id":"a\udc00","name":"x"}]}""", "ships[0].id", "BadText")] + [InlineData("""{"ships":[{"id":"a","name":"x","na\ud800me":"y"}]}""", "manifest", "BadText")] + public void A_lone_surrogate_is_refused_not_thrown(string json, string field, string code) + { + var refusal = Assert.Throws(() => Parse(json)); + + Assert.Equal((field, code), (refusal.Field, refusal.Code)); + } + + [Fact] + public void Malformed_utf8_anywhere_in_the_file_is_refused() + { + byte[] inName = [.. "{\"ships\":[{\"id\":\"a\",\"name\":\"X"u8, 0xC3, 0x28, .. "\"}]}"u8]; + byte[] inUnreadValue = [.. "{\"ships\":[],\"x\":\"X"u8, 0xFF, .. "\"}"u8]; + + foreach (var bytes in new[] { inName, inUnreadValue }) + { + var refusal = Assert.Throws(() => ConceptSubjectRegistry.ParseManifest(bytes)); + + Assert.Equal(("manifest", "NotUtf8"), (refusal.Field, refusal.Code)); + } + } + + [Theory] + [InlineData("""{"ships":[{"id":"a","id":"b","name":"A"}]}""")] + [InlineData("""{"ships":[{"id":"a","name":"A","name":"B"}]}""")] + [InlineData("""{"ships":[],"ships":[]}""")] + [InlineData("""{"ships":[{"id":"a","name":"A"}],"votes":{"x":1,"x":2}}""")] + [InlineData("""{"ships":[{"id":"a","name":"A","meta":{"k":[{"z":1,"z":1}]}}]}""")] + [InlineData("""{"ships":[{"id":"a","name":"A","id":"b"}]}""")] + public void A_duplicate_json_property_is_refused_wherever_it_sits(string json) + { + var refusal = Assert.Throws(() => Parse(json)); + + Assert.Equal(("manifest", "DuplicateProperty"), (refusal.Field, refusal.Code)); + } + + [Fact] + public void The_same_property_name_in_sibling_and_nested_objects_is_not_a_duplicate() + { + var ships = Parse("""{"id":"top","ships":[{"id":"a","name":"A","meta":{"id":"x","name":"y"}},{"id":"b","name":"B"}]}"""); + + Assert.Equal(["a", "b"], ships.Select(s => s.Key)); + } + + [Fact] + public void A_manifest_of_exactly_the_byte_bound_is_accepted_and_one_more_byte_is_refused() + { + const string head = """{"ships":[{"id":"a","name":"A"}]}"""; + var fits = Encoding.UTF8.GetBytes(head + new string(' ', ConceptSubjectRegistry.MaxManifestBytes - head.Length)); + + Assert.Equal(ConceptSubjectRegistry.MaxManifestBytes, fits.Length); + Assert.Single(ConceptSubjectRegistry.ParseManifest(fits)); + var refusal = Assert.Throws(() => ConceptSubjectRegistry.ParseManifest([.. fits, (byte)' '])); + Assert.Equal(("manifest", "TooLarge"), (refusal.Field, refusal.Code)); + } + + [Fact] + public void A_manifest_of_exactly_the_ship_bound_is_accepted_and_one_more_ship_is_refused() + { + static string Fleet(int count) => + """{"ships":[""" + string.Join(",", Enumerable.Range(0, count).Select(i => $$"""{"id":"s{{i}}","name":"S{{i}}"}""")) + "]}"; + + Assert.Equal(ConceptSubjectRegistry.MaxShips, Parse(Fleet(ConceptSubjectRegistry.MaxShips)).Count); + var refusal = Assert.Throws(() => Parse(Fleet(ConceptSubjectRegistry.MaxShips + 1))); + Assert.Equal(("ships", "TooMany"), (refusal.Field, refusal.Code)); + } + [Fact] public async Task Absent_keys_are_planned_in_key_order() { @@ -211,7 +377,7 @@ public sealed class ConceptSubjectRegistryTests using var world = new World(); var json = """{"ships":[{"id":"good","name":"Good"},{"id":"ship-a\n","name":"Bad"}]}"""; - Assert.Throws(() => ConceptSubjectRegistry.ParseManifest(json)); + Assert.Throws(() => Parse(json)); Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync())); } @@ -221,7 +387,7 @@ public sealed class ConceptSubjectRegistryTests const string canary = "CANARY-secret/value"; var json = $$"""{"ships":[{"id":"{{canary}}","name":"A"}]}"""; - var refusal = Assert.Throws(() => ConceptSubjectRegistry.ParseManifest(json)); + var refusal = Assert.Throws(() => Parse(json)); Assert.DoesNotContain("CANARY", refusal.Message); using var world = new World(); var collection = await Assert.ThrowsAsync(() => world.Registry(world.NewDb()).PlanAsync(canary, [], None, default)); @@ -329,6 +495,111 @@ public sealed class ConceptSubjectRegistryTests Assert.Equal(ConceptCommand.Usage, await ConceptCommand.RunAsync([], world.Services(), new StringWriter())); } + [Theory] + [InlineData("")] + [InlineData("CANARY\0path.json")] + public async Task Host_command_refuses_an_empty_or_nul_path(string path) + { + using var world = new World(); + + var output = new StringWriter(); + Assert.Equal(ConceptCommand.Refused, await ConceptCommand.RunAsync(["plan", Collection, path], world.Services(), output)); + + Assert.Equal("refused manifest Unreadable", output.ToString().Trim()); + } + + [Fact] + public async Task Host_command_refuses_a_directory_and_an_oversized_file() + { + using var world = new World(); + var big = Path.Combine(world.Dir, "big.json"); + await File.WriteAllBytesAsync(big, new byte[ConceptSubjectRegistry.MaxManifestBytes + 1]); + + var dir = new StringWriter(); + Assert.Equal(ConceptCommand.Refused, await ConceptCommand.RunAsync(["plan", Collection, world.Dir], world.Services(), dir)); + Assert.Equal("refused manifest Unreadable", dir.ToString().Trim()); + var large = new StringWriter(); + Assert.Equal(ConceptCommand.Refused, await ConceptCommand.RunAsync(["plan", Collection, big], world.Services(), large)); + Assert.Equal("refused manifest TooLarge", large.ToString().Trim()); + } + + [Fact] + public async Task Host_command_refuses_a_manifest_that_is_not_utf8_text() + { + using var world = new World(); + var path = Path.Combine(world.Dir, "latin1.json"); + await File.WriteAllBytesAsync(path, [.. "{\"ships\":[{\"id\":\"a\",\"name\":\"CANARY-"u8, 0xE9, .. "\"}]}"u8]); + + var output = new StringWriter(); + Assert.Equal(ConceptCommand.Refused, await ConceptCommand.RunAsync(["plan", Collection, path], world.Services(), output)); + + Assert.Equal("refused manifest NotUtf8", output.ToString().Trim()); + } + + [Fact] + public async Task Built_command_exits_with_its_documented_codes_and_echoes_no_input() + { + using var world = new World(); + var big = Path.Combine(world.Dir, "CANARYBIG.json"); + await File.WriteAllBytesAsync(big, new byte[ConceptSubjectRegistry.MaxManifestBytes + 1]); + var bidi = Path.Combine(world.Dir, "CANARYBIDI.json"); + await File.WriteAllTextAsync(bidi, """{"ships":[{"id":"a","name":"CANARYTITLE‮"}]}"""); + var dup = Path.Combine(world.Dir, "CANARYDUP.json"); + await File.WriteAllTextAsync(dup, """{"ships":[{"id":"CANARYID","id":"b","name":"A"}]}"""); + var surrogate = Path.Combine(world.Dir, "CANARYSURR.json"); + await File.WriteAllTextAsync(surrogate, """{"ships":[{"id":"a","name":"CANARYNAME\ud800"}]}"""); + var latin1 = Path.Combine(world.Dir, "CANARYLATIN.json"); + await File.WriteAllBytesAsync(latin1, [.. "{\"ships\":[{\"id\":\"a\",\"name\":\"CANARYBYTES"u8, 0xE9, .. "\"}]}"u8]); + + var cases = new (string[] Args, int Code)[] + { + (["plan", Collection, ""], ConceptCommand.Refused), + (["plan", Collection, Path.Combine(world.Dir, "CANARYMISSING.json")], ConceptCommand.Refused), + (["plan", Collection, world.Dir], ConceptCommand.Refused), + (["plan", Collection, big], ConceptCommand.Refused), + (["plan", Collection, bidi], ConceptCommand.Refused), + (["apply", Collection, dup], ConceptCommand.Refused), + (["apply", Collection, surrogate], ConceptCommand.Refused), + (["plan", Collection, latin1], ConceptCommand.Refused), + (["plan", "CANARYCOLLECTION", bidi], ConceptCommand.Refused), + (["frob", "CANARYARG", "x"], ConceptCommand.Usage), + (["plan"], ConceptCommand.Usage), + }; + foreach (var (args, code) in cases) + { + var result = await RunBuiltCommandAsync(args); + + Assert.True(code == result.ExitCode, $"case {args[0]}/{args.Length}: exit {result.ExitCode}, stdout {result.StdOut}, stderr {result.StdErr}"); + Assert.DoesNotContain("CANARY", result.StdOut + result.StdErr); + Assert.DoesNotContain("Unhandled exception", result.StdOut + result.StdErr); + } + } + + private static async Task<(int ExitCode, string StdOut, string StdErr)> RunBuiltCommandAsync(string[] args) + { + var dll = Path.Combine(AppContext.BaseDirectory, "Bifrost.Web.dll"); + Assert.True(File.Exists(Path.ChangeExtension(dll, ".runtimeconfig.json")), "Bifrost.Web.runtimeconfig.json is not beside the test assembly"); + var start = new ProcessStartInfo("dotnet") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + foreach (var part in new[] { dll, "concepts" }.Concat(args)) + { + start.ArgumentList.Add(part); + } + + // Every case refuses before the database is touched; the password is a canary for leaks. + start.Environment["ConnectionStrings__Bifrost"] = "Host=127.0.0.1;Port=1;Database=x;Username=u;Password=CANARYPW"; + using var process = Process.Start(start)!; + var stdout = process.StandardOutput.ReadToEndAsync(); + var stderr = process.StandardError.ReadToEndAsync(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await process.WaitForExitAsync(timeout.Token); + return (process.ExitCode, await stdout, await stderr); + } + private sealed class World : IDisposable { private readonly string _dbName = $"registry-{Guid.NewGuid():N}"; From 9c4124dd9f360bb6c720cc3d4819f1ac3e2c3243 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 23:04:00 +0200 Subject: [PATCH 7/9] Registry tests: spell the separator and format characters as escapes Co-Authored-By: Claude Opus 5.5 --- .../ConceptSubjectRegistryTests.cs | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs b/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs index a67bf9a..36a72d2 100644 --- a/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs @@ -246,27 +246,27 @@ public sealed class ConceptSubjectRegistryTests private static readonly JsonSerializerOptions Raw = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; [Theory] - [InlineData("‮evil")] - [InlineData("a‪b")] - [InlineData("a⁦b")] - [InlineData("a⁩b")] - [InlineData("a؜b")] - [InlineData("a‎b")] - [InlineData("a‏b")] - [InlineData("a
b")] - [InlineData("a
b")] - [InlineData("a​b")] - [InlineData("a‌b")] - [InlineData("a‍b")] - [InlineData("a⁠b")] - [InlineData("ab")] + [InlineData("\u202Eevil")] + [InlineData("a\u202Ab")] + [InlineData("a\u2066b")] + [InlineData("a\u2069b")] + [InlineData("a\u061Cb")] + [InlineData("a\u200Eb")] + [InlineData("a\u200Fb")] + [InlineData("a\u2028b")] + [InlineData("a\u2029b")] + [InlineData("a\u200Bb")] + [InlineData("a\u200Cb")] + [InlineData("a\u200Db")] + [InlineData("a\u2060b")] + [InlineData("a\uFEFFb")] [InlineData("a\u0085b")] [InlineData("a\tb")] [InlineData(" Ibis")] [InlineData("Ibis ")] - [InlineData(" Ibis")] - [InlineData("Ibis ")] - [InlineData(" Ibis")] + [InlineData("\u00A0Ibis")] + [InlineData("Ibis\u00A0")] + [InlineData("\u3000Ibis")] [InlineData("!!!")] public void A_title_with_a_format_character_edge_whitespace_or_no_letter_is_refused(string title) { @@ -543,7 +543,7 @@ public sealed class ConceptSubjectRegistryTests var big = Path.Combine(world.Dir, "CANARYBIG.json"); await File.WriteAllBytesAsync(big, new byte[ConceptSubjectRegistry.MaxManifestBytes + 1]); var bidi = Path.Combine(world.Dir, "CANARYBIDI.json"); - await File.WriteAllTextAsync(bidi, """{"ships":[{"id":"a","name":"CANARYTITLE‮"}]}"""); + await File.WriteAllTextAsync(bidi, """{"ships":[{"id":"a","name":"CANARYTITLE\u202E"}]}"""); var dup = Path.Combine(world.Dir, "CANARYDUP.json"); await File.WriteAllTextAsync(dup, """{"ships":[{"id":"CANARYID","id":"b","name":"A"}]}"""); var surrogate = Path.Combine(world.Dir, "CANARYSURR.json"); From 747183078a5ecd9f00fc8f40bc57308879798d8d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 23:08:11 +0200 Subject: [PATCH 8/9] Registry tests: build the over-bound manifest as an array Co-Authored-By: Claude Opus 5.5 --- tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs b/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs index 36a72d2..07243e5 100644 --- a/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs @@ -345,7 +345,7 @@ public sealed class ConceptSubjectRegistryTests Assert.Equal(ConceptSubjectRegistry.MaxManifestBytes, fits.Length); Assert.Single(ConceptSubjectRegistry.ParseManifest(fits)); - var refusal = Assert.Throws(() => ConceptSubjectRegistry.ParseManifest([.. fits, (byte)' '])); + var refusal = Assert.Throws(() => ConceptSubjectRegistry.ParseManifest(fits.Append((byte)' ').ToArray())); Assert.Equal(("manifest", "TooLarge"), (refusal.Field, refusal.Code)); } From c9e0b7b0e113cc27a3198e73a54bf69e3bfb982b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 23:26:43 +0200 Subject: [PATCH 9/9] Registry tests: pin the order of already-retired entries Co-Authored-By: Claude Opus 5.5 --- tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs b/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs index 07243e5..c942457 100644 --- a/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs @@ -152,7 +152,7 @@ public sealed class ConceptSubjectRegistryTests Assert.Equal( [("bison", SubjectChange.Unchanged), ("ibis-mk2", SubjectChange.Retired), ("kestrel", SubjectChange.Retired)], - second.Entries.OrderBy(e => e.Key, StringComparer.Ordinal).Select(e => (e.Key, e.Change))); + second.Entries.Select(e => (e.Key, e.Change))); Assert.Equal(audits, await world.Fresh(db => db.AuditEvents.CountAsync())); var after = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().OrderBy(x => x.Key).ToListAsync()); Assert.Equal(before.Select(x => (x.Id, x.Title, x.RetiredAtUtc)), after.Select(x => (x.Id, x.Title, x.RetiredAtUtc)));