Bifrost/src/Bifrost.Web/Features/ConceptVotes/ConceptCommand.cs
Meta Vens 0bb3b2f76c 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 <noreply@anthropic.com>
2026-10-10 23:01:40 +02:00

70 lines
2.8 KiB
C#

using Microsoft.Extensions.DependencyInjection;
namespace Bifrost.Web.Features.ConceptVotes;
// Host command: Bifrost.Web.dll concepts plan|apply <collection> <manifest.json> [--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<int> 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 <collection> <manifest.json> [--retire k1,k2]");
return Usage;
}
string[] retire = hasRetire ? args[4].Split(',', StringSplitOptions.RemoveEmptyEntries) : [];
try
{
var ships = ConceptSubjectRegistry.ParseManifest(await ReadManifestAsync(args[2]));
await using var scope = services.CreateAsyncScope();
var registry = scope.ServiceProvider.GetRequiredService<ConceptSubjectRegistry>();
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;
}
}
// Reads at most one byte past the bound, so a wrong file is never read whole.
private static async Task<byte[]> ReadManifestAsync(string path)
{
try
{
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];
}
// 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");
}
}
}