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; // 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; } // 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? SelectedProductId { get; init; } public string? SelectedProfileName { get; init; } public int ExecutionPermitLifetimeSeconds { get; init; } = 300; public AcadLaunchMode LaunchMode { get; init; } = AcadLaunchMode.Production; public bool ProductionDeploy { get; init; } public IReadOnlyDictionary PathVariables { get; init; } = new Dictionary(); public static PilotLocalConfiguration SafeDefault { get; } = new(); } public enum AcadLaunchMode { Production, NextPilotPreview, NextPilotApply, LifecycleProbe } public sealed record PilotConfigurationLoadResult(PilotLocalConfiguration Configuration, IReadOnlyList 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 options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow }; options.Converters.Add(new JsonStringEnumConverter()); var config = JsonSerializer.Deserialize(json, options); 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 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.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, []); } } public sealed record AutoCadProfileCandidate(string Name, bool IsCurrent, bool IsPlantSpecific, IReadOnlyList Evidence); public interface IAutoCadProfileDiscovery { IReadOnlyList DiscoverProfiles(ProductInstallation product); } public static class ProfileSelector { public static AutoCadProfileCandidate? Select(IReadOnlyList 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 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(); if (c.Configuration.LauncherMode != LauncherMode.NextPilot) v.Add("WRONG_LAUNCHER_MODE"); if (!c.Configuration.PilotEnabled) v.Add("PILOT_DISABLED"); if (!c.Configuration.RequireExplicitExecute) v.Add("EXPLICIT_EXECUTE_REQUIRED"); if (c.Configuration.PluginExecutionMode is not (PluginExecutionMode.Smoke or PluginExecutionMode.DryRun or PluginExecutionMode.Apply)) v.Add("PLUGIN_EXECUTION_MODE_NOT_ALLOWED"); if (c.Configuration.PluginExecutionMode == PluginExecutionMode.Apply && !c.Configuration.AllowApply) v.Add("APPLY_NOT_EXPLICITLY_ALLOWED"); if (c.Configuration.PluginExecutionMode == PluginExecutionMode.Apply && (!Path.IsPathFullyQualified(c.Configuration.ApplyPermitPath ?? string.Empty) || (c.Configuration.ApplyPermitPath ?? string.Empty).StartsWith("\\\\", StringComparison.Ordinal))) v.Add("APPLY_PERMIT_PATH_REQUIRED"); var automation = c.Configuration.LaunchMode is AcadLaunchMode.NextPilotPreview or AcadLaunchMode.NextPilotApply or AcadLaunchMode.LifecycleProbe; if (automation && (!string.Equals(c.Configuration.AutomationMode, "FirstLimitedApplyRuntimeTest", StringComparison.Ordinal) || !Path.IsPathFullyQualified(c.Configuration.AutomationConfigurationPath ?? string.Empty) || (c.Configuration.AutomationConfigurationPath ?? string.Empty).StartsWith("\\\\", StringComparison.Ordinal) || !Path.IsPathFullyQualified(c.Configuration.FixtureMarkerPath ?? string.Empty) || (c.Configuration.FixtureMarkerPath ?? string.Empty).StartsWith("\\\\", StringComparison.Ordinal) || c.Configuration.ProductionDeploy)) v.Add("AUTOMATION_CONFIGURATION_INVALID"); if (!automation && c.Configuration.AutomationMode is not null) v.Add("AUTOMATION_MODE_MISMATCH"); if (c.Configuration.ForceAfterEvidence && !automation) v.Add("FORCED_CLEANUP_TEST_AUTOMATION_REQUIRED"); if (c.Product is null) v.Add("PRODUCT_NOT_FOUND"); else if (c.Product.ProductVersion != "2026" || c.Product.Architecture != "x64") v.Add("PRODUCT_NOT_PLANT3D_2026"); if (c.Profile is null) v.Add("PROFILE_NOT_RESOLVED"); if (!c.Root.IsValid) v.Add("PILOT_ROOT_UNTRUSTED"); if (!c.Manifest.IsValid) v.Add("MANIFEST_INVALID"); if (!c.HashesValid) v.Add("HASH_VALIDATION_FAILED"); if (c.AutodeskDllPresent) v.Add("AUTODESK_DLL_PRESENT"); if (c.AcadRunning) v.Add("ACAD_ALREADY_RUNNING"); if (forExecution && !c.Configuration.AllowProcessStart) v.Add("PROCESS_START_DISABLED"); return new(v.Count == 0, v); } } public sealed record ProcessLaunchSpecification(string ExecutablePath, IReadOnlyList Arguments, string WorkingDirectory, IReadOnlyDictionary EnvironmentVariables); public sealed record PilotLaunchPlan(string PlanId, ProductInstallation Product, AutoCadProfileCandidate Profile, string PackageId, string PackageVersion, string ManifestSha256, string PilotRoot, string VersionDirectory, string EntryAssemblyPath, string StartupScriptPath, string StartupScriptSha256, ProcessLaunchSpecification Process, PilotSafetyDecision SafetyDecision); public sealed class AcadArgumentBuilder { public IReadOnlyList Build(AcadLaunchMode mode, string? automationScriptPath) { var arguments = new List { "/product", "PLNT3D", "/language", "ru-RU" }; if (mode is AcadLaunchMode.NextPilotPreview or AcadLaunchMode.NextPilotApply or AcadLaunchMode.LifecycleProbe) { if (string.IsNullOrWhiteSpace(automationScriptPath) || !Path.IsPathFullyQualified(automationScriptPath)) throw new InvalidOperationException("AUTOMATION_SCRIPT_PATH_REQUIRED"); arguments.Add("/b"); arguments.Add(automationScriptPath); } else if (!string.IsNullOrWhiteSpace(automationScriptPath)) throw new InvalidOperationException("PRODUCTION_BATCH_SCRIPT_FORBIDDEN"); return arguments; } } public static class PilotPlanFactory { public static PilotLaunchPlan Create(ProductInstallation product, AutoCadProfileCandidate profile, PackageManifest manifest, string manifestHash, string root, string scriptPath, string scriptHash, PilotSafetyDecision decision, AcadLaunchMode mode = AcadLaunchMode.Production) { var version = Path.Combine(root, "packages", manifest.PackageId, "versions", manifest.Version); var entry = Path.Combine(version, manifest.EntryAssembly); var args = new AcadArgumentBuilder().Build(mode, mode == AcadLaunchMode.Production ? null : scriptPath); var process = new ProcessLaunchSpecification(product.ExecutablePath, args, Path.GetDirectoryName(product.ExecutablePath)!, new Dictionary { ["P_REC_ANNOT_RUNTIME_MODE"] = "Smoke" }); var material = string.Join('|', manifest.PackageId, manifest.Version, manifestHash, product.ExecutablePath, entry, scriptHash, mode); var id = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(material))); return new(id, product, profile, manifest.PackageId, manifest.Version, manifestHash, root, version, entry, scriptPath, scriptHash, process, decision); } } public sealed record ExecutionPermit(string PlanId, string Fingerprint, DateTimeOffset ExpiresAtUtc, Guid Id); public interface IExecutionPermitService { ExecutionPermit Issue(PilotLaunchPlan plan, int lifetimeSeconds); bool Validate(ExecutionPermit permit, PilotLaunchPlan plan, DateTimeOffset now); } public sealed class ExecutionPermitService : IExecutionPermitService { private readonly HashSet 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); }