1548 lines
90 KiB
PowerShell
1548 lines
90 KiB
PowerShell
#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 = $true
|
|
$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
|
|
}
|