Compare commits
80
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e7cb3ac39 | ||
|
|
5a14658994 | ||
|
|
e12b8c549c | ||
|
|
6f7888b18b | ||
|
|
1f5853de86 | ||
|
|
94d35a12d9 | ||
|
|
cda89c359e | ||
|
|
8652325980 | ||
|
|
7025ce0a46 | ||
|
|
514302b966 | ||
|
|
a5a615f7f0 | ||
|
|
06d7cb0015 | ||
|
|
fb632b48d8 | ||
|
|
2792e244e9 | ||
|
|
2c578576d7 | ||
|
|
f460e87253 | ||
|
|
433dc9a50d | ||
|
|
45b1379853 | ||
|
|
97fbc1d746 | ||
|
|
0213b229a9 | ||
|
|
876e4f5d50 | ||
|
|
1c18f4893d | ||
|
|
72f127a427 | ||
|
|
5c812f87fd | ||
|
|
f052dd0633 | ||
|
|
75a1097653 | ||
|
|
0879d716dd | ||
|
|
8d32f8e7ae | ||
|
|
9fce6ee830 | ||
|
|
64fa1f5664 | ||
|
|
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 | ||
|
|
115f44fcb9 | ||
|
|
8a2ca2daa6 | ||
|
|
e2725c7f4c | ||
|
|
b88e09afd4 | ||
|
|
9209937b85 | ||
|
|
399cdf503e | ||
|
|
72cd0cc337 | ||
|
|
dc9aeff1b5 |
@@ -34,3 +34,4 @@ PRecAnnot*.dll
|
|||||||
appsettings.Local.json
|
appsettings.Local.json
|
||||||
*.secrets.json
|
*.secrets.json
|
||||||
.env
|
.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,42 @@ namespace Launcher.Next.App;
|
|||||||
public partial class App : System.Windows.Application
|
public partial class App : System.Windows.Application
|
||||||
{
|
{
|
||||||
protected override async void OnStartup(System.Windows.StartupEventArgs e)
|
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)
|
||||||
|
{
|
||||||
|
ShutdownMode = System.Windows.ShutdownMode.OnExplicitShutdown;
|
||||||
|
Shutdown(await LegacyConfigurationProbeCli.RunAsync(e.Args.Skip(1).ToArray()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.Args.FirstOrDefault()?.Equals("package-lifecycle-pilot", StringComparison.OrdinalIgnoreCase) == true)
|
||||||
|
{
|
||||||
|
ShutdownMode = System.Windows.ShutdownMode.OnExplicitShutdown;
|
||||||
|
Shutdown(await PackageLifecyclePilotCli.RunAsync(e.Args.Skip(1).ToArray()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.Args.FirstOrDefault()?.Equals("local-parity-launch", StringComparison.OrdinalIgnoreCase) == true)
|
||||||
|
{
|
||||||
|
ShutdownMode = System.Windows.ShutdownMode.OnExplicitShutdown;
|
||||||
|
Shutdown(await LocalParityLaunchCli.RunAsync(e.Args.Skip(1).ToArray()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.Args.Length == 4 && e.Args[0].Equals("--mode", StringComparison.OrdinalIgnoreCase) && e.Args[1].Equals("LocalParityLaunch", StringComparison.OrdinalIgnoreCase) && e.Args[2].Equals("--config", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
try { new MainWindow(LocalParityLaunchService.LoadProfile(e.Args[3])).Show(); }
|
||||||
|
catch (Exception exception) { System.Windows.MessageBox.Show("Controlled local configuration blocked: " + exception.Message, "Launcher Next"); Shutdown(2); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.Args.FirstOrDefault()?.Equals("pilot", StringComparison.OrdinalIgnoreCase) == true)
|
||||||
|
{
|
||||||
|
// A pilot command is asynchronous and owns no WPF window. Without an
|
||||||
|
// explicit shutdown mode the dispatcher may terminate at its first
|
||||||
|
// await, after the Preview child has been started but before Apply
|
||||||
|
// and its evidence/rollback boundary have run.
|
||||||
|
ShutdownMode = System.Windows.ShutdownMode.OnExplicitShutdown;
|
||||||
|
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;
|
||||||
|
using System.IO;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Launcher.Next.App;
|
||||||
|
|
||||||
|
/// <summary>Explicit automation of the same LocalParityLaunchService used by the main-window actions.</summary>
|
||||||
|
internal static class LocalParityLaunchCli
|
||||||
|
{
|
||||||
|
public static async Task<int> RunAsync(string[] args)
|
||||||
|
{
|
||||||
|
if (args.Length != 4 || args[0] != "--mode" || args[1] != "LocalParityLaunch" || args[2] != "--config") return 2;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var service = new LocalParityLaunchService();
|
||||||
|
var prepared = await service.PrepareAsync(LocalParityLaunchService.LoadProfile(args[3]), CancellationToken.None);
|
||||||
|
var launch = await service.LaunchAsync(prepared, CancellationToken.None);
|
||||||
|
return launch.TerminalState == "Succeeded" ? 0 : 1;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
// Explicit local mode: retain a bounded diagnostic next to its supplied profile.
|
||||||
|
// Normal production and automation routes never take this path.
|
||||||
|
try { await File.WriteAllTextAsync(Path.Combine(Path.GetDirectoryName(args[3])!, "local-parity-last-error.txt"), exception.ToString()); } catch { }
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Launcher.Next.Application;
|
||||||
|
using Launcher.Next.Autodesk;
|
||||||
|
using Launcher.Next.Core;
|
||||||
|
using Launcher.Next.Infrastructure;
|
||||||
|
|
||||||
|
namespace Launcher.Next.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Explicit non-production workflow for a legacy-shaped configuration whose source,
|
||||||
|
/// cache, activation state, starter and evidence all live under one supplied local root.
|
||||||
|
/// It never accepts UNC roots or uses the legacy compatibility synchronizer.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record LocalParityLaunchProfile(
|
||||||
|
int SchemaVersion, string Name, string Root, string SettingsPath, string DownloadListPath,
|
||||||
|
string OverlayPath, string CatalogRoot, string PatternName, string EvidenceRoot, string? NetworkSettingsEndpoint = null,
|
||||||
|
LegacySettingsSnapshot? SettingsSnapshot = null);
|
||||||
|
|
||||||
|
internal sealed record LocalParityPackage(PackageRequirement Requirement, PackageManifest Manifest,
|
||||||
|
ImmutableLocalPackage Package, PackageActivationResult Activation);
|
||||||
|
|
||||||
|
internal sealed record LocalParityPrepared(LocalParityLaunchProfile Profile, IReadOnlyList<LocalParityPackage> Packages,
|
||||||
|
string StarterPath, string StarterSha256);
|
||||||
|
|
||||||
|
internal sealed record LocalParityLaunchResult(int ProcessId, bool StartupVerified, bool PackageLoadConfirmed,
|
||||||
|
string EvidencePath, string TerminalState);
|
||||||
|
|
||||||
|
internal sealed class LocalParityLaunchService
|
||||||
|
{
|
||||||
|
private readonly LegacyConfigurationAdapter adapter = new();
|
||||||
|
|
||||||
|
public static LocalParityLaunchProfile LoadProfile(string path)
|
||||||
|
{
|
||||||
|
var profile = JsonSerializer.Deserialize<LocalParityLaunchProfile>(File.ReadAllText(path), new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
|
||||||
|
?? throw new InvalidOperationException("LOCAL_PARITY_PROFILE_INVALID");
|
||||||
|
if (profile.SchemaVersion != 1 || string.IsNullOrWhiteSpace(profile.Name) || string.IsNullOrWhiteSpace(profile.PatternName))
|
||||||
|
throw new InvalidOperationException("LOCAL_PARITY_PROFILE_INVALID");
|
||||||
|
ValidateProfile(profile);
|
||||||
|
profile = profile with { Root = Path.GetFullPath(profile.Root) };
|
||||||
|
if (!string.IsNullOrWhiteSpace(profile.NetworkSettingsEndpoint))
|
||||||
|
{
|
||||||
|
var snapshot = LegacyNetworkConfiguration.AcquireApprovedSnapshot(profile.NetworkSettingsEndpoint, profile.Root);
|
||||||
|
profile = profile with { SettingsPath = snapshot.LocalSnapshotPath, SettingsSnapshot = snapshot };
|
||||||
|
}
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LocalParityPrepared> PrepareAsync(LocalParityLaunchProfile profile, CancellationToken ct)
|
||||||
|
{
|
||||||
|
ValidateProfile(profile);
|
||||||
|
var settings = adapter.ParseSettings(await File.ReadAllTextAsync(profile.SettingsPath, ct));
|
||||||
|
var pattern = settings.Patterns.Where(x => string.Equals(x.Name, profile.PatternName, StringComparison.Ordinal)).ToArray();
|
||||||
|
if (pattern.Length != 1) throw new InvalidOperationException(pattern.Length == 0 ? "LOCAL_PARITY_PATTERN_NOT_FOUND" : "LOCAL_PARITY_PATTERN_AMBIGUOUS");
|
||||||
|
var selection = adapter.ParseDownloadList(await File.ReadAllTextAsync(profile.DownloadListPath, ct));
|
||||||
|
var overlay = LegacyCompatibilityOverlayParser.Parse(await File.ReadAllTextAsync(profile.OverlayPath, ct));
|
||||||
|
var requirements = adapter.Join(pattern[0], selection, overlay);
|
||||||
|
var store = new LocalPackageStore(profile.Root, new Sha256FileHasher());
|
||||||
|
var prepared = new List<LocalParityPackage>();
|
||||||
|
foreach (var requirement in requirements)
|
||||||
|
{
|
||||||
|
var source = Under(profile.CatalogRoot, requirement.PackageId, requirement.RequiredVersion);
|
||||||
|
var manifestPath = Under(source, "package.manifest.json");
|
||||||
|
var manifest = PackageManifestJson.Parse(await File.ReadAllTextAsync(manifestPath, ct));
|
||||||
|
if (!ManifestMatches(manifest, requirement)) throw new InvalidOperationException("LOCAL_PARITY_MANIFEST_REQUIREMENT_MISMATCH");
|
||||||
|
var package = await store.MaterializeAsync(manifest, source, ct);
|
||||||
|
await store.BindAsync(requirement, package.Version, ct);
|
||||||
|
var activation = await store.ActivateImmutableAsync(package, requirement, ct);
|
||||||
|
if (activation.Outcome is not ("Activated" or "AlreadyActiveExact")) throw new InvalidOperationException("LOCAL_PARITY_ACTIVATION_" + activation.Outcome);
|
||||||
|
if (await store.ResolveAsync(requirement, ct) is null) throw new InvalidOperationException("LOCAL_PARITY_ACTIVE_REVALIDATION_FAILED");
|
||||||
|
prepared.Add(new(requirement, manifest, package, activation));
|
||||||
|
}
|
||||||
|
var starter = await GenerateStarterAsync(profile, prepared, ct);
|
||||||
|
return new(profile, prepared, starter.Path, starter.Sha256);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LocalParityLaunchResult> LaunchAsync(LocalParityPrepared prepared, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var product = new PlantProductDiscovery(new WindowsRegistryReader()).DiscoverPlant3D(2026)
|
||||||
|
?? throw new InvalidOperationException("PLANT3D_2026_NOT_FOUND");
|
||||||
|
var operationId = Guid.NewGuid().ToString("N");
|
||||||
|
var evidenceDirectory = Under(prepared.Profile.EvidenceRoot, operationId);
|
||||||
|
Directory.CreateDirectory(evidenceDirectory);
|
||||||
|
var bootstrapRoot = Path.GetDirectoryName(prepared.StarterPath)!;
|
||||||
|
var updatePackage = prepared.Packages.SingleOrDefault(x => string.Equals(x.Package.PackageId, "P_Rec_Update", StringComparison.Ordinal));
|
||||||
|
var updateAssemblyHash = updatePackage is null ? null : await HashAsync(Under(updatePackage.Package.LocalPackageRoot, updatePackage.Manifest.EntryAssembly), ct);
|
||||||
|
var logPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Promhimproekt", "PRecAnnot2026", "Logs", "runtime.jsonl");
|
||||||
|
var beforeLogLength = File.Exists(logPath) ? new FileInfo(logPath).Length : 0L;
|
||||||
|
var psi = new ProcessStartInfo { FileName = product.ExecutablePath, WorkingDirectory = Path.GetDirectoryName(product.ExecutablePath)!, UseShellExecute = false };
|
||||||
|
psi.ArgumentList.Add("/product"); psi.ArgumentList.Add("PLNT3D"); psi.ArgumentList.Add("/language"); psi.ArgumentList.Add("ru-RU");
|
||||||
|
// Controlled-local only: accepted Plant runtime bootstrap. Keep /b and the owned SCR last.
|
||||||
|
psi.ArgumentList.Add("/b"); psi.ArgumentList.Add(prepared.StarterPath);
|
||||||
|
psi.Environment["P_REC_ANNOT_RUNTIME_MODE"] = "Smoke";
|
||||||
|
psi.Environment["P_REC_UPDATE_MARKER_ROOT"] = Path.Combine(bootstrapRoot, "markers");
|
||||||
|
psi.Environment["P_REC_UPDATE_OPERATION_ID"] = operationId;
|
||||||
|
using var process = Process.Start(psi) ?? throw new InvalidOperationException("PLANT3D_PROCESS_NOT_CREATED");
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(30), ct);
|
||||||
|
var alive = !process.HasExited;
|
||||||
|
var marker = Path.Combine(Path.GetDirectoryName(prepared.StarterPath)!, "pxp-oa-netload.marker");
|
||||||
|
var markerConfirmed = File.Exists(marker);
|
||||||
|
var runtimeConfirmed = false;
|
||||||
|
if (File.Exists(logPath))
|
||||||
|
{
|
||||||
|
await using var stream = new FileStream(logPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||||
|
stream.Seek(beforeLogLength, SeekOrigin.Begin);
|
||||||
|
using var reader = new StreamReader(stream, Encoding.UTF8);
|
||||||
|
var newLog = await reader.ReadToEndAsync(ct);
|
||||||
|
runtimeConfirmed = newLog.Contains("AssemblyInitializeCompleted", StringComparison.Ordinal) && newLog.Contains($"\"ProcessId\":{process.Id}", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
var markerRoot = Path.Combine(bootstrapRoot, "markers");
|
||||||
|
var beforeUpdateNetload = File.Exists(Path.Combine(markerRoot, "BeforePRecUpdateNetload.marker"));
|
||||||
|
var afterUpdateNetload = File.Exists(Path.Combine(markerRoot, "AfterPRecUpdateNetload.marker"));
|
||||||
|
var scriptCompleted = File.Exists(Path.Combine(markerRoot, "ScriptCompleted.marker"));
|
||||||
|
var updateEntered = updatePackage is null || File.Exists(Path.Combine(markerRoot, "InitializeEntered.marker.json"));
|
||||||
|
var updateInitialized = updatePackage is null || File.Exists(Path.Combine(markerRoot, "AssemblyInitializeCompleted.marker.json"));
|
||||||
|
var updateCommandRegistered = updatePackage is null || File.Exists(Path.Combine(markerRoot, "CommandRegistrationVerified.marker.json"));
|
||||||
|
var loaded = runtimeConfirmed && updateInitialized && updateCommandRegistered;
|
||||||
|
var terminal = alive && loaded ? "Succeeded" : "Blocked";
|
||||||
|
var settingsSnapshot = prepared.Profile.SettingsSnapshot is null ? null : new { sourceEndpointFingerprint = prepared.Profile.SettingsSnapshot.SourceFingerprint, prepared.Profile.SettingsSnapshot.Sha256, prepared.Profile.SettingsSnapshot.Length, prepared.Profile.SettingsSnapshot.LastWriteUtc, prepared.Profile.SettingsSnapshot.AcquiredUtc, prepared.Profile.SettingsSnapshot.OfflineFallback };
|
||||||
|
var evidence = new { schemaVersion = 4, launcherCommit = ThisCommit(), autodeskCommit = "68f5006", operationId, configuration = prepared.Profile.Name, selectedPattern = prepared.Profile.PatternName, settingsSnapshot, orderedPluginIds = prepared.Packages.Select(x => x.Requirement.PackageId), exactPackages = prepared.Packages.Select((x, rosterPosition) => new { legacyId = x.Requirement.PackageId == "P_Rec_Update" ? "6ffef84b01097975f51fb3bbb11279e1" : "b455ee6eaed79d76f084846a60ce3284", rosterPosition, x.Package.PackageId, x.Package.Version, entryAssembly = x.Manifest.EntryAssembly, entrySha256 = x.Package.PackageId == "P_Rec_Update" ? updateAssemblyHash : HashAsync(Under(x.Package.LocalPackageRoot, x.Manifest.EntryAssembly), ct).GetAwaiter().GetResult(), manifestSha256 = HashAsync(Path.Combine(x.Package.LocalPackageRoot, "package.manifest.json"), ct).GetAwaiter().GetResult(), activation = x.Activation.Outcome, pointerGeneration = x.Activation.Receipt?.PointerGeneration, netloadAttempted = x.Package.PackageId != "P_Rec_Update" || beforeUpdateNetload, netloadReturned = x.Package.PackageId != "P_Rec_Update" || afterUpdateNetload, initializeEntered = x.Package.PackageId != "P_Rec_Update" || updateEntered, initializeCompleted = x.Package.PackageId != "P_Rec_Update" || updateInitialized, commandProbeResult = x.Package.PackageId == "P_Rec_Update" ? (updateCommandRegistered ? "COMMAND_REGISTRATION_VERIFIED" : "Unavailable") : "AssemblyInitializeCompleted" }), materialization = "MaterializedOrAlreadyPresentExact", bootstrapMechanism="StartupScript", commandLineSwitch="/b", scriptWasLastArgument=true, scriptSha256=prepared.StarterSha256, scriptMarkers = new { beforeUpdateNetload, afterUpdateNetload, scriptCompleted }, plant = new { product = "Plant3D", year = 2026, executableFingerprint = Fingerprint(product.ExecutablePath, operationId), interactive = true, batchArgumentUsed = true }, process = new { pid = process.Id, newProcessCreated=true, startupVerified = alive, packageLoadConfirmed = loaded, markerConfirmed, runtimeJournalConfirmed = runtimeConfirmed, updateEntered, updateInitialized, updateCommandRegistered, processInteractiveAfterScript=alive }, terminalState = terminal, terminalResult = loaded ? "PACKAGE_LOAD_VERIFIED" : updateEntered ? "PACKAGE_INITIALIZE_TIMEOUT" : afterUpdateNetload ? "PACKAGE_LOAD_FAILED" : beforeUpdateNetload ? "SCRIPT_ABORTED" : "SCRIPT_ABORTED", sideEffects = new { corporateDfsUsed = false, corporateDfsWriteAttempted = false, directDfsExecutionAttempted = false, productionProjectAccessed = false, productionPointerMutationAttempted = false, machineWideSecurityChanged=false, productionDeploymentAttempted = false } };
|
||||||
|
var evidencePath = await WriteEvidenceAsync(evidenceDirectory, evidence, ct);
|
||||||
|
return new(process.Id, alive, loaded, evidencePath, terminal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<(string Path, string Sha256)> GenerateStarterAsync(LocalParityLaunchProfile profile, IReadOnlyList<LocalParityPackage> packages, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var starterRoot = Under(profile.Root, "bootstrap", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(starterRoot);
|
||||||
|
var entries = packages.Select(x => (x.Package.PackageId, Path: Under(x.Package.LocalPackageRoot, x.Manifest.EntryAssembly))).ToArray();
|
||||||
|
if (entries.Any(x => !File.Exists(x.Path))) throw new InvalidOperationException("LOCAL_PARITY_ENTRY_MISSING");
|
||||||
|
var markerRoot = Under(starterRoot, "markers"); Directory.CreateDirectory(markerRoot);
|
||||||
|
var helperPath = Under(starterRoot, "bootstrap-markers.lsp");
|
||||||
|
var helper = "(defun PXP-MARK (p s / f) (setq f (open p \"w\")) (write-line s f) (close f) (princ))";
|
||||||
|
await File.WriteAllTextAsync(helperPath, helper, new UTF8Encoding(false), ct);
|
||||||
|
string LispPath(string path) => path.Replace('\\', '/');
|
||||||
|
string Mark(string name) => "(PXP-MARK \"" + LispPath(Under(markerRoot, name + ".marker")) + "\" \"" + name + "\")\r\n";
|
||||||
|
var content = "(load \"" + LispPath(helperPath) + "\")\r\n_.FILEDIA\r\n0\r\n_.CMDDIA\r\n0\r\n";
|
||||||
|
foreach (var entry in entries)
|
||||||
|
{
|
||||||
|
if (entry.PackageId == "P_Rec_Update") content += Mark("BeforePRecUpdateNetload");
|
||||||
|
content += "_.NETLOAD\r\n\"" + entry.Path.Replace("\"", "\\\"") + "\"\r\n";
|
||||||
|
if (entry.PackageId == "P_Rec_Update") content += Mark("AfterPRecUpdateNetload") + "P-R-UPD-STATUS\r\n";
|
||||||
|
}
|
||||||
|
content += Mark("ScriptCompleted");
|
||||||
|
var path = Under(starterRoot, "startup.scr");
|
||||||
|
await File.WriteAllTextAsync(path, content, new UTF8Encoding(false), ct);
|
||||||
|
return (path, await HashAsync(path, ct));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ManifestMatches(PackageManifest manifest, PackageRequirement requirement) =>
|
||||||
|
manifest.PackageId == requirement.PackageId && manifest.Version == requirement.RequiredVersion && manifest.Product == requirement.Product && manifest.ProductYear == requirement.ProductYear && string.Equals(manifest.Architecture, requirement.Architecture, StringComparison.OrdinalIgnoreCase) && string.Equals(manifest.RolloutChannel, requirement.Channel, StringComparison.OrdinalIgnoreCase);
|
||||||
|
private static void ValidateProfile(LocalParityLaunchProfile profile)
|
||||||
|
{
|
||||||
|
if (profile.SchemaVersion != 1 || string.IsNullOrWhiteSpace(profile.Root)) throw new InvalidOperationException("LOCAL_PARITY_PROFILE_INVALID");
|
||||||
|
var root = Path.GetFullPath(profile.Root);
|
||||||
|
if (!Path.IsPathFullyQualified(root) || root.StartsWith("\\\\", StringComparison.Ordinal) || root.Contains("?", StringComparison.Ordinal) || new DirectoryInfo(root).Exists && new DirectoryInfo(root).Attributes.HasFlag(FileAttributes.ReparsePoint)) throw new InvalidOperationException("LOCAL_PARITY_ROOT_NOT_LOCAL");
|
||||||
|
foreach (var path in new[] { profile.SettingsPath, profile.DownloadListPath, profile.OverlayPath, profile.CatalogRoot, profile.EvidenceRoot }) if (!UnderRoot(root, path)) throw new InvalidOperationException("LOCAL_PARITY_PATH_OUTSIDE_ROOT");
|
||||||
|
}
|
||||||
|
private static bool UnderRoot(string root, string path) { var r = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar; var p = Path.GetFullPath(path); return p.StartsWith(r, StringComparison.OrdinalIgnoreCase); }
|
||||||
|
private static string Under(string root, params string[] parts) { var result = Path.GetFullPath(Path.Combine(new[] { root }.Concat(parts).ToArray())); if (!UnderRoot(root, result) && !string.Equals(Path.GetFullPath(root), result, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("LOCAL_PARITY_PATH_ESCAPE"); return result; }
|
||||||
|
private static async Task<string> HashAsync(string path, CancellationToken ct) { await using var stream = File.OpenRead(path); return Convert.ToHexString(await SHA256.HashDataAsync(stream, ct).ConfigureAwait(false)); }
|
||||||
|
private static string Fingerprint(string path, string salt) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(salt + "|" + path)));
|
||||||
|
internal static async Task<string> WriteEvidenceAsync(string directory, object value, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var final = Path.Combine(directory, "controlled-local-parity-launch.json");
|
||||||
|
var temp = final + ".tmp";
|
||||||
|
var json = JsonSerializer.Serialize(value);
|
||||||
|
await File.WriteAllTextAsync(temp, json, Encoding.UTF8, ct).ConfigureAwait(false);
|
||||||
|
File.Move(temp, final, overwrite: true);
|
||||||
|
using var doc = JsonDocument.Parse(await File.ReadAllTextAsync(final, ct).ConfigureAwait(false));
|
||||||
|
if (doc.RootElement.GetProperty("schemaVersion").GetInt32() != 4) throw new InvalidOperationException("LOCAL_PARITY_EVIDENCE_INVALID");
|
||||||
|
return final;
|
||||||
|
}
|
||||||
|
private static string ThisCommit()
|
||||||
|
{
|
||||||
|
var embedded = Environment.GetEnvironmentVariable("LAUNCHER_COMMIT");
|
||||||
|
if (!string.IsNullOrWhiteSpace(embedded)) return embedded;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var releaseManifest = Path.Combine(AppContext.BaseDirectory, "owner-review-release.json");
|
||||||
|
if (File.Exists(releaseManifest))
|
||||||
|
{
|
||||||
|
using var document = JsonDocument.Parse(File.ReadAllText(releaseManifest));
|
||||||
|
if (document.RootElement.TryGetProperty("launcherCommit", out var commit) && !string.IsNullOrWhiteSpace(commit.GetString())) return commit.GetString()!;
|
||||||
|
}
|
||||||
|
var start = new ProcessStartInfo("git", "rev-parse HEAD") { WorkingDirectory = AppContext.BaseDirectory, UseShellExecute = false, RedirectStandardOutput = true };
|
||||||
|
using var process = Process.Start(start);
|
||||||
|
var value = process is null ? "" : process.StandardOutput.ReadToEnd().Trim();
|
||||||
|
return string.IsNullOrWhiteSpace(value) ? "unknown" : value;
|
||||||
|
}
|
||||||
|
catch { return "unknown"; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
<Window x:Class="Launcher.Next.App.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="Launcher Next (Foundation)" Height="300" Width="540"><StackPanel Margin="20"><TextBlock FontSize="20" Text="Launcher Next — foundation"/><TextBlock Margin="0,12,0,0" Text="Plant 3D: discovery available in diagnostics"/><TextBlock Text="Mode: Legacy (default); NextPilot requires explicit configuration"/><TextBlock Text="Package: no production catalog connected"/><Button Margin="0,16,0,0" IsEnabled="False" Content="Launch (disabled: dry-run foundation)"/></StackPanel></Window>
|
<Window x:Class="Launcher.Next.App.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="Launcher Next" Height="620" Width="840"><Grid Margin="20"><Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/><RowDefinition Height="*"/><RowDefinition Height="Auto"/></Grid.RowDefinitions><TextBlock FontSize="22" FontWeight="SemiBold" Text="Launcher Next"/><GroupBox Grid.Row="1" Header="Выберите ПО" Margin="0,14,0,0"><StackPanel Margin="8"><ListBox x:Name="Products" DisplayMemberPath="Name" SelectionChanged="Products_SelectionChanged" Height="78"/><TextBlock x:Name="ProductStatus" Margin="0,7,0,0" TextWrapping="Wrap"/></StackPanel></GroupBox><GroupBox Grid.Row="2" Header="Проект и режим загрузки" Margin="0,10,0,0"><Grid Margin="8"><Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions><StackPanel><TextBlock Text="Путь к проекту:"/><TextBlock x:Name="ProjectPath" Text="Выберите программное обеспечение" TextWrapping="Wrap"/><StackPanel Orientation="Horizontal" Margin="0,10,0,0"><RadioButton x:Name="AutomaticMode" Content="Автоматически" IsChecked="True" Checked="LoadMode_Changed"/><RadioButton x:Name="ListMode" Content="По списку" Margin="22,0,0,0" Checked="LoadMode_Changed"/></StackPanel></StackPanel><Button Grid.Column="1" x:Name="SelectProjectButton" Content="Выбрать проект" IsEnabled="False" VerticalAlignment="Center" Margin="16,0,0,0" Click="SelectProject_Click"/></Grid></GroupBox><GroupBox Grid.Row="3" Header="Список плагинов" Margin="0,10,0,0"><StackPanel Margin="8"><TextBlock x:Name="PluginStatus" Text="Список будет определён после выбора проекта." TextWrapping="Wrap"/><ListBox x:Name="Plugins" SelectionMode="Multiple" DisplayMemberPath="Id" Margin="0,8,0,0" Height="110" IsEnabled="False"/></StackPanel></GroupBox><StackPanel Grid.Row="4" Margin="0,14,0,0"><WrapPanel><Button x:Name="StartButton" Content="Выбрать плагины и приступить к работе" IsEnabled="False" Click="Start_Click"/><Button Content="Справка" Margin="8,0,0,0" Click="Help_Click"/><Button Content="Диагностика" Margin="8,0,0,0" Click="Diagnostics_Click"/></WrapPanel><TextBlock x:Name="Status" Margin="0,10,0,0" TextWrapping="Wrap"/><TextBlock x:Name="Version" Margin="0,5,0,0"/></StackPanel></Grid></Window>
|
||||||
|
|||||||
@@ -1 +1,221 @@
|
|||||||
namespace Launcher.Next.App; public partial class MainWindow : System.Windows.Window { public MainWindow() => InitializeComponent(); }
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading;
|
||||||
|
using Launcher.Next.Application;
|
||||||
|
using Launcher.Next.Autodesk;
|
||||||
|
|
||||||
|
namespace Launcher.Next.App;
|
||||||
|
|
||||||
|
public partial class MainWindow : System.Windows.Window
|
||||||
|
{
|
||||||
|
private sealed record LauncherProduct(string Id, string Name, string? Executable, string Diagnostic);
|
||||||
|
private readonly LocalParityLaunchProfile? localProfile;
|
||||||
|
private readonly LocalParityLaunchService localService = new();
|
||||||
|
private readonly LegacyConfigurationAdapter adapter = new();
|
||||||
|
private LauncherProduct? selected;
|
||||||
|
private string? projectPath;
|
||||||
|
private string? manualRosterPath;
|
||||||
|
private string? selectedRosterSnapshotPath;
|
||||||
|
private string stage = "ProductSelection";
|
||||||
|
private string code = "PRODUCT_NOT_SELECTED";
|
||||||
|
private string detail = "";
|
||||||
|
private string pattern = "";
|
||||||
|
private string expectedRoster = "";
|
||||||
|
private string orderedIds = "";
|
||||||
|
private string mappingState = "NotEvaluated";
|
||||||
|
private string preparationState = "NotStarted";
|
||||||
|
private string activationState = "NotStarted";
|
||||||
|
private string bootstrapState = "NotStarted";
|
||||||
|
private string processState = "NotStarted";
|
||||||
|
private int? processId;
|
||||||
|
private string packageLoadMarker = "NotStarted";
|
||||||
|
|
||||||
|
public MainWindow() { InitializeComponent(); Initialize(); }
|
||||||
|
internal MainWindow(LocalParityLaunchProfile profile) { localProfile = profile; InitializeComponent(); Initialize(); }
|
||||||
|
|
||||||
|
private void Initialize()
|
||||||
|
{
|
||||||
|
var plant = new PlantProductDiscovery(new WindowsRegistryReader()).DiscoverPlant3D(2026);
|
||||||
|
var navisworks = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Autodesk", "Navisworks Manage 2026", "Roamer.exe");
|
||||||
|
Products.ItemsSource = new[]
|
||||||
|
{
|
||||||
|
new LauncherProduct("navisworks", "Navisworks Manage 2026", File.Exists(navisworks) ? navisworks : null, File.Exists(navisworks) ? "Найден" : "Navisworks Manage 2026 не найден"),
|
||||||
|
new LauncherProduct("pnid", "Plant P&ID 2026", plant?.ExecutablePath, plant is null ? "Plant P&ID 2026 не найден" : "Используется установленный Plant runtime"),
|
||||||
|
new LauncherProduct("plant3d", "Plant 3D 2026", plant?.ExecutablePath, plant is null ? "Plant 3D 2026 не найден" : "Найден")
|
||||||
|
};
|
||||||
|
Version.Text = "Версия: " + typeof(MainWindow).Assembly.GetName().Version;
|
||||||
|
Transition("ProductSelection", "PRODUCT_NOT_SELECTED", "Выберите программное обеспечение");
|
||||||
|
if (localProfile is not null) Products.SelectedIndex = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Products_SelectionChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
selected = Products.SelectedItem as LauncherProduct;
|
||||||
|
projectPath = null;
|
||||||
|
manualRosterPath = null;
|
||||||
|
ClearOperationState();
|
||||||
|
ProjectPath.Text = selected is null ? "Выберите программное обеспечение" : "Проект не выбран";
|
||||||
|
SelectProjectButton.IsEnabled = selected?.Executable is not null;
|
||||||
|
ProductStatus.Text = selected?.Diagnostic ?? "";
|
||||||
|
Transition(selected?.Executable is null ? "ProductSelectionFailed" : "ProjectSelection", selected?.Executable is null ? "PRODUCT_NOT_FOUND" : "PROJECT_NOT_SELECTED", selected?.Executable is null ? selected?.Diagnostic ?? "Продукт не найден" : "Выберите проект");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SelectProject_Click(object sender, System.Windows.RoutedEventArgs e) => SelectProjectFile_Click(sender, e);
|
||||||
|
|
||||||
|
private void SelectProjectFile_Click(object sender, System.Windows.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (selected is null) return;
|
||||||
|
var product = LegacyProductSelections.For(selected.Id);
|
||||||
|
var dialog = new Microsoft.Win32.OpenFileDialog { Title = "Выберите проект", Filter = product.Filter, CheckFileExists = true, Multiselect = false };
|
||||||
|
if (dialog.ShowDialog() != true) return;
|
||||||
|
ClearOperationState();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Transition("ProjectSelected", "PROJECT_SELECTED", "Проверка проекта");
|
||||||
|
projectPath = LegacyProductSelections.ValidateSelectedFile(product, dialog.FileName);
|
||||||
|
manualRosterPath = null;
|
||||||
|
ProjectPath.Text = Path.GetFileName(projectPath);
|
||||||
|
Transition("ProjectValidated", "PROJECT_VALID", "Поиск списка плагинов");
|
||||||
|
ResolvePlugins();
|
||||||
|
}
|
||||||
|
catch (Exception ex) { ReportFailure(ex, "ProjectValidationFailed"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LoadMode_Changed(object sender, System.Windows.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (projectPath is null || selected is null) return;
|
||||||
|
if (ListMode.IsChecked == true)
|
||||||
|
{
|
||||||
|
var dialog = new Microsoft.Win32.OpenFileDialog { Title = "Выберите download_list.xml", Filter = "download_list.xml (*.xml)|*.xml|All files (*.*)|*.*", CheckFileExists = true, Multiselect = false };
|
||||||
|
if (dialog.ShowDialog() != true) { AutomaticMode.IsChecked = true; return; }
|
||||||
|
manualRosterPath = dialog.FileName;
|
||||||
|
}
|
||||||
|
else manualRosterPath = null;
|
||||||
|
ClearOperationState();
|
||||||
|
ResolvePlugins();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResolvePlugins()
|
||||||
|
{
|
||||||
|
if (selected is null || projectPath is null) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var product = LegacyProductSelections.For(selected.Id);
|
||||||
|
pattern = localProfile is not null && selected.Id == "plant3d" ? localProfile.PatternName : product.Pattern;
|
||||||
|
expectedRoster = manualRosterPath ?? LegacyProductSelections.AutomaticRoster(product, projectPath);
|
||||||
|
Transition("AutomaticRosterPathResolved", "AUTOMATIC_ROSTER_PATH_RESOLVED", "Поиск списка плагинов", expectedRoster);
|
||||||
|
var plan = LegacyProductSelections.ReadRoster(product, projectPath, manualRosterPath, adapter, localProfile?.Root);
|
||||||
|
orderedIds = string.Join(", ", plan.OrderedIds);
|
||||||
|
selectedRosterSnapshotPath = plan.SnapshotPath ?? plan.RosterPath;
|
||||||
|
if (plan.SnapshotSha256 is not null) detail = $"Roster snapshot SHA-256: {plan.SnapshotSha256}";
|
||||||
|
Plugins.ItemsSource = plan.OrderedIds;
|
||||||
|
Plugins.IsEnabled = false;
|
||||||
|
PluginStatus.Text = "Источник списка: " + (manualRosterPath is null ? "Автоматически" : "Выбран вручную");
|
||||||
|
Transition("OrderedPluginIdsResolved", "PLUGIN_PLAN_RESOLVED", "Плагины определены");
|
||||||
|
mappingState = plan.UnmappedIds.Count == 0 ? "Mapped" : "Unmapped: " + string.Join(", ", plan.UnmappedIds);
|
||||||
|
if (plan.UnmappedIds.Count > 0)
|
||||||
|
{
|
||||||
|
StartButton.IsEnabled = false;
|
||||||
|
Transition("PackageMappingFailed", "PACKAGE_MAPPING_NOT_FOUND", $"Для {product.DisplayName} отсутствуют пакеты 2026: {string.Join(", ", plan.UnmappedIds)}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (localProfile is null || selected.Id != "plant3d")
|
||||||
|
{
|
||||||
|
StartButton.IsEnabled = false;
|
||||||
|
Transition("PackageMappingFailed", "PACKAGE_MAPPING_NOT_FOUND", "Для выбранных плагинов отсутствуют пакеты 2026");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var unmapped = EvaluateLocalProfileDefinitions(plan);
|
||||||
|
if (unmapped.Count > 0)
|
||||||
|
{
|
||||||
|
mappingState = "Known legacy plugin, package 2026 absent: " + string.Join(", ", unmapped);
|
||||||
|
StartButton.IsEnabled = false;
|
||||||
|
Transition("PackageMappingFailed", "PACKAGE_MAPPING_NOT_FOUND", "Для части плагинов проекта пока отсутствуют пакеты версии 2026");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mappingState = "Mapped";
|
||||||
|
StartButton.IsEnabled = true;
|
||||||
|
Transition("Ready", "READY", "Готово к запуску");
|
||||||
|
}
|
||||||
|
catch (Exception ex) { ReportFailure(ex, "RosterResolutionFailed"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private IReadOnlyList<string> EvaluateLocalProfileDefinitions(LegacyPluginPlan plan)
|
||||||
|
{
|
||||||
|
var settings = adapter.ParseSettings(File.ReadAllText(localProfile!.SettingsPath));
|
||||||
|
var selectedPattern = settings.Patterns.SingleOrDefault(x => string.Equals(x.Name, localProfile.PatternName, StringComparison.Ordinal));
|
||||||
|
if (selectedPattern is null) throw new InvalidOperationException("LOCAL_PARITY_PATTERN_NOT_FOUND");
|
||||||
|
var overlay = LegacyCompatibilityOverlayParser.Parse(File.ReadAllText(localProfile.OverlayPath));
|
||||||
|
var missing = new List<string>();
|
||||||
|
foreach (var id in plan.OrderedIds.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var definitions = selectedPattern.Plugins.Where(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase)).ToArray();
|
||||||
|
if (definitions.Length == 0) throw new InvalidOperationException("LEGACY_PLUGIN_ID_NOT_DEFINED");
|
||||||
|
if (definitions.Length > 1) throw new InvalidOperationException("AMBIGUOUS_LEGACY_PLUGIN_DEFINITION");
|
||||||
|
if (!definitions[0].IsAllowedOn(Environment.MachineName)) { missing.Add(id + " (machine restricted)"); continue; }
|
||||||
|
if (!overlay.Plugins.Any(x => string.Equals(x.LegacyId, id, StringComparison.OrdinalIgnoreCase))) missing.Add(id);
|
||||||
|
}
|
||||||
|
return missing;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void Start_Click(object sender, System.Windows.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (localProfile is null || selected?.Id != "plant3d" || projectPath is null || !StartButton.IsEnabled) return;
|
||||||
|
StartButton.IsEnabled = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
preparationState = "InProgress";
|
||||||
|
Transition("PackagePreparation", "PACKAGE_PREPARING", "Подготовка плагинов");
|
||||||
|
var profile = localProfile with { DownloadListPath = selectedRosterSnapshotPath ?? localProfile.DownloadListPath };
|
||||||
|
var prepared = await localService.PrepareAsync(profile, CancellationToken.None);
|
||||||
|
preparationState = "Succeeded";
|
||||||
|
activationState = string.Join(", ", prepared.Packages.Select(x => x.Activation.Outcome));
|
||||||
|
bootstrapState = "Created";
|
||||||
|
Transition("ProcessStart", "PROCESS_STARTING", "Запуск Plant 3D");
|
||||||
|
var result = await localService.LaunchAsync(prepared, CancellationToken.None);
|
||||||
|
processId = result.ProcessId;
|
||||||
|
processState = result.StartupVerified ? "Started" : "ExitedEarly";
|
||||||
|
packageLoadMarker = result.PackageLoadConfirmed ? "AssemblyInitializeCompleted" : "Missing";
|
||||||
|
if (result.TerminalState == "Succeeded") Transition("Ready", "PACKAGE_LOAD_CONFIRMED", $"Plant 3D готов к работе (PID {result.ProcessId})");
|
||||||
|
else Transition("PackageLoadFailed", "PACKAGE_LOAD_NOT_CONFIRMED", "Plant 3D запущен, но автоматическая загрузка плагинов не подтверждена");
|
||||||
|
}
|
||||||
|
catch (Exception ex) { ReportFailure(ex, "PackagePreparationFailed"); }
|
||||||
|
finally { StartButton.IsEnabled = stage == "Ready"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearOperationState()
|
||||||
|
{
|
||||||
|
Plugins.ItemsSource = null;
|
||||||
|
Plugins.IsEnabled = false;
|
||||||
|
StartButton.IsEnabled = false;
|
||||||
|
pattern = expectedRoster = orderedIds = detail = "";
|
||||||
|
selectedRosterSnapshotPath = null;
|
||||||
|
mappingState = preparationState = activationState = bootstrapState = processState = "NotStarted";
|
||||||
|
processId = null;
|
||||||
|
packageLoadMarker = "NotStarted";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Transition(string nextStage, string nextCode, string message, string? technicalDetail = null)
|
||||||
|
{
|
||||||
|
stage = nextStage;
|
||||||
|
code = nextCode;
|
||||||
|
detail = technicalDetail ?? "";
|
||||||
|
Status.Text = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReportFailure(Exception exception, string fallbackStage)
|
||||||
|
{
|
||||||
|
var failure = LauncherOperationFailures.Classify(exception.Message, fallbackStage, fallbackStage == "ProcessStartFailed");
|
||||||
|
Transition(failure.Stage, failure.Code, failure.UserMessage, exception.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Help_Click(object sender, System.Windows.RoutedEventArgs e) => System.Windows.MessageBox.Show("Выберите ПО и project file. В автоматическом режиме Launcher использует список плагинов, определённый проектом. Режим \"По списку\" позволяет явно выбрать download_list.xml.", "Справка");
|
||||||
|
|
||||||
|
private void Diagnostics_Click(object sender, System.Windows.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var report = $"Launcher version: {typeof(MainWindow).Assembly.GetName().Version}\nSelected product: {selected?.Name}\nDiscovery: {selected?.Diagnostic}\nSelected project: {projectPath}\nProject validation: {(projectPath is null ? "NotStarted" : "Passed")}\nSelected mode: {(AutomaticMode.IsChecked == true ? "Automatic" : "Manual")}\nPattern: {pattern}\nExpected download list: {expectedRoster}\nOrdered legacy IDs: {orderedIds}\nDefinition join: {mappingState}\nPreparation: {preparationState}\nActivation: {activationState}\nBootstrap: {bootstrapState}\nProcess start: {processState}\nPID: {processId}\nPackage-load marker: {packageLoadMarker}\nLast error stage: {stage}\nLast error code: {code}\nTechnical detail: {detail}";
|
||||||
|
System.Windows.MessageBox.Show(report, "Диагностика Launcher Next");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.IO;
|
||||||
|
using Launcher.Next.Core;
|
||||||
|
using Launcher.Next.Infrastructure;
|
||||||
|
|
||||||
|
namespace Launcher.Next.App;
|
||||||
|
|
||||||
|
internal static class PackageLifecyclePilotCli
|
||||||
|
{
|
||||||
|
private sealed class Fault(PackageLifecycleFaultPoint point) : IPackageLifecycleFaultInjector
|
||||||
|
{ public void ThrowIfRequested(PackageLifecycleFaultPoint requested) { if(requested==point) throw new IOException($"INJECTED:{point}"); } }
|
||||||
|
|
||||||
|
public static async Task<int> RunAsync(string[] args)
|
||||||
|
{
|
||||||
|
if (args.Length != 4 || args[0] != "--mode" || args[1] != "PackageLifecyclePilot" || args[2] != "--root") return 2;
|
||||||
|
ResolvedPackageLifecycleRoots roots; try { roots=new PathResolver().ResolvePackageLifecycleRoots(args[3]); } catch { return 2; }
|
||||||
|
var id=Guid.NewGuid().ToString("N"); var started=DateTimeOffset.UtcNow; var evidenceDir=Path.Combine(roots.Evidence.Value,id);
|
||||||
|
Directory.CreateDirectory(evidenceDir); Directory.CreateDirectory(roots.Catalog.Value); Directory.CreateDirectory(Path.Combine(roots.Pilot.Value,"foreign-staging-fixture"));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var store=new LocalPackageStore(roots.Pilot.Value,new Sha256FileHasher());
|
||||||
|
async Task<(ImmutableLocalPackage Package,PackageManifest Manifest)> Materialize(string version,byte seed,LocalPackageStore target)
|
||||||
|
{ var a=new[]{seed,(byte)(seed+1)};var b=new[]{(byte)(seed+2)};await File.WriteAllBytesAsync(Path.Combine(roots.Catalog.Value,"entry.dll"),a);await File.WriteAllBytesAsync(Path.Combine(roots.Catalog.Value,"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 target.MaterializeAsync(manifest,roots.Catalog.Value,CancellationToken.None),manifest); }
|
||||||
|
PackageRequirement Req(string v)=>new("Pilot.Package","Plant3D",2026,"x64","pilot",v,"pilot-roster",true);
|
||||||
|
var initial=await store.GetCurrentVersionAsync("Pilot.Package",CancellationToken.None);
|
||||||
|
var (a,ma)=await Materialize("1.0.0",1,store); var afterMaterializeA=await store.GetCurrentVersionAsync("Pilot.Package",CancellationToken.None); await store.BindAsync(Req("1.0.0"),"1.0.0",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,store); var beforeB=await store.GetCurrentVersionAsync("Pilot.Package",CancellationToken.None); await store.BindAsync(Req("2.0.0"),"2.0.0",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 lkg=await store.ResolveAsync(Req("1.0.0"),CancellationToken.None);
|
||||||
|
|
||||||
|
async Task<string> ExpectedFault(PackageLifecycleFaultPoint point,string version)
|
||||||
|
{ try { await Materialize(version,9,new LocalPackageStore(roots.Pilot.Value,new Sha256FileHasher(),new Fault(point))); return "UnexpectedSuccess"; } catch { return "Blocked"; } }
|
||||||
|
var materializationFault=await ExpectedFault(PackageLifecycleFaultPoint.AfterPayloadCopyBeforeValidation,"9.0.0");
|
||||||
|
string pointerFault; try { await new LocalPackageStore(roots.Pilot.Value,new Sha256FileHasher(),new Fault(PackageLifecycleFaultPoint.BeforePointerReplace)).ActivateImmutableAsync(b,Req("2.0.0"),CancellationToken.None);pointerFault="UnexpectedSuccess";}catch{pointerFault="Blocked";}
|
||||||
|
var foreign=Path.Combine(roots.Pilot.Value,"foreign-staging-fixture");var fake=new PackageStagingOwnership("foreign","Pilot.Package","0",foreign,DateTimeOffset.UtcNow,"foreign");var cleanup=(await store.CleanupOwnedStagingAsync(fake,CancellationToken.None)).Outcome;
|
||||||
|
var success=initial is null&&afterMaterializeA is null&&aa.Outcome=="Activated"&&repeat.Outcome=="AlreadyActiveExact"&&beforeB=="1.0.0"&&ab.Outcome=="Activated"&&rollback.Outcome=="RolledBack"&¤t=="1.0.0"&&lkg?.Version=="1.0.0"&&materializationFault=="Blocked"&&pointerFault=="Blocked"&&cleanup=="OwnershipUnknown"&&Directory.Exists(foreign);
|
||||||
|
object Package(PackageManifest m)=>new{exactIdentity=new{m.PackageId,m.Product,m.ProductYear,m.Architecture,m.RolloutChannel,m.Version},manifestSha256=Convert.ToHexString(SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(m))),payloadSetSha256=PayloadSet(m),payloadFiles=m.Files.Select(x=>new{x.Path,x.Length,x.Sha256})};
|
||||||
|
var evidence=new { schemaVersion=2,launcherCommit=ThisCommit(),operationId=id,mode="PackageLifecyclePilot",startedUtc=started,endedUtc=DateTimeOffset.UtcNow,terminalState=success?"Succeeded":"Blocked",exitClassification=success?"CONTROLLED_FILESYSTEM_PILOT_VERIFIED":"BLOCKED",sourceClassification="SyntheticLocal",typedRoots=new[]{Role("Catalog",roots.Catalog.Value,id),Role("Staging",roots.Staging.Value,id),Role("VersionStore",roots.VersionStore.Value,id),Role("Active",roots.Active.Value,id),Role("Evidence",roots.Evidence.Value,id)},packages=new[]{Package(ma),Package(mb)},pointers=new{initial,afterMaterializeA,afterActivateA=aa.Outcome,generationA=aa.Receipt?.PointerGeneration,repeatedA=repeat.Outcome,beforeActivateB=beforeB,afterActivateB=ab.Outcome,generationB=ab.Receipt?.PointerGeneration,afterRollback=rollback.Outcome,current,contentBoundSchema=2},exactLkg=new{selected=lkg?.Version,directoryScanAttempted=false,exactBinding=lkg is not null},failureScenarios=new[]{new{faultPoint="AfterPayloadCopyBeforeValidation",actual=materializationFault,invariant=current=="1.0.0"},new{faultPoint="BeforePointerReplace",actual=pointerFault,invariant=(await store.GetCurrentVersionAsync("Pilot.Package",CancellationToken.None))=="1.0.0"},new{faultPoint="OwnershipMismatch",actual=cleanup,invariant=Directory.Exists(foreign)}},locking=new{scope="ProcessLocal",crossProcessSafety="NotVerified",networkShareSafety="NotSupported"},sideEffects=new{corporateDfsUsed=false,corporateDfsWriteAttempted=false,autoCadLaunchAttempted=false,processLaunchAttempted=false,productionPointerMutationAttempted=false,productionProjectAccessed=false,foundationUiCreated=false},claims=new{materializationIsolation="Verified",ownedStagingCleanup="Verified",immutableContentBinding="Verified",activation="Verified",activationIdempotency="Verified",receiptBoundRollback="Verified",deterministicFailureRecovery="Verified",exactLkg="Verified",processLocalConcurrency="Verified",corporateDfs="NotVerified",autoCadRuntime="NotVerified",productionAcceptance="NotVerified"}};
|
||||||
|
var final=Path.Combine(evidenceDir,"package-lifecycle-pilot.json");var temp=final+"."+id+".tmp";await using(var stream=new FileStream(temp,FileMode.CreateNew,FileAccess.Write,FileShare.None)){await JsonSerializer.SerializeAsync(stream,evidence);await stream.FlushAsync();stream.Flush(true);}File.Move(temp,final);using var reread=JsonDocument.Parse(await File.ReadAllTextAsync(final));if(reread.RootElement.GetProperty("schemaVersion").GetInt32()!=2)return 10;return success?0:10;
|
||||||
|
}
|
||||||
|
catch { return 10; }
|
||||||
|
}
|
||||||
|
private static object Role(string role,string path,string salt)=>new{role,localOnly=true,topologyValidated=true,reparseRejected=true,fingerprint=Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(salt+Path.GetFullPath(path))))};
|
||||||
|
private static string PayloadSet(PackageManifest m)=>Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join("|",m.Files.OrderBy(x=>x.Path,StringComparer.Ordinal).Select(x=>$"{x.Path}:{x.Length}:{x.Sha256}")))));
|
||||||
|
private static string ThisCommit()=>Environment.GetEnvironmentVariable("LAUNCHER_COMMIT")??"WORKTREE_BUILD";
|
||||||
|
}
|
||||||
+281
-12
@@ -20,21 +20,28 @@ internal static class PilotCli
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (args.Length == 0 || args[0] is not ("prepare" or "run" or "status" or "rollback")) 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];
|
var command = args[0];
|
||||||
if (!TryParse(command, args.Skip(1).ToArray(), out var options, out var error)) return Fail(Blocked, error!);
|
if (!TryParse(command, args.Skip(1).ToArray(), out var options, out var error)) return Fail(Blocked, error!);
|
||||||
var root = options.PilotRoot!;
|
var root = options.PilotRoot!;
|
||||||
if (command == "status") return await StatusAsync(root);
|
if (command == "status") return await StatusAsync(root);
|
||||||
if (command == "rollback") return await RollbackAsync(root);
|
if (command == "rollback") return await RollbackAsync(root);
|
||||||
if (!TryReadConfiguration(root, out var config, out error)) return Fail(Blocked, error!);
|
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!);
|
if (!TryBuildManifest(options.PackageSource!, options.EntryAssembly!, out var manifest, out var manifestHash, out error)) return Fail(Blocked, error!);
|
||||||
|
var startupRoster = StartupRosterPreflight.Inspect(options.PackageSource!, manifest!);
|
||||||
|
Directory.CreateDirectory(Path.Combine(root, "evidence"));
|
||||||
|
StartupRosterPreflight.Write(Path.Combine(root, "evidence", "startup-roster-preflight.json"), startupRoster);
|
||||||
|
if (!startupRoster.IsSafe) return Fail(Blocked, "CANDIDATE_ISOLATION_" + startupRoster.Status.ToString().ToUpperInvariant());
|
||||||
Directory.CreateDirectory(Path.Combine(root, "catalog"));
|
Directory.CreateDirectory(Path.Combine(root, "catalog"));
|
||||||
await File.WriteAllTextAsync(Path.Combine(root, "catalog", "P_Rec_Annot.manifest.json"), JsonSerializer.Serialize(manifest));
|
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);
|
if (command == "run" && File.Exists(SessionPath(root)) && await HasActiveSession(root)) return await HandleExistingSession(root);
|
||||||
var service = CreateService();
|
var service = CreateService(paths);
|
||||||
var product = new ProductInstallation(@"C:\Program Files\Autodesk\AutoCAD 2026\acad.exe", "2026", "x64", []);
|
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 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);
|
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));
|
if (prepared.Plan is null) return Fail(prepared.SafetyDecision.Violations.Contains("ACAD_ALREADY_RUNNING") ? AcadRunning : Blocked, string.Join(',', prepared.SafetyDecision.Violations));
|
||||||
@@ -74,11 +81,240 @@ internal static class PilotCli
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static readonly IExecutionPermitService PermitService = new ExecutionPermitService();
|
private static readonly IExecutionPermitService PermitService = new ExecutionPermitService();
|
||||||
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 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, 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!);
|
||||||
|
Directory.CreateDirectory(options.EvidenceRoot);
|
||||||
|
var roster = StartupRosterPreflight.Inspect(options.PackageSource, manifest!);
|
||||||
|
StartupRosterPreflight.Write(Path.Combine(options.EvidenceRoot, "startup-roster-preflight.json"), roster);
|
||||||
|
await journal.RecordAsync("StartupRosterPreflight", roster.IsSafe ? "Completed" : "Failed", roster.Status.ToString(), CancellationToken.None);
|
||||||
|
if (!roster.IsSafe) return Fail(Blocked, "CANDIDATE_ISOLATION_" + roster.Status.ToString().ToUpperInvariant());
|
||||||
|
if (Process.GetProcessesByName("acad").Any()) return Fail(AcadRunning, "ACAD_ALREADY_RUNNING");
|
||||||
|
await journal.RecordAsync("HashPreparationStarted", "Started", null, CancellationToken.None);
|
||||||
|
var before = await ContentHashesAsync(options.FixtureMarker, journal, CancellationToken.None);
|
||||||
|
await journal.RecordAsync("HashPreparationCompleted", "Completed", null, CancellationToken.None);
|
||||||
|
var previewConfigPath = Path.Combine(options.EvidenceRoot, "automation-preview.config.json");
|
||||||
|
var previewEvidence = Path.Combine(options.EvidenceRoot, "preview-result.json");
|
||||||
|
var entryHash = manifest!.Files.Single(x => string.Equals(x.Path, options.EntryAssembly, StringComparison.OrdinalIgnoreCase)).Sha256;
|
||||||
|
var targetCommand = baseConfig.AutomationSelection?.CommandName ?? "PXP-OA";
|
||||||
|
await File.WriteAllTextAsync(previewConfigPath, JsonSerializer.Serialize(new { phase = "Preview", targetCommand, 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 = 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, 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, "М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", targetCommand, 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 = 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, 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;
|
||||||
|
return applySession.Started && applySession.RollbackVerified && applySession.EvidenceCompleted && applyVerified && hashesEqual ? Success : Fail(ProcessFailed, !hashesEqual ? "AUTOMATION_CONTENT_CHANGED" : "AUTOMATION_APPLY_EVIDENCE_INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<AutomationSessionResult> RunAutomationSession(PilotLocalConfiguration config, PackageManifest manifest, string manifestHash, AutomationOptions options, string phase, string evidencePath)
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
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
|
||||||
|
{
|
||||||
|
var exit = WaitForExitAsync(execution.ProcessId.Value);
|
||||||
|
if (!config.ForceAfterEvidence) { await exit; gracefulExit = true; }
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// The graceful-exit timeout is deliberately measured only after
|
||||||
|
// terminal evidence is durable. Killing an active AutoCAD
|
||||||
|
// command before that boundary would turn ForceAfterEvidence
|
||||||
|
// into an unsafe general process killer.
|
||||||
|
// Evidence is written by the Plant process, not by a task owned
|
||||||
|
// by this host. Poll it with a bounded interval: waiting only on
|
||||||
|
// the timeout task delays an already durable result by a full
|
||||||
|
// timeout and makes the preview/apply hand-off unnecessarily slow.
|
||||||
|
await WaitForEvidenceOrExitAsync(exit, evidencePath, TimeSpan.FromSeconds(Math.Clamp(config.GracefulExitTimeoutSeconds, 1, 120)));
|
||||||
|
if (exit.IsCompleted) gracefulExit = true;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var timeout = Task.Delay(TimeSpan.FromSeconds(Math.Clamp(config.GracefulExitTimeoutSeconds, 1, 120)));
|
||||||
|
if (HasCompletedEvidence(evidencePath) && await Task.WhenAny(exit, timeout) == exit) gracefulExit = true;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
using var process = Process.GetProcessById(execution.ProcessId.Value);
|
||||||
|
if (!process.HasExited) { process.Kill(entireProcessTree: true); forcedCleanup = true; }
|
||||||
|
await exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally { await service.RollbackAsync(new(options.PilotRoot, manifest.PackageId, manifest.Version, execution.StartupScriptPath!, null, execution.SessionId, execution.ActivationSnapshotPath), CancellationToken.None); }
|
||||||
|
var evidenceCompleted = HasCompletedEvidence(evidencePath);
|
||||||
|
return new(true, gracefulExit, forcedCleanup, await IsRolledBack(options.PilotRoot), evidenceCompleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseAutomation(string[] args, out AutomationOptions options, out string? error)
|
||||||
|
{
|
||||||
|
options = null!; error = null; var d = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
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", "--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 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(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 async Task WaitForExitAsync(int processId) { using var process = Process.GetProcessById(processId); await process.WaitForExitAsync(); }
|
||||||
|
private static async Task WaitForEvidenceOrExitAsync(Task exit, string evidencePath, TimeSpan timeout)
|
||||||
|
{
|
||||||
|
var deadline = DateTimeOffset.UtcNow + timeout;
|
||||||
|
while (!exit.IsCompleted && !HasCompletedEvidence(evidencePath) && DateTimeOffset.UtcNow < deadline)
|
||||||
|
await Task.WhenAny(exit, Task.Delay(TimeSpan.FromMilliseconds(250)));
|
||||||
|
}
|
||||||
|
private static bool HasCompletedEvidence(string path)
|
||||||
|
{
|
||||||
|
try { using var json = JsonDocument.Parse(File.ReadAllText(path)); return json.RootElement.TryGetProperty("Completed", out var completed) && completed.ValueKind == JsonValueKind.True; }
|
||||||
|
catch { return false; }
|
||||||
|
}
|
||||||
private static async Task<int> RollbackAsync(string root)
|
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");
|
return result.Completed ? Success : Fail(ExplicitRollbackFailed, result.ErrorCode ?? "ROLLBACK_FAILED");
|
||||||
}
|
}
|
||||||
private static async Task<int> StatusAsync(string root)
|
private static async Task<int> StatusAsync(string root)
|
||||||
@@ -110,12 +346,40 @@ internal static class PilotCli
|
|||||||
private static string SessionPath(string root) => Path.Combine(root, "state", "pilot-session.json");
|
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)
|
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");
|
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(path)) { error = "PILOT_CONFIGURATION_MISSING"; return false; }
|
if (!File.Exists(machine)) { error = "PILOT_CONFIGURATION_MISSING"; return false; }
|
||||||
var parsed = PilotConfigurationParser.Parse(File.ReadAllText(path)); config = parsed.Configuration;
|
var parsed = LauncherConfigurationLayers.LoadFromFiles(defaults, machine, session); config = parsed.Configuration;
|
||||||
if (!parsed.IsValid) { error = "PILOT_CONFIGURATION_INVALID"; return false; }
|
if (!parsed.IsValid) { error = "PILOT_CONFIGURATION_INVALID"; return false; }
|
||||||
return true;
|
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)
|
private static bool TryBuildManifest(string source, string entry, out PackageManifest? manifest, out string? hash, out string? error)
|
||||||
{
|
{
|
||||||
manifest = null; hash = null; error = null;
|
manifest = null; hash = null; error = null;
|
||||||
@@ -129,8 +393,13 @@ internal static class PilotCli
|
|||||||
using var stream = File.OpenRead(path); var bytes = SHA256.HashData(stream);
|
using var stream = File.OpenRead(path); var bytes = SHA256.HashData(stream);
|
||||||
list.Add(new(relative, new FileInfo(path).Length, Convert.ToHexString(bytes)));
|
list.Add(new(relative, new FileInfo(path).Length, Convert.ToHexString(bytes)));
|
||||||
}
|
}
|
||||||
var version = "0.1.0-pilot-ea3a9e1";
|
var assemblyVersion = "0.1.0-pilot";
|
||||||
try { version = System.Reflection.AssemblyName.GetAssemblyName(Path.Combine(source, entry)).Version?.ToString() ?? version; } catch { }
|
try { assemblyVersion = System.Reflection.AssemblyName.GetAssemblyName(Path.Combine(source, entry)).Version?.ToString() ?? assemblyVersion; } catch { }
|
||||||
|
// A pilot version directory is immutable for the duration of its activation.
|
||||||
|
// Bind its name to the validated package content so a rebuilt local bundle
|
||||||
|
// cannot silently reuse a stale directory with the same assembly version.
|
||||||
|
var contentIdentity = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join("|", list.OrderBy(x => x.Path, StringComparer.OrdinalIgnoreCase).Select(x => $"{x.Path}:{x.Sha256}")))))[..12];
|
||||||
|
var version = assemblyVersion + "-" + contentIdentity;
|
||||||
manifest = new PackageManifest(1, "P_Rec_Annot", version, "Plant3D", 2026, "x64", entry, list.OrderBy(x => x.Path, StringComparer.OrdinalIgnoreCase).ToArray(), new("netload", "PHP_REC_ANNOT"), "0.1.0", ["R25.1"], "net8.0-windows", null, null, "pilot", false, true);
|
manifest = new PackageManifest(1, "P_Rec_Annot", version, "Plant3D", 2026, "x64", entry, list.OrderBy(x => x.Path, StringComparer.OrdinalIgnoreCase).ToArray(), new("netload", "PHP_REC_ANNOT"), "0.1.0", ["R25.1"], "net8.0-windows", null, null, "pilot", false, true);
|
||||||
var json = JsonSerializer.Serialize(manifest); hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(json)));
|
var json = JsonSerializer.Serialize(manifest); hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(json)));
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("Launcher.Next.Tests")]
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace Launcher.Next.Application;
|
||||||
|
|
||||||
|
public sealed record LauncherOperationFailure(string Stage, string Code, string UserMessage, bool ProcessStartAttempted);
|
||||||
|
|
||||||
|
public static class LauncherOperationFailures
|
||||||
|
{
|
||||||
|
public static LauncherOperationFailure Classify(string code, string fallbackStage, bool processStartAttempted = false) => code switch
|
||||||
|
{
|
||||||
|
"LEGACY_DOWNLOAD_LIST_NOT_FOUND" => new("RosterResolutionFailed", "AUTOMATIC_DOWNLOAD_LIST_NOT_FOUND", "Для выбранного проекта не найден список плагинов", false),
|
||||||
|
"DOWNLOAD_LIST_XML_MALFORMED" => new("PluginPlanFailed", "DOWNLOAD_LIST_XML_MALFORMED", "Некорректный download_list.xml", false),
|
||||||
|
"DOWNLOAD_LIST_ROOT_INVALID" => new("PluginPlanFailed", "DOWNLOAD_LIST_ROOT_INVALID", "download_list.xml имеет недопустимый корневой элемент", false),
|
||||||
|
"LEGACY_PLUGIN_ID_NOT_DEFINED" => new("PluginPlanFailed", "DOWNLOAD_LIST_ID_UNKNOWN", "В Settings.xml отсутствует описание плагина", false),
|
||||||
|
"AMBIGUOUS_LEGACY_PLUGIN_DEFINITION" => new("PluginPlanFailed", "DOWNLOAD_LIST_PLUGIN_DEFINITION_AMBIGUOUS", "Определение плагина в Settings.xml неоднозначно", false),
|
||||||
|
"LEGACY_PLUGIN_PRODUCT_MISMATCH" => new("PluginPlanFailed", "PLUGIN_PRODUCT_MISMATCH", "Список плагинов не совместим с выбранным продуктом", false),
|
||||||
|
"LEGACY_MAPPING_NOT_FOUND" => new("PackageMappingFailed", "PACKAGE_MAPPING_NOT_FOUND", "Для выбранных плагинов отсутствуют пакеты 2026", false),
|
||||||
|
"LOCAL_PARITY_ROSTER_PROFILE_MISMATCH" => new("RosterResolutionFailed", "OWNER_REVIEW_PROFILE_ROSTER_MISMATCH", "Контролируемая конфигурация не соответствует выбранному проекту", false),
|
||||||
|
_ when code.Contains("PROCESS", StringComparison.Ordinal) || code.Contains("PLANT3D_2026_NOT_FOUND", StringComparison.Ordinal) => new("ProcessStartFailed", "PROCESS_START_FAILED", "Не удалось запустить Autodesk", true),
|
||||||
|
_ when code.Contains("ACTIVATION", StringComparison.Ordinal) => new("PackageActivationFailed", "PACKAGE_ACTIVATION_FAILED", "Не удалось активировать пакет", false),
|
||||||
|
_ when code.Contains("MANIFEST", StringComparison.Ordinal) => new("PackagePreparationFailed", "PACKAGE_MANIFEST_INVALID", "Пакет повреждён", false),
|
||||||
|
_ => new(fallbackStage, code, "Операция не выполнена: " + code, processStartAttempted)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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","controlled-local"}.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,93 @@
|
|||||||
|
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, IReadOnlyList<string>? Machines = null)
|
||||||
|
{
|
||||||
|
public bool IsAllowedOn(string machineName) => Machines is null || Machines.Count == 0 || Machines.Contains(machineName, StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
public sealed record LegacyProductPattern(string Name, string Filter, string Roster, IReadOnlyList<LegacyPluginDefinition> Plugins, IReadOnlyList<string>? Attributes = null, IReadOnlyList<string>? Sources = null, string? Target = null, string? Loader = null, string? ObjectPath = null);
|
||||||
|
public sealed record LegacyLauncherSettings(string Help, string CareMarker, IReadOnlyList<LegacyProductPattern> Patterns);
|
||||||
|
public sealed record LegacyDownloadListEntry(int ElementIndex, string RawId, string NormalizedId, int? FirstOccurrenceIndex, string SourceShape);
|
||||||
|
public sealed record LegacyDownloadSelection(IReadOnlyList<string> PluginIds, IReadOnlyList<LegacyDownloadListEntry>? Entries = null)
|
||||||
|
{
|
||||||
|
public IReadOnlyList<string> EffectivePluginIds => PluginIds.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
plugin.Elements("machine").Select(x => x.Value.Trim()).Where(x => x.Length > 0).ToArray())).ToArray();
|
||||||
|
return new LegacyProductPattern(Value("name"), Value("filter"), Value("roster"), plugins,
|
||||||
|
pattern.Elements("attrib").Select(x => x.Value).ToArray(),
|
||||||
|
pattern.Elements("source").Elements("course").Select(x => x.Value).ToArray(),
|
||||||
|
pattern.Element("target")?.Value, pattern.Element("loader")?.Value, pattern.Element("object")?.Value);
|
||||||
|
}).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)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var reader = XmlReader.Create(new StringReader(xml), XmlSettings);
|
||||||
|
var root = XDocument.Load(reader).Root;
|
||||||
|
if (root?.Name != "root") throw new InvalidOperationException("DOWNLOAD_LIST_ROOT_INVALID");
|
||||||
|
// F2_Xml serialized only root/id and removed only exact empty strings. Real project files
|
||||||
|
// also contain source-grouped <area><id> entries; preserve their document order as the
|
||||||
|
// modern plan while retaining raw values and duplicate evidence.
|
||||||
|
var direct = root.Elements("id").Select(x => (Element: x, Shape: "Root"));
|
||||||
|
var grouped = root.Elements("area").SelectMany(area => area.Elements("id").Select(x => (Element: x, Shape: "Area")));
|
||||||
|
var entries = (direct.Any() ? direct : grouped).Select((entry, index) =>
|
||||||
|
{
|
||||||
|
var raw = entry.Element.Value;
|
||||||
|
var normalized = raw; // legacy F2_Xml did not Trim or case-normalize IDs.
|
||||||
|
return new LegacyDownloadListEntry(index, raw, normalized, null, entry.Shape);
|
||||||
|
}).ToArray();
|
||||||
|
var seen = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var annotated = entries.Select(entry =>
|
||||||
|
{
|
||||||
|
if (entry.RawId == "") return entry;
|
||||||
|
if (seen.TryGetValue(entry.NormalizedId, out var first)) return entry with { FirstOccurrenceIndex = first };
|
||||||
|
seen[entry.NormalizedId] = entry.ElementIndex;
|
||||||
|
return entry;
|
||||||
|
}).ToArray();
|
||||||
|
return new(annotated.Where(x => x.RawId != "").Select(x => x.RawId).ToArray(), annotated);
|
||||||
|
}
|
||||||
|
catch (XmlException) { throw new InvalidOperationException("DOWNLOAD_LIST_XML_MALFORMED"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<PackageRequirement> Join(LegacyProductPattern pattern, LegacyDownloadSelection selection, LegacyCompatibilityOverlay overlay)
|
||||||
|
{
|
||||||
|
var result = new List<PackageRequirement>();
|
||||||
|
foreach (var id in selection.EffectivePluginIds)
|
||||||
|
{
|
||||||
|
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,88 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
namespace Launcher.Next.Application;
|
||||||
|
|
||||||
|
public sealed record LegacySettingsSnapshot(string SourceEndpoint, string SourceFingerprint, string Sha256, long Length, DateTimeOffset LastWriteUtc, DateTimeOffset AcquiredUtc, string LocalSnapshotPath, bool OfflineFallback);
|
||||||
|
|
||||||
|
public static class LegacyNetworkConfiguration
|
||||||
|
{
|
||||||
|
public const string ApprovedSettingsEndpoint = @"\\phimproject.com\common\BIM\save\All_Serv_Install\Settings.xml";
|
||||||
|
public const string LegacyBimAlias = @"N:\BIM";
|
||||||
|
public const string ApprovedBimEndpoint = @"\\phimproject.com\common\BIM";
|
||||||
|
|
||||||
|
public static string ResolveApprovedAlias(string legacyPath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(legacyPath)) throw new InvalidOperationException("LEGACY_NETWORK_PATH_INVALID");
|
||||||
|
var normalized = legacyPath.Replace('/', '\\');
|
||||||
|
if (string.Equals(normalized, LegacyBimAlias, StringComparison.OrdinalIgnoreCase)) return ApprovedBimEndpoint;
|
||||||
|
var prefix = LegacyBimAlias + "\\";
|
||||||
|
if (!normalized.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("LEGACY_NETWORK_ALIAS_NOT_APPROVED");
|
||||||
|
return ApprovedBimEndpoint + normalized[(prefix.Length - 1)..];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LegacySettingsSnapshot AcquireApprovedSnapshot(string endpoint, string localRoot)
|
||||||
|
{
|
||||||
|
if (!string.Equals(endpoint, ApprovedSettingsEndpoint, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("LEGACY_SETTINGS_ENDPOINT_NOT_APPROVED");
|
||||||
|
if (!Path.IsPathFullyQualified(endpoint) || !endpoint.StartsWith(@"\\", StringComparison.Ordinal) || endpoint.Contains('?') || endpoint.Contains('*')) throw new InvalidOperationException("LEGACY_SETTINGS_ENDPOINT_INVALID");
|
||||||
|
if (!File.Exists(endpoint) || File.GetAttributes(endpoint).HasFlag(FileAttributes.ReparsePoint)) return OfflineSnapshot(endpoint, localRoot);
|
||||||
|
var first = new FileInfo(endpoint);
|
||||||
|
var sourceHashBefore = Hash(endpoint);
|
||||||
|
var snapshots = Path.Combine(Path.GetFullPath(localRoot), "configuration-snapshots");
|
||||||
|
Directory.CreateDirectory(snapshots);
|
||||||
|
var temporary = Path.Combine(snapshots, Guid.NewGuid().ToString("N") + ".tmp");
|
||||||
|
File.Copy(endpoint, temporary, false);
|
||||||
|
var copiedHash = Hash(temporary);
|
||||||
|
var after = new FileInfo(endpoint);
|
||||||
|
var sourceHashAfter = Hash(endpoint);
|
||||||
|
if (first.Length != after.Length || first.LastWriteTimeUtc != after.LastWriteTimeUtc || !string.Equals(sourceHashBefore, sourceHashAfter, StringComparison.Ordinal) || !string.Equals(copiedHash, sourceHashAfter, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
File.Delete(temporary);
|
||||||
|
throw new InvalidOperationException("LEGACY_SETTINGS_CHANGED_DURING_ACQUISITION");
|
||||||
|
}
|
||||||
|
var final = Path.Combine(snapshots, copiedHash + ".xml");
|
||||||
|
if (File.Exists(final)) File.Delete(temporary); else File.Move(temporary, final);
|
||||||
|
if (!string.Equals(Hash(final), copiedHash, StringComparison.Ordinal)) throw new InvalidOperationException("LEGACY_SETTINGS_SNAPSHOT_HASH_MISMATCH");
|
||||||
|
return new(endpoint, Fingerprint(endpoint, copiedHash), copiedHash, first.Length, new DateTimeOffset(first.LastWriteTimeUtc), DateTimeOffset.UtcNow, final, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LegacySettingsSnapshot OfflineSnapshot(string endpoint, string localRoot)
|
||||||
|
{
|
||||||
|
var snapshots = Path.Combine(Path.GetFullPath(localRoot), "configuration-snapshots");
|
||||||
|
if (!Directory.Exists(snapshots)) throw new InvalidOperationException("LEGACY_SETTINGS_NETWORK_UNAVAILABLE");
|
||||||
|
var candidate = Directory.GetFiles(snapshots, "*.xml").OrderByDescending(File.GetLastWriteTimeUtc).FirstOrDefault();
|
||||||
|
if (candidate is null) throw new InvalidOperationException("LEGACY_SETTINGS_NETWORK_UNAVAILABLE");
|
||||||
|
var info = new FileInfo(candidate);
|
||||||
|
var hash = Hash(candidate);
|
||||||
|
return new(endpoint, Fingerprint(endpoint, hash), hash, info.Length, new DateTimeOffset(info.LastWriteTimeUtc), DateTimeOffset.UtcNow, candidate, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Hash(string path) { using var stream = File.OpenRead(path); return Convert.ToHexString(SHA256.HashData(stream)); }
|
||||||
|
private static string Fingerprint(string endpoint, string hash) => Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(endpoint + "|" + hash)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class LegacyReadOnlySnapshots
|
||||||
|
{
|
||||||
|
public static LegacySettingsSnapshot Acquire(string source, string localRoot, string role)
|
||||||
|
{
|
||||||
|
if (!Path.IsPathFullyQualified(source) || !source.StartsWith(@"\\", StringComparison.Ordinal) || source.Contains('?') || source.Contains('*') || !File.Exists(source) || File.GetAttributes(source).HasFlag(FileAttributes.ReparsePoint)) throw new InvalidOperationException("READONLY_SNAPSHOT_SOURCE_INVALID");
|
||||||
|
var before = new FileInfo(source);
|
||||||
|
var beforeHash = Hash(source);
|
||||||
|
var directory = Path.Combine(Path.GetFullPath(localRoot), "configuration-snapshots", role);
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
var temporary = Path.Combine(directory, Guid.NewGuid().ToString("N") + ".tmp");
|
||||||
|
File.Copy(source, temporary, false);
|
||||||
|
var copiedHash = Hash(temporary);
|
||||||
|
var after = new FileInfo(source);
|
||||||
|
var afterHash = Hash(source);
|
||||||
|
if (before.Length != after.Length || before.LastWriteTimeUtc != after.LastWriteTimeUtc || beforeHash != afterHash || copiedHash != afterHash)
|
||||||
|
{
|
||||||
|
File.Delete(temporary);
|
||||||
|
throw new InvalidOperationException("READONLY_SNAPSHOT_SOURCE_CHANGED");
|
||||||
|
}
|
||||||
|
var final = Path.Combine(directory, copiedHash + ".xml");
|
||||||
|
if (File.Exists(final)) File.Delete(temporary); else File.Move(temporary, final);
|
||||||
|
return new(source, Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(role + "|" + source + "|" + copiedHash))), copiedHash, before.Length, new DateTimeOffset(before.LastWriteTimeUtc), DateTimeOffset.UtcNow, final, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Hash(string path) { using var stream = File.OpenRead(path); return Convert.ToHexString(SHA256.HashData(stream)); }
|
||||||
|
}
|
||||||
@@ -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,38 @@
|
|||||||
|
namespace Launcher.Next.Application;
|
||||||
|
|
||||||
|
public sealed record LegacyProductSelection(string Id, string DisplayName, string Filter, string RequiredFileName, string Pattern, string[] SourceVerifiedLegacyIds, string[] UnmappedLegacyIds, bool SupportsPlantBootstrap);
|
||||||
|
public sealed record LegacyPluginPlan(string RosterPath, string Source, IReadOnlyList<string> OrderedIds, IReadOnlyList<string> UnmappedIds, string? SnapshotPath = null, string? SnapshotSha256 = null);
|
||||||
|
|
||||||
|
public static class LegacyProductSelections
|
||||||
|
{
|
||||||
|
public static readonly LegacyProductSelection Plant3D = new("plant3d", "Plant 3D 2026", "Project files |Project.xml|All files (*.*)|*.*", "Project.xml", "Plant 3D_2019", [], [], true);
|
||||||
|
public static readonly LegacyProductSelection Pnid = new("pnid", "Plant P&ID 2026", "Project files |Project.xml|All files (*.*)|*.*", "Project.xml", "Plant PnID_2019", ["c1b999d6c16029e9ac5b1e1a8afe1500", "5b80379c3ec715e7da0ee414c53aa7bc", "1111"], ["c1b999d6c16029e9ac5b1e1a8afe1500", "5b80379c3ec715e7da0ee414c53aa7bc", "1111"], true);
|
||||||
|
public static readonly LegacyProductSelection Navisworks = new("navisworks", "Navisworks Manage 2026", "nwd files (*.nwd)|*.nwd|All files (*.*)|*.*", ".nwd", "Navisworks_Manage_2018", ["7dd981169fa14d21aaef3fcdb0e7e970"], ["7dd981169fa14d21aaef3fcdb0e7e970"], false);
|
||||||
|
|
||||||
|
public static LegacyProductSelection For(string id) => id switch { "plant3d" => Plant3D, "pnid" => Pnid, "navisworks" => Navisworks, _ => throw new InvalidOperationException("LEGACY_PRODUCT_UNKNOWN") };
|
||||||
|
|
||||||
|
public static string ValidateSelectedFile(LegacyProductSelection product, string selected)
|
||||||
|
{
|
||||||
|
var file = Path.GetFullPath(selected);
|
||||||
|
if (!File.Exists(file) || File.GetAttributes(file).HasFlag(FileAttributes.ReparsePoint)) throw new InvalidOperationException("LEGACY_PROJECT_FILE_INVALID");
|
||||||
|
var correctName = product.RequiredFileName.StartsWith('.') ? file.EndsWith(product.RequiredFileName, StringComparison.OrdinalIgnoreCase) : string.Equals(Path.GetFileName(file), product.RequiredFileName, StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (!correctName) throw new InvalidOperationException("LEGACY_PROJECT_FILE_PRODUCT_MISMATCH");
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string AutomaticRoster(LegacyProductSelection product, string selectedFile)
|
||||||
|
{
|
||||||
|
var projectDirectory = Path.GetDirectoryName(ValidateSelectedFile(product, selectedFile))!;
|
||||||
|
return Path.GetFullPath(Path.Combine(projectDirectory, "..", "_System", product.Pattern, "Applications", "download_list.xml"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LegacyPluginPlan ReadRoster(LegacyProductSelection product, string selectedFile, string? manualXml, LegacyConfigurationAdapter parser, string? snapshotRoot = null)
|
||||||
|
{
|
||||||
|
var roster = manualXml is null ? AutomaticRoster(product, selectedFile) : Path.GetFullPath(manualXml);
|
||||||
|
if (!File.Exists(roster) || File.GetAttributes(roster).HasFlag(FileAttributes.ReparsePoint) || !string.Equals(Path.GetFileName(roster), "download_list.xml", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("LEGACY_DOWNLOAD_LIST_NOT_FOUND");
|
||||||
|
var snapshot = roster.StartsWith(@"\\", StringComparison.Ordinal) && snapshotRoot is not null ? LegacyReadOnlySnapshots.Acquire(roster, snapshotRoot, "project-rosters") : null;
|
||||||
|
var ids = parser.ParseDownloadList(File.ReadAllText(snapshot?.LocalSnapshotPath ?? roster)).PluginIds;
|
||||||
|
if (product.SourceVerifiedLegacyIds.Length > 0 && ids.Any(id => !product.SourceVerifiedLegacyIds.Contains(id, StringComparer.OrdinalIgnoreCase))) throw new InvalidOperationException("LEGACY_PLUGIN_PRODUCT_MISMATCH");
|
||||||
|
return new(roster, manualXml is null ? "Automatic" : "Manual", ids, product.UnmappedLegacyIds.Intersect(ids, StringComparer.OrdinalIgnoreCase).ToArray(), snapshot?.LocalSnapshotPath, snapshot?.Sha256);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,15 +7,48 @@ public sealed class PlantProductDiscovery(IRegistryReader registry) : IProductDi
|
|||||||
{
|
{
|
||||||
public ProductInstallation? DiscoverPlant3D(int year)
|
public ProductInstallation? DiscoverPlant3D(int year)
|
||||||
{
|
{
|
||||||
var install = registry.GetValue($@"SOFTWARE\Autodesk\AutoCAD\R25.1\ACAD-xxxx:409", "InstallDir") as string;
|
if (year != 2026) return null;
|
||||||
if (string.IsNullOrWhiteSpace(install)) return null; var exe = Path.Combine(install, "acad.exe");
|
const string plantInstall = @"SOFTWARE\Autodesk\AutoCAD\R25.1\ACAD-9117\Install";
|
||||||
return File.Exists(exe) ? new(exe, year.ToString(), "x64", []) : null;
|
var install = registry.GetValue(plantInstall, "InstallDir") as string;
|
||||||
|
var exe = string.IsNullOrWhiteSpace(install) ? null : Path.Combine(install, "acad.exe");
|
||||||
|
if (exe is null || !File.Exists(exe) || !Directory.Exists(Path.Combine(install!, "PLNT3D"))) return null;
|
||||||
|
return new(exe, "2026", "x64", []);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
public sealed class StartupScriptBuilder : IStartupScriptBuilder
|
public sealed class StartupScriptBuilder : IStartupScriptBuilder
|
||||||
{
|
{
|
||||||
public string BuildNetloadScript(string root, string entry)
|
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"; }
|
{ 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(ResolvedAutomationScriptPaths paths)
|
||||||
|
{
|
||||||
|
var fixture = Path.GetFullPath(paths.FixtureOrthoDwg);
|
||||||
|
if (!File.Exists(fixture)) throw new InvalidOperationException("AUTOMATION_FIXTURE_NOT_FOUND");
|
||||||
|
var quote = (string value) => "\"" + value.Replace("\"", "\\\"") + "\"";
|
||||||
|
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.Selection is null ? BuildDefaultCommands(paths) : BuildSelectedEntityCommands(paths);
|
||||||
|
return dialogFreeOpen + "_.OPEN\n" + quote(fixture) + "\n" + diagnostic + BuildNetloadScript(paths.ValidatedPackageRoot, paths.EntryAssembly) +
|
||||||
|
"PHP_REC_AUTOMATION_PREPARE\n" + commands + "PHP_REC_AUTOMATION_FINALIZE\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildDefaultCommands(ResolvedAutomationScriptPaths paths) => 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";
|
||||||
|
|
||||||
|
private static string BuildSelectedEntityCommands(ResolvedAutomationScriptPaths paths)
|
||||||
|
{
|
||||||
|
var selection = paths.Selection!;
|
||||||
|
selection.Validate();
|
||||||
|
var invoke = "(setq P_REC_ANNOT_SELECTED_ENTITY (handent \"" + selection.ObjectHandle.ToUpperInvariant() + "\"))\n" +
|
||||||
|
"(if (and P_REC_ANNOT_SELECTED_ENTITY (= (cdr (assoc 0 (entget P_REC_ANNOT_SELECTED_ENTITY))) \"" + selection.ExpectedDxfName.ToUpperInvariant() + "\")) " +
|
||||||
|
"(command-s \"" + selection.CommandName.ToUpperInvariant() + "\" P_REC_ANNOT_SELECTED_ENTITY) " +
|
||||||
|
"(progn (prompt \"\\nP_REC_ANNOT_SELECTION_REJECTED\") (quit)))\n";
|
||||||
|
return paths.Apply ? "_.UNDO\n_MARK\n" + invoke + invoke + "_.UNDO\n_BACK\n" : invoke + invoke;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
public sealed class DryRunAutodeskProcessLauncher : IAutodeskProcessLauncher
|
public sealed class DryRunAutodeskProcessLauncher : IAutodeskProcessLauncher
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
namespace Launcher.Next.Core;
|
namespace Launcher.Next.Core;
|
||||||
|
|
||||||
public enum LauncherMode { Legacy, DryRun, NextPilot, NextPreferred }
|
public enum LauncherMode { Legacy, DryRun, NextPilot, NextPreferred }
|
||||||
public enum PluginExecutionMode { Smoke, DryRun }
|
public enum PluginExecutionMode { Smoke, DryRun, Apply }
|
||||||
public sealed record ProductInstallation(string ExecutablePath, string ProductVersion, string Architecture, IReadOnlyList<string> Profiles);
|
public sealed record ProductInstallation(string ExecutablePath, string ProductVersion, string Architecture, IReadOnlyList<string> Profiles);
|
||||||
public sealed record PackageFile(string Path, long Length, string Sha256, bool Optional = false);
|
public sealed record PackageFile(string Path, long Length, string Sha256, bool Optional = false);
|
||||||
public sealed record StartupDefinition(string Type, string Command);
|
public sealed record StartupDefinition(string Type, string Command);
|
||||||
@@ -17,18 +17,39 @@ public sealed record ValidationResult(IReadOnlyList<ValidationIssue> Issues)
|
|||||||
{ public bool IsValid => Issues.Count == 0; public static ValidationResult Success { get; } = new([]); }
|
{ 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 PackageProgress(string Stage, int Completed, int Total);
|
||||||
public sealed record ActivationResult(string CurrentVersion, string? PreviousVersion);
|
public sealed record ActivationResult(string CurrentVersion, string? PreviousVersion);
|
||||||
|
public sealed record PackagePayloadBinding(string RelativePath,long Length,string Sha256);
|
||||||
|
public sealed record PackageActivationReceipt(string OperationId, string PackageId, string Version, string? PreviousVersion, DateTimeOffset ActivatedUtc, long PointerGeneration = 0, string? ActiveRootIdentity = null, int SchemaVersion = 2, string? ManifestSha256 = null, string? PayloadSetSha256 = null, string? RosterIdentity = null, IReadOnlyList<PackagePayloadBinding>? PayloadFiles = null, long PointerGenerationBefore = 0, string? EvidenceCorrelationId = 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, AfterPayloadCopyBeforeValidation, BeforeImmutableCommit, AfterImmutableCommitBeforeReturn, BeforePointerTempWrite, BeforePointerReplace, BeforePointerReadback, BeforeRollbackReplace, BeforeRollbackReadback, 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 LaunchRequest(ProductInstallation Product, string Profile, string StartupScriptPath, bool DryRun);
|
||||||
public sealed record PreparedLaunch(string Executable, IReadOnlyList<string> Arguments, string WorkingDirectory, bool WouldStart);
|
public sealed record PreparedLaunch(string Executable, IReadOnlyList<string> Arguments, string WorkingDirectory, bool WouldStart);
|
||||||
|
|
||||||
public interface IProductDiscovery { ProductInstallation? DiscoverPlant3D(int year); }
|
public interface IProductDiscovery { ProductInstallation? DiscoverPlant3D(int year); }
|
||||||
public interface IPackageCatalogClient { Task<PackageManifest?> GetManifestAsync(string packageId, CancellationToken cancellationToken); }
|
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 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 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 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 IPackageActivator { Task<ActivationResult> ActivateAsync(string packageId, string version, CancellationToken cancellationToken); }
|
||||||
public interface IRollbackManager { Task<string?> RollbackAsync(string packageId, 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 IAutodeskProcessLauncher { PreparedLaunch Prepare(LaunchRequest request); }
|
||||||
public interface IStartupScriptBuilder { string BuildNetloadScript(string validatedPackageRoot, string entryAssembly); }
|
public sealed record ResolvedAutomationScriptPaths(string ValidatedPackageRoot, string EntryAssembly, string FixtureOrthoDwg, string FixtureModelDwg, string AutomationScriptPath, bool Diagnostic, bool Apply, ControlledEntitySelection? Selection = null);
|
||||||
|
public interface IStartupScriptBuilder { string BuildNetloadScript(string validatedPackageRoot, string entryAssembly); string BuildAutomationScript(ResolvedAutomationScriptPaths paths); }
|
||||||
public interface ILauncherConfigurationProvider { LauncherMode GetMode(); }
|
public interface ILauncherConfigurationProvider { LauncherMode GetMode(); }
|
||||||
public interface ILauncherTelemetry { void Write(string eventType, IReadOnlyDictionary<string, object?>? fields = null); }
|
public interface ILauncherTelemetry { void Write(string eventType, IReadOnlyDictionary<string, object?>? fields = null); }
|
||||||
public interface IClock { DateTimeOffset UtcNow { get; } }
|
public interface IClock { DateTimeOffset UtcNow { get; } }
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
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 sealed record ResolvedPackageCatalogRoot { internal ResolvedPackageCatalogRoot(string value)=>Value=value; public string Value{get;} }
|
||||||
|
public sealed record ResolvedImmutablePackagePath { internal ResolvedImmutablePackagePath(string value)=>Value=value; public string Value{get;} }
|
||||||
|
public sealed record ResolvedPackageLifecycleRoots(
|
||||||
|
ResolvedPackagePilotRoot Pilot,
|
||||||
|
ResolvedPackageCatalogRoot Catalog,
|
||||||
|
ResolvedPackageStagingRoot Staging,
|
||||||
|
ResolvedPackageVersionStoreRoot VersionStore,
|
||||||
|
ResolvedActivePackageRoot Active,
|
||||||
|
ResolvedPackageLifecycleEvidenceRoot Evidence);
|
||||||
|
|
||||||
|
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);
|
||||||
|
ResolvedPackageLifecycleRoots ResolvePackageLifecycleRoots(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)||Uri.IsWellFormedUriString(value,UriKind.Absolute)||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); }
|
||||||
|
public ResolvedPackageLifecycleRoots ResolvePackageLifecycleRoots(string value)
|
||||||
|
{
|
||||||
|
var pilot=ResolvePackagePilotRoot(value); string Child(string name)=>Path.GetFullPath(Path.Combine(pilot.Value,name));
|
||||||
|
var paths=new[]{Child("catalog"),Child("staging"),Child("version-store"),Child("active"),Child("evidence")};
|
||||||
|
if(paths.Distinct(StringComparer.OrdinalIgnoreCase).Count()!=paths.Length) throw new InvalidOperationException("PACKAGE_ROOT_COLLISION");
|
||||||
|
foreach(var path in paths) if(!path.StartsWith(pilot.Value.TrimEnd(Path.DirectorySeparatorChar)+Path.DirectorySeparatorChar,StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("PACKAGE_ROOT_TOPOLOGY_INVALID");
|
||||||
|
return new(pilot,new(paths[0]),new(paths[1]),new(paths[2]),new(paths[3]),new(paths[4]));
|
||||||
|
}
|
||||||
|
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);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,12 +13,42 @@ public sealed record PilotLocalConfiguration
|
|||||||
public bool AllowProcessStart { get; init; }
|
public bool AllowProcessStart { get; init; }
|
||||||
public bool RequireExplicitExecute { get; init; } = true;
|
public bool RequireExplicitExecute { get; init; } = true;
|
||||||
public PluginExecutionMode PluginExecutionMode { get; init; } = PluginExecutionMode.Smoke;
|
public PluginExecutionMode PluginExecutionMode { get; init; } = PluginExecutionMode.Smoke;
|
||||||
|
// Apply is never a normal pilot default. It is accepted only by this
|
||||||
|
// dedicated test branch and still requires the plugin-side fixture permit.
|
||||||
|
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; }
|
||||||
|
// Controlled automation may reproduce an actual Editor.GetEntity boundary
|
||||||
|
// only with this immutable, explicitly typed fixture selection. It is never
|
||||||
|
// populated for normal production launches.
|
||||||
|
public ControlledEntitySelection? AutomationSelection { 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; }
|
||||||
|
public int GracefulExitTimeoutSeconds { get; init; } = 30;
|
||||||
public string? PilotRoot { get; init; }
|
public string? PilotRoot { get; init; }
|
||||||
public string? SelectedProductId { get; init; }
|
public string? SelectedProductId { get; init; }
|
||||||
public string? SelectedProfileName { get; init; }
|
public string? SelectedProfileName { get; init; }
|
||||||
public int ExecutionPermitLifetimeSeconds { get; init; } = 300;
|
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 static PilotLocalConfiguration SafeDefault { get; } = new();
|
||||||
}
|
}
|
||||||
|
public sealed record ControlledEntitySelection(string CommandName, string ObjectHandle, string ExpectedDxfName)
|
||||||
|
{
|
||||||
|
public void Validate()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(CommandName) || !CommandName.All(value => char.IsAsciiLetterOrDigit(value) || value is '-' or '_') ||
|
||||||
|
string.IsNullOrWhiteSpace(ObjectHandle) || ObjectHandle.Length > 32 || !ObjectHandle.All(Uri.IsHexDigit) ||
|
||||||
|
string.IsNullOrWhiteSpace(ExpectedDxfName) || !ExpectedDxfName.All(value => char.IsAsciiLetterOrDigit(value) || value == '_'))
|
||||||
|
throw new InvalidOperationException("AUTOMATION_ENTITY_SELECTION_INVALID");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public enum AcadLaunchMode { Production, NextPilotPreview, NextPilotApply, LifecycleProbe }
|
||||||
public sealed record PilotConfigurationLoadResult(PilotLocalConfiguration Configuration, IReadOnlyList<string> Errors)
|
public sealed record PilotConfigurationLoadResult(PilotLocalConfiguration Configuration, IReadOnlyList<string> Errors)
|
||||||
{ public bool IsValid => Errors.Count == 0; }
|
{ public bool IsValid => Errors.Count == 0; }
|
||||||
public static class PilotConfigurationParser
|
public static class PilotConfigurationParser
|
||||||
@@ -40,7 +70,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"]);
|
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!));
|
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, []);
|
return new(true, full, []);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,14 +87,43 @@ public interface INextPilotSafetyGate { PilotSafetyDecision Evaluate(PilotSafety
|
|||||||
public sealed class NextPilotSafetyGate : INextPilotSafetyGate
|
public sealed class NextPilotSafetyGate : INextPilotSafetyGate
|
||||||
{
|
{
|
||||||
public PilotSafetyDecision Evaluate(PilotSafetyContext c, bool forExecution)
|
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)) v.Add("PLUGIN_EXECUTION_MODE_NOT_ALLOWED"); 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); }
|
{
|
||||||
|
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");
|
||||||
|
try { c.Configuration.AutomationSelection?.Validate(); } catch (InvalidOperationException) { v.Add("AUTOMATION_ENTITY_SELECTION_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 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 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 class PilotPlanFactory
|
||||||
{
|
{
|
||||||
public static PilotLaunchPlan Create(ProductInstallation product, AutoCadProfileCandidate profile, PackageManifest manifest, string manifestHash, string root, string scriptPath, string scriptHash, PilotSafetyDecision 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 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); }
|
{ 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 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); }
|
public interface IExecutionPermitService { ExecutionPermit Issue(PilotLaunchPlan plan, int lifetimeSeconds); bool Validate(ExecutionPermit permit, PilotLaunchPlan plan, DateTimeOffset now); }
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Launcher.Next.Core;
|
||||||
|
|
||||||
|
public enum StartupRosterCandidateStatus
|
||||||
|
{
|
||||||
|
CandidateOnly,
|
||||||
|
LegacyCandidatePresent,
|
||||||
|
ConflictingCandidatePresent,
|
||||||
|
UnexpectedAutoloadRegistration,
|
||||||
|
UnresolvedStartupEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record StartupRosterEntry(string Source, string? Registration, string? PackageId,
|
||||||
|
string? Version, string? EntryAssembly, string? EntryPath, string? EntrySha256,
|
||||||
|
bool Eligible, string LoadTiming);
|
||||||
|
|
||||||
|
public sealed record StartupRosterReport(StartupRosterCandidateStatus Status,
|
||||||
|
string RequestedPackageId, string RequestedVersion, string? RequestedEntrySha256,
|
||||||
|
IReadOnlyList<StartupRosterEntry> Entries, DateTimeOffset CreatedUtc)
|
||||||
|
{
|
||||||
|
public bool IsSafe => Status == StartupRosterCandidateStatus.CandidateOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read-only, bounded discovery of P_Rec_Annot startup candidates in controlled local roots.
|
||||||
|
public static class StartupRosterPreflight
|
||||||
|
{
|
||||||
|
public static StartupRosterReport Inspect(string requestedPackageRoot, PackageManifest requested,
|
||||||
|
IEnumerable<string>? additionalRoots = null)
|
||||||
|
{
|
||||||
|
var roots = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { Path.GetFullPath(requestedPackageRoot) };
|
||||||
|
foreach (var root in additionalRoots ?? DiscoverKnownRoots())
|
||||||
|
if (!string.IsNullOrWhiteSpace(root) && Directory.Exists(root)) roots.Add(Path.GetFullPath(root));
|
||||||
|
var entries = new List<StartupRosterEntry>();
|
||||||
|
var requestedPath = Path.Combine(Path.GetFullPath(requestedPackageRoot), requested.EntryAssembly);
|
||||||
|
var requestedHash = requested.Files.FirstOrDefault(f => f.Path.Equals(requested.EntryAssembly, StringComparison.OrdinalIgnoreCase))?.Sha256;
|
||||||
|
if (File.Exists(requestedPath))
|
||||||
|
entries.Add(new("RequestedPackage", requested.Startup.Type + ":" + requested.Startup.Command, requested.PackageId, requested.Version,
|
||||||
|
requested.EntryAssembly, requestedPath, Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(requestedPath))), true, "before-netload"));
|
||||||
|
else entries.Add(new("RequestedPackage", requested.Startup.Type + ":" + requested.Startup.Command, requested.PackageId, requested.Version,
|
||||||
|
requested.EntryAssembly, null, requestedHash, false, "before-netload"));
|
||||||
|
foreach (var root in roots) DiscoverRoot(root, requested, entries);
|
||||||
|
var eligible = entries.Where(e => e.Eligible).ToArray();
|
||||||
|
StartupRosterCandidateStatus status;
|
||||||
|
if (entries.Any(e => e.EntryPath is null)) status = StartupRosterCandidateStatus.UnresolvedStartupEntry;
|
||||||
|
else if (eligible.Length == 0) status = StartupRosterCandidateStatus.UnresolvedStartupEntry;
|
||||||
|
else if (eligible.Length == 1 && string.Equals(eligible[0].EntrySha256, requestedHash, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& string.Equals(eligible[0].PackageId, requested.PackageId, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& string.Equals(eligible[0].Version, requested.Version, StringComparison.OrdinalIgnoreCase)) status = StartupRosterCandidateStatus.CandidateOnly;
|
||||||
|
else if (eligible.Any(e => string.Equals(e.PackageId, requested.PackageId, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !string.Equals(e.Version, requested.Version, StringComparison.OrdinalIgnoreCase))) status = StartupRosterCandidateStatus.ConflictingCandidatePresent;
|
||||||
|
else if (eligible.Any(e => !string.Equals(e.EntrySha256, requestedHash, StringComparison.OrdinalIgnoreCase))) status = StartupRosterCandidateStatus.LegacyCandidatePresent;
|
||||||
|
else status = StartupRosterCandidateStatus.UnexpectedAutoloadRegistration;
|
||||||
|
return new(status, requested.PackageId, requested.Version, requestedHash, entries, DateTimeOffset.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Write(string path, StartupRosterReport report) =>
|
||||||
|
File.WriteAllText(path, JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true }));
|
||||||
|
|
||||||
|
private static IEnumerable<string> DiscoverKnownRoots()
|
||||||
|
{
|
||||||
|
var controlledTemp = string.Concat("C:", Path.DirectorySeparatorChar, "dev", Path.DirectorySeparatorChar, "TEMP");
|
||||||
|
foreach (var temp in new[] { Path.GetTempPath(), controlledTemp }.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||||
|
if (Directory.Exists(temp))
|
||||||
|
foreach (var root in Directory.EnumerateDirectories(temp, "local-parity-launch-*", SearchOption.TopDirectoryOnly))
|
||||||
|
yield return Path.Combine(root, "Applications");
|
||||||
|
foreach (var root in new[] { Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) + "\\Autodesk\\ApplicationPlugins",
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData) + "\\Autodesk\\ApplicationPlugins" }) yield return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DiscoverRoot(string root, PackageManifest requested, List<StartupRosterEntry> entries)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(root)) return;
|
||||||
|
foreach (var dir in Directory.EnumerateDirectories(root, "P_Rec_Annot", SearchOption.TopDirectoryOnly))
|
||||||
|
{
|
||||||
|
var current = Path.Combine(dir, "current.json");
|
||||||
|
if (File.Exists(current))
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(File.ReadAllText(current));
|
||||||
|
var version = doc.RootElement.TryGetProperty("Current", out var c) ? c.GetString() : null;
|
||||||
|
var packageRoot = version is null ? null : Path.Combine(dir, "versions", version);
|
||||||
|
AddManifest(packageRoot, "LauncherCurrent", "netload", requested, entries);
|
||||||
|
}
|
||||||
|
else AddManifest(dir, "ApplicationPlugins", "autoload", requested, entries);
|
||||||
|
}
|
||||||
|
foreach (var bundle in Directory.EnumerateDirectories(root, "P_Rec_Annot*.bundle", SearchOption.TopDirectoryOnly)) AddManifest(bundle, "PackageContents", "autoload", requested, entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddManifest(string? packageRoot, string source, string registration, PackageManifest requested, List<StartupRosterEntry> entries)
|
||||||
|
{
|
||||||
|
if (packageRoot is null) { entries.Add(new(source, registration, "P_Rec_Annot", null, null, null, null, false, "before-netload")); return; }
|
||||||
|
var manifestPath = Path.Combine(packageRoot, "package.manifest.json");
|
||||||
|
if (!File.Exists(manifestPath)) { entries.Add(new(source, registration, "P_Rec_Annot", Path.GetFileName(packageRoot), null, null, null, false, "before-netload")); return; }
|
||||||
|
using var doc = JsonDocument.Parse(File.ReadAllText(manifestPath));
|
||||||
|
var root = doc.RootElement; string? S(string n) => root.TryGetProperty(n, out var p) ? p.GetString() : null;
|
||||||
|
var id = S("PackageId") ?? "P_Rec_Annot"; var version = S("Version"); var asm = S("EntryAssembly") ?? requested.EntryAssembly;
|
||||||
|
var path = Path.Combine(packageRoot, asm); string? hash = File.Exists(path) ? Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))) : null;
|
||||||
|
var eligible = File.Exists(path);
|
||||||
|
entries.Add(new(source, registration, id, version, asm, eligible ? path : null, hash, eligible, "before-netload"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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,138 @@ namespace Launcher.Next.Infrastructure;
|
|||||||
|
|
||||||
public sealed class Sha256FileHasher : IFileHasher
|
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 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 = 2, long Generation = 0, string? ActivationOperationId = null, DateTimeOffset? ActivatedAtUtc = null, string? ManifestSha256 = null, string? PayloadSetSha256 = null, IReadOnlyList<PackagePayloadBinding>? PayloadFiles = 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 PackageRoot(string id) => Path.Combine(root, "Applications", id);
|
||||||
private string PointerPath(string id) => Path.Combine(PackageRoot(id), "current.json");
|
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 is < 1 or > 2 || string.IsNullOrWhiteSpace(pointer.Current) ? null : pointer;
|
||||||
|
}
|
||||||
public async Task<string?> GetCurrentVersionAsync(string id, CancellationToken ct) => (await ReadPointer(id, ct))?.Current;
|
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<string?> GetPreviousVersionAsync(string id, CancellationToken ct) => (await ReadPointer(id, ct))?.Previous;
|
||||||
public async Task<ActivationResult> ActivateAsync(string id, string version, CancellationToken ct)
|
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);
|
await PointerGate.WaitAsync(ct); try
|
||||||
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);
|
var dir = VersionDirectory(id, version); if (!Directory.Exists(dir)) throw new DirectoryNotFoundException(dir);
|
||||||
return new(version, prior?.Current);
|
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,2,(prior?.Generation??0)+1,operationId,DateTimeOffset.UtcNow,content.Manifest,content.PayloadSet,content.Files),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),prior.SchemaVersion,prior.ManifestSha256,prior.PayloadSetSha256,requirement.RosterIdentity,prior.PayloadFiles,Math.Max(0,prior.Generation-1),prior.ActivationOperationId));
|
||||||
|
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),after.SchemaVersion,after.ManifestSha256,after.PayloadSetSha256,requirement.RosterIdentity,after.PayloadFiles,Math.Max(0,after.Generation-1),after.ActivationOperationId));
|
||||||
|
}
|
||||||
|
public async Task<PackageRollbackResult> RollbackAsync(PackageActivationReceipt receipt, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var current=await GetCurrentVersionAsync(receipt.PackageId,ct);
|
||||||
|
var pointer=await ReadPointer(receipt.PackageId,ct); if (receipt.SchemaVersion!=2 || pointer is null || !string.Equals(current,receipt.Version,StringComparison.Ordinal) || pointer.ActivationOperationId!=receipt.OperationId || pointer.Generation!=receipt.PointerGeneration || !string.Equals(pointer.ManifestSha256,receipt.ManifestSha256,StringComparison.OrdinalIgnoreCase) || !string.Equals(pointer.PayloadSetSha256,receipt.PayloadSetSha256,StringComparison.OrdinalIgnoreCase) || !string.Equals(receipt.ActiveRootIdentity,PackageRoot(receipt.PackageId),StringComparison.OrdinalIgnoreCase)) return new("RollbackBlockedStateChanged",current);
|
||||||
|
if (string.IsNullOrWhiteSpace(receipt.PreviousVersion)) return new("RollbackNotApplicable",current);
|
||||||
|
(faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.BeforeRollbackReplace); var restored=await ActivateAsync(receipt.PackageId,receipt.PreviousVersion,ct); (faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.BeforeRollbackReadback);
|
||||||
|
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<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 && !await PointerMatchesContentAsync(pointer,requirement.PackageId,pointer.Current,ct)) current=null;
|
||||||
|
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)
|
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);
|
var materialized = await MaterializeAsync(manifest, sourceRoot, ct, progress);
|
||||||
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)); }
|
return await ActivateAsync(materialized.PackageId, materialized.Version, ct);
|
||||||
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; }
|
|
||||||
}
|
}
|
||||||
|
/// <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 = PackageFilePath(sourceRoot, f.Path); var sourceInfo=new FileInfo(source); if(!sourceInfo.Exists||sourceInfo.Length!=f.Length||!string.Equals(await hasher.Sha256Async(source,ct),f.Sha256,StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("SOURCE_HASH_MISMATCH"); 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)); }
|
||||||
|
(faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.AfterPayloadCopyBeforeValidation);
|
||||||
|
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)) { if(!await ExistingMatchesAsync(versionDir,manifest,ct)) throw new InvalidOperationException("IMMUTABLE_PACKAGE_CONFLICT"); Directory.Delete(staging,true); } else Directory.Move(staging, versionDir); (faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.AfterImmutableCommitBeforeReturn); 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);
|
||||||
|
(faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.DuringOwnedStagingCleanup); 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,IReadOnlyList<PackagePayloadBinding> Files)> 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 files=manifest.Files.OrderBy(x=>x.Path,StringComparer.Ordinal).Select(x=>new PackagePayloadBinding(x.Path.Replace('\\','/'),x.Length,x.Sha256.ToUpperInvariant())).ToArray(); var payload=string.Join("|",files.Select(x=>$"{x.RelativePath}:{x.Length}:{x.Sha256}")); return (manifestHash,Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(payload))),files); }
|
||||||
|
private async Task<bool> PointerMatchesContentAsync(Pointer pointer,string id,string version,CancellationToken ct)
|
||||||
|
{ if(pointer.SchemaVersion==1) return true; try { var content=await ContentFingerprintAsync(id,version,ct); return string.Equals(pointer.ManifestSha256,content.Manifest,StringComparison.OrdinalIgnoreCase)&&string.Equals(pointer.PayloadSetSha256,content.PayloadSet,StringComparison.OrdinalIgnoreCase)&&pointer.PayloadFiles is not null&&pointer.PayloadFiles.SequenceEqual(content.Files); } catch{return false;} }
|
||||||
|
private async Task<bool> ExistingMatchesAsync(string directory,PackageManifest expected,CancellationToken ct)
|
||||||
|
{ try { var stored=PackageManifestJson.Parse(await File.ReadAllTextAsync(Path.Combine(directory,StoredManifestFileName),ct)); if(!ManifestEquivalent(stored,expected)) return false; foreach(var file in expected.Files){var path=PackageFilePath(directory,file.Path);var info=new FileInfo(path);if(!info.Exists||info.Length!=file.Length||!string.Equals(await hasher.Sha256Async(path,ct),file.Sha256,StringComparison.OrdinalIgnoreCase))return false;}return true;}catch{return false;} }
|
||||||
|
private static bool ManifestEquivalent(PackageManifest left,PackageManifest right) =>
|
||||||
|
left.SchemaVersion==right.SchemaVersion && left.PackageId==right.PackageId && left.Version==right.Version && left.Product==right.Product && left.ProductYear==right.ProductYear && string.Equals(left.Architecture,right.Architecture,StringComparison.OrdinalIgnoreCase) && left.EntryAssembly==right.EntryAssembly && left.Startup==right.Startup && string.Equals(left.RolloutChannel,right.RolloutChannel,StringComparison.OrdinalIgnoreCase) && left.Files.Count==right.Files.Count && left.Files.OrderBy(x=>x.Path,StringComparer.Ordinal).SequenceEqual(right.Files.OrderBy(x=>x.Path,StringComparer.Ordinal));
|
||||||
|
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,
|
IStartupScriptBuilder scriptBuilder,
|
||||||
IExecutionPermitService permits,
|
IExecutionPermitService permits,
|
||||||
IAutoCadProcessProbe probe,
|
IAutoCadProcessProbe probe,
|
||||||
IOperatingSystemProcessInvoker invoker) : IPilotExecutionService
|
IOperatingSystemProcessInvoker invoker,
|
||||||
|
IPathResolver? pathResolver = null) : IPilotExecutionService
|
||||||
{
|
{
|
||||||
private sealed record Pointer(string Version);
|
private sealed record Pointer(string Version);
|
||||||
private sealed record ActivationSnapshot(
|
private sealed record ActivationSnapshot(
|
||||||
@@ -25,6 +26,7 @@ public sealed class PilotExecutionService(
|
|||||||
int SchemaVersion, string SessionId, string TransactionId, string State,
|
int SchemaVersion, string SessionId, string TransactionId, string State,
|
||||||
string PackageId, string PackageVersion, string ManifestSha256,
|
string PackageId, string PackageVersion, string ManifestSha256,
|
||||||
string PilotRoot, string VersionDirectory, string StartupScriptPath,
|
string PilotRoot, string VersionDirectory, string StartupScriptPath,
|
||||||
|
string LaunchMode, IReadOnlyDictionary<string, string?> ResolvedPaths, IReadOnlyList<string> AcadArguments, string? ActualStartupScriptSha256,
|
||||||
string ActivationSnapshotPath, int? ProcessId, DateTimeOffset? ProcessStartUtc,
|
string ActivationSnapshotPath, int? ProcessId, DateTimeOffset? ProcessStartUtc,
|
||||||
int? ProcessExitCode, DateTimeOffset CreatedAtUtc, DateTimeOffset UpdatedAtUtc,
|
int? ProcessExitCode, DateTimeOffset CreatedAtUtc, DateTimeOffset UpdatedAtUtc,
|
||||||
string? LastErrorCode, string? LastErrorMessage);
|
string? LastErrorCode, string? LastErrorMessage);
|
||||||
@@ -32,23 +34,39 @@ public sealed class PilotExecutionService(
|
|||||||
public async Task<PilotPreparationResult> PrepareAsync(PilotPreparationRequest request, CancellationToken cancellationToken)
|
public async Task<PilotPreparationResult> PrepareAsync(PilotPreparationRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
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 validation = validator.Validate(request.Manifest, 2026, "x64");
|
||||||
var hashes = await ValidateSource(request.Manifest, request.PackageSourceRoot, cancellationToken);
|
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 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);
|
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 automation = request.Configuration.LaunchMode is AcadLaunchMode.NextPilotPreview or AcadLaunchMode.NextPilotApply or AcadLaunchMode.LifecycleProbe;
|
||||||
var content = $"_.NETLOAD\n\"{futureEntry.Replace("\"", "\\\"")}\"\n";
|
var content = automation ? "AUTOMATION_SCRIPT_PENDING" : "PRODUCTION_NO_SCRIPT";
|
||||||
var scriptHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content)));
|
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);
|
||||||
return new(plan with { Process = plan.Process with { EnvironmentVariables = new Dictionary<string, string> { ["P_REC_ANNOT_RUNTIME_MODE"] = request.Configuration.PluginExecutionMode.ToString() } } }, safety);
|
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 (automation)
|
||||||
|
{
|
||||||
|
environment["P_REC_ANNOT_LAUNCHER_MODE"] = "NextPilot";
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PilotExecutionResult> ExecuteAsync(PilotExecutionRequest request, CancellationToken cancellationToken)
|
public async Task<PilotExecutionResult> ExecuteAsync(PilotExecutionRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var plan = request.Plan;
|
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 root = rootPolicy.Validate(request.Preparation.PilotRoot);
|
||||||
var validation = validator.Validate(request.Preparation.Manifest, 2026, "x64");
|
var validation = validator.Validate(request.Preparation.Manifest, 2026, "x64");
|
||||||
var hashes = await ValidateSource(request.Preparation.Manifest, request.Preparation.PackageSourceRoot, cancellationToken);
|
var hashes = await ValidateSource(request.Preparation.Manifest, request.Preparation.PackageSourceRoot, cancellationToken);
|
||||||
@@ -56,7 +74,7 @@ public sealed class PilotExecutionService(
|
|||||||
new AutoCadProfileCandidate(string.Empty, false, true, ["No profile in confirmed contract."]), root, validation, hashes, false, probe.IsAcadRunning());
|
new AutoCadProfileCandidate(string.Empty, false, true, ["No profile in confirmed contract."]), root, validation, hashes, false, probe.IsAcadRunning());
|
||||||
var decision = gate.Evaluate(context, true);
|
var decision = gate.Evaluate(context, true);
|
||||||
if (!decision.IsAllowed) return new(false, null, decision.Violations.First(), null, null);
|
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);
|
return new(false, null, "PILOT_PLAN_CHANGED", null, null);
|
||||||
|
|
||||||
var sessionId = Guid.NewGuid().ToString("N");
|
var sessionId = Guid.NewGuid().ToString("N");
|
||||||
@@ -64,7 +82,7 @@ public sealed class PilotExecutionService(
|
|||||||
var packageRoot = Path.Combine(plan.PilotRoot, "packages", plan.PackageId);
|
var packageRoot = Path.Combine(plan.PilotRoot, "packages", plan.PackageId);
|
||||||
var versionDirectory = plan.VersionDirectory;
|
var versionDirectory = plan.VersionDirectory;
|
||||||
var staging = Path.Combine(plan.PilotRoot, "staging", transactionId);
|
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");
|
var snapshotPath = Path.Combine(plan.PilotRoot, "state", "transactions", transactionId + ".json");
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(snapshotPath)!);
|
Directory.CreateDirectory(Path.GetDirectoryName(snapshotPath)!);
|
||||||
var currentPath = Path.Combine(packageRoot, "current.json");
|
var currentPath = Path.Combine(packageRoot, "current.json");
|
||||||
@@ -73,7 +91,7 @@ public sealed class PilotExecutionService(
|
|||||||
var previous = await ReadTextOrNull(previousPath, cancellationToken);
|
var previous = await ReadTextOrNull(previousPath, cancellationToken);
|
||||||
var snapshot = new ActivationSnapshot(transactionId, plan.PackageId, plan.PackageVersion, current, Hash(current), previous, Hash(previous), versionDirectory, scriptPath, staging);
|
var snapshot = new ActivationSnapshot(transactionId, plan.PackageId, plan.PackageVersion, current, Hash(current), previous, Hash(previous), versionDirectory, scriptPath, staging);
|
||||||
await WriteJsonAtomic(snapshotPath, snapshot, cancellationToken);
|
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
|
try
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(staging);
|
Directory.CreateDirectory(staging);
|
||||||
@@ -91,10 +109,20 @@ public sealed class PilotExecutionService(
|
|||||||
if (Directory.Exists(versionDirectory)) Directory.Delete(staging, true); else Directory.Move(staging, versionDirectory);
|
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(previousPath, current ?? string.Empty, cancellationToken, current is not null);
|
||||||
await WriteTextAtomic(currentPath, JsonSerializer.Serialize(new Pointer(plan.PackageVersion)), cancellationToken, true);
|
await WriteTextAtomic(currentPath, JsonSerializer.Serialize(new Pointer(plan.PackageVersion)), cancellationToken, true);
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(scriptPath)!);
|
var automation = request.Preparation.Configuration.LaunchMode is AcadLaunchMode.NextPilotPreview or AcadLaunchMode.NextPilotApply or AcadLaunchMode.LifecycleProbe;
|
||||||
await File.WriteAllTextAsync(scriptPath, scriptBuilder.BuildNetloadScript(versionDirectory, request.Preparation.Manifest.EntryAssembly), cancellationToken);
|
if (automation)
|
||||||
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 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 = 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, request.Preparation.Configuration.AutomationSelection));
|
||||||
|
await File.WriteAllTextAsync(scriptPath, script, cancellationToken);
|
||||||
|
}
|
||||||
|
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)
|
if (!result.Started)
|
||||||
{
|
{
|
||||||
await MarkFailed(plan.PilotRoot, result.BlockCode ?? "PROCESS_START_FAILED", cancellationToken);
|
await MarkFailed(plan.PilotRoot, result.BlockCode ?? "PROCESS_START_FAILED", cancellationToken);
|
||||||
@@ -111,6 +139,14 @@ public sealed class PilotExecutionService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string ReadFixtureOrtho(string? automationConfigurationPath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(automationConfigurationPath) || !File.Exists(automationConfigurationPath)) throw new InvalidOperationException("AUTOMATION_CONFIG_MISSING");
|
||||||
|
using var json = JsonDocument.Parse(File.ReadAllText(automationConfigurationPath));
|
||||||
|
if (!json.RootElement.TryGetProperty("orthoDrawingPath", out var value) || string.IsNullOrWhiteSpace(value.GetString())) throw new InvalidOperationException("AUTOMATION_ORTHO_PATH_REQUIRED");
|
||||||
|
return Path.GetFullPath(value.GetString()!);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<PilotRollbackResult> RollbackAsync(PilotRollbackRequest request, CancellationToken cancellationToken)
|
public async Task<PilotRollbackResult> RollbackAsync(PilotRollbackRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -1,5 +1,51 @@
|
|||||||
# Package activation
|
# Package activation
|
||||||
|
|
||||||
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.
|
Files are copied to an operation-owned staging directory, rehashed and moved to a deterministic immutable version location. Materialization never changes activation pointers. Explicit activation writes schema-v2 `current.json` through an operation-owned temporary file, flush, replace and readback. The pointer binds manifest SHA-256, deterministic payload-set SHA-256 and every payload relative path/length/SHA-256.
|
||||||
|
|
||||||
|
Activation and receipt-bound rollback are serialized by a process-local `SemaphoreSlim`. Cross-process coordination is not verified and network-share pointer mutation is not supported. The controlled lifecycle pilot uses only an explicit local root and does not imply corporate DFS, AutoCAD runtime or production acceptance.
|
||||||
|
|
||||||
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.
|
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
|
||||||
|
immutable 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 follows only the explicit current/previous pointers and never enumerates the version store to find an approximate candidate.
|
||||||
|
|
||||||
|
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 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.
|
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,15 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"pilotTooling": "VERIFIED_BY_TESTS",
|
||||||
|
"realCorporateRosterParsing": "BLOCKED",
|
||||||
|
"realCorporatePackageAcquisition": "BLOCKED",
|
||||||
|
"blockReason": "CORPORATE_CATALOG_AND_ROSTER_PATHS_NOT_CONFIGURED",
|
||||||
|
"localImmutableStaging": "CONTROLLED_FILESYSTEM_PILOT_VERIFIED",
|
||||||
|
"localPackageLifecycle": "CONTROLLED_FILESYSTEM_PILOT_VERIFIED",
|
||||||
|
"controlledOutageRecovery": "CONTROLLED_FILESYSTEM_PILOT_VERIFIED",
|
||||||
|
"crossProcessActivationCoordination": "NOT_RUNTIME_VERIFIED",
|
||||||
|
"productionActivation": "NOT_RUNTIME_VERIFIED",
|
||||||
|
"autodeskRuntime": "NOT_RUNTIME_VERIFIED",
|
||||||
|
"productionReadiness": "NOT_RUNTIME_VERIFIED",
|
||||||
|
"redaction": "No corporate paths, roster contents, package payload, session identity, or machine data were recorded."
|
||||||
|
}
|
||||||
+6
-1
@@ -1,6 +1,11 @@
|
|||||||
# Foundation scope
|
# 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.
|
`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.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Legacy launcher capability matrix
|
||||||
|
|
||||||
|
Source audit: `All_Serv_Install/Form1.cs`, `F1_Xml.cs`, `F2_Xml.cs`, `All_Serv_Load/Form1.cs`, `All_Serv_InstallUpdater/Form1.cs`, `Control_Sum_XML.cs`, `P_Serv_Starter/Program.cs`, `launcher_starter.lsp`, `Settings.xml`, and `download_list_plant.xml`.
|
||||||
|
|
||||||
|
| Legacy capability | User-visible behavior | Modern equivalent | Status |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Settings startup validation | Shows configurations only after required XML fields validate | Strict `LegacyConfigurationAdapter.ParseSettings` and UI file selection | SOURCE_VERIFIED / IMPLEMENTED |
|
||||||
|
| Pattern selection | Lists pattern name, filter and roster, then enables project/plugin workflow | Launcher Next lists patterns and selected filter/roster | SOURCE_VERIFIED / IMPLEMENTED |
|
||||||
|
| Ordered download list | Resolves ordered plugin IDs, sources and renewal aliases | Adapter preserves ordered IDs; overlay join rejects missing/ambiguous IDs | SOURCE_VERIFIED / VERIFIED_BY_TESTS |
|
||||||
|
| Plugin copy/install | Recursively copies arbitrary catalog directories and optional install payloads | Verified manifest acquisition to immutable local package only | SOURCE_VERIFIED / UNSUPPORTED_BLOCKING |
|
||||||
|
| Update checks | Reconciles server tree using mutable MD5 `control_sums.xml` | No trust is assigned to legacy checksum XML; SHA-256 manifest validation is required | SOURCE_VERIFIED / IGNORED_NON_OPERATIONAL_WITH_REASON |
|
||||||
|
| Local active state | Deletes/replaces local catalogs before launch | Materialize, validate, immutable commit and explicit activation | IMPLEMENTED / CONTROLLED_RUNTIME_VERIFIED |
|
||||||
|
| Starter LSP | Stages loader, mutates SupportPath and loads DLL/DVB/LISP | Not reproduced: direct legacy payload execution and persistent SupportPath mutation are unsafe | SOURCE_VERIFIED / UNSUPPORTED_BLOCKING |
|
||||||
|
| Plant discovery | Legacy BAT starts Plant 3D fire-and-forget | Product-specific ACAD-9117 registry discovery and UI status | SOURCE_VERIFIED / IMPLEMENTED |
|
||||||
|
| Interactive launch | Starts BAT without PID/evidence or wait | Requires verified local active package, explicit safe launch plan, PID check and evidence | SOURCE_VERIFIED / UNSUPPORTED_BLOCKING |
|
||||||
|
| Error/status UX | WinForms message boxes and partial continuation | Explicit UI ready/block status and diagnostics | SOURCE_VERIFIED / IMPLEMENTED |
|
||||||
|
|
||||||
|
## Blocking external inputs
|
||||||
|
|
||||||
|
The checked-in legacy settings reference `N:`/`Q:` production sources and legacy binaries. No owner-approved 2026 compatibility overlay, local manifest-backed catalog, or exact package roster is present in this worktree. Therefore enabling Prepare or Launch would either guess mappings or execute unverified legacy payloads; both violate the Launcher invariants.
|
||||||
|
|
||||||
|
## Source-verified product selection contract
|
||||||
|
|
||||||
|
| Product | Legacy source behavior | Launcher Next implementation | Verification / remaining input |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Plant 3D | `Form1.button_select_project_Click` opens the selected pattern's file filter; automatic list comes from `%PROJECTDIRECTORY%\\..\\_System\\<pattern>\\Applications\\download_list.xml`; list mode explicitly opens `download_list.xml`. | Product-specific `OpenFileDialog`, exact relative roster resolver and manual XML selection feed the existing controlled Plant package/`/b` bootstrap flow. | `PLANT3D_CONTROLLED_WORKFLOW_VERIFIED`; launch/bootstrap path unchanged. |
|
||||||
|
| Plant P&ID | Filter is `Project files |Project.xml|All files (*.*)|*.*`; legacy starter was shared with Plant 3D. | Requires exactly `Project.xml`; resolves `Plant PnID_2019` automatic roster or explicit manual `download_list.xml`; preserves list order. | `PNID_UI_DISCOVERY_PROJECT_AND_PLAN_VERIFIED`; mappings blocked for `c1b999d6c16029e9ac5b1e1a8afe1500`, `5b80379c3ec715e7da0ee414c53aa7bc`, `1111`. No 2026 package is invented. |
|
||||||
|
| Navisworks Manage | Filter is `nwd files (*.nwd)|*.nwd|All files (*.*)|*.*`; automatic roster is project-relative by the same formula. The reviewed source does not establish a Navisworks plugin bootstrap. | Requires `.nwd`; resolves `Navisworks_Manage_2018` automatic roster or explicit manual `download_list.xml`; does not select Plant `/b`, `NETLOAD`, or PXP-OA bootstrap. | `NAVISWORKS_UI_DISCOVERY_PROJECT_AND_PLAN_VERIFIED`; mapping blocked for `7dd981169fa14d21aaef3fcdb0e7e970`. |
|
||||||
|
|
||||||
|
The automatic resolver performs no directory scan or fallback: it reads only the canonicalized source-verified location. Manual selection keeps the selected project file unchanged, enforces the `download_list.xml` basename, rejects reparse files and relies on the strict XML parser for malformed and duplicate IDs.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Owner project roster compatibility
|
||||||
|
|
||||||
|
Read-only analysis of `\\phimproject.com\a\ATest_Total\_System\Plant 3D_2019\Applications\download_list.xml` recorded SHA-256 `493CA3B94ECF937CA09D2A37120A6D86DC81597B5C10284A1DF37E3D0352979D` (3,589 bytes; UTF-8 BOM).
|
||||||
|
|
||||||
|
Its real shape is `root → area → id`: one `area`, 33 ordered IDs, no empty IDs and no duplicate IDs. `b455ee6eaed79d76f084846a60ce3284` is entry 27 and joins to `P_Rec_Annot` / legacy `OrthoLoader.dll`; the explicit 2026 mapping resolves it to `P_Rec_Annot` `0.1.0` / `PRecAnnot.Plant3D2026.dll`.
|
||||||
|
|
||||||
|
Legacy `All_Serv_Load/F2_Xml.cs` used `[XmlElement("id")]` on `F2_Root`, which reads only direct `root/id` entries and removes only exact empty strings. It neither trims nor rejects duplicate IDs; nested `area/id` entries are ignored. Launcher Next now preserves that direct-ID behavior for diagnostics while deterministically expanding the real `area/id` source order for the modern plan. Duplicate source IDs remain visible, while package execution uses a case-insensitive effective unique list.
|
||||||
|
|
||||||
|
The project roster is acquired from UNC into an owned local snapshot before parsing. XML parse/shape errors, missing Settings definitions, ambiguous definitions and absent 2026 mappings are distinct stages. Known-but-unmapped plugins remain visible and block launch without being misreported as invalid XML or process failure.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Owner-review network configuration
|
||||||
|
|
||||||
|
The only approved legacy Settings endpoint is `\\phimproject.com\common\BIM\save\All_Serv_Install\Settings.xml`. Launcher Next reads it read-only, validates it as the exact UNC file, snapshots it under the local controlled root, verifies the copied SHA-256, and parses only the local snapshot.
|
||||||
|
|
||||||
|
`N:\BIM\...` is supported only as a versioned alias for `\\phimproject.com\common\BIM\...`; other drive aliases are rejected. No payload is executed from the network source.
|
||||||
|
|
||||||
|
For the controlled Plant owner-review distribution, the real `Plant 3D_2019` definition supplies the project-relative roster template. The controlled roster contains the real Settings plugin ID `b455ee6eaed79d76f084846a60ce3284`, mapped explicitly to `P_Rec_Annot` `0.1.0` / `PRecAnnot.Plant3D2026.dll`. This is a migration mapping: its legacy `OrthoLoader.dll` name is not executed.
|
||||||
|
|
||||||
|
When the network endpoint is unavailable, Launcher can use only an existing validated local snapshot and identifies that condition as offline fallback in runtime evidence; it never silently substitutes a synthetic Settings file.
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
# Pilot local configuration
|
# 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
|
```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}
|
{"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}"
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$OutputRoot,
|
||||||
|
[string]$SourceFixtureRoot = 'C:\dev\TEMP\local-parity-launch-20260806'
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||||
|
$releaseRoot = Join-Path $repoRoot 'Launcher.Next.App\bin\x64\Release\net8.0-windows'
|
||||||
|
$output = [IO.Path]::GetFullPath($OutputRoot)
|
||||||
|
$fixture = [IO.Path]::GetFullPath($SourceFixtureRoot)
|
||||||
|
if (-not (Test-Path -LiteralPath (Join-Path $releaseRoot 'Launcher.Next.App.exe'))) { throw 'OWNER_REVIEW_RELEASE_EXE_MISSING' }
|
||||||
|
if (-not (Test-Path -LiteralPath (Join-Path $fixture 'catalog\P_Rec_Annot\0.1.0\package.manifest.json'))) { throw 'OWNER_REVIEW_CATALOG_INPUT_MISSING' }
|
||||||
|
if (Test-Path -LiteralPath $output) { throw 'OWNER_REVIEW_OUTPUT_ALREADY_EXISTS' }
|
||||||
|
|
||||||
|
$app = Join-Path $output 'app'
|
||||||
|
$controlled = Join-Path $output 'controlled'
|
||||||
|
New-Item -ItemType Directory -Path $app, $controlled, (Join-Path $controlled 'configuration'), (Join-Path $controlled 'project'), (Join-Path $controlled 'evidence') | Out-Null
|
||||||
|
Copy-Item -Path (Join-Path $releaseRoot '*') -Destination $app -Recurse
|
||||||
|
Copy-Item -LiteralPath (Join-Path $fixture 'catalog') -Destination $controlled -Recurse
|
||||||
|
Copy-Item -LiteralPath (Join-Path $fixture 'project\Project.xml') -Destination (Join-Path $controlled 'project\Project.xml')
|
||||||
|
|
||||||
|
$rosterDirectory = Join-Path $controlled '_System\Plant 3D_2019\Applications'
|
||||||
|
New-Item -ItemType Directory -Path $rosterDirectory | Out-Null
|
||||||
|
'<root><id>b455ee6eaed79d76f084846a60ce3284</id></root>' | Set-Content -LiteralPath (Join-Path $rosterDirectory 'download_list.xml') -Encoding utf8
|
||||||
|
'{"schemaVersion":1,"mappings":[{"mappingId":"owner-network-p-rec-annot","pattern":"Plant 3D_2019","legacyPluginId":"b455ee6eaed79d76f084846a60ce3284","legacyCatalog":"P_Rec_Annot","legacyInstall":"OrthoLoader.dll","package":{"id":"P_Rec_Annot","product":"Plant3D","productYear":2026,"architecture":"x64","channel":"controlled-local","version":"0.1.0"}}]}' | Set-Content -LiteralPath (Join-Path $controlled 'configuration\compatibility-overlay.json') -Encoding utf8
|
||||||
|
|
||||||
|
$profile = [ordered]@{
|
||||||
|
schemaVersion = 1
|
||||||
|
name = 'Owner Review Controlled Local PXP-OA'
|
||||||
|
root = $controlled
|
||||||
|
settingsPath = (Join-Path $controlled 'configuration\settings-pending.xml')
|
||||||
|
downloadListPath = (Join-Path $rosterDirectory 'download_list.xml')
|
||||||
|
overlayPath = (Join-Path $controlled 'configuration\compatibility-overlay.json')
|
||||||
|
catalogRoot = (Join-Path $controlled 'catalog')
|
||||||
|
patternName = 'Plant 3D_2019'
|
||||||
|
evidenceRoot = (Join-Path $controlled 'evidence')
|
||||||
|
networkSettingsEndpoint = '\\phimproject.com\common\BIM\save\All_Serv_Install\Settings.xml'
|
||||||
|
}
|
||||||
|
$profilePath = Join-Path $controlled 'configuration\owner-review-profile.json'
|
||||||
|
$profile | ConvertTo-Json | Set-Content -LiteralPath $profilePath -Encoding utf8
|
||||||
|
@{ launcherCommit = (git -C $repoRoot rev-parse HEAD).Trim(); distribution = 'OwnerReviewControlledLocal'; createdUtc = [DateTimeOffset]::UtcNow } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $app 'owner-review-release.json') -Encoding utf8
|
||||||
|
@"
|
||||||
|
@echo off
|
||||||
|
"%~dp0Launcher.Next.App.exe" --mode LocalParityLaunch --config "%~dp0..\controlled\configuration\owner-review-profile.json"
|
||||||
|
"@ | Set-Content -LiteralPath (Join-Path $app 'Launch Owner Review.cmd') -Encoding ascii
|
||||||
|
|
||||||
|
[pscustomobject]@{
|
||||||
|
DistributionRoot = $output
|
||||||
|
Executable = (Join-Path $app 'Launcher.Next.App.exe')
|
||||||
|
Launcher = (Join-Path $app 'Launch Owner Review.cmd')
|
||||||
|
Profile = $profilePath
|
||||||
|
Project = (Join-Path $controlled 'project\Project.xml')
|
||||||
|
AutomaticRoster = (Join-Path $rosterDirectory 'download_list.xml')
|
||||||
|
} | Format-List
|
||||||
@@ -4,12 +4,22 @@ using Xunit;
|
|||||||
namespace Launcher.Next.Tests;
|
namespace Launcher.Next.Tests;
|
||||||
public sealed class ControlledExecutionTests
|
public sealed class ControlledExecutionTests
|
||||||
{
|
{
|
||||||
|
[Fact] public void ApplyMode_RequiresExplicitAllowApply()
|
||||||
|
{ var c = new PilotLocalConfiguration { SchemaVersion = 1, LauncherMode = LauncherMode.NextPilot, PilotEnabled = true, AllowProcessStart = true, RequireExplicitExecute = true, PluginExecutionMode = PluginExecutionMode.Apply }; 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("APPLY_NOT_EXPLICITLY_ALLOWED", d.Violations); }
|
||||||
|
[Fact] public void ApplyMode_WithExplicitAllowApply_IsAllowedByLauncherGate()
|
||||||
|
{ var c = new PilotLocalConfiguration { SchemaVersion = 1, LauncherMode = LauncherMode.NextPilot, PilotEnabled = true, AllowProcessStart = true, AllowApply = true, ApplyPermitPath = "C:\\test\\permit.json", RequireExplicitExecute = true, PluginExecutionMode = PluginExecutionMode.Apply }; 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 ApplyMode_RequiresLocalPermitPath()
|
||||||
|
{ var c = new PilotLocalConfiguration { SchemaVersion = 1, LauncherMode = LauncherMode.NextPilot, PilotEnabled = true, AllowProcessStart = true, AllowApply = true, RequireExplicitExecute = true, PluginExecutionMode = PluginExecutionMode.Apply }; 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("APPLY_PERMIT_PATH_REQUIRED", d.Violations); }
|
||||||
[Fact] public void MissingPilotConfiguration_DefaultsToDryRun() => Assert.Equal(LauncherMode.DryRun, PilotConfigurationParser.Parse(null).Configuration.LauncherMode);
|
[Fact] public void MissingPilotConfiguration_DefaultsToDryRun() => Assert.Equal(LauncherMode.DryRun, PilotConfigurationParser.Parse(null).Configuration.LauncherMode);
|
||||||
[Fact] public void InvalidPilotConfiguration_BlocksPilot() => Assert.False(PilotConfigurationParser.Parse("{").IsValid);
|
[Fact] public void InvalidPilotConfiguration_BlocksPilot() => Assert.False(PilotConfigurationParser.Parse("{").IsValid);
|
||||||
[Fact] public void PilotRoot_Unc_IsRejected() => Assert.False(new PilotRootPolicy("C:\\AutoCAD User Support").Validate("\\\\server\\share").IsValid);
|
[Fact] public void PilotRoot_Unc_IsRejected() => Assert.False(new PilotRootPolicy("C:\\AutoCAD User Support").Validate("\\\\server\\share").IsValid);
|
||||||
[Fact] public void PilotRoot_AllowedCorporateChild_IsAccepted() => Assert.True(new PilotRootPolicy("C:\\AutoCAD User Support").Validate("C:\\AutoCAD User Support\\LauncherNextPilot").IsValid);
|
[Fact] public void PilotRoot_AllowedCorporateChild_IsAccepted() => Assert.True(new PilotRootPolicy("C:\\AutoCAD User Support").Validate("C:\\AutoCAD User Support\\LauncherNextPilot").IsValid);
|
||||||
[Fact] public void SafetyGate_ProcessStartDisabled_BlocksExecute()
|
[Fact] public void SafetyGate_ProcessStartDisabled_BlocksExecute()
|
||||||
{ var c=new PilotLocalConfiguration { SchemaVersion=1, LauncherMode=LauncherMode.NextPilot, PilotEnabled=true, PluginExecutionMode=PluginExecutionMode.Smoke }; var decision=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("PROCESS_START_DISABLED",decision.Violations); }
|
{ var c=new PilotLocalConfiguration { SchemaVersion=1, LauncherMode=LauncherMode.NextPilot, PilotEnabled=true, PluginExecutionMode=PluginExecutionMode.Smoke }; var decision=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("PROCESS_START_DISABLED",decision.Violations); }
|
||||||
|
[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",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()
|
[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)); }
|
{ 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()
|
[Fact] public void GuardedLauncher_RealExecutionReleaseDisabled_BlocksStart()
|
||||||
|
|||||||
@@ -7,9 +7,12 @@ namespace Launcher.Next.Tests;
|
|||||||
|
|
||||||
public sealed class FoundationTests
|
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);
|
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_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_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_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);
|
[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); }
|
{ 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()
|
[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); }
|
{ 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()
|
[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); }
|
{ 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()
|
[Fact] public void ProcessLauncher_DryRun_DoesNotStartProcess()
|
||||||
@@ -36,4 +64,12 @@ public sealed class FoundationTests
|
|||||||
try { var store = new LocalPackageStore(root, new Sha256FileHasher()); var active = await store.SynchronizeAsync(manifest, source, CancellationToken.None); var packageRoot = Path.Combine(root, "Applications", manifest.PackageId, "versions", active.CurrentVersion); var script = new StartupScriptBuilder().BuildNetloadScript(packageRoot, manifest.EntryAssembly); var launch = new DryRunAutodeskProcessLauncher().Prepare(new LaunchRequest(new("C:\\acad.exe", "2026", "x64", []), "Pilot", script, true)); Assert.False(launch.WouldStart); Assert.Single(System.Text.RegularExpressions.Regex.Matches(script, "netload", System.Text.RegularExpressions.RegexOptions.IgnoreCase).Cast<object>()); }
|
try { var store = new LocalPackageStore(root, new Sha256FileHasher()); var active = await store.SynchronizeAsync(manifest, source, CancellationToken.None); var packageRoot = Path.Combine(root, "Applications", manifest.PackageId, "versions", active.CurrentVersion); var script = new StartupScriptBuilder().BuildNetloadScript(packageRoot, manifest.EntryAssembly); var launch = new DryRunAutodeskProcessLauncher().Prepare(new LaunchRequest(new("C:\\acad.exe", "2026", "x64", []), "Pilot", script, true)); Assert.False(launch.WouldStart); Assert.Single(System.Text.RegularExpressions.Regex.Matches(script, "netload", System.Text.RegularExpressions.RegexOptions.IgnoreCase).Cast<object>()); }
|
||||||
finally { Directory.Delete(root, true); }
|
finally { Directory.Delete(root, true); }
|
||||||
}
|
}
|
||||||
|
[Fact] public void PilotCli_UsesContentAddressedVersionForImmutableActivation()
|
||||||
|
{
|
||||||
|
var root = new DirectoryInfo(AppContext.BaseDirectory);
|
||||||
|
while (root is not null && !File.Exists(Path.Combine(root.FullName, "Launcher.Next.sln"))) root = root.Parent;
|
||||||
|
var source = File.ReadAllText(Path.Combine(root!.FullName, "Launcher.Next.App", "PilotCli.cs"));
|
||||||
|
Assert.Contains("contentIdentity", source);
|
||||||
|
Assert.Contains("assemblyVersion + \"-\" + contentIdentity", source);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,10 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\Launcher.Next.Core\Launcher.Next.Core.csproj" />
|
<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.Infrastructure\Launcher.Next.Infrastructure.csproj" />
|
||||||
<ProjectReference Include="..\..\Launcher.Next.Autodesk\Launcher.Next.Autodesk.csproj" />
|
<ProjectReference Include="..\..\Launcher.Next.Autodesk\Launcher.Next.Autodesk.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Launcher.Next.App\Launcher.Next.App.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup><None Update="fixtures\p-rec-annot-pilot.manifest.json" CopyToOutputDirectory="PreserveNewest" /></ItemGroup>
|
<ItemGroup><None Update="fixtures\p-rec-annot-pilot.manifest.json" CopyToOutputDirectory="PreserveNewest" /></ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using Launcher.Next.Application;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Launcher.Next.Tests;
|
||||||
|
|
||||||
|
public sealed class LegacyNetworkConfigurationTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ApprovedSettingsEndpoint_IsExactUncFile()
|
||||||
|
{
|
||||||
|
Assert.Equal(@"\\phimproject.com\common\BIM\save\All_Serv_Install\Settings.xml", LegacyNetworkConfiguration.ApprovedSettingsEndpoint);
|
||||||
|
Assert.StartsWith(@"\\", LegacyNetworkConfiguration.ApprovedSettingsEndpoint, StringComparison.Ordinal);
|
||||||
|
Assert.EndsWith("Settings.xml", LegacyNetworkConfiguration.ApprovedSettingsEndpoint, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(@"N:\BIM", @"\\phimproject.com\common\BIM")]
|
||||||
|
[InlineData(@"N:\BIM\save\Apps\Plant3D\DriveA", @"\\phimproject.com\common\BIM\save\Apps\Plant3D\DriveA")]
|
||||||
|
public void ApprovedNDriveAlias_ResolvesOnlyBimNamespace(string original, string expected)
|
||||||
|
=> Assert.Equal(expected, LegacyNetworkConfiguration.ResolveApprovedAlias(original));
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(@"N:\Other\payload.dll")]
|
||||||
|
[InlineData(@"Q:\BIM\save\Apps")]
|
||||||
|
[InlineData("relative\\Settings.xml")]
|
||||||
|
public void UnknownLegacyAliases_AreRejected(string path)
|
||||||
|
=> Assert.Equal("LEGACY_NETWORK_ALIAS_NOT_APPROVED", Assert.Throws<InvalidOperationException>(() => LegacyNetworkConfiguration.ResolveApprovedAlias(path)).Message);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnapprovedEndpoint_IsRejectedBeforeAnyNetworkAccess()
|
||||||
|
{
|
||||||
|
var root = Path.Combine(Path.GetTempPath(), "launcher-network-" + Guid.NewGuid().ToString("N"));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.Equal("LEGACY_SETTINGS_ENDPOINT_NOT_APPROVED", Assert.Throws<InvalidOperationException>(() => LegacyNetworkConfiguration.AcquireApprovedSnapshot(@"\\example.invalid\Settings.xml", root)).Message);
|
||||||
|
}
|
||||||
|
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using Launcher.Next.Application;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Launcher.Next.Tests;
|
||||||
|
|
||||||
|
public sealed class LegacyNetworkSettingsParserTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void FullLegacyPattern_PreservesSourcesAttributesAndMachineRestriction()
|
||||||
|
{
|
||||||
|
const string xml = "<root><path><help>h</help><care>c</care></path><data><pattern><name>Plant 3D_2019</name><filter>Project files |Project.xml|All files (*.*)|*.*</filter><roster>%PROJECTDIRECTORY%\\..\\_System\\Plant 3D_2019\\Applications\\download_list.xml</roster><source><course>N:\\BIM\\save\\Apps\\Plant3D\\DriveA</course></source><source><course>N:\\BIM\\save\\Apps\\Plant3D\\DriveQ</course></source><target>target</target><loader>loader</loader><object>object</object><attrib>first</attrib><attrib>second</attrib></pattern><plugin><id>b455ee6eaed79d76f084846a60ce3284</id><renewal></renewal><catalog>P_Rec_Annot</catalog><install>OrthoLoader.dll</install><machine>BUILD-01</machine></plugin></data></root>";
|
||||||
|
var settings = new LegacyConfigurationAdapter().ParseSettings(xml);
|
||||||
|
var pattern = Assert.Single(settings.Patterns);
|
||||||
|
Assert.Equal(["N:\\BIM\\save\\Apps\\Plant3D\\DriveA", "N:\\BIM\\save\\Apps\\Plant3D\\DriveQ"], pattern.Sources);
|
||||||
|
Assert.Equal(["first", "second"], pattern.Attributes);
|
||||||
|
Assert.Equal("target", pattern.Target);
|
||||||
|
var plugin = Assert.Single(pattern.Plugins);
|
||||||
|
Assert.Equal("P_Rec_Annot", plugin.Catalog);
|
||||||
|
Assert.True(plugin.IsAllowedOn("build-01"));
|
||||||
|
Assert.False(plugin.IsAllowedOn("OTHER"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PluginWithoutMachineRestriction_IsAllowedEverywhere()
|
||||||
|
{
|
||||||
|
var plugin = new LegacyPluginDefinition("id", "", "catalog", "install", 0);
|
||||||
|
Assert.True(plugin.IsAllowedOn(Environment.MachineName));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RealOwnerRosterShape_UsesAreaIdsInSourceOrderAndPreservesDuplicateEvidence()
|
||||||
|
{
|
||||||
|
const string roster = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><root><!-- comment --><area><agname>1</agname><id>first</id><id> second </id><id>first</id><id></id></area><ignored><id>not-selected</id></ignored></root>";
|
||||||
|
var selection = new LegacyConfigurationAdapter().ParseDownloadList(roster);
|
||||||
|
Assert.Equal(["first", " second ", "first"], selection.PluginIds);
|
||||||
|
Assert.Equal(["first", " second "], selection.EffectivePluginIds);
|
||||||
|
Assert.Equal("Area", selection.Entries![0].SourceShape);
|
||||||
|
Assert.Equal(0, selection.Entries[2].FirstOccurrenceIndex);
|
||||||
|
Assert.Equal("", selection.Entries[3].RawId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DownloadListErrors_AreSpecific()
|
||||||
|
{
|
||||||
|
var adapter = new LegacyConfigurationAdapter();
|
||||||
|
Assert.Equal("DOWNLOAD_LIST_ROOT_INVALID", Assert.Throws<InvalidOperationException>(() => adapter.ParseDownloadList("<other/>")).Message);
|
||||||
|
Assert.Equal("DOWNLOAD_LIST_XML_MALFORMED", Assert.Throws<InvalidOperationException>(() => adapter.ParseDownloadList("<root>")).Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
using Launcher.Next.Application;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Launcher.Next.Tests;
|
||||||
|
|
||||||
|
public sealed class LegacyProductSelectionTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void SourceVerifiedProducts_ExposeExactOpenFileFilters()
|
||||||
|
{
|
||||||
|
Assert.Equal("Project files |Project.xml|All files (*.*)|*.*", LegacyProductSelections.Pnid.Filter);
|
||||||
|
Assert.Equal("nwd files (*.nwd)|*.nwd|All files (*.*)|*.*", LegacyProductSelections.Navisworks.Filter);
|
||||||
|
Assert.Equal("Project.xml", LegacyProductSelections.Pnid.RequiredFileName);
|
||||||
|
Assert.Equal(".nwd", LegacyProductSelections.Navisworks.RequiredFileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AutomaticRoster_UsesOnlyExactLegacyRelativeLocation()
|
||||||
|
{
|
||||||
|
using var fixture = new SelectionFixture();
|
||||||
|
var project = fixture.ProjectFile("Project.xml");
|
||||||
|
var actual = LegacyProductSelections.AutomaticRoster(LegacyProductSelections.Pnid, project);
|
||||||
|
Assert.Equal(Path.Combine(fixture.Root, "_System", "Plant PnID_2019", "Applications", "download_list.xml"), actual);
|
||||||
|
Assert.DoesNotContain("scan", actual, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SelectedProjectFile_IsProductSpecificAndRejectsWrongExtension()
|
||||||
|
{
|
||||||
|
using var fixture = new SelectionFixture();
|
||||||
|
var nwd = fixture.ProjectFile("model.nwd");
|
||||||
|
Assert.Equal(nwd, LegacyProductSelections.ValidateSelectedFile(LegacyProductSelections.Navisworks, nwd));
|
||||||
|
Assert.Throws<InvalidOperationException>(() => LegacyProductSelections.ValidateSelectedFile(LegacyProductSelections.Pnid, nwd));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AutomaticPnidRoster_PreservesOrderAndReportsExactUnmappedIds()
|
||||||
|
{
|
||||||
|
using var fixture = new SelectionFixture();
|
||||||
|
var project = fixture.ProjectFile("Project.xml");
|
||||||
|
fixture.WriteAutomatic("Plant PnID_2019", "<root><id>c1b999d6c16029e9ac5b1e1a8afe1500</id><id>5b80379c3ec715e7da0ee414c53aa7bc</id><id>1111</id></root>");
|
||||||
|
var plan = LegacyProductSelections.ReadRoster(LegacyProductSelections.Pnid, project, null, new LegacyConfigurationAdapter());
|
||||||
|
Assert.Equal("Automatic", plan.Source);
|
||||||
|
Assert.Equal(["c1b999d6c16029e9ac5b1e1a8afe1500", "5b80379c3ec715e7da0ee414c53aa7bc", "1111"], plan.OrderedIds);
|
||||||
|
Assert.Equal(plan.OrderedIds, plan.UnmappedIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ManualRoster_DoesNotChangeProjectAndUsesExplicitDownloadList()
|
||||||
|
{
|
||||||
|
using var fixture = new SelectionFixture();
|
||||||
|
var project = fixture.ProjectFile("model.nwd");
|
||||||
|
var manual = Path.Combine(fixture.Root, "download_list.xml");
|
||||||
|
File.WriteAllText(manual, "<root><id>7dd981169fa14d21aaef3fcdb0e7e970</id></root>");
|
||||||
|
var plan = LegacyProductSelections.ReadRoster(LegacyProductSelections.Navisworks, project, manual, new LegacyConfigurationAdapter());
|
||||||
|
Assert.Equal("Manual", plan.Source);
|
||||||
|
Assert.Equal(manual, plan.RosterPath);
|
||||||
|
Assert.Equal(["7dd981169fa14d21aaef3fcdb0e7e970"], plan.UnmappedIds);
|
||||||
|
Assert.Equal(project, LegacyProductSelections.ValidateSelectedFile(LegacyProductSelections.Navisworks, project));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MissingAutomaticRoster_FailsClosedWithoutSearchingOtherDirectories()
|
||||||
|
{
|
||||||
|
using var fixture = new SelectionFixture();
|
||||||
|
var project = fixture.ProjectFile("model.nwd");
|
||||||
|
File.WriteAllText(Path.Combine(fixture.Root, "download_list.xml"), "<root><id>7dd981169fa14d21aaef3fcdb0e7e970</id></root>");
|
||||||
|
var ex = Assert.Throws<InvalidOperationException>(() => LegacyProductSelections.ReadRoster(LegacyProductSelections.Navisworks, project, null, new LegacyConfigurationAdapter()));
|
||||||
|
Assert.Equal("LEGACY_DOWNLOAD_LIST_NOT_FOUND", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProductIncompatibleIds_AreBlocked_AndDuplicateIdsRemainDiagnosticInput()
|
||||||
|
{
|
||||||
|
using var fixture = new SelectionFixture();
|
||||||
|
var project = fixture.ProjectFile("Project.xml");
|
||||||
|
fixture.WriteAutomatic("Plant PnID_2019", "<root><id>unexpected</id></root>");
|
||||||
|
Assert.Equal("LEGACY_PLUGIN_PRODUCT_MISMATCH", Assert.Throws<InvalidOperationException>(() => LegacyProductSelections.ReadRoster(LegacyProductSelections.Pnid, project, null, new LegacyConfigurationAdapter())).Message);
|
||||||
|
fixture.WriteAutomatic("Plant PnID_2019", "<root><id>1111</id><id>1111</id></root>");
|
||||||
|
var duplicatePlan = LegacyProductSelections.ReadRoster(LegacyProductSelections.Pnid, project, null, new LegacyConfigurationAdapter());
|
||||||
|
Assert.Equal(["1111", "1111"], duplicatePlan.OrderedIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Navisworks_DoesNotAdvertisePlantBootstrap()
|
||||||
|
{
|
||||||
|
Assert.False(LegacyProductSelections.Navisworks.SupportsPlantBootstrap);
|
||||||
|
Assert.True(LegacyProductSelections.Pnid.SupportsPlantBootstrap);
|
||||||
|
Assert.Equal(["7dd981169fa14d21aaef3fcdb0e7e970"], LegacyProductSelections.Navisworks.UnmappedLegacyIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SelectionFixture : IDisposable
|
||||||
|
{
|
||||||
|
public SelectionFixture() { Root = Path.Combine(Path.GetTempPath(), "launcher-next-selection-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(Path.Combine(Root, "Project")); }
|
||||||
|
public string Root { get; }
|
||||||
|
public string ProjectFile(string name) { var path = Path.Combine(Root, "Project", name); File.WriteAllText(path, "fixture"); return path; }
|
||||||
|
public void WriteAutomatic(string pattern, string xml) { var directory = Path.Combine(Root, "_System", pattern, "Applications"); Directory.CreateDirectory(directory); File.WriteAllText(Path.Combine(directory, "download_list.xml"), xml); }
|
||||||
|
public void Dispose() { if (Directory.Exists(Root)) Directory.Delete(Root, true); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Launcher.Next.App;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Launcher.Next.Tests;
|
||||||
|
|
||||||
|
public sealed class LocalParityEvidenceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task EvidenceWriter_AtomicallyPublishesValidatedSchema()
|
||||||
|
{
|
||||||
|
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(root);
|
||||||
|
var path = await LocalParityLaunchService.WriteEvidenceAsync(root, new { schemaVersion = 4, state = "PACKAGE_LOAD_VERIFIED" }, CancellationToken.None);
|
||||||
|
Assert.True(File.Exists(path));
|
||||||
|
using var document = JsonDocument.Parse(await File.ReadAllTextAsync(path));
|
||||||
|
Assert.Equal(4, document.RootElement.GetProperty("schemaVersion").GetInt32());
|
||||||
|
Assert.False(File.Exists(path + ".tmp"));
|
||||||
|
}
|
||||||
|
finally { if (Directory.Exists(root)) Directory.Delete(root, recursive: true); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EvidenceWriter_ReplacesStaleTemporaryArtifact()
|
||||||
|
{
|
||||||
|
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(root);
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(root, "controlled-local-parity-launch.json.tmp"), "stale");
|
||||||
|
var path = await LocalParityLaunchService.WriteEvidenceAsync(root, new { schemaVersion = 4, terminalResult = "PACKAGE_LOAD_VERIFIED" }, CancellationToken.None);
|
||||||
|
Assert.Contains("PACKAGE_LOAD_VERIFIED", await File.ReadAllTextAsync(path));
|
||||||
|
Assert.False(File.Exists(path + ".tmp"));
|
||||||
|
}
|
||||||
|
finally { if (Directory.Exists(root)) Directory.Delete(root, recursive: true); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using Launcher.Next.Application;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Launcher.Next.Tests;
|
||||||
|
|
||||||
|
public sealed class OwnerReviewStateTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void MissingAutomaticRoster_IsRosterFailure_NotProcessStartFailure()
|
||||||
|
{
|
||||||
|
var failure = LauncherOperationFailures.Classify("LEGACY_DOWNLOAD_LIST_NOT_FOUND", "RosterResolutionFailed");
|
||||||
|
Assert.Equal("RosterResolutionFailed", failure.Stage);
|
||||||
|
Assert.Equal("AUTOMATIC_DOWNLOAD_LIST_NOT_FOUND", failure.Code);
|
||||||
|
Assert.False(failure.ProcessStartAttempted);
|
||||||
|
Assert.DoesNotContain("запустить Autodesk", failure.UserMessage, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("DOWNLOAD_LIST_XML_MALFORMED", "PluginPlanFailed", "DOWNLOAD_LIST_XML_MALFORMED")]
|
||||||
|
[InlineData("LEGACY_PLUGIN_PRODUCT_MISMATCH", "PluginPlanFailed", "PLUGIN_PRODUCT_MISMATCH")]
|
||||||
|
[InlineData("LEGACY_MAPPING_NOT_FOUND", "PackageMappingFailed", "PACKAGE_MAPPING_NOT_FOUND")]
|
||||||
|
[InlineData("LOCAL_PARITY_ROSTER_PROFILE_MISMATCH", "RosterResolutionFailed", "OWNER_REVIEW_PROFILE_ROSTER_MISMATCH")]
|
||||||
|
public void SelectionAndMappingErrors_KeepTheirOperationStage(string source, string stage, string code)
|
||||||
|
{
|
||||||
|
var failure = LauncherOperationFailures.Classify(source, "ProjectValidationFailed");
|
||||||
|
Assert.Equal(stage, failure.Stage);
|
||||||
|
Assert.Equal(code, failure.Code);
|
||||||
|
Assert.False(failure.ProcessStartAttempted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LaunchFailure_IsAttributedOnlyToActualProcessStartCode()
|
||||||
|
{
|
||||||
|
var failure = LauncherOperationFailures.Classify("PLANT3D_PROCESS_NOT_CREATED", "PackagePreparationFailed");
|
||||||
|
Assert.Equal("ProcessStartFailed", failure.Stage);
|
||||||
|
Assert.True(failure.ProcessStartAttempted);
|
||||||
|
Assert.Equal("Не удалось запустить Autodesk", failure.UserMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnknownPreparationFailure_DoesNotBecomeProcessFailure()
|
||||||
|
{
|
||||||
|
var failure = LauncherOperationFailures.Classify("LOCAL_PARITY_PATTERN_NOT_FOUND", "PackagePreparationFailed");
|
||||||
|
Assert.Equal("PackagePreparationFailed", failure.Stage);
|
||||||
|
Assert.False(failure.ProcessStartAttempted);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
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")); }
|
||||||
|
[Fact] public void LifecycleRootsAreTypedDistinctAndConfined()
|
||||||
|
{ var root=Path.Combine(Path.GetTempPath(),Guid.NewGuid().ToString("N")); var roots=new PathResolver(new Dictionary<string,string?>()).ResolvePackageLifecycleRoots(root); var values=new[]{roots.Catalog.Value,roots.Staging.Value,roots.VersionStore.Value,roots.Active.Value,roots.Evidence.Value}; Assert.Equal(values.Length,values.Distinct(StringComparer.OrdinalIgnoreCase).Count()); Assert.All(values,x=>Assert.StartsWith(roots.Pilot.Value,x,StringComparison.OrdinalIgnoreCase)); }
|
||||||
|
[Theory]
|
||||||
|
[InlineData(PackageLifecycleFaultPoint.AfterStagingAllocated)]
|
||||||
|
[InlineData(PackageLifecycleFaultPoint.DuringPayloadCopy)]
|
||||||
|
[InlineData(PackageLifecycleFaultPoint.AfterPayloadCopyBeforeValidation)]
|
||||||
|
[InlineData(PackageLifecycleFaultPoint.BeforeImmutableCommit)]
|
||||||
|
[InlineData(PackageLifecycleFaultPoint.AfterImmutableCommitBeforeReturn)]
|
||||||
|
public async Task DeterministicMaterializationFaultsNeverMutatePointers(PackageLifecycleFaultPoint point)
|
||||||
|
{ 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(point));try{await Assert.ThrowsAnyAsync<Exception>(()=>store.MaterializeAsync(Manifest("1.0.0",[1],[2]),source,CancellationToken.None));Assert.Null(await store.GetCurrentVersionAsync("Lifecycle.Package",CancellationToken.None));Assert.Null(await store.GetPreviousVersionAsync("Lifecycle.Package",CancellationToken.None));}finally{if(Directory.Exists(root))Directory.Delete(root,true);} }
|
||||||
|
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 RepeatedExactMaterializationRevalidatesCommittedManifestAndPayload()
|
||||||
|
{
|
||||||
|
var root=Path.Combine(Path.GetTempPath(),Guid.NewGuid().ToString("N"));var source=Path.Combine(root,"catalog");Directory.CreateDirectory(source);var bytes=new byte[]{6,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 first=await store.MaterializeAsync(Manifest("1.0.0",bytes,bytes),source,CancellationToken.None);var second=await store.MaterializeAsync(Manifest("1.0.0",bytes,bytes),source,CancellationToken.None);Assert.Equal(first.LocalPackageRoot,second.LocalPackageRoot);Assert.False(File.Exists(Path.Combine(root,"Applications","Lifecycle.Package","current.json"))); }
|
||||||
|
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 StrictPointerBindsIndividualPayloadsAndExactLkgRejectsTampering()
|
||||||
|
{
|
||||||
|
var root=Path.Combine(Path.GetTempPath(),Guid.NewGuid().ToString("N"));var source=Path.Combine(root,"catalog");Directory.CreateDirectory(source);var bytes=new byte[]{4,5};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 p=await store.MaterializeAsync(Manifest("1.0.0",bytes,bytes),source,CancellationToken.None);await store.BindAsync(Req("1.0.0"),"1.0.0",CancellationToken.None);await store.ActivateImmutableAsync(p,Req("1.0.0"),CancellationToken.None);var pointerPath=Path.Combine(root,"Applications","Lifecycle.Package","current.json");using(var doc=JsonDocument.Parse(await File.ReadAllTextAsync(pointerPath))){Assert.Equal(2,doc.RootElement.GetProperty("SchemaVersion").GetInt32());Assert.Equal(2,doc.RootElement.GetProperty("PayloadFiles").GetArrayLength());}Assert.NotNull(await store.ResolveAsync(Req("1.0.0"),CancellationToken.None));var json=await File.ReadAllTextAsync(pointerPath);json=json.Replace("PayloadSetSha256\":\"","PayloadSetSha256\":\"00",StringComparison.Ordinal);await File.WriteAllTextAsync(pointerPath,json);Assert.Null(await store.ResolveAsync(Req("1.0.0"),CancellationToken.None)); }
|
||||||
|
finally{if(Directory.Exists(root))Directory.Delete(root,true);}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(PackageLifecycleFaultPoint.BeforePointerTempWrite)]
|
||||||
|
[InlineData(PackageLifecycleFaultPoint.BeforePointerReplace)]
|
||||||
|
[InlineData(PackageLifecycleFaultPoint.BeforePointerReadback)]
|
||||||
|
public async Task PointerFaultsDoNotReturnFalseActivationAndReleaseLock(PackageLifecycleFaultPoint point)
|
||||||
|
{ 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]);try{var package=await new LocalPackageStore(root,new Sha256FileHasher()).MaterializeAsync(Manifest("1.0.0",[1],[2]),source,CancellationToken.None);var failing=new LocalPackageStore(root,new Sha256FileHasher(),new Fault(point));await Assert.ThrowsAnyAsync<Exception>(()=>failing.ActivateImmutableAsync(package,Req("1.0.0"),CancellationToken.None));var healthy=new LocalPackageStore(root,new Sha256FileHasher());var result=await healthy.ActivateImmutableAsync(package,Req("1.0.0"),CancellationToken.None);Assert.Contains(result.Outcome,new[]{"Activated","AlreadyActiveExact"});}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,151 @@
|
|||||||
|
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 SelectedEntityScript_UsesExactHandleAndDxfTypeWithoutCoordinates()
|
||||||
|
{
|
||||||
|
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, "selected.scr"), false, true, new("PXP-AAV", "1a2b", "VIEWPORT")));
|
||||||
|
Assert.Contains("(handent \"1A2B\")", script); Assert.Contains("(command-s \"PXP-AAV\" P_REC_ANNOT_SELECTED_ENTITY)", script); Assert.Contains("P_REC_ANNOT_SELECTION_REJECTED", script); Assert.DoesNotContain("_.ZOOM", script); Assert.DoesNotContain("_.POINT", script);
|
||||||
|
}
|
||||||
|
finally { Directory.Delete(root, true); }
|
||||||
|
}
|
||||||
|
[Fact]
|
||||||
|
public void SelectedEntityScript_RejectsUnsafeSelectionTokens()
|
||||||
|
{
|
||||||
|
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 { Assert.Throws<InvalidOperationException>(() => new StartupScriptBuilder().BuildAutomationScript(new(root, entry, fixture, model, Path.Combine(root, "selected.scr"), false, false, new("PXP-AAV;QUIT", "1A2B", "VIEWPORT")))); }
|
||||||
|
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,18 @@
|
|||||||
|
using Launcher.Next.Autodesk;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Launcher.Next.Tests;
|
||||||
|
|
||||||
|
public sealed class PlantDiscoveryTests
|
||||||
|
{
|
||||||
|
private sealed class Registry(string? install) : IRegistryReader
|
||||||
|
{ public object? GetValue(string path,string name)=>path.Contains("ACAD-9117",StringComparison.Ordinal)&&name=="InstallDir"?install:null; }
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Plant2026DiscoveryUsesPlantSpecificRegistryKey()
|
||||||
|
{
|
||||||
|
var directory=Path.Combine(Path.GetTempPath(),Guid.NewGuid().ToString("N"));Directory.CreateDirectory(Path.Combine(directory,"PLNT3D"));File.WriteAllBytes(Path.Combine(directory,"acad.exe"),[]);
|
||||||
|
try { var product=new PlantProductDiscovery(new Registry(directory)).DiscoverPlant3D(2026);Assert.NotNull(product);Assert.Equal("2026",product!.ProductVersion);Assert.Equal(Path.Combine(directory,"acad.exe"),product.ExecutablePath); }
|
||||||
|
finally { Directory.Delete(directory,true); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
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 ExplicitPRecUpdateMapping_PreservesRosterOrderAlongsidePRecAnnot()
|
||||||
|
{
|
||||||
|
const string settings = "<root><path><help>h</help><care>c</care></path><data><pattern><name>Plant</name><filter>*.xml</filter><roster>r</roster></pattern><plugin><id>b455ee6eaed79d76f084846a60ce3284</id><renewal></renewal><catalog>P_Rec_Annot</catalog><install>OrthoLoader.dll</install></plugin><plugin><id>6ffef84b01097975f51fb3bbb11279e1</id><renewal></renewal><catalog>P_Rec_Update</catalog><install>P_Rec_Update.dll</install></plugin></data></root>";
|
||||||
|
const string selection = "<root><id>b455ee6eaed79d76f084846a60ce3284</id><id>6ffef84b01097975f51fb3bbb11279e1</id></root>";
|
||||||
|
const string overlay = """{"schemaVersion":1,"mappings":[{"mappingId":"p-rec-annot","pattern":"Plant","legacyPluginId":"b455ee6eaed79d76f084846a60ce3284","legacyCatalog":"P_Rec_Annot","legacyInstall":"OrthoLoader.dll","package":{"id":"P_Rec_Annot","product":"Plant3D","productYear":2026,"architecture":"x64","channel":"controlled-local","version":"0.1.0"}},{"mappingId":"p-rec-update","pattern":"Plant","legacyPluginId":"6ffef84b01097975f51fb3bbb11279e1","legacyCatalog":"P_Rec_Update","legacyInstall":"P_Rec_Update.dll","package":{"id":"P_Rec_Update","product":"Plant3D","productYear":2026,"architecture":"x64","channel":"controlled-local","version":"0.1.0"}}]}""";
|
||||||
|
var adapter = new LegacyConfigurationAdapter();
|
||||||
|
var requirement = adapter.Join(adapter.ParseSettings(settings).Patterns[0], adapter.ParseDownloadList(selection), LegacyCompatibilityOverlayParser.Parse(overlay));
|
||||||
|
Assert.Equal(["P_Rec_Annot", "P_Rec_Update"], requirement.Select(x => x.PackageId));
|
||||||
|
Assert.All(requirement, x => Assert.Equal("0.1.0", x.RequiredVersion));
|
||||||
|
}
|
||||||
|
|
||||||
|
[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 }));
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using Launcher.Next.Core;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Launcher.Next.Tests;
|
||||||
|
|
||||||
|
public sealed class StartupRosterPreflightTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ForeignVersionIsConflicting()
|
||||||
|
{
|
||||||
|
var root = Path.Combine(Path.GetTempPath(), "roster-" + Guid.NewGuid().ToString("N"));
|
||||||
|
var requestedRoot = Path.Combine(root, "requested");
|
||||||
|
Directory.CreateDirectory(requestedRoot);
|
||||||
|
File.WriteAllText(Path.Combine(requestedRoot, "PRecAnnot.Plant3D2026.dll"), "new");
|
||||||
|
var old = Path.Combine(root, "Applications", "P_Rec_Annot", "versions", "0.1.0");
|
||||||
|
Directory.CreateDirectory(old);
|
||||||
|
File.WriteAllText(Path.Combine(old, "PRecAnnot.Plant3D2026.dll"), "old");
|
||||||
|
File.WriteAllText(Path.Combine(root, "Applications", "P_Rec_Annot", "current.json"), "{\"Current\":\"0.1.0\"}");
|
||||||
|
File.WriteAllText(Path.Combine(old, "package.manifest.json"), "{\"PackageId\":\"P_Rec_Annot\",\"Version\":\"0.1.0\",\"EntryAssembly\":\"PRecAnnot.Plant3D2026.dll\"}");
|
||||||
|
var requested = new PackageManifest(1, "P_Rec_Annot", "0.2.9", "Plant3D", 2026, "x64", "PRecAnnot.Plant3D2026.dll", [], new("netload", "PHP_REC_ANNOT"));
|
||||||
|
var report = StartupRosterPreflight.Inspect(requestedRoot, requested, [Path.Combine(root, "Applications")]);
|
||||||
|
Assert.Equal(StartupRosterCandidateStatus.ConflictingCandidatePresent, report.Status);
|
||||||
|
Directory.Delete(root, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user