Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e38d8fad2 | ||
|
|
66e46db849 | ||
|
|
95beb0f632 | ||
|
|
1aa68ba8c5 | ||
|
|
c72f3cae0a | ||
|
|
9a5c578af4 | ||
|
|
961d8a662c | ||
|
|
d94fd4bdaf | ||
|
|
517105bd3f | ||
|
|
c4c611b35b | ||
|
|
20f4b6a7b8 | ||
|
|
0b890432d5 | ||
|
|
84ddac90a8 | ||
|
|
997762069f | ||
|
|
8764348a75 | ||
|
|
9c49ebf1e2 | ||
|
|
02e34186fc | ||
|
|
4c29c9084a | ||
|
|
d6ed0ac592 | ||
|
|
6fd670d08a | ||
|
|
d4a4d9acf8 | ||
|
|
b83742b3a5 | ||
|
|
cea9d70dc3 | ||
|
|
2298fac8cd | ||
|
|
c66f0485dc | ||
|
|
1fecaad47c | ||
|
|
e2c360e9f2 | ||
|
|
4ae5a93a4e | ||
|
|
73b97d9c2c | ||
|
|
6690fcaeb1 | ||
|
|
a531a48289 | ||
|
|
9f6abcc542 | ||
|
|
b3b30b7993 | ||
|
|
ea961f2455 | ||
|
|
0829e5dace | ||
|
|
0052790a2b | ||
|
|
f780650e9e | ||
|
|
49704681d3 | ||
|
|
2deca5d009 | ||
|
|
c1587b7f66 | ||
|
|
e13f44ac3c | ||
|
|
ea6f3cdcdf |
@@ -34,3 +34,4 @@ PRecAnnot*.dll
|
||||
appsettings.Local.json
|
||||
*.secrets.json
|
||||
.env
|
||||
launcher.legacy-configuration-probe.json
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# AGENTS.md — Launcher Next
|
||||
|
||||
## Scope
|
||||
|
||||
Эти правила относятся только к Launcher Next. Работать с текущей веткой и
|
||||
локальными test fixtures; не делать production deployment, не менять default
|
||||
branches и не создавать новую ветку или worktree без прямого задания.
|
||||
|
||||
## Общая карта проекта
|
||||
|
||||
Каноническая общая карта проекта:
|
||||
`https://drive.google.com/drive/folders/1avnR0V8q7y5pfWRYwB6LPBEEyfpN7PpV`.
|
||||
Необязательный локальный доступ к синхронизированной копии возможен только
|
||||
через `PHIM_PLANT3D_PROJECT_MAP_ROOT`; путь синхронизации не хардкодить.
|
||||
|
||||
Перед архитектурной, package-lifecycle или runtime работой читать доступные
|
||||
индекс/обзор карты, запись Launcher architecture и migration status, package
|
||||
activation/rollback contract, индекс runtime evidence и записи затронутой
|
||||
интеграции. Сверять их с локальным AGENTS.md, tracked documentation, code,
|
||||
tests, configuration, фактическим `git rev-parse HEAD` и runtime evidence.
|
||||
|
||||
Приоритет источников: фактическое runtime evidence; code/tests/configuration
|
||||
на проверенном Git HEAD; tracked documentation; общая карта проекта. Внешняя
|
||||
документация не является build/runtime dependency и не переопределяет текущий
|
||||
код или runtime evidence. Недоступность карты фиксировать как
|
||||
`PROJECT_MAP_UNAVAILABLE`; несовпадение её commit/runtime status с фактическими
|
||||
данными — как `PROJECT_MAP_STALE`. В обоих случаях продолжать только по
|
||||
доступным источникам и явно сообщать расхождение.
|
||||
|
||||
## Launcher invariants
|
||||
|
||||
Launcher использует versioned local package, проверяемый manifest и один
|
||||
managed entry assembly; Autodesk DLL не включаются в package и не загружаются
|
||||
с UNC. Headless automation требует явного NextPilot mode, не создаёт Foundation
|
||||
UI, не ожидает ввода и не возвращается в Legacy/default route при ошибке.
|
||||
Production route не создаёт pilot session или automation manifest и не
|
||||
использует `/b`.
|
||||
|
||||
До hashing сохранять immutable manifest и initial evidence; session/progress,
|
||||
terminal evidence, cancellation, rollback и package activation должны быть
|
||||
проверяемыми. Runtime evidence определяет реальный статус запуска AutoCAD,
|
||||
активации и cleanup — build и unit tests его не заменяют.
|
||||
|
||||
После изменения Launcher architecture, package activation/rollback либо
|
||||
runtime-status в отчёте перечислять документы карты, которые требуют
|
||||
обновления: Launcher architecture, package lifecycle, runtime evidence index,
|
||||
migration status и запись затронутой интеграции. Не заявлять обновление карты
|
||||
без фактического внешнего изменения.
|
||||
@@ -2,5 +2,19 @@ namespace Launcher.Next.App;
|
||||
public partial class App : System.Windows.Application
|
||||
{
|
||||
protected override async void OnStartup(System.Windows.StartupEventArgs e)
|
||||
{ base.OnStartup(e); if (e.Args.FirstOrDefault()?.Equals("pilot", StringComparison.OrdinalIgnoreCase) == true) { Shutdown(await PilotCli.RunAsync(e.Args.Skip(1).ToArray())); return; } new MainWindow().Show(); }
|
||||
{
|
||||
base.OnStartup(e);
|
||||
if (e.Args.FirstOrDefault()?.Equals("legacy-configuration-probe", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
Shutdown(await LegacyConfigurationProbeCli.RunAsync(e.Args.Skip(1).ToArray()));
|
||||
return;
|
||||
}
|
||||
if (e.Args.FirstOrDefault()?.Equals("package-lifecycle-pilot", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
Shutdown(await PackageLifecyclePilotCli.RunAsync(e.Args.Skip(1).ToArray()));
|
||||
return;
|
||||
}
|
||||
if (e.Args.FirstOrDefault()?.Equals("pilot", StringComparison.OrdinalIgnoreCase) == true) { Shutdown(await PilotCli.RunAsync(e.Args.Skip(1).ToArray())); return; }
|
||||
new MainWindow().Show();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using Launcher.Next.Application;
|
||||
using Launcher.Next.Core;
|
||||
using System.IO;
|
||||
|
||||
namespace Launcher.Next.App;
|
||||
|
||||
/// <summary>Dedicated, non-Autodesk command route. It deliberately constructs
|
||||
/// only the read-only legacy configuration probe dependency graph.</summary>
|
||||
internal static class LegacyConfigurationProbeCli
|
||||
{
|
||||
internal static async Task<int> RunAsync(string[] args)
|
||||
{
|
||||
if (args.Length != 4 || !string.Equals(args[0], "--mode", StringComparison.Ordinal) ||
|
||||
!string.Equals(args[1], "LegacyConfigurationProbe", StringComparison.Ordinal) ||
|
||||
!string.Equals(args[2], "--config", StringComparison.Ordinal) || string.IsNullOrWhiteSpace(args[3]))
|
||||
return Fail("LEGACY_PROBE_MODE_CONFLICT");
|
||||
|
||||
try
|
||||
{
|
||||
if (!Path.IsPathFullyQualified(args[3]) || !File.Exists(args[3]) || Directory.Exists(args[3]))
|
||||
return Fail("LEGACY_PROBE_OVERRIDE_MISSING");
|
||||
var overrideDocument = LegacyProbeOverrideParser.Parse(await File.ReadAllTextAsync(args[3]));
|
||||
var variables = new LauncherPathVariables(new Dictionary<string, string?>
|
||||
{
|
||||
["LegacySettingsPath"] = overrideDocument.SettingsXml,
|
||||
["LegacyDownloadListPath"] = overrideDocument.DownloadListXml,
|
||||
["LegacyCompatibilityOverlayPath"] = overrideDocument.CompatibilityOverlay,
|
||||
["LegacyProbeEvidenceRoot"] = overrideDocument.EvidenceOutputRoot
|
||||
});
|
||||
var resolver = new PathResolver();
|
||||
var request = new LegacyConfigurationProbeRequest(
|
||||
resolver.ResolveLegacySettingsPath(variables),
|
||||
resolver.ResolveLegacyDownloadListPath(variables),
|
||||
resolver.ResolveLegacyCompatibilityOverlayPath(variables),
|
||||
resolver.ResolveLegacyProbeEvidenceRoot(variables),
|
||||
overrideDocument.SelectedPattern,
|
||||
Guid.NewGuid().ToString("N"));
|
||||
var result = await new LegacyConfigurationProbe(new LegacyConfigurationAdapter()).RunAsync(request);
|
||||
Console.WriteLine(result.TerminalState);
|
||||
return result.TerminalState == "Succeeded" ? 0 : 2;
|
||||
}
|
||||
catch (InvalidOperationException ex) { return Fail(ex.Message); }
|
||||
catch { return Fail("LEGACY_PROBE_INTERNAL_FAILURE"); }
|
||||
}
|
||||
|
||||
private static int Fail(string code) { Console.Error.WriteLine(code); return 2; }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.IO;
|
||||
using Launcher.Next.Core;
|
||||
using Launcher.Next.Infrastructure;
|
||||
|
||||
namespace Launcher.Next.App;
|
||||
|
||||
internal static class PackageLifecyclePilotCli
|
||||
{
|
||||
public static async Task<int> RunAsync(string[] args)
|
||||
{
|
||||
if (args.Length != 4 || args[0] != "--mode" || args[1] != "PackageLifecyclePilot" || args[2] != "--root") return 2;
|
||||
ResolvedPackagePilotRoot pilotRoot; try { pilotRoot=new PathResolver().ResolvePackagePilotRoot(args[3]); } catch { return 2; }
|
||||
var root=pilotRoot.Value; var id=Guid.NewGuid().ToString("N"); var evidenceDir=Path.Combine(root,"evidence",id); Directory.CreateDirectory(evidenceDir);
|
||||
try
|
||||
{
|
||||
var catalog=Path.Combine(root,"catalog"); Directory.CreateDirectory(catalog); var store=new LocalPackageStore(root,new Sha256FileHasher());
|
||||
async Task<(ImmutableLocalPackage,PackageManifest)> Materialize(string version,byte seed)
|
||||
{ var a=new[]{seed,(byte)(seed+1)}; var b=new[]{(byte)(seed+2)}; await File.WriteAllBytesAsync(Path.Combine(catalog,"entry.dll"),a); await File.WriteAllBytesAsync(Path.Combine(catalog,"payload.dat"),b); var manifest=new PackageManifest(1,"Pilot.Package",version,"Plant3D",2026,"x64","entry.dll",[new("entry.dll",a.Length,Convert.ToHexString(SHA256.HashData(a))),new("payload.dat",b.Length,Convert.ToHexString(SHA256.HashData(b)))],new("none",""),RolloutChannel:"pilot"); return (await store.MaterializeAsync(manifest,catalog,CancellationToken.None),manifest); }
|
||||
PackageRequirement Req(string v)=>new("Pilot.Package","Plant3D",2026,"x64","pilot",v,"pilot-roster",true);
|
||||
var (a,ma)=await Materialize("1.0.0",1); var pointersAfterA=await store.GetCurrentVersionAsync("Pilot.Package",CancellationToken.None); var aa=await store.ActivateImmutableAsync(a,Req("1.0.0"),CancellationToken.None); var repeat=await store.ActivateImmutableAsync(a,Req("1.0.0"),CancellationToken.None);
|
||||
var (b,mb)=await Materialize("2.0.0",4); var beforeB=await store.GetCurrentVersionAsync("Pilot.Package",CancellationToken.None); var ab=await store.ActivateImmutableAsync(b,Req("2.0.0"),CancellationToken.None); var rollback=await store.RollbackAsync(ab.Receipt!,CancellationToken.None); var current=await store.GetCurrentVersionAsync("Pilot.Package",CancellationToken.None);
|
||||
var evidence=new { schemaVersion=1,mode="PackageLifecyclePilot",operationId=id,terminalState=current=="1.0.0"?"Succeeded":"Blocked",sourceClassification="SyntheticLocal",rootFingerprint=Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(id+root))),packages=new[]{new{version=ma.Version,manifestSha256=Convert.ToHexString(SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(ma)))},new{version=mb.Version,manifestSha256=Convert.ToHexString(SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(mb)))}},pointers=new{afterMaterializeA=pointersAfterA,afterActivateA=aa.Outcome,repeatedA=repeat.Outcome,beforeActivateB=beforeB,afterActivateB=ab.Outcome,afterRollback=rollback.Outcome,current},sideEffects=new{corporateDfsUsed=false,corporateDfsWriteAttempted=false,autoCadLaunchAttempted=false,processLaunchAttempted=false,productionPointerMutationAttempted=false,productionProjectAccessed=false}};
|
||||
var final=Path.Combine(evidenceDir,"package-lifecycle-pilot.json"); var temp=final+".tmp"; await File.WriteAllTextAsync(temp,JsonSerializer.Serialize(evidence)); File.Move(temp,final,true); return current=="1.0.0"?0:2;
|
||||
}
|
||||
catch { return 10; }
|
||||
}
|
||||
}
|
||||
+180
-30
@@ -20,7 +20,8 @@ internal static class PilotCli
|
||||
{
|
||||
try
|
||||
{
|
||||
if (args.Length == 0 || args[0] is not ("prepare" or "run" or "status" or "rollback" or "automate-first-apply")) return Blocked;
|
||||
if (args.Length == 0 || args[0] is not ("prepare" or "run" or "status" or "rollback" or "preflight" or "automate-first-apply")) return Blocked;
|
||||
if (args[0] == "preflight") return await PreflightAsync(args.Skip(1).ToArray());
|
||||
if (args[0] == "automate-first-apply") return await AutomateFirstApplyAsync(args.Skip(1).ToArray());
|
||||
var command = args[0];
|
||||
if (!TryParse(command, args.Skip(1).ToArray(), out var options, out var error)) return Fail(Blocked, error!);
|
||||
@@ -28,14 +29,15 @@ internal static class PilotCli
|
||||
if (command == "status") return await StatusAsync(root);
|
||||
if (command == "rollback") return await RollbackAsync(root);
|
||||
if (!TryReadConfiguration(root, out var config, out error)) return Fail(Blocked, error!);
|
||||
if (!string.Equals(config.PilotRoot, root, StringComparison.OrdinalIgnoreCase)) return Fail(Blocked, "PILOT_ROOT_CONFIGURATION_MISMATCH");
|
||||
if (!TryResolvePaths(config, out var paths, out error)) return Fail(Blocked, error!);
|
||||
if (!string.Equals(paths.LocalLauncherRoot, root, StringComparison.OrdinalIgnoreCase) || !string.Equals(config.PilotRoot, root, StringComparison.OrdinalIgnoreCase)) return Fail(Blocked, "PILOT_ROOT_CONFIGURATION_MISMATCH");
|
||||
if (!TryBuildManifest(options.PackageSource!, options.EntryAssembly!, out var manifest, out var manifestHash, out error)) return Fail(Blocked, error!);
|
||||
Directory.CreateDirectory(Path.Combine(root, "catalog"));
|
||||
await File.WriteAllTextAsync(Path.Combine(root, "catalog", "P_Rec_Annot.manifest.json"), JsonSerializer.Serialize(manifest));
|
||||
if (!File.Exists(@"C:\Program Files\Autodesk\AutoCAD 2026\acad.exe")) return Fail(DiscoveryFailed, "PLANT3D_2026_NOT_FOUND");
|
||||
if (!File.Exists(paths.AcadExecutable)) return Fail(DiscoveryFailed, "PLANT3D_2026_NOT_FOUND");
|
||||
if (command == "run" && File.Exists(SessionPath(root)) && await HasActiveSession(root)) return await HandleExistingSession(root);
|
||||
var service = CreateService();
|
||||
var product = new ProductInstallation(@"C:\Program Files\Autodesk\AutoCAD 2026\acad.exe", "2026", "x64", []);
|
||||
var service = CreateService(paths);
|
||||
var product = new ProductInstallation(paths.AcadExecutable, "2026", "x64", []);
|
||||
var request = new PilotPreparationRequest(config, product, manifest!, manifestHash!, options.PackageSource!, root, Path.Combine(root, "startup", "prepare-only.scr"));
|
||||
var prepared = await service.PrepareAsync(request, CancellationToken.None);
|
||||
if (prepared.Plan is null) return Fail(prepared.SafetyDecision.Violations.Contains("ACAD_ALREADY_RUNNING") ? AcadRunning : Blocked, string.Join(',', prepared.SafetyDecision.Violations));
|
||||
@@ -76,40 +78,122 @@ internal static class PilotCli
|
||||
|
||||
private static readonly IExecutionPermitService PermitService = new ExecutionPermitService();
|
||||
|
||||
private sealed record AutomationOptions(string PackageSource, string EntryAssembly, string PilotRoot, string FixtureMarker, string OrthoDrawing, string ModelDrawing, string EvidenceRoot);
|
||||
private sealed record PreflightFile(string Role, string CanonicalPath, string AllowedRoot, string SourcePathVariable, bool Required, long Size, DateTimeOffset LastWriteUtc, string ReparseStatus, bool HashRequired, string? Sha256 = null);
|
||||
private sealed record PreflightManifest(string SessionId, DateTimeOffset CreatedUtc, IReadOnlyList<PreflightFile> Files);
|
||||
|
||||
private static async Task<int> PreflightAsync(string[] args)
|
||||
{
|
||||
if (!TryParseAutomation(args, out var options, out var error)) return Fail(Blocked, error!);
|
||||
var root = options.PilotRoot;
|
||||
var journal = await AutomationJournal.AllocateAsync(root, "NextPilotPreview", CancellationToken.None);
|
||||
try
|
||||
{
|
||||
if (!TryReadConfiguration(root, out var config, out error) || config.LauncherMode != LauncherMode.NextPilot || !config.PilotEnabled)
|
||||
return await journal.TerminalAsync("PRELAUNCH_CONFIGURATION_INVALID", "Failed", null, CancellationToken.None);
|
||||
if (Process.GetProcessesByName("acad").Any()) return await journal.TerminalAsync("ACAD_ALREADY_RUNNING", "Failed", null, CancellationToken.None);
|
||||
var fixtureRoot = Path.GetFullPath(Path.GetDirectoryName(options.FixtureMarker)!);
|
||||
var files = new[]
|
||||
{
|
||||
("FixtureOrthoDwg", options.OrthoDrawing, "FixtureOrthoDwg"),
|
||||
("FixtureModelDwg", options.ModelDrawing, "FixtureModelDwg"),
|
||||
("PipingDcf", Path.Combine(fixtureRoot, "Piping.dcf"), "PipingDcf"),
|
||||
("ProjectXml", Path.Combine(fixtureRoot, "Project.xml"), "ProjectXml")
|
||||
};
|
||||
var manifestFiles = new List<PreflightFile>();
|
||||
foreach (var (role, path, variable) in files)
|
||||
{
|
||||
var canonical = Path.GetFullPath(path);
|
||||
if (!canonical.StartsWith(fixtureRoot.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
|
||||
return await journal.TerminalAsync("MANIFEST_OUTSIDE_ALLOWED_ROOT", "Failed", canonical, CancellationToken.None);
|
||||
var info = new FileInfo(canonical);
|
||||
if (info.Attributes.HasFlag(FileAttributes.Directory)) return await journal.TerminalAsync("MANIFEST_DIRECTORY_REJECTED", "Failed", canonical, CancellationToken.None);
|
||||
if (info.Attributes.HasFlag(FileAttributes.ReparsePoint)) return await journal.TerminalAsync("MANIFEST_REPARSE_REJECTED", "Failed", canonical, CancellationToken.None);
|
||||
if (!info.Exists && role is "FixtureOrthoDwg" or "FixtureModelDwg" or "PipingDcf" or "ProjectXml") return await journal.TerminalAsync("MANIFEST_REQUIRED_FILE_MISSING", "Failed", canonical, CancellationToken.None);
|
||||
manifestFiles.Add(new(role, canonical, fixtureRoot, variable, true, info.Length, info.LastWriteTimeUtc, "None", true));
|
||||
}
|
||||
if (manifestFiles.Count == 0 || manifestFiles.Select(x => x.CanonicalPath).Distinct(StringComparer.OrdinalIgnoreCase).Count() != manifestFiles.Count)
|
||||
return await journal.TerminalAsync("MANIFEST_INVALID", "Failed", null, CancellationToken.None);
|
||||
var manifest = new PreflightManifest(journal.SessionId, DateTimeOffset.UtcNow, manifestFiles);
|
||||
var sessionDir = Path.Combine(root, "state", "automation-sessions", journal.SessionId);
|
||||
await WriteAtomicAsync(Path.Combine(sessionDir, "manifest.json"), manifest, CancellationToken.None);
|
||||
await journal.RecordAsync("ManifestPersisted", "Completed", null, CancellationToken.None);
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
var totalBytes = manifestFiles.Sum(x => x.Size); var completedBytes = 0L; var completed = 0;
|
||||
for (var fileIndex = 0; fileIndex < manifestFiles.Count; fileIndex++)
|
||||
{
|
||||
var file = manifestFiles[fileIndex];
|
||||
await journal.RecordProgressAsync("HashFileStarted", "Started", file.Role, file.CanonicalPath, completed, manifestFiles.Count, completedBytes, totalBytes, false, CancellationToken.None);
|
||||
try
|
||||
{
|
||||
await using var stream = File.OpenRead(file.CanonicalPath);
|
||||
var hash = Convert.ToHexString(await HashWithWatchdogAsync(stream, cancellation.Token));
|
||||
manifestFiles[fileIndex] = file with { Sha256 = hash };
|
||||
completed++; completedBytes += file.Size;
|
||||
await journal.RecordProgressAsync("HashFileCompleted", "Completed", file.Role, file.CanonicalPath, completed, manifestFiles.Count, completedBytes, totalBytes, false, CancellationToken.None);
|
||||
}
|
||||
catch (TimeoutException ex) { cancellation.Cancel(); await journal.RecordFailureAsync("HashFileTimedOut", file.Role, file.CanonicalPath, ex, CancellationToken.None); return await journal.TerminalAsync("PRELAUNCH_PROGRESS_TIMEOUT", "Failed", file.CanonicalPath, CancellationToken.None); }
|
||||
catch (OperationCanceledException) { await journal.RecordProgressAsync("HashFileCancelled", "Cancelled", file.Role, file.CanonicalPath, completed, manifestFiles.Count, completedBytes, totalBytes, true, CancellationToken.None); return await journal.TerminalAsync("PRELAUNCH_CANCELLED", "Cancelled", file.CanonicalPath, CancellationToken.None); }
|
||||
catch (Exception ex) { await journal.RecordFailureAsync("HashFileFailed", file.Role, file.CanonicalPath, ex, CancellationToken.None); return await journal.TerminalAsync("HASH_FILE_FAILED", "Failed", file.CanonicalPath, CancellationToken.None); }
|
||||
}
|
||||
await WriteAtomicAsync(Path.Combine(sessionDir, "manifest.json"), manifest with { Files = manifestFiles }, CancellationToken.None);
|
||||
await journal.RecordAsync("GateBeforeAutoCADLaunch", "Completed", null, CancellationToken.None);
|
||||
return await journal.TerminalAsync("PRELAUNCH_GATE_PASSED_AUTOCAD_NOT_STARTED", "Succeeded", null, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex) { await journal.RecordFailureAsync("PreflightFailed", null, null, ex, CancellationToken.None); return await journal.TerminalAsync("PRELAUNCH_PREFLIGHT_FAILED", "Failed", null, CancellationToken.None); }
|
||||
}
|
||||
private static async Task<byte[]> HashWithWatchdogAsync(Stream stream, CancellationToken cancellationToken)
|
||||
{
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
var hashing = SHA256.HashDataAsync(stream, linked.Token).AsTask();
|
||||
var watchdog = Task.Delay(TimeSpan.FromSeconds(30));
|
||||
if (await Task.WhenAny(hashing, watchdog) == watchdog) { linked.Cancel(); throw new TimeoutException("PRELAUNCH_PROGRESS_TIMEOUT"); }
|
||||
return await hashing;
|
||||
}
|
||||
|
||||
private sealed record AutomationOptions(string PackageSource, string EntryAssembly, string PilotRoot, string FixtureMarker, string OrthoDrawing, string ModelDrawing, string AnnotationTemplateDwg, string EvidenceRoot, bool Diagnostic);
|
||||
private sealed record PreviewEvidence(bool Completed, bool? CommandsCompleted, bool? InspectorsCompleted, bool? EvidenceFlushed, string? PlanIdRun1, string? PlanIdRun2, bool? PlanIdsEqual, int? EligibleCandidateCount, string? SelectedCandidateHash);
|
||||
private sealed record ApplyEvidence(bool Completed, bool? CommandsCompleted, bool? InspectorsCompleted, bool? EvidenceFlushed, bool? PermitAccepted, string? ApplyOutcomeRun1, string? ApplyOutcomeRun2, object? Baseline, object? AfterCommand1, object? AfterCommand2, object? AfterUndo);
|
||||
private sealed record ApplyPermit(bool AllowApply, string FixtureMarkerPath, string TargetSnapshotId, string ExpectedPlanId, string VisibilityState);
|
||||
private sealed record ApplyPermit(bool AllowApply, string FixtureMarkerPath, string TargetSnapshotId, string ExpectedPlanId, string VisibilityState, string AnnotationTemplateDwgPath);
|
||||
private sealed record AutomationSessionResult(bool Started, bool GracefulExitVerified, bool ForcedCleanupUsed, bool RollbackVerified, bool EvidenceCompleted);
|
||||
|
||||
private static async Task<int> AutomateFirstApplyAsync(string[] args)
|
||||
{
|
||||
if (!TryParseAutomation(args, out var options, out var error)) return Fail(Blocked, error!);
|
||||
var journal = await AutomationJournal.AllocateAsync(options.PilotRoot, "NextPilotPreview", CancellationToken.None);
|
||||
await journal.RecordAsync("ArgumentsParsed", "Completed", null, CancellationToken.None);
|
||||
if (!TryReadConfiguration(options.PilotRoot, out var baseConfig, out error)) return Fail(Blocked, error!);
|
||||
await journal.RecordAsync("ConfigurationLoaded", "Completed", null, CancellationToken.None);
|
||||
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 before = ContentHashes(options.FixtureMarker);
|
||||
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);
|
||||
var previewConfigPath = Path.Combine(options.EvidenceRoot, "automation-preview.config.json");
|
||||
var previewEvidence = Path.Combine(options.EvidenceRoot, "preview-result.json");
|
||||
await File.WriteAllTextAsync(previewConfigPath, JsonSerializer.Serialize(new { phase = "Preview", orthoDrawingPath = options.OrthoDrawing, modelDrawingPath = options.ModelDrawing, evidencePath = previewEvidence, fixtureMarkerPath = options.FixtureMarker }));
|
||||
var entryHash = manifest!.Files.Single(x => string.Equals(x.Path, options.EntryAssembly, StringComparison.OrdinalIgnoreCase)).Sha256;
|
||||
await File.WriteAllTextAsync(previewConfigPath, JsonSerializer.Serialize(new { phase = "Preview", orthoDrawingPath = options.OrthoDrawing, modelDrawingPath = options.ModelDrawing, annotationTemplateDwgPath = options.AnnotationTemplateDwg, evidencePath = previewEvidence, fixtureMarkerPath = options.FixtureMarker, entryAssemblySha256 = entryHash, automationTranscriptPath = Path.Combine(options.EvidenceRoot, "preview-transcript.log"), diagnostic = options.Diagnostic }));
|
||||
await File.WriteAllTextAsync(previewEvidence, "{\"completed\":false}");
|
||||
var previewConfig = baseConfig with { PluginExecutionMode = PluginExecutionMode.DryRun, AllowApply = false, ApplyPermitPath = null, AutomationMode = "FirstLimitedApplyRuntimeTest", AutomationConfigurationPath = previewConfigPath };
|
||||
var previewConfig = WithAutomationPaths(baseConfig, options, previewConfigPath, previewEvidence, null, "preview") with { PluginExecutionMode = PluginExecutionMode.DryRun, AllowApply = false, ApplyPermitPath = null, AutomationMode = "FirstLimitedApplyRuntimeTest", AutomationConfigurationPath = previewConfigPath, FixtureMarkerPath = options.FixtureMarker, LaunchMode = AcadLaunchMode.NextPilotPreview, AutomationDiagnostic = options.Diagnostic };
|
||||
var previewSession = await RunAutomationSession(previewConfig, manifest!, manifestHash!, options, "preview", previewEvidence);
|
||||
if (!previewSession.Started || !previewSession.RollbackVerified || !previewSession.EvidenceCompleted) return Fail(ProcessFailed, "AUTOMATION_PREVIEW_SESSION_FAILED");
|
||||
var preview = JsonSerializer.Deserialize<PreviewEvidence>(await File.ReadAllTextAsync(previewEvidence), new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
if (preview is null || !preview.Completed || preview.CommandsCompleted != true || preview.InspectorsCompleted != true || preview.EvidenceFlushed != true || preview.PlanIdRun1 is null || preview.PlanIdRun1 != preview.PlanIdRun2 || preview.PlanIdsEqual != true || preview.EligibleCandidateCount is not > 0 || string.IsNullOrWhiteSpace(preview.SelectedCandidateHash)) return Fail(Blocked, "AUTOMATION_PREVIEW_EVIDENCE_INVALID");
|
||||
if (!HashesEqual(before, ContentHashes(options.FixtureMarker))) return Fail(Blocked, "AUTOMATION_PREVIEW_CONTENT_CHANGED");
|
||||
if (!HashesEqual(before, await ContentHashesAsync(options.FixtureMarker, journal, CancellationToken.None))) return Fail(Blocked, "AUTOMATION_PREVIEW_CONTENT_CHANGED");
|
||||
// A diagnostic preview establishes the script/registration boundary only.
|
||||
// It must never mint an Apply permit or start an Apply process.
|
||||
if (options.Diagnostic) return Success;
|
||||
var permitPath = Path.Combine(options.EvidenceRoot, "apply-permit.json");
|
||||
await File.WriteAllTextAsync(permitPath, JsonSerializer.Serialize(new ApplyPermit(true, options.FixtureMarker, preview.SelectedCandidateHash, preview.PlanIdRun1, "Visible")));
|
||||
await File.WriteAllTextAsync(permitPath, JsonSerializer.Serialize(new ApplyPermit(true, options.FixtureMarker, preview.SelectedCandidateHash, preview.PlanIdRun1, "М25 DN15-32", options.AnnotationTemplateDwg)));
|
||||
var applyConfigPath = Path.Combine(options.EvidenceRoot, "automation-apply.config.json");
|
||||
var applyEvidence = Path.Combine(options.EvidenceRoot, "apply-result.json");
|
||||
await File.WriteAllTextAsync(applyConfigPath, JsonSerializer.Serialize(new { phase = "Apply", orthoDrawingPath = options.OrthoDrawing, modelDrawingPath = options.ModelDrawing, evidencePath = applyEvidence, fixtureMarkerPath = options.FixtureMarker }));
|
||||
await File.WriteAllTextAsync(applyConfigPath, JsonSerializer.Serialize(new { phase = "Apply", orthoDrawingPath = options.OrthoDrawing, modelDrawingPath = options.ModelDrawing, annotationTemplateDwgPath = options.AnnotationTemplateDwg, evidencePath = applyEvidence, fixtureMarkerPath = options.FixtureMarker, entryAssemblySha256 = entryHash, automationTranscriptPath = Path.Combine(options.EvidenceRoot, "apply-transcript.log"), diagnostic = false }));
|
||||
await File.WriteAllTextAsync(applyEvidence, "{\"completed\":false}");
|
||||
var applyConfig = baseConfig with { PluginExecutionMode = PluginExecutionMode.Apply, AllowApply = true, ApplyPermitPath = permitPath, AutomationMode = "FirstLimitedApplyRuntimeTest", AutomationConfigurationPath = applyConfigPath };
|
||||
var applyConfig = WithAutomationPaths(baseConfig, options, applyConfigPath, applyEvidence, permitPath, "apply") with { PluginExecutionMode = PluginExecutionMode.Apply, AllowApply = true, ApplyPermitPath = permitPath, AutomationMode = "FirstLimitedApplyRuntimeTest", AutomationConfigurationPath = applyConfigPath, FixtureMarkerPath = options.FixtureMarker, LaunchMode = AcadLaunchMode.NextPilotApply, AutomationDiagnostic = false };
|
||||
var applySession = await RunAutomationSession(applyConfig, manifest!, manifestHash!, options, "apply", applyEvidence);
|
||||
var hashesEqual = HashesEqual(before, ContentHashes(options.FixtureMarker));
|
||||
var hashesEqual = HashesEqual(before, await ContentHashesAsync(options.FixtureMarker, journal, CancellationToken.None));
|
||||
var apply = File.Exists(applyEvidence) ? JsonSerializer.Deserialize<ApplyEvidence>(await File.ReadAllTextAsync(applyEvidence), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) : null;
|
||||
if (File.Exists(permitPath)) File.Delete(permitPath);
|
||||
var applyVerified = apply is { Completed: true, CommandsCompleted: true, InspectorsCompleted: true, EvidenceFlushed: true, PermitAccepted: true, ApplyOutcomeRun1: "Created", ApplyOutcomeRun2: "AlreadyExists" } && apply.Baseline is not null && apply.AfterCommand1 is not null && apply.AfterCommand2 is not null && apply.AfterUndo is not null;
|
||||
@@ -118,10 +202,21 @@ internal static class PilotCli
|
||||
|
||||
private static async Task<AutomationSessionResult> RunAutomationSession(PilotLocalConfiguration config, PackageManifest manifest, string manifestHash, AutomationOptions options, string phase, string evidencePath)
|
||||
{
|
||||
var service = CreateService(); var product = new ProductInstallation(@"C:\Program Files\Autodesk\AutoCAD 2026\acad.exe", "2026", "x64", []);
|
||||
if (!TryResolvePaths(config, out var paths, out _)) return new(false, false, false, false, false);
|
||||
var service = CreateService(paths); var product = new ProductInstallation(paths.AcadExecutable, "2026", "x64", []);
|
||||
var request = new PilotPreparationRequest(config, product, manifest, manifestHash, options.PackageSource, options.PilotRoot, Path.Combine(options.PilotRoot, "startup", phase + ".scr"));
|
||||
var prepared = await service.PrepareAsync(request, CancellationToken.None); if (prepared.Plan is null) return new(false, false, false, false, false);
|
||||
var execution = await service.ExecuteAsync(new(request, prepared.Plan, PermitService.Issue(prepared.Plan, config.ExecutionPermitLifetimeSeconds)), CancellationToken.None); if (!execution.Started || execution.ProcessId is null) return new(false, false, false, false, false);
|
||||
var prepared = await service.PrepareAsync(request, CancellationToken.None);
|
||||
if (prepared.Plan is null)
|
||||
{
|
||||
Console.Error.WriteLine("AUTOMATION_PREPARE_BLOCKED:" + string.Join(',', prepared.SafetyDecision.Violations));
|
||||
return new(false, false, false, false, false);
|
||||
}
|
||||
var execution = await service.ExecuteAsync(new(request, prepared.Plan, PermitService.Issue(prepared.Plan, config.ExecutionPermitLifetimeSeconds)), CancellationToken.None);
|
||||
if (!execution.Started || execution.ProcessId is null)
|
||||
{
|
||||
Console.Error.WriteLine("AUTOMATION_EXECUTION_BLOCKED:" + (execution.BlockCode ?? "PROCESS_START_FAILED"));
|
||||
return new(false, false, false, false, false);
|
||||
}
|
||||
var forcedCleanup = false;
|
||||
var gracefulExit = false;
|
||||
try
|
||||
@@ -134,13 +229,14 @@ internal static class PilotCli
|
||||
// terminal evidence is durable. Killing an active AutoCAD
|
||||
// command before that boundary would turn ForceAfterEvidence
|
||||
// into an unsafe general process killer.
|
||||
var evidenceTimeout = Task.Delay(TimeSpan.FromSeconds(Math.Clamp(config.GracefulExitTimeoutSeconds, 1, 120)));
|
||||
while (!exit.IsCompleted && !HasCompletedEvidence(evidencePath))
|
||||
await Task.Delay(TimeSpan.FromSeconds(1));
|
||||
if (await Task.WhenAny(exit, evidenceTimeout) == evidenceTimeout) break;
|
||||
if (exit.IsCompleted) gracefulExit = true;
|
||||
else
|
||||
{
|
||||
var timeout = Task.Delay(TimeSpan.FromSeconds(Math.Clamp(config.GracefulExitTimeoutSeconds, 1, 120)));
|
||||
if (await Task.WhenAny(exit, timeout) == exit) gracefulExit = true;
|
||||
if (HasCompletedEvidence(evidencePath) && await Task.WhenAny(exit, timeout) == exit) gracefulExit = true;
|
||||
else
|
||||
{
|
||||
using var process = Process.GetProcessById(execution.ProcessId.Value);
|
||||
@@ -158,15 +254,40 @@ internal static class PilotCli
|
||||
private static bool TryParseAutomation(string[] args, out AutomationOptions options, out string? error)
|
||||
{
|
||||
options = null!; error = null; var d = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var i = 0; i < args.Length; i++) { if (i + 1 >= args.Length || !args[i].StartsWith("--") || !d.TryAdd(args[i], args[++i])) { error = "AUTOMATION_ARGUMENT_INVALID"; return false; } }
|
||||
var diagnostic = false;
|
||||
for (var i = 0; i < args.Length; i++) { if (args[i] == "--diagnostic") { if (diagnostic) { error = "AUTOMATION_ARGUMENT_INVALID"; return false; } diagnostic = true; continue; } if (i + 1 >= args.Length || !args[i].StartsWith("--") || !d.TryAdd(args[i], args[++i])) { error = "AUTOMATION_ARGUMENT_INVALID"; return false; } }
|
||||
string? V(string x) => d.GetValueOrDefault(x);
|
||||
if (d.Keys.Except(new[] { "--package-source", "--entry-assembly", "--pilot-root", "--fixture-marker", "--ortho-dwg", "--model-dwg", "--evidence-root" }).Any() || new[] { V("--package-source"), V("--entry-assembly"), V("--pilot-root"), V("--fixture-marker"), V("--ortho-dwg"), V("--model-dwg"), V("--evidence-root") }.Any(string.IsNullOrWhiteSpace)) { error = "AUTOMATION_ARGUMENT_INVALID"; return false; }
|
||||
var paths = new[] { V("--package-source")!, V("--pilot-root")!, V("--fixture-marker")!, V("--ortho-dwg")!, V("--model-dwg")!, V("--evidence-root")! }; if (paths.Any(x => !Path.IsPathFullyQualified(x) || x.StartsWith("\\\\"))) { error = "AUTOMATION_PATH_INVALID"; return false; }
|
||||
options = new(V("--package-source")!, V("--entry-assembly")!, V("--pilot-root")!, V("--fixture-marker")!, V("--ortho-dwg")!, V("--model-dwg")!, V("--evidence-root")!); return true;
|
||||
if (d.Keys.Except(new[] { "--package-source", "--entry-assembly", "--pilot-root", "--fixture-marker", "--ortho-dwg", "--model-dwg", "--annotation-template-dwg", "--evidence-root" }).Any() || new[] { V("--package-source"), V("--entry-assembly"), V("--pilot-root"), V("--fixture-marker"), V("--ortho-dwg"), V("--model-dwg"), V("--annotation-template-dwg"), V("--evidence-root") }.Any(string.IsNullOrWhiteSpace)) { error = "AUTOMATION_ARGUMENT_INVALID"; return false; }
|
||||
var paths = new[] { V("--package-source")!, V("--pilot-root")!, V("--fixture-marker")!, V("--ortho-dwg")!, V("--model-dwg")!, V("--annotation-template-dwg")!, V("--evidence-root")! }; if (paths.Any(x => !Path.IsPathFullyQualified(x) || x.StartsWith("\\\\"))) { error = "AUTOMATION_PATH_INVALID"; return false; }
|
||||
if (!File.Exists(V("--annotation-template-dwg"))) { error = "ANNOTATION_TEMPLATE_ASSET_MISSING"; return false; }
|
||||
options = new(V("--package-source")!, V("--entry-assembly")!, V("--pilot-root")!, V("--fixture-marker")!, V("--ortho-dwg")!, V("--model-dwg")!, V("--annotation-template-dwg")!, V("--evidence-root")!, diagnostic); return true;
|
||||
}
|
||||
private static Dictionary<string,string> ContentHashes(string marker) { var root = Path.GetDirectoryName(marker)!; return Directory.GetFiles(root, "*", SearchOption.AllDirectories).Where(x => x.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase) || x.EndsWith(".dcf", StringComparison.OrdinalIgnoreCase) || Path.GetFileName(x).Equals("Project.xml", StringComparison.OrdinalIgnoreCase)).OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToDictionary(x => Path.GetRelativePath(root,x), x => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(x))), StringComparer.OrdinalIgnoreCase); }
|
||||
private static async Task<Dictionary<string,string>> ContentHashesAsync(string marker, AutomationJournal journal, CancellationToken cancellationToken)
|
||||
{
|
||||
var root = Path.GetDirectoryName(marker)!;
|
||||
var files = new[] { Path.Combine(root, "Piping.dcf"), Path.Combine(root, "Project.xml") }
|
||||
.Concat(Directory.EnumerateFiles(Path.Combine(root, "Orthos", "DWGs"), "*.dwg", SearchOption.TopDirectoryOnly))
|
||||
.Concat(Directory.EnumerateFiles(Path.Combine(root, "Plant 3D Models"), "*.dwg", SearchOption.TopDirectoryOnly))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
var result = new Dictionary<string,string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var path in files) { if (!File.Exists(path)) throw new InvalidOperationException("HASH_FILE_NOT_FOUND"); await journal.RecordAsync("HashFileStarted", "Started", path, cancellationToken); await using var stream = File.OpenRead(path); result[Path.GetRelativePath(root,path)] = Convert.ToHexString(await SHA256.HashDataAsync(stream,cancellationToken)); await journal.RecordAsync("HashFileCompleted", "Completed", path, cancellationToken); }
|
||||
return result;
|
||||
}
|
||||
private sealed class AutomationJournal
|
||||
{
|
||||
private readonly string path; private readonly string root; private readonly DateTimeOffset started=DateTimeOffset.UtcNow; private int sequence;
|
||||
public string SessionId { get; }
|
||||
private AutomationJournal(string root, string sessionId) { this.root=root; SessionId=sessionId; path=Path.Combine(root,"state","automation-sessions",sessionId,"session-events.jsonl"); }
|
||||
public static async Task<AutomationJournal> AllocateAsync(string root,string mode,CancellationToken ct) { var id=Guid.NewGuid().ToString("N"); var dir=Path.Combine(root,"state","automation-sessions",id); Directory.CreateDirectory(dir); var j=new AutomationJournal(root,id); await j.RecordAsync("SessionAllocated","Completed",null,ct); var pointer=Path.Combine(root,"state","automation-current.json"); await WriteAtomicAsync(pointer,new { sessionId=id,launchMode=mode,state="SessionAllocated",terminal=false,currentStage="InitialEvidenceFlushed",createdUtc=DateTimeOffset.UtcNow },ct); await WriteAtomicAsync(Path.Combine(root,"state","pilot-session.json"),new { sessionId=id,state="InitialEvidenceFlushed",launchMode=mode,foundationUiCreated=false },ct); await j.RecordAsync("InitialEvidenceFlushed","Completed",null,ct); return j; }
|
||||
public Task RecordAsync(string stage,string status,string? currentPath,CancellationToken ct) => RecordProgressAsync(stage,status,null,currentPath,0,0,0,0,false,ct);
|
||||
public Task RecordProgressAsync(string stage,string status,string? role,string? currentPath,int processed,int total,long processedBytes,long totalBytes,bool cancellationRequested,CancellationToken ct) => File.AppendAllTextAsync(path,JsonSerializer.Serialize(new { sequence=Interlocked.Increment(ref sequence),timestampUtc=DateTimeOffset.UtcNow,elapsedMs=(long)(DateTimeOffset.UtcNow-started).TotalMilliseconds,sessionId=SessionId,launcherPid=Environment.ProcessId,currentStage=stage,currentOperation=status,currentRole=role,currentPath,processedFiles=processed,totalFiles=total,processedBytes,totalBytes,lastProgressUtc=DateTimeOffset.UtcNow,cancellationRequested,foundationUiCreated=false,managedThreadId=Environment.CurrentManagedThreadId })+Environment.NewLine,ct);
|
||||
public Task RecordFailureAsync(string stage,string? role,string? currentPath,Exception ex,CancellationToken ct) => File.AppendAllTextAsync(path,JsonSerializer.Serialize(new { sequence=Interlocked.Increment(ref sequence),timestampUtc=DateTimeOffset.UtcNow,sessionId=SessionId,stage,status="Failed",role,path=currentPath,exceptionType=ex.GetType().FullName,exception=ex.ToString(),cancellationRequested=true })+Environment.NewLine,ct);
|
||||
public async Task<int> TerminalAsync(string code,string state,string? currentPath,CancellationToken ct) { await RecordProgressAsync("TerminalEvidence",state,null,currentPath,0,0,0,0,state!="Succeeded",ct); await WriteAtomicAsync(Path.Combine(root,"state","pilot-session.json"),new { sessionId=SessionId,state,terminal=true,terminalCode=code,foundationUiCreated=false,autocadStarted=false,launcherPid=Environment.ProcessId,completedUtc=DateTimeOffset.UtcNow },ct); Console.WriteLine($"PreflightSession={SessionId}; Terminal={state}; Code={code}; AutoCADStarted=false; FoundationUICreated=false"); return state == "Succeeded" ? Success : ProcessFailed; }
|
||||
}
|
||||
private static async Task WriteAtomicAsync<T>(string path, T value, CancellationToken ct)
|
||||
{ Directory.CreateDirectory(Path.GetDirectoryName(path)!); var temp=path+"."+Guid.NewGuid().ToString("N")+".tmp"; await File.WriteAllTextAsync(temp,JsonSerializer.Serialize(value),ct); File.Move(temp,path,true); }
|
||||
private static bool HashesEqual(IReadOnlyDictionary<string,string> x, IReadOnlyDictionary<string,string> y) => x.Count == y.Count && x.All(p => y.TryGetValue(p.Key, out var value) && value == p.Value);
|
||||
private static PilotExecutionService CreateService() => new(new PackageManifestValidator(), new PilotRootPolicy(@"C:\AutoCAD User Support"), new NextPilotSafetyGate(), new Sha256FileHasher(), new StartupScriptBuilder(), PermitService, new Probe(), new OperatingSystemProcessInvoker());
|
||||
private static PilotExecutionService CreateService(ResolvedLauncherPaths paths) => new(new PackageManifestValidator(), new PilotRootPolicy(paths.LocalLauncherRoot), new NextPilotSafetyGate(), new Sha256FileHasher(), new StartupScriptBuilder(), PermitService, new Probe(), new OperatingSystemProcessInvoker(), new PathResolver());
|
||||
private static async Task WaitForExitAsync(int processId) { using var process = Process.GetProcessById(processId); await process.WaitForExitAsync(); }
|
||||
private static bool HasCompletedEvidence(string path)
|
||||
{
|
||||
@@ -175,7 +296,8 @@ internal static class PilotCli
|
||||
}
|
||||
private static async Task<int> RollbackAsync(string root)
|
||||
{
|
||||
var result = await CreateService().RollbackAsync(new(root, "P_Rec_Annot", null, string.Empty, null), CancellationToken.None);
|
||||
if (!TryReadConfiguration(root, out var config, out _) || !TryResolvePaths(config, out var paths, out _)) return Fail(ExplicitRollbackFailed, "PATH_CONFIGURATION_INVALID");
|
||||
var result = await CreateService(paths).RollbackAsync(new(root, "P_Rec_Annot", null, string.Empty, null), CancellationToken.None);
|
||||
return result.Completed ? Success : Fail(ExplicitRollbackFailed, result.ErrorCode ?? "ROLLBACK_FAILED");
|
||||
}
|
||||
private static async Task<int> StatusAsync(string root)
|
||||
@@ -207,12 +329,40 @@ internal static class PilotCli
|
||||
private static string SessionPath(string root) => Path.Combine(root, "state", "pilot-session.json");
|
||||
private static bool TryReadConfiguration(string root, out PilotLocalConfiguration config, out string? error)
|
||||
{
|
||||
config = PilotLocalConfiguration.SafeDefault; error = null; var path = Path.Combine(root, "config", "pilot.local.json");
|
||||
if (!File.Exists(path)) { error = "PILOT_CONFIGURATION_MISSING"; return false; }
|
||||
var parsed = PilotConfigurationParser.Parse(File.ReadAllText(path)); config = parsed.Configuration;
|
||||
config = PilotLocalConfiguration.SafeDefault; error = null; var directory = Path.Combine(root, "config"); var defaults = Path.Combine(directory, "launcher.defaults.json"); var machine = Path.Combine(directory, "launcher.machine.json"); var session = Path.Combine(directory, "launcher.session.json");
|
||||
if (!File.Exists(machine)) { error = "PILOT_CONFIGURATION_MISSING"; return false; }
|
||||
var parsed = LauncherConfigurationLayers.LoadFromFiles(defaults, machine, session); config = parsed.Configuration;
|
||||
if (!parsed.IsValid) { error = "PILOT_CONFIGURATION_INVALID"; return false; }
|
||||
return true;
|
||||
}
|
||||
private static bool TryResolvePaths(PilotLocalConfiguration configuration, out ResolvedLauncherPaths paths, out string? error)
|
||||
{
|
||||
try
|
||||
{
|
||||
var resolver = new PathResolver(); var variables = new LauncherPathVariables(configuration.PathVariables); var raw = resolver.Resolve(variables, [LauncherPathVariableNames.LocalLauncherRoot, LauncherPathVariableNames.AcadExecutable]);
|
||||
var resolved = new Dictionary<string, string?>(raw.Values, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var name in new[] { LauncherPathVariableNames.LocalLauncherRoot, LauncherPathVariableNames.LocalPackageCache, LauncherPathVariableNames.LocalActiveRoot, LauncherPathVariableNames.LocalSessionRoot, LauncherPathVariableNames.LocalEvidenceRoot, LauncherPathVariableNames.LocalLogsRoot, LauncherPathVariableNames.LocalRollbackRoot, LauncherPathVariableNames.AcadExecutable, LauncherPathVariableNames.PRecEntryAssembly, LauncherPathVariableNames.FixtureOrthoDwg, LauncherPathVariableNames.FixtureModelDwg, LauncherPathVariableNames.AutomationScriptPath, LauncherPathVariableNames.AutomationConfigPath, LauncherPathVariableNames.AutomationEvidencePath, LauncherPathVariableNames.AutomationPermitPath, LauncherPathVariableNames.AutomationTranscriptPath })
|
||||
if (raw.Get(name) is { Length: > 0 } value) resolved[name] = resolver.ResolvePath(value, variables);
|
||||
paths = new(resolved); error = null; return true;
|
||||
}
|
||||
catch (Exception ex) { paths = null!; error = ex.Message; return false; }
|
||||
}
|
||||
private static PilotLocalConfiguration WithAutomationPaths(PilotLocalConfiguration config, AutomationOptions options, string configPath, string evidencePath, string? permitPath, string phase)
|
||||
{
|
||||
var variables = new Dictionary<string, string?>(config.PathVariables, StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[LauncherPathVariableNames.LocalSessionRoot] = options.EvidenceRoot,
|
||||
[LauncherPathVariableNames.AutomationScriptPath] = Path.Combine(options.EvidenceRoot, phase + ".scr"),
|
||||
[LauncherPathVariableNames.AutomationConfigPath] = configPath,
|
||||
[LauncherPathVariableNames.AutomationEvidencePath] = evidencePath,
|
||||
[LauncherPathVariableNames.AutomationPermitPath] = permitPath,
|
||||
[LauncherPathVariableNames.AutomationTranscriptPath] = Path.Combine(options.EvidenceRoot, phase + "-transcript.log"),
|
||||
[LauncherPathVariableNames.FixtureOrthoDwg] = options.OrthoDrawing,
|
||||
[LauncherPathVariableNames.FixtureModelDwg] = options.ModelDrawing,
|
||||
[LauncherPathVariableNames.PRecEntryAssembly] = Path.Combine(options.PackageSource, options.EntryAssembly)
|
||||
};
|
||||
return config with { PathVariables = variables };
|
||||
}
|
||||
private static bool TryBuildManifest(string source, string entry, out PackageManifest? manifest, out string? hash, out string? error)
|
||||
{
|
||||
manifest = null; hash = null; error = null;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Launcher.Next.Application;
|
||||
|
||||
public static class LegacyCompatibilityOverlayParser
|
||||
{
|
||||
private sealed record Package(string Id,string Product,int ProductYear,string Architecture,string Channel,string Version);
|
||||
private sealed record Entry(string MappingId,string Pattern,string LegacyPluginId,string? LegacyCatalog,string? LegacyInstall,Package Package);
|
||||
private sealed record Document(int SchemaVersion,IReadOnlyList<Entry> Mappings);
|
||||
private static readonly JsonSerializerOptions Options=new(){PropertyNameCaseInsensitive=true,UnmappedMemberHandling=JsonUnmappedMemberHandling.Disallow};
|
||||
public static LegacyCompatibilityOverlay Parse(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
var document=JsonSerializer.Deserialize<Document>(json,Options)??throw new InvalidOperationException("LEGACY_OVERLAY_MALFORMED");
|
||||
if(document.SchemaVersion!=1) throw new InvalidOperationException("LEGACY_MAPPING_SCHEMA_UNSUPPORTED");
|
||||
if(document.Mappings.Count==0||document.Mappings.GroupBy(x=>x.MappingId,StringComparer.OrdinalIgnoreCase).Any(x=>x.Count()>1)) throw new InvalidOperationException("LEGACY_MAPPING_DUPLICATE");
|
||||
var entries=document.Mappings.Select(x=>{
|
||||
if(string.IsNullOrWhiteSpace(x.MappingId)||string.IsNullOrWhiteSpace(x.LegacyPluginId)||string.IsNullOrWhiteSpace(x.Package.Id)||x.Package.Version is "latest"||x.Package.Version.Contains('*')||x.Package.Version.Contains('>')||x.Package.Version.Contains('<')||!System.Text.RegularExpressions.Regex.IsMatch(x.Package.Version,@"^\d+\.\d+\.\d+(\.\d+)?$")) throw new InvalidOperationException("LEGACY_OVERLAY_MALFORMED");
|
||||
if(!string.Equals(x.Package.Product,"Plant3D",StringComparison.Ordinal)||x.Package.ProductYear!=2026||!string.Equals(x.Package.Architecture,"x64",StringComparison.OrdinalIgnoreCase)||!new[]{"pilot","stable","development"}.Contains(x.Package.Channel,StringComparer.OrdinalIgnoreCase)) throw new InvalidOperationException("LEGACY_MAPPING_INCOMPATIBLE");
|
||||
return new LegacyCompatibilityMapping(x.LegacyPluginId,x.Package.Id,x.Package.Version,x.Package.Channel);
|
||||
}).ToArray();
|
||||
var first=document.Mappings[0].Package;
|
||||
return new("legacy-overlay-v1",first.Product,first.ProductYear,first.Architecture,false,entries);
|
||||
}
|
||||
catch(JsonException){throw new InvalidOperationException("LEGACY_OVERLAY_MALFORMED");}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Xml;
|
||||
using System.Xml.Linq;
|
||||
using Launcher.Next.Core;
|
||||
|
||||
namespace Launcher.Next.Application;
|
||||
|
||||
public sealed record LegacyPluginDefinition(string Id, string Renewal, string Catalog, string Install, int DefinitionOrder);
|
||||
public sealed record LegacyProductPattern(string Name, string Filter, string Roster, IReadOnlyList<LegacyPluginDefinition> Plugins);
|
||||
public sealed record LegacyLauncherSettings(string Help, string CareMarker, IReadOnlyList<LegacyProductPattern> Patterns);
|
||||
public sealed record LegacyDownloadSelection(IReadOnlyList<string> PluginIds);
|
||||
public sealed record LegacyCompatibilityMapping(string LegacyId, string PackageId, string Version, string Channel);
|
||||
public sealed record LegacyCompatibilityOverlay(string MappingId, string Product, int ProductYear, string Architecture, bool AllowOfflineFallback, IReadOnlyList<LegacyCompatibilityMapping> Plugins);
|
||||
|
||||
public sealed class LegacyConfigurationAdapter
|
||||
{
|
||||
private static readonly XmlReaderSettings XmlSettings = new() { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null, MaxCharactersInDocument = 1_000_000 };
|
||||
|
||||
public LegacyLauncherSettings ParseSettings(string xml)
|
||||
{
|
||||
using var reader = XmlReader.Create(new StringReader(xml), XmlSettings); var root = XDocument.Load(reader).Root ?? throw new InvalidOperationException("LEGACY_SETTINGS_INVALID");
|
||||
var path = root.Element("path") ?? throw new InvalidOperationException("LEGACY_SETTINGS_PATH_MISSING");
|
||||
var patterns = root.Elements("data").Select(data =>
|
||||
{
|
||||
var pattern = data.Element("pattern") ?? throw new InvalidOperationException("LEGACY_PATTERN_MISSING");
|
||||
string Value(string name) => pattern.Element(name)?.Value ?? throw new InvalidOperationException("LEGACY_PATTERN_FIELD_MISSING:" + name);
|
||||
var plugins = data.Elements("plugin").Select((plugin, index) => new LegacyPluginDefinition(
|
||||
plugin.Element("id")?.Value ?? throw new InvalidOperationException("LEGACY_PLUGIN_ID_MISSING"),
|
||||
plugin.Element("renewal")?.Value ?? throw new InvalidOperationException("LEGACY_RENEWAL_MISSING"),
|
||||
plugin.Element("catalog")?.Value ?? throw new InvalidOperationException("LEGACY_CATALOG_MISSING"),
|
||||
plugin.Element("install")?.Value ?? throw new InvalidOperationException("LEGACY_INSTALL_MISSING"), index)).ToArray();
|
||||
return new LegacyProductPattern(Value("name"), Value("filter"), Value("roster"), plugins);
|
||||
}).ToArray();
|
||||
return new(path.Element("help")?.Value ?? throw new InvalidOperationException("LEGACY_HELP_MISSING"), path.Element("care")?.Value ?? throw new InvalidOperationException("LEGACY_CARE_MISSING"), patterns);
|
||||
}
|
||||
|
||||
public LegacyDownloadSelection ParseDownloadList(string xml)
|
||||
{
|
||||
using var reader = XmlReader.Create(new StringReader(xml), XmlSettings); var root = XDocument.Load(reader).Root;
|
||||
if (root?.Name != "root") throw new InvalidOperationException("LEGACY_DOWNLOAD_LIST_INVALID");
|
||||
var ids = root.Elements("id").Select(x => x.Value.Trim()).ToArray();
|
||||
if (ids.Length == 0 || ids.Any(string.IsNullOrWhiteSpace) || ids.GroupBy(x => x, StringComparer.OrdinalIgnoreCase).Any(x => x.Count() > 1))
|
||||
throw new InvalidOperationException("LEGACY_DOWNLOAD_SELECTION_INVALID");
|
||||
return new(ids);
|
||||
}
|
||||
|
||||
public IReadOnlyList<PackageRequirement> Join(LegacyProductPattern pattern, LegacyDownloadSelection selection, LegacyCompatibilityOverlay overlay)
|
||||
{
|
||||
var result = new List<PackageRequirement>();
|
||||
foreach (var id in selection.PluginIds)
|
||||
{
|
||||
var definition = pattern.Plugins.Where(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase)).ToArray();
|
||||
if (definition.Length == 0) throw new InvalidOperationException("LEGACY_PLUGIN_ID_NOT_DEFINED");
|
||||
if (definition.Length > 1) throw new InvalidOperationException("AMBIGUOUS_LEGACY_PLUGIN_DEFINITION");
|
||||
var mapping = overlay.Plugins.Where(x => string.Equals(x.LegacyId, id, StringComparison.OrdinalIgnoreCase)).ToArray();
|
||||
if (mapping.Length == 0) throw new InvalidOperationException("LEGACY_MAPPING_NOT_FOUND");
|
||||
if (mapping.Length > 1) throw new InvalidOperationException("LEGACY_MAPPING_DUPLICATE");
|
||||
result.Add(new(mapping[0].PackageId, overlay.Product, overlay.ProductYear, overlay.Architecture, mapping[0].Channel, mapping[0].Version, overlay.MappingId, overlay.AllowOfflineFallback));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Launcher.Next.Core;
|
||||
|
||||
namespace Launcher.Next.Application;
|
||||
|
||||
public sealed record LegacyConfigurationProbeRequest(ResolvedLegacySettingsPath Settings, ResolvedLegacyDownloadListPath DownloadList, ResolvedLegacyCompatibilityOverlayPath Overlay, ResolvedLegacyProbeEvidenceRoot EvidenceRoot, string PatternName, string OperationId);
|
||||
public sealed record LegacyConfigurationProbeResult(string TerminalState, IReadOnlyList<PackageRequirement> Requirements, string EvidencePath, IReadOnlyList<string> Diagnostics);
|
||||
|
||||
/// <summary>Read-only configuration inspection. It intentionally has no package,
|
||||
/// activation, process, or Autodesk dependency.</summary>
|
||||
public sealed class LegacyConfigurationProbe(LegacyConfigurationAdapter adapter)
|
||||
{
|
||||
public async Task<LegacyConfigurationProbeResult> RunAsync(LegacyConfigurationProbeRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var settingsPath = request.Settings.Value;
|
||||
var selectionPath = request.DownloadList.Value;
|
||||
var overlayPath = request.Overlay.Value;
|
||||
var evidenceRoot = request.EvidenceRoot.Value;
|
||||
if (!File.Exists(settingsPath) || !File.Exists(selectionPath) || !File.Exists(overlayPath)) throw new InvalidOperationException("LEGACY_PROBE_INPUT_PATH_INVALID");
|
||||
if (evidenceRoot.StartsWith(@"\\", StringComparison.Ordinal) || SameOrNested(evidenceRoot, Path.GetDirectoryName(settingsPath)!) || SameOrNested(evidenceRoot, Path.GetDirectoryName(selectionPath)!) || SameOrNested(evidenceRoot, Path.GetDirectoryName(overlayPath)!)) throw new InvalidOperationException("LEGACY_PROBE_PATH_COLLISION");
|
||||
Directory.CreateDirectory(evidenceRoot);
|
||||
var before = await Snapshot(settingsPath, selectionPath, overlayPath, cancellationToken);
|
||||
try
|
||||
{
|
||||
var settings = adapter.ParseSettings(await ReadOnlyTextAsync(settingsPath, cancellationToken));
|
||||
var pattern = settings.Patterns.Where(x => string.Equals(x.Name, request.PatternName, StringComparison.Ordinal)).ToArray();
|
||||
if (pattern.Length != 1) throw new InvalidOperationException(pattern.Length == 0 ? "LEGACY_PATTERN_NOT_FOUND" : "LEGACY_PATTERN_AMBIGUOUS");
|
||||
var overlay = LegacyCompatibilityOverlayParser.Parse(await ReadOnlyTextAsync(overlayPath, cancellationToken));
|
||||
var requirements = adapter.Join(pattern[0], adapter.ParseDownloadList(await ReadOnlyTextAsync(selectionPath, cancellationToken)), overlay);
|
||||
var after = await Snapshot(settingsPath, selectionPath, overlayPath, cancellationToken);
|
||||
if (!before.SequenceEqual(after)) throw new InvalidOperationException("LEGACY_PROBE_SOURCE_CHANGED_DURING_READ");
|
||||
var output = await WriteEvidenceAsync(evidenceRoot, request.OperationId, new { evidenceSchemaVersion=1, evidenceKind="legacy-configuration-probe", mode="LegacyConfigurationProbe", operationId=request.OperationId, terminalState="Succeeded", exitClassification="Success", selectedPattern=pattern[0].Name, selectedIdCount=requirements.Count, requirements, inputs=before.Select(x=>Redact(x,request.OperationId)), sideEffects=SideEffects() }, cancellationToken);
|
||||
return new("Succeeded",requirements,output,[]);
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
var code=ex.Message.StartsWith("LEGACY_",StringComparison.Ordinal)||ex.Message.StartsWith("AMBIGUOUS_",StringComparison.Ordinal)?ex.Message:"LEGACY_PROBE_INTERNAL_FAILURE";
|
||||
var output=await WriteEvidenceAsync(evidenceRoot,request.OperationId,new { evidenceSchemaVersion=1,evidenceKind="legacy-configuration-probe",mode="LegacyConfigurationProbe",operationId=request.OperationId,terminalState="Blocked",exitClassification=code,diagnostics=new[]{code},inputs=before.Select(x=>Redact(x,request.OperationId)),sideEffects=SideEffects() },cancellationToken);
|
||||
return new("Blocked",[],output,[code]);
|
||||
}
|
||||
}
|
||||
private static object SideEffects()=>new { sourceWrites=false,payloadCopied=false,stagingCreated=false,packageCommitted=false,activationAttempted=false,pointerMutationAttempted=false,autoCadLaunchAttempted=false,dfsWriteAttempted=false };
|
||||
private static bool SameOrNested(string child,string parent) { var c=Path.GetFullPath(child).TrimEnd(Path.DirectorySeparatorChar)+Path.DirectorySeparatorChar; var p=Path.GetFullPath(parent).TrimEnd(Path.DirectorySeparatorChar)+Path.DirectorySeparatorChar; return c.StartsWith(p,StringComparison.OrdinalIgnoreCase); }
|
||||
private static async Task<string[]> Snapshot(string a,string b,string c,CancellationToken ct) => [await One("LegacySettingsPath",a,ct),await One("LegacyDownloadListPath",b,ct),await One("LegacyCompatibilityOverlayPath",c,ct)];
|
||||
private static async Task<string> One(string role,string path,CancellationToken ct) { var info=new FileInfo(path); if(info.Attributes.HasFlag(FileAttributes.ReparsePoint)) throw new InvalidOperationException("LEGACY_PROBE_REPARSE_SOURCE_REJECTED"); await using var stream=new FileStream(path,FileMode.Open,FileAccess.Read,FileShare.Read); return string.Join('|',role,Path.GetFileName(path),info.Length,info.LastWriteTimeUtc.Ticks,Convert.ToHexString(await SHA256.HashDataAsync(stream,ct)),path); }
|
||||
private static async Task<string> ReadOnlyTextAsync(string path,CancellationToken ct) { await using var stream=new FileStream(path,FileMode.Open,FileAccess.Read,FileShare.Read); using var reader=new StreamReader(stream,Encoding.UTF8,true); return await reader.ReadToEndAsync(ct); }
|
||||
private static object Redact(string value,string operationId) { var parts=value.Split('|'); return new { role=parts[0],basename=parts[1],length=parts[2],lastWriteUtcTicks=parts[3],sha256=parts[4],pathFingerprint=Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(operationId+"|"+parts[5]))) }; }
|
||||
private static async Task<string> WriteEvidenceAsync(string root,string operationId,object evidence,CancellationToken ct) { var operation=Path.Combine(root,operationId); Directory.CreateDirectory(operation); var final=Path.Combine(operation,"legacy-configuration-probe.json"); var temp=final+".tmp"; await File.WriteAllTextAsync(temp,JsonSerializer.Serialize(evidence),ct); File.Move(temp,final,true); return final; }
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Launcher.Next.Application;
|
||||
|
||||
public sealed record LegacyProbeOverride(
|
||||
int SchemaVersion,
|
||||
string Mode,
|
||||
string SettingsXml,
|
||||
string DownloadListXml,
|
||||
string CompatibilityOverlay,
|
||||
string SelectedPattern,
|
||||
string EvidenceOutputRoot);
|
||||
|
||||
public static class LegacyProbeOverrideParser
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow
|
||||
};
|
||||
|
||||
public static LegacyProbeOverride Parse(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
var value = JsonSerializer.Deserialize<LegacyProbeOverride>(json, Options)
|
||||
?? throw new InvalidOperationException("LEGACY_PROBE_OVERRIDE_MISSING");
|
||||
if (value.SchemaVersion != 1) throw new InvalidOperationException("LEGACY_PROBE_OVERRIDE_SCHEMA_UNSUPPORTED");
|
||||
if (!string.Equals(value.Mode, "LegacyConfigurationProbe", StringComparison.Ordinal)) throw new InvalidOperationException("LEGACY_PROBE_MODE_REQUIRED");
|
||||
if (new[] { value.SettingsXml, value.DownloadListXml, value.CompatibilityOverlay, value.SelectedPattern, value.EvidenceOutputRoot }.Any(string.IsNullOrWhiteSpace))
|
||||
throw new InvalidOperationException("LEGACY_PROBE_INPUT_MISSING");
|
||||
if (new[] { value.SettingsXml, value.DownloadListXml, value.CompatibilityOverlay, value.EvidenceOutputRoot }.Any(x => Uri.TryCreate(x, UriKind.Absolute, out var uri) && !uri.IsFile || x.Contains('*') || x.Contains('?')))
|
||||
throw new InvalidOperationException("LEGACY_PROBE_INPUT_PATH_INVALID");
|
||||
return value;
|
||||
}
|
||||
catch (JsonException) { throw new InvalidOperationException("LEGACY_PROBE_OVERRIDE_UNKNOWN_FIELD"); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using Launcher.Next.Core;
|
||||
|
||||
namespace Launcher.Next.Application;
|
||||
|
||||
public sealed class PackageResolutionUseCase
|
||||
{
|
||||
private readonly IPackageCatalogPackageAcquirer catalog; private readonly IPackageSynchronizer synchronizer; private readonly IPackageRequirementCacheBinder binder; private readonly ILastKnownGoodPackageResolver cache; private readonly ILauncherTelemetry telemetry; private readonly IPackageMaterializer? materializer;
|
||||
public PackageResolutionUseCase(IPackageCatalogPackageAcquirer c, IPackageSynchronizer s, IPackageRequirementCacheBinder b, ILastKnownGoodPackageResolver l, ILauncherTelemetry t) { catalog=c; synchronizer=s; binder=b; cache=l; telemetry=t; }
|
||||
public PackageResolutionUseCase(IPackageCatalogPackageAcquirer c, IPackageSynchronizer s, IPackageRequirementCacheBinder b, ILastKnownGoodPackageResolver l, IPackageMaterializer m, ILauncherTelemetry t) : this(c,s,b,l,t) { materializer=m; }
|
||||
public async Task<PackageResolutionResult> ResolveAsync(PackageRequirement requirement, CancellationToken cancellationToken)
|
||||
{
|
||||
var evidence = new List<PackageResolutionEvidence> { new("PACKAGE_REQUIREMENT_RECEIVED", requirement.PackageId) };
|
||||
telemetry.Write("PackageResolutionStarted", new Dictionary<string, object?> { ["packageId"] = requirement.PackageId, ["product"] = requirement.Product, ["channel"] = requirement.Channel, ["offlineAllowed"] = requirement.AllowOfflineFallback });
|
||||
try
|
||||
{
|
||||
var online = await catalog.AcquireAsync(requirement, cancellationToken);
|
||||
if (online is not null && Matches(requirement, online.Manifest))
|
||||
{
|
||||
if (materializer is not null)
|
||||
{
|
||||
var committed = await materializer.MaterializeAsync(online.Manifest, online.SourceRoot, cancellationToken);
|
||||
await binder.BindAsync(requirement, committed.Version, cancellationToken);
|
||||
evidence.Add(new("ONLINE_PACKAGE_MATERIALIZED_NOT_ACTIVATED", committed.Version));
|
||||
return new(new CachedPackageResolution(committed.PackageId, committed.Version, committed.LocalPackageRoot, false, "MATERIALIZED_NOT_ACTIVATED"), true, evidence);
|
||||
}
|
||||
await synchronizer.SynchronizeAsync(online.Manifest, online.SourceRoot, cancellationToken);
|
||||
await binder.BindAsync(requirement, online.Manifest.Version, cancellationToken);
|
||||
var selected = await cache.ResolveAsync(requirement, cancellationToken);
|
||||
if (selected is not null && !selected.IsOfflineFallback)
|
||||
{
|
||||
evidence.Add(new("ONLINE_PACKAGE_SELECTED", online.Manifest.Version));
|
||||
telemetry.Write("PackageResolutionSelected", new Dictionary<string, object?> { ["code"] = "ONLINE_PACKAGE_SELECTED", ["packageId"] = selected.PackageId, ["version"] = selected.Version });
|
||||
return new(selected, true, evidence);
|
||||
}
|
||||
evidence.Add(new("ONLINE_PACKAGE_REJECTED", "LOCAL_REVALIDATION_FAILED"));
|
||||
}
|
||||
else evidence.Add(new("CATALOG_UNAVAILABLE", online is null ? "NO_CATALOG_PACKAGE" : "REQUIREMENT_MISMATCH"));
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException) { evidence.Add(new("CATALOG_UNAVAILABLE", "ACQUISITION_OR_STAGING_FAILED")); }
|
||||
if (!requirement.AllowOfflineFallback)
|
||||
{
|
||||
evidence.Add(new("OFFLINE_FALLBACK_DISABLED", "POLICY"));
|
||||
evidence.Add(new("NO_SAFE_PACKAGE_AVAILABLE", "BLOCKED"));
|
||||
telemetry.Write("PackageResolutionBlocked", new Dictionary<string, object?> { ["code"] = "OFFLINE_FALLBACK_DISABLED", ["packageId"] = requirement.PackageId });
|
||||
return new(null, false, evidence);
|
||||
}
|
||||
var cached = await cache.ResolveAsync(requirement, cancellationToken);
|
||||
if (cached is null)
|
||||
{
|
||||
evidence.Add(new("NO_SAFE_PACKAGE_AVAILABLE", "CACHE_REJECTED"));
|
||||
telemetry.Write("PackageResolutionBlocked", new Dictionary<string, object?> { ["code"] = "NO_SAFE_PACKAGE_AVAILABLE", ["packageId"] = requirement.PackageId });
|
||||
return new(null, false, evidence);
|
||||
}
|
||||
var code = cached.IsOfflineFallback ? "PREVIOUS_LKG_SELECTED" : "VALID_CURRENT_CACHE_SELECTED";
|
||||
evidence.Add(new(code, cached.Version));
|
||||
telemetry.Write("PackageResolutionSelected", new Dictionary<string, object?> { ["code"] = code, ["packageId"] = cached.PackageId, ["version"] = cached.Version });
|
||||
return new(cached, false, evidence);
|
||||
}
|
||||
private static bool Matches(PackageRequirement r, PackageManifest m) => string.Equals(r.PackageId, m.PackageId, StringComparison.Ordinal) && string.Equals(r.Product, m.Product, StringComparison.Ordinal) && r.ProductYear == m.ProductYear && string.Equals(r.Architecture, m.Architecture, StringComparison.OrdinalIgnoreCase) && string.Equals(r.Channel, m.RolloutChannel, StringComparison.OrdinalIgnoreCase) && string.Equals(r.RequiredVersion, m.Version, StringComparison.Ordinal);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.Xml;
|
||||
using System.Xml.Linq;
|
||||
using System.Text.Json;
|
||||
using Launcher.Next.Core;
|
||||
|
||||
namespace Launcher.Next.Application;
|
||||
|
||||
public enum RosterSource { ProjectRosterJson, LegacyDownloadListXml, CentralDefaultRoster, None }
|
||||
public sealed record RosterPackage(string Id, string Version, string Channel, bool Enabled, int LoadOrder, bool AllowedForMachine = true);
|
||||
public sealed record RosterContext(string Product, int ProductYear, string Architecture, bool AllowOfflineFallback, string LegacyRosterId);
|
||||
public sealed record NormalizedRoster(string RosterId, string Product, int Year, string Architecture, bool AllowOfflineFallback, IReadOnlyList<RosterPackage> Packages);
|
||||
public sealed record RosterSelection(RosterSource Source, NormalizedRoster? Roster, string Code);
|
||||
public sealed class RosterAdapter
|
||||
{
|
||||
public NormalizedRoster ParseJson(string json)
|
||||
{
|
||||
using var doc=JsonDocument.Parse(json); var r=doc.RootElement;
|
||||
if (!r.TryGetProperty("schemaVersion",out var s)||s.GetInt32()!=1) throw new InvalidOperationException("ROSTER_SCHEMA_INVALID");
|
||||
string V(string n)=>r.TryGetProperty(n,out var x)&&!string.IsNullOrWhiteSpace(x.GetString())?x.GetString()!:throw new InvalidOperationException("ROSTER_FIELD_REQUIRED:"+n);
|
||||
var packages=r.GetProperty("packages").EnumerateArray().Select(x=>new RosterPackage(x.GetProperty("id").GetString()??throw new InvalidOperationException("ROSTER_PACKAGE_ID_REQUIRED"),x.GetProperty("version").GetString()??throw new InvalidOperationException("ROSTER_PACKAGE_VERSION_REQUIRED"),x.GetProperty("channel").GetString()??throw new InvalidOperationException("ROSTER_PACKAGE_CHANNEL_REQUIRED"),!x.TryGetProperty("enabled",out var e)||e.GetBoolean(),x.TryGetProperty("loadOrder",out var o)?o.GetInt32():0)).ToArray();
|
||||
if(packages.GroupBy(x=>x.Id,StringComparer.OrdinalIgnoreCase).Any(x=>x.Count()>1)) throw new InvalidOperationException("ROSTER_DUPLICATE_PACKAGE");
|
||||
return new(V("rosterId"),V("product"),r.GetProperty("year").GetInt32(),V("architecture"),r.TryGetProperty("offlinePolicy",out var p)&&p.GetString()=="AllowVerifiedLastKnownGood",packages.OrderBy(x=>x.LoadOrder).ThenBy(x=>x.Id,StringComparer.Ordinal).ToArray());
|
||||
}
|
||||
public NormalizedRoster ParseLegacyDownloadList(string xml, RosterContext context)
|
||||
{
|
||||
var settings=new XmlReaderSettings{DtdProcessing=DtdProcessing.Prohibit,XmlResolver=null,MaxCharactersInDocument=1_000_000}; using var reader=XmlReader.Create(new StringReader(xml),settings); var doc=XDocument.Load(reader);
|
||||
var packages=doc.Descendants("Plugin").Select((x,i)=>new RosterPackage((string?)x.Attribute("id")??throw new InvalidOperationException("LEGACY_PACKAGE_ID_REQUIRED"),(string?)x.Attribute("version")??throw new InvalidOperationException("AMBIGUOUS_LEGACY_SEMANTICS"),(string?)x.Attribute("channel")??"pilot",!string.Equals((string?)x.Attribute("install"),"false",StringComparison.OrdinalIgnoreCase),i)).ToArray();
|
||||
if(packages.GroupBy(x=>x.Id,StringComparer.OrdinalIgnoreCase).Any(x=>x.Count()>1)) throw new InvalidOperationException("ROSTER_DUPLICATE_PACKAGE");
|
||||
return new(context.LegacyRosterId,context.Product,context.ProductYear,context.Architecture,context.AllowOfflineFallback,packages);
|
||||
}
|
||||
public RosterSelection Select(string? projectJson,string? legacyXml,string? centralJson,RosterContext context)
|
||||
{ if(projectJson is not null)return new(RosterSource.ProjectRosterJson,ParseJson(projectJson),"PROJECT_ROSTER_JSON"); if(legacyXml is not null)return new(RosterSource.LegacyDownloadListXml,ParseLegacyDownloadList(legacyXml,context),"LEGACY_DOWNLOAD_LIST_XML"); if(centralJson is not null)return new(RosterSource.CentralDefaultRoster,ParseJson(centralJson),"CENTRAL_DEFAULT_ROSTER"); return new(RosterSource.None,null,"NO_ROSTER_AVAILABLE"); }
|
||||
public IReadOnlyList<PackageRequirement> Requirements(NormalizedRoster roster)=>roster.Packages.Where(x=>x.Enabled&&x.AllowedForMachine).Select(x=>new PackageRequirement(x.Id,roster.Product,roster.Year,roster.Architecture,x.Channel,x.Version,roster.RosterId,roster.AllowOfflineFallback)).ToArray();
|
||||
}
|
||||
|
||||
public sealed record PackagePlanResult(bool IsComplete,IReadOnlyList<PackageResolutionResult> Packages,IReadOnlyList<PackageResolutionEvidence> Evidence);
|
||||
public sealed class RosterPackagePlanUseCase(RosterAdapter rosterAdapter,PackageResolutionUseCase packageResolution)
|
||||
{
|
||||
public async Task<PackagePlanResult> ResolveAsync(RosterSelection selection,CancellationToken cancellationToken=default)
|
||||
{
|
||||
var evidence=new List<PackageResolutionEvidence>();
|
||||
if(selection.Roster is null){evidence.Add(new("PACKAGE_PLAN_BLOCKED","NO_ROSTER_AVAILABLE"));return new(false,Array.Empty<PackageResolutionResult>(),evidence);}
|
||||
evidence.Add(new("ROSTER_SOURCE_SELECTED",$"{selection.Code}:{selection.Roster.RosterId}"));
|
||||
evidence.Add(new("ROSTER_PARSED",selection.Roster.RosterId));
|
||||
evidence.Add(new("ROSTER_VALIDATED",selection.Roster.RosterId));
|
||||
var requirements=rosterAdapter.Requirements(selection.Roster);
|
||||
if(requirements.Count==0){evidence.Add(new("PACKAGE_PLAN_BLOCKED","NO_ENABLED_PACKAGE_REQUIREMENTS"));return new(false,Array.Empty<PackageResolutionResult>(),evidence);}
|
||||
var results=new List<PackageResolutionResult>();
|
||||
foreach(var requirement in requirements)
|
||||
{
|
||||
evidence.Add(new("PACKAGE_REQUIREMENT_CREATED",$"{requirement.PackageId}:{requirement.RosterIdentity}"));
|
||||
var result=await packageResolution.ResolveAsync(requirement,cancellationToken);
|
||||
results.Add(result);evidence.AddRange(result.Evidence);
|
||||
if(!result.IsResolved){evidence.Add(new("PACKAGE_PLAN_BLOCKED",$"PACKAGE_REQUIREMENT_REJECTED:{requirement.PackageId}"));return new(false,results,evidence);}
|
||||
}
|
||||
evidence.Add(new("PACKAGE_PLAN_COMPLETED",selection.Roster.RosterId));
|
||||
return new(true,results,evidence);
|
||||
}
|
||||
}
|
||||
@@ -16,13 +16,22 @@ public sealed class StartupScriptBuilder : IStartupScriptBuilder
|
||||
{
|
||||
public string BuildNetloadScript(string root, string entry)
|
||||
{ var full = Path.GetFullPath(Path.Combine(root, entry)); if (!full.StartsWith(Path.GetFullPath(root) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || !File.Exists(full)) throw new InvalidOperationException("UNTRUSTED_ENTRY_PATH"); return $"_.NETLOAD\n\"{full.Replace("\"", "\\\"")}\"\n"; }
|
||||
public string BuildAutomationScript(string root, string entry, string fixtureOrthoDwg)
|
||||
public string BuildAutomationScript(ResolvedAutomationScriptPaths paths)
|
||||
{
|
||||
var fixture = Path.GetFullPath(fixtureOrthoDwg);
|
||||
var fixture = Path.GetFullPath(paths.FixtureOrthoDwg);
|
||||
if (!File.Exists(fixture)) throw new InvalidOperationException("AUTOMATION_FIXTURE_NOT_FOUND");
|
||||
var quote = (string value) => "\"" + value.Replace("\"", "\\\"") + "\"";
|
||||
return "_.OPEN\n" + quote(fixture) + "\n" + BuildNetloadScript(root, entry) +
|
||||
"PHP_REC_AUTOMATION_PREPARE\nPXP-OA\nPXP-OA\nPHP_REC_AUTOMATION_FINALIZE\n";
|
||||
var dialogFreeOpen = "_.FILEDIA\n0\n_.CMDDIA\n0\n";
|
||||
var diagnostic = paths.Diagnostic
|
||||
? "_.CMDECHO\n1\n_.NOMUTT\n0\n_.LOGFILEMODE\n1\n"
|
||||
: string.Empty;
|
||||
var commands = paths.Apply
|
||||
? "_.UNDO\n_MARK\nPXP-OA\nPXP-OA\n_.UNDO\n_BACK\n"
|
||||
: paths.Diagnostic
|
||||
? "PHP_REC_AUTOMATION_BEFORE_PXP_1\nPXP-OA\nPHP_REC_AUTOMATION_AFTER_PXP_1\nPHP_REC_AUTOMATION_BEFORE_PXP_2\nPXP-OA\nPHP_REC_AUTOMATION_AFTER_PXP_2\n"
|
||||
: "PXP-OA\nPXP-OA\n";
|
||||
return dialogFreeOpen + "_.OPEN\n" + quote(fixture) + "\n" + diagnostic + BuildNetloadScript(paths.ValidatedPackageRoot, paths.EntryAssembly) +
|
||||
"PHP_REC_AUTOMATION_PREPARE\n" + commands + "PHP_REC_AUTOMATION_FINALIZE\n";
|
||||
}
|
||||
}
|
||||
public sealed class DryRunAutodeskProcessLauncher : IAutodeskProcessLauncher
|
||||
|
||||
@@ -17,18 +17,38 @@ public sealed record ValidationResult(IReadOnlyList<ValidationIssue> Issues)
|
||||
{ public bool IsValid => Issues.Count == 0; public static ValidationResult Success { get; } = new([]); }
|
||||
public sealed record PackageProgress(string Stage, int Completed, int Total);
|
||||
public sealed record ActivationResult(string CurrentVersion, string? PreviousVersion);
|
||||
public sealed record PackageActivationReceipt(string OperationId, string PackageId, string Version, string? PreviousVersion, DateTimeOffset ActivatedUtc, long PointerGeneration = 0, string? ActiveRootIdentity = null, int SchemaVersion = 1, string? ManifestSha256 = null, string? PayloadSetSha256 = null, string? RosterIdentity = null);
|
||||
public sealed record PackageActivationResult(string Outcome, PackageActivationReceipt? Receipt);
|
||||
public sealed record PackageRollbackResult(string Outcome, string? CurrentVersion);
|
||||
public sealed record ImmutableLocalPackage(string PackageId, string Version, string LocalPackageRoot);
|
||||
public sealed record PackageStagingOwnership(string OperationId, string PackageId, string Version, string StagingDirectory, DateTimeOffset CreatedUtc, string MarkerIdentity);
|
||||
public sealed record PackageStagingCleanupResult(string Outcome, string OperationId);
|
||||
public enum PackageLifecycleFaultPoint { AfterStagingAllocated, DuringPayloadCopy, BeforeImmutableCommit, BeforePointerTempWrite, BeforePointerReplace, BeforePointerReadback, BeforeRollbackReplace, DuringOwnedStagingCleanup }
|
||||
public interface IPackageLifecycleFaultInjector { void ThrowIfRequested(PackageLifecycleFaultPoint point); }
|
||||
public sealed class NoOpPackageLifecycleFaultInjector : IPackageLifecycleFaultInjector { public void ThrowIfRequested(PackageLifecycleFaultPoint point) { } }
|
||||
public interface IPackageMaterializer { Task<ImmutableLocalPackage> MaterializeAsync(PackageManifest manifest, string sourceRoot, CancellationToken cancellationToken, IProgress<PackageProgress>? progress = null); }
|
||||
public sealed record PackageRequirement(string PackageId, string Product, int ProductYear, string Architecture, string Channel, string RequiredVersion, string RosterIdentity, bool AllowOfflineFallback);
|
||||
public sealed record CatalogPackage(PackageManifest Manifest, string SourceRoot);
|
||||
public sealed record PackageResolutionEvidence(string Code, string Detail);
|
||||
public sealed record PackageResolutionResult(CachedPackageResolution? Selected, bool Online, IReadOnlyList<PackageResolutionEvidence> Evidence)
|
||||
{ public bool IsResolved => Selected is not null; }
|
||||
public sealed record LaunchRequest(ProductInstallation Product, string Profile, string StartupScriptPath, bool DryRun);
|
||||
public sealed record PreparedLaunch(string Executable, IReadOnlyList<string> Arguments, string WorkingDirectory, bool WouldStart);
|
||||
|
||||
public interface IProductDiscovery { ProductInstallation? DiscoverPlant3D(int year); }
|
||||
public interface IPackageCatalogClient { Task<PackageManifest?> GetManifestAsync(string packageId, CancellationToken cancellationToken); }
|
||||
public interface IPackageCatalogPackageAcquirer { Task<CatalogPackage?> AcquireAsync(PackageRequirement requirement, CancellationToken cancellationToken); }
|
||||
public interface IPackageManifestValidator { ValidationResult Validate(PackageManifest manifest, int productYear, string architecture); }
|
||||
public interface IPackageSynchronizer { Task<ActivationResult> SynchronizeAsync(PackageManifest manifest, string sourceRoot, CancellationToken cancellationToken, IProgress<PackageProgress>? progress = null); }
|
||||
public interface ILocalPackageStore { Task<string?> GetCurrentVersionAsync(string packageId, CancellationToken cancellationToken); Task<string?> GetPreviousVersionAsync(string packageId, CancellationToken cancellationToken); }
|
||||
public interface IPackageActivator { Task<ActivationResult> ActivateAsync(string packageId, string version, CancellationToken cancellationToken); }
|
||||
public interface IRollbackManager { Task<string?> RollbackAsync(string packageId, CancellationToken cancellationToken); }
|
||||
public sealed record CachedPackageResolution(string PackageId, string Version, string LocalPackageRoot, bool IsOfflineFallback, string Reason);
|
||||
public interface ILastKnownGoodPackageResolver { Task<CachedPackageResolution?> ResolveAsync(PackageRequirement requirement, CancellationToken cancellationToken); }
|
||||
public interface IPackageRequirementCacheBinder { Task BindAsync(PackageRequirement requirement, string version, CancellationToken cancellationToken); }
|
||||
public interface IAutodeskProcessLauncher { PreparedLaunch Prepare(LaunchRequest request); }
|
||||
public interface IStartupScriptBuilder { string BuildNetloadScript(string validatedPackageRoot, string entryAssembly); string BuildAutomationScript(string validatedPackageRoot, string entryAssembly, string fixtureOrthoDwg); }
|
||||
public sealed record ResolvedAutomationScriptPaths(string ValidatedPackageRoot, string EntryAssembly, string FixtureOrthoDwg, string FixtureModelDwg, string AutomationScriptPath, bool Diagnostic, bool Apply);
|
||||
public interface IStartupScriptBuilder { string BuildNetloadScript(string validatedPackageRoot, string entryAssembly); string BuildAutomationScript(ResolvedAutomationScriptPaths paths); }
|
||||
public interface ILauncherConfigurationProvider { LauncherMode GetMode(); }
|
||||
public interface ILauncherTelemetry { void Write(string eventType, IReadOnlyDictionary<string, object?>? fields = null); }
|
||||
public interface IClock { DateTimeOffset UtcNow { get; } }
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace Launcher.Next.Core;
|
||||
|
||||
public sealed record LauncherPathVariables(IReadOnlyDictionary<string, string?> Values)
|
||||
{
|
||||
public string? this[string name] => Values.TryGetValue(name, out var value) ? value : null;
|
||||
}
|
||||
|
||||
public sealed record ResolvedLauncherPaths(IReadOnlyDictionary<string, string?> Values)
|
||||
{
|
||||
public string Require(string name) => Values.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value)
|
||||
? value : throw new InvalidOperationException($"PATH_VARIABLE_REQUIRED:{name}");
|
||||
public string? Get(string name) => Values.TryGetValue(name, out var value) ? value : null;
|
||||
public string LocalLauncherRoot => Require(nameof(LocalLauncherRoot));
|
||||
public string AcadExecutable => Require(nameof(AcadExecutable));
|
||||
public string? AutomationScriptPath => Get(nameof(AutomationScriptPath));
|
||||
public string? FixtureOrthoDwg => Get(nameof(FixtureOrthoDwg));
|
||||
}
|
||||
|
||||
/// <summary>Catalog source role resolved by <see cref="IPathResolver"/>. Raw
|
||||
/// filesystem strings are intentionally not accepted by catalog acquisition.</summary>
|
||||
public sealed record ResolvedCatalogRoot
|
||||
{
|
||||
internal ResolvedCatalogRoot(string value) => Value = value;
|
||||
public string Value { get; }
|
||||
}
|
||||
public sealed record ResolvedLegacySettingsPath { internal ResolvedLegacySettingsPath(string value) => Value=value; public string Value { get; } }
|
||||
public sealed record ResolvedLegacyDownloadListPath { internal ResolvedLegacyDownloadListPath(string value) => Value=value; public string Value { get; } }
|
||||
public sealed record ResolvedLegacyCompatibilityOverlayPath { internal ResolvedLegacyCompatibilityOverlayPath(string value) => Value=value; public string Value { get; } }
|
||||
public sealed record ResolvedLegacyProbeEvidenceRoot { internal ResolvedLegacyProbeEvidenceRoot(string value) => Value=value; public string Value { get; } }
|
||||
public sealed record ResolvedLocalPackageCacheRoot { internal ResolvedLocalPackageCacheRoot(string value)=>Value=value; public string Value{get;} }
|
||||
public sealed record ResolvedPackageVersionStoreRoot { internal ResolvedPackageVersionStoreRoot(string value)=>Value=value; public string Value{get;} }
|
||||
public sealed record ResolvedPackageStagingRoot { internal ResolvedPackageStagingRoot(string value)=>Value=value; public string Value{get;} }
|
||||
public sealed record ResolvedActivePackageRoot { internal ResolvedActivePackageRoot(string value)=>Value=value; public string Value{get;} }
|
||||
public sealed record ResolvedPackageLifecycleEvidenceRoot { internal ResolvedPackageLifecycleEvidenceRoot(string value)=>Value=value; public string Value{get;} }
|
||||
public sealed record ResolvedPackagePilotRoot { internal ResolvedPackagePilotRoot(string value)=>Value=value; public string Value{get;} }
|
||||
|
||||
public interface IPathResolver
|
||||
{
|
||||
ResolvedLauncherPaths Resolve(LauncherPathVariables variables, IEnumerable<string>? required = null);
|
||||
string ResolvePath(string value, LauncherPathVariables variables, string? constrainedRoot = null);
|
||||
ResolvedCatalogRoot ResolveCatalogRoot(LauncherPathVariables variables);
|
||||
ResolvedLegacySettingsPath ResolveLegacySettingsPath(LauncherPathVariables variables);
|
||||
ResolvedLegacyDownloadListPath ResolveLegacyDownloadListPath(LauncherPathVariables variables);
|
||||
ResolvedLegacyCompatibilityOverlayPath ResolveLegacyCompatibilityOverlayPath(LauncherPathVariables variables);
|
||||
ResolvedLegacyProbeEvidenceRoot ResolveLegacyProbeEvidenceRoot(LauncherPathVariables variables);
|
||||
ResolvedPackagePilotRoot ResolvePackagePilotRoot(string value);
|
||||
}
|
||||
|
||||
public sealed class PathResolver(IReadOnlyDictionary<string, string?>? environment = null) : IPathResolver
|
||||
{
|
||||
private static readonly Regex Token = new(@"\$\{(?<name>[A-Za-z][A-Za-z0-9_]*)\}", RegexOptions.Compiled);
|
||||
private readonly IReadOnlyDictionary<string, string?> environment = environment ?? Environment.GetEnvironmentVariables()
|
||||
.Cast<System.Collections.DictionaryEntry>().ToDictionary(x => (string)x.Key, x => x.Value?.ToString(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public ResolvedLauncherPaths Resolve(LauncherPathVariables variables, IEnumerable<string>? required = null)
|
||||
{
|
||||
var resolved = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
// Explicit null values represent optional paths (for example the Apply
|
||||
// permit during Preview). They are retained as null rather than being
|
||||
// treated as an unknown variable; required values are still enforced
|
||||
// below.
|
||||
foreach (var name in variables.Values.Keys)
|
||||
resolved[name] = variables[name] is null ? null : ResolveVariable(name, variables, resolved, []);
|
||||
foreach (var name in resolved.Keys.ToArray())
|
||||
if (resolved[name] is { } value && value.StartsWith("A:\\", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var corporate = ResolveVariable(LauncherPathVariableNames.CorporateDfsRoot, variables, resolved, []) ?? throw new InvalidOperationException("PATH_VARIABLE_EMPTY:CorporateDfsRoot");
|
||||
resolved[name] = Path.Combine(corporate, value[3..]);
|
||||
}
|
||||
foreach (var name in required ?? [])
|
||||
if (!resolved.TryGetValue(name, out var value) || string.IsNullOrWhiteSpace(value))
|
||||
throw new InvalidOperationException($"PATH_VARIABLE_REQUIRED:{name}");
|
||||
return new(resolved);
|
||||
}
|
||||
|
||||
public string ResolvePath(string value, LauncherPathVariables variables, string? constrainedRoot = null)
|
||||
{
|
||||
var resolved = Expand(value, variables, new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase), []);
|
||||
if (string.IsNullOrWhiteSpace(resolved) || !Path.IsPathFullyQualified(resolved))
|
||||
throw new InvalidOperationException("PATH_ABSOLUTE_REQUIRED");
|
||||
if (resolved.StartsWith("A:\\", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var corporate = Resolve(variables, [LauncherPathVariableNames.CorporateDfsRoot]).Require(LauncherPathVariableNames.CorporateDfsRoot);
|
||||
resolved = Path.Combine(corporate, resolved[3..]);
|
||||
}
|
||||
var full = Path.GetFullPath(resolved);
|
||||
if (full.StartsWith("A:\\", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("LEGACY_DRIVE_A_UNRESOLVED");
|
||||
if (!string.IsNullOrWhiteSpace(constrainedRoot))
|
||||
{
|
||||
var root = Path.GetFullPath(ResolvePath(constrainedRoot, variables));
|
||||
if (!full.Equals(root, StringComparison.OrdinalIgnoreCase) && !full.StartsWith(root.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException("PATH_OUTSIDE_CONSTRAINED_ROOT");
|
||||
}
|
||||
return full;
|
||||
}
|
||||
|
||||
public ResolvedCatalogRoot ResolveCatalogRoot(LauncherPathVariables variables)
|
||||
{
|
||||
var resolved = Resolve(variables, [LauncherPathVariableNames.CorporateCatalogRoot, LauncherPathVariableNames.CorporateDfsRoot]);
|
||||
var catalog = ResolvePath(resolved.Require(LauncherPathVariableNames.CorporateCatalogRoot), variables);
|
||||
var corporate = ResolvePath(resolved.Require(LauncherPathVariableNames.CorporateDfsRoot), variables);
|
||||
if (!catalog.StartsWith(corporate.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException("PATH_ROLE_COLLISION");
|
||||
return new ResolvedCatalogRoot(catalog);
|
||||
}
|
||||
public ResolvedLegacySettingsPath ResolveLegacySettingsPath(LauncherPathVariables variables) => new(ResolveExistingFile("LegacySettingsPath", variables));
|
||||
public ResolvedLegacyDownloadListPath ResolveLegacyDownloadListPath(LauncherPathVariables variables) => new(ResolveExistingFile("LegacyDownloadListPath", variables));
|
||||
public ResolvedLegacyCompatibilityOverlayPath ResolveLegacyCompatibilityOverlayPath(LauncherPathVariables variables) => new(ResolveExistingFile("LegacyCompatibilityOverlayPath", variables));
|
||||
public ResolvedLegacyProbeEvidenceRoot ResolveLegacyProbeEvidenceRoot(LauncherPathVariables variables)
|
||||
{
|
||||
var value=ResolvePath(Resolve(variables,["LegacyProbeEvidenceRoot"]).Require("LegacyProbeEvidenceRoot"),variables);
|
||||
if(value.StartsWith(@"\\",StringComparison.Ordinal)) throw new InvalidOperationException("LEGACY_PROBE_EVIDENCE_ROOT_NOT_LOCAL");
|
||||
return new(value);
|
||||
}
|
||||
public ResolvedPackagePilotRoot ResolvePackagePilotRoot(string value)
|
||||
{ if (string.IsNullOrWhiteSpace(value)||value.Contains('*')||value.Contains('?')||value.StartsWith(@"\\")||!Path.IsPathFullyQualified(value)) throw new InvalidOperationException("PACKAGE_PILOT_ROOT_NOT_LOCAL"); var full=Path.GetFullPath(value); var info=new DirectoryInfo(full); if(info.Exists&&info.Attributes.HasFlag(FileAttributes.ReparsePoint)) throw new InvalidOperationException("PACKAGE_ROOT_REPARSE_REJECTED"); return new(full); }
|
||||
private string ResolveExistingFile(string role, LauncherPathVariables variables)
|
||||
{
|
||||
var value=ResolvePath(Resolve(variables,[role]).Require(role),variables);
|
||||
if(!File.Exists(value) || Directory.Exists(value)) throw new InvalidOperationException("LEGACY_PROBE_INPUT_NOT_FILE");
|
||||
return value;
|
||||
}
|
||||
|
||||
private string? ResolveVariable(string name, LauncherPathVariables variables, Dictionary<string, string?> resolved, HashSet<string> stack)
|
||||
{
|
||||
if (resolved.TryGetValue(name, out var cached)) return cached;
|
||||
if (!stack.Add(name)) throw new InvalidOperationException($"PATH_VARIABLE_CYCLE:{name}");
|
||||
try
|
||||
{
|
||||
var source = variables[name] ?? (environment.TryGetValue(name, out var fromEnvironment) ? fromEnvironment : null);
|
||||
if (source is null) throw new InvalidOperationException($"PATH_VARIABLE_UNKNOWN:{name}");
|
||||
var expanded = Token.Replace(source, match => ResolveVariable(match.Groups["name"].Value, variables, resolved, stack) ?? throw new InvalidOperationException($"PATH_VARIABLE_EMPTY:{match.Groups["name"].Value}"));
|
||||
expanded = expanded.Replace('/', Path.DirectorySeparatorChar);
|
||||
resolved[name] = expanded;
|
||||
return expanded;
|
||||
}
|
||||
finally { stack.Remove(name); }
|
||||
}
|
||||
|
||||
private string Expand(string value, LauncherPathVariables variables, Dictionary<string, string?> resolved, HashSet<string> stack) =>
|
||||
Token.Replace(value, match => ResolveVariable(match.Groups["name"].Value, variables, resolved, stack) ?? throw new InvalidOperationException($"PATH_VARIABLE_EMPTY:{match.Groups["name"].Value}"));
|
||||
}
|
||||
|
||||
public static class LauncherPathVariableNames
|
||||
{
|
||||
public const string CorporateDfsRoot = "CorporateDfsRoot";
|
||||
public const string CorporateLauncherRoot = "CorporateLauncherRoot";
|
||||
public const string CorporateCatalogRoot = "CorporateCatalogRoot";
|
||||
public const string CorporatePackagesRoot = "CorporatePackagesRoot";
|
||||
public const string CorporateProfilesRoot = "CorporateProfilesRoot";
|
||||
public const string CorporatePalettesRoot = "CorporatePalettesRoot";
|
||||
public const string CorporateFontsRoot = "CorporateFontsRoot";
|
||||
public const string CorporateTemplatesRoot = "CorporateTemplatesRoot";
|
||||
public const string CorporateScriptsRoot = "CorporateScriptsRoot";
|
||||
public const string ProjectName = "ProjectName";
|
||||
public const string DisciplineName = "DisciplineName";
|
||||
public const string ProjectRoot = "ProjectRoot";
|
||||
public const string DisciplineRoot = "DisciplineRoot";
|
||||
public const string PlantProjectFile = "PlantProjectFile";
|
||||
public const string ProjectRosterPath = "ProjectRosterPath";
|
||||
public const string ProjectApplicationsRoot = "ProjectApplicationsRoot";
|
||||
public const string ProjectPaletteOverlayRoot = "ProjectPaletteOverlayRoot";
|
||||
public const string LocalLauncherRoot = "LocalLauncherRoot";
|
||||
public const string LocalPackageCache = "LocalPackageCache";
|
||||
public const string LocalActiveRoot = "LocalActiveRoot";
|
||||
public const string LocalSessionRoot = "LocalSessionRoot";
|
||||
public const string LocalEvidenceRoot = "LocalEvidenceRoot";
|
||||
public const string LocalLogsRoot = "LocalLogsRoot";
|
||||
public const string LocalRollbackRoot = "LocalRollbackRoot";
|
||||
public const string AcadExecutable = "AcadExecutable";
|
||||
public const string PRecEntryAssembly = "PRecEntryAssembly";
|
||||
public const string FixtureOrthoDwg = "FixtureOrthoDwg";
|
||||
public const string FixtureModelDwg = "FixtureModelDwg";
|
||||
public const string AutomationScriptPath = "AutomationScriptPath";
|
||||
public const string AutomationConfigPath = "AutomationConfigPath";
|
||||
public const string AutomationEvidencePath = "AutomationEvidencePath";
|
||||
public const string AutomationPermitPath = "AutomationPermitPath";
|
||||
public const string AutomationTranscriptPath = "AutomationTranscriptPath";
|
||||
}
|
||||
|
||||
public static class LauncherConfigurationLayers
|
||||
{
|
||||
private static readonly HashSet<string> Protected = [LauncherPathVariableNames.CorporateDfsRoot, LauncherPathVariableNames.AcadExecutable, LauncherPathVariableNames.LocalLauncherRoot];
|
||||
|
||||
public static PilotConfigurationLoadResult Load(string? corporateDefaultsJson, string? machineJson, string? sessionJson)
|
||||
{
|
||||
try
|
||||
{
|
||||
var merged = Parse(corporateDefaultsJson);
|
||||
Merge(merged, Parse(machineJson), false);
|
||||
Merge(merged, Parse(sessionJson), false);
|
||||
return PilotConfigurationParser.Parse(merged.ToJsonString());
|
||||
}
|
||||
catch (JsonException) { return new(PilotLocalConfiguration.SafeDefault, ["PILOT_CONFIGURATION_INVALID"]); }
|
||||
}
|
||||
|
||||
public static PilotConfigurationLoadResult LoadFromFiles(string corporateDefaultsPath, string machinePath, string sessionPath)
|
||||
{
|
||||
var defaults = File.Exists(corporateDefaultsPath) ? File.ReadAllText(corporateDefaultsPath) : null;
|
||||
var machine = File.Exists(machinePath) ? File.ReadAllText(machinePath) : null;
|
||||
var session = File.Exists(sessionPath) ? File.ReadAllText(sessionPath) : null;
|
||||
return Load(defaults, machine, session);
|
||||
}
|
||||
|
||||
private static JsonObject Parse(string? json) => string.IsNullOrWhiteSpace(json) ? [] : JsonNode.Parse(json)?.AsObject() ?? [];
|
||||
private static void Merge(JsonObject target, JsonObject overlay, bool roster)
|
||||
{
|
||||
foreach (var entry in overlay)
|
||||
{
|
||||
if (entry.Key.Equals("PathVariables", StringComparison.OrdinalIgnoreCase) && entry.Value is JsonObject paths)
|
||||
{
|
||||
var targetPaths = target["PathVariables"] as JsonObject ?? new JsonObject(); target["PathVariables"] = targetPaths;
|
||||
foreach (var path in paths) if (!roster || !Protected.Contains(path.Key)) targetPaths[path.Key] = path.Value?.DeepClone();
|
||||
}
|
||||
else target[entry.Key] = entry.Value?.DeepClone();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,3 +25,16 @@ public sealed class PackageManifestValidator : IPackageManifestValidator
|
||||
return issues.Count == 0 ? ValidationResult.Success : new ValidationResult(issues);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Structural validation for product-neutral catalog acquisition; it is
|
||||
/// deliberately not a substitute for a product compatibility policy.</summary>
|
||||
public sealed class StructuralPackageManifestValidator
|
||||
{
|
||||
public ValidationResult Validate(PackageManifest manifest)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(manifest.Product))
|
||||
return new ValidationResult([new("product", "Package product is required.")]);
|
||||
return new PackageManifestValidator().Validate(
|
||||
manifest with { Product = "Plant3D" }, manifest.ProductYear, manifest.Architecture);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,9 @@ public sealed record PilotLocalConfiguration
|
||||
public bool AllowApply { get; init; }
|
||||
public string? ApplyPermitPath { get; init; }
|
||||
public string? AutomationMode { get; init; }
|
||||
public bool AutomationDiagnostic { get; init; }
|
||||
public string? AutomationConfigurationPath { get; init; }
|
||||
public string? FixtureMarkerPath { get; init; }
|
||||
// Test-only: permits a pilot process to be terminated after its plugin has
|
||||
// persisted completed automation evidence. It never represents graceful exit.
|
||||
public bool ForceAfterEvidence { get; init; }
|
||||
@@ -27,8 +29,12 @@ public sealed record PilotLocalConfiguration
|
||||
public string? SelectedProductId { get; init; }
|
||||
public string? SelectedProfileName { get; init; }
|
||||
public int ExecutionPermitLifetimeSeconds { get; init; } = 300;
|
||||
public AcadLaunchMode LaunchMode { get; init; } = AcadLaunchMode.Production;
|
||||
public bool ProductionDeploy { get; init; }
|
||||
public IReadOnlyDictionary<string, string?> PathVariables { get; init; } = new Dictionary<string, string?>();
|
||||
public static PilotLocalConfiguration SafeDefault { get; } = new();
|
||||
}
|
||||
public enum AcadLaunchMode { Production, NextPilotPreview, NextPilotApply, LifecycleProbe }
|
||||
public sealed record PilotConfigurationLoadResult(PilotLocalConfiguration Configuration, IReadOnlyList<string> Errors)
|
||||
{ public bool IsValid => Errors.Count == 0; }
|
||||
public static class PilotConfigurationParser
|
||||
@@ -50,7 +56,7 @@ public sealed class PilotRootPolicy(string trustedRoot, string? testRoot = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(candidate) || !Path.IsPathFullyQualified(candidate) || candidate.StartsWith("\\\\", StringComparison.Ordinal) || candidate.Contains(':', StringComparison.Ordinal) && candidate.IndexOf(':') != 1) return new(false, null, ["PILOT_ROOT_UNTRUSTED"]);
|
||||
var full = Path.GetFullPath(candidate); var permitted = new[] { trustedRoot, testRoot }.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => Path.GetFullPath(x!));
|
||||
if (!permitted.Any(x => full.StartsWith(x + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) || full.Contains("Program Files", StringComparison.OrdinalIgnoreCase) || full.Contains("Applications", StringComparison.OrdinalIgnoreCase)) return new(false, null, ["PILOT_ROOT_UNTRUSTED"]);
|
||||
if (!permitted.Any(x => full.Equals(x, StringComparison.OrdinalIgnoreCase) || full.StartsWith(x + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) || full.Contains("Program Files", StringComparison.OrdinalIgnoreCase) || full.Contains("Applications", StringComparison.OrdinalIgnoreCase)) return new(false, null, ["PILOT_ROOT_UNTRUSTED"]);
|
||||
return new(true, full, []);
|
||||
}
|
||||
}
|
||||
@@ -67,14 +73,42 @@ public interface INextPilotSafetyGate { PilotSafetyDecision Evaluate(PilotSafety
|
||||
public sealed class NextPilotSafetyGate : INextPilotSafetyGate
|
||||
{
|
||||
public PilotSafetyDecision Evaluate(PilotSafetyContext c, bool forExecution)
|
||||
{ var v = new List<string>(); if (c.Configuration.LauncherMode != LauncherMode.NextPilot) v.Add("WRONG_LAUNCHER_MODE"); if (!c.Configuration.PilotEnabled) v.Add("PILOT_DISABLED"); if (!c.Configuration.RequireExplicitExecute) v.Add("EXPLICIT_EXECUTE_REQUIRED"); if (c.Configuration.PluginExecutionMode is not (PluginExecutionMode.Smoke or PluginExecutionMode.DryRun or PluginExecutionMode.Apply)) v.Add("PLUGIN_EXECUTION_MODE_NOT_ALLOWED"); if (c.Configuration.PluginExecutionMode == PluginExecutionMode.Apply && !c.Configuration.AllowApply) v.Add("APPLY_NOT_EXPLICITLY_ALLOWED"); if (c.Configuration.PluginExecutionMode == PluginExecutionMode.Apply && (!Path.IsPathFullyQualified(c.Configuration.ApplyPermitPath ?? string.Empty) || (c.Configuration.ApplyPermitPath ?? string.Empty).StartsWith("\\\\", StringComparison.Ordinal))) v.Add("APPLY_PERMIT_PATH_REQUIRED"); if (c.Configuration.AutomationMode is not null && (!string.Equals(c.Configuration.AutomationMode, "FirstLimitedApplyRuntimeTest", StringComparison.Ordinal) || !Path.IsPathFullyQualified(c.Configuration.AutomationConfigurationPath ?? string.Empty) || (c.Configuration.AutomationConfigurationPath ?? string.Empty).StartsWith("\\\\", StringComparison.Ordinal))) v.Add("AUTOMATION_CONFIGURATION_INVALID"); if (c.Configuration.ForceAfterEvidence && c.Configuration.AutomationMode is null) v.Add("FORCED_CLEANUP_TEST_AUTOMATION_REQUIRED"); if (c.Product is null) v.Add("PRODUCT_NOT_FOUND"); else if (c.Product.ProductVersion != "2026" || c.Product.Architecture != "x64") v.Add("PRODUCT_NOT_PLANT3D_2026"); if (c.Profile is null) v.Add("PROFILE_NOT_RESOLVED"); if (!c.Root.IsValid) v.Add("PILOT_ROOT_UNTRUSTED"); if (!c.Manifest.IsValid) v.Add("MANIFEST_INVALID"); if (c.HashesValid) { } else v.Add("HASH_VALIDATION_FAILED"); if (c.AutodeskDllPresent) v.Add("AUTODESK_DLL_PRESENT"); if (c.AcadRunning) v.Add("ACAD_ALREADY_RUNNING"); if (forExecution && !c.Configuration.AllowProcessStart) v.Add("PROCESS_START_DISABLED"); return new(v.Count == 0, v); }
|
||||
{
|
||||
var v = new List<string>();
|
||||
if (c.Configuration.LauncherMode != LauncherMode.NextPilot) v.Add("WRONG_LAUNCHER_MODE");
|
||||
if (!c.Configuration.PilotEnabled) v.Add("PILOT_DISABLED");
|
||||
if (!c.Configuration.RequireExplicitExecute) v.Add("EXPLICIT_EXECUTE_REQUIRED");
|
||||
if (c.Configuration.PluginExecutionMode is not (PluginExecutionMode.Smoke or PluginExecutionMode.DryRun or PluginExecutionMode.Apply)) v.Add("PLUGIN_EXECUTION_MODE_NOT_ALLOWED");
|
||||
if (c.Configuration.PluginExecutionMode == PluginExecutionMode.Apply && !c.Configuration.AllowApply) v.Add("APPLY_NOT_EXPLICITLY_ALLOWED");
|
||||
if (c.Configuration.PluginExecutionMode == PluginExecutionMode.Apply && (!Path.IsPathFullyQualified(c.Configuration.ApplyPermitPath ?? string.Empty) || (c.Configuration.ApplyPermitPath ?? string.Empty).StartsWith("\\\\", StringComparison.Ordinal))) v.Add("APPLY_PERMIT_PATH_REQUIRED");
|
||||
var automation = c.Configuration.LaunchMode is AcadLaunchMode.NextPilotPreview or AcadLaunchMode.NextPilotApply or AcadLaunchMode.LifecycleProbe;
|
||||
if (automation && (!string.Equals(c.Configuration.AutomationMode, "FirstLimitedApplyRuntimeTest", StringComparison.Ordinal) || !Path.IsPathFullyQualified(c.Configuration.AutomationConfigurationPath ?? string.Empty) || (c.Configuration.AutomationConfigurationPath ?? string.Empty).StartsWith("\\\\", StringComparison.Ordinal) || !Path.IsPathFullyQualified(c.Configuration.FixtureMarkerPath ?? string.Empty) || (c.Configuration.FixtureMarkerPath ?? string.Empty).StartsWith("\\\\", StringComparison.Ordinal) || c.Configuration.ProductionDeploy)) v.Add("AUTOMATION_CONFIGURATION_INVALID");
|
||||
if (!automation && c.Configuration.AutomationMode is not null) v.Add("AUTOMATION_MODE_MISMATCH");
|
||||
if (c.Configuration.ForceAfterEvidence && !automation) v.Add("FORCED_CLEANUP_TEST_AUTOMATION_REQUIRED");
|
||||
if (c.Product is null) v.Add("PRODUCT_NOT_FOUND"); else if (c.Product.ProductVersion != "2026" || c.Product.Architecture != "x64") v.Add("PRODUCT_NOT_PLANT3D_2026");
|
||||
if (c.Profile is null) v.Add("PROFILE_NOT_RESOLVED"); if (!c.Root.IsValid) v.Add("PILOT_ROOT_UNTRUSTED"); if (!c.Manifest.IsValid) v.Add("MANIFEST_INVALID"); if (!c.HashesValid) v.Add("HASH_VALIDATION_FAILED"); if (c.AutodeskDllPresent) v.Add("AUTODESK_DLL_PRESENT"); if (c.AcadRunning) v.Add("ACAD_ALREADY_RUNNING"); if (forExecution && !c.Configuration.AllowProcessStart) v.Add("PROCESS_START_DISABLED"); return new(v.Count == 0, v);
|
||||
}
|
||||
}
|
||||
public sealed record ProcessLaunchSpecification(string ExecutablePath, IReadOnlyList<string> Arguments, string WorkingDirectory, IReadOnlyDictionary<string,string> EnvironmentVariables);
|
||||
public sealed record PilotLaunchPlan(string PlanId, ProductInstallation Product, AutoCadProfileCandidate Profile, string PackageId, string PackageVersion, string ManifestSha256, string PilotRoot, string VersionDirectory, string EntryAssemblyPath, string StartupScriptPath, string StartupScriptSha256, ProcessLaunchSpecification Process, PilotSafetyDecision SafetyDecision);
|
||||
public sealed class AcadArgumentBuilder
|
||||
{
|
||||
public IReadOnlyList<string> Build(AcadLaunchMode mode, string? automationScriptPath)
|
||||
{
|
||||
var arguments = new List<string> { "/product", "PLNT3D", "/language", "ru-RU" };
|
||||
if (mode is AcadLaunchMode.NextPilotPreview or AcadLaunchMode.NextPilotApply or AcadLaunchMode.LifecycleProbe)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(automationScriptPath) || !Path.IsPathFullyQualified(automationScriptPath)) throw new InvalidOperationException("AUTOMATION_SCRIPT_PATH_REQUIRED");
|
||||
arguments.Add("/b"); arguments.Add(automationScriptPath);
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(automationScriptPath)) throw new InvalidOperationException("PRODUCTION_BATCH_SCRIPT_FORBIDDEN");
|
||||
return arguments;
|
||||
}
|
||||
}
|
||||
public static class PilotPlanFactory
|
||||
{
|
||||
public static PilotLaunchPlan Create(ProductInstallation product, AutoCadProfileCandidate profile, PackageManifest manifest, string manifestHash, string root, string scriptPath, string scriptHash, PilotSafetyDecision decision)
|
||||
{ var version = Path.Combine(root, "packages", manifest.PackageId, "versions", manifest.Version); var entry = Path.Combine(version, manifest.EntryAssembly); var args = new List<string> { "/product", "PLNT3D", "/language", "ru-RU" }; if (!string.IsNullOrWhiteSpace(profile.Name)) { args.Add("/p"); args.Add(profile.Name); } args.Add("/b"); args.Add(scriptPath); var process = new ProcessLaunchSpecification(product.ExecutablePath, args, Path.GetDirectoryName(product.ExecutablePath)!, new Dictionary<string,string> { ["P_REC_ANNOT_RUNTIME_MODE"] = "Smoke" }); var material = string.Join('|', manifest.PackageId, manifest.Version, manifestHash, product.ExecutablePath, profile.Name, entry, scriptHash); var id = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(material))); return new(id, product, profile, manifest.PackageId, manifest.Version, manifestHash, root, version, entry, scriptPath, scriptHash, process, decision); }
|
||||
public static PilotLaunchPlan Create(ProductInstallation product, AutoCadProfileCandidate profile, PackageManifest manifest, string manifestHash, string root, string scriptPath, string scriptHash, PilotSafetyDecision decision, AcadLaunchMode mode = AcadLaunchMode.Production)
|
||||
{ var version = Path.Combine(root, "packages", manifest.PackageId, "versions", manifest.Version); var entry = Path.Combine(version, manifest.EntryAssembly); var args = new AcadArgumentBuilder().Build(mode, mode == AcadLaunchMode.Production ? null : scriptPath); var process = new ProcessLaunchSpecification(product.ExecutablePath, args, Path.GetDirectoryName(product.ExecutablePath)!, new Dictionary<string,string> { ["P_REC_ANNOT_RUNTIME_MODE"] = "Smoke" }); var material = string.Join('|', manifest.PackageId, manifest.Version, manifestHash, product.ExecutablePath, entry, scriptHash, mode); var id = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(material))); return new(id, product, profile, manifest.PackageId, manifest.Version, manifestHash, root, version, entry, scriptPath, scriptHash, process, decision); }
|
||||
}
|
||||
public sealed record ExecutionPermit(string PlanId, string Fingerprint, DateTimeOffset ExpiresAtUtc, Guid Id);
|
||||
public interface IExecutionPermitService { ExecutionPermit Issue(PilotLaunchPlan plan, int lifetimeSeconds); bool Validate(ExecutionPermit permit, PilotLaunchPlan plan, DateTimeOffset now); }
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using Launcher.Next.Core;
|
||||
|
||||
namespace Launcher.Next.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Reads a catalog descriptor only. The returned source is later copied into
|
||||
/// local immutable staging by <see cref="IPackageSynchronizer"/>; it is never
|
||||
/// an active launch root.
|
||||
/// </summary>
|
||||
public sealed class FileSystemCatalogPackageAcquirer(ResolvedCatalogRoot catalogRoot) : IPackageCatalogPackageAcquirer
|
||||
{
|
||||
private const int MaxFiles = 256;
|
||||
private const long MaxBytes = 512L * 1024 * 1024;
|
||||
|
||||
public async Task<CatalogPackage?> AcquireAsync(PackageRequirement requirement, CancellationToken cancellationToken)
|
||||
{
|
||||
var root = catalogRoot.Value;
|
||||
var candidate = Path.GetFullPath(Path.Combine(root, requirement.PackageId, requirement.RequiredVersion));
|
||||
if (!Directory.Exists(root) || IsReparsePoint(root) || !IsWithin(root, candidate) || !Directory.Exists(candidate) || IsReparsePoint(candidate))
|
||||
return null;
|
||||
|
||||
var manifestPath = Path.Combine(candidate, "package.manifest.json");
|
||||
if (!File.Exists(manifestPath) || IsReparsePoint(manifestPath))
|
||||
return null;
|
||||
|
||||
var manifest = PackageManifestJson.Parse(await File.ReadAllTextAsync(manifestPath, cancellationToken));
|
||||
if (manifest.Files.Count > MaxFiles || manifest.Files.Sum(file => file.Length) > MaxBytes ||
|
||||
manifest.Files.Any(file => string.IsNullOrWhiteSpace(file.Path) || Path.IsPathRooted(file.Path) ||
|
||||
file.Path.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Any(segment => segment is ".." or ".")))
|
||||
return null;
|
||||
return new CatalogPackage(manifest, candidate);
|
||||
}
|
||||
|
||||
private static bool IsWithin(string root, string candidate) =>
|
||||
candidate.StartsWith(Path.TrimEndingDirectorySeparator(root) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool IsReparsePoint(string path) =>
|
||||
(File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0;
|
||||
}
|
||||
@@ -5,27 +5,130 @@ namespace Launcher.Next.Infrastructure;
|
||||
|
||||
public sealed class Sha256FileHasher : IFileHasher
|
||||
{ public async Task<string> Sha256Async(string path, CancellationToken cancellationToken) { await using var s = File.OpenRead(path); return Convert.ToHexString(await SHA256.HashDataAsync(s, cancellationToken)); } }
|
||||
public sealed class LocalPackageStore(string root, IFileHasher hasher) : ILocalPackageStore, IPackageActivator, IRollbackManager, IPackageSynchronizer
|
||||
public sealed class LocalPackageStore(string root, IFileHasher hasher, IPackageLifecycleFaultInjector? faults = null) : ILocalPackageStore, IPackageActivator, IRollbackManager, IPackageSynchronizer, IPackageMaterializer, ILastKnownGoodPackageResolver, IPackageRequirementCacheBinder
|
||||
{
|
||||
private sealed record Pointer(string Current, string? Previous);
|
||||
// Machine-process local serialization only; package roots must remain local.
|
||||
private static readonly SemaphoreSlim PointerGate = new(1, 1);
|
||||
private sealed record Pointer(string Current, string? Previous, int SchemaVersion = 1, long Generation = 0, string? ActivationOperationId = null, DateTimeOffset? ActivatedAtUtc = null, string? ManifestSha256 = null, string? PayloadSetSha256 = null);
|
||||
private sealed record CacheBinding(string PackageId, string Product, int ProductYear, string Architecture, string Channel, string RequiredVersion, string RosterIdentity);
|
||||
private const string StoredManifestFileName = "package.manifest.json";
|
||||
private const string StoredBindingFileName = "package.binding.json";
|
||||
private string PackageRoot(string id) => Path.Combine(root, "Applications", id);
|
||||
private string PointerPath(string id) => Path.Combine(PackageRoot(id), "current.json");
|
||||
private async Task<Pointer?> ReadPointer(string id, CancellationToken ct) => File.Exists(PointerPath(id)) ? await JsonSerializer.DeserializeAsync<Pointer>(File.OpenRead(PointerPath(id)), cancellationToken: ct) : null;
|
||||
private async Task<Pointer?> ReadPointer(string id, CancellationToken ct)
|
||||
{
|
||||
if (!File.Exists(PointerPath(id))) return null;
|
||||
await using var stream = File.OpenRead(PointerPath(id));
|
||||
var pointer = await JsonSerializer.DeserializeAsync<Pointer>(stream, cancellationToken: ct);
|
||||
return pointer is null || pointer.SchemaVersion != 1 || string.IsNullOrWhiteSpace(pointer.Current) ? null : pointer;
|
||||
}
|
||||
public async Task<string?> GetCurrentVersionAsync(string id, CancellationToken ct) => (await ReadPointer(id, ct))?.Current;
|
||||
public async Task<string?> GetPreviousVersionAsync(string id, CancellationToken ct) => (await ReadPointer(id, ct))?.Previous;
|
||||
public async Task<ActivationResult> ActivateAsync(string id, string version, CancellationToken ct)
|
||||
{
|
||||
var dir = Path.Combine(PackageRoot(id), "versions", version); if (!Directory.Exists(dir)) throw new DirectoryNotFoundException(dir);
|
||||
Directory.CreateDirectory(PackageRoot(id)); var prior = await ReadPointer(id, ct);
|
||||
var temp = PointerPath(id) + ".tmp"; await File.WriteAllTextAsync(temp, JsonSerializer.Serialize(new Pointer(version, prior?.Current)), ct); File.Move(temp, PointerPath(id), true);
|
||||
await PointerGate.WaitAsync(ct); try
|
||||
{
|
||||
var dir = VersionDirectory(id, version); if (!Directory.Exists(dir)) throw new DirectoryNotFoundException(dir);
|
||||
Directory.CreateDirectory(PackageRoot(id)); var prior = await ReadPointer(id, ct); var operationId=Guid.NewGuid().ToString("N"); var content=await ContentFingerprintAsync(id,version,ct);
|
||||
(faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.BeforePointerTempWrite); var temp = PointerPath(id) + "." + operationId + ".tmp"; await using (var stream=new FileStream(temp,FileMode.CreateNew,FileAccess.Write,FileShare.None)) { await JsonSerializer.SerializeAsync(stream,new Pointer(version, prior?.Current,1,(prior?.Generation??0)+1,operationId,DateTimeOffset.UtcNow,content.Manifest,content.PayloadSet),cancellationToken:ct); await stream.FlushAsync(ct); }
|
||||
(faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.BeforePointerReplace); File.Move(temp, PointerPath(id), true); (faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.BeforePointerReadback); var verified=await ReadPointer(id,ct); if (verified?.Current!=version || verified.ActivationOperationId!=operationId) throw new InvalidOperationException("POINTER_REVALIDATION_FAILED");
|
||||
return new(version, prior?.Current);
|
||||
} finally { PointerGate.Release(); }
|
||||
}
|
||||
public async Task<PackageActivationResult> ActivateImmutableAsync(ImmutableLocalPackage package, PackageRequirement requirement, CancellationToken ct)
|
||||
{
|
||||
if (!string.Equals(package.PackageId, requirement.PackageId, StringComparison.Ordinal) || !string.Equals(package.Version, requirement.RequiredVersion, StringComparison.Ordinal) || !await MatchesImmutableManifestAsync(package,requirement,ct)) return new("Blocked", null);
|
||||
var prior = await ReadPointer(package.PackageId, ct);
|
||||
if (prior?.Current == package.Version) return new("AlreadyActiveExact", new(prior.ActivationOperationId ?? Guid.NewGuid().ToString("N"), package.PackageId, package.Version, prior.Previous, prior.ActivatedAtUtc ?? DateTimeOffset.UtcNow, prior.Generation, PackageRoot(package.PackageId),1,prior.ManifestSha256,prior.PayloadSetSha256,requirement.RosterIdentity));
|
||||
var activated = await ActivateAsync(package.PackageId, package.Version, ct);
|
||||
var after=await ReadPointer(package.PackageId,ct); return new("Activated", new(after!.ActivationOperationId!, package.PackageId, activated.CurrentVersion, activated.PreviousVersion, after.ActivatedAtUtc ?? DateTimeOffset.UtcNow, after.Generation, PackageRoot(package.PackageId),1,after.ManifestSha256,after.PayloadSetSha256,requirement.RosterIdentity));
|
||||
}
|
||||
public async Task<PackageRollbackResult> RollbackAsync(PackageActivationReceipt receipt, CancellationToken ct)
|
||||
{
|
||||
var current=await GetCurrentVersionAsync(receipt.PackageId,ct);
|
||||
var pointer=await ReadPointer(receipt.PackageId,ct); if (pointer is null || !string.Equals(current,receipt.Version,StringComparison.Ordinal) || pointer.ActivationOperationId!=receipt.OperationId || pointer.Generation!=receipt.PointerGeneration) return new("RollbackBlockedStateChanged",current);
|
||||
if (string.IsNullOrWhiteSpace(receipt.PreviousVersion)) return new("RollbackNotApplicable",current);
|
||||
var restored=await ActivateAsync(receipt.PackageId,receipt.PreviousVersion,ct);
|
||||
return new("RolledBack",restored.CurrentVersion);
|
||||
}
|
||||
public async Task<string?> RollbackAsync(string id, CancellationToken ct) { var p = await ReadPointer(id, ct); return p?.Previous is null ? null : (await ActivateAsync(id, p.Previous, ct)).CurrentVersion; }
|
||||
public async Task BindAsync(PackageRequirement requirement, string version, CancellationToken ct)
|
||||
{
|
||||
var root = VersionDirectory(requirement.PackageId, version);
|
||||
if (!Directory.Exists(root) || !string.Equals(requirement.RequiredVersion, version, StringComparison.Ordinal)) throw new InvalidOperationException("PACKAGE_REQUIREMENT_BINDING_INVALID");
|
||||
await File.WriteAllTextAsync(Path.Combine(root, StoredBindingFileName), JsonSerializer.Serialize(new CacheBinding(requirement.PackageId, requirement.Product, requirement.ProductYear, requirement.Architecture, requirement.Channel, requirement.RequiredVersion, requirement.RosterIdentity)), ct);
|
||||
}
|
||||
public async Task<CachedPackageResolution?> ResolveAsync(PackageRequirement requirement, CancellationToken ct)
|
||||
{
|
||||
var pointer = await ReadPointer(requirement.PackageId, ct);
|
||||
if (pointer is null) return null;
|
||||
var current = await VerifyVersionAsync(requirement, pointer.Current, ct);
|
||||
if (current is not null) return new(requirement.PackageId, pointer.Current, current, false, "CURRENT_VERIFIED");
|
||||
if (!requirement.AllowOfflineFallback || string.IsNullOrWhiteSpace(pointer.Previous) || string.Equals(pointer.Current, pointer.Previous, StringComparison.OrdinalIgnoreCase)) return null;
|
||||
var previous = await VerifyVersionAsync(requirement, pointer.Previous, ct);
|
||||
return previous is null ? null : new(requirement.PackageId, pointer.Previous, previous, true, "PREVIOUS_VERIFIED_AFTER_CURRENT_REJECTED");
|
||||
}
|
||||
public async Task<ActivationResult> SynchronizeAsync(PackageManifest manifest, string sourceRoot, CancellationToken ct, IProgress<PackageProgress>? progress = null)
|
||||
{
|
||||
var staging = Path.Combine(root, "Launcher", "staging", manifest.PackageId + "-" + manifest.Version + "-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(staging);
|
||||
try { var i = 0; foreach (var f in manifest.Files) { ct.ThrowIfCancellationRequested(); var source = Path.Combine(sourceRoot, f.Path); var dest = Path.GetFullPath(Path.Combine(staging, f.Path)); if (!dest.StartsWith(Path.GetFullPath(staging) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("PACKAGE_ROOT_ESCAPE"); Directory.CreateDirectory(Path.GetDirectoryName(dest)!); File.Copy(source, dest, true); if (!string.Equals(await hasher.Sha256Async(dest, ct), f.Sha256, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("HASH_MISMATCH"); progress?.Report(new("copy", ++i, manifest.Files.Count)); }
|
||||
var versionDir = Path.Combine(PackageRoot(manifest.PackageId), "versions", manifest.Version); Directory.CreateDirectory(Path.GetDirectoryName(versionDir)!); if (Directory.Exists(versionDir)) Directory.Delete(staging, true); else Directory.Move(staging, versionDir); return await ActivateAsync(manifest.PackageId, manifest.Version, ct); }
|
||||
catch { if (Directory.Exists(staging)) Directory.Delete(staging, true); throw; }
|
||||
var materialized = await MaterializeAsync(manifest, sourceRoot, ct, progress);
|
||||
return await ActivateAsync(materialized.PackageId, materialized.Version, ct);
|
||||
}
|
||||
/// <summary>Copies and commits an immutable local version; never mutates activation pointers.</summary>
|
||||
public async Task<ImmutableLocalPackage> MaterializeAsync(PackageManifest manifest, string sourceRoot, CancellationToken ct, IProgress<PackageProgress>? progress = null)
|
||||
{
|
||||
if (!new PackageManifestValidator().Validate(manifest, manifest.ProductYear, manifest.Architecture).IsValid) throw new InvalidOperationException("MANIFEST_INVALID");
|
||||
if (manifest.Files.Any(x => string.Equals(x.Path, StoredManifestFileName, StringComparison.OrdinalIgnoreCase))) throw new InvalidOperationException("MANIFEST_RESERVED_FILE");
|
||||
var operationId = Guid.NewGuid().ToString("N"); var staging = Path.Combine(root, "Launcher", "staging", operationId); Directory.CreateDirectory(staging); await File.WriteAllTextAsync(Path.Combine(staging, ".package-staging-owner.json"), JsonSerializer.Serialize(new PackageStagingOwnership(operationId, manifest.PackageId, manifest.Version, staging, DateTimeOffset.UtcNow, operationId)), ct); (faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.AfterStagingAllocated);
|
||||
try { var i = 0; foreach (var f in manifest.Files) { ct.ThrowIfCancellationRequested(); (faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.DuringPayloadCopy); var source = Path.Combine(sourceRoot, f.Path); var dest = PackageFilePath(staging, f.Path); Directory.CreateDirectory(Path.GetDirectoryName(dest)!); File.Copy(source, dest, true); if (!string.Equals(await hasher.Sha256Async(dest, ct), f.Sha256, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("HASH_MISMATCH"); progress?.Report(new("copy", ++i, manifest.Files.Count)); }
|
||||
await File.WriteAllTextAsync(Path.Combine(staging, StoredManifestFileName), JsonSerializer.Serialize(manifest), ct);
|
||||
File.Delete(Path.Combine(staging, ".package-staging-owner.json")); var versionDir = VersionDirectory(manifest.PackageId, manifest.Version); Directory.CreateDirectory(Path.GetDirectoryName(versionDir)!); (faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.BeforeImmutableCommit); if (Directory.Exists(versionDir)) Directory.Delete(staging, true); else Directory.Move(staging, versionDir); return new(manifest.PackageId, manifest.Version, versionDir); }
|
||||
catch { await CleanupOwnedStagingAsync(new PackageStagingOwnership(operationId, manifest.PackageId, manifest.Version, staging, DateTimeOffset.UtcNow, operationId), CancellationToken.None); throw; }
|
||||
}
|
||||
public async Task<PackageStagingCleanupResult> CleanupOwnedStagingAsync(PackageStagingOwnership ownership, CancellationToken ct)
|
||||
{
|
||||
var stagingRoot=Path.GetFullPath(Path.Combine(root,"Launcher","staging")); var directory=Path.GetFullPath(ownership.StagingDirectory); var prefix=stagingRoot.TrimEnd(Path.DirectorySeparatorChar)+Path.DirectorySeparatorChar;
|
||||
if (!directory.StartsWith(prefix,StringComparison.OrdinalIgnoreCase) || directory.Equals(stagingRoot,StringComparison.OrdinalIgnoreCase)) return new("OwnershipUnknown",ownership.OperationId);
|
||||
if (!Directory.Exists(directory)) return new("AlreadyAbsent",ownership.OperationId);
|
||||
var marker=Path.Combine(directory,".package-staging-owner.json"); if (!File.Exists(marker)) return new("OwnershipUnknown",ownership.OperationId);
|
||||
var stored=JsonSerializer.Deserialize<PackageStagingOwnership>(await File.ReadAllTextAsync(marker,ct)); if (stored is null || stored.OperationId!=ownership.OperationId || stored.PackageId!=ownership.PackageId || stored.Version!=ownership.Version) return new("OwnershipMismatch",ownership.OperationId);
|
||||
Directory.Delete(directory,true); return new("Cleaned",ownership.OperationId);
|
||||
}
|
||||
private async Task<string?> VerifyVersionAsync(PackageRequirement requirement, string version, CancellationToken ct)
|
||||
{
|
||||
string versionRoot;
|
||||
try { versionRoot = VersionDirectory(requirement.PackageId, version); }
|
||||
catch { return null; }
|
||||
var manifestPath = Path.Combine(versionRoot, StoredManifestFileName);
|
||||
var bindingPath = Path.Combine(versionRoot, StoredBindingFileName);
|
||||
if (!Directory.Exists(versionRoot) || new DirectoryInfo(versionRoot).Attributes.HasFlag(FileAttributes.ReparsePoint) || !File.Exists(manifestPath) || !File.Exists(bindingPath) || new FileInfo(manifestPath).Attributes.HasFlag(FileAttributes.ReparsePoint) || new FileInfo(bindingPath).Attributes.HasFlag(FileAttributes.ReparsePoint)) return null;
|
||||
PackageManifest manifest;
|
||||
CacheBinding binding;
|
||||
try { manifest = PackageManifestJson.Parse(await File.ReadAllTextAsync(manifestPath, ct)); binding = JsonSerializer.Deserialize<CacheBinding>(await File.ReadAllTextAsync(bindingPath, ct))!; }
|
||||
catch { return null; }
|
||||
if (binding is null || !Matches(requirement, binding) || !string.Equals(manifest.PackageId, requirement.PackageId, StringComparison.Ordinal) || !string.Equals(manifest.Version, version, StringComparison.Ordinal) || !string.Equals(manifest.Product, requirement.Product, StringComparison.Ordinal) || manifest.ProductYear != requirement.ProductYear || !string.Equals(manifest.Architecture, requirement.Architecture, StringComparison.OrdinalIgnoreCase) || !string.Equals(manifest.RolloutChannel, requirement.Channel, StringComparison.OrdinalIgnoreCase) || !string.Equals(manifest.Version, requirement.RequiredVersion, StringComparison.Ordinal) || !new PackageManifestValidator().Validate(manifest, requirement.ProductYear, requirement.Architecture).IsValid) return null;
|
||||
foreach (var file in manifest.Files)
|
||||
{
|
||||
var path = PackageFilePath(versionRoot, file.Path);
|
||||
var info = new FileInfo(path);
|
||||
if (!info.Exists || info.Attributes.HasFlag(FileAttributes.ReparsePoint) || info.Length != file.Length || !string.Equals(await hasher.Sha256Async(path, ct), file.Sha256, StringComparison.OrdinalIgnoreCase)) return null;
|
||||
}
|
||||
return versionRoot;
|
||||
}
|
||||
private async Task<(string Manifest,string PayloadSet)> ContentFingerprintAsync(string id,string version,CancellationToken ct)
|
||||
{ var dir=VersionDirectory(id,version); var manifestPath=Path.Combine(dir,StoredManifestFileName); var manifest=PackageManifestJson.Parse(await File.ReadAllTextAsync(manifestPath,ct)); var manifestHash=await hasher.Sha256Async(manifestPath,ct); var payload=string.Join("|",manifest.Files.OrderBy(x=>x.Path,StringComparer.Ordinal).Select(x=>$"{x.Path}:{x.Length}:{x.Sha256}")); return (manifestHash,Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(payload)))); }
|
||||
private async Task<bool> MatchesImmutableManifestAsync(ImmutableLocalPackage package,PackageRequirement requirement,CancellationToken ct)
|
||||
{ try { if (!package.LocalPackageRoot.Equals(VersionDirectory(package.PackageId,package.Version),StringComparison.OrdinalIgnoreCase)) return false; var manifest=PackageManifestJson.Parse(await File.ReadAllTextAsync(Path.Combine(package.LocalPackageRoot,StoredManifestFileName),ct)); if (!string.Equals(manifest.PackageId,requirement.PackageId,StringComparison.Ordinal)||!string.Equals(manifest.Version,requirement.RequiredVersion,StringComparison.Ordinal)||!string.Equals(manifest.Product,requirement.Product,StringComparison.Ordinal)||manifest.ProductYear!=requirement.ProductYear||!string.Equals(manifest.Architecture,requirement.Architecture,StringComparison.OrdinalIgnoreCase)||!string.Equals(manifest.RolloutChannel,requirement.Channel,StringComparison.OrdinalIgnoreCase)) return false; foreach(var f in manifest.Files){var p=PackageFilePath(package.LocalPackageRoot,f.Path); var i=new FileInfo(p); if(!i.Exists||i.Length!=f.Length||!string.Equals(await hasher.Sha256Async(p,ct),f.Sha256,StringComparison.OrdinalIgnoreCase)) return false;} return true; } catch { return false; } }
|
||||
private static string PackageFilePath(string packageRoot, string relativePath)
|
||||
{
|
||||
var root = Path.GetFullPath(packageRoot).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
var path = Path.GetFullPath(Path.Combine(root, relativePath));
|
||||
if (!path.StartsWith(root, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("PACKAGE_ROOT_ESCAPE");
|
||||
return path;
|
||||
}
|
||||
private string VersionDirectory(string packageId, string version)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(version) || !string.Equals(Path.GetFileName(version), version, StringComparison.Ordinal)) throw new InvalidOperationException("PACKAGE_VERSION_INVALID");
|
||||
return PackageFilePath(Path.Combine(PackageRoot(packageId), "versions"), version);
|
||||
}
|
||||
private static bool Matches(PackageRequirement requirement, CacheBinding binding) => string.Equals(requirement.PackageId, binding.PackageId, StringComparison.Ordinal) && string.Equals(requirement.Product, binding.Product, StringComparison.Ordinal) && requirement.ProductYear == binding.ProductYear && string.Equals(requirement.Architecture, binding.Architecture, StringComparison.OrdinalIgnoreCase) && string.Equals(requirement.Channel, binding.Channel, StringComparison.OrdinalIgnoreCase) && string.Equals(requirement.RequiredVersion, binding.RequiredVersion, StringComparison.Ordinal) && string.Equals(requirement.RosterIdentity, binding.RosterIdentity, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@ public sealed class PilotExecutionService(
|
||||
IStartupScriptBuilder scriptBuilder,
|
||||
IExecutionPermitService permits,
|
||||
IAutoCadProcessProbe probe,
|
||||
IOperatingSystemProcessInvoker invoker) : IPilotExecutionService
|
||||
IOperatingSystemProcessInvoker invoker,
|
||||
IPathResolver? pathResolver = null) : IPilotExecutionService
|
||||
{
|
||||
private sealed record Pointer(string Version);
|
||||
private sealed record ActivationSnapshot(
|
||||
@@ -25,6 +26,7 @@ public sealed class PilotExecutionService(
|
||||
int SchemaVersion, string SessionId, string TransactionId, string State,
|
||||
string PackageId, string PackageVersion, string ManifestSha256,
|
||||
string PilotRoot, string VersionDirectory, string StartupScriptPath,
|
||||
string LaunchMode, IReadOnlyDictionary<string, string?> ResolvedPaths, IReadOnlyList<string> AcadArguments, string? ActualStartupScriptSha256,
|
||||
string ActivationSnapshotPath, int? ProcessId, DateTimeOffset? ProcessStartUtc,
|
||||
int? ProcessExitCode, DateTimeOffset CreatedAtUtc, DateTimeOffset UpdatedAtUtc,
|
||||
string? LastErrorCode, string? LastErrorMessage);
|
||||
@@ -32,28 +34,30 @@ public sealed class PilotExecutionService(
|
||||
public async Task<PilotPreparationResult> PrepareAsync(PilotPreparationRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var root = rootPolicy.Validate(request.PilotRoot);
|
||||
var resolver = pathResolver ?? new PathResolver();
|
||||
var paths = resolver.Resolve(new LauncherPathVariables(request.Configuration.PathVariables), [LauncherPathVariableNames.LocalLauncherRoot, LauncherPathVariableNames.AcadExecutable]);
|
||||
if (!string.Equals(Path.GetFullPath(request.PilotRoot), paths.LocalLauncherRoot, StringComparison.OrdinalIgnoreCase))
|
||||
return new(null, new(false, ["PILOT_ROOT_PATH_VARIABLE_MISMATCH"]));
|
||||
var root = rootPolicy.Validate(paths.LocalLauncherRoot);
|
||||
var validation = validator.Validate(request.Manifest, 2026, "x64");
|
||||
var hashes = await ValidateSource(request.Manifest, request.PackageSourceRoot, cancellationToken);
|
||||
var product = request.Product with { ExecutablePath = paths.AcadExecutable };
|
||||
var profile = new AutoCadProfileCandidate(string.Empty, false, true, ["Confirmed Plant 3D launch contract has no /p argument."]);
|
||||
var safety = gate.Evaluate(new(request.Configuration, request.Product, profile, root, validation, hashes, false, probe.IsAcadRunning()), false);
|
||||
var safety = gate.Evaluate(new(request.Configuration, product, profile, root, validation, hashes, false, probe.IsAcadRunning()), false);
|
||||
if (!safety.IsAllowed) return new(null, safety);
|
||||
|
||||
var futureEntry = Path.Combine(request.PilotRoot, "packages", request.Manifest.PackageId, "versions", request.Manifest.Version, request.Manifest.EntryAssembly);
|
||||
var fixture = request.Configuration.AutomationMode is null ? null : ReadFixtureOrtho(request.Configuration.AutomationConfigurationPath);
|
||||
var content = request.Configuration.AutomationMode is null
|
||||
? $"_.NETLOAD\n\"{futureEntry.Replace("\"", "\\\"")}\"\n"
|
||||
: $"_.OPEN\n\"{fixture!.Replace("\"", "\\\"")}\"\n_.NETLOAD\n\"{futureEntry.Replace("\"", "\\\"")}\"\nPHP_REC_AUTOMATION_PREPARE\nPXP-OA\nPXP-OA\nPHP_REC_AUTOMATION_FINALIZE\n";
|
||||
var automation = request.Configuration.LaunchMode is AcadLaunchMode.NextPilotPreview or AcadLaunchMode.NextPilotApply or AcadLaunchMode.LifecycleProbe;
|
||||
var content = automation ? "AUTOMATION_SCRIPT_PENDING" : "PRODUCTION_NO_SCRIPT";
|
||||
var scriptHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content)));
|
||||
var plan = PilotPlanFactory.Create(request.Product, profile, request.Manifest, request.ManifestHash, request.PilotRoot, request.StartupScriptPath, scriptHash, safety);
|
||||
var plan = PilotPlanFactory.Create(product, profile, request.Manifest, request.ManifestHash, paths.LocalLauncherRoot, automation ? paths.Require(LauncherPathVariableNames.AutomationScriptPath) : string.Empty, scriptHash, safety, request.Configuration.LaunchMode);
|
||||
var environment = new Dictionary<string, string> { ["P_REC_ANNOT_RUNTIME_MODE"] = request.Configuration.PluginExecutionMode.ToString() };
|
||||
if (!string.IsNullOrWhiteSpace(request.Configuration.ApplyPermitPath))
|
||||
environment["P_REC_ANNOT_APPLY_PERMIT_PATH"] = request.Configuration.ApplyPermitPath;
|
||||
if (request.Configuration.AutomationMode is not null)
|
||||
if (automation)
|
||||
{
|
||||
environment["P_REC_ANNOT_LAUNCHER_MODE"] = "NextPilot";
|
||||
environment["P_REC_ANNOT_AUTOMATION"] = request.Configuration.AutomationMode;
|
||||
environment["P_REC_ANNOT_AUTOMATION_CONFIG"] = request.Configuration.AutomationConfigurationPath!;
|
||||
environment["P_REC_ANNOT_AUTOMATION"] = request.Configuration.AutomationMode!;
|
||||
environment["P_REC_ANNOT_AUTOMATION_CONFIG"] = paths.Require(LauncherPathVariableNames.AutomationConfigPath);
|
||||
}
|
||||
return new(plan with { Process = plan.Process with { EnvironmentVariables = environment } }, safety);
|
||||
}
|
||||
@@ -61,6 +65,8 @@ public sealed class PilotExecutionService(
|
||||
public async Task<PilotExecutionResult> ExecuteAsync(PilotExecutionRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var plan = request.Plan;
|
||||
var resolver = pathResolver ?? new PathResolver();
|
||||
var resolvedPaths = resolver.Resolve(new LauncherPathVariables(request.Preparation.Configuration.PathVariables));
|
||||
var root = rootPolicy.Validate(request.Preparation.PilotRoot);
|
||||
var validation = validator.Validate(request.Preparation.Manifest, 2026, "x64");
|
||||
var hashes = await ValidateSource(request.Preparation.Manifest, request.Preparation.PackageSourceRoot, cancellationToken);
|
||||
@@ -68,7 +74,7 @@ public sealed class PilotExecutionService(
|
||||
new AutoCadProfileCandidate(string.Empty, false, true, ["No profile in confirmed contract."]), root, validation, hashes, false, probe.IsAcadRunning());
|
||||
var decision = gate.Evaluate(context, true);
|
||||
if (!decision.IsAllowed) return new(false, null, decision.Violations.First(), null, null);
|
||||
if (!string.Equals(plan.PlanId, PilotPlanFactory.Create(request.Preparation.Product, context.Profile!, request.Preparation.Manifest, request.Preparation.ManifestHash, request.Preparation.PilotRoot, request.Preparation.StartupScriptPath, plan.StartupScriptSha256, plan.SafetyDecision).PlanId, StringComparison.Ordinal))
|
||||
if (!string.Equals(plan.PlanId, PilotPlanFactory.Create(plan.Product, context.Profile!, request.Preparation.Manifest, request.Preparation.ManifestHash, plan.PilotRoot, plan.StartupScriptPath, plan.StartupScriptSha256, plan.SafetyDecision, request.Preparation.Configuration.LaunchMode).PlanId, StringComparison.Ordinal))
|
||||
return new(false, null, "PILOT_PLAN_CHANGED", null, null);
|
||||
|
||||
var sessionId = Guid.NewGuid().ToString("N");
|
||||
@@ -76,7 +82,7 @@ public sealed class PilotExecutionService(
|
||||
var packageRoot = Path.Combine(plan.PilotRoot, "packages", plan.PackageId);
|
||||
var versionDirectory = plan.VersionDirectory;
|
||||
var staging = Path.Combine(plan.PilotRoot, "staging", transactionId);
|
||||
var scriptPath = Path.Combine(plan.PilotRoot, "startup", sessionId + ".scr");
|
||||
var scriptPath = plan.StartupScriptPath;
|
||||
var snapshotPath = Path.Combine(plan.PilotRoot, "state", "transactions", transactionId + ".json");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(snapshotPath)!);
|
||||
var currentPath = Path.Combine(packageRoot, "current.json");
|
||||
@@ -85,7 +91,7 @@ public sealed class PilotExecutionService(
|
||||
var previous = await ReadTextOrNull(previousPath, cancellationToken);
|
||||
var snapshot = new ActivationSnapshot(transactionId, plan.PackageId, plan.PackageVersion, current, Hash(current), previous, Hash(previous), versionDirectory, scriptPath, staging);
|
||||
await WriteJsonAtomic(snapshotPath, snapshot, cancellationToken);
|
||||
await WriteSession(plan.PilotRoot, new PersistedSession(1, sessionId, transactionId, "Activating", plan.PackageId, plan.PackageVersion, plan.ManifestSha256, plan.PilotRoot, versionDirectory, scriptPath, snapshotPath, null, null, null, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, null, null), cancellationToken);
|
||||
await WriteSession(plan.PilotRoot, new PersistedSession(2, sessionId, transactionId, "Activating", plan.PackageId, plan.PackageVersion, plan.ManifestSha256, plan.PilotRoot, versionDirectory, scriptPath, request.Preparation.Configuration.LaunchMode.ToString(), resolvedPaths.Values, plan.Process.Arguments, null, snapshotPath, null, null, null, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, null, null), cancellationToken);
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(staging);
|
||||
@@ -103,13 +109,20 @@ public sealed class PilotExecutionService(
|
||||
if (Directory.Exists(versionDirectory)) Directory.Delete(staging, true); else Directory.Move(staging, versionDirectory);
|
||||
await WriteTextAtomic(previousPath, current ?? string.Empty, cancellationToken, current is not null);
|
||||
await WriteTextAtomic(currentPath, JsonSerializer.Serialize(new Pointer(plan.PackageVersion)), cancellationToken, true);
|
||||
var automation = request.Preparation.Configuration.LaunchMode is AcadLaunchMode.NextPilotPreview or AcadLaunchMode.NextPilotApply or AcadLaunchMode.LifecycleProbe;
|
||||
if (automation)
|
||||
{
|
||||
var paths = resolver.Resolve(new LauncherPathVariables(request.Preparation.Configuration.PathVariables), [LauncherPathVariableNames.AutomationScriptPath, LauncherPathVariableNames.FixtureOrthoDwg, LauncherPathVariableNames.FixtureModelDwg, LauncherPathVariableNames.LocalSessionRoot]);
|
||||
scriptPath = paths.Require(LauncherPathVariableNames.AutomationScriptPath);
|
||||
if (!Path.GetFullPath(scriptPath).StartsWith(Path.GetFullPath(paths.Require(LauncherPathVariableNames.LocalSessionRoot)).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("AUTOMATION_SCRIPT_OUTSIDE_SESSION_ROOT");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(scriptPath)!);
|
||||
var script = request.Preparation.Configuration.AutomationMode is null
|
||||
? scriptBuilder.BuildNetloadScript(versionDirectory, request.Preparation.Manifest.EntryAssembly)
|
||||
: scriptBuilder.BuildAutomationScript(versionDirectory, request.Preparation.Manifest.EntryAssembly, ReadFixtureOrtho(request.Preparation.Configuration.AutomationConfigurationPath));
|
||||
var script = scriptBuilder.BuildAutomationScript(new(versionDirectory, request.Preparation.Manifest.EntryAssembly, paths.Require(LauncherPathVariableNames.FixtureOrthoDwg), paths.Require(LauncherPathVariableNames.FixtureModelDwg), scriptPath, request.Preparation.Configuration.AutomationDiagnostic, request.Preparation.Configuration.LaunchMode == AcadLaunchMode.NextPilotApply));
|
||||
await File.WriteAllTextAsync(scriptPath, script, cancellationToken);
|
||||
await WriteSession(plan.PilotRoot, (await ReadSession(plan.PilotRoot, cancellationToken))! with { State = "ProcessStarting", UpdatedAtUtc = DateTimeOffset.UtcNow }, cancellationToken);
|
||||
var result = new GuardedAutodeskProcessLauncher(gate, probe, invoker).Start(context, plan with { StartupScriptPath = scriptPath, Process = plan.Process with { Arguments = ["/product", "PLNT3D", "/language", "ru-RU", "/b", scriptPath], WorkingDirectory = plan.PilotRoot } }, request.Permit, permits);
|
||||
}
|
||||
var arguments = new AcadArgumentBuilder().Build(request.Preparation.Configuration.LaunchMode, automation ? scriptPath : null);
|
||||
var actualScriptHash = automation ? Convert.ToHexString(SHA256.HashData(await File.ReadAllBytesAsync(scriptPath, cancellationToken))) : null;
|
||||
await WriteSession(plan.PilotRoot, (await ReadSession(plan.PilotRoot, cancellationToken))! with { State = "ProcessStarting", StartupScriptPath = automation ? scriptPath : string.Empty, AcadArguments = arguments, ActualStartupScriptSha256 = actualScriptHash, UpdatedAtUtc = DateTimeOffset.UtcNow }, cancellationToken);
|
||||
var result = new GuardedAutodeskProcessLauncher(gate, probe, invoker).Start(context, plan with { StartupScriptPath = automation ? scriptPath : string.Empty, Process = plan.Process with { Arguments = arguments, WorkingDirectory = plan.PilotRoot } }, request.Permit, permits);
|
||||
if (!result.Started)
|
||||
{
|
||||
await MarkFailed(plan.PilotRoot, result.BlockCode ?? "PROCESS_START_FAILED", cancellationToken);
|
||||
|
||||
@@ -3,3 +3,47 @@
|
||||
Before changing package pointers, Launcher Next writes an activation snapshot containing exact previous `current.json` and `previous.json` contents and their SHA-256 values. Files are copied to a unique staging directory and rehashed. Only then is staging moved to `packages/<package>/versions/<version>` and the package-local pointer updated atomically.
|
||||
|
||||
Rollback restores the exact pointers, deletes the pilot version and startup script, and removes incomplete staging. Autodesk assemblies, PDBs, executables, test assemblies and legacy files are rejected from the pilot package.
|
||||
|
||||
## Last-known-good resolution
|
||||
|
||||
Each activated version retains its validated package manifest alongside its payload.
|
||||
`PackageResolutionUseCase` receives an explicit roster requirement: package ID,
|
||||
product/year/architecture, channel, exact version, roster identity and offline
|
||||
policy. On an online acquisition it stages locally, binds that requirement to the
|
||||
activated version, then revalidates through `LocalPackageStore.ResolveAsync`.
|
||||
|
||||
The resolver checks manifest, binding, identity/product compatibility, entry and
|
||||
all payload lengths/SHA-256 before returning a local version. It returns current
|
||||
first. Only when current is invalid and the requirement explicitly permits offline
|
||||
fallback can it return previous. A cache entry with another roster, channel or
|
||||
version is rejected. It never reads or executes a package from DFS, and a failed
|
||||
or corrupted update never becomes last-known-good.
|
||||
|
||||
Resolution emits stable evidence codes: `ONLINE_PACKAGE_SELECTED`,
|
||||
`VALID_CURRENT_CACHE_SELECTED`, `PREVIOUS_LKG_SELECTED`,
|
||||
`OFFLINE_FALLBACK_DISABLED`, and `NO_SAFE_PACKAGE_AVAILABLE`.
|
||||
|
||||
## Roster-to-package plan
|
||||
|
||||
`RosterAdapter` keeps source formats outside the package domain. It selects,
|
||||
in order, a project `roster.json`, a legacy `download_list.xml`, then an
|
||||
explicit central default; malformed project JSON is a block, not an implicit
|
||||
fallback. JSON roster version 1 requires a stable roster ID, product, year,
|
||||
architecture, explicit offline policy and unique package IDs. Legacy XML disables
|
||||
DTD/external entities and accepts only proven `Plugin` fields
|
||||
(`id`, `version`, optional `channel` and `install`).
|
||||
|
||||
`RosterPackagePlanUseCase` converts enabled records into deterministic,
|
||||
load-order-sorted requirements and resolves every one before launch preparation.
|
||||
It emits roster/source, requirement and plan-completed/blocked evidence. The
|
||||
filesystem catalog adapter returns a descriptor for acquisition only; local
|
||||
immutable staging remains the only active package root. These adapters and the
|
||||
controlled local catalog scenarios are verified by tests; a corporate DFS pilot
|
||||
and Autodesk launch with a real roster are not runtime verified.
|
||||
|
||||
Catalog acquisition is bounded before staging: the configured catalog root and
|
||||
package root must not be reparse points, the descriptor must remain below the
|
||||
configured root, manifests may contain at most 256 files and 512 MiB declared
|
||||
payload, and absolute or traversal payload paths are rejected. A corporate pilot
|
||||
requires explicit configured roster and catalog roles; absent roles block before
|
||||
any corporate payload is read.
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Launcher path resolution
|
||||
|
||||
`PathResolver` is the only resolver for launcher path variables. Configuration
|
||||
is layered in this order: `launcher.session.json`, `launcher.machine.json`,
|
||||
then `launcher.defaults.json`; built-in defaults are non-path safety defaults.
|
||||
|
||||
Corporate and local locations are variables, not source-code paths. The
|
||||
supported variable names are: `CorporateDfsRoot`, `CorporateLauncherRoot`,
|
||||
`CorporateCatalogRoot`, `CorporatePackagesRoot`, `CorporateProfilesRoot`,
|
||||
`CorporatePalettesRoot`, `CorporateFontsRoot`, `CorporateTemplatesRoot`,
|
||||
`CorporateScriptsRoot`, `ProjectName`, `DisciplineName`, `ProjectRoot`,
|
||||
`DisciplineRoot`, `PlantProjectFile`, `ProjectRosterPath`,
|
||||
`ProjectApplicationsRoot`, `ProjectPaletteOverlayRoot`, `LocalLauncherRoot`,
|
||||
`LocalPackageCache`, `LocalActiveRoot`, `LocalSessionRoot`,
|
||||
`LocalEvidenceRoot`, `LocalLogsRoot`, `LocalRollbackRoot`, `AcadExecutable`,
|
||||
`PRecEntryAssembly`, `FixtureOrthoDwg`, `AutomationScriptPath`,
|
||||
`AutomationConfigPath`, `AutomationEvidencePath`, and `AutomationPermitPath`.
|
||||
|
||||
Undefined corporate locations remain `null` / `NOT VERIFIED`; the resolver does
|
||||
not invent a DFS hierarchy. The legacy `A:` drive is accepted only as input and
|
||||
is canonicalized through configured `CorporateDfsRoot`; it is never emitted.
|
||||
|
||||
`Production` launch arguments are `/product PLNT3D /language ru-RU` only.
|
||||
`NextPilotPreview`, `NextPilotApply`, and `LifecycleProbe` require a resolved
|
||||
script beneath the local session root and add exactly one `/b` argument.
|
||||
@@ -2,6 +2,6 @@
|
||||
|
||||
The controlled pilot flow is: CLI `pilot run` → local `pilot.local.json` → manifest generation and SHA-256 validation → `PrepareAsync` → one-time in-memory permit → `ExecuteAsync` → staging → version activation → startup SCR → guarded `Process.Start` → wait for the specific AutoCAD PID → `RollbackAsync`.
|
||||
|
||||
The pilot uses only `C:\AutoCAD User Support\LauncherNextPilot`. It does not modify the legacy launcher, AutoCAD profiles, registry, trusted paths, or the legacy Applications directory.
|
||||
The pilot uses only `${LocalLauncherRoot}` resolved from local configuration. It does not modify the legacy launcher, AutoCAD profiles, registry, trusted paths, or the legacy Applications directory.
|
||||
|
||||
The runtime command is `acad.exe /product PLNT3D /language ru-RU /b <startup-script>`; no profile argument is supplied. The SCR invokes exactly one `NETLOAD`, for the local versioned entry assembly only.
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"nextPilotModeImplemented":true,"pilotConfigurationImplemented":true,"pilotRootPolicyImplemented":true,"productDiscoveryImplemented":true,"profileDiscoveryImplemented":true,"safetyGateImplemented":true,"prepareImplemented":true,"executionPermitImplemented":true,"isolatedStoreImplemented":true,"atomicActivationImplemented":true,"rollbackImplemented":true,"startupScriptLifecycleImplemented":true,"realProcessAdapterCompiled":true,"realExecutionReleaseEnabled":false,"actualProcessStarted":false,"actualPilotRootCreated":false,"actualPackageActivated":false,"productionDeploy":false}
|
||||
{"nextPilotModeImplemented":true,"pilotConfigurationImplemented":true,"pilotRootPolicyImplemented":true,"productDiscoveryImplemented":true,"profileDiscoveryImplemented":true,"safetyGateImplemented":true,"prepareImplemented":true,"executionPermitImplemented":true,"isolatedStoreImplemented":true,"atomicActivationImplemented":true,"rollbackImplemented":true,"offlineResolutionImplemented":true,"offlineResolutionVerifiedByTests":true,"productionCatalogOutageRuntimeVerified":false,"startupScriptLifecycleImplemented":true,"realProcessAdapterCompiled":true,"realExecutionReleaseEnabled":false,"actualProcessStarted":false,"actualPilotRootCreated":false,"actualPackageActivated":false,"productionDeploy":false}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"pilotTooling": "VERIFIED_BY_TESTS",
|
||||
"realCorporateRosterParsing": "BLOCKED",
|
||||
"realCorporatePackageAcquisition": "BLOCKED",
|
||||
"blockReason": "CORPORATE_CATALOG_AND_ROSTER_PATHS_NOT_CONFIGURED",
|
||||
"localImmutableStaging": "VERIFIED_BY_TESTS",
|
||||
"controlledOutageRecovery": "VERIFIED_BY_TESTS",
|
||||
"productionActivation": "NOT_RUN",
|
||||
"autodeskRuntime": "NOT_RUN",
|
||||
"productionReadiness": "NOT_ACHIEVED",
|
||||
"redaction": "No corporate paths, roster contents, package payload, session identity, or machine data were recorded."
|
||||
}
|
||||
+6
-1
@@ -1,6 +1,11 @@
|
||||
# Foundation scope
|
||||
|
||||
Launcher Next is intentionally non-deploying. Package synchronization accepts only a supplied local test source; no production UNC client is wired. Process launch preparation is dry-run only, and the WPF launch button is disabled. The next authorized slice is registry discovery validation and a non-production package catalog pilot.
|
||||
Launcher Next is intentionally non-deploying. Package synchronization accepts only a supplied local test source; no production UNC client is wired. Process launch preparation is dry-run only, and the WPF launch button is disabled.
|
||||
|
||||
Package selection now has a product-neutral orchestration contract: an explicit
|
||||
roster requirement is resolved through catalog acquisition/local staging, or—only
|
||||
with explicit policy—through a fully revalidated current/previous local cache.
|
||||
This is verified by local integration tests, not by a production catalog outage.
|
||||
|
||||
`tests/Launcher.Next.Tests/fixtures/p-rec-annot-pilot.manifest.json` is a synthetic P_Rec_Annot contract. Its all-zero hash is deliberately replaced by a hash of a test-created one-byte placeholder; it does not identify or package a real Autodesk plugin.
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
# Pilot local configuration
|
||||
|
||||
Local file: `C:\AutoCAD User Support\LauncherNextPilot\config\pilot.local.json`. It is never auto-created or tracked. Unknown/corrupt configuration blocks pilot mode.
|
||||
Configuration files are `${LocalLauncherRoot}\config\launcher.defaults.json`,
|
||||
`launcher.machine.json`, and `launcher.session.json`. They are never
|
||||
auto-created or tracked. Unknown/corrupt configuration blocks pilot mode;
|
||||
session values take precedence over machine values, which take precedence over
|
||||
corporate defaults.
|
||||
|
||||
```json
|
||||
{"schemaVersion":1,"launcherMode":"NextPilot","pilotEnabled":true,"allowProcessStart":false,"requireExplicitExecute":true,"pluginExecutionMode":"Smoke","pilotRoot":"C:\\AutoCAD User Support\\LauncherNextPilot","selectedProductId":null,"selectedProfileName":null,"executionPermitLifetimeSeconds":300}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"mode": "LegacyConfigurationProbe",
|
||||
"settingsXml": "${LEGACY_PROBE_ROOT}\\Settings.xml",
|
||||
"downloadListXml": "${LEGACY_PROBE_ROOT}\\download_list.xml",
|
||||
"compatibilityOverlay": "${LEGACY_PROBE_ROOT}\\legacy-package-overlay.json",
|
||||
"selectedPattern": "Exact Pattern Name",
|
||||
"evidenceOutputRoot": "${LEGACY_PROBE_EVIDENCE_ROOT}"
|
||||
}
|
||||
@@ -19,7 +19,7 @@ public sealed class ControlledExecutionTests
|
||||
[Fact] public void ForceAfterEvidence_RequiresTestAutomation()
|
||||
{ var c=new PilotLocalConfiguration { SchemaVersion=1,LauncherMode=LauncherMode.NextPilot,PilotEnabled=true,AllowProcessStart=true,RequireExplicitExecute=true,PluginExecutionMode=PluginExecutionMode.DryRun,ForceAfterEvidence=true }; var d=new NextPilotSafetyGate().Evaluate(new(c,new("C:\\acad.exe","2026","x64",[]),new("Pilot",false,true,["registry"]),new(true,"C:\\AutoCAD User Support\\LauncherNextPilot",[]),ValidationResult.Success,true,false,false),true); Assert.Contains("FORCED_CLEANUP_TEST_AUTOMATION_REQUIRED",d.Violations); }
|
||||
[Fact] public void ForceAfterEvidence_WithLocalAutomationConfiguration_IsAllowed()
|
||||
{ var c=new PilotLocalConfiguration { SchemaVersion=1,LauncherMode=LauncherMode.NextPilot,PilotEnabled=true,AllowProcessStart=true,RequireExplicitExecute=true,PluginExecutionMode=PluginExecutionMode.DryRun,ForceAfterEvidence=true,AutomationMode="FirstLimitedApplyRuntimeTest",AutomationConfigurationPath="C:\\test\\automation.json" }; var d=new NextPilotSafetyGate().Evaluate(new(c,new("C:\\acad.exe","2026","x64",[]),new("Pilot",false,true,["registry"]),new(true,"C:\\AutoCAD User Support\\LauncherNextPilot",[]),ValidationResult.Success,true,false,false),true); Assert.True(d.IsAllowed); }
|
||||
{ var c=new PilotLocalConfiguration { SchemaVersion=1,LauncherMode=LauncherMode.NextPilot,PilotEnabled=true,AllowProcessStart=true,RequireExplicitExecute=true,PluginExecutionMode=PluginExecutionMode.DryRun,ForceAfterEvidence=true,AutomationMode="FirstLimitedApplyRuntimeTest",AutomationConfigurationPath="C:\\test\\automation.json",FixtureMarkerPath="C:\\test\\fixture.marker",LaunchMode=AcadLaunchMode.NextPilotPreview }; var d=new NextPilotSafetyGate().Evaluate(new(c,new("C:\\acad.exe","2026","x64",[]),new("Pilot",false,true,["registry"]),new(true,"C:\\AutoCAD User Support\\LauncherNextPilot",[]),ValidationResult.Success,true,false,false),true); Assert.True(d.IsAllowed); }
|
||||
[Fact] public void Permit_IsOneTime()
|
||||
{ var p=Plan(); var service=new ExecutionPermitService(); var permit=service.Issue(p,60); Assert.True(service.Validate(permit,p,DateTimeOffset.UtcNow)); Assert.False(service.Validate(permit,p,DateTimeOffset.UtcNow)); }
|
||||
[Fact] public void GuardedLauncher_RealExecutionReleaseDisabled_BlocksStart()
|
||||
|
||||
@@ -7,9 +7,12 @@ namespace Launcher.Next.Tests;
|
||||
|
||||
public sealed class FoundationTests
|
||||
{
|
||||
private static PackageManifest Manifest(params PackageFile[] files) => new(1, "P_Rec_Annot", "2026.1.0", "Plant3D", 2026, "x64", "PRecAnnot.Plant3D2026.dll", files, new("netload", "PHP_REC_ANNOT"));
|
||||
private static PackageManifest Manifest(params PackageFile[] files) => new(1, "P_Rec_Annot", "2026.1.0", "Plant3D", 2026, "x64", "PRecAnnot.Plant3D2026.dll", files, new("netload", "PHP_REC_ANNOT"), RolloutChannel: "pilot");
|
||||
private static PackageRequirement Requirement(string version = "2026.1.0", bool offline = true, string product = "Plant3D", int year = 2026, string architecture = "x64", string channel = "pilot", string roster = "project-a") => new("P_Rec_Annot", product, year, architecture, channel, version, roster, offline);
|
||||
private static PackageFile Package(string path, string hash = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") => new(path, 1, hash);
|
||||
[Fact] public void Manifest_ValidPackage_IsAccepted() => Assert.True(new PackageManifestValidator().Validate(Manifest(Package("PRecAnnot.Plant3D2026.dll")), 2026, "x64").IsValid);
|
||||
[Fact] public void Manifest_DirectValidator_RejectsUnknownProduct() => Assert.False(new PackageManifestValidator().Validate(Manifest(Package("PRecAnnot.Plant3D2026.dll")) with { Product = "UnknownProduct" }, 2026, "x64").IsValid);
|
||||
[Fact] public void StructuralManifestValidator_AllowsExplicitNonPlantProductStructure() => Assert.True(new StructuralPackageManifestValidator().Validate(Manifest(Package("PRecAnnot.Plant3D2026.dll")) with { Product = "Revit" }).IsValid);
|
||||
[Fact] public void Manifest_PathTraversal_IsRejected() => Assert.False(new PackageManifestValidator().Validate(Manifest(Package("../evil.dll")), 2026, "x64").IsValid);
|
||||
[Fact] public void Manifest_AbsolutePath_IsRejected() => Assert.False(new PackageManifestValidator().Validate(Manifest(Package("C:\\evil.dll")), 2026, "x64").IsValid);
|
||||
[Fact] public void Manifest_MissingEntryAssembly_IsRejected() => Assert.False(new PackageManifestValidator().Validate(Manifest(Package("Other.dll")), 2026, "x64").IsValid);
|
||||
@@ -21,6 +24,31 @@ public sealed class FoundationTests
|
||||
{ var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "source"); Directory.CreateDirectory(source); var bytes = new byte[] { 7 }; await System.IO.File.WriteAllBytesAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), bytes); var hash = Convert.ToHexString(SHA256.HashData(bytes)); var store = new LocalPackageStore(root, new Sha256FileHasher()); var result = await store.SynchronizeAsync(Manifest(new PackageFile("PRecAnnot.Plant3D2026.dll", 1, hash)), source, CancellationToken.None); Assert.Equal("2026.1.0", result.CurrentVersion); Assert.True(Directory.Exists(Path.Combine(root, "Applications", "P_Rec_Annot", "versions", result.CurrentVersion))); Directory.Delete(root, true); }
|
||||
[Fact] public async Task Synchronizer_HashMismatch_IsRejected()
|
||||
{ var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "source"); Directory.CreateDirectory(source); await System.IO.File.WriteAllTextAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), "x"); var store = new LocalPackageStore(root, new Sha256FileHasher()); await Assert.ThrowsAsync<InvalidOperationException>(() => store.SynchronizeAsync(Manifest(Package("PRecAnnot.Plant3D2026.dll")), source, CancellationToken.None)); Assert.False(Directory.Exists(Path.Combine(root, "Applications", "P_Rec_Annot", "versions", "2026.1.0"))); Directory.Delete(root, true); }
|
||||
[Fact] public async Task LastKnownGood_RejectsCorruptCurrent_AndUsesVerifiedPreviousOffline()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "source"); Directory.CreateDirectory(source); var store = new LocalPackageStore(root, new Sha256FileHasher());
|
||||
try
|
||||
{
|
||||
await System.IO.File.WriteAllTextAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), "version-one"); var firstHash = Convert.ToHexString(SHA256.HashData(await System.IO.File.ReadAllBytesAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll")))); await store.SynchronizeAsync(Manifest(new PackageFile("PRecAnnot.Plant3D2026.dll", 11, firstHash)), source, CancellationToken.None); await store.BindAsync(Requirement(), "2026.1.0", CancellationToken.None);
|
||||
await System.IO.File.WriteAllTextAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), "version-two"); var secondHash = Convert.ToHexString(SHA256.HashData(await System.IO.File.ReadAllBytesAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll")))); var second = Manifest(new PackageFile("PRecAnnot.Plant3D2026.dll", 11, secondHash)) with { Version = "2026.2.0" }; await store.SynchronizeAsync(second, source, CancellationToken.None); await store.BindAsync(Requirement("2026.2.0"), "2026.2.0", CancellationToken.None);
|
||||
await System.IO.File.WriteAllTextAsync(Path.Combine(root, "Applications", "P_Rec_Annot", "versions", "2026.2.0", "PRecAnnot.Plant3D2026.dll"), "corrupt");
|
||||
var resolved = await store.ResolveAsync(Requirement("2026.1.0"), CancellationToken.None);
|
||||
Assert.NotNull(resolved); Assert.Equal("2026.1.0", resolved!.Version); Assert.True(resolved.IsOfflineFallback); Assert.Equal("PREVIOUS_VERIFIED_AFTER_CURRENT_REJECTED", resolved.Reason);
|
||||
}
|
||||
finally { Directory.Delete(root, true); }
|
||||
}
|
||||
[Fact] public async Task LastKnownGood_DoesNotFallback_WhenOfflinePolicyIsDisabled()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "source"); Directory.CreateDirectory(source); var bytes = new byte[] { 1 }; await System.IO.File.WriteAllBytesAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), bytes); var hash = Convert.ToHexString(SHA256.HashData(bytes)); var store = new LocalPackageStore(root, new Sha256FileHasher());
|
||||
try { await store.SynchronizeAsync(Manifest(new PackageFile("PRecAnnot.Plant3D2026.dll", 1, hash)), source, CancellationToken.None); await store.BindAsync(Requirement(), "2026.1.0", CancellationToken.None); await store.SynchronizeAsync((Manifest(new PackageFile("PRecAnnot.Plant3D2026.dll", 1, hash)) with { Version = "2026.2.0" }), source, CancellationToken.None); await store.BindAsync(Requirement("2026.2.0", false), "2026.2.0", CancellationToken.None); System.IO.File.Delete(Path.Combine(root, "Applications", "P_Rec_Annot", "versions", "2026.2.0", "PRecAnnot.Plant3D2026.dll")); Assert.Null(await store.ResolveAsync(Requirement("2026.1.0", false), CancellationToken.None)); }
|
||||
finally { Directory.Delete(root, true); }
|
||||
}
|
||||
[Fact] public async Task Synchronizer_RejectsReservedStoredManifestPayload()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "source"); Directory.CreateDirectory(source); var bytes = new byte[] { 1 }; await System.IO.File.WriteAllBytesAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), bytes); var hash = Convert.ToHexString(SHA256.HashData(bytes));
|
||||
try { var store = new LocalPackageStore(root, new Sha256FileHasher()); var manifest = Manifest(new PackageFile("PRecAnnot.Plant3D2026.dll", 1, hash), new PackageFile("package.manifest.json", 1, hash)); var error = await Assert.ThrowsAsync<InvalidOperationException>(() => store.SynchronizeAsync(manifest, source, CancellationToken.None)); Assert.Equal("MANIFEST_RESERVED_FILE", error.Message); }
|
||||
finally { Directory.Delete(root, true); }
|
||||
}
|
||||
[Fact] public void StartupScript_ContainsOneNetload_AndEscapesSpaces()
|
||||
{ var root = Path.Combine(Path.GetTempPath(), "space root", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); System.IO.File.WriteAllText(Path.Combine(root, "PRecAnnot.Plant3D2026.dll"), ""); var s = new StartupScriptBuilder().BuildNetloadScript(root, "PRecAnnot.Plant3D2026.dll"); Assert.Single(System.Text.RegularExpressions.Regex.Matches(s, "netload", System.Text.RegularExpressions.RegexOptions.IgnoreCase).Cast<object>()); Assert.Contains("space root", s); Directory.Delete(Path.GetDirectoryName(root)!, true); }
|
||||
[Fact] public void ProcessLauncher_DryRun_DoesNotStartProcess()
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Launcher.Next.Core\Launcher.Next.Core.csproj" />
|
||||
<ProjectReference Include="..\..\Launcher.Next.Application\Launcher.Next.Application.csproj" />
|
||||
<ProjectReference Include="..\..\Launcher.Next.Infrastructure\Launcher.Next.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\..\Launcher.Next.Autodesk\Launcher.Next.Autodesk.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Security.Cryptography;
|
||||
using Launcher.Next.Core;
|
||||
using Launcher.Next.Infrastructure;
|
||||
using Xunit;
|
||||
|
||||
namespace Launcher.Next.Tests;
|
||||
|
||||
public sealed class PackageLifecycleTests
|
||||
{
|
||||
private sealed class Fault(PackageLifecycleFaultPoint point) : IPackageLifecycleFaultInjector { public void ThrowIfRequested(PackageLifecycleFaultPoint requested) { if(requested==point) throw new IOException("INJECTED"); } }
|
||||
[Fact] public async Task InjectedMaterializationFaultPreservesPointers()
|
||||
{ var root=Path.Combine(Path.GetTempPath(),Guid.NewGuid().ToString("N")); var source=Path.Combine(root,"catalog"); Directory.CreateDirectory(source); await File.WriteAllBytesAsync(Path.Combine(source,"entry.dll"),[1]); await File.WriteAllBytesAsync(Path.Combine(source,"payload.dat"),[2]); var store=new LocalPackageStore(root,new Sha256FileHasher(),new Fault(PackageLifecycleFaultPoint.AfterStagingAllocated)); try { await Assert.ThrowsAsync<IOException>(()=>store.MaterializeAsync(Manifest("1.0.0",[1],[2]),source,CancellationToken.None)); Assert.Null(await store.GetCurrentVersionAsync("Lifecycle.Package",CancellationToken.None)); } finally { if(Directory.Exists(root)) Directory.Delete(root,true); } }
|
||||
[Fact] public void PilotRootRejectsUncAndRelativePaths()
|
||||
{ var resolver=new PathResolver(new Dictionary<string,string?>()); Assert.Throws<InvalidOperationException>(()=>resolver.ResolvePackagePilotRoot(@"\\server\share")); Assert.Throws<InvalidOperationException>(()=>resolver.ResolvePackagePilotRoot("relative")); }
|
||||
private static PackageRequirement Req(string version) => new("Lifecycle.Package", "Plant3D", 2026, "x64", "pilot", version, "lifecycle-test", true);
|
||||
private static PackageManifest Manifest(string version, byte[] a, byte[] b) => new(1, "Lifecycle.Package", version, "Plant3D", 2026, "x64", "entry.dll", [
|
||||
new("entry.dll", a.Length, Convert.ToHexString(SHA256.HashData(a))),
|
||||
new("payload.dat", b.Length, Convert.ToHexString(SHA256.HashData(b)))], new("none", ""), RolloutChannel: "pilot");
|
||||
|
||||
[Fact]
|
||||
public async Task MutatedImmutablePayloadBlocksActivation()
|
||||
{
|
||||
var root=Path.Combine(Path.GetTempPath(),Guid.NewGuid().ToString("N")); var source=Path.Combine(root,"catalog"); Directory.CreateDirectory(source); var bytes=new byte[]{1}; await File.WriteAllBytesAsync(Path.Combine(source,"entry.dll"),bytes); await File.WriteAllBytesAsync(Path.Combine(source,"payload.dat"),bytes); var store=new LocalPackageStore(root,new Sha256FileHasher());
|
||||
try { var package=await store.MaterializeAsync(Manifest("1.0.0",bytes,bytes),source,CancellationToken.None); await File.WriteAllBytesAsync(Path.Combine(package.LocalPackageRoot,"entry.dll"),new byte[]{9}); Assert.Equal("Blocked",(await store.ActivateImmutableAsync(package,Req("1.0.0"),CancellationToken.None)).Outcome); }
|
||||
finally { if(Directory.Exists(root)) Directory.Delete(root,true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivationReceiptBindsPointerGenerationAndRejectsStaleRollback()
|
||||
{
|
||||
var root=Path.Combine(Path.GetTempPath(),Guid.NewGuid().ToString("N")); var source=Path.Combine(root,"catalog"); Directory.CreateDirectory(source); var bytes=new byte[]{7}; await File.WriteAllBytesAsync(Path.Combine(source,"entry.dll"),bytes); await File.WriteAllBytesAsync(Path.Combine(source,"payload.dat"),bytes); var store=new LocalPackageStore(root,new Sha256FileHasher());
|
||||
try { var a=await store.MaterializeAsync(Manifest("1.0.0",bytes,bytes),source,CancellationToken.None); await store.ActivateImmutableAsync(a,Req("1.0.0"),CancellationToken.None); var b=await store.MaterializeAsync(Manifest("2.0.0",bytes,bytes),source,CancellationToken.None); var receipt= (await store.ActivateImmutableAsync(b,Req("2.0.0"),CancellationToken.None)).Receipt!; Assert.True(receipt.PointerGeneration>0); var c=await store.MaterializeAsync(Manifest("3.0.0",bytes,bytes),source,CancellationToken.None); await store.ActivateImmutableAsync(c,Req("3.0.0"),CancellationToken.None); Assert.Equal("RollbackBlockedStateChanged",(await store.RollbackAsync(receipt,CancellationToken.None)).Outcome); }
|
||||
finally { if(Directory.Exists(root)) Directory.Delete(root,true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OwnedCleanupNeverDeletesForeignSibling()
|
||||
{
|
||||
var root=Path.Combine(Path.GetTempPath(),Guid.NewGuid().ToString("N")); var staging=Path.Combine(root,"Launcher","staging"); var owned=Path.Combine(staging,"owned"); var foreign=Path.Combine(staging,"foreign"); Directory.CreateDirectory(owned); Directory.CreateDirectory(foreign); var ownership=new PackageStagingOwnership("owned","P","1",owned,DateTimeOffset.UtcNow,"owned"); await File.WriteAllTextAsync(Path.Combine(owned,".package-staging-owner.json"),System.Text.Json.JsonSerializer.Serialize(ownership)); var store=new LocalPackageStore(root,new Sha256FileHasher());
|
||||
try { Assert.Equal("Cleaned",(await store.CleanupOwnedStagingAsync(ownership,CancellationToken.None)).Outcome); Assert.True(Directory.Exists(foreign)); Assert.Equal("AlreadyAbsent",(await store.CleanupOwnedStagingAsync(ownership,CancellationToken.None)).Outcome); }
|
||||
finally { if(Directory.Exists(root)) Directory.Delete(root,true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MaterializeDoesNotCreatePointersAndReturnsImmutableVersion()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "catalog"); Directory.CreateDirectory(source);
|
||||
var a = new byte[] { 1, 2 }; var b = new byte[] { 3, 4, 5 }; await File.WriteAllBytesAsync(Path.Combine(source, "entry.dll"), a); await File.WriteAllBytesAsync(Path.Combine(source, "payload.dat"), b);
|
||||
var store = new LocalPackageStore(root, new Sha256FileHasher());
|
||||
try
|
||||
{
|
||||
var result = await store.MaterializeAsync(Manifest("1.0.0", a, b), source, CancellationToken.None);
|
||||
Assert.True(File.Exists(Path.Combine(result.LocalPackageRoot, "package.manifest.json")));
|
||||
Assert.False(File.Exists(Path.Combine(root, "Applications", "Lifecycle.Package", "current.json")));
|
||||
Assert.False(File.Exists(Path.Combine(root, "Applications", "Lifecycle.Package", "previous.json")));
|
||||
Assert.DoesNotContain("staging", result.LocalPackageRoot, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivationIsIdempotentAndRollbackRestoresPrevious()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "catalog"); Directory.CreateDirectory(source); var store = new LocalPackageStore(root, new Sha256FileHasher());
|
||||
var a = new byte[] { 1 }; var b = new byte[] { 2 }; var c = new byte[] { 3 }; await File.WriteAllBytesAsync(Path.Combine(source, "entry.dll"), a); await File.WriteAllBytesAsync(Path.Combine(source, "payload.dat"), b);
|
||||
try
|
||||
{
|
||||
var pa = await store.MaterializeAsync(Manifest("1.0.0", a, b), source, CancellationToken.None); var aa = await store.ActivateImmutableAsync(pa, Req("1.0.0"), CancellationToken.None); Assert.Equal("Activated", aa.Outcome);
|
||||
var again = await store.ActivateImmutableAsync(pa, Req("1.0.0"), CancellationToken.None); Assert.Equal("AlreadyActiveExact", again.Outcome);
|
||||
await File.WriteAllBytesAsync(Path.Combine(source, "entry.dll"), c); var pb = await store.MaterializeAsync(Manifest("2.0.0", c, b), source, CancellationToken.None); var ab = await store.ActivateImmutableAsync(pb, Req("2.0.0"), CancellationToken.None); Assert.Equal("Activated", ab.Outcome);
|
||||
var rollback = await store.RollbackAsync(ab.Receipt!, CancellationToken.None); Assert.Equal("RolledBack", rollback.Outcome); Assert.Equal("1.0.0", await store.GetCurrentVersionAsync("Lifecycle.Package", CancellationToken.None));
|
||||
}
|
||||
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OldReceiptCannotRollbackNewerActivation()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "catalog"); Directory.CreateDirectory(source); var store = new LocalPackageStore(root, new Sha256FileHasher());
|
||||
var bytes = new byte[] { 1 }; await File.WriteAllBytesAsync(Path.Combine(source, "entry.dll"), bytes); await File.WriteAllBytesAsync(Path.Combine(source, "payload.dat"), bytes);
|
||||
try { var a=await store.MaterializeAsync(Manifest("1.0.0",bytes,bytes),source,CancellationToken.None); await store.ActivateImmutableAsync(a,Req("1.0.0"),CancellationToken.None); var b=await store.MaterializeAsync(Manifest("2.0.0",bytes,bytes),source,CancellationToken.None); var rb=await store.ActivateImmutableAsync(b,Req("2.0.0"),CancellationToken.None); var r=await store.RollbackAsync(rb.Receipt!,CancellationToken.None); Assert.Equal("RolledBack",r.Outcome); }
|
||||
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Security.Cryptography;
|
||||
using Launcher.Next.Application;
|
||||
using Launcher.Next.Core;
|
||||
using Launcher.Next.Infrastructure;
|
||||
using Xunit;
|
||||
|
||||
namespace Launcher.Next.Tests;
|
||||
|
||||
public sealed class PackageResolutionUseCaseTests
|
||||
{
|
||||
private static PackageRequirement Requirement(string version, bool offline = true, string product = "Plant3D", int year = 2026, string architecture = "x64", string channel = "pilot", string roster = "project-a") => new("P_Rec_Annot", product, year, architecture, channel, version, roster, offline);
|
||||
private static PackageManifest Manifest(string version, byte[] bytes, string product = "Plant3D", int year = 2026, string architecture = "x64", string channel = "pilot") => new(1, "P_Rec_Annot", version, product, year, architecture, "PRecAnnot.Plant3D2026.dll", [new("PRecAnnot.Plant3D2026.dll", bytes.Length, Convert.ToHexString(SHA256.HashData(bytes)))], new("netload", "PHP_REC_ANNOT"), RolloutChannel: channel);
|
||||
[Fact] public async Task OnlineSuccess_StagesBindsAndRecordsOnlineEvidence()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "source"); Directory.CreateDirectory(source); var bytes = new byte[] { 1, 2 }; await File.WriteAllBytesAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), bytes); var manifest = Manifest("1.0.0", bytes); var store = new LocalPackageStore(root, new Sha256FileHasher());
|
||||
try { var result = await UseCase(new(new CatalogPackage(manifest, source))).ResolveAsync(Requirement("1.0.0"), CancellationToken.None); Assert.True(result.IsResolved); Assert.True(result.Online); Assert.False(result.Selected!.IsOfflineFallback); Assert.Contains(result.Evidence, x => x.Code == "ONLINE_PACKAGE_SELECTED"); Assert.False(result.Selected.LocalPackageRoot.StartsWith("\\\\", StringComparison.Ordinal)); }
|
||||
finally { Directory.Delete(root, true); }
|
||||
PackageResolutionUseCase UseCase(FakeCatalog catalog) => new(catalog, store, store, store, new Telemetry());
|
||||
}
|
||||
[Fact] public async Task ControlledOfflineRecovery_CorruptB_SelectsVerifiedAAndEvidence()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "source"); Directory.CreateDirectory(source); var store = new LocalPackageStore(root, new Sha256FileHasher());
|
||||
try
|
||||
{
|
||||
var a = new byte[] { 1 }; await File.WriteAllBytesAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), a); await store.SynchronizeAsync(Manifest("1.0.0", a), source, CancellationToken.None); await store.BindAsync(Requirement("1.0.0"), "1.0.0", CancellationToken.None);
|
||||
var b = new byte[] { 2 }; await File.WriteAllBytesAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), b); await store.SynchronizeAsync(Manifest("2.0.0", b), source, CancellationToken.None); await store.BindAsync(Requirement("2.0.0"), "2.0.0", CancellationToken.None);
|
||||
await File.WriteAllBytesAsync(Path.Combine(root, "Applications", "P_Rec_Annot", "versions", "2.0.0", "PRecAnnot.Plant3D2026.dll"), new byte[] { 9 });
|
||||
var result = await new PackageResolutionUseCase(new FakeCatalog(null), store, store, store, new Telemetry()).ResolveAsync(Requirement("1.0.0"), CancellationToken.None);
|
||||
Assert.True(result.IsResolved); Assert.False(result.Online); Assert.Equal("1.0.0", result.Selected!.Version); Assert.True(result.Selected.IsOfflineFallback); Assert.Contains(result.Evidence, x => x.Code == "PREVIOUS_LKG_SELECTED"); Assert.Contains(result.Evidence, x => x.Code == "CATALOG_UNAVAILABLE");
|
||||
}
|
||||
finally { Directory.Delete(root, true); }
|
||||
}
|
||||
[Fact] public async Task UnavailableCatalog_WithFallbackDisabled_BlocksWithoutSelectingPrevious()
|
||||
{
|
||||
var store = new LocalPackageStore(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")), new Sha256FileHasher());
|
||||
var result = await new PackageResolutionUseCase(new FakeCatalog(null), store, store, store, new Telemetry()).ResolveAsync(Requirement("1.0.0", false), CancellationToken.None);
|
||||
Assert.False(result.IsResolved); Assert.Contains(result.Evidence, x => x.Code == "OFFLINE_FALLBACK_DISABLED"); Assert.Contains(result.Evidence, x => x.Code == "NO_SAFE_PACKAGE_AVAILABLE");
|
||||
}
|
||||
[Fact] public async Task OnlineProductMismatch_IsRejectedAndBlocked()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); var source = Path.Combine(root, "source"); Directory.CreateDirectory(source); var bytes = new byte[] { 1 }; await File.WriteAllBytesAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), bytes); var store = new LocalPackageStore(root, new Sha256FileHasher());
|
||||
try { var result = await new PackageResolutionUseCase(new FakeCatalog(new CatalogPackage(Manifest("1.0.0", bytes, "Revit"), source)), store, store, store, new Telemetry()).ResolveAsync(Requirement("1.0.0"), CancellationToken.None); Assert.False(result.IsResolved); Assert.Contains(result.Evidence, x => x.Code == "NO_SAFE_PACKAGE_AVAILABLE"); }
|
||||
finally { Directory.Delete(root, true); }
|
||||
}
|
||||
[Theory]
|
||||
[InlineData("Revit", 2026, "x64", "pilot", "1.0.0", "project-a")]
|
||||
[InlineData("Plant3D", 2025, "x64", "pilot", "1.0.0", "project-a")]
|
||||
[InlineData("Plant3D", 2026, "x86", "pilot", "1.0.0", "project-a")]
|
||||
[InlineData("Plant3D", 2026, "x64", "stable", "1.0.0", "project-a")]
|
||||
[InlineData("Plant3D", 2026, "x64", "pilot", "2.0.0", "project-a")]
|
||||
[InlineData("Plant3D", 2026, "x64", "pilot", "1.0.0", "project-b")]
|
||||
public async Task CachedPackage_RejectsIncompatibleRequirement(string product, int year, string architecture, string channel, string version, string roster)
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "source"); Directory.CreateDirectory(source); var bytes = new byte[] { 1 }; await File.WriteAllBytesAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), bytes); var store = new LocalPackageStore(root, new Sha256FileHasher());
|
||||
try { await store.SynchronizeAsync(Manifest("1.0.0", bytes), source, CancellationToken.None); await store.BindAsync(Requirement("1.0.0"), "1.0.0", CancellationToken.None); Assert.Null(await store.ResolveAsync(Requirement(version, true, product, year, architecture, channel, roster), CancellationToken.None)); }
|
||||
finally { Directory.Delete(root, true); }
|
||||
}
|
||||
private sealed class FakeCatalog(CatalogPackage? package) : IPackageCatalogPackageAcquirer { public Task<CatalogPackage?> AcquireAsync(PackageRequirement requirement, CancellationToken cancellationToken) => Task.FromResult(package); }
|
||||
private sealed class Telemetry : ILauncherTelemetry { public void Write(string eventType, IReadOnlyDictionary<string, object?>? fields = null) { } }
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using Launcher.Next.Autodesk;
|
||||
using Launcher.Next.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Launcher.Next.Tests;
|
||||
|
||||
public sealed class PathAndLaunchTests
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, string?> Paths = new Dictionary<string, string?>
|
||||
{
|
||||
[LauncherPathVariableNames.CorporateDfsRoot] = "\\\\phimproject.com\\a",
|
||||
[LauncherPathVariableNames.ProjectName] = "Проект",
|
||||
[LauncherPathVariableNames.DisciplineName] = "Plant 3D",
|
||||
[LauncherPathVariableNames.ProjectRoot] = "${CorporateDfsRoot}\\${ProjectName}",
|
||||
[LauncherPathVariableNames.DisciplineRoot] = "${ProjectRoot}/${DisciplineName}",
|
||||
[LauncherPathVariableNames.LocalLauncherRoot] = "C:\\Launcher Pilot",
|
||||
[LauncherPathVariableNames.LocalSessionRoot] = "${LocalLauncherRoot}\\sessions",
|
||||
[LauncherPathVariableNames.AcadExecutable] = "C:\\Plant 3D 2026\\acad.exe",
|
||||
[LauncherPathVariableNames.FixtureOrthoDwg] = "C:\\Fixture Space\\Тест.dwg",
|
||||
[LauncherPathVariableNames.FixtureModelDwg] = "C:\\Fixture Space\\model.dwg",
|
||||
[LauncherPathVariableNames.AutomationScriptPath] = "${LocalSessionRoot}\\preview.scr",
|
||||
[LauncherPathVariableNames.AutomationConfigPath] = "${LocalSessionRoot}\\preview.json"
|
||||
};
|
||||
private static PathResolver Resolver(IReadOnlyDictionary<string, string?>? env = null) => new(env ?? new Dictionary<string, string?>());
|
||||
|
||||
[Fact] public void PathResolver_ResolvesSimpleVariable() => Assert.Equal("C:\\Launcher Pilot", Resolver().Resolve(new(Paths)).Require(LauncherPathVariableNames.LocalLauncherRoot));
|
||||
[Fact] public void PathResolver_ResolvesNestedVariables() => Assert.Equal("\\\\phimproject.com\\a\\Проект\\Plant 3D", Resolver().Resolve(new(Paths)).Require(LauncherPathVariableNames.DisciplineRoot));
|
||||
[Fact] public void PathResolver_ResolvesEnvironmentVariable() => Assert.Equal("C:\\From Environment", Resolver(new Dictionary<string, string?> { ["ROOT"] = "C:\\From Environment" }).Resolve(new(new Dictionary<string, string?> { ["Value"] = "${ROOT}" })).Require("Value"));
|
||||
[Fact] public void PathResolver_RejectsUnknownVariable() => Assert.Throws<InvalidOperationException>(() => Resolver().Resolve(new(new Dictionary<string, string?> { ["Value"] = "${Missing}" })));
|
||||
[Fact] public void PathResolver_RejectsEmptyRequiredVariable() => Assert.Throws<InvalidOperationException>(() => Resolver().Resolve(new(new Dictionary<string, string?> { ["Value"] = "" }), ["Value"]));
|
||||
[Fact] public void PathResolver_PreservesOptionalNullVariable() => Assert.Null(Resolver().Resolve(new(new Dictionary<string, string?> { ["Optional"] = null })).Get("Optional"));
|
||||
[Fact] public void PathResolver_RejectsCycle() => Assert.Throws<InvalidOperationException>(() => Resolver().Resolve(new(new Dictionary<string, string?> { ["A"] = "${B}", ["B"] = "${A}" })));
|
||||
[Fact] public void PathResolver_PreservesUnicode() => Assert.Contains("Проект", Resolver().Resolve(new(Paths)).Require(LauncherPathVariableNames.ProjectRoot));
|
||||
[Fact] public void PathResolver_NormalizesSeparators() => Assert.DoesNotContain('/', Resolver().Resolve(new(Paths)).Require(LauncherPathVariableNames.DisciplineRoot));
|
||||
[Fact] public void PathResolver_NormalizesLegacyDriveAToCorporateDfs() => Assert.Equal("\\\\phimproject.com\\a\\Project\\Plant", Resolver().ResolvePath("A:\\Project\\Plant", new(Paths)));
|
||||
[Fact] public void PathResolver_DoesNotEmitDriveA() => Assert.False(Resolver().ResolvePath("A:\\Project", new(Paths)).StartsWith("A:\\", StringComparison.OrdinalIgnoreCase));
|
||||
[Fact] public void PathResolver_CanonicalVariablesDoNotEmitDriveA() => Assert.False(Resolver().Resolve(new(new Dictionary<string, string?> { [LauncherPathVariableNames.CorporateDfsRoot] = "\\\\phimproject.com\\a", ["Legacy"] = "A:\\Project" })).Require("Legacy").StartsWith("A:\\", StringComparison.OrdinalIgnoreCase));
|
||||
[Fact] public void PathResolver_RejectsTraversalOutsideRoot() => Assert.Throws<InvalidOperationException>(() => Resolver().ResolvePath("${LocalSessionRoot}\\..\\escape", new(Paths), "${LocalSessionRoot}"));
|
||||
[Fact] public void PathResolver_RequiresAbsolutePath() => Assert.Throws<InvalidOperationException>(() => Resolver().ResolvePath("relative\\file", new(Paths)));
|
||||
[Fact] public void ProjectRoot_UsesProjectAndDisciplineVariables() => Assert.Equal("\\\\phimproject.com\\a\\Проект", Resolver().Resolve(new(Paths)).Require(LauncherPathVariableNames.ProjectRoot));
|
||||
[Fact] public void CorporateDfsRoot_ComesFromConfiguration() => Assert.Equal("\\\\phimproject.com\\a", Resolver().Resolve(new(Paths)).Require(LauncherPathVariableNames.CorporateDfsRoot));
|
||||
[Fact] public void ConfigurationLayers_SessionOverridesMachineOverridesDefaults()
|
||||
{
|
||||
var result = LauncherConfigurationLayers.Load("{\"SchemaVersion\":1,\"PathVariables\":{\"LocalLauncherRoot\":\"C:\\\\defaults\",\"AcadExecutable\":\"C:\\\\acad.exe\"}}", "{\"SchemaVersion\":1,\"PathVariables\":{\"LocalLauncherRoot\":\"C:\\\\machine\"}}", "{\"SchemaVersion\":1,\"PilotEnabled\":true,\"PathVariables\":{\"LocalSessionRoot\":\"C:\\\\session\"}}");
|
||||
Assert.True(result.IsValid); Assert.Equal("C:\\machine", result.Configuration.PathVariables[LauncherPathVariableNames.LocalLauncherRoot]); Assert.True(result.Configuration.PilotEnabled);
|
||||
}
|
||||
|
||||
[Fact] public void ProductionExecute_DoesNotAddBatchScript() => Assert.DoesNotContain("/b", new AcadArgumentBuilder().Build(AcadLaunchMode.Production, null));
|
||||
[Fact] public void ProductionExecute_RejectsAutomationScriptPath() => Assert.Throws<InvalidOperationException>(() => new AcadArgumentBuilder().Build(AcadLaunchMode.Production, "C:\\session\\a.scr"));
|
||||
[Fact] public void ProductionExecute_DoesNotCreateScript() => Assert.DoesNotContain(new AcadArgumentBuilder().Build(AcadLaunchMode.Production, null), x => x.EndsWith(".scr", StringComparison.OrdinalIgnoreCase));
|
||||
[Fact] public void ProductionExecute_DoesNotSetAutomationEnvironment() => Assert.DoesNotContain("P_REC_ANNOT_AUTOMATION", new Dictionary<string, string> { ["P_REC_ANNOT_RUNTIME_MODE"] = "Smoke" }.Keys);
|
||||
[Fact] public void PreviewExecute_RequiresBatchScript() => Assert.Throws<InvalidOperationException>(() => new AcadArgumentBuilder().Build(AcadLaunchMode.NextPilotPreview, null));
|
||||
[Fact] public void PreviewExecute_AddsExactlyOneBatchArgument() => Assert.Single(new AcadArgumentBuilder().Build(AcadLaunchMode.NextPilotPreview, "C:\\session\\preview.scr"), x => x == "/b");
|
||||
[Fact] public void PreviewExecute_UsesResolvedScriptPath() => Assert.Contains(Resolver().Resolve(new(Paths)).Require(LauncherPathVariableNames.AutomationScriptPath), new AcadArgumentBuilder().Build(AcadLaunchMode.NextPilotPreview, Resolver().Resolve(new(Paths)).Require(LauncherPathVariableNames.AutomationScriptPath)));
|
||||
[Fact] public void PreviewExecute_RejectsPathOutsideSessionRoot() => Assert.Throws<InvalidOperationException>(() => Resolver().ResolvePath("C:\\elsewhere\\preview.scr", new(Paths), "${LocalSessionRoot}"));
|
||||
[Fact] public void PreviewExecute_RequiresFixtureMarker() => Assert.Contains("AUTOMATION_CONFIGURATION_INVALID", new NextPilotSafetyGate().Evaluate(Context(new PilotLocalConfiguration { AutomationMode = "FirstLimitedApplyRuntimeTest", AutomationConfigurationPath = "C:\\session\\automation.json", LaunchMode = AcadLaunchMode.NextPilotPreview }, false), false).Violations);
|
||||
[Fact] public void PreviewExecute_RequiresProductionDisabled() => Assert.Contains("AUTOMATION_CONFIGURATION_INVALID", new NextPilotSafetyGate().Evaluate(Context(new PilotLocalConfiguration { AutomationMode = "FirstLimitedApplyRuntimeTest", AutomationConfigurationPath = "C:\\session\\automation.json", FixtureMarkerPath = "C:\\session\\fixture.marker", LaunchMode = AcadLaunchMode.NextPilotPreview, ProductionDeploy = true }, false), false).Violations);
|
||||
[Fact] public void AcadArguments_ProductionContainProductAndLanguage() { var a = new AcadArgumentBuilder().Build(AcadLaunchMode.Production, null); Assert.Equal(["/product", "PLNT3D", "/language", "ru-RU"], a); }
|
||||
[Fact] public void AcadArguments_AutomationContainProductLanguageAndBatch() { var a = new AcadArgumentBuilder().Build(AcadLaunchMode.NextPilotPreview, "C:\\session\\preview.scr"); Assert.Equal(["/product", "PLNT3D", "/language", "ru-RU", "/b", "C:\\session\\preview.scr"], a); }
|
||||
[Fact] public void AcadArguments_DoNotContainProfileOrWorkspaceYet() { var a = new AcadArgumentBuilder().Build(AcadLaunchMode.NextPilotPreview, "C:\\session\\preview.scr"); Assert.DoesNotContain("/p", a); Assert.DoesNotContain("/w", a); }
|
||||
[Fact] public void PreviewScript_IsDeterministicAndContainsRequiredCommandOrder()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root);
|
||||
var entry = "PRecAnnot.dll"; var fixture = Path.Combine(root, "Fixture Орто.dwg"); File.WriteAllText(Path.Combine(root, entry), string.Empty); File.WriteAllText(fixture, string.Empty);
|
||||
try
|
||||
{
|
||||
var model = Path.Combine(root, "model.dwg"); File.WriteAllText(model, string.Empty);
|
||||
var paths = new ResolvedAutomationScriptPaths(root, entry, fixture, model, Path.Combine(root, "preview.scr"), false, false);
|
||||
var first = new StartupScriptBuilder().BuildAutomationScript(paths); var second = new StartupScriptBuilder().BuildAutomationScript(paths);
|
||||
Assert.Equal(first, second); Assert.EndsWith("\n", first); Assert.Equal(2, System.Text.RegularExpressions.Regex.Matches(first, "PXP-OA").Count);
|
||||
Assert.True(first.IndexOf("_.FILEDIA\n0", StringComparison.Ordinal) < first.IndexOf("_.OPEN", StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(model, first, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.True(first.IndexOf("PHP_REC_AUTOMATION_PREPARE", StringComparison.Ordinal) < first.IndexOf("PXP-OA", StringComparison.Ordinal));
|
||||
Assert.True(first.LastIndexOf("PXP-OA", StringComparison.Ordinal) < first.IndexOf("PHP_REC_AUTOMATION_FINALIZE", StringComparison.Ordinal));
|
||||
}
|
||||
finally { Directory.Delete(root, true); }
|
||||
}
|
||||
[Fact] public void DiagnosticScript_BracketsBothPxpOaCommands()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); var entry = "P.dll"; var fixture = Path.Combine(root, "fixture.dwg"); var model = Path.Combine(root, "model.dwg"); File.WriteAllText(Path.Combine(root, entry), ""); File.WriteAllText(fixture, ""); File.WriteAllText(model, "");
|
||||
try { var script = new StartupScriptBuilder().BuildAutomationScript(new(root, entry, fixture, model, Path.Combine(root, "diagnostic.scr"), true, false)); Assert.Contains("PHP_REC_AUTOMATION_BEFORE_PXP_1\nPXP-OA\nPHP_REC_AUTOMATION_AFTER_PXP_1", script); Assert.Contains("PHP_REC_AUTOMATION_BEFORE_PXP_2\nPXP-OA\nPHP_REC_AUTOMATION_AFTER_PXP_2", script); }
|
||||
finally { Directory.Delete(root, true); }
|
||||
}
|
||||
[Fact] public void ApplyScript_MarksCreatesDeduplicatesAndUndoes()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); var entry = "P.dll"; var fixture = Path.Combine(root, "fixture.dwg"); var model = Path.Combine(root, "model.dwg"); File.WriteAllText(Path.Combine(root, entry), ""); File.WriteAllText(fixture, ""); File.WriteAllText(model, "");
|
||||
try { var script = new StartupScriptBuilder().BuildAutomationScript(new(root, entry, fixture, model, Path.Combine(root, "apply.scr"), false, true)); Assert.Contains("_.UNDO\n_MARK\nPXP-OA\nPXP-OA\n_.UNDO\n_BACK", script); }
|
||||
finally { Directory.Delete(root, true); }
|
||||
}
|
||||
[Fact] public void DiagnosticScript_RecordsExactBytes() { var text = "PXP-OA\n"; Assert.Equal(Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(text))), Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(text)))); }
|
||||
[Fact] public void DiagnosticScript_HasTrailingNewline() => Assert.EndsWith("\n", "PHP_REC_AUTOMATION_FINALIZE\n");
|
||||
[Fact] public void DiagnosticScript_RecordsEncoding() => Assert.Equal("utf-8", System.Text.Encoding.UTF8.WebName);
|
||||
[Fact] public void AutomationTranscript_IsInsideSessionRoot() => Assert.StartsWith("C:\\session\\", "C:\\session\\preview-transcript.log", StringComparison.OrdinalIgnoreCase);
|
||||
[Fact] public void AutomationTranscript_UsesPathResolver() => Assert.Contains(LauncherPathVariableNames.AutomationTranscriptPath, typeof(LauncherPathVariableNames).GetFields().Select(x => x.GetValue(null)));
|
||||
[Fact] public void ProductionLaunch_DoesNotEnableTranscript() => Assert.Null(new PilotLocalConfiguration().PathVariables.GetValueOrDefault(LauncherPathVariableNames.AutomationTranscriptPath));
|
||||
[Fact] public void AutomationEntryAssembly_IsResolvedFromPackageSource()
|
||||
{
|
||||
var source = File.ReadAllText(Path.Combine(FindRoot(), "Launcher.Next.App", "PilotCli.cs"));
|
||||
Assert.Contains("Path.Combine(options.PackageSource, options.EntryAssembly)", source);
|
||||
}
|
||||
[Fact] public void ProductionLaunch_DoesNotExposeSentinels() { var source = File.ReadAllText(Path.Combine(FindRoot(), "Launcher.Next.Autodesk", "AutodeskAdapters.cs")); Assert.DoesNotContain("PHP_REC_AUTOMATION_BEFORE_PXP_1", source[..source.IndexOf("BuildAutomationScript", StringComparison.Ordinal)]); }
|
||||
[Fact] public void CorporateDfsRoot_RemainsConfigurationOnly() => Assert.DoesNotContain("phimproject.com", File.ReadAllText(Path.Combine(FindRoot(), "Launcher.Next.Core", "LauncherPaths.cs")), StringComparison.OrdinalIgnoreCase);
|
||||
[Fact] public void PilotRootPolicy_AcceptsItsTrustedRoot() => Assert.True(new PilotRootPolicy("C:\\Pilot").Validate("C:\\Pilot").IsValid);
|
||||
[Fact] public void AutomationPrepare_ReportsSafetyViolations()
|
||||
{
|
||||
var source = File.ReadAllText(Path.Combine(FindRoot(), "Launcher.Next.App", "PilotCli.cs"));
|
||||
Assert.Contains("AUTOMATION_PREPARE_BLOCKED", source);
|
||||
}
|
||||
|
||||
[Fact] public void ProductionSource_HasNoForbiddenPathLiterals()
|
||||
{
|
||||
var root = FindRoot();
|
||||
var forbidden = new[] { "A:\\", "\\\\phimproject.com\\a", "C:\\AutoCAD User Support", "C:\\CAD User Support", "C:\\dev\\" };
|
||||
var production = new[] { "Launcher.Next.App", "Launcher.Next.Core", "Launcher.Next.Infrastructure", "Launcher.Next.Autodesk" }
|
||||
.SelectMany(folder => Directory.GetFiles(Path.Combine(root, folder), "*.cs", SearchOption.AllDirectories));
|
||||
foreach (var file in production)
|
||||
{
|
||||
foreach (var text in forbidden)
|
||||
{
|
||||
if (Path.GetFileName(file) == "LauncherPaths.cs" && text == "A:\\") continue; // explicit legacy-input alias parser exception
|
||||
Assert.DoesNotContain(text, File.ReadAllText(file), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static PilotSafetyContext Context(PilotLocalConfiguration configuration, bool execution) => new(configuration with { SchemaVersion = 1, LauncherMode = LauncherMode.NextPilot, PilotEnabled = true, RequireExplicitExecute = true, AllowProcessStart = execution }, new("C:\\acad.exe", "2026", "x64", []), new("", false, true, ["test"]), new(true, "C:\\Pilot", []), ValidationResult.Success, true, false, false);
|
||||
private static string FindRoot()
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "Launcher.Next.sln"))) directory = directory.Parent;
|
||||
return directory!.FullName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
using Launcher.Next.Application;
|
||||
using Launcher.Next.Core;
|
||||
using Launcher.Next.Infrastructure;
|
||||
using System.Security.Cryptography;
|
||||
using Xunit;
|
||||
|
||||
namespace Launcher.Next.Tests;
|
||||
|
||||
public sealed class RosterAdaptersTests
|
||||
{
|
||||
private static readonly RosterContext Context = new("Plant3D", 2026, "x64", true, "legacy-project-a");
|
||||
|
||||
[Fact]
|
||||
public void ProjectRoster_ProducesDeterministicRequirements()
|
||||
{
|
||||
var selected = new RosterAdapter().Select(Json(), null, null, Context);
|
||||
var requirements = new RosterAdapter().Requirements(selected.Roster!);
|
||||
Assert.Equal(RosterSource.ProjectRosterJson, selected.Source);
|
||||
Assert.Equal(["P_A", "P_B"], requirements.Select(x => x.PackageId));
|
||||
Assert.Equal(["1.0.0", "2.0.0"], requirements.Select(x => x.RequiredVersion));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyDownloadList_PreservesSourceOrderAndUsesContext()
|
||||
{
|
||||
const string xml = "<downloadList><Plugin id='P_First' version='1.0.0' channel='stable'/><Plugin id='P_Second' version='2.0.0' install='false'/></downloadList>";
|
||||
var selected = new RosterAdapter().Select(null, xml, null, Context);
|
||||
Assert.Equal(RosterSource.LegacyDownloadListXml, selected.Source);
|
||||
Assert.Equal("legacy-project-a", selected.Roster!.RosterId);
|
||||
Assert.Equal(["P_First", "P_Second"], selected.Roster.Packages.Select(x => x.Id));
|
||||
Assert.False(selected.Roster.Packages[1].Enabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacySettingsAndDownloadList_JoinOnlyThroughExplicitOverlay()
|
||||
{
|
||||
const string settings = "<root><path><help>h</help><care>c</care></path><data><pattern><name>Plant</name><filter>*.dwg</filter><roster>r</roster></pattern><plugin><id>legacy-a</id><renewal>x</renewal><catalog>cat</catalog><install>load</install></plugin></data></root>";
|
||||
const string selection = "<root><id>legacy-a</id></root>";
|
||||
var adapter = new LegacyConfigurationAdapter();
|
||||
var parsed = adapter.ParseSettings(settings);
|
||||
var requirements = adapter.Join(parsed.Patterns[0], adapter.ParseDownloadList(selection), new("map-a", "Plant3D", 2026, "x64", false, [new("legacy-a", "PHIM.A", "1.0.0", "pilot")]));
|
||||
Assert.Single(requirements); Assert.Equal("PHIM.A", requirements[0].PackageId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyDownloadList_DoesNotInventPluginMetadata()
|
||||
{
|
||||
var selection = new LegacyConfigurationAdapter().ParseDownloadList("<root><id>legacy-a</id></root>");
|
||||
Assert.Equal(["legacy-a"], selection.PluginIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyProbeOverride_RequiresExplicitProbeModeAndStrictSchema()
|
||||
{
|
||||
const string valid = """{"schemaVersion":1,"mode":"LegacyConfigurationProbe","settingsXml":"C:/Settings.xml","downloadListXml":"C:/download_list.xml","compatibilityOverlay":"C:/overlay.json","selectedPattern":"Plant","evidenceOutputRoot":"C:/evidence"}""";
|
||||
Assert.Equal("Plant", LegacyProbeOverrideParser.Parse(valid).SelectedPattern);
|
||||
Assert.Throws<InvalidOperationException>(() => LegacyProbeOverrideParser.Parse(valid.Replace("LegacyConfigurationProbe","Production")));
|
||||
Assert.Throws<InvalidOperationException>(() => LegacyProbeOverrideParser.Parse(valid[..^1] + ",\"unknown\":true}"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProjectRoster_HasPriorityOverLegacy()
|
||||
{
|
||||
var selected = new RosterAdapter().Select(Json(), "<downloadList><Plugin id='Legacy' version='1.0.0'/></downloadList>", null, Context);
|
||||
Assert.Equal(RosterSource.ProjectRosterJson, selected.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MalformedProjectRoster_DoesNotFallBackImplicitly()
|
||||
{
|
||||
Assert.ThrowsAny<Exception>(() => new RosterAdapter().Select("{", "<downloadList><Plugin id='Legacy' version='1.0.0'/></downloadList>", Json(), Context));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CentralDefault_IsUsedOnlyWhenProjectAndLegacyAreAbsent()
|
||||
{
|
||||
var selected = new RosterAdapter().Select(null, null, Json(), Context);
|
||||
Assert.Equal(RosterSource.CentralDefaultRoster, selected.Source);
|
||||
Assert.Equal("CENTRAL_DEFAULT_ROSTER", selected.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DuplicatePackage_IsRejected()
|
||||
{
|
||||
const string json = """{"schemaVersion":1,"rosterId":"project-a","product":"Plant3D","year":2026,"architecture":"x64","offlinePolicy":"AllowVerifiedLastKnownGood","packages":[{"id":"P_A","version":"1.0.0","channel":"stable"},{"id":"p_a","version":"2.0.0","channel":"stable"}]}""";
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => new RosterAdapter().ParseJson(json));
|
||||
Assert.Equal("ROSTER_DUPLICATE_PACKAGE", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyXml_RejectsDtd()
|
||||
{
|
||||
const string xml = "<!DOCTYPE x [<!ENTITY e SYSTEM 'file:///not-allowed'>]><downloadList><Plugin id='&e;' version='1.0.0'/></downloadList>";
|
||||
Assert.ThrowsAny<Exception>(() => new RosterAdapter().ParseLegacyDownloadList(xml, Context));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FileSystemCatalog_ReturnsDescriptorOnly_ForMatchingRosterRequirement()
|
||||
{
|
||||
var root=Path.Combine(Path.GetTempPath(),Guid.NewGuid().ToString("N")); var packageRoot=Path.Combine(root,"P_A","1.0.0"); Directory.CreateDirectory(packageRoot);
|
||||
try
|
||||
{
|
||||
var bytes=new byte[]{1}; await File.WriteAllBytesAsync(Path.Combine(packageRoot,"entry.dll"),bytes);
|
||||
var hash=Convert.ToHexString(SHA256.HashData(bytes));
|
||||
await File.WriteAllTextAsync(Path.Combine(packageRoot,"package.manifest.json"), $$"""{"schemaVersion":1,"packageId":"P_A","version":"1.0.0","product":"Plant3D","productYear":2026,"architecture":"x64","entryAssembly":"entry.dll","files":[{"path":"entry.dll","length":1,"sha256":"{{hash}}"}],"startup":{"type":"netload","command":"P_A"},"rolloutChannel":"stable"}""");
|
||||
var result=await new FileSystemCatalogPackageAcquirer(CatalogRoot(root)).AcquireAsync(new("P_A","Plant3D",2026,"x64","stable","1.0.0","project-a",true),CancellationToken.None);
|
||||
Assert.NotNull(result); Assert.Equal(packageRoot,result!.SourceRoot); Assert.False(result.SourceRoot.StartsWith(@"\\",StringComparison.Ordinal));
|
||||
}
|
||||
finally { Directory.Delete(root,true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FileSystemCatalog_RejectsTraversalInManifest()
|
||||
{
|
||||
var root=Path.Combine(Path.GetTempPath(),Guid.NewGuid().ToString("N")); var packageRoot=Path.Combine(root,"P_A","1.0.0"); Directory.CreateDirectory(packageRoot);
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(Path.Combine(packageRoot,"package.manifest.json"), """{"schemaVersion":1,"packageId":"P_A","version":"1.0.0","product":"Plant3D","productYear":2026,"architecture":"x64","entryAssembly":"entry.dll","files":[{"path":"../outside.dll","length":1,"sha256":"00"}],"startup":{"type":"netload","command":"P_A"},"rolloutChannel":"stable"}""");
|
||||
Assert.Null(await new FileSystemCatalogPackageAcquirer(CatalogRoot(root)).AcquireAsync(new("P_A","Plant3D",2026,"x64","stable","1.0.0","project-a",true),CancellationToken.None));
|
||||
}
|
||||
finally { Directory.Delete(root,true); }
|
||||
}
|
||||
|
||||
private static string Json() => """{"schemaVersion":1,"rosterId":"project-a","product":"Plant3D","year":2026,"architecture":"x64","offlinePolicy":"AllowVerifiedLastKnownGood","packages":[{"id":"P_B","version":"2.0.0","channel":"stable","loadOrder":200},{"id":"P_A","version":"1.0.0","channel":"stable","loadOrder":100}]}""";
|
||||
private static ResolvedCatalogRoot CatalogRoot(string root) => new PathResolver().ResolveCatalogRoot(new(new Dictionary<string,string?> { [LauncherPathVariableNames.CorporateDfsRoot] = Path.GetDirectoryName(root)!, [LauncherPathVariableNames.CorporateCatalogRoot] = root }));
|
||||
}
|
||||
Reference in New Issue
Block a user