feat: fail closed on conflicting P_Rec_Annot startup roster

This commit is contained in:
codex
2026-08-10 10:08:48 +03:00
parent a5a615f7f0
commit 514302b966
3 changed files with 122 additions and 1 deletions
+5 -1
View File
@@ -166,8 +166,12 @@ internal static class PilotCli
if (!TryResolvePaths(baseConfig, out var basePaths, out error)) return Fail(Blocked, error!);
if (baseConfig.LauncherMode != LauncherMode.NextPilot || !baseConfig.PilotEnabled || !baseConfig.AllowProcessStart || !baseConfig.RequireExplicitExecute || !baseConfig.ForceAfterEvidence) return Fail(Blocked, "AUTOMATION_SAFETY_GATE_REJECTED");
if (!TryBuildManifest(options.PackageSource, options.EntryAssembly, out var manifest, out var manifestHash, out error)) return Fail(Blocked, error!);
if (Process.GetProcessesByName("acad").Any()) return Fail(AcadRunning, "ACAD_ALREADY_RUNNING");
Directory.CreateDirectory(options.EvidenceRoot);
var roster = StartupRosterPreflight.Inspect(options.PackageSource, manifest!);
StartupRosterPreflight.Write(Path.Combine(options.EvidenceRoot, "startup-roster-preflight.json"), roster);
await journal.RecordAsync("StartupRosterPreflight", roster.IsSafe ? "Completed" : "Failed", roster.Status.ToString(), CancellationToken.None);
if (!roster.IsSafe) return Fail(Blocked, "CANDIDATE_ISOLATION_" + roster.Status.ToString().ToUpperInvariant());
if (Process.GetProcessesByName("acad").Any()) return Fail(AcadRunning, "ACAD_ALREADY_RUNNING");
await journal.RecordAsync("HashPreparationStarted", "Started", null, CancellationToken.None);
var before = await ContentHashesAsync(options.FixtureMarker, journal, CancellationToken.None);
await journal.RecordAsync("HashPreparationCompleted", "Completed", null, CancellationToken.None);
@@ -0,0 +1,95 @@
using System.Security.Cryptography;
using System.Text.Json;
namespace Launcher.Next.Core;
public enum StartupRosterCandidateStatus
{
CandidateOnly,
LegacyCandidatePresent,
ConflictingCandidatePresent,
UnexpectedAutoloadRegistration,
UnresolvedStartupEntry
}
public sealed record StartupRosterEntry(string Source, string? Registration, string? PackageId,
string? Version, string? EntryAssembly, string? EntryPath, string? EntrySha256,
bool Eligible, string LoadTiming);
public sealed record StartupRosterReport(StartupRosterCandidateStatus Status,
string RequestedPackageId, string RequestedVersion, string? RequestedEntrySha256,
IReadOnlyList<StartupRosterEntry> Entries, DateTimeOffset CreatedUtc)
{
public bool IsSafe => Status == StartupRosterCandidateStatus.CandidateOnly;
}
/// Read-only, bounded discovery of P_Rec_Annot startup candidates in controlled local roots.
public static class StartupRosterPreflight
{
public static StartupRosterReport Inspect(string requestedPackageRoot, PackageManifest requested,
IEnumerable<string>? additionalRoots = null)
{
var roots = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { Path.GetFullPath(requestedPackageRoot) };
foreach (var root in additionalRoots ?? DiscoverKnownRoots())
if (!string.IsNullOrWhiteSpace(root) && Directory.Exists(root)) roots.Add(Path.GetFullPath(root));
var entries = new List<StartupRosterEntry>();
foreach (var root in roots) DiscoverRoot(root, requested, entries);
var requestedHash = requested.Files.FirstOrDefault(f => f.Path.Equals(requested.EntryAssembly, StringComparison.OrdinalIgnoreCase))?.Sha256;
var eligible = entries.Where(e => e.Eligible).ToArray();
StartupRosterCandidateStatus status;
if (entries.Any(e => e.EntryPath is null || e.EntrySha256 is null)) status = StartupRosterCandidateStatus.UnresolvedStartupEntry;
else if (eligible.Length == 0) status = StartupRosterCandidateStatus.UnresolvedStartupEntry;
else if (eligible.Length == 1 && string.Equals(eligible[0].EntrySha256, requestedHash, StringComparison.OrdinalIgnoreCase)
&& string.Equals(eligible[0].PackageId, requested.PackageId, StringComparison.OrdinalIgnoreCase)
&& string.Equals(eligible[0].Version, requested.Version, StringComparison.OrdinalIgnoreCase)) status = StartupRosterCandidateStatus.CandidateOnly;
else if (eligible.Any(e => string.Equals(e.PackageId, requested.PackageId, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(e.Version, requested.Version, StringComparison.OrdinalIgnoreCase))) status = StartupRosterCandidateStatus.ConflictingCandidatePresent;
else if (eligible.Any(e => !string.Equals(e.EntrySha256, requestedHash, StringComparison.OrdinalIgnoreCase))) status = StartupRosterCandidateStatus.LegacyCandidatePresent;
else status = StartupRosterCandidateStatus.UnexpectedAutoloadRegistration;
return new(status, requested.PackageId, requested.Version, requestedHash, entries, DateTimeOffset.UtcNow);
}
public static void Write(string path, StartupRosterReport report) =>
File.WriteAllText(path, JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true }));
private static IEnumerable<string> DiscoverKnownRoots()
{
var temp = Path.GetTempPath();
if (Directory.Exists(temp))
foreach (var root in Directory.EnumerateDirectories(temp, "local-parity-launch-*", SearchOption.TopDirectoryOnly))
yield return Path.Combine(root, "Applications");
foreach (var root in new[] { Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) + "\\Autodesk\\ApplicationPlugins",
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData) + "\\Autodesk\\ApplicationPlugins" }) yield return root;
}
private static void DiscoverRoot(string root, PackageManifest requested, List<StartupRosterEntry> entries)
{
if (!Directory.Exists(root)) return;
foreach (var dir in Directory.EnumerateDirectories(root, "P_Rec_Annot", SearchOption.TopDirectoryOnly))
{
var current = Path.Combine(dir, "current.json");
if (File.Exists(current))
{
using var doc = JsonDocument.Parse(File.ReadAllText(current));
var version = doc.RootElement.TryGetProperty("Current", out var c) ? c.GetString() : null;
var packageRoot = version is null ? null : Path.Combine(dir, "versions", version);
AddManifest(packageRoot, "LauncherCurrent", "netload", requested, entries);
}
else AddManifest(dir, "ApplicationPlugins", "autoload", requested, entries);
}
foreach (var bundle in Directory.EnumerateDirectories(root, "P_Rec_Annot*.bundle", SearchOption.TopDirectoryOnly)) AddManifest(bundle, "PackageContents", "autoload", requested, entries);
}
private static void AddManifest(string? packageRoot, string source, string registration, PackageManifest requested, List<StartupRosterEntry> entries)
{
if (packageRoot is null) { entries.Add(new(source, registration, "P_Rec_Annot", null, null, null, null, false, "before-netload")); return; }
var manifestPath = Path.Combine(packageRoot, "package.manifest.json");
if (!File.Exists(manifestPath)) { entries.Add(new(source, registration, "P_Rec_Annot", Path.GetFileName(packageRoot), null, null, null, false, "before-netload")); return; }
using var doc = JsonDocument.Parse(File.ReadAllText(manifestPath));
var root = doc.RootElement; string? S(string n) => root.TryGetProperty(n, out var p) ? p.GetString() : null;
var id = S("PackageId") ?? "P_Rec_Annot"; var version = S("Version"); var asm = S("EntryAssembly") ?? requested.EntryAssembly;
var path = Path.Combine(packageRoot, asm); string? hash = File.Exists(path) ? Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))) : null;
var eligible = File.Exists(path);
entries.Add(new(source, registration, id, version, asm, eligible ? path : null, hash, eligible, "before-netload"));
}
}
@@ -0,0 +1,22 @@
using Launcher.Next.Core;
using Xunit;
namespace Launcher.Next.Tests;
public sealed class StartupRosterPreflightTests
{
[Fact]
public void ForeignVersionIsConflicting()
{
var root = Path.Combine(Path.GetTempPath(), "roster-" + Guid.NewGuid().ToString("N"));
var old = Path.Combine(root, "P_Rec_Annot", "versions", "0.1.0");
Directory.CreateDirectory(old);
File.WriteAllText(Path.Combine(old, "PRecAnnot.Plant3D2026.dll"), "old");
File.WriteAllText(Path.Combine(root, "P_Rec_Annot", "current.json"), "{\"Current\":\"0.1.0\"}");
File.WriteAllText(Path.Combine(old, "package.manifest.json"), "{\"PackageId\":\"P_Rec_Annot\",\"Version\":\"0.1.0\",\"EntryAssembly\":\"PRecAnnot.Plant3D2026.dll\"}");
var requested = new PackageManifest(1, "P_Rec_Annot", "0.2.9", "Plant3D", 2026, "x64", "PRecAnnot.Plant3D2026.dll", [], new("netload", "PHP_REC_ANNOT"));
var report = StartupRosterPreflight.Inspect(root, requested, [root]);
Assert.Equal(StartupRosterCandidateStatus.ConflictingCandidatePresent, report.Status);
Directory.Delete(root, true);
}
}