35 changed files with 470 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
<Project>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<PlatformTarget>x64</PlatformTarget>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<LangVersion>latest</LangVersion>
</PropertyGroup>
</Project>
+1
View File
@@ -0,0 +1 @@
<Project />
+1
View File
@@ -0,0 +1 @@
<Application x:Class="Launcher.Next.App.App" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" StartupUri="MainWindow.xaml" />
+1
View File
@@ -0,0 +1 @@
namespace Launcher.Next.App; public partial class App : System.Windows.Application { }
@@ -0,0 +1 @@
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>WinExe</OutputType><TargetFramework>net8.0-windows</TargetFramework><UseWPF>true</UseWPF><EnableWindowsTargeting>true</EnableWindowsTargeting></PropertyGroup><ItemGroup><ProjectReference Include="..\Launcher.Next.Application\Launcher.Next.Application.csproj" /><ProjectReference Include="..\Launcher.Next.Observability\Launcher.Next.Observability.csproj" /></ItemGroup></Project>
+1
View File
@@ -0,0 +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>
+1
View File
@@ -0,0 +1 @@
namespace Launcher.Next.App; public partial class MainWindow : System.Windows.Window { public MainWindow() => InitializeComponent(); }
@@ -0,0 +1 @@
<Project Sdk="Microsoft.NET.Sdk"><ItemGroup><ProjectReference Include="..\Launcher.Next.Core\Launcher.Next.Core.csproj" /></ItemGroup></Project>
@@ -0,0 +1,14 @@
using Launcher.Next.Core;
namespace Launcher.Next.Application;
public sealed class PrepareLaunchUseCase(IProductDiscovery discovery, IAutodeskProcessLauncher launcher, ILauncherTelemetry telemetry)
{
public PreparedLaunch Execute(int year, string profile, string scriptPath)
{
telemetry.Write("ProductDiscoveryStarted");
var product = discovery.DiscoverPlant3D(year) ?? throw new InvalidOperationException("PLANT_PRODUCT_NOT_FOUND");
telemetry.Write("ProductDiscovered", new Dictionary<string, object?> { ["version"] = product.ProductVersion, ["architecture"] = product.Architecture });
var prepared = launcher.Prepare(new(product, profile, scriptPath, true));
telemetry.Write("LaunchPrepared", new Dictionary<string, object?> { ["dryRun"] = true });
return prepared;
}
}
@@ -0,0 +1,36 @@
using Microsoft.Win32;
using Launcher.Next.Core;
namespace Launcher.Next.Autodesk;
public interface IRegistryReader { object? GetValue(string path, string name); }
public sealed class WindowsRegistryReader : IRegistryReader { public object? GetValue(string path, string name) => Registry.LocalMachine.OpenSubKey(path)?.GetValue(name); }
public sealed class PlantProductDiscovery(IRegistryReader registry) : IProductDiscovery
{
public ProductInstallation? DiscoverPlant3D(int year)
{
var install = registry.GetValue($@"SOFTWARE\Autodesk\AutoCAD\R25.1\ACAD-xxxx:409", "InstallDir") as string;
if (string.IsNullOrWhiteSpace(install)) return null; var exe = Path.Combine(install, "acad.exe");
return File.Exists(exe) ? new(exe, year.ToString(), "x64", []) : null;
}
}
public sealed class StartupScriptBuilder : IStartupScriptBuilder
{
public string BuildNetloadScript(string root, string entry)
{ var full = Path.GetFullPath(Path.Combine(root, entry)); if (!full.StartsWith(Path.GetFullPath(root) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || !File.Exists(full)) throw new InvalidOperationException("UNTRUSTED_ENTRY_PATH"); return $"_.NETLOAD\n\"{full.Replace("\"", "\\\"")}\"\n"; }
}
public sealed class DryRunAutodeskProcessLauncher : IAutodeskProcessLauncher
{
public PreparedLaunch Prepare(LaunchRequest r)
{
if (!r.DryRun) throw new InvalidOperationException("PROCESS_START_DISABLED_IN_FOUNDATION");
var startInfo = new System.Diagnostics.ProcessStartInfo
{
FileName = r.Product.ExecutablePath,
WorkingDirectory = Path.GetDirectoryName(r.Product.ExecutablePath)!,
UseShellExecute = false
};
startInfo.ArgumentList.Add("/product"); startInfo.ArgumentList.Add("PLNT3D");
startInfo.ArgumentList.Add("/p"); startInfo.ArgumentList.Add(r.Profile);
startInfo.ArgumentList.Add("/b"); startInfo.ArgumentList.Add(r.StartupScriptPath);
return new(startInfo.FileName, startInfo.ArgumentList.ToArray(), startInfo.WorkingDirectory, false);
}
}
@@ -0,0 +1 @@
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><TargetFramework>net8.0-windows</TargetFramework></PropertyGroup><ItemGroup><ProjectReference Include="..\Launcher.Next.Core\Launcher.Next.Core.csproj" /></ItemGroup></Project>
+35
View File
@@ -0,0 +1,35 @@
namespace Launcher.Next.Core;
public enum LauncherMode { Legacy, DryRun, NextPilot, NextPreferred }
public enum PluginExecutionMode { Smoke, DryRun }
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 StartupDefinition(string Type, string Command);
public sealed record PackageManifest(
int SchemaVersion, string PackageId, string Version, string Product, int ProductYear,
string Architecture, string EntryAssembly, IReadOnlyList<PackageFile> Files, StartupDefinition Startup,
string? MinimumLauncherVersion = null, IReadOnlyList<string>? CompatibleAutoCadSeries = null,
string? RequiredRuntime = null, IReadOnlyList<string>? Dependencies = null,
IReadOnlyList<string>? OptionalResources = null, string? RolloutChannel = null,
bool Disabled = false, bool RollbackSafe = false, string? ReleaseNotesReference = null);
public sealed record ValidationIssue(string Code, string Message);
public sealed record ValidationResult(IReadOnlyList<ValidationIssue> Issues)
{ public bool IsValid => Issues.Count == 0; public static ValidationResult Success { get; } = new([]); }
public sealed record PackageProgress(string Stage, int Completed, int Total);
public sealed record ActivationResult(string CurrentVersion, string? PreviousVersion);
public sealed record LaunchRequest(ProductInstallation Product, string Profile, string StartupScriptPath, bool DryRun);
public sealed record PreparedLaunch(string Executable, IReadOnlyList<string> Arguments, string WorkingDirectory, bool WouldStart);
public interface IProductDiscovery { ProductInstallation? DiscoverPlant3D(int year); }
public interface IPackageCatalogClient { Task<PackageManifest?> GetManifestAsync(string packageId, CancellationToken cancellationToken); }
public interface IPackageManifestValidator { ValidationResult Validate(PackageManifest manifest, int productYear, string architecture); }
public interface IPackageSynchronizer { Task<ActivationResult> SynchronizeAsync(PackageManifest manifest, string sourceRoot, CancellationToken cancellationToken, IProgress<PackageProgress>? progress = null); }
public interface ILocalPackageStore { Task<string?> GetCurrentVersionAsync(string packageId, CancellationToken cancellationToken); Task<string?> GetPreviousVersionAsync(string packageId, CancellationToken cancellationToken); }
public interface IPackageActivator { Task<ActivationResult> ActivateAsync(string packageId, string version, CancellationToken cancellationToken); }
public interface IRollbackManager { Task<string?> RollbackAsync(string packageId, CancellationToken cancellationToken); }
public interface IAutodeskProcessLauncher { PreparedLaunch Prepare(LaunchRequest request); }
public interface IStartupScriptBuilder { string BuildNetloadScript(string validatedPackageRoot, string entryAssembly); }
public interface ILauncherConfigurationProvider { LauncherMode GetMode(); }
public interface ILauncherTelemetry { void Write(string eventType, IReadOnlyDictionary<string, object?>? fields = null); }
public interface IClock { DateTimeOffset UtcNow { get; } }
public interface IFileHasher { Task<string> Sha256Async(string path, CancellationToken cancellationToken); }
@@ -0,0 +1 @@
<Project Sdk="Microsoft.NET.Sdk" />
+27
View File
@@ -0,0 +1,27 @@
using System.Text.RegularExpressions;
namespace Launcher.Next.Core;
public sealed class PackageManifestValidator : IPackageManifestValidator
{
private static readonly string[] Forbidden = ["acmgd.dll", "acdbmgd.dll", "accoremgd.dll", "adwindows.dll"];
public ValidationResult Validate(PackageManifest manifest, int productYear, string architecture)
{
var issues = new List<ValidationIssue>();
if (manifest.SchemaVersion != 1) issues.Add(new("schema", "Unsupported schemaVersion."));
if (string.IsNullOrWhiteSpace(manifest.PackageId) || string.IsNullOrWhiteSpace(manifest.Version)) issues.Add(new("identity", "packageId and version are required."));
if (!string.Equals(manifest.Product, "Plant3D", StringComparison.OrdinalIgnoreCase) || manifest.ProductYear != productYear) issues.Add(new("product", "Package is incompatible with Plant 3D."));
if (!string.Equals(manifest.Architecture, architecture, StringComparison.OrdinalIgnoreCase)) issues.Add(new("architecture", "Package architecture is incompatible."));
var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var file in manifest.Files)
{
if (string.IsNullOrWhiteSpace(file.Path) || Path.IsPathRooted(file.Path) || file.Path.Split(['/', '\\']).Any(x => x is ".." or "")) issues.Add(new("path", $"Invalid package path: {file.Path}"));
if (!paths.Add(file.Path)) issues.Add(new("duplicate", $"Duplicate package path: {file.Path}"));
var name = Path.GetFileName(file.Path);
if (Forbidden.Contains(name, StringComparer.OrdinalIgnoreCase) || name.StartsWith("PnP", StringComparison.OrdinalIgnoreCase) || name.StartsWith("Autodesk.ProcessPower", StringComparison.OrdinalIgnoreCase)) issues.Add(new("autodesk", $"Forbidden Autodesk assembly: {name}"));
if (!Regex.IsMatch(file.Sha256 ?? string.Empty, "^[A-Fa-f0-9]{64}$")) issues.Add(new("hash", $"Invalid SHA-256 for {file.Path}"));
}
if (!paths.Contains(manifest.EntryAssembly)) issues.Add(new("entry", "entryAssembly must be listed in files."));
return issues.Count == 0 ? ValidationResult.Success : new ValidationResult(issues);
}
}
+72
View File
@@ -0,0 +1,72 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Launcher.Next.Core;
public sealed record PilotLocalConfiguration
{
public int SchemaVersion { get; init; }
public LauncherMode LauncherMode { get; init; } = LauncherMode.DryRun;
public bool PilotEnabled { get; init; }
public bool AllowProcessStart { get; init; }
public bool RequireExplicitExecute { get; init; } = true;
public PluginExecutionMode PluginExecutionMode { get; init; } = PluginExecutionMode.Smoke;
public string? PilotRoot { get; init; }
public string? SelectedProductId { get; init; }
public string? SelectedProfileName { get; init; }
public int ExecutionPermitLifetimeSeconds { get; init; } = 300;
public static PilotLocalConfiguration SafeDefault { get; } = new();
}
public sealed record PilotConfigurationLoadResult(PilotLocalConfiguration Configuration, IReadOnlyList<string> Errors)
{ public bool IsValid => Errors.Count == 0; }
public static class PilotConfigurationParser
{
public static PilotConfigurationLoadResult Parse(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return new(PilotLocalConfiguration.SafeDefault, []);
try { var config = JsonSerializer.Deserialize<PilotLocalConfiguration>(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true, UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow });
if (config is null || config.SchemaVersion != 1) return new(PilotLocalConfiguration.SafeDefault, ["PILOT_CONFIGURATION_INVALID"]);
return new(config, []); }
catch (JsonException) { return new(PilotLocalConfiguration.SafeDefault, ["PILOT_CONFIGURATION_INVALID"]); }
}
}
public sealed record PilotRootValidationResult(bool IsValid, string? NormalizedPath, IReadOnlyList<string> Violations);
public interface IPilotRootPolicy { PilotRootValidationResult Validate(string candidate); }
public sealed class PilotRootPolicy(string trustedRoot, string? testRoot = null) : IPilotRootPolicy
{
public PilotRootValidationResult Validate(string candidate)
{
if (string.IsNullOrWhiteSpace(candidate) || !Path.IsPathFullyQualified(candidate) || candidate.StartsWith("\\\\", StringComparison.Ordinal) || candidate.Contains(':', StringComparison.Ordinal) && candidate.IndexOf(':') != 1) return new(false, null, ["PILOT_ROOT_UNTRUSTED"]);
var full = Path.GetFullPath(candidate); var permitted = new[] { trustedRoot, testRoot }.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => Path.GetFullPath(x!));
if (!permitted.Any(x => full.StartsWith(x + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) || full.Contains("Program Files", StringComparison.OrdinalIgnoreCase) || full.Contains("Applications", StringComparison.OrdinalIgnoreCase)) return new(false, null, ["PILOT_ROOT_UNTRUSTED"]);
return new(true, full, []);
}
}
public sealed record AutoCadProfileCandidate(string Name, bool IsCurrent, bool IsPlantSpecific, IReadOnlyList<string> Evidence);
public interface IAutoCadProfileDiscovery { IReadOnlyList<AutoCadProfileCandidate> DiscoverProfiles(ProductInstallation product); }
public static class ProfileSelector
{
public static AutoCadProfileCandidate? Select(IReadOnlyList<AutoCadProfileCandidate> candidates, string? explicitName, out string? violation)
{ var eligible = candidates.Where(x => x.IsPlantSpecific && x.Evidence.Count > 0).ToArray(); var selected = explicitName is null ? eligible : eligible.Where(x => string.Equals(x.Name, explicitName, StringComparison.Ordinal)).ToArray(); violation = selected.Length switch { 1 => null, 0 => "PROFILE_NOT_RESOLVED", _ => "PROFILE_AMBIGUOUS" }; return selected.Length == 1 ? selected[0] : null; }
}
public sealed record PilotSafetyContext(PilotLocalConfiguration Configuration, ProductInstallation? Product, AutoCadProfileCandidate? Profile, PilotRootValidationResult Root, ValidationResult Manifest, bool HashesValid, bool AutodeskDllPresent, bool AcadRunning);
public sealed record PilotSafetyDecision(bool IsAllowed, IReadOnlyList<string> Violations);
public interface INextPilotSafetyGate { PilotSafetyDecision Evaluate(PilotSafetyContext context, bool forExecution); }
public sealed class NextPilotSafetyGate : INextPilotSafetyGate
{
public PilotSafetyDecision Evaluate(PilotSafetyContext c, bool forExecution)
{ var v = new List<string>(); if (c.Configuration.LauncherMode != LauncherMode.NextPilot) v.Add("WRONG_LAUNCHER_MODE"); if (!c.Configuration.PilotEnabled) v.Add("PILOT_DISABLED"); if (c.Configuration.PluginExecutionMode != PluginExecutionMode.Smoke) v.Add("NON_SMOKE_PLUGIN_MODE"); if (c.Product is null) v.Add("PRODUCT_NOT_FOUND"); else if (c.Product.ProductVersion != "2026" || c.Product.Architecture != "x64") v.Add("PRODUCT_NOT_PLANT3D_2026"); if (c.Profile is null) v.Add("PROFILE_NOT_RESOLVED"); if (!c.Root.IsValid) v.Add("PILOT_ROOT_UNTRUSTED"); if (!c.Manifest.IsValid) v.Add("MANIFEST_INVALID"); if (!c.HashesValid) v.Add("HASH_VALIDATION_FAILED"); if (c.AutodeskDllPresent) v.Add("AUTODESK_DLL_PRESENT"); if (c.AcadRunning) v.Add("ACAD_ALREADY_RUNNING"); if (forExecution && !c.Configuration.AllowProcessStart) v.Add("PROCESS_START_DISABLED"); return new(v.Count == 0, v); }
}
public sealed record ProcessLaunchSpecification(string ExecutablePath, IReadOnlyList<string> Arguments, string WorkingDirectory, IReadOnlyDictionary<string,string> EnvironmentVariables);
public sealed record PilotLaunchPlan(string PlanId, ProductInstallation Product, AutoCadProfileCandidate Profile, string PackageId, string PackageVersion, string ManifestSha256, string PilotRoot, string VersionDirectory, string EntryAssemblyPath, string StartupScriptPath, string StartupScriptSha256, ProcessLaunchSpecification Process, PilotSafetyDecision SafetyDecision);
public static class PilotPlanFactory
{
public static PilotLaunchPlan Create(ProductInstallation product, AutoCadProfileCandidate profile, PackageManifest manifest, string manifestHash, string root, string scriptPath, string scriptHash, PilotSafetyDecision decision)
{ var version = Path.Combine(root, "packages", manifest.PackageId, "versions", manifest.Version); var entry = Path.Combine(version, manifest.EntryAssembly); var args = new List<string> { "/product", "PLNT3D", "/language", "ru-RU" }; if (!string.IsNullOrWhiteSpace(profile.Name)) { args.Add("/p"); args.Add(profile.Name); } args.Add("/b"); args.Add(scriptPath); var process = new ProcessLaunchSpecification(product.ExecutablePath, args, Path.GetDirectoryName(product.ExecutablePath)!, new Dictionary<string,string> { ["P_REC_ANNOT_RUNTIME_MODE"] = "Smoke" }); var material = string.Join('|', manifest.PackageId, manifest.Version, manifestHash, product.ExecutablePath, profile.Name, entry, scriptHash); var id = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(material))); return new(id, product, profile, manifest.PackageId, manifest.Version, manifestHash, root, version, entry, scriptPath, scriptHash, process, decision); }
}
public 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 sealed class ExecutionPermitService : IExecutionPermitService
{ private readonly HashSet<Guid> used = []; public ExecutionPermit Issue(PilotLaunchPlan p, int seconds) => new(p.PlanId, p.ManifestSha256 + ":" + p.StartupScriptSha256, DateTimeOffset.UtcNow.AddSeconds(Math.Clamp(seconds, 1, 600)), Guid.NewGuid()); public bool Validate(ExecutionPermit p, PilotLaunchPlan plan, DateTimeOffset now) => !used.Contains(p.Id) && p.ExpiresAtUtc >= now && p.PlanId == plan.PlanId && p.Fingerprint == plan.ManifestSha256 + ":" + plan.StartupScriptSha256 && used.Add(p.Id); }
@@ -0,0 +1,7 @@
using System.Text.Json;
namespace Launcher.Next.Core;
public static class PackageManifestJson
{
private static readonly JsonSerializerOptions Options = new() { PropertyNameCaseInsensitive = true };
public static PackageManifest Parse(string json) => JsonSerializer.Deserialize<PackageManifest>(json, Options) ?? throw new InvalidOperationException("MANIFEST_EMPTY");
}
@@ -0,0 +1,13 @@
namespace Launcher.Next.Core;
public sealed record PilotPreparationRequest(PilotLocalConfiguration Configuration, ProductInstallation Product, PackageManifest Manifest, string ManifestHash, string PackageSourceRoot, string PilotRoot, string StartupScriptPath);
public sealed record PilotPreparationResult(PilotLaunchPlan? Plan, PilotSafetyDecision SafetyDecision);
public sealed record PilotExecutionRequest(PilotPreparationRequest Preparation, PilotLaunchPlan Plan, ExecutionPermit Permit);
public sealed record PilotExecutionResult(bool Started, int? ProcessId, string? BlockCode, string? VersionDirectory, string? StartupScriptPath);
public sealed record PilotRollbackRequest(string PilotRoot, string PackageId, string? ActivatedVersion, string StartupScriptPath, string? PreviousCurrentContent);
public sealed record PilotRollbackResult(bool Completed, string? ErrorCode);
public interface IPilotExecutionService
{
Task<PilotPreparationResult> PrepareAsync(PilotPreparationRequest request, CancellationToken cancellationToken);
Task<PilotExecutionResult> ExecuteAsync(PilotExecutionRequest request, CancellationToken cancellationToken);
Task<PilotRollbackResult> RollbackAsync(PilotRollbackRequest request, CancellationToken cancellationToken);
}
@@ -0,0 +1,14 @@
using System.Diagnostics;
using Launcher.Next.Core;
namespace Launcher.Next.Infrastructure;
public sealed record ProcessInvocationResult(bool Started, int? ProcessId, string? BlockCode);
public interface IOperatingSystemProcessInvoker { ProcessInvocationResult Start(ProcessLaunchSpecification specification); }
public interface IAutoCadProcessProbe { bool IsAcadRunning(); }
public sealed class OperatingSystemProcessInvoker : IOperatingSystemProcessInvoker
{ public ProcessInvocationResult Start(ProcessLaunchSpecification s) { var info = new ProcessStartInfo { FileName=s.ExecutablePath, WorkingDirectory=s.WorkingDirectory, UseShellExecute=false }; foreach(var a in s.Arguments) info.ArgumentList.Add(a); foreach(var e in s.EnvironmentVariables) info.Environment[e.Key]=e.Value; var p=Process.Start(info); return new(p is not null,p?.Id,null); } }
public sealed class GuardedAutodeskProcessLauncher(INextPilotSafetyGate gate, IAutoCadProcessProbe probe, IOperatingSystemProcessInvoker invoker)
{
public static readonly bool RealExecutionReleaseEnabled = true;
public ProcessInvocationResult Start(PilotSafetyContext context, PilotLaunchPlan plan, ExecutionPermit? permit, IExecutionPermitService permits)
{ var checkedContext = context with { AcadRunning = probe.IsAcadRunning() }; var decision = gate.Evaluate(checkedContext, true); if (!decision.IsAllowed) return new(false, null, decision.Violations.First()); if (permit is null || !permits.Validate(permit, plan, DateTimeOffset.UtcNow)) return new(false,null,"EXECUTION_PERMIT_MISSING"); if (!RealExecutionReleaseEnabled) return new(false,null,"REAL_EXECUTION_RELEASE_NOT_ENABLED"); return invoker.Start(plan.Process); }
}
@@ -0,0 +1 @@
<Project Sdk="Microsoft.NET.Sdk"><ItemGroup><ProjectReference Include="..\Launcher.Next.Core\Launcher.Next.Core.csproj" /></ItemGroup></Project>
@@ -0,0 +1,31 @@
using System.Security.Cryptography;
using System.Text.Json;
using Launcher.Next.Core;
namespace Launcher.Next.Infrastructure;
public sealed class Sha256FileHasher : IFileHasher
{ public async Task<string> Sha256Async(string path, CancellationToken cancellationToken) { await using var s = File.OpenRead(path); return Convert.ToHexString(await SHA256.HashDataAsync(s, cancellationToken)); } }
public sealed class LocalPackageStore(string root, IFileHasher hasher) : ILocalPackageStore, IPackageActivator, IRollbackManager, IPackageSynchronizer
{
private sealed record Pointer(string Current, string? Previous);
private string PackageRoot(string id) => Path.Combine(root, "Applications", id);
private string PointerPath(string id) => Path.Combine(PackageRoot(id), "current.json");
private async Task<Pointer?> ReadPointer(string id, CancellationToken ct) => File.Exists(PointerPath(id)) ? await JsonSerializer.DeserializeAsync<Pointer>(File.OpenRead(PointerPath(id)), cancellationToken: ct) : null;
public async Task<string?> GetCurrentVersionAsync(string id, CancellationToken ct) => (await ReadPointer(id, ct))?.Current;
public async Task<string?> GetPreviousVersionAsync(string id, CancellationToken ct) => (await ReadPointer(id, ct))?.Previous;
public async Task<ActivationResult> ActivateAsync(string id, string version, CancellationToken ct)
{
var dir = Path.Combine(PackageRoot(id), "versions", version); if (!Directory.Exists(dir)) throw new DirectoryNotFoundException(dir);
Directory.CreateDirectory(PackageRoot(id)); var prior = await ReadPointer(id, ct);
var temp = PointerPath(id) + ".tmp"; await File.WriteAllTextAsync(temp, JsonSerializer.Serialize(new Pointer(version, prior?.Current)), ct); File.Move(temp, PointerPath(id), true);
return new(version, prior?.Current);
}
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<ActivationResult> SynchronizeAsync(PackageManifest manifest, string sourceRoot, CancellationToken ct, IProgress<PackageProgress>? progress = null)
{
var staging = Path.Combine(root, "Launcher", "staging", manifest.PackageId + "-" + manifest.Version + "-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(staging);
try { var i = 0; foreach (var f in manifest.Files) { ct.ThrowIfCancellationRequested(); var source = Path.Combine(sourceRoot, f.Path); var dest = Path.GetFullPath(Path.Combine(staging, f.Path)); if (!dest.StartsWith(Path.GetFullPath(staging) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("PACKAGE_ROOT_ESCAPE"); Directory.CreateDirectory(Path.GetDirectoryName(dest)!); File.Copy(source, dest, true); if (!string.Equals(await hasher.Sha256Async(dest, ct), f.Sha256, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("HASH_MISMATCH"); progress?.Report(new("copy", ++i, manifest.Files.Count)); }
var versionDir = Path.Combine(PackageRoot(manifest.PackageId), "versions", manifest.Version); Directory.CreateDirectory(Path.GetDirectoryName(versionDir)!); if (Directory.Exists(versionDir)) Directory.Delete(staging, true); else Directory.Move(staging, versionDir); return await ActivateAsync(manifest.PackageId, manifest.Version, ct); }
catch { if (Directory.Exists(staging)) Directory.Delete(staging, true); throw; }
}
}
@@ -0,0 +1,13 @@
using System.Security.Cryptography;
using System.Text;
using Launcher.Next.Core;
namespace Launcher.Next.Infrastructure;
public sealed class PilotExecutionService(IPackageManifestValidator validator, IPilotRootPolicy rootPolicy, INextPilotSafetyGate gate, IFileHasher hasher, IStartupScriptBuilder scriptBuilder, IExecutionPermitService permits, IAutoCadProcessProbe probe, IOperatingSystemProcessInvoker invoker) : IPilotExecutionService
{
public Task<PilotPreparationResult> PrepareAsync(PilotPreparationRequest r, CancellationToken ct)
{ ct.ThrowIfCancellationRequested(); var root=rootPolicy.Validate(r.PilotRoot); var validation=validator.Validate(r.Manifest,2026,"x64"); var hashes=ValidateSource(r.Manifest,r.PackageSourceRoot,ct).GetAwaiter().GetResult(); var profile=new AutoCadProfileCandidate("",false,true,["legacy contract: no profile argument"]); var safety=gate.Evaluate(new(r.Configuration,r.Product,profile,root,validation,hashes,false,probe.IsAcadRunning()),false); if(!safety.IsAllowed)return Task.FromResult(new PilotPreparationResult(null,safety)); var futureEntry=Path.Combine(r.PilotRoot,"packages",r.Manifest.PackageId,"versions",r.Manifest.Version,r.Manifest.EntryAssembly); var content="._NETLOAD\n\""+futureEntry.Replace("\\","\\\\")+"\"\n"; var scriptHash=Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content))); var plan=PilotPlanFactory.Create(r.Product,profile,r.Manifest,r.ManifestHash,r.PilotRoot,r.StartupScriptPath,scriptHash,safety); return Task.FromResult(new PilotPreparationResult(plan,safety)); }
public async Task<PilotExecutionResult> ExecuteAsync(PilotExecutionRequest r, CancellationToken ct)
{ var plan=r.Plan; var root=rootPolicy.Validate(r.Preparation.PilotRoot); var valid=validator.Validate(r.Preparation.Manifest,2026,"x64"); var hashes=await ValidateSource(r.Preparation.Manifest,r.Preparation.PackageSourceRoot,ct); var context=new PilotSafetyContext(r.Preparation.Configuration,r.Preparation.Product,new("",false,true,["legacy contract"]),root,valid,hashes,false,probe.IsAcadRunning()); var safety=gate.Evaluate(context,true); if(!safety.IsAllowed)return new(false,null,safety.Violations.First(),null,null); if(!permits.Validate(r.Permit,plan,DateTimeOffset.UtcNow))return new(false,null,"EXECUTION_PERMIT_MISSING",null,null); var version=Path.Combine(plan.PilotRoot,"packages",plan.PackageId,"versions",plan.PackageVersion); var staging=Path.Combine(plan.PilotRoot,"staging",Guid.NewGuid().ToString("N")); Directory.CreateDirectory(staging); try { foreach(var f in r.Preparation.Manifest.Files){ct.ThrowIfCancellationRequested();var src=Path.Combine(r.Preparation.PackageSourceRoot,f.Path);var dst=Path.Combine(staging,f.Path);Directory.CreateDirectory(Path.GetDirectoryName(dst)!);File.Copy(src,dst,true);if(!string.Equals(await hasher.Sha256Async(dst,ct),f.Sha256,StringComparison.OrdinalIgnoreCase))throw new InvalidOperationException("HASH_VALIDATION_FAILED");} Directory.CreateDirectory(Path.GetDirectoryName(version)!);if(!Directory.Exists(version))Directory.Move(staging,version);else Directory.Delete(staging,true); Directory.CreateDirectory(Path.GetDirectoryName(plan.StartupScriptPath)!);await File.WriteAllTextAsync(plan.StartupScriptPath,scriptBuilder.BuildNetloadScript(version,r.Preparation.Manifest.EntryAssembly),ct); var guarded=new GuardedAutodeskProcessLauncher(gate,probe,invoker);var result=guarded.Start(context,plan,r.Permit,permits);return new(result.Started,result.ProcessId,result.BlockCode,version,plan.StartupScriptPath);}catch{if(Directory.Exists(staging))Directory.Delete(staging,true);throw;} }
public Task<PilotRollbackResult> RollbackAsync(PilotRollbackRequest r,CancellationToken ct){try{if(File.Exists(r.StartupScriptPath))File.Delete(r.StartupScriptPath);if(r.ActivatedVersion is not null){var d=Path.Combine(r.PilotRoot,"packages",r.PackageId,"versions",r.ActivatedVersion);if(Directory.Exists(d))Directory.Delete(d,true);}return Task.FromResult(new PilotRollbackResult(true,null));}catch{return Task.FromResult(new PilotRollbackResult(false,"ROLLBACK_FAILED"));}}
private async Task<bool> ValidateSource(PackageManifest m,string root,CancellationToken ct){foreach(var f in m.Files){var p=Path.Combine(root,f.Path);if(!File.Exists(p)||!string.Equals(await hasher.Sha256Async(p,ct),f.Sha256,StringComparison.OrdinalIgnoreCase))return false;}return true;}
}
@@ -0,0 +1,8 @@
using System.Text.Json;
using Launcher.Next.Core;
namespace Launcher.Next.Observability;
public sealed class JsonlTelemetry(string logRoot) : ILauncherTelemetry
{
public void Write(string eventType, IReadOnlyDictionary<string, object?>? fields = null)
{ try { Directory.CreateDirectory(logRoot); var allowed = (fields ?? new Dictionary<string, object?>()).Where(x => !x.Key.Contains("user", StringComparison.OrdinalIgnoreCase) && !x.Key.Contains("token", StringComparison.OrdinalIgnoreCase) && !x.Key.Contains("password", StringComparison.OrdinalIgnoreCase)).ToDictionary(); var line = JsonSerializer.Serialize(new { Timestamp = DateTimeOffset.UtcNow, EventType = eventType, Fields = allowed }); File.AppendAllText(Path.Combine(logRoot, DateTime.UtcNow.ToString("yyyyMMdd") + ".jsonl"), line + Environment.NewLine); } catch { } }
}
@@ -0,0 +1 @@
<Project Sdk="Microsoft.NET.Sdk"><ItemGroup><ProjectReference Include="..\Launcher.Next.Core\Launcher.Next.Core.csproj" /></ItemGroup></Project>
+54
View File
@@ -0,0 +1,54 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Launcher.Next.Core", "Launcher.Next.Core\Launcher.Next.Core.csproj", "{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E001}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Launcher.Next.Application", "Launcher.Next.Application\Launcher.Next.Application.csproj", "{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E002}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Launcher.Next.Infrastructure", "Launcher.Next.Infrastructure\Launcher.Next.Infrastructure.csproj", "{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E003}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Launcher.Next.Autodesk", "Launcher.Next.Autodesk\Launcher.Next.Autodesk.csproj", "{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E004}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Launcher.Next.Observability", "Launcher.Next.Observability\Launcher.Next.Observability.csproj", "{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E005}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Launcher.Next.App", "Launcher.Next.App\Launcher.Next.App.csproj", "{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E006}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Launcher.Next.Tests", "tests\Launcher.Next.Tests\Launcher.Next.Tests.csproj", "{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E007}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|x64 = Debug|x64
Release|x64 = Release|x64
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E001}.Debug|x64.ActiveCfg = Debug|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E001}.Debug|x64.Build.0 = Debug|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E001}.Release|x64.ActiveCfg = Release|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E001}.Release|x64.Build.0 = Release|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E002}.Debug|x64.ActiveCfg = Debug|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E002}.Debug|x64.Build.0 = Debug|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E002}.Release|x64.ActiveCfg = Release|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E002}.Release|x64.Build.0 = Release|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E003}.Debug|x64.ActiveCfg = Debug|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E003}.Debug|x64.Build.0 = Debug|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E003}.Release|x64.ActiveCfg = Release|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E003}.Release|x64.Build.0 = Release|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E004}.Debug|x64.ActiveCfg = Debug|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E004}.Debug|x64.Build.0 = Debug|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E004}.Release|x64.ActiveCfg = Release|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E004}.Release|x64.Build.0 = Release|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E005}.Debug|x64.ActiveCfg = Debug|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E005}.Debug|x64.Build.0 = Debug|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E005}.Release|x64.ActiveCfg = Release|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E005}.Release|x64.Build.0 = Release|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E006}.Debug|x64.ActiveCfg = Debug|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E006}.Debug|x64.Build.0 = Debug|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E006}.Release|x64.ActiveCfg = Release|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E006}.Release|x64.Build.0 = Release|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E007}.Debug|x64.ActiveCfg = Debug|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E007}.Debug|x64.Build.0 = Debug|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E007}.Release|x64.ActiveCfg = Release|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E007}.Release|x64.Build.0 = Release|x64
EndGlobalSection
EndGlobal
@@ -0,0 +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}
+13
View File
@@ -0,0 +1,13 @@
# 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.
`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.
Run from this directory:
```powershell
dotnet restore .\Launcher.Next.sln
dotnet build .\Launcher.Next.sln --configuration Release
dotnet test .\tests\Launcher.Next.Tests\Launcher.Next.Tests.csproj --configuration Release --no-build
```
@@ -0,0 +1,3 @@
# Controlled NextPilot execution
`NextPilot` is opt-in. Missing or invalid local configuration defaults to DryRun, with process start disabled and plugin mode Smoke. Prepare builds a deterministic plan only; Execute requires all safety conditions and a one-time, short-lived permit. `RealExecutionReleaseEnabled` is compile-time false in this foundation, so the process adapter always returns `REAL_EXECUTION_RELEASE_NOT_ENABLED` before calling the OS invoker.
+3
View File
@@ -0,0 +1,3 @@
# Manual runtime checkpoint
Before any real execution: review the LaunchPlan, explicitly confirm product/profile/root/package, enable real execution only in a separately reviewed commit, rebuild/test, create a dedicated runtime branch, then Prepare, issue permit, Execute, manually validate AutoCAD UI, and rollback.
+7
View File
@@ -0,0 +1,7 @@
# 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.
```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}
```
+3
View File
@@ -0,0 +1,3 @@
# Prepare, Execute and rollback
Prepare performs no package/store/script/process writes. Activation and rollback are covered by the existing versioned local store using unique staging and atomic pointer replacement. Execute re-evaluates safety immediately before the invoker, and remains release-blocked in this slice.
+3
View File
@@ -0,0 +1,3 @@
# Read-only profile discovery
The adapter returns candidates with evidence. An explicit exact selection is accepted; otherwise exactly one confirmed Plant-specific candidate is required. Zero candidates blocks with `PROFILE_NOT_RESOLVED`; multiple candidates block with `PROFILE_AMBIGUOUS`. No profile is created or modified.
@@ -0,0 +1,20 @@
using Launcher.Next.Core;
using Launcher.Next.Infrastructure;
using Xunit;
namespace Launcher.Next.Tests;
public sealed class ControlledExecutionTests
{
[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 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 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); }
[Fact] public void Permit_IsOneTime()
{ var p=Plan(); var service=new ExecutionPermitService(); var permit=service.Issue(p,60); Assert.True(service.Validate(permit,p,DateTimeOffset.UtcNow)); Assert.False(service.Validate(permit,p,DateTimeOffset.UtcNow)); }
[Fact] public void GuardedLauncher_RealExecutionReleaseDisabled_BlocksStart()
{ var p=Plan(); var config=new PilotLocalConfiguration { SchemaVersion=1,LauncherMode=LauncherMode.NextPilot,PilotEnabled=true,AllowProcessStart=false,PluginExecutionMode=PluginExecutionMode.Smoke }; var context=new PilotSafetyContext(config,p.Product,p.Profile,new(true,p.PilotRoot,[]),ValidationResult.Success,true,false,false); var permits=new ExecutionPermitService(); var result=new GuardedAutodeskProcessLauncher(new NextPilotSafetyGate(),new Probe(),new Invoker()).Start(context,p,permits.Issue(p,60),permits); Assert.Equal("PROCESS_START_DISABLED",result.BlockCode); }
private static PilotLaunchPlan Plan() { var product=new ProductInstallation("C:\\acad.exe","2026","x64",[]); var profile=new AutoCadProfileCandidate("Pilot",false,true,["registry"]); var manifest=new PackageManifest(1,"P","1","Plant3D",2026,"x64","P.dll",[new("P.dll",1,new string('A',64))],new("netload","P")); return PilotPlanFactory.Create(product,profile,manifest,"M","C:\\AutoCAD User Support\\LauncherNextPilot","C:\\script.scr","S",new(true,[])); }
private sealed class Probe : IAutoCadProcessProbe { public bool IsAcadRunning()=>false; }
private sealed class Invoker : IOperatingSystemProcessInvoker { public ProcessInvocationResult Start(ProcessLaunchSpecification _) => throw new Xunit.Sdk.XunitException("must not start"); }
}
@@ -0,0 +1,39 @@
using System.Security.Cryptography;
using Launcher.Next.Autodesk;
using Launcher.Next.Core;
using Launcher.Next.Infrastructure;
using Xunit;
namespace Launcher.Next.Tests;
public sealed class FoundationTests
{
private static PackageManifest Manifest(params PackageFile[] files) => new(1, "P_Rec_Annot", "2026.1.0", "Plant3D", 2026, "x64", "PRecAnnot.Plant3D2026.dll", files, new("netload", "PHP_REC_ANNOT"));
private static 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_PathTraversal_IsRejected() => Assert.False(new PackageManifestValidator().Validate(Manifest(Package("../evil.dll")), 2026, "x64").IsValid);
[Fact] public void Manifest_AbsolutePath_IsRejected() => Assert.False(new PackageManifestValidator().Validate(Manifest(Package("C:\\evil.dll")), 2026, "x64").IsValid);
[Fact] public void Manifest_MissingEntryAssembly_IsRejected() => Assert.False(new PackageManifestValidator().Validate(Manifest(Package("Other.dll")), 2026, "x64").IsValid);
[Fact] public void Manifest_AutodeskAssembly_IsRejected() => Assert.False(new PackageManifestValidator().Validate(Manifest(Package("PRecAnnot.Plant3D2026.dll"), Package("AcMgd.dll")), 2026, "x64").IsValid);
[Fact] public void Manifest_DuplicateFile_IsRejected() => Assert.False(new PackageManifestValidator().Validate(Manifest(Package("PRecAnnot.Plant3D2026.dll"), Package("PRecAnnot.Plant3D2026.dll")), 2026, "x64").IsValid);
[Fact] public void Manifest_WrongArchitecture_IsRejected() => Assert.False(new PackageManifestValidator().Validate(Manifest(Package("PRecAnnot.Plant3D2026.dll")), 2026, "x86").IsValid);
[Fact] public void Manifest_WrongProductYear_IsRejected() => Assert.False(new PackageManifestValidator().Validate(Manifest(Package("PRecAnnot.Plant3D2026.dll")), 2025, "x64").IsValid);
[Fact] public async Task Synchronizer_UsesStaging_AndRetainsPreviousVersion()
{ var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "source"); Directory.CreateDirectory(source); var bytes = new byte[] { 7 }; await System.IO.File.WriteAllBytesAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), bytes); var hash = Convert.ToHexString(SHA256.HashData(bytes)); var store = new LocalPackageStore(root, new Sha256FileHasher()); var result = await store.SynchronizeAsync(Manifest(new PackageFile("PRecAnnot.Plant3D2026.dll", 1, hash)), source, CancellationToken.None); Assert.Equal("2026.1.0", result.CurrentVersion); Assert.True(Directory.Exists(Path.Combine(root, "Applications", "P_Rec_Annot", "versions", result.CurrentVersion))); Directory.Delete(root, true); }
[Fact] public async Task Synchronizer_HashMismatch_IsRejected()
{ var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "source"); Directory.CreateDirectory(source); await System.IO.File.WriteAllTextAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), "x"); var store = new LocalPackageStore(root, new Sha256FileHasher()); await Assert.ThrowsAsync<InvalidOperationException>(() => store.SynchronizeAsync(Manifest(Package("PRecAnnot.Plant3D2026.dll")), source, CancellationToken.None)); Assert.False(Directory.Exists(Path.Combine(root, "Applications", "P_Rec_Annot", "versions", "2026.1.0"))); Directory.Delete(root, true); }
[Fact] public void StartupScript_ContainsOneNetload_AndEscapesSpaces()
{ var root = Path.Combine(Path.GetTempPath(), "space root", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); System.IO.File.WriteAllText(Path.Combine(root, "PRecAnnot.Plant3D2026.dll"), ""); var s = new StartupScriptBuilder().BuildNetloadScript(root, "PRecAnnot.Plant3D2026.dll"); Assert.Single(System.Text.RegularExpressions.Regex.Matches(s, "netload", System.Text.RegularExpressions.RegexOptions.IgnoreCase).Cast<object>()); Assert.Contains("space root", s); Directory.Delete(Path.GetDirectoryName(root)!, true); }
[Fact] public void ProcessLauncher_DryRun_DoesNotStartProcess()
{ var p = new DryRunAutodeskProcessLauncher().Prepare(new LaunchRequest(new ProductInstallation("C:\\acad.exe", "2026", "x64", []), "Pilot", "C:\\script.scr", true)); Assert.False(p.WouldStart); }
[Fact] public void Core_HasNoAutodeskOrProcessReferences() => Assert.DoesNotContain(typeof(PackageManifest).Assembly.GetReferencedAssemblies(), x => x.Name!.Contains("Autodesk") || x.Name.Contains("Diagnostics.Process"));
[Fact] public async Task PRecAnnotPilot_DryRun_ValidatesActivatesAndPreparesWithoutStarting()
{
var fixture = await System.IO.File.ReadAllTextAsync(Path.Combine(AppContext.BaseDirectory, "fixtures", "p-rec-annot-pilot.manifest.json"));
var parsed = PackageManifestJson.Parse(fixture); var bytes = new byte[] { 7 }; var hash = Convert.ToHexString(SHA256.HashData(bytes));
var manifest = parsed with { Files = [new PackageFile(parsed.EntryAssembly, bytes.Length, hash)] };
Assert.True(new PackageManifestValidator().Validate(manifest, 2026, "x64").IsValid);
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "fixture"); Directory.CreateDirectory(source); await System.IO.File.WriteAllBytesAsync(Path.Combine(source, manifest.EntryAssembly), bytes);
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); }
}
}
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><TargetFramework>net8.0-windows</TargetFramework><IsPackable>false</IsPackable></PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"><PrivateAssets>all</PrivateAssets></PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Launcher.Next.Core\Launcher.Next.Core.csproj" />
<ProjectReference Include="..\..\Launcher.Next.Infrastructure\Launcher.Next.Infrastructure.csproj" />
<ProjectReference Include="..\..\Launcher.Next.Autodesk\Launcher.Next.Autodesk.csproj" />
</ItemGroup>
<ItemGroup><None Update="fixtures\p-rec-annot-pilot.manifest.json" CopyToOutputDirectory="PreserveNewest" /></ItemGroup>
</Project>
@@ -0,0 +1,19 @@
{
"schemaVersion": 1,
"packageId": "P_Rec_Annot",
"version": "2026.1.0-pilot",
"product": "Plant3D",
"productYear": 2026,
"architecture": "x64",
"entryAssembly": "PRecAnnot.Plant3D2026.dll",
"files": [
{
"path": "PRecAnnot.Plant3D2026.dll",
"length": 1,
"sha256": "0000000000000000000000000000000000000000000000000000000000000000"
}
],
"startup": { "type": "netload", "command": "PHP_REC_ANNOT" },
"rolloutChannel": "pilot",
"rollbackSafe": true
}