diff --git a/ClumsyPilot/ClumsyPilot.csproj b/ClumsyPilot/ClumsyPilot.csproj index 0592399..fbd1869 100644 --- a/ClumsyPilot/ClumsyPilot.csproj +++ b/ClumsyPilot/ClumsyPilot.csproj @@ -38,4 +38,15 @@ + + + + + + + diff --git a/ClumsyPilot/scripts/Publish-ClumsyPilotPlugin.ps1 b/ClumsyPilot/scripts/Publish-ClumsyPilotPlugin.ps1 new file mode 100644 index 0000000..c124f93 --- /dev/null +++ b/ClumsyPilot/scripts/Publish-ClumsyPilotPlugin.ps1 @@ -0,0 +1,122 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$ManagedDll, + + [Parameter(Mandatory = $true)] + [string]$OutputDirectory +) + +$ErrorActionPreference = 'Stop' + +function Get-PeMachine { + param([Parameter(Mandatory = $true)][string]$Path) + + [byte[]]$bytes = [System.IO.File]::ReadAllBytes($Path) + if ($bytes.Length -le 0x40 -or $bytes[0] -ne 0x4d -or $bytes[1] -ne 0x5a) { + throw "Native runtime is not a PE file: $Path" + } + + [int]$peOffset = [System.BitConverter]::ToInt32($bytes, 0x3c) + if ($peOffset -lt 0 -or $peOffset + 6 -gt $bytes.Length -or $bytes[$peOffset] -ne 0x50 -or $bytes[$peOffset + 1] -ne 0x45) { + throw "Native runtime has an invalid PE header: $Path" + } + + return [System.BitConverter]::ToUInt16($bytes, $peOffset + 4) +} + +function Test-SamePath { + param([string]$Left, [string]$Right) + + return [string]::Equals($Left.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar), + $Right.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar), + [System.StringComparison]::OrdinalIgnoreCase) +} + +$managedDllPath = [System.IO.Path]::GetFullPath($ManagedDll) +$outputDirectoryPath = [System.IO.Path]::GetFullPath($OutputDirectory) +$driveRoot = [System.IO.Path]::GetPathRoot($outputDirectoryPath) +$workspaceRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..')) + +if (Test-SamePath $outputDirectoryPath $driveRoot) { + throw 'Refusing to publish into a drive root.' +} +if (Test-SamePath $outputDirectoryPath $workspaceRoot) { + throw 'Refusing to publish into the workspace root.' +} +if (-not [System.IO.File]::Exists($managedDllPath)) { + throw "Managed DLL does not exist: $managedDllPath" +} +if (-not [System.Environment]::Is64BitProcess) { + throw 'Refusing to publish from a non-x64 PowerShell host.' +} + +$clumsyPilotRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +$osqpRoot = Join-Path $clumsyPilotRoot 'ThirdParty\OSQP' +$nativeDll = Join-Path $osqpRoot 'win-x64\osqp.dll' +$hashManifest = Join-Path $osqpRoot 'SHA256SUMS' +$license = Join-Path $osqpRoot 'LICENSE' +$notice = Join-Path $osqpRoot 'NOTICE' +$version = Join-Path $osqpRoot 'VERSION' +$requiredFiles = @($nativeDll, $hashManifest, $license, $notice, $version) +foreach ($requiredFile in $requiredFiles) { + if (-not [System.IO.File]::Exists($requiredFile)) { + throw "Required OSQP package file does not exist: $requiredFile" + } +} + +if ((Get-PeMachine $nativeDll) -ne 0x8664) { + throw 'Refusing to publish a non-x64 OSQP runtime.' +} + +$manifestTokens = @((Get-Content -Raw -LiteralPath $hashManifest) -split '\s+' | Where-Object { $_.Length -gt 0 }) +if ($manifestTokens.Length -lt 2 -or $manifestTokens[1] -ne 'win-x64/osqp.dll') { + throw 'SHA256SUMS does not describe the pinned win-x64 OSQP runtime.' +} +$expectedHash = $manifestTokens[0].ToLowerInvariant() +$actualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $nativeDll).Hash.ToLowerInvariant() +if ($actualHash -ne $expectedHash) { + throw 'OSQP runtime SHA-256 does not match SHA256SUMS.' +} + +[System.IO.Directory]::CreateDirectory($outputDirectoryPath) | Out-Null +$pluginsDirectory = Join-Path $outputDirectoryPath 'plugins' +$nonce = [System.Guid]::NewGuid().ToString('N') +$stagingDirectory = Join-Path $outputDirectoryPath ('.plugins-staging-' + $nonce) +$backupDirectory = Join-Path $outputDirectoryPath ('.plugins-backup-' + $nonce) +$movedExistingPlugins = $false + +try { + [System.IO.Directory]::CreateDirectory((Join-Path $stagingDirectory 'licenses')) | Out-Null + [System.IO.File]::Copy($managedDllPath, (Join-Path $stagingDirectory 'ClumsyPilot.dll'), $false) + [System.IO.File]::Copy($nativeDll, (Join-Path $stagingDirectory 'osqp.dll'), $false) + [System.IO.File]::Copy($license, (Join-Path $stagingDirectory 'licenses\OSQP-LICENSE.txt'), $false) + [System.IO.File]::Copy($notice, (Join-Path $stagingDirectory 'licenses\OSQP-NOTICE.txt'), $false) + [System.IO.File]::Copy($version, (Join-Path $stagingDirectory 'licenses\OSQP-VERSION.txt'), $false) + + if ([System.IO.Directory]::Exists($pluginsDirectory)) { + [System.IO.Directory]::Move($pluginsDirectory, $backupDirectory) + $movedExistingPlugins = $true + } + + [System.IO.Directory]::Move($stagingDirectory, $pluginsDirectory) + + if ($movedExistingPlugins -and [System.IO.Directory]::Exists($backupDirectory)) { + [System.IO.Directory]::Delete($backupDirectory, $true) + } +} +catch { + if (-not [System.IO.Directory]::Exists($pluginsDirectory) -and $movedExistingPlugins -and + [System.IO.Directory]::Exists($backupDirectory)) { + [System.IO.Directory]::Move($backupDirectory, $pluginsDirectory) + } + throw +} +finally { + if ([System.IO.Directory]::Exists($stagingDirectory)) { + [System.IO.Directory]::Delete($stagingDirectory, $true) + } + if ([System.IO.Directory]::Exists($backupDirectory) -and [System.IO.Directory]::Exists($pluginsDirectory)) { + [System.IO.Directory]::Delete($backupDirectory, $true) + } +} diff --git a/ClumsyPilot/tests/EMPlannerVerificationHost/PluginPackagingChecks.cs b/ClumsyPilot/tests/EMPlannerVerificationHost/PluginPackagingChecks.cs new file mode 100644 index 0000000..acca903 --- /dev/null +++ b/ClumsyPilot/tests/EMPlannerVerificationHost/PluginPackagingChecks.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Reflection; +using System.Security.Cryptography; + +namespace EMPlannerVerificationHost; + +internal static class PluginPackagingChecks +{ + public static void Run() + { + string repositoryRoot = Directory.GetCurrentDirectory(); + string scriptPath = Path.Combine(repositoryRoot, "ClumsyPilot", "scripts", "Publish-ClumsyPilotPlugin.ps1"); + Verification.True(File.Exists(scriptPath), "plugin publish script exists"); + + string managedDll = Path.Combine(AppContext.BaseDirectory, "ClumsyPilot.dll"); + Verification.True(File.Exists(managedDll), "verification host has managed ClumsyPilot.dll"); + VerifyBuildOutputMetadata(); + + string packageRoot = Path.Combine(Path.GetTempPath(), "em-planner-plugin-package-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(packageRoot); + try + { + Publish(scriptPath, managedDll, packageRoot); + VerifyPackage(repositoryRoot, packageRoot); + + Publish(scriptPath, managedDll, packageRoot); + VerifyPackage(repositoryRoot, packageRoot); + AssertNoStaleSiblingDirectories(packageRoot); + } + finally + { + if (Directory.Exists(packageRoot)) + Directory.Delete(packageRoot, true); + } + } + + private static void Publish(string scriptPath, string managedDll, string packageRoot) + { + var startInfo = new ProcessStartInfo + { + FileName = "powershell.exe", + WorkingDirectory = Directory.GetCurrentDirectory(), + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + startInfo.ArgumentList.Add("-NoProfile"); + startInfo.ArgumentList.Add("-ExecutionPolicy"); + startInfo.ArgumentList.Add("Bypass"); + startInfo.ArgumentList.Add("-File"); + startInfo.ArgumentList.Add(scriptPath); + startInfo.ArgumentList.Add("-ManagedDll"); + startInfo.ArgumentList.Add(managedDll); + startInfo.ArgumentList.Add("-OutputDirectory"); + startInfo.ArgumentList.Add(packageRoot); + + using (var process = new Process { StartInfo = startInfo }) + { + process.Start(); + string standardOutput = process.StandardOutput.ReadToEnd(); + string standardError = process.StandardError.ReadToEnd(); + process.WaitForExit(); + if (process.ExitCode != 0) + { + throw new InvalidOperationException("plugin publish exited " + process.ExitCode + ": " + + standardOutput + standardError); + } + } + } + + private static void VerifyPackage(string repositoryRoot, string packageRoot) + { + string pluginsDirectory = Path.Combine(packageRoot, "plugins"); + Verification.True(Directory.Exists(pluginsDirectory), "plugins directory exists"); + + var relativeFiles = new List(); + string[] files = Directory.GetFiles(pluginsDirectory, "*", SearchOption.AllDirectories); + for (int index = 0; index < files.Length; index++) + relativeFiles.Add(Path.GetRelativePath(packageRoot, files[index]).Replace('\\', '/')); + relativeFiles.Sort(StringComparer.Ordinal); + + Verification.Equal("plugins/ClumsyPilot.dll|plugins/licenses/OSQP-LICENSE.txt|plugins/licenses/OSQP-NOTICE.txt|" + + "plugins/licenses/OSQP-VERSION.txt|plugins/osqp.dll", string.Join("|", relativeFiles), + "plugin tree contains exactly the managed DLL, native DLL, and OSQP license files"); + + string managedPlugin = Path.Combine(pluginsDirectory, "ClumsyPilot.dll"); + AssemblyName managedAssembly = AssemblyName.GetAssemblyName(managedPlugin); + Verification.True(!string.IsNullOrWhiteSpace(managedAssembly.Name), "packaged ClumsyPilot.dll is managed"); + + string nativePlugin = Path.Combine(pluginsDirectory, "osqp.dll"); + Verification.Equal((ushort)0x8664, ReadPeMachine(nativePlugin), "packaged OSQP binary is x64"); + Verification.Equal(ReadExpectedOsqpHash(repositoryRoot), GetSha256(nativePlugin), + "packaged OSQP hash matches SHA256SUMS"); + } + + private static void AssertNoStaleSiblingDirectories(string packageRoot) + { + string parentDirectory = Path.GetDirectoryName(packageRoot) ?? + throw new InvalidOperationException("temporary package root has no parent directory"); + string prefix = Path.GetFileName(packageRoot); + string[] matchingDirectories = Directory.GetDirectories(parentDirectory, prefix + "*"); + Verification.Equal(1, matchingDirectories.Length, "publish leaves no stale sibling staging directory"); + Verification.Equal(packageRoot, matchingDirectories[0], "publish keeps only the requested temporary test root"); + } + + private static ushort ReadPeMachine(string path) + { + byte[] bytes = File.ReadAllBytes(path); + Verification.True(bytes.Length > 0x40 && bytes[0] == 'M' && bytes[1] == 'Z', "OSQP has DOS header"); + int peOffset = BitConverter.ToInt32(bytes, 0x3c); + Verification.True(peOffset >= 0 && peOffset + 6 <= bytes.Length && bytes[peOffset] == 'P' && + bytes[peOffset + 1] == 'E', "OSQP has PE header"); + return BitConverter.ToUInt16(bytes, peOffset + 4); + } + + private static string ReadExpectedOsqpHash(string repositoryRoot) + { + string manifestPath = Path.Combine(repositoryRoot, "ClumsyPilot", "ThirdParty", "OSQP", "SHA256SUMS"); + string[] tokens = File.ReadAllText(manifestPath).Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + Verification.True(tokens.Length >= 2 && string.Equals(tokens[1], "win-x64/osqp.dll", StringComparison.Ordinal), + "OSQP hash manifest describes the pinned x64 runtime"); + return tokens[0].ToLowerInvariant(); + } + + private static string GetSha256(string path) + { + return Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))).ToLowerInvariant(); + } + + private static void VerifyBuildOutputMetadata() + { + string outputDirectory = AppContext.BaseDirectory; + Verification.True(File.Exists(Path.Combine(outputDirectory, "osqp.dll")), + "build output contains the pinned OSQP runtime"); + Verification.True(File.Exists(Path.Combine(outputDirectory, "licenses", "OSQP-LICENSE.txt")), + "build output contains the OSQP license"); + Verification.True(File.Exists(Path.Combine(outputDirectory, "licenses", "OSQP-NOTICE.txt")), + "build output contains the OSQP notice"); + Verification.True(File.Exists(Path.Combine(outputDirectory, "licenses", "OSQP-VERSION.txt")), + "build output contains the OSQP version"); + } +} diff --git a/ClumsyPilot/tests/EMPlannerVerificationHost/Program.cs b/ClumsyPilot/tests/EMPlannerVerificationHost/Program.cs index 9fc64cb..dd2fb82 100644 --- a/ClumsyPilot/tests/EMPlannerVerificationHost/Program.cs +++ b/ClumsyPilot/tests/EMPlannerVerificationHost/Program.cs @@ -13,7 +13,7 @@ internal static class Program args[0] != "longitudinal-model" && args[0] != "longitudinal-integration" && args[0] != "longitudinal-real-osqp-probe" && args[0] != "trajectory" && args[0] != "em-planning-service" && args[0] != "em-core-all" && args[0] != "coordinator" && - args[0] != "executor")) + args[0] != "executor" && args[0] != "plugin-package")) { Console.Error.WriteLine("Usage: EMPlannerVerificationHost foundation|segmentation|frenet|corridor|optimization|osqp|osqp-loader|all-foundation|lateral-model|lateral-integration|lateral-real-osqp|lateral-all|longitudinal-model|longitudinal-integration"); return 2; @@ -119,6 +119,11 @@ internal static class Program ExecutorChecks.Run(); Console.WriteLine("PASS executor"); } + if (args[0] == "plugin-package") + { + PluginPackagingChecks.Run(); + Console.WriteLine("PASS plugin-package"); + } return 0; } catch (Exception exception)