#Requires -Version 5.1 [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string] $SpecificationPath, [Parameter(Mandatory = $true)] [string] $OutputPath ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $utf8NoBom = [Text.UTF8Encoding]::new($false, $true) $repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $orchestratorPath = Join-Path $PSScriptRoot 'Invoke-LegacyPgmPrepareGate.ps1' $nodeHelperPath = Join-Path $PSScriptRoot 'Test-LegacyPgmPrepareGate.mjs' $userProfilePath = [Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile) $approvedNodePath = [IO.Path]::GetFullPath((Join-Path $userProfilePath '.cache\codex-runtimes\codex-primary-runtime\dependencies\node\bin\node.exe')) $expectedRemote = 'https://gitea.comtropy.synology.me/Wickedness/MBN_STOCK_WEBVIEW.git' $expectedUrl = 'https://legacy-parity.mbn.local/index.html' $approvedSceneDirectory = 'C:\Users\MD\source\repos\MBN_STOCK_N\MBN_STOCK_N\bin\Debug\Cuts' $approvedCutPath = Join-Path $approvedSceneDirectory '5001.t2s' $approvedCutSha256 = '99CE3B689A42D8C42BEB09A86FA10C2D7C1AEF4F50D324D81276C1A1E4C4D8A7' $approvedMsixPath = Join-Path $repositoryRoot 'src\MBN_STOCK_WEBVIEW.LegacyParityApp\AppPackages\MBN_STOCK_WEBVIEW.LegacyParityApp_0.1.0.0_x64_Test\MBN_STOCK_WEBVIEW.LegacyParityApp_0.1.0.0_x64.msix' $approvedMsixSha256 = '0E44F87FDD31852B7DDFCD6CF3A6325DA674F1F5A23AD39CB262A725AEB84367' $approvedDatabaseConfigPath = Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) 'MBN_STOCK_WEBVIEW\Config\database.local.json' $expectedMarketText = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('7L2U7Iqk7ZS8')) $expectedStockName = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('7IK87ISx7KCE7J6Q')) $expectedCutLabel = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('MeyXtO2MkOq4sOuzuF/tmITsnqzqsIA=')) $expectedGraphicType = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('MeyXtO2MkOq4sOuzuA==')) $expectedSubtype = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('7ZiE7J6s6rCA')) function Fail([string] $Message) { throw [InvalidOperationException]::new($Message) } function FullPath([string] $Path, [string] $Name) { if ([string]::IsNullOrWhiteSpace($Path) -or -not [IO.Path]::IsPathRooted($Path)) { Fail "$Name must be an absolute path." } return [IO.Path]::GetFullPath($Path) } function SamePath([string] $Left, [string] $Right) { return [string]::Equals( [IO.Path]::GetFullPath($Left).TrimEnd('\'), [IO.Path]::GetFullPath($Right).TrimEnd('\'), [StringComparison]::OrdinalIgnoreCase) } function Assert-NoReparse([string] $Path, [string] $Name) { $current = [IO.Path]::GetFullPath($Path) while (-not [string]::IsNullOrWhiteSpace($current)) { if ([IO.File]::Exists($current) -or [IO.Directory]::Exists($current)) { if (([IO.File]::GetAttributes($current) -band [IO.FileAttributes]::ReparsePoint) -ne 0) { Fail "$Name contains a reparse point." } } $parent = [IO.Directory]::GetParent($current) if ($null -eq $parent) { break } $current = $parent.FullName } } function Read-Json([string] $Path, [string] $Name) { $full = FullPath $Path $Name if (-not [IO.File]::Exists($full)) { Fail "$Name does not exist." } Assert-NoReparse $full $Name $bytes = [IO.File]::ReadAllBytes($full) try { if ($bytes.Length -eq 0 -or $bytes.Length -gt 1MB) { Fail "$Name has an unsafe size." } if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { Fail "$Name must be UTF-8 without BOM." } try { $text = $utf8NoBom.GetString($bytes) } catch { Fail "$Name is not strict UTF-8." } try { return $text | ConvertFrom-Json -ErrorAction Stop } catch { Fail "$Name is not valid JSON." } } finally { [Array]::Clear($bytes, 0, $bytes.Length) } } function Require([object] $Object, [string] $Name, [string] $Context) { if ($null -eq $Object -or -not ($Object.PSObject.Properties.Name -ccontains $Name)) { Fail "$Context.$Name is required." } return $Object.$Name } function Assert-ExactProperties([object] $Object, [string[]] $Expected, [string] $Context) { if ($null -eq $Object) { Fail "$Context is required." } $actual = @($Object.PSObject.Properties.Name) if ($actual.Count -ne $Expected.Count) { Fail "$Context property count is not exact." } foreach ($name in $Expected) { if (@($actual | Where-Object { $_ -ceq $name }).Count -ne 1) { Fail "$Context property set is not exact: $name." } } } function Assert-JsonInt([object] $Value, [int] $Expected, [string] $Name) { if ($Value -isnot [int] -or [int]$Value -ne $Expected) { Fail "$Name must be JSON integer $Expected." } } function Assert-JsonBool([object] $Value, [bool] $Expected, [string] $Name) { if ($Value -isnot [bool] -or [bool]$Value -ne $Expected) { Fail "$Name must be JSON boolean $Expected." } } function Assert-JsonString([object] $Value, [string] $Expected, [string] $Name) { if ($Value -isnot [string] -or [string]$Value -cne $Expected) { Fail "$Name must be the exact JSON string." } } function New-FilePin([string] $Path, [string] $Name) { $full = FullPath $Path $Name if (-not [IO.File]::Exists($full)) { Fail "$Name does not exist." } Assert-NoReparse $full $Name $item = Get-Item -LiteralPath $full -Force return [ordered]@{ path = $full length = [int64]$item.Length sha256 = (Get-FileHash -LiteralPath $full -Algorithm SHA256).Hash } } function Get-RuntimeTreePin([string] $Root) { $fullRoot = FullPath $Root 'package.installLocation' if (-not [IO.Directory]::Exists($fullRoot)) { Fail 'package.installLocation does not exist.' } Assert-NoReparse $fullRoot 'package.installLocation' foreach ($directory in [IO.Directory]::GetDirectories($fullRoot, '*', [IO.SearchOption]::AllDirectories)) { Assert-NoReparse $directory 'package runtime directory' } $files = @([IO.Directory]::GetFiles($fullRoot, '*', [IO.SearchOption]::AllDirectories)) if ($files.Count -eq 0 -or $files.Count -gt 4096) { Fail 'The package runtime tree count is unsafe.' } $relative = New-Object 'Collections.Generic.List[string]' $rootPrefix = $fullRoot.TrimEnd('\') + '\' foreach ($file in $files) { Assert-NoReparse $file 'package runtime file' if (-not $file.StartsWith($rootPrefix, [StringComparison]::OrdinalIgnoreCase)) { Fail 'A package runtime file escaped the install root.' } $name = $file.Substring($rootPrefix.Length).Replace('\', '/') if ($name.Contains('|') -or $name.Contains("`r") -or $name.Contains("`n")) { Fail 'A package runtime relative path cannot be represented safely.' } [void]$relative.Add($name) } $names = $relative.ToArray() [Array]::Sort($names, [StringComparer]::Ordinal) $builder = [Text.StringBuilder]::new() foreach ($name in $names) { $file = Join-Path $fullRoot $name.Replace('/', '\') $item = Get-Item -LiteralPath $file -Force $hash = (Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash [void]$builder.Append($name).Append('|').Append(([int64]$item.Length).ToString([Globalization.CultureInfo]::InvariantCulture)).Append('|').Append($hash).Append("`n") } $manifestBytes = $utf8NoBom.GetBytes($builder.ToString()) try { $algorithm = [Security.Cryptography.SHA256]::Create() try { $digest = ([BitConverter]::ToString($algorithm.ComputeHash($manifestBytes))).Replace('-', '') } finally { $algorithm.Dispose() } } finally { [Array]::Clear($manifestBytes, 0, $manifestBytes.Length) } return [ordered]@{ fileCount = $names.Length; manifestSha256 = $digest } } function Assert-GateConfig([string] $Path, [string] $SceneDirectory) { $config = Read-Json $Path 'configuration.gate' $names = @( 'mode','host','port','tcpMode','clientPort','outputChannel','layoutIndex', 'legacySceneFadeDuration','legacySceneBackgroundKind', 'legacySceneBackgroundAssetPath','legacySceneBackgroundVideoLoopCount', 'legacySceneBackgroundVideoLoopInfinite','legacyBackgroundDirectory', 'sceneDirectory','testProcessWindowTitlePattern','testSceneAllowlist', 'trustedLiveOutputEnabled','queueCapacity','connectTimeoutMilliseconds', 'operationTimeoutMilliseconds','disconnectTimeoutMilliseconds', 'processPollIntervalMilliseconds','reconnectDelayMilliseconds', 'maximumReconnectAttempts','reconnectEnabled', 'maximumAutomaticRefreshesPerTakeIn') Assert-ExactProperties $config $names 'configuration.gate JSON' if ($config.testSceneAllowlist -isnot [Array]) { Fail 'configuration.gate.testSceneAllowlist must be a JSON array.' } $allowlist = @(Require $config 'testSceneAllowlist' 'configuration.gate JSON') Assert-JsonString $config.mode 'DryRun' 'configuration.gate.mode' Assert-JsonString $config.host '127.0.0.1' 'configuration.gate.host' Assert-JsonInt $config.port 30001 'configuration.gate.port' Assert-JsonInt $config.tcpMode 1 'configuration.gate.tcpMode' Assert-JsonInt $config.clientPort 0 'configuration.gate.clientPort' Assert-JsonInt $config.layoutIndex 10 'configuration.gate.layoutIndex' Assert-JsonInt $config.legacySceneFadeDuration 6 'configuration.gate.legacySceneFadeDuration' Assert-JsonString $config.legacySceneBackgroundKind 'None' 'configuration.gate.legacySceneBackgroundKind' Assert-JsonInt $config.legacySceneBackgroundVideoLoopCount 2004 'configuration.gate.legacySceneBackgroundVideoLoopCount' Assert-JsonBool $config.legacySceneBackgroundVideoLoopInfinite $true 'configuration.gate.legacySceneBackgroundVideoLoopInfinite' Assert-JsonBool $config.trustedLiveOutputEnabled $true 'configuration.gate.trustedLiveOutputEnabled' Assert-JsonInt $config.queueCapacity 64 'configuration.gate.queueCapacity' Assert-JsonInt $config.connectTimeoutMilliseconds 5000 'configuration.gate.connectTimeoutMilliseconds' Assert-JsonInt $config.operationTimeoutMilliseconds 5000 'configuration.gate.operationTimeoutMilliseconds' Assert-JsonInt $config.disconnectTimeoutMilliseconds 3000 'configuration.gate.disconnectTimeoutMilliseconds' Assert-JsonInt $config.processPollIntervalMilliseconds 1000 'configuration.gate.processPollIntervalMilliseconds' Assert-JsonInt $config.reconnectDelayMilliseconds 1000 'configuration.gate.reconnectDelayMilliseconds' Assert-JsonInt $config.maximumReconnectAttempts 0 'configuration.gate.maximumReconnectAttempts' Assert-JsonBool $config.reconnectEnabled $false 'configuration.gate.reconnectEnabled' Assert-JsonInt $config.maximumAutomaticRefreshesPerTakeIn 0 'configuration.gate.maximumAutomaticRefreshesPerTakeIn' if ( $null -ne (Require $config 'outputChannel' 'configuration.gate JSON') -or $null -ne (Require $config 'legacySceneBackgroundAssetPath' 'configuration.gate JSON') -or $null -ne (Require $config 'legacyBackgroundDirectory' 'configuration.gate JSON') -or -not (SamePath ([string](Require $config 'sceneDirectory' 'configuration.gate JSON')) $SceneDirectory) -or $null -ne (Require $config 'testProcessWindowTitlePattern' 'configuration.gate JSON') -or $allowlist.Count -ne 1 -or $allowlist[0] -isnot [string] -or [string]$allowlist[0] -cne '5001') { Fail 'configuration.gate JSON is not the closed 5001-only persistent-DryRun configuration.' } } $specificationPath = FullPath $SpecificationPath 'SpecificationPath' $outputPath = FullPath $OutputPath 'OutputPath' if ($outputPath.StartsWith($repositoryRoot.TrimEnd('\') + '\', [StringComparison]::OrdinalIgnoreCase)) { Fail 'OutputPath must remain outside the Git repository so the execution checkout can be clean.' } if ([IO.File]::Exists($outputPath) -or [IO.Directory]::Exists($outputPath)) { Fail 'OutputPath already exists; a frozen plan is never overwritten.' } if (SamePath $specificationPath $outputPath) { Fail 'SpecificationPath and OutputPath must differ.' } if (-not [IO.File]::Exists($orchestratorPath) -or -not [IO.File]::Exists($nodeHelperPath)) { Fail 'The committed Gate orchestrator and Node helper must both exist before sealing a plan.' } $spec = Read-Json $specificationPath 'SpecificationPath' Assert-ExactProperties $spec @( 'schemaVersion','roundId','repository','package','automation','configuration', 'cut','k3d','pgm','webView') 'specification' Assert-JsonInt (Require $spec 'schemaVersion' 'specification') 1 'specification.schemaVersion' $roundId = [string](Require $spec 'roundId' 'specification') if ($roundId -cnotmatch '^MBNWEB-[0-9]{8}-[A-Z0-9][A-Z0-9-]{0,31}$') { Fail 'roundId is invalid.' } $repo = Require $spec 'repository' 'specification' Assert-ExactProperties $repo @('path','branch','commit','upstream','remoteUrl') 'repository' if (-not (SamePath ([string]$repo.path) $repositoryRoot) -or [string]$repo.branch -cne 'main' -or [string]$repo.upstream -cne 'origin/main' -or [string]$repo.remoteUrl -cne $expectedRemote -or [string]$repo.commit -cnotmatch '^[0-9a-f]{40}$') { Fail 'repository pin is not exact.' } $package = Require $spec 'package' 'specification' Assert-ExactProperties $package @( 'name','familyName','fullName','publisher','version','architecture', 'applicationId','installLocation','executablePath','msixPath') 'package' if ([string]$package.name -cne 'Wickedness.MBNStockWebView.LegacyParity' -or [string]$package.familyName -cne 'Wickedness.MBNStockWebView.LegacyParity_qbv3jkvsn3aj0' -or [string]$package.fullName -cne 'Wickedness.MBNStockWebView.LegacyParity_0.1.0.0_x64__qbv3jkvsn3aj0' -or [string]$package.publisher -cne 'CN=Comtrophy' -or [string]$package.version -cne '0.1.0.0' -or [string]$package.architecture -cne 'X64' -or [string]$package.applicationId -cne 'LegacyParityApp') { Fail 'package identity is not the exact LegacyParity Release x64 package.' } $installLocation = FullPath ([string]$package.installLocation) 'package.installLocation' $expectedInstallLocation = Join-Path $repositoryRoot 'src\MBN_STOCK_WEBVIEW.LegacyParityApp\bin\x64\Release\net8.0-windows10.0.19041.0\win-x64' if (-not (SamePath $installLocation $expectedInstallLocation)) { Fail 'package.installLocation is not the exact Release x64 output.' } $executablePath = FullPath ([string]$package.executablePath) 'package.executablePath' if (-not (SamePath $executablePath (Join-Path $installLocation 'MBN_STOCK_WEBVIEW.LegacyParityApp.exe'))) { Fail 'package executable is outside the exact install root.' } $msixPath = FullPath ([string]$package.msixPath) 'package.msixPath' if (-not (SamePath $msixPath $approvedMsixPath)) { Fail 'package.msixPath is not the exact Release x64 AppPackages MSIX.' } if ((Get-FileHash -LiteralPath $msixPath -Algorithm SHA256).Hash -cne $approvedMsixSha256) { Fail 'The approved Release x64 MSIX SHA-256 changed.' } $automation = Require $spec 'automation' 'specification' Assert-ExactProperties $automation @('nodePath') 'automation' if (-not (SamePath ([string]$automation.nodePath) $approvedNodePath)) { Fail 'automation.nodePath is not the approved bundled Node runtime.' } $configuration = Require $spec 'configuration' 'specification' Assert-ExactProperties $configuration @('originalPath','gatePath','sceneDirectory') 'configuration' $originalConfigPath = FullPath ([string]$configuration.originalPath) 'configuration.originalPath' $gateConfigPath = FullPath ([string]$configuration.gatePath) 'configuration.gatePath' $sceneDirectory = FullPath ([string]$configuration.sceneDirectory) 'configuration.sceneDirectory' if (-not (SamePath $sceneDirectory $approvedSceneDirectory)) { Fail 'configuration.sceneDirectory is not the approved read-only original Cuts root.' } if (SamePath $originalConfigPath $gateConfigPath) { Fail 'Original and Gate config paths must differ.' } Assert-GateConfig $gateConfigPath $sceneDirectory $cut = Require $spec 'cut' 'specification' Assert-ExactProperties $cut @('path') 'cut' $cutPath = FullPath ([string]$cut.path) 'cut.path' if (-not (SamePath $cutPath $approvedCutPath)) { Fail 'cut.path is not the approved read-only original 5001.t2s.' } if ((Get-FileHash -LiteralPath $cutPath -Algorithm SHA256).Hash -cne $approvedCutSha256) { Fail 'The approved original 5001.t2s SHA-256 changed.' } $k3d = Require $spec 'k3d' 'specification' Assert-ExactProperties $k3d @('nativePath','interopPath') 'k3d' $pgm = Require $spec 'pgm' 'specification' Assert-ExactProperties $pgm @( 'processId','startTimeUtc','processName','executablePath','windowTitle', 'listenerAddress','host','port') 'pgm' if ($pgm.processId -isnot [int] -or [int]$pgm.processId -le 0 -or $pgm.port -isnot [int] -or [string]$pgm.startTimeUtc -cnotmatch '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{7}Z$' -or [string]$pgm.windowTitle -cne 'PGM' -or [string]$pgm.listenerAddress -cne '0.0.0.0' -or [string]$pgm.host -cne '127.0.0.1' -or [int]$pgm.port -ne 30001) { Fail 'PGM pin is not the exact current PGM listener contract.' } $webView = Require $spec 'webView' 'specification' Assert-ExactProperties $webView @('address','port','url') 'webView' if ($webView.port -isnot [int] -or [string]$webView.address -cne '127.0.0.1' -or [int]$webView.port -lt 1024 -or [int]$webView.port -gt 65535 -or [int]$webView.port -eq 30001 -or [string]$webView.url -cne $expectedUrl) { Fail 'WebView pin is invalid.' } $plan = [ordered]@{ schemaVersion = 1 kind = 'LegacyPgmPrepareGatePlan' roundId = $roundId createdAtUtc = [DateTime]::UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss.fffffff'Z'", [Globalization.CultureInfo]::InvariantCulture) repository = [ordered]@{ path = $repositoryRoot; branch = 'main'; commit = [string]$repo.commit upstream = 'origin/main'; remoteUrl = $expectedRemote } package = [ordered]@{ name = [string]$package.name; familyName = [string]$package.familyName fullName = [string]$package.fullName; publisher = [string]$package.publisher version = [string]$package.version; architecture = 'X64' applicationId = [string]$package.applicationId; installLocation = $installLocation executable = New-FilePin $executablePath 'package executable' msix = New-FilePin $msixPath 'package MSIX' runtimeTree = Get-RuntimeTreePin $installLocation } automation = [ordered]@{ orchestrator = New-FilePin $orchestratorPath 'Gate orchestrator' node = New-FilePin ([string]$automation.nodePath) 'Node runtime' helper = New-FilePin $nodeHelperPath 'Node Gate helper' } configuration = [ordered]@{ original = New-FilePin $originalConfigPath 'original DryRun config' gate = New-FilePin $gateConfigPath 'Gate DryRun config' database = New-FilePin $approvedDatabaseConfigPath 'database config' sceneDirectory = $sceneDirectory } cut = [ordered]@{ sceneCode = '5001'; pageNumberOneBased = 1 file = New-FilePin $cutPath '5001 cut' } selector = [ordered]@{ marketText = $expectedMarketText; groupCode = 'KOSPI'; stockName = $expectedStockName stockCode = '005930'; cutLabel = $expectedCutLabel graphicType = $expectedGraphicType; subtype = $expectedSubtype normalizedSubtype = 'CURRENT'; sceneAlias = '5001' } k3d = [ordered]@{ native = New-FilePin ([string]$k3d.nativePath) 'K3D native binary' interop = New-FilePin ([string]$k3d.interopPath) 'K3D interop assembly' } pgm = [ordered]@{ processId = [int]$pgm.processId; startTimeUtc = [string]$pgm.startTimeUtc processName = [string]$pgm.processName executable = New-FilePin ([string]$pgm.executablePath) 'PGM executable' windowTitle = 'PGM'; listenerAddress = '0.0.0.0'; host = '127.0.0.1'; port = 30001 } webView = [ordered]@{ address = '127.0.0.1'; port = [int]$webView.port; url = $expectedUrl } approvalPolicy = [ordered]@{ requiresSeparateAuthorization = $true; maximumAgeSeconds = 900; maximumClockSkewSeconds = 5 } budgets = [ordered]@{ connect = 1; prepare = 1; takeIn = 0; next = 0; pageNext = 0 takeOut = 0; databaseWrite = 0; appClose = 0; retry = 0 } safety = [ordered]@{ automaticConnectOnly = $true; nodeHelperInvocationMaximum = 1 nodeHelperFailStopRequired = $true packageWrapperFailStopRequired = $true persistentGateConfigModeDryRun = $true; restoreOriginalBeforePrepare = $true nativeOneShotPrepareGateRequired = $true leaveApplicationAndSessionUntouched = $true; forceCloseForbidden = $true automaticSessionCleanupForbiddenAfterLaunch = $true; retryForbidden = $true } } $parent = [IO.Path]::GetDirectoryName($outputPath) if ([string]::IsNullOrWhiteSpace($parent)) { Fail 'OutputPath parent is invalid.' } [void][IO.Directory]::CreateDirectory($parent) Assert-NoReparse $parent 'OutputPath parent' $json = ($plan | ConvertTo-Json -Depth 12) + [Environment]::NewLine $bytes = $utf8NoBom.GetBytes($json) try { $stream = [IO.File]::Open($outputPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::Read) try { $stream.Write($bytes, 0, $bytes.Length); $stream.Flush($true) } finally { $stream.Dispose() } } finally { [Array]::Clear($bytes, 0, $bytes.Length) } [pscustomobject][ordered]@{ result = 'PLAN_CREATED_COMMAND_FREE' roundId = $roundId planPath = $outputPath planLength = (Get-Item -LiteralPath $outputPath).Length planSha256 = (Get-FileHash -LiteralPath $outputPath -Algorithm SHA256).Hash authorizationCreated = $false appCommandsIssued = 0 vendorCommandsIssued = 0 }