diff --git a/Launcher.Next.App/LocalParityLaunchService.cs b/Launcher.Next.App/LocalParityLaunchService.cs index ab3cff7..ada11b0 100644 --- a/Launcher.Next.App/LocalParityLaunchService.cs +++ b/Launcher.Next.App/LocalParityLaunchService.cs @@ -88,6 +88,9 @@ internal sealed class LocalParityLaunchService var operationId = Guid.NewGuid().ToString("N"); var evidenceDirectory = Under(prepared.Profile.EvidenceRoot, operationId); Directory.CreateDirectory(evidenceDirectory); + var bootstrapRoot = Path.GetDirectoryName(prepared.StarterPath)!; + var updatePackage = prepared.Packages.SingleOrDefault(x => string.Equals(x.Package.PackageId, "P_Rec_Update", StringComparison.Ordinal)); + var updateAssemblyHash = updatePackage is null ? null : await HashAsync(Under(updatePackage.Package.LocalPackageRoot, updatePackage.Manifest.EntryAssembly), ct); var logPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Promhimproekt", "PRecAnnot2026", "Logs", "runtime.jsonl"); var beforeLogLength = File.Exists(logPath) ? new FileInfo(logPath).Length : 0L; var psi = new ProcessStartInfo { FileName = product.ExecutablePath, WorkingDirectory = Path.GetDirectoryName(product.ExecutablePath)!, UseShellExecute = false }; @@ -95,7 +98,8 @@ internal sealed class LocalParityLaunchService // Controlled-local only: accepted Plant runtime bootstrap. Keep /b and the owned SCR last. psi.ArgumentList.Add("/b"); psi.ArgumentList.Add(prepared.StarterPath); psi.Environment["P_REC_ANNOT_RUNTIME_MODE"] = "Smoke"; - psi.Environment["P_REC_UPDATE_MARKER_ROOT"] = Path.Combine(Path.GetDirectoryName(prepared.StarterPath)!, "markers"); + psi.Environment["P_REC_UPDATE_MARKER_ROOT"] = Path.Combine(bootstrapRoot, "markers"); + psi.Environment["P_REC_UPDATE_OPERATION_ID"] = operationId; using var process = Process.Start(psi) ?? throw new InvalidOperationException("PLANT3D_PROCESS_NOT_CREATED"); await Task.Delay(TimeSpan.FromSeconds(30), ct); var alive = !process.HasExited; @@ -110,12 +114,17 @@ internal sealed class LocalParityLaunchService var newLog = await reader.ReadToEndAsync(ct); runtimeConfirmed = newLog.Contains("AssemblyInitializeCompleted", StringComparison.Ordinal) && newLog.Contains($"\"ProcessId\":{process.Id}", StringComparison.Ordinal); } - var updatePackage = prepared.Packages.SingleOrDefault(x => string.Equals(x.Package.PackageId, "P_Rec_Update", StringComparison.Ordinal)); - var updateInitialized = updatePackage is null || File.Exists(Path.Combine(Path.GetDirectoryName(prepared.StarterPath)!, "markers", "AssemblyInitializeCompleted.marker")); - var loaded = runtimeConfirmed && updateInitialized; + var markerRoot = Path.Combine(bootstrapRoot, "markers"); + var beforeUpdateNetload = File.Exists(Path.Combine(markerRoot, "BeforePRecUpdateNetload.marker")); + var afterUpdateNetload = File.Exists(Path.Combine(markerRoot, "AfterPRecUpdateNetload.marker")); + var scriptCompleted = File.Exists(Path.Combine(markerRoot, "ScriptCompleted.marker")); + var updateEntered = updatePackage is null || File.Exists(Path.Combine(markerRoot, "InitializeEntered.marker.json")); + var updateInitialized = updatePackage is null || File.Exists(Path.Combine(markerRoot, "AssemblyInitializeCompleted.marker.json")); + var updateCommandRegistered = updatePackage is null || File.Exists(Path.Combine(markerRoot, "CommandRegistrationVerified.marker.json")); + var loaded = runtimeConfirmed && updateInitialized && updateCommandRegistered; var terminal = alive && loaded ? "Succeeded" : "Blocked"; var settingsSnapshot = prepared.Profile.SettingsSnapshot is null ? null : new { sourceEndpointFingerprint = prepared.Profile.SettingsSnapshot.SourceFingerprint, prepared.Profile.SettingsSnapshot.Sha256, prepared.Profile.SettingsSnapshot.Length, prepared.Profile.SettingsSnapshot.LastWriteUtc, prepared.Profile.SettingsSnapshot.AcquiredUtc, prepared.Profile.SettingsSnapshot.OfflineFallback }; - var evidence = new { schemaVersion = 3, launcherCommit = ThisCommit(), autodeskCommit = "68f5006", operationId, configuration = prepared.Profile.Name, selectedPattern = prepared.Profile.PatternName, settingsSnapshot, orderedPluginIds = prepared.Packages.Select(x => x.Requirement.PackageId), exactPackages = prepared.Packages.Select(x => new { x.Package.PackageId, x.Package.Version, x.Manifest.Product, x.Manifest.ProductYear, x.Manifest.Architecture, x.Manifest.RolloutChannel, manifestSha256 = HashAsync(Path.Combine(x.Package.LocalPackageRoot, "package.manifest.json"), ct).GetAwaiter().GetResult(), payloads = x.Manifest.Files.Select(f => new { path = f.Path, f.Length, f.Sha256 }), activation = x.Activation.Outcome, pointerGeneration = x.Activation.Receipt?.PointerGeneration }), materialization = "MaterializedOrAlreadyPresentExact", bootstrapMechanism="StartupScript", commandLineSwitch="/b", scriptWasLastArgument=true, scriptSha256=prepared.StarterSha256, plant = new { product = "Plant3D", year = 2026, executableFingerprint = Fingerprint(product.ExecutablePath, operationId), interactive = true, batchArgumentUsed = true }, process = new { pid = process.Id, newProcessCreated=true, startupVerified = alive, packageLoadConfirmed = loaded, markerConfirmed, runtimeJournalConfirmed = runtimeConfirmed, updateInitialized, processInteractiveAfterScript=alive }, terminalState = terminal, sideEffects = new { corporateDfsUsed = false, corporateDfsWriteAttempted = false, directDfsExecutionAttempted = false, productionProjectAccessed = false, productionPointerMutationAttempted = false, machineWideSecurityChanged=false, productionDeploymentAttempted = false } }; + var evidence = new { schemaVersion = 4, launcherCommit = ThisCommit(), autodeskCommit = "68f5006", operationId, configuration = prepared.Profile.Name, selectedPattern = prepared.Profile.PatternName, settingsSnapshot, orderedPluginIds = prepared.Packages.Select(x => x.Requirement.PackageId), exactPackages = prepared.Packages.Select((x, rosterPosition) => new { legacyId = x.Requirement.PackageId == "P_Rec_Update" ? "6ffef84b01097975f51fb3bbb11279e1" : "b455ee6eaed79d76f084846a60ce3284", rosterPosition, x.Package.PackageId, x.Package.Version, entryAssembly = x.Manifest.EntryAssembly, entrySha256 = x.Package.PackageId == "P_Rec_Update" ? updateAssemblyHash : HashAsync(Under(x.Package.LocalPackageRoot, x.Manifest.EntryAssembly), ct).GetAwaiter().GetResult(), manifestSha256 = HashAsync(Path.Combine(x.Package.LocalPackageRoot, "package.manifest.json"), ct).GetAwaiter().GetResult(), activation = x.Activation.Outcome, pointerGeneration = x.Activation.Receipt?.PointerGeneration, netloadAttempted = x.Package.PackageId != "P_Rec_Update" || beforeUpdateNetload, netloadReturned = x.Package.PackageId != "P_Rec_Update" || afterUpdateNetload, initializeEntered = x.Package.PackageId != "P_Rec_Update" || updateEntered, initializeCompleted = x.Package.PackageId != "P_Rec_Update" || updateInitialized, commandProbeResult = x.Package.PackageId == "P_Rec_Update" ? (updateCommandRegistered ? "COMMAND_REGISTRATION_VERIFIED" : "Unavailable") : "AssemblyInitializeCompleted" }), materialization = "MaterializedOrAlreadyPresentExact", bootstrapMechanism="StartupScript", commandLineSwitch="/b", scriptWasLastArgument=true, scriptSha256=prepared.StarterSha256, scriptMarkers = new { beforeUpdateNetload, afterUpdateNetload, scriptCompleted }, plant = new { product = "Plant3D", year = 2026, executableFingerprint = Fingerprint(product.ExecutablePath, operationId), interactive = true, batchArgumentUsed = true }, process = new { pid = process.Id, newProcessCreated=true, startupVerified = alive, packageLoadConfirmed = loaded, markerConfirmed, runtimeJournalConfirmed = runtimeConfirmed, updateEntered, updateInitialized, updateCommandRegistered, processInteractiveAfterScript=alive }, terminalState = terminal, terminalResult = loaded ? "PACKAGE_LOAD_VERIFIED" : updateEntered ? "PACKAGE_INITIALIZE_TIMEOUT" : afterUpdateNetload ? "PACKAGE_LOAD_FAILED" : beforeUpdateNetload ? "SCRIPT_ABORTED" : "SCRIPT_ABORTED", sideEffects = new { corporateDfsUsed = false, corporateDfsWriteAttempted = false, directDfsExecutionAttempted = false, productionProjectAccessed = false, productionPointerMutationAttempted = false, machineWideSecurityChanged=false, productionDeploymentAttempted = false } }; var evidencePath = await WriteEvidenceAsync(evidenceDirectory, evidence, ct); return new(process.Id, alive, loaded, evidencePath, terminal); } @@ -123,10 +132,22 @@ internal sealed class LocalParityLaunchService private static async Task<(string Path, string Sha256)> GenerateStarterAsync(LocalParityLaunchProfile profile, IReadOnlyList packages, CancellationToken ct) { var starterRoot = Under(profile.Root, "bootstrap", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(starterRoot); - var entries = packages.Select(x => Under(x.Package.LocalPackageRoot, x.Manifest.EntryAssembly)).ToArray(); - if (entries.Any(x => !File.Exists(x))) throw new InvalidOperationException("LOCAL_PARITY_ENTRY_MISSING"); - Directory.CreateDirectory(Under(starterRoot, "markers")); - var content = "_.FILEDIA\r\n0\r\n_.CMDDIA\r\n0\r\n" + string.Concat(entries.Select(entry => "_.NETLOAD\r\n\"" + entry.Replace("\"", "\\\"") + "\"\r\n")); + var entries = packages.Select(x => (x.Package.PackageId, Path: Under(x.Package.LocalPackageRoot, x.Manifest.EntryAssembly))).ToArray(); + if (entries.Any(x => !File.Exists(x.Path))) throw new InvalidOperationException("LOCAL_PARITY_ENTRY_MISSING"); + var markerRoot = Under(starterRoot, "markers"); Directory.CreateDirectory(markerRoot); + var helperPath = Under(starterRoot, "bootstrap-markers.lsp"); + var helper = "(defun PXP-MARK (p s / f) (setq f (open p \"w\")) (write-line s f) (close f) (princ))"; + await File.WriteAllTextAsync(helperPath, helper, new UTF8Encoding(false), ct); + string LispPath(string path) => path.Replace('\\', '/'); + string Mark(string name) => "(PXP-MARK \"" + LispPath(Under(markerRoot, name + ".marker")) + "\" \"" + name + "\")\r\n"; + var content = "(load \"" + LispPath(helperPath) + "\")\r\n_.FILEDIA\r\n0\r\n_.CMDDIA\r\n0\r\n"; + foreach (var entry in entries) + { + if (entry.PackageId == "P_Rec_Update") content += Mark("BeforePRecUpdateNetload"); + content += "_.NETLOAD\r\n\"" + entry.Path.Replace("\"", "\\\"") + "\"\r\n"; + if (entry.PackageId == "P_Rec_Update") content += Mark("AfterPRecUpdateNetload") + "P-R-UPD-STATUS\r\n"; + } + content += Mark("ScriptCompleted"); var path = Under(starterRoot, "startup.scr"); await File.WriteAllTextAsync(path, content, new UTF8Encoding(false), ct); return (path, await HashAsync(path, ct)); @@ -143,9 +164,19 @@ internal sealed class LocalParityLaunchService } private static bool UnderRoot(string root, string path) { var r = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar; var p = Path.GetFullPath(path); return p.StartsWith(r, StringComparison.OrdinalIgnoreCase); } private static string Under(string root, params string[] parts) { var result = Path.GetFullPath(Path.Combine(new[] { root }.Concat(parts).ToArray())); if (!UnderRoot(root, result) && !string.Equals(Path.GetFullPath(root), result, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("LOCAL_PARITY_PATH_ESCAPE"); return result; } - private static async Task HashAsync(string path, CancellationToken ct) { await using var stream = File.OpenRead(path); return Convert.ToHexString(await SHA256.HashDataAsync(stream, ct)); } + private static async Task HashAsync(string path, CancellationToken ct) { await using var stream = File.OpenRead(path); return Convert.ToHexString(await SHA256.HashDataAsync(stream, ct).ConfigureAwait(false)); } private static string Fingerprint(string path, string salt) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(salt + "|" + path))); - private static async Task WriteEvidenceAsync(string directory, object value, CancellationToken ct) { var final = Path.Combine(directory, "controlled-local-parity-launch.json"); var temp = final + ".tmp"; await using (var stream = new FileStream(temp, FileMode.CreateNew, FileAccess.Write, FileShare.None)) { await JsonSerializer.SerializeAsync(stream, value, cancellationToken: ct); await stream.FlushAsync(ct); } File.Move(temp, final); using var doc = JsonDocument.Parse(await File.ReadAllTextAsync(final, ct)); if (doc.RootElement.GetProperty("schemaVersion").GetInt32() != 3) throw new InvalidOperationException("LOCAL_PARITY_EVIDENCE_INVALID"); return final; } + internal static async Task WriteEvidenceAsync(string directory, object value, CancellationToken ct) + { + var final = Path.Combine(directory, "controlled-local-parity-launch.json"); + var temp = final + ".tmp"; + var json = JsonSerializer.Serialize(value); + await File.WriteAllTextAsync(temp, json, Encoding.UTF8, ct).ConfigureAwait(false); + File.Move(temp, final, overwrite: true); + using var doc = JsonDocument.Parse(await File.ReadAllTextAsync(final, ct).ConfigureAwait(false)); + if (doc.RootElement.GetProperty("schemaVersion").GetInt32() != 4) throw new InvalidOperationException("LOCAL_PARITY_EVIDENCE_INVALID"); + return final; + } private static string ThisCommit() { var embedded = Environment.GetEnvironmentVariable("LAUNCHER_COMMIT"); diff --git a/Launcher.Next.App/Properties/AssemblyInfo.cs b/Launcher.Next.App/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..2bbce5d --- /dev/null +++ b/Launcher.Next.App/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Launcher.Next.Tests")] diff --git a/tests/Launcher.Next.Tests/Launcher.Next.Tests.csproj b/tests/Launcher.Next.Tests/Launcher.Next.Tests.csproj index 5e2a7ee..11afe6a 100644 --- a/tests/Launcher.Next.Tests/Launcher.Next.Tests.csproj +++ b/tests/Launcher.Next.Tests/Launcher.Next.Tests.csproj @@ -10,6 +10,7 @@ + diff --git a/tests/Launcher.Next.Tests/LocalParityEvidenceTests.cs b/tests/Launcher.Next.Tests/LocalParityEvidenceTests.cs new file mode 100644 index 0000000..dc87199 --- /dev/null +++ b/tests/Launcher.Next.Tests/LocalParityEvidenceTests.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using Launcher.Next.App; +using Xunit; + +namespace Launcher.Next.Tests; + +public sealed class LocalParityEvidenceTests +{ + [Fact] + public async Task EvidenceWriter_AtomicallyPublishesValidatedSchema() + { + var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + try + { + Directory.CreateDirectory(root); + var path = await LocalParityLaunchService.WriteEvidenceAsync(root, new { schemaVersion = 4, state = "PACKAGE_LOAD_VERIFIED" }, CancellationToken.None); + Assert.True(File.Exists(path)); + using var document = JsonDocument.Parse(await File.ReadAllTextAsync(path)); + Assert.Equal(4, document.RootElement.GetProperty("schemaVersion").GetInt32()); + Assert.False(File.Exists(path + ".tmp")); + } + finally { if (Directory.Exists(root)) Directory.Delete(root, recursive: true); } + } + + [Fact] + public async Task EvidenceWriter_ReplacesStaleTemporaryArtifact() + { + var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + try + { + Directory.CreateDirectory(root); + await File.WriteAllTextAsync(Path.Combine(root, "controlled-local-parity-launch.json.tmp"), "stale"); + var path = await LocalParityLaunchService.WriteEvidenceAsync(root, new { schemaVersion = 4, terminalResult = "PACKAGE_LOAD_VERIFIED" }, CancellationToken.None); + Assert.Contains("PACKAGE_LOAD_VERIFIED", await File.ReadAllTextAsync(path)); + Assert.False(File.Exists(path + ".tmp")); + } + finally { if (Directory.Exists(root)) Directory.Delete(root, recursive: true); } + } +}