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}";