diff --git a/Launcher.Next.Autodesk/AutodeskAdapters.cs b/Launcher.Next.Autodesk/AutodeskAdapters.cs index 05c5964..5afb719 100644 --- a/Launcher.Next.Autodesk/AutodeskAdapters.cs +++ b/Launcher.Next.Autodesk/AutodeskAdapters.cs @@ -15,7 +15,7 @@ public sealed class PlantProductDiscovery(IRegistryReader registry) : IProductDi 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 $"(command \"._netload\" \"{full.Replace("\\", "\\\\").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 sealed class DryRunAutodeskProcessLauncher : IAutodeskProcessLauncher { diff --git a/Launcher.Next.Core/NextPilot.cs b/Launcher.Next.Core/NextPilot.cs index a9ac17e..9fd3aef 100644 --- a/Launcher.Next.Core/NextPilot.cs +++ b/Launcher.Next.Core/NextPilot.cs @@ -64,7 +64,7 @@ public sealed record PilotLaunchPlan(string PlanId, ProductInstallation Product, 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 process = new ProcessLaunchSpecification(product.ExecutablePath, ["/product", "PLNT3D", "/p", profile.Name, "/b", scriptPath], Path.GetDirectoryName(product.ExecutablePath)!, new Dictionary { ["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 List { "/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 { ["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); } diff --git a/Launcher.Next.Core/PilotExecutionContracts.cs b/Launcher.Next.Core/PilotExecutionContracts.cs new file mode 100644 index 0000000..c82de33 --- /dev/null +++ b/Launcher.Next.Core/PilotExecutionContracts.cs @@ -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 PrepareAsync(PilotPreparationRequest request, CancellationToken cancellationToken); + Task ExecuteAsync(PilotExecutionRequest request, CancellationToken cancellationToken); + Task RollbackAsync(PilotRollbackRequest request, CancellationToken cancellationToken); +} diff --git a/Launcher.Next.Infrastructure/GuardedProcessLauncher.cs b/Launcher.Next.Infrastructure/GuardedProcessLauncher.cs index 5f6d134..e5b72e4 100644 --- a/Launcher.Next.Infrastructure/GuardedProcessLauncher.cs +++ b/Launcher.Next.Infrastructure/GuardedProcessLauncher.cs @@ -8,7 +8,7 @@ public sealed class OperatingSystemProcessInvoker : IOperatingSystemProcessInvok { 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 = false; + 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); } } diff --git a/Launcher.Next.Infrastructure/PilotExecutionService.cs b/Launcher.Next.Infrastructure/PilotExecutionService.cs new file mode 100644 index 0000000..21d83f2 --- /dev/null +++ b/Launcher.Next.Infrastructure/PilotExecutionService.cs @@ -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 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 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 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 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;} +} diff --git a/tests/Launcher.Next.Tests/ControlledExecutionTests.cs b/tests/Launcher.Next.Tests/ControlledExecutionTests.cs index b349c6f..d0dc10d 100644 --- a/tests/Launcher.Next.Tests/ControlledExecutionTests.cs +++ b/tests/Launcher.Next.Tests/ControlledExecutionTests.cs @@ -13,7 +13,7 @@ public sealed class ControlledExecutionTests [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=true,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("REAL_EXECUTION_RELEASE_NOT_ENABLED",result.BlockCode); } + { 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"); }