#Requires -Version 5.1 [CmdletBinding()] param( [Parameter(Mandatory = $true)] [ValidateSet('ValidatePlan', 'ExecuteAuthorizedGateA')] [string] $Action, [Parameter(Mandatory = $true)] [string] $PlanPath, [Parameter(Mandatory = $true)] [ValidatePattern('^[0-9A-Fa-f]{64}$')] [string] $ExpectedPlanSha256, [string] $AuthorizationPath, [ValidatePattern('^$|^[0-9A-Fa-f]{64}$')] [string] $ExpectedAuthorizationSha256, [string] $EvidencePath ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $utf8NoBom = [Text.UTF8Encoding]::new($false, $true) $repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $expectedRemote = 'https://gitea.comtropy.synology.me/Wickedness/MBN_STOCK_WEBVIEW.git' $expectedUrl = 'https://legacy-parity.mbn.local/index.html' $expectedPackageName = 'Wickedness.MBNStockWebView.LegacyParity' $expectedPackageFamily = 'Wickedness.MBNStockWebView.LegacyParity_qbv3jkvsn3aj0' $expectedPackageFullName = 'Wickedness.MBNStockWebView.LegacyParity_0.1.0.0_x64__qbv3jkvsn3aj0' $expectedExecutableName = 'MBN_STOCK_WEBVIEW.LegacyParityApp.exe' $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' $userProfilePath = [Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile) $approvedNodePath = [IO.Path]::GetFullPath((Join-Path $userProfilePath '.cache\codex-runtimes\codex-primary-runtime\dependencies\node\bin\node.exe')) $expectedWindowTitle = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('Vi1TdG9jayDspp3qtozsoJXrs7TshqHstpzsi5zsiqTthZwgZm9yIOunpOydvOqyveygnFRWICgyNi4wMy4yNik=')) $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')) $liveAuthorizationName = 'MBN_STOCK_PLAYOUT_AUTHORIZE_LIVE_OUTPUT' $liveAuthorizationValue = 'I_AUTHORIZE_LIVE_PROGRAM_OUTPUT_FOR_THIS_LAUNCH' $capabilityName = 'MBN_LEGACY_PGM_GATE_A_PREPARE_CAPABILITY' $pgmProcessIdName = 'MBN_LEGACY_PGM_GATE_A_PGM_PID' $pgmStartTimeUtcTicksName = 'MBN_LEGACY_PGM_GATE_A_PGM_START_TIME_UTC_TICKS' $gateExpiresAtUtcTicksName = 'MBN_LEGACY_PGM_GATE_A_EXPIRES_AT_UTC_TICKS' # This stays false until the native bridge independently consumes and enforces the # one-shot capability. Changing it requires a clean pushed commit and a newly sealed plan. $nativeOneShotPrepareGateReady = $false $script:application = $null $script:launchAttempted = $false $script:applicationLaunched = $false $script:gateConfigInstalled = $false $script:configRestored = $false $script:configRestoreAttempts = 0 $script:nodeInvocations = 0 $script:nodeExitCode = $null $script:nodeStillRunning = $false $script:phase = 'plan-validation' $script:status = 'NOT_STARTED' $script:failure = $null $script:backupPath = $null $script:nodeEvidencePath = $null $script:screenshotPath = $null $script:plan = $null $script:authorization = $null $script:planHash = $ExpectedPlanSha256.ToUpperInvariant() $script:authorizationHash = $null $script:gateCapability = $null $script:gateCapabilitySha256 = $null $script:gateCapabilityGenerations = 0 $script:nodePermitRequests = 0 $script:nodePermitDeliveries = 0 $script:nodePermitClientProcessId = $null $script:packagePermitRequests = 0 $script:packagePermitDeliveries = 0 $script:packagePermitClientProcessId = $null $script:authorizationExpiresAtUtcTicks = $null $script:startedAtUtc = [DateTime]::UtcNow $script:fileLeases = New-Object 'Collections.Generic.List[IO.FileStream]' if ($null -eq ('LegacyPgmPreparePackageIdentity' -as [type])) { Add-Type -TypeDefinition @' using System; using Microsoft.Win32.SafeHandles; using System.Runtime.InteropServices; using System.Text; public static class LegacyPgmPreparePackageIdentity { [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] private static extern int GetPackageFullName(IntPtr process, ref int length, StringBuilder value); [DllImport("kernel32.dll", SetLastError = true)] private static extern bool GetNamedPipeClientProcessId(IntPtr pipe, out uint clientProcessId); public static string Read(IntPtr process) { int length = 0; if (GetPackageFullName(process, ref length, null) != 122 || length < 2 || length > 4096) throw new InvalidOperationException("Package identity is unavailable."); var value = new StringBuilder(length); if (GetPackageFullName(process, ref length, value) != 0 || value.Length == 0) throw new InvalidOperationException("Package identity could not be read."); return value.ToString(); } public static int ReadNamedPipeClientProcessId(SafePipeHandle pipe) { if (pipe == null || pipe.IsInvalid || pipe.IsClosed) throw new InvalidOperationException("Named pipe handle is unavailable."); uint processId; if (!GetNamedPipeClientProcessId(pipe.DangerousGetHandle(), out processId) || processId == 0 || processId > Int32.MaxValue) throw new InvalidOperationException("Named pipe client identity is unavailable."); return (int)processId; } } '@ } function Fail([string] $Message) { throw [InvalidOperationException]::new($Message) } function New-CurrentUserOneShotPipe([string] $Purpose) { if ([string]::IsNullOrWhiteSpace($Purpose) -or $Purpose -cnotmatch '^[A-Za-z]+$') { Fail 'The one-shot pipe purpose is invalid.' } $identity = [Security.Principal.WindowsIdentity]::GetCurrent() if ($null -eq $identity.User) { Fail 'The current user SID is unavailable for pipe isolation.' } $security = [IO.Pipes.PipeSecurity]::new() $security.SetOwner($identity.User) $security.SetAccessRuleProtection($true, $false) $security.AddAccessRule([IO.Pipes.PipeAccessRule]::new( $identity.User, [IO.Pipes.PipeAccessRights]::ReadWrite, [Security.AccessControl.AccessControlType]::Allow)) $name = "MBN_STOCK_WEBVIEW.GateA.$Purpose.$(([Guid]::NewGuid().ToString('N')).ToUpperInvariant())" $options = [IO.Pipes.PipeOptions]::Asynchronous -bor [IO.Pipes.PipeOptions]::WriteThrough $stream = [IO.Pipes.NamedPipeServerStream]::new( $name, # Node's libuv named-pipe client opens a duplex handle. The protocol is # still parent-write-only; InOut is required only for Windows handle compatibility. [IO.Pipes.PipeDirection]::InOut, 1, [IO.Pipes.PipeTransmissionMode]::Byte, $options, 4096, 4096, $security) try { return [pscustomobject][ordered]@{ Name = $name Stream = $stream Connection = $stream.WaitForConnectionAsync() } } catch { $stream.Dispose() throw } } function Close-OneShotPipe([object] $Pipe) { if ($null -eq $Pipe) { return } try { $Pipe.Stream.Dispose() } catch { } } 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 Test-Within([string] $Child, [string] $Parent) { $childFull = [IO.Path]::GetFullPath($Child) $parentPrefix = [IO.Path]::GetFullPath($Parent).TrimEnd('\') + '\' return $childFull.StartsWith($parentPrefix, [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 Convert-Hash([byte[]] $Bytes) { $algorithm = [Security.Cryptography.SHA256]::Create() try { return ([BitConverter]::ToString($algorithm.ComputeHash($Bytes))).Replace('-', '') } finally { $algorithm.Dispose() } } function Read-SealedJson([string] $Path, [string] $ExpectedHash, [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 2MB) { Fail "$Name has an unsafe size." } if ((Convert-Hash $bytes) -cne $ExpectedHash.ToUpperInvariant()) { Fail "$Name raw-byte SHA-256 changed." } 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 { $value = $text | ConvertFrom-Json -ErrorAction Stop } catch { Fail "$Name is not valid JSON." } return [pscustomobject]@{ Path = $full; Length = $bytes.Length; Value = $value } } 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 Assert-FilePin([object] $Pin, [string] $Name) { Assert-ExactProperties $Pin @('path','length','sha256') $Name if ($Pin.path -isnot [string] -or $Pin.sha256 -isnot [string] -or ($Pin.length -isnot [int] -and $Pin.length -isnot [long])) { Fail "$Name pin JSON types are invalid." } $path = FullPath ([string]$Pin.path) "$Name.path" if (-not [IO.File]::Exists($path)) { Fail "$Name is missing." } Assert-NoReparse $path $Name $item = Get-Item -LiteralPath $path -Force if ([int64]$Pin.length -ne [int64]$item.Length -or [string]$Pin.sha256 -cnotmatch '^[0-9A-F]{64}$' -or (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash -cne [string]$Pin.sha256) { Fail "$Name pin changed." } return $path } function Add-ReadLease([string] $Path, [string] $Name) { try { $stream = [IO.File]::Open($Path,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read) [void]$script:fileLeases.Add($stream) } catch { Fail "$Name could not be frozen against write/delete for this Gate A run." } } function Get-RuntimeTreePin([string] $Root) { $fullRoot = FullPath $Root 'package.installLocation' Assert-NoReparse $fullRoot 'package.installLocation' $files = @([IO.Directory]::GetFiles($fullRoot, '*', [IO.SearchOption]::AllDirectories)) if ($files.Count -eq 0 -or $files.Count -gt 4096) { Fail 'The runtime tree count is unsafe.' } $rootPrefix = $fullRoot.TrimEnd('\') + '\' $names = @($files | ForEach-Object { Assert-NoReparse $_ 'runtime file' if (-not $_.StartsWith($rootPrefix, [StringComparison]::OrdinalIgnoreCase)) { Fail 'Runtime file escaped root.' } $name = $_.Substring($rootPrefix.Length).Replace('\','/') if ($name.Contains('|') -or $name.Contains("`r") -or $name.Contains("`n")) { Fail 'Unsafe runtime filename.' } $name }) [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 [void]$builder.Append($name).Append('|').Append(([int64]$item.Length).ToString([Globalization.CultureInfo]::InvariantCulture)).Append('|').Append((Get-FileHash $file -Algorithm SHA256).Hash).Append("`n") } $bytes = $utf8NoBom.GetBytes($builder.ToString()) try { return [pscustomobject]@{ fileCount = $names.Length; manifestSha256 = Convert-Hash $bytes } } finally { [Array]::Clear($bytes,0,$bytes.Length) } } function Add-RuntimeTreeReadLeases([string] $Root) { $fullRoot = FullPath $Root 'package.installLocation' Assert-NoReparse $fullRoot 'package.installLocation' $files = @([IO.Directory]::GetFiles($fullRoot, '*', [IO.SearchOption]::AllDirectories)) if ($files.Count -eq 0 -or $files.Count -gt 4096) { Fail 'The runtime tree lease count is unsafe.' } [Array]::Sort($files, [StringComparer]::OrdinalIgnoreCase) foreach ($file in $files) { Assert-NoReparse $file 'runtime file lease' Add-ReadLease $file 'runtime file' } } function Assert-ClosedBudgets([object] $Budgets, [string] $Context) { $names = @('connect','prepare','takeIn','next','pageNext','takeOut','databaseWrite','appClose','retry') Assert-ExactProperties $Budgets $names $Context $expected = [ordered]@{connect=1;prepare=1;takeIn=0;next=0;pageNext=0;takeOut=0;databaseWrite=0;appClose=0;retry=0} foreach ($name in $expected.Keys) { Assert-JsonInt $Budgets.$name ([int]$expected[$name]) "$Context.$name" } } function Assert-Plan([object] $Plan) { Assert-ExactProperties $Plan @( 'schemaVersion','kind','roundId','createdAtUtc','repository','package','automation', 'configuration','cut','selector','k3d','pgm','webView','approvalPolicy','budgets','safety') 'plan' Assert-JsonInt $Plan.schemaVersion 1 'plan.schemaVersion' Assert-JsonString $Plan.kind 'LegacyPgmPrepareGatePlan' 'plan.kind' if ($Plan.roundId -isnot [string] -or [string]$Plan.roundId -cnotmatch '^MBNWEB-[0-9]{8}-[A-Z0-9][A-Z0-9-]{0,31}$') { Fail 'Plan roundId is invalid.' } Assert-ExactProperties $Plan.repository @('path','branch','commit','upstream','remoteUrl') 'plan.repository' if (-not (SamePath ([string]$Plan.repository.path) $repositoryRoot) -or [string]$Plan.repository.branch -cne 'main' -or [string]$Plan.repository.upstream -cne 'origin/main' -or [string]$Plan.repository.remoteUrl -cne $expectedRemote -or [string]$Plan.repository.commit -cnotmatch '^[0-9a-f]{40}$') { Fail 'Plan repository pin is invalid.' } Assert-ExactProperties $Plan.package @( 'name','familyName','fullName','publisher','version','architecture','applicationId', 'installLocation','executable','msix','runtimeTree') 'plan.package' if ([string]$Plan.package.name -cne $expectedPackageName -or [string]$Plan.package.familyName -cne $expectedPackageFamily -or [string]$Plan.package.fullName -cne $expectedPackageFullName -or [string]$Plan.package.publisher -cne 'CN=Comtrophy' -or [string]$Plan.package.version -cne '0.1.0.0' -or [string]$Plan.package.architecture -cne 'X64' -or [string]$Plan.package.applicationId -cne 'LegacyParityApp') { Fail 'Plan package pin is invalid.' } $expectedInstallLocation = Join-Path $repositoryRoot 'src\MBN_STOCK_WEBVIEW.LegacyParityApp\bin\x64\Release\net8.0-windows10.0.19041.0\win-x64' if (-not (SamePath ([string]$Plan.package.installLocation) $expectedInstallLocation)) { Fail 'Plan package install location is not the exact Release x64 output.' } if (-not (SamePath ([string]$Plan.package.executable.path) (Join-Path $expectedInstallLocation $expectedExecutableName))) { Fail 'Plan package executable escaped the exact Release x64 install location.' } if (-not (SamePath ([string]$Plan.package.msix.path) $approvedMsixPath) -or [string]$Plan.package.msix.sha256 -cne $approvedMsixSha256) { Fail 'Plan MSIX is not the exact Release x64 AppPackages artifact.' } Assert-ExactProperties $Plan.automation @('orchestrator','node','helper') 'plan.automation' if (-not (SamePath ([string]$Plan.automation.node.path) $approvedNodePath) -or [IO.Path]::GetFileName([string]$Plan.automation.node.path) -cne 'node.exe') { Fail 'Plan Node runtime is not the approved canonical runtime.' } Assert-ExactProperties $Plan.configuration @('original','gate','database','sceneDirectory') 'plan.configuration' if (SamePath ([string]$Plan.configuration.original.path) ([string]$Plan.configuration.gate.path)) { Fail 'Plan original and Gate configuration paths must differ.' } if (-not (SamePath ([string]$Plan.configuration.database.path) $approvedDatabaseConfigPath)) { Fail 'Plan database configuration is not the exact local application configuration.' } Assert-ExactProperties $Plan.cut @('sceneCode','pageNumberOneBased','file') 'plan.cut' Assert-JsonString $Plan.cut.sceneCode '5001' 'plan.cut.sceneCode' Assert-JsonInt $Plan.cut.pageNumberOneBased 1 'plan.cut.pageNumberOneBased' if (-not [IO.Directory]::Exists([string]$Plan.configuration.sceneDirectory) -or -not (SamePath ([string]$Plan.configuration.sceneDirectory) $approvedSceneDirectory) -or -not (SamePath ([string]$Plan.cut.file.path) $approvedCutPath) -or [string]$Plan.cut.file.sha256 -cne $approvedCutSha256) { Fail 'Plan scene directory or 5001 cut path is not exact.' } Assert-ExactProperties $Plan.selector @( 'marketText','groupCode','stockName','stockCode','cutLabel','graphicType','subtype','normalizedSubtype','sceneAlias') 'plan.selector' $selectorExpected = [ordered]@{ marketText=$expectedMarketText;groupCode='KOSPI';stockName=$expectedStockName;stockCode='005930' cutLabel=$expectedCutLabel;graphicType=$expectedGraphicType;subtype=$expectedSubtype normalizedSubtype='CURRENT';sceneAlias='5001' } foreach ($name in $selectorExpected.Keys) { Assert-JsonString $Plan.selector.$name ([string]$selectorExpected[$name]) "plan.selector.$name" } Assert-ExactProperties $Plan.k3d @('native','interop') 'plan.k3d' Assert-ExactProperties $Plan.pgm @( 'processId','startTimeUtc','processName','executable','windowTitle','listenerAddress','host','port') 'plan.pgm' Assert-JsonInt $Plan.pgm.port 30001 'plan.pgm.port' Assert-JsonString $Plan.pgm.windowTitle 'PGM' 'plan.pgm.windowTitle' Assert-JsonString $Plan.pgm.listenerAddress '0.0.0.0' 'plan.pgm.listenerAddress' Assert-JsonString $Plan.pgm.host '127.0.0.1' 'plan.pgm.host' if ($Plan.pgm.processId -isnot [int] -or [int]$Plan.pgm.processId -le 0 -or $Plan.pgm.startTimeUtc -isnot [string] -or $Plan.pgm.processName -isnot [string]) { Fail 'Plan PGM pin is invalid.' } Assert-ExactProperties $Plan.webView @('address','port','url') 'plan.webView' Assert-JsonString $Plan.webView.address '127.0.0.1' 'plan.webView.address' Assert-JsonString $Plan.webView.url $expectedUrl 'plan.webView.url' if ($Plan.webView.port -isnot [int] -or [int]$Plan.webView.port -lt 1024 -or [int]$Plan.webView.port -gt 65535 -or [int]$Plan.webView.port -eq 30001) { Fail 'Plan WebView pin is invalid.' } Assert-ExactProperties $Plan.approvalPolicy @( 'requiresSeparateAuthorization','maximumAgeSeconds','maximumClockSkewSeconds') 'plan.approvalPolicy' Assert-JsonBool $Plan.approvalPolicy.requiresSeparateAuthorization $true 'plan.approvalPolicy.requiresSeparateAuthorization' Assert-JsonInt $Plan.approvalPolicy.maximumAgeSeconds 900 'plan.approvalPolicy.maximumAgeSeconds' Assert-JsonInt $Plan.approvalPolicy.maximumClockSkewSeconds 5 'plan.approvalPolicy.maximumClockSkewSeconds' Assert-ClosedBudgets $Plan.budgets 'plan.budgets' Assert-ExactProperties $Plan.safety @( 'automaticConnectOnly','nodeHelperInvocationMaximum','nodeHelperFailStopRequired', 'packageWrapperFailStopRequired', 'persistentGateConfigModeDryRun', 'restoreOriginalBeforePrepare','nativeOneShotPrepareGateRequired', 'leaveApplicationAndSessionUntouched','forceCloseForbidden', 'automaticSessionCleanupForbiddenAfterLaunch','retryForbidden') 'plan.safety' Assert-JsonInt $Plan.safety.nodeHelperInvocationMaximum 1 'plan.safety.nodeHelperInvocationMaximum' foreach ($property in @($Plan.safety.PSObject.Properties | Where-Object { [string]$_.Name -cne 'nodeHelperInvocationMaximum' })) { Assert-JsonBool $property.Value $true "plan.safety.$($property.Name)" } } function Parse-CanonicalUtc([string] $Text, [string] $Name) { $value = [DateTimeOffset]::MinValue if (-not [DateTimeOffset]::TryParseExact( $Text, "yyyy-MM-dd'T'HH:mm:ss.fff'Z'", [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::AssumeUniversal, [ref]$value)) { Fail "$Name is not canonical UTC." } return $value.ToUniversalTime() } function Assert-OriginalConfigDryRun([object] $Pin) { $capture = Read-SealedJson ([string]$Pin.path) ([string]$Pin.sha256) 'original config' $modeProperties = @($capture.Value.PSObject.Properties | Where-Object { [string]::Equals($_.Name, 'mode', [StringComparison]::OrdinalIgnoreCase) }) if ($modeProperties.Count -ne 1 -or -not [string]::Equals([string]$modeProperties[0].Value, 'DryRun', [StringComparison]::Ordinal)) { Fail 'The original persistent playout configuration is not uniquely DryRun.' } } function Assert-GateConfigSafety([object] $Configuration) { $capture = Read-SealedJson ([string]$Configuration.gate.path) ([string]$Configuration.gate.sha256) 'Gate config' $config = $capture.Value $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 'Gate config JSON' if ($config.testSceneAllowlist -isnot [Array]) { Fail 'Gate config.testSceneAllowlist must be a JSON array.' } $allowlist = @($config.testSceneAllowlist) Assert-JsonString $config.mode 'DryRun' 'Gate config.mode' Assert-JsonString $config.host '127.0.0.1' 'Gate config.host' Assert-JsonInt $config.port 30001 'Gate config.port' Assert-JsonInt $config.tcpMode 1 'Gate config.tcpMode' Assert-JsonInt $config.clientPort 0 'Gate config.clientPort' Assert-JsonInt $config.layoutIndex 10 'Gate config.layoutIndex' Assert-JsonInt $config.legacySceneFadeDuration 6 'Gate config.legacySceneFadeDuration' Assert-JsonString $config.legacySceneBackgroundKind 'None' 'Gate config.legacySceneBackgroundKind' Assert-JsonInt $config.legacySceneBackgroundVideoLoopCount 2004 'Gate config.legacySceneBackgroundVideoLoopCount' Assert-JsonBool $config.legacySceneBackgroundVideoLoopInfinite $true 'Gate config.legacySceneBackgroundVideoLoopInfinite' Assert-JsonBool $config.trustedLiveOutputEnabled $true 'Gate config.trustedLiveOutputEnabled' Assert-JsonInt $config.queueCapacity 64 'Gate config.queueCapacity' Assert-JsonInt $config.connectTimeoutMilliseconds 5000 'Gate config.connectTimeoutMilliseconds' Assert-JsonInt $config.operationTimeoutMilliseconds 5000 'Gate config.operationTimeoutMilliseconds' Assert-JsonInt $config.disconnectTimeoutMilliseconds 3000 'Gate config.disconnectTimeoutMilliseconds' Assert-JsonInt $config.processPollIntervalMilliseconds 1000 'Gate config.processPollIntervalMilliseconds' Assert-JsonInt $config.reconnectDelayMilliseconds 1000 'Gate config.reconnectDelayMilliseconds' Assert-JsonInt $config.maximumReconnectAttempts 0 'Gate config.maximumReconnectAttempts' Assert-JsonBool $config.reconnectEnabled $false 'Gate config.reconnectEnabled' Assert-JsonInt $config.maximumAutomaticRefreshesPerTakeIn 0 'Gate config.maximumAutomaticRefreshesPerTakeIn' if ($null -ne $config.outputChannel -or $null -ne $config.legacySceneBackgroundAssetPath -or $null -ne $config.legacyBackgroundDirectory -or $null -ne $config.testProcessWindowTitlePattern -or -not (SamePath ([string]$config.sceneDirectory) ([string]$Configuration.sceneDirectory)) -or $allowlist.Count -ne 1 -or [string]$allowlist[0] -cne '5001' -or $allowlist[0] -isnot [string]) { Fail 'The Gate config is not persistent DryRun with the closed 5001-only safety settings.' } } function Assert-Authorization([object] $Authorization, [object] $Plan) { Assert-ExactProperties $Authorization @( 'schemaVersion','kind','roundId','planSha256','authorizedAtUtc','expiresAtUtc', 'authorizationStatement','currentK3dLicenseValid', 'runningTornadoIsLicensedMainProgram','currentPgmApproved','budgets') 'authorization' Assert-JsonInt $Authorization.schemaVersion 1 'authorization.schemaVersion' Assert-JsonString $Authorization.kind 'LegacyPgmPrepareGateAuthorization' 'authorization.kind' Assert-JsonString $Authorization.roundId ([string]$Plan.roundId) 'authorization.roundId' Assert-JsonString $Authorization.planSha256 $script:planHash 'authorization.planSha256' Assert-JsonString $Authorization.authorizationStatement 'I_AUTHORIZE_CURRENT_PGM_LIVE_CONNECT_ONCE_AND_PREPARE_5001_PAGE_1_ONCE' 'authorization.authorizationStatement' Assert-JsonBool $Authorization.currentK3dLicenseValid $true 'authorization.currentK3dLicenseValid' Assert-JsonBool $Authorization.runningTornadoIsLicensedMainProgram $true 'authorization.runningTornadoIsLicensedMainProgram' Assert-JsonBool $Authorization.currentPgmApproved $true 'authorization.currentPgmApproved' if ($Authorization.authorizedAtUtc -isnot [string] -or $Authorization.expiresAtUtc -isnot [string]) { Fail 'Authorization UTC fields must be JSON strings.' } Assert-ClosedBudgets $Authorization.budgets 'authorization.budgets' $authorized = Parse-CanonicalUtc ([string]$Authorization.authorizedAtUtc) 'authorization.authorizedAtUtc' $expires = Parse-CanonicalUtc ([string]$Authorization.expiresAtUtc) 'authorization.expiresAtUtc' $now = [DateTimeOffset]::UtcNow if ($expires -le $authorized -or ($expires - $authorized).TotalSeconds -gt 900 -or $now -lt $authorized.AddSeconds(-5) -or $now -ge $expires.AddSeconds(-5)) { Fail 'Gate A authorization is not currently unexpired.' } return $expires } function Invoke-Git([string[]] $Arguments) { $output = & git -C $repositoryRoot @Arguments 2>&1 if ($LASTEXITCODE -ne 0) { Fail "Git read failed: $($Arguments -join ' ')." } return (($output | ForEach-Object { [string]$_ }) -join "`n").Trim() } function Assert-CleanPushedCommit([object] $Repository) { if ((Invoke-Git @('status','--porcelain=v1','--untracked-files=all')).Length -ne 0 -or (Invoke-Git @('rev-parse','HEAD')) -cne [string]$Repository.commit -or (Invoke-Git @('symbolic-ref','--short','HEAD')) -cne 'main' -or (Invoke-Git @('rev-parse','@{upstream}')) -cne [string]$Repository.commit -or (Invoke-Git @('remote','get-url','origin')) -cne $expectedRemote -or (Invoke-Git @('rev-list','--left-right','--count','HEAD...@{upstream}')) -cne "0`t0") { Fail 'The repository is not the exact clean pushed main commit.' } } function Assert-NoUnsafeEnvironment { foreach ($scope in @([EnvironmentVariableTarget]::Process,[EnvironmentVariableTarget]::User,[EnvironmentVariableTarget]::Machine)) { foreach ($nameObject in [Environment]::GetEnvironmentVariables($scope).Keys) { $name = [string]$nameObject if ($name -like 'MBN_STOCK_PLAYOUT_*' -or $name -like 'MBN_STOCK_K3D_*' -or $name -like 'WEBVIEW2_*' -or $name -like 'COREWEBVIEW2_*' -or $name -like 'DOTNET_*' -or $name -like 'CORECLR_*' -or $name -like 'COR_*' -or $name -like 'COMPlus_*' -or $name -like 'MBN_STOCK_ORACLE_*' -or $name -like 'MBN_STOCK_MARIADB_*' -or $name -like 'MBN_STOCK_DB_*' -or $name -in @($capabilityName,$pgmProcessIdName,$pgmStartTimeUtcTicksName,$gateExpiresAtUtcTicksName)) { Fail "A reserved launch environment variable is already present: $scope/$name." } } } } function Assert-Pgm([object] $Pgm) { $process = Get-Process -Id ([int]$Pgm.processId) -ErrorAction Stop $process.Refresh() $start = [DateTime]::ParseExact([string]$Pgm.startTimeUtc, "yyyy-MM-dd'T'HH:mm:ss.fffffff'Z'", [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::AssumeUniversal).ToUniversalTime() $exe = FullPath ([string]$Pgm.executable.path) 'PGM executable' if ($process.HasExited -or [string]$process.ProcessName -cne [string]$Pgm.processName -or -not (SamePath $process.Path $exe) -or $process.StartTime.ToUniversalTime().Ticks -ne $start.Ticks -or [string]$process.MainWindowTitle -cne 'PGM') { Fail 'The current PGM process identity changed.' } $listeners = @(Get-NetTCPConnection -State Listen -LocalPort 30001 -ErrorAction Stop) if ($listeners.Count -ne 1 -or [int]$listeners[0].OwningProcess -ne $process.Id -or [string]$listeners[0].LocalAddress -cne '0.0.0.0') { Fail 'The current PGM listener identity changed.' } $tornadoProcesses = @(Get-Process -ErrorAction Stop | Where-Object { [string]$_.ProcessName -like 'Tornado2*' }) if ($tornadoProcesses.Count -ne 1 -or $tornadoProcesses[0].Id -ne $process.Id) { Fail 'The Tornado process set is not the single frozen current PGM.' } } function Assert-K3dRegistration([object] $K3d) { $inspector = Join-Path $PSScriptRoot 'Inspect-K3DRegistration.ps1' if (-not [IO.File]::Exists($inspector)) { Fail 'The read-only K3D registration inspector is unavailable.' } $reports = @(& $inspector) if ($reports.Count -ne 1) { Fail 'The K3D registration inspection result is not unique.' } $report = $reports[0] if ([string]$report.Status -cne 'Valid' -or [string]$report.RegistryHive -cne 'HKLM' -or [string]$report.RegistryView -cne 'Registry64' -or [string]$report.CurrentUserOverrides -cne 'None' -or $report.ComActivated -ne $false -or -not (SamePath ([string]$report.TypeLibPath) ([string]$K3d.native.path))) { Fail 'The exact read-only K3D x64 registration pin changed.' } $classes = @($report.Classes) if ($classes.Count -ne 2 -or @($classes | Where-Object { -not (SamePath ([string]$_.ServerPath) ([string]$K3d.native.path)) -or [string]$_.ServerMachine -cne 'AMD64' -or [string]$_.ThreadingModel -cne 'Apartment' -or $_.ReciprocalMapping -ne $true }).Count -ne 0) { Fail 'The K3D COM class registration pin changed.' } $nativeDirectory = [IO.Directory]::GetParent([string]$K3d.native.path) $vendorRoot = $nativeDirectory.Parent.Parent.Parent if ($null -eq $vendorRoot -or -not (SamePath (Join-Path $vendorRoot.FullName 'Bin\x64\C#\Interop.K3DAsyncEngineLib.dll') ([string]$K3d.interop.path))) { Fail 'The K3D interop assembly is not the exact registered vendor sibling.' } } function Assert-ExactPackageRegistration([object] $Package) { $packages = @(Get-AppxPackage -Name $expectedPackageName -ErrorAction Stop) if ($packages.Count -ne 1 -or -not $packages[0].IsDevelopmentMode -or [string]$packages[0].SignatureKind -cne 'None' -or [string]$packages[0].Status -cne 'Ok' -or [string]$packages[0].PackageFullName -cne $expectedPackageFullName -or [string]$packages[0].PackageFamilyName -cne $expectedPackageFamily -or [string]$packages[0].Publisher -cne 'CN=Comtrophy' -or [string]$packages[0].Version -cne '0.1.0.0' -or [string]$packages[0].Architecture -cne 'X64' -or -not (SamePath ([string]$packages[0].InstallLocation) ([string]$Package.installLocation))) { Fail 'Exact Release x64 development package registration is absent.' } } function Assert-ReadOnlyPlanEnvironment([object] $Plan) { Assert-CleanPushedCommit $Plan.repository Assert-NoUnsafeEnvironment if (-not (SamePath (Assert-FilePin $Plan.automation.orchestrator 'Gate orchestrator') $PSCommandPath) -or -not (SamePath (Assert-FilePin $Plan.automation.helper 'Node helper') (Join-Path $PSScriptRoot 'Test-LegacyPgmPrepareGate.mjs'))) { Fail 'Automation file identity changed.' } [void](Assert-FilePin $Plan.automation.node 'Node runtime') [void](Assert-FilePin $Plan.package.msix 'Release x64 MSIX') [void](Assert-FilePin $Plan.package.executable 'package executable') $tree = Get-RuntimeTreePin ([string]$Plan.package.installLocation) if ($tree.fileCount -ne [int]$Plan.package.runtimeTree.fileCount -or $tree.manifestSha256 -cne [string]$Plan.package.runtimeTree.manifestSha256) { Fail 'Package runtime tree changed.' } [void](Assert-FilePin $Plan.configuration.original 'original config') Assert-OriginalConfigDryRun $Plan.configuration.original [void](Assert-FilePin $Plan.configuration.gate 'Gate config') Assert-GateConfigSafety $Plan.configuration [void](Assert-FilePin $Plan.configuration.database 'database config') [void](Assert-FilePin $Plan.cut.file '5001 cut') [void](Assert-FilePin $Plan.k3d.native 'K3D native binary') [void](Assert-FilePin $Plan.k3d.interop 'K3D interop assembly') [void](Assert-FilePin $Plan.pgm.executable 'PGM executable') Assert-NoReparse ([string]$Plan.configuration.sceneDirectory) 'scene directory' Assert-K3dRegistration $Plan.k3d Assert-Pgm $Plan.pgm Assert-ExactPackageRegistration $Plan.package if (@(Get-Process -Name ([IO.Path]::GetFileNameWithoutExtension($expectedExecutableName)) -ErrorAction SilentlyContinue).Count -ne 0 -or @(Get-NetTCPConnection -State Listen -LocalPort ([int]$Plan.webView.port) -ErrorAction SilentlyContinue).Count -ne 0) { Fail 'An existing application or CDP endpoint is present.' } return [pscustomobject][ordered]@{ filePinsValidated = 12 runtimeTreeValidated = $true packageRegistrationValidated = $true currentPgmValidated = $true k3dRegistrationValidatedReadOnly = $true k3dComActivated = $false } } function Assert-Application([Diagnostics.Process] $Process, [string] $Executable, [bool] $RequireReadyWindow = $true) { $Process.Refresh() if ($Process.HasExited -or -not (SamePath $Process.Path $Executable) -or [LegacyPgmPreparePackageIdentity]::Read($Process.Handle) -cne $expectedPackageFullName) { Fail 'The package application identity changed.' } if ($RequireReadyWindow) { if ($Process.MainWindowHandle -eq [IntPtr]::Zero -or [string]$Process.MainWindowTitle -cne $expectedWindowTitle) { Fail 'The package application window is not ready or changed identity.' } } } function Test-DescendsFrom([int] $ProcessId, [int] $AncestorId) { $seen = [Collections.Generic.HashSet[int]]::new(); $current = $ProcessId while ($current -gt 0 -and $seen.Add($current)) { if ($current -eq $AncestorId) { return $true } $row = Get-CimInstance Win32_Process -Filter "ProcessId = $current" -ErrorAction SilentlyContinue if ($null -eq $row) { return $false }; $current = [int]$row.ParentProcessId } return $false } function Assert-Cdp([int] $Port, [int] $AppPid) { $listeners = @(Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction Stop) if ($listeners.Count -ne 1 -or [string]$listeners[0].LocalAddress -cne '127.0.0.1') { Fail 'The exact IPv4 loopback CDP listener is unavailable.' } $owner = Get-Process -Id ([int]$listeners[0].OwningProcess) -ErrorAction Stop if ([IO.Path]::GetFileName($owner.Path) -cne 'msedgewebview2.exe' -or -not (Test-DescendsFrom $owner.Id $AppPid)) { Fail 'The CDP listener is not owned by the exact app WebView child.' } } function Wait-ExactTarget([int] $Port, [int] $AppPid) { $deadline = [DateTime]::UtcNow.AddSeconds(30) while ([DateTime]::UtcNow -lt $deadline) { Assert-Application $script:application ([string]$script:plan.package.executable.path) Assert-Pgm $script:plan.pgm try { $readinessListeners = @(Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue) if ($readinessListeners.Count -eq 0) { Start-Sleep -Milliseconds 250 continue } Assert-Cdp $Port $AppPid $client = [Net.WebClient]::new() try { $client.Proxy = $null $text = $client.DownloadString("http://127.0.0.1:$Port/json/list") $targets = @($text | ConvertFrom-Json -ErrorAction Stop) } finally { $client.Dispose() } $exact = @($targets | Where-Object { [string]$_.type -ceq 'page' -and [string]$_.url -ceq $expectedUrl }) if ($exact.Count -gt 1) { Fail 'More than one exact WebView target exists.' } if ($exact.Count -eq 1) { $uri = [Uri]([string]$exact[0].webSocketDebuggerUrl) if ($uri.Scheme -cne 'ws' -or $uri.Host -cne '127.0.0.1' -or $uri.Port -ne $Port -or -not $uri.AbsolutePath.StartsWith('/devtools/page/', [StringComparison]::Ordinal)) { Fail 'The exact target WebSocket URL is unsafe.' } return } } catch [InvalidOperationException] { throw } catch { } Start-Sleep -Milliseconds 250 } Fail 'The exact WebView target did not appear before timeout.' } function Write-BytesExclusive([string] $Path, [byte[]] $Bytes) { $stream = [IO.File]::Open($Path,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::Read) try { $stream.Write($Bytes,0,$Bytes.Length); $stream.Flush($true) } finally { $stream.Dispose() } } function New-GateCapability { $script:gateCapabilityGenerations++ if ($script:gateCapabilityGenerations -ne 1) { Fail 'The native Gate A capability generation budget was exceeded.' } $bytes = New-Object byte[] 32 $rng = [Security.Cryptography.RandomNumberGenerator]::Create() try { $rng.GetBytes($bytes) return ([BitConverter]::ToString($bytes)).Replace('-','') } finally { $rng.Dispose() [Array]::Clear($bytes,0,$bytes.Length) } } function Install-GateConfiguration([object] $Configuration, [string] $RoundId) { $original = Assert-FilePin $Configuration.original 'original config' $gate = Assert-FilePin $Configuration.gate 'Gate config' if (-not (SamePath $original (Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) 'MBN_STOCK_WEBVIEW\Config\playout.local.json'))) { Fail 'The original config is not the exact application config path.' } $script:backupPath = Join-Path ([IO.Path]::GetDirectoryName($original)) ("playout.local.$RoundId.backup") $temporary = $original + '.' + [Guid]::NewGuid().ToString('N') + '.gate.tmp' if ([IO.File]::Exists($script:backupPath) -or [IO.File]::Exists($temporary)) { Fail 'A stale Gate config artifact exists.' } $bytes = [IO.File]::ReadAllBytes($gate) try { Write-BytesExclusive $temporary $bytes } finally { [Array]::Clear($bytes,0,$bytes.Length) } try { [IO.File]::Replace($temporary,$original,$script:backupPath,$true) $script:gateConfigInstalled = $true } finally { if ([IO.File]::Exists($temporary)) { [IO.File]::Delete($temporary) } } if ((Get-FileHash $original -Algorithm SHA256).Hash -cne [string]$Configuration.gate.sha256 -or (Get-FileHash $script:backupPath -Algorithm SHA256).Hash -cne [string]$Configuration.original.sha256) { Fail 'Atomic Gate config installation could not be verified.' } } function Restore-OriginalConfiguration([object] $Configuration) { if (-not $script:gateConfigInstalled -or $script:configRestored) { return } $script:configRestoreAttempts++ if ($script:configRestoreAttempts -ne 1) { Fail 'The exact original config restore one-shot budget was exceeded.' } $original = [string]$Configuration.original.path if (-not [IO.File]::Exists($script:backupPath) -or (Get-FileHash $script:backupPath -Algorithm SHA256).Hash -cne [string]$Configuration.original.sha256 -or (Get-FileHash $original -Algorithm SHA256).Hash -cne [string]$Configuration.gate.sha256) { Fail 'The exact config restore precondition changed.' } [IO.File]::Replace($script:backupPath,$original,$null,$true) if ((Get-FileHash $original -Algorithm SHA256).Hash -cne [string]$Configuration.original.sha256 -or [IO.File]::Exists($script:backupPath)) { Fail 'The original config restore is not exact.' } $script:configRestored = $true } function Assert-PackagePermitClient( [object] $Pipe, [DateTime] $NotBeforeUtc, [string] $ExpectedExecutable) { $clientProcessId = [LegacyPgmPreparePackageIdentity]::ReadNamedPipeClientProcessId( $Pipe.Stream.SafePipeHandle) $process = Get-Process -Id $clientProcessId -ErrorAction Stop $process.Refresh() if ($process.HasExited -or -not (SamePath $process.Path $ExpectedExecutable) -or $process.StartTime.ToUniversalTime() -lt $NotBeforeUtc.AddSeconds(-1) -or [LegacyPgmPreparePackageIdentity]::Read($process.Handle) -cne $expectedPackageFullName) { Fail 'The package launch permit pipe client identity is not exact.' } $script:packagePermitClientProcessId = $clientProcessId return $process } function Stop-PackageWrapperFailStop([Diagnostics.Process] $Process) { # This stops only the short-lived package-context control wrapper. It never # closes, terminates, disconnects, or commands the app, PGM, Tornado, or K3D. if ($null -eq $Process) { return } $exitObserved = $false try { $exitObserved = [bool]$Process.WaitForExit(0) } catch { } if (-not $exitObserved) { try { $Process.Kill() } catch { try { $exitObserved = [bool]$Process.WaitForExit(0) } catch { } if (-not $exitObserved) { Fail 'OUTCOME_UNKNOWN: The package-context wrapper could not be fail-stopped or proven exited.' } } if (-not $exitObserved) { try { $exitObserved = [bool]$Process.WaitForExit(5000) } catch { } } } if (-not $exitObserved) { Fail 'OUTCOME_UNKNOWN: The package-context wrapper was not proven stopped.' } } function Start-Package([object] $Plan) { $command = Get-Command Invoke-CommandInDesktopPackage -ErrorAction SilentlyContinue if ($null -eq $command -or [string]$command.Source -cne 'Appx') { Fail 'The Appx package-context launch helper is required.' } if ([string]$script:gateCapability -cnotmatch '^[0-9A-F]{64}$') { Fail 'The native Gate A capability is absent before package launch.' } if ($script:authorizationExpiresAtUtcTicks -isnot [long] -or [long]$script:authorizationExpiresAtUtcTicks -le [DateTime]::UtcNow.Ticks) { Fail 'The native Gate A authorization expiry is absent or expired before package launch.' } $pgmStartUtc = [DateTime]::ParseExact( [string]$Plan.pgm.startTimeUtc, "yyyy-MM-dd'T'HH:mm:ss.fffffff'Z'", [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::AssumeUniversal).ToUniversalTime() $environment = [ordered]@{ MBN_STOCK_PLAYOUT_MODE='Live'; MBN_STOCK_PLAYOUT_HOST='127.0.0.1'; MBN_STOCK_PLAYOUT_PORT='30001' MBN_STOCK_PLAYOUT_TCP_MODE='1'; MBN_STOCK_PLAYOUT_CLIENT_PORT='0'; MBN_STOCK_PLAYOUT_LAYOUT_INDEX='10' MBN_STOCK_PLAYOUT_LEGACY_FADE_DURATION='6'; MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_KIND='None' MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_VIDEO_LOOP_COUNT='2004'; MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_VIDEO_LOOP_INFINITE='true' MBN_STOCK_PLAYOUT_SCENE_DIRECTORY=[string]$Plan.configuration.sceneDirectory MBN_STOCK_PLAYOUT_QUEUE_CAPACITY='64'; MBN_STOCK_PLAYOUT_CONNECT_TIMEOUT_MS='5000' MBN_STOCK_PLAYOUT_OPERATION_TIMEOUT_MS='5000'; MBN_STOCK_PLAYOUT_DISCONNECT_TIMEOUT_MS='3000' MBN_STOCK_PLAYOUT_PROCESS_POLL_INTERVAL_MS='1000'; MBN_STOCK_PLAYOUT_RECONNECT_DELAY_MS='1000' MBN_STOCK_PLAYOUT_MAXIMUM_RECONNECT_ATTEMPTS='0'; MBN_STOCK_PLAYOUT_RECONNECT_ENABLED='false' MBN_STOCK_K3D_NATIVE_SHA256=[string]$Plan.k3d.native.sha256 MBN_STOCK_K3D_INTEROP_SHA256=[string]$Plan.k3d.interop.sha256 $liveAuthorizationName=$liveAuthorizationValue $capabilityName=[string]$script:gateCapability $pgmProcessIdName=([int]$Plan.pgm.processId).ToString([Globalization.CultureInfo]::InvariantCulture) $pgmStartTimeUtcTicksName=$pgmStartUtc.Ticks.ToString([Globalization.CultureInfo]::InvariantCulture) $gateExpiresAtUtcTicksName=([long]$script:authorizationExpiresAtUtcTicks).ToString([Globalization.CultureInfo]::InvariantCulture) WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS="--remote-debugging-address=127.0.0.1 --remote-debugging-port=$([int]$Plan.webView.port)" } $encodedExe = [Convert]::ToBase64String($utf8NoBom.GetBytes([string]$Plan.package.executable.path)) $encodedDir = [Convert]::ToBase64String($utf8NoBom.GetBytes([string]$Plan.package.installLocation)) $launchPipe = New-CurrentUserOneShotPipe 'Package' $pipeName = [string]$launchPipe.Name $child = @" `$exe=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('$encodedExe')) `$dir=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('$encodedDir')) `$client=[IO.Pipes.NamedPipeClientStream]::new('.', '$pipeName', [IO.Pipes.PipeDirection]::In, [IO.Pipes.PipeOptions]::None) try { `$client.Connect(15000) `$lengthBytes=New-Object byte[] 4 `$offset=0 while(`$offset -lt 4){`$read=`$client.Read(`$lengthBytes,`$offset,4-`$offset);if(`$read -le 0){exit 92};`$offset+=`$read} `$length=[BitConverter]::ToInt32(`$lengthBytes,0) if(`$length -le 0 -or `$length -gt 65536){exit 93} `$payload=New-Object byte[] `$length `$offset=0 while(`$offset -lt `$length){`$read=`$client.Read(`$payload,`$offset,`$length-`$offset);if(`$read -le 0){exit 94};`$offset+=`$read} if(`$client.ReadByte() -ne -1){exit 95} `$strictUtf8=New-Object Text.UTF8Encoding(`$false,`$true) `$map=`$strictUtf8.GetString(`$payload)|ConvertFrom-Json -ErrorAction Stop } finally { if(`$null-ne`$client){`$client.Dispose()} } `$start=New-Object Diagnostics.ProcessStartInfo `$start.FileName=`$exe;`$start.WorkingDirectory=`$dir;`$start.UseShellExecute=`$false foreach(`$name in @(`$start.EnvironmentVariables.Keys)){if(`$name -like 'MBN_STOCK_PLAYOUT_*' -or `$name -like 'MBN_STOCK_K3D_*' -or `$name -like 'MBN_LEGACY_PGM_GATE_A_*' -or `$name -like 'MBN_STOCK_ORACLE_*' -or `$name -like 'MBN_STOCK_MARIADB_*' -or `$name -like 'MBN_STOCK_DB_*' -or `$name -like 'WEBVIEW2_*' -or `$name -like 'COREWEBVIEW2_*' -or `$name -like 'DOTNET_*' -or `$name -like 'CORECLR_*' -or `$name -like 'COR_*' -or `$name -like 'COMPlus_*'){`$start.EnvironmentVariables.Remove(`$name)}} foreach(`$property in `$map.PSObject.Properties){`$start.EnvironmentVariables[`$property.Name]=[string]`$property.Value} try { if(`$null -eq [Diagnostics.Process]::Start(`$start)){exit 91} } finally { foreach(`$name in @(`$start.EnvironmentVariables.Keys)){if(`$name -like 'MBN_STOCK_PLAYOUT_*' -or `$name -like 'MBN_STOCK_K3D_*' -or `$name -like 'MBN_LEGACY_PGM_GATE_A_*' -or `$name -like 'MBN_STOCK_ORACLE_*' -or `$name -like 'MBN_STOCK_MARIADB_*' -or `$name -like 'MBN_STOCK_DB_*' -or `$name -like 'WEBVIEW2_*' -or `$name -like 'COREWEBVIEW2_*' -or `$name -like 'DOTNET_*' -or `$name -like 'CORECLR_*' -or `$name -like 'COR_*' -or `$name -like 'COMPlus_*'){`$start.EnvironmentVariables.Remove(`$name)}} if(`$null-ne`$payload){[Array]::Clear(`$payload,0,`$payload.Length)} if(`$null-ne`$lengthBytes){[Array]::Clear(`$lengthBytes,0,`$lengthBytes.Length)} } "@ $encodedChild = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($child)) $notBefore = [DateTime]::UtcNow $script:launchAttempted = $true $wrapperExecutable = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' $launcher = [Management.Automation.PowerShell]::Create() $wrapper = $null $async = $null try { [void]$launcher.AddCommand('Import-Module') [void]$launcher.AddParameter('Name','Appx') [void]$launcher.AddParameter('ErrorAction','Stop') [void]$launcher.AddStatement() [void]$launcher.AddCommand('Invoke-CommandInDesktopPackage') [void]$launcher.AddParameter('PackageFamilyName',$expectedPackageFamily) [void]$launcher.AddParameter('AppId','LegacyParityApp') [void]$launcher.AddParameter('Command',$wrapperExecutable) [void]$launcher.AddParameter('Args',"-NoLogo -NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand $encodedChild") [void]$launcher.AddParameter('PreventBreakaway') [void]$launcher.AddParameter('ErrorAction','Stop') $async = $launcher.BeginInvoke() $pipeDeadline = [DateTime]::UtcNow.AddSeconds(20) while (-not $launchPipe.Connection.IsCompleted -and [DateTime]::UtcNow -lt $pipeDeadline) { if ($async.IsCompleted) { [void]$launcher.EndInvoke($async) Fail 'The package-context wrapper exited before requesting its one-shot launch permit.' } Assert-Pgm $Plan.pgm Start-Sleep -Milliseconds 50 } if (-not $launchPipe.Connection.IsCompleted) { Fail 'The package-context wrapper did not request its launch permit.' } $launchPipe.Connection.GetAwaiter().GetResult() $script:packagePermitRequests++ if ($script:packagePermitRequests -ne 1 -or -not $launchPipe.Stream.IsConnected) { Fail 'The package launch permit request budget or connection state is invalid.' } $wrapper = Assert-PackagePermitClient $launchPipe $notBefore $wrapperExecutable [void](Assert-Authorization $script:authorization $Plan) Assert-Pgm $Plan.pgm if (@(Get-Process -Name ([IO.Path]::GetFileNameWithoutExtension($expectedExecutableName)) -ErrorAction SilentlyContinue).Count -ne 0 -or @(Get-NetTCPConnection -State Listen -LocalPort ([int]$Plan.webView.port) -ErrorAction SilentlyContinue).Count -ne 0) { Fail 'The app or CDP precondition changed before package launch permit delivery.' } $payload = $utf8NoBom.GetBytes(($environment | ConvertTo-Json -Compress)) $lengthBytes = [BitConverter]::GetBytes([int]$payload.Length) try { if ($payload.Length -le 0 -or $payload.Length -gt 65536) { Fail 'The package launch environment payload is unsafe.' } $launchPipe.Stream.Write($lengthBytes,0,$lengthBytes.Length) $launchPipe.Stream.Write($payload,0,$payload.Length) $launchPipe.Stream.Flush() $script:packagePermitDeliveries++ } finally { [Array]::Clear($payload,0,$payload.Length) [Array]::Clear($lengthBytes,0,$lengthBytes.Length) Close-OneShotPipe $launchPipe } if ($script:packagePermitDeliveries -ne 1) { Fail 'The package launch permit was not delivered exactly once.' } $wrapperDeadline = [DateTime]::UtcNow.AddSeconds(20) while (-not $async.IsCompleted -and [DateTime]::UtcNow -lt $wrapperDeadline) { Start-Sleep -Milliseconds 50 } if (-not $async.IsCompleted) { Fail 'The package-context wrapper exceeded its one-shot launch timeout.' } [void]$launcher.EndInvoke($async) if ($launcher.HadErrors -or $launcher.Streams.Error.Count -ne 0) { Fail 'The package-context wrapper reported a launch error.' } if ($null -eq $wrapper -or -not $wrapper.WaitForExit(0)) { Fail 'OUTCOME_UNKNOWN: The package-context wrapper pipeline completed without proving the exact wrapper exited.' } } catch { $caught = $_ Close-OneShotPipe $launchPipe $failStopFailure = $null try { if ($null -ne $wrapper) { Stop-PackageWrapperFailStop $wrapper } } catch { $failStopFailure = $_ } try { if ($null -ne $async -and -not $async.IsCompleted) { $launcher.Stop() } } catch { } if ($null -ne $failStopFailure) { throw $failStopFailure } throw $caught } finally { $launcher.Dispose() } $deadline = [DateTime]::UtcNow.AddSeconds(30) while ([DateTime]::UtcNow -lt $deadline) { $items = @(Get-Process -Name ([IO.Path]::GetFileNameWithoutExtension($expectedExecutableName)) -ErrorAction SilentlyContinue | Where-Object { -not $_.HasExited }) if ($items.Count -gt 1) { Fail 'More than one LegacyParityApp process appeared.' } if ($items.Count -eq 1 -and $items[0].StartTime.ToUniversalTime() -ge $notBefore.AddSeconds(-1)) { $script:application = $items[0] $script:applicationLaunched = $true Assert-Application $items[0] ([string]$Plan.package.executable.path) $false $items[0].Refresh() if ($items[0].MainWindowHandle -ne [IntPtr]::Zero -and [string]$items[0].MainWindowTitle -ceq $expectedWindowTitle) { return $items[0] } } Start-Sleep -Milliseconds 100 } Fail 'The exact package application did not start before timeout.' } function Quote-Argument([string] $Value) { if ($Value -notmatch '[\s"]') { return $Value } return '"' + ([regex]::Replace($Value, '(\\*)"', '$1$1\"') -replace '(\\+)$','$1$1') + '"' } function Test-ExactStringSequence([object[]] $Actual, [string[]] $Expected) { if ($Actual.Count -ne $Expected.Count) { return $false } for ($index = 0; $index -lt $Expected.Count; $index++) { if ($Actual[$index] -isnot [string] -or [string]$Actual[$index] -cne $Expected[$index]) { return $false } } return $true } function Assert-NodeScreenshot([object] $Screenshot, [string] $ExpectedPath) { Assert-ExactProperties $Screenshot @('path','bytes','sha256') 'Node evidence.screenshot' if ($Screenshot.path -isnot [string] -or $Screenshot.sha256 -isnot [string] -or ($Screenshot.bytes -isnot [int] -and $Screenshot.bytes -isnot [long])) { Fail 'OUTCOME_UNKNOWN: Node screenshot evidence JSON types are invalid.' } $path = FullPath ([string]$Screenshot.path) 'Node evidence.screenshot.path' if (-not (SamePath $path $ExpectedPath) -or -not [IO.File]::Exists($path)) { Fail 'OUTCOME_UNKNOWN: Node screenshot path is not the exclusive requested PNG path.' } Assert-NoReparse $path 'Node screenshot' Add-ReadLease $path 'Node screenshot' $bytes = [IO.File]::ReadAllBytes($path) try { $signature = [byte[]](0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A) if ($bytes.Length -lt 1024 -or [int64]$Screenshot.bytes -ne [int64]$bytes.LongLength -or [string]$Screenshot.sha256 -cnotmatch '^[0-9A-F]{64}$' -or (Convert-Hash $bytes) -cne [string]$Screenshot.sha256) { Fail 'OUTCOME_UNKNOWN: Node screenshot length or SHA-256 does not match the actual PNG.' } for ($index = 0; $index -lt $signature.Length; $index++) { if ($bytes[$index] -ne $signature[$index]) { Fail 'OUTCOME_UNKNOWN: Node screenshot does not have the exact PNG signature.' } } } finally { [Array]::Clear($bytes,0,$bytes.Length) } } function Assert-NodePreparedFinalState([object] $FinalState) { Assert-ExactProperties $FinalState @( 'revision','isBusy','statusMessage','statusKind','playlist','playout') 'Node evidence.finalState' if (($FinalState.revision -isnot [int] -and $FinalState.revision -isnot [long]) -or [int64]$FinalState.revision -lt 0) { Fail 'OUTCOME_UNKNOWN: Node final revision is invalid.' } Assert-JsonBool $FinalState.isBusy $false 'Node evidence.finalState.isBusy' if ($FinalState.playlist -isnot [Array]) { Fail 'OUTCOME_UNKNOWN: Node final playlist must be a JSON array.' } $playlist = @($FinalState.playlist) if ($playlist.Count -ne 1) { Fail 'OUTCOME_UNKNOWN: Node final playlist is not exactly one row.' } $row = $playlist[0] Assert-ExactProperties $row @( 'rowId','isEnabled','isActive','marketText','stockName','graphicType','subtype','pageText') 'Node evidence.finalState.playlist[0]' if ($row.rowId -isnot [string] -or [string]::IsNullOrWhiteSpace([string]$row.rowId)) { Fail 'OUTCOME_UNKNOWN: Node final playlist row identity is invalid.' } Assert-JsonBool $row.isEnabled $true 'Node evidence.finalState.playlist[0].isEnabled' Assert-JsonBool $row.isActive $true 'Node evidence.finalState.playlist[0].isActive' Assert-JsonString $row.marketText $expectedMarketText 'Node evidence.finalState.playlist[0].marketText' Assert-JsonString $row.stockName $expectedStockName 'Node evidence.finalState.playlist[0].stockName' Assert-JsonString $row.graphicType $expectedGraphicType 'Node evidence.finalState.playlist[0].graphicType' Assert-JsonString $row.subtype $expectedSubtype 'Node evidence.finalState.playlist[0].subtype' Assert-JsonString $row.pageText '1/1' 'Node evidence.finalState.playlist[0].pageText' $playout = $FinalState.playout Assert-ExactProperties $playout @( 'mode','connectionState','phase','isConnected','isCommandAvailable','liveTakeInAllowed', 'isPlayCompletionPending','isTakeOutCompletionPending','outcomeUnknown','preparedCode', 'onAirCode','currentEntryId','pageIndexZeroBased','pageCount','pageSize','itemCount', 'currentPageItemCount','isLastPage','nextKind','isBusy','refreshActive', 'refreshCompletedCount','refreshMaximumCount','refreshLimitReached','message') 'Node evidence.finalState.playout' Assert-JsonString $playout.mode 'live' 'Node evidence.finalState.playout.mode' Assert-JsonString $playout.connectionState 'connected' 'Node evidence.finalState.playout.connectionState' Assert-JsonString $playout.phase 'prepared' 'Node evidence.finalState.playout.phase' Assert-JsonBool $playout.isConnected $true 'Node evidence.finalState.playout.isConnected' Assert-JsonBool $playout.isCommandAvailable $true 'Node evidence.finalState.playout.isCommandAvailable' Assert-JsonBool $playout.liveTakeInAllowed $false 'Node evidence.finalState.playout.liveTakeInAllowed' Assert-JsonBool $playout.isPlayCompletionPending $false 'Node evidence.finalState.playout.isPlayCompletionPending' Assert-JsonBool $playout.isTakeOutCompletionPending $false 'Node evidence.finalState.playout.isTakeOutCompletionPending' Assert-JsonBool $playout.outcomeUnknown $false 'Node evidence.finalState.playout.outcomeUnknown' Assert-JsonString $playout.preparedCode '5001' 'Node evidence.finalState.playout.preparedCode' if ($null -ne $playout.onAirCode) { Fail 'OUTCOME_UNKNOWN: Node final onAirCode must be JSON null.' } Assert-JsonString $playout.currentEntryId ([string]$row.rowId) 'Node evidence.finalState.playout.currentEntryId' Assert-JsonInt $playout.pageIndexZeroBased 0 'Node evidence.finalState.playout.pageIndexZeroBased' Assert-JsonInt $playout.pageCount 1 'Node evidence.finalState.playout.pageCount' Assert-JsonInt $playout.itemCount 1 'Node evidence.finalState.playout.itemCount' Assert-JsonInt $playout.currentPageItemCount 1 'Node evidence.finalState.playout.currentPageItemCount' Assert-JsonBool $playout.isLastPage $true 'Node evidence.finalState.playout.isLastPage' Assert-JsonString $playout.nextKind 'endOfPlaylist' 'Node evidence.finalState.playout.nextKind' Assert-JsonBool $playout.isBusy $false 'Node evidence.finalState.playout.isBusy' Assert-JsonBool $playout.refreshActive $false 'Node evidence.finalState.playout.refreshActive' } function Assert-NodeSuccessEvidence([object] $Node, [object] $Plan) { Assert-ExactProperties $Node @( 'schemaVersion','result','startedAtUtc','completedAtUtc','planSha256','roundId','target', 'commandBudget','observed','checkpoints','screenshot','finalState','error','authorizationSha256') 'Node evidence' Assert-JsonInt $Node.schemaVersion 1 'Node evidence.schemaVersion' Assert-JsonString $Node.result 'PASS_PREPARED' 'Node evidence.result' Assert-JsonString $Node.planSha256 $script:planHash 'Node evidence.planSha256' Assert-JsonString $Node.authorizationSha256 $script:authorizationHash 'Node evidence.authorizationSha256' Assert-JsonString $Node.roundId ([string]$Plan.roundId) 'Node evidence.roundId' if ($null -ne $Node.error) { Fail 'OUTCOME_UNKNOWN: Node success evidence contains an error.' } Assert-ExactProperties $Node.target @('expectedUrl','port','id') 'Node evidence.target' Assert-JsonString $Node.target.expectedUrl $expectedUrl 'Node evidence.target.expectedUrl' Assert-JsonInt $Node.target.port ([int]$Plan.webView.port) 'Node evidence.target.port' if ($Node.target.id -isnot [string] -or [string]::IsNullOrWhiteSpace([string]$Node.target.id)) { Fail 'OUTCOME_UNKNOWN: Node target identity is missing.' } Assert-ClosedBudgets $Node.commandBudget 'Node evidence.commandBudget' Assert-ExactProperties $Node.observed @( 'prepareDispatchPossible','prepareIssued','busyObserved','jitPermitRequested', 'jitPermitReceived','outboundTypes','blocked') 'Node evidence.observed' Assert-JsonBool $Node.observed.prepareDispatchPossible $true 'Node evidence.observed.prepareDispatchPossible' Assert-JsonBool $Node.observed.prepareIssued $true 'Node evidence.observed.prepareIssued' Assert-JsonBool $Node.observed.busyObserved $true 'Node evidence.observed.busyObserved' Assert-JsonBool $Node.observed.jitPermitRequested $true 'Node evidence.observed.jitPermitRequested' Assert-JsonBool $Node.observed.jitPermitReceived $true 'Node evidence.observed.jitPermitReceived' if ($script:nodePermitRequests -ne 1 -or $script:nodePermitDeliveries -ne 1 -or $script:nodePermitClientProcessId -le 0) { Fail 'OUTCOME_UNKNOWN: The parent-owned JIT permit ledger is not exactly one verified delivery.' } if ($Node.observed.outboundTypes -isnot [Array] -or $Node.observed.blocked -isnot [Array] -or @($Node.observed.blocked).Count -ne 0) { Fail 'OUTCOME_UNKNOWN: Node success ledger is not an unblocked JSON array ledger.' } $actualOutbound = @($Node.observed.outboundTypes) $withoutSelection = @('ready','search-stocks','cut-pointer-down','drop-selected-cuts','gate-a-prepare') $withSelection = @('ready','search-stocks','select-stock','cut-pointer-down','drop-selected-cuts','gate-a-prepare') if (-not (Test-ExactStringSequence $actualOutbound $withoutSelection) -and -not (Test-ExactStringSequence $actualOutbound $withSelection)) { Fail 'OUTCOME_UNKNOWN: Node success outbound ledger order is not exact.' } if ($Node.checkpoints -isnot [Array]) { Fail 'OUTCOME_UNKNOWN: Node checkpoints must be a JSON array.' } Assert-NodePreparedFinalState $Node.finalState Assert-NodeScreenshot $Node.screenshot $script:screenshotPath } function Get-GateCapabilityHash([string] $Capability) { if ($Capability -cnotmatch '^[0-9A-F]{64}$') { Fail 'The native Gate A capability is invalid.' } $bytes = [Text.Encoding]::ASCII.GetBytes($Capability) try { return Convert-Hash $bytes } finally { [Array]::Clear($bytes,0,$bytes.Length) } } function Assert-NodePermitClient( [object] $Pipe, [Diagnostics.Process] $Process, [long] $ExpectedStartTimeUtcTicks, [string] $ExpectedExecutable) { $clientProcessId = [LegacyPgmPreparePackageIdentity]::ReadNamedPipeClientProcessId( $Pipe.Stream.SafePipeHandle) if ($clientProcessId -ne $Process.Id) { Fail 'The JIT permit pipe was claimed by a process other than the spawned Node helper.' } $Process.Refresh() if ($Process.HasExited -or -not (SamePath $Process.Path $ExpectedExecutable) -or $Process.StartTime.ToUniversalTime().Ticks -ne $ExpectedStartTimeUtcTicks) { Fail 'The JIT permit pipe Node process identity changed.' } $row = Get-CimInstance Win32_Process -Filter "ProcessId = $clientProcessId" -ErrorAction Stop if ($null -eq $row -or [int]$row.ParentProcessId -ne $PID -or -not (SamePath ([string]$row.ExecutablePath) $ExpectedExecutable)) { Fail 'The JIT permit pipe client is not the exact direct child Node helper.' } $script:nodePermitClientProcessId = $clientProcessId } function Stop-NodeHelperFailStop([Diagnostics.Process] $Process, [object] $Pipe) { # This revokes and stops only the control-plane Node helper. It never closes, # disconnects, terminates, or otherwise commands the app, PGM, Tornado, or K3D. Close-OneShotPipe $Pipe $script:gateCapability = $null if ($null -eq $Process) { return } $script:nodeStillRunning = $true $exitObserved = $false try { $exitObserved = [bool]$Process.WaitForExit(0) } catch { } if (-not $exitObserved) { try { $Process.Kill() } catch { try { $exitObserved = [bool]$Process.WaitForExit(0) } catch { } if (-not $exitObserved) { Fail 'OUTCOME_UNKNOWN: The Node helper could not be fail-stopped or proven exited after parent revocation.' } } if (-not $exitObserved) { try { $exitObserved = [bool]$Process.WaitForExit(5000) } catch { } } } if (-not $exitObserved) { Fail 'OUTCOME_UNKNOWN: The Node helper was not proven stopped after parent revocation.' } $script:nodeStillRunning = $false try { $script:nodeExitCode = [int]$Process.ExitCode } catch { } } function Start-NodeHelper([object] $Plan) { $script:nodeInvocations++ if ($script:nodeInvocations -ne 1) { Fail 'The Node helper one-shot budget was exceeded.' } if ([string]$script:gateCapability -cnotmatch '^[0-9A-F]{64}$' -or [string]$script:gateCapabilitySha256 -cnotmatch '^[0-9A-F]{64}$' -or (Get-GateCapabilityHash ([string]$script:gateCapability)) -cne [string]$script:gateCapabilitySha256) { Fail 'The native Gate A capability is absent before the Node helper starts.' } $permitPipe = New-CurrentUserOneShotPipe 'Node' $args = @( [string]$Plan.automation.helper.path, '--port',[string]$Plan.webView.port,'--plan',(FullPath $PlanPath 'PlanPath'), '--plan-sha256',$script:planHash,'--authorization',(FullPath $AuthorizationPath 'AuthorizationPath'), '--authorization-sha256',$script:authorizationHash, '--output',$script:nodeEvidencePath,'--screenshot',$script:screenshotPath, '--capability-sha256',[string]$script:gateCapabilitySha256, '--permit-pipe',[string]$permitPipe.Name, '--timeout-ms','90000') $info = [Diagnostics.ProcessStartInfo]::new() $info.FileName = [string]$Plan.automation.node.path $info.WorkingDirectory = $repositoryRoot $info.UseShellExecute = $false $info.CreateNoWindow = $true $info.Arguments = (($args | ForEach-Object { Quote-Argument ([string]$_) }) -join ' ') foreach ($name in @($info.EnvironmentVariables.Keys)) { if ([string]$name -like 'NODE_*' -or [string]$name -like 'MBN_LEGACY_PGM_GATE_A_*' -or [string]$name -like 'MBN_STOCK_ORACLE_*' -or [string]$name -like 'MBN_STOCK_MARIADB_*' -or [string]$name -like 'MBN_STOCK_DB_*' -or [string]$name -like 'DOTNET_*' -or [string]$name -like 'CORECLR_*' -or [string]$name -like 'COR_*' -or [string]$name -like 'COMPlus_*' -or [string]$name -in @('OPENSSL_CONF','OPENSSL_MODULES')) { $info.EnvironmentVariables.Remove([string]$name) } } $process = $null try { $process = [Diagnostics.Process]::Start($info) } catch { Close-OneShotPipe $permitPipe $script:gateCapability = $null throw } if ($null -eq $process) { Close-OneShotPipe $permitPipe $script:gateCapability = $null Fail 'The one Node helper invocation did not start.' } try { $process.Refresh() $nodeStartTimeUtcTicks = $process.StartTime.ToUniversalTime().Ticks $deadline = [DateTime]::UtcNow.AddSeconds(105) while (-not $process.HasExited -and [DateTime]::UtcNow -lt $deadline) { Assert-Application $script:application ([string]$Plan.package.executable.path) Assert-Pgm $Plan.pgm Assert-Cdp ([int]$Plan.webView.port) $script:application.Id if ($script:nodePermitDeliveries -eq 0 -and $permitPipe.Connection.IsCompleted) { $permitPipe.Connection.GetAwaiter().GetResult() $script:nodePermitRequests++ if ($script:nodePermitRequests -ne 1 -or -not $permitPipe.Stream.IsConnected) { Fail 'The Node JIT permit request budget or connection state is invalid.' } Assert-NodePermitClient ` $permitPipe ` $process ` $nodeStartTimeUtcTicks ` ([string]$Plan.automation.node.path) $currentExpiry = Assert-Authorization $script:authorization $Plan if ($currentExpiry.UtcDateTime.Ticks -ne [long]$script:authorizationExpiresAtUtcTicks) { Fail 'The authorization expiry changed at the JIT permit boundary.' } Assert-Application $script:application ([string]$Plan.package.executable.path) Assert-Pgm $Plan.pgm Assert-Cdp ([int]$Plan.webView.port) $script:application.Id $bytes = [Text.Encoding]::ASCII.GetBytes([string]$script:gateCapability) try { if ($bytes.Length -ne 64 -or (Convert-Hash $bytes) -cne [string]$script:gateCapabilitySha256) { Fail 'The JIT permit bearer changed before delivery.' } $permitPipe.Stream.Write($bytes,0,$bytes.Length) $permitPipe.Stream.Flush() $script:nodePermitDeliveries++ } finally { [Array]::Clear($bytes,0,$bytes.Length) $script:gateCapability = $null Close-OneShotPipe $permitPipe } if ($script:nodePermitDeliveries -ne 1) { Fail 'The Node JIT permit delivery budget was not exactly consumed.' } } Start-Sleep -Milliseconds 100 $process.Refresh() } if (-not $process.HasExited) { Fail 'OUTCOME_UNKNOWN: Node helper exceeded its outer timeout; parent fail-stop is required without any app or playout cleanup command.' } Close-OneShotPipe $permitPipe $script:gateCapability = $null if (-not $process.WaitForExit(0)) { Fail 'OUTCOME_UNKNOWN: Node helper exit could not be positively observed.' } $process.Refresh() $script:nodeExitCode = [int]$process.ExitCode if (-not [IO.File]::Exists($script:nodeEvidencePath)) { Fail 'OUTCOME_UNKNOWN: Node helper produced no exclusive evidence file.' } Add-ReadLease $script:nodeEvidencePath 'Node evidence' if ($script:nodeExitCode -eq 0) { # The last monitor sample can precede Node exit. Re-pin every live identity # immediately before accepting the prepared success evidence. Assert-Pgm $Plan.pgm Assert-Application $script:application ([string]$Plan.package.executable.path) Assert-Cdp ([int]$Plan.webView.port) $script:application.Id $node = Read-SealedJson $script:nodeEvidencePath (Get-FileHash $script:nodeEvidencePath -Algorithm SHA256).Hash 'Node evidence' try { Assert-NodeSuccessEvidence $node.Value $Plan } catch { if ($_.Exception.Message.StartsWith('OUTCOME_UNKNOWN:',[StringComparison]::Ordinal)) { throw } Fail ('OUTCOME_UNKNOWN: Node success evidence is not the exact sealed PREPARED result: ' + $_.Exception.Message) } return } if ($script:nodeExitCode -eq 2) { try { $known = ([IO.File]::ReadAllText($script:nodeEvidencePath,$utf8NoBom) | ConvertFrom-Json -ErrorAction Stop) if ([int]$known.schemaVersion -ne 1 -or [string]$known.result -cne 'KNOWN_FAILURE' -or [string]$known.planSha256 -cne $script:planHash -or [string]$known.authorizationSha256 -cne $script:authorizationHash -or $known.observed.prepareDispatchPossible -isnot [bool] -or $known.observed.prepareIssued -isnot [bool] -or $known.observed.busyObserved -isnot [bool] -or $known.observed.jitPermitRequested -isnot [bool] -or $known.observed.jitPermitReceived -isnot [bool]) { Fail 'OUTCOME_UNKNOWN: Node known-failure evidence is not exact.' } } catch { if ($_.Exception.Message.StartsWith('OUTCOME_UNKNOWN:',[StringComparison]::Ordinal)) { throw } Fail 'OUTCOME_UNKNOWN: Node known-failure evidence could not be validated.' } Fail 'KNOWN_FAILURE: Node helper stopped without a successful PREPARE.' } Fail "OUTCOME_UNKNOWN: Node helper exited with code $($script:nodeExitCode)." } catch { $caught = $_ Stop-NodeHelperFailStop $process $permitPipe throw $caught } } function Write-Evidence([string] $Path) { $nodePin = $null $prepareMayHaveBeenDispatched = $script:nodeInvocations -gt 0 $nodeObserved = $null $screenshotPin = $null if (-not [string]::IsNullOrWhiteSpace($script:nodeEvidencePath) -and [IO.File]::Exists($script:nodeEvidencePath)) { $item = Get-Item -LiteralPath $script:nodeEvidencePath -Force $nodePin = [ordered]@{ path=$script:nodeEvidencePath; length=[int64]$item.Length; sha256=(Get-FileHash $script:nodeEvidencePath -Algorithm SHA256).Hash } try { $nodeValue = ([IO.File]::ReadAllText($script:nodeEvidencePath,$utf8NoBom) | ConvertFrom-Json -ErrorAction Stop) $trustedNodeIdentity = [int]$nodeValue.schemaVersion -eq 1 -and [string]$nodeValue.planSha256 -ceq $script:planHash -and [string]$nodeValue.authorizationSha256 -ceq $script:authorizationHash $nodeObserved = [ordered]@{ trustedIdentity = $trustedNodeIdentity result = [string]$nodeValue.result prepareDispatchPossible = $(if ($nodeValue.observed.prepareDispatchPossible -is [bool]) { [bool]$nodeValue.observed.prepareDispatchPossible } else { $null }) prepareIssued = $(if ($nodeValue.observed.prepareIssued -is [bool]) { [bool]$nodeValue.observed.prepareIssued } else { $null }) busyObserved = $(if ($nodeValue.observed.busyObserved -is [bool]) { [bool]$nodeValue.observed.busyObserved } else { $null }) jitPermitRequested = $(if ($nodeValue.observed.jitPermitRequested -is [bool]) { [bool]$nodeValue.observed.jitPermitRequested } else { $null }) jitPermitReceived = $(if ($nodeValue.observed.jitPermitReceived -is [bool]) { [bool]$nodeValue.observed.jitPermitReceived } else { $null }) outboundTypes = @($nodeValue.observed.outboundTypes) blocked = @($nodeValue.observed.blocked) finalState = $nodeValue.finalState screenshot = $nodeValue.screenshot } if ($script:nodeExitCode -eq 2 -and $trustedNodeIdentity -and [string]$nodeValue.result -ceq 'KNOWN_FAILURE' -and $nodeValue.observed.prepareDispatchPossible -is [bool] -and [bool]$nodeValue.observed.prepareDispatchPossible -eq $false -and $nodeValue.observed.prepareIssued -is [bool] -and [bool]$nodeValue.observed.prepareIssued -eq $false) { $prepareMayHaveBeenDispatched = $false } } catch { $prepareMayHaveBeenDispatched = $true } } if (-not [string]::IsNullOrWhiteSpace($script:screenshotPath) -and [IO.File]::Exists($script:screenshotPath)) { $shot = Get-Item -LiteralPath $script:screenshotPath -Force $screenshotPin = [ordered]@{ path=$script:screenshotPath; length=[int64]$shot.Length; sha256=(Get-FileHash $script:screenshotPath -Algorithm SHA256).Hash } } $evidence = [ordered]@{ schemaVersion=1; kind='LegacyPgmPrepareGateEvidence'; roundId=$(if($null-ne$script:plan){[string]$script:plan.roundId}else{$null}) status=$script:status; phase=$script:phase; startedAtUtc=$script:startedAtUtc.ToString('o') completedAtUtc=[DateTime]::UtcNow.ToString('o'); planSha256=$script:planHash authorizationSha256=$script:authorizationHash; appLaunched=$script:applicationLaunched appProcessId=$(if($null-ne$script:application){$script:application.Id}else{$null}) launchAttempted=$script:launchAttempted; nodeHelperInvocations=$script:nodeInvocations nodeExitCode=$script:nodeExitCode; nodeStillRunning=$script:nodeStillRunning nodePermitRequests=$script:nodePermitRequests nodePermitDeliveries=$script:nodePermitDeliveries nodePermitClientProcessId=$script:nodePermitClientProcessId capabilitySha256=$script:gateCapabilitySha256 packagePermitRequests=$script:packagePermitRequests packagePermitDeliveries=$script:packagePermitDeliveries packagePermitClientProcessId=$script:packagePermitClientProcessId nodeEvidence=$nodePin; screenshot=$screenshotPin; observed=$nodeObserved authorizedBudgets=[ordered]@{connect=1;prepare=1;takeIn=0;next=0;pageNext=0;takeOut=0;databaseWrite=0;appClose=0;retry=0} gateConfigInstalled=$script:gateConfigInstalled originalConfigRestored=$script:configRestored manualConfigRestoreRequired=($script:gateConfigInstalled -and -not $script:configRestored) prepareMayHaveBeenDispatched=$prepareMayHaveBeenDispatched configRestoreAttempts=$script:configRestoreAttempts automaticSessionCleanupAttemptedAfterLaunch=$false; error=$script:failure } $json = ($evidence | ConvertTo-Json -Depth 8) + [Environment]::NewLine $bytes = $utf8NoBom.GetBytes($json) try { Write-BytesExclusive $Path $bytes } finally { [Array]::Clear($bytes,0,$bytes.Length) } } $planCapture = Read-SealedJson $PlanPath $script:planHash 'PlanPath' $script:plan = $planCapture.Value Assert-Plan $script:plan if ($Action -ceq 'ValidatePlan') { $script:phase = 'read-only-plan-environment-validation' $validation = Assert-ReadOnlyPlanEnvironment $script:plan [pscustomobject][ordered]@{ result='PLAN_VALID'; roundId=[string]$script:plan.roundId; planPath=$planCapture.Path planLength=$planCapture.Length; planSha256=$script:planHash filePinsValidated=$validation.filePinsValidated runtimeTreeValidated=$validation.runtimeTreeValidated packageRegistrationValidated=$validation.packageRegistrationValidated currentPgmValidated=$validation.currentPgmValidated k3dRegistrationValidatedReadOnly=$validation.k3dRegistrationValidatedReadOnly authorizationRead=$false; packageLaunches=0; appCommandsIssued=0; playoutCommandsIssued=0 comActivations=0; vendorCommandsIssued=0 } return } if ([string]::IsNullOrWhiteSpace($AuthorizationPath) -or [string]::IsNullOrWhiteSpace($ExpectedAuthorizationSha256) -or [string]::IsNullOrWhiteSpace($EvidencePath)) { Fail 'ExecuteAuthorizedGateA requires authorization path/hash and a new evidence path.' } $script:authorizationHash = $ExpectedAuthorizationSha256.ToUpperInvariant() $evidenceFull = FullPath $EvidencePath 'EvidencePath' if ((Test-Within $planCapture.Path $repositoryRoot) -or (Test-Within (FullPath $AuthorizationPath 'AuthorizationPath') $repositoryRoot) -or (Test-Within $evidenceFull $repositoryRoot)) { Fail 'Plan, authorization and live evidence must remain outside the Git repository.' } if ([IO.File]::Exists($evidenceFull) -or [IO.Directory]::Exists($evidenceFull)) { Fail 'EvidencePath already exists.' } $evidenceParent = [IO.Path]::GetDirectoryName($evidenceFull); [void][IO.Directory]::CreateDirectory($evidenceParent); Assert-NoReparse $evidenceParent 'EvidencePath parent' $script:nodeEvidencePath = Join-Path $evidenceParent (([IO.Path]::GetFileNameWithoutExtension($evidenceFull)) + '.node.json') $script:screenshotPath = Join-Path $evidenceParent (([IO.Path]::GetFileNameWithoutExtension($evidenceFull)) + '.png') if ([IO.File]::Exists($script:nodeEvidencePath) -or [IO.Directory]::Exists($script:nodeEvidencePath) -or [IO.File]::Exists($script:screenshotPath) -or [IO.Directory]::Exists($script:screenshotPath)) { Fail 'Node evidence or screenshot path already exists.' } try { $script:phase = 'authorization' $authorizationCapture = Read-SealedJson $AuthorizationPath $script:authorizationHash 'AuthorizationPath' $script:authorization = $authorizationCapture.Value $expiresAt = Assert-Authorization $script:authorization $script:plan $script:authorizationExpiresAtUtcTicks = [long]$expiresAt.UtcDateTime.Ticks $script:phase = 'native-one-shot-gate-readiness' if (-not $nativeOneShotPrepareGateReady) { Fail 'Execution is disabled until the native one-shot PREPARE capability gate is implemented, tested, committed and pushed.' } $script:phase = 'frozen-preflight' Assert-CleanPushedCommit $script:plan.repository Assert-NoUnsafeEnvironment if (-not (SamePath (Assert-FilePin $script:plan.automation.orchestrator 'Gate orchestrator') $PSCommandPath) -or -not (SamePath (Assert-FilePin $script:plan.automation.helper 'Node helper') (Join-Path $PSScriptRoot 'Test-LegacyPgmPrepareGate.mjs'))) { Fail 'Automation file identity changed.' } [void](Assert-FilePin $script:plan.automation.node 'Node runtime') [void](Assert-FilePin $script:plan.package.msix 'Release x64 MSIX') $appExe = Assert-FilePin $script:plan.package.executable 'package executable' $tree = Get-RuntimeTreePin ([string]$script:plan.package.installLocation) if ($tree.fileCount -ne [int]$script:plan.package.runtimeTree.fileCount -or $tree.manifestSha256 -cne [string]$script:plan.package.runtimeTree.manifestSha256) { Fail 'Package runtime tree changed.' } Add-RuntimeTreeReadLeases ([string]$script:plan.package.installLocation) $leasedTree = Get-RuntimeTreePin ([string]$script:plan.package.installLocation) if ($leasedTree.fileCount -ne [int]$script:plan.package.runtimeTree.fileCount -or $leasedTree.manifestSha256 -cne [string]$script:plan.package.runtimeTree.manifestSha256) { Fail 'Package runtime tree changed while its read leases were acquired.' } [void](Assert-FilePin $script:plan.configuration.original 'original config') Assert-OriginalConfigDryRun $script:plan.configuration.original [void](Assert-FilePin $script:plan.configuration.gate 'Gate config') Assert-GateConfigSafety $script:plan.configuration [void](Assert-FilePin $script:plan.configuration.database 'database config') [void](Assert-FilePin $script:plan.cut.file '5001 cut') [void](Assert-FilePin $script:plan.k3d.native 'K3D native binary') [void](Assert-FilePin $script:plan.k3d.interop 'K3D interop assembly') [void](Assert-FilePin $script:plan.pgm.executable 'PGM executable') Assert-K3dRegistration $script:plan.k3d Assert-NoReparse ([string]$script:plan.configuration.sceneDirectory) 'scene directory' Add-ReadLease ([string]$script:plan.automation.helper.path) 'Node helper' Add-ReadLease ([string]$script:plan.automation.node.path) 'Node runtime' Add-ReadLease ([string]$script:plan.package.executable.path) 'package executable' Add-ReadLease ([string]$script:plan.configuration.gate.path) 'Gate config source' Add-ReadLease ([string]$script:plan.configuration.database.path) 'database config' Add-ReadLease ([string]$script:plan.cut.file.path) '5001 cut' Add-ReadLease ([string]$script:plan.k3d.native.path) 'K3D native binary' Add-ReadLease ([string]$script:plan.k3d.interop.path) 'K3D interop assembly' Add-ReadLease ([string]$script:plan.pgm.executable.path) 'PGM executable' Assert-Pgm $script:plan.pgm $packages = @(Get-AppxPackage -Name $expectedPackageName -ErrorAction Stop) if ($packages.Count -ne 1 -or -not $packages[0].IsDevelopmentMode -or [string]$packages[0].SignatureKind -cne 'None' -or [string]$packages[0].Status -cne 'Ok' -or [string]$packages[0].PackageFullName -cne $expectedPackageFullName -or [string]$packages[0].PackageFamilyName -cne $expectedPackageFamily -or [string]$packages[0].Publisher -cne 'CN=Comtrophy' -or [string]$packages[0].Version -cne '0.1.0.0' -or [string]$packages[0].Architecture -cne 'X64' -or -not (SamePath ([string]$packages[0].InstallLocation) ([string]$script:plan.package.installLocation))) { Fail 'Exact Release x64 development package registration is absent.' } if (@(Get-Process -Name ([IO.Path]::GetFileNameWithoutExtension($expectedExecutableName)) -ErrorAction SilentlyContinue).Count -ne 0 -or @(Get-NetTCPConnection -State Listen -LocalPort ([int]$script:plan.webView.port) -ErrorAction SilentlyContinue).Count -ne 0) { Fail 'An existing application or CDP endpoint is present.' } [void](Assert-Authorization $script:authorization $script:plan) $script:phase = 'gate-config-install' Install-GateConfiguration $script:plan.configuration ([string]$script:plan.roundId) $script:phase = 'native-capability-generation' $script:gateCapability = New-GateCapability $script:gateCapabilitySha256 = Get-GateCapabilityHash ([string]$script:gateCapability) $script:phase = 'package-launch-auto-connect' $script:application = Start-Package $script:plan $script:applicationLaunched = $true $script:phase = 'wait-exact-target' Wait-ExactTarget ([int]$script:plan.webView.port) $script:application.Id $script:phase = 'restore-original-config' Restore-OriginalConfiguration $script:plan.configuration [void](Assert-Authorization $script:authorization $script:plan) Assert-Pgm $script:plan.pgm; Assert-Application $script:application $appExe; Assert-Cdp ([int]$script:plan.webView.port) $script:application.Id $script:phase = 'prepare5001-once' Start-NodeHelper $script:plan $script:status = 'PREPARED_KNOWN_SUCCESS' $script:phase = 'prepared-session-left-open' } catch { $script:failure = $_.Exception.Message if ($script:failure.StartsWith('KNOWN_FAILURE:',[StringComparison]::Ordinal)) { $script:status='KNOWN_FAILURE' } elseif (-not $script:launchAttempted) { $script:status='PRECONDITION_FAILED' } else { $script:status='OUTCOME_UNKNOWN' } if (-not $script:launchAttempted -and $script:gateConfigInstalled -and -not $script:configRestored -and $script:configRestoreAttempts -eq 0) { try { Restore-OriginalConfiguration $script:plan.configuration } catch { $script:failure += ' Pre-launch one-shot local config restore failed: ' + $_.Exception.Message } } } Write-Evidence $evidenceFull $evidenceItem = Get-Item -LiteralPath $evidenceFull -Force $evidenceSha256 = (Get-FileHash -LiteralPath $evidenceFull -Algorithm SHA256).Hash foreach ($lease in $script:fileLeases) { try { $lease.Dispose() } catch { } } $script:fileLeases.Clear() if ($script:status -cne 'PREPARED_KNOWN_SUCCESS') { Fail "$($script:status): $($script:failure) Evidence: $evidenceFull SHA-256: $evidenceSha256" } [pscustomobject][ordered]@{ result=$script:status; roundId=[string]$script:plan.roundId; planSha256=$script:planHash authorizationSha256=$script:authorizationHash; applicationProcessId=$script:application.Id authorizedConnectMaximum=1; prepare5001Page1Observed=1; nodeHelperInvocations=1 packagePermitDeliveries=$script:packagePermitDeliveries; nodePermitDeliveries=$script:nodePermitDeliveries takeIn=0; next=0; pageNext=0; takeOut=0; databaseWrite=0; appClose=0; retry=0 originalConfigRestored=$script:configRestored; applicationLeftOpen=$true evidence=$evidenceFull; evidenceLength=[int64]$evidenceItem.Length; evidenceSha256=$evidenceSha256 nodeEvidence=$script:nodeEvidencePath; screenshot=$script:screenshotPath }