From 514302b9664ed0fc1a46dfcfb7d9bec129fae917 Mon Sep 17 00:00:00 2001 From: codex Date: Mon, 10 Aug 2026 10:08:48 +0300 Subject: [PATCH] feat: fail closed on conflicting P_Rec_Annot startup roster --- Launcher.Next.App/PilotCli.cs | 6 +- Launcher.Next.Core/StartupRosterPreflight.cs | 95 +++++++++++++++++++ .../StartupRosterPreflightTests.cs | 22 +++++ 3 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 Launcher.Next.Core/StartupRosterPreflight.cs create mode 100644 tests/Launcher.Next.Tests/StartupRosterPreflightTests.cs diff --git a/Launcher.Next.App/PilotCli.cs b/Launcher.Next.App/PilotCli.cs index 91002da..3523fcf 100644 --- a/Launcher.Next.App/PilotCli.cs +++ b/Launcher.Next.App/PilotCli.cs @@ -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); diff --git a/Launcher.Next.Core/StartupRosterPreflight.cs b/Launcher.Next.Core/StartupRosterPreflight.cs new file mode 100644 index 0000000..3189331 --- /dev/null +++ b/Launcher.Next.Core/StartupRosterPreflight.cs @@ -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 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? additionalRoots = null) + { + var roots = new HashSet(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(); + 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 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 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 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")); + } +} diff --git a/tests/Launcher.Next.Tests/StartupRosterPreflightTests.cs b/tests/Launcher.Next.Tests/StartupRosterPreflightTests.cs new file mode 100644 index 0000000..5ce61b1 --- /dev/null +++ b/tests/Launcher.Next.Tests/StartupRosterPreflightTests.cs @@ -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); + } +}