diff --git a/docs/PLAYOUT_OPERATIONS.md b/docs/PLAYOUT_OPERATIONS.md index 21e8e4f..0e57946 100644 --- a/docs/PLAYOUT_OPERATIONS.md +++ b/docs/PLAYOUT_OPERATIONS.md @@ -215,6 +215,35 @@ Network Monitoring/PGM 관찰 담당자: 대상, cut, selector, page, 횟수 중 하나라도 바뀌면 같은 회차 승인을 재사용하지 않는다. +#### 5001 PREPARE 전용 one-shot Gate A + +현재 5001/page 1 연결 검증은 [`New-LegacyPgmPreparePlan.ps1`](../scripts/New-LegacyPgmPreparePlan.ps1), +[`Invoke-LegacyPgmPrepareGate.ps1`](../scripts/Invoke-LegacyPgmPrepareGate.ps1), +[`Test-LegacyPgmPrepareGate.mjs`](../scripts/Test-LegacyPgmPrepareGate.mjs)의 고정 계약을 사용한다. +계획 생성과 `ValidatePlan`은 명령을 보내지 않으며, 실행은 clean/pushed `main`, 정확한 Release x64 +MSIX와 package runtime, 원본 `5001.t2s`, K3D 등록, PGM PID/시작 시각/listener, 15분 이내의 +별도 승인 파일이 모두 일치할 때만 열린다. 실제 DTO를 만드는 로컬 `database.local.json`도 내용이나 +비밀번호를 노출하지 않고 경로·크기·SHA-256으로 고정해 실행 동안 read lease를 유지한다. + +이 게이트의 허용 예산은 현재 PGM `CONNECT` 1회와 삼성전자 `5001` page 1 `PREPARE` 1회뿐이다. +`TAKE IN`, `NEXT`, Page NEXT, `TAKE OUT`, `DISCONNECT`, 자동 재연결, DB write와 재시도는 모두 0회다. +앱은 WebView 생성 전에 process-lifetime 권한을 캡처하고, native engine은 동일 권한으로 exact PGM, +exact cut lease, exact Live profile과 SDK dispatch 직전 만료를 다시 검사한다. Gate A 동안 DB mutation, +로컬 영구 저장, Fade와 배경 변경도 native 경계 전에 거부한다. +Oracle/MariaDB 설정 override와 managed runtime startup hook/profiler 환경값은 process/user/machine +어느 범위에서도 허용하지 않으며 app과 helper의 상속 환경에서도 제거한다. + +raw one-shot capability는 명령줄·계획·증적·Web 상태에 기록하지 않는다. package-context wrapper와 +Node helper는 current-user ACL named pipe로 각 parent가 OS PID·실행 파일·시작 시각·package/parent +identity를 확인한 직후에만 필요한 값을 한 번 받는다. 감시 실패나 제한 시간 초과에서는 pipe를 먼저 +폐기하고 control-plane helper만 fail-stop한다. 이는 앱, PGM, Tornado 또는 K3D 프로세스를 종료하거나 +vendor cleanup 명령을 보내는 권한이 아니다. + +성공 시 앱과 session은 `PREPARED 5001` 상태로 그대로 두고 별도 승인 없는 cleanup을 하지 않는다. +결과가 불명확하면 helper 증적을 `OutcomeUnknown`으로 남기고 앱 종료, 반대 명령, 재실행 또는 같은 +PREPARE 반복을 하지 않는다. 다음 단계는 Network Monitoring과 PGM을 사람이 확인한 뒤 새 계획과 +새 승인을 만드는 것이다. + ### Gate B: TAKE IN 직전 승인 PREPARE 결과와 Network Monitoring 상태를 운영자가 확인한 후, TAKE IN 직전에 다음과 같이 명시적인 승인을 받아야 한다. diff --git a/scripts/Invoke-LegacyPgmPrepareGate.ps1 b/scripts/Invoke-LegacyPgmPrepareGate.ps1 new file mode 100644 index 0000000..ae0f2a1 --- /dev/null +++ b/scripts/Invoke-LegacyPgmPrepareGate.ps1 @@ -0,0 +1,1547 @@ +#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 +} diff --git a/scripts/New-LegacyPgmPreparePlan.ps1 b/scripts/New-LegacyPgmPreparePlan.ps1 new file mode 100644 index 0000000..f333933 --- /dev/null +++ b/scripts/New-LegacyPgmPreparePlan.ps1 @@ -0,0 +1,402 @@ +#Requires -Version 5.1 + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string] $SpecificationPath, + + [Parameter(Mandatory = $true)] + [string] $OutputPath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$utf8NoBom = [Text.UTF8Encoding]::new($false, $true) +$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +$orchestratorPath = Join-Path $PSScriptRoot 'Invoke-LegacyPgmPrepareGate.ps1' +$nodeHelperPath = Join-Path $PSScriptRoot 'Test-LegacyPgmPrepareGate.mjs' +$userProfilePath = [Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile) +$approvedNodePath = [IO.Path]::GetFullPath((Join-Path $userProfilePath '.cache\codex-runtimes\codex-primary-runtime\dependencies\node\bin\node.exe')) +$expectedRemote = 'https://gitea.comtropy.synology.me/Wickedness/MBN_STOCK_WEBVIEW.git' +$expectedUrl = 'https://legacy-parity.mbn.local/index.html' +$approvedSceneDirectory = 'C:\Users\MD\source\repos\MBN_STOCK_N\MBN_STOCK_N\bin\Debug\Cuts' +$approvedCutPath = Join-Path $approvedSceneDirectory '5001.t2s' +$approvedCutSha256 = '99CE3B689A42D8C42BEB09A86FA10C2D7C1AEF4F50D324D81276C1A1E4C4D8A7' +$approvedMsixPath = Join-Path $repositoryRoot 'src\MBN_STOCK_WEBVIEW.LegacyParityApp\AppPackages\MBN_STOCK_WEBVIEW.LegacyParityApp_0.1.0.0_x64_Test\MBN_STOCK_WEBVIEW.LegacyParityApp_0.1.0.0_x64.msix' +$approvedMsixSha256 = '0E44F87FDD31852B7DDFCD6CF3A6325DA674F1F5A23AD39CB262A725AEB84367' +$approvedDatabaseConfigPath = Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) 'MBN_STOCK_WEBVIEW\Config\database.local.json' +$expectedMarketText = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('7L2U7Iqk7ZS8')) +$expectedStockName = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('7IK87ISx7KCE7J6Q')) +$expectedCutLabel = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('MeyXtO2MkOq4sOuzuF/tmITsnqzqsIA=')) +$expectedGraphicType = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('MeyXtO2MkOq4sOuzuA==')) +$expectedSubtype = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('7ZiE7J6s6rCA')) + +function Fail([string] $Message) { + throw [InvalidOperationException]::new($Message) +} + +function FullPath([string] $Path, [string] $Name) { + if ([string]::IsNullOrWhiteSpace($Path) -or -not [IO.Path]::IsPathRooted($Path)) { + Fail "$Name must be an absolute path." + } + return [IO.Path]::GetFullPath($Path) +} + +function SamePath([string] $Left, [string] $Right) { + return [string]::Equals( + [IO.Path]::GetFullPath($Left).TrimEnd('\'), + [IO.Path]::GetFullPath($Right).TrimEnd('\'), + [StringComparison]::OrdinalIgnoreCase) +} + +function Assert-NoReparse([string] $Path, [string] $Name) { + $current = [IO.Path]::GetFullPath($Path) + while (-not [string]::IsNullOrWhiteSpace($current)) { + if ([IO.File]::Exists($current) -or [IO.Directory]::Exists($current)) { + if (([IO.File]::GetAttributes($current) -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + Fail "$Name contains a reparse point." + } + } + $parent = [IO.Directory]::GetParent($current) + if ($null -eq $parent) { break } + $current = $parent.FullName + } +} + +function Read-Json([string] $Path, [string] $Name) { + $full = FullPath $Path $Name + if (-not [IO.File]::Exists($full)) { Fail "$Name does not exist." } + Assert-NoReparse $full $Name + $bytes = [IO.File]::ReadAllBytes($full) + try { + if ($bytes.Length -eq 0 -or $bytes.Length -gt 1MB) { Fail "$Name has an unsafe size." } + if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { + Fail "$Name must be UTF-8 without BOM." + } + try { $text = $utf8NoBom.GetString($bytes) } + catch { Fail "$Name is not strict UTF-8." } + try { return $text | ConvertFrom-Json -ErrorAction Stop } + catch { Fail "$Name is not valid JSON." } + } + finally { [Array]::Clear($bytes, 0, $bytes.Length) } +} + +function Require([object] $Object, [string] $Name, [string] $Context) { + if ($null -eq $Object -or -not ($Object.PSObject.Properties.Name -ccontains $Name)) { + Fail "$Context.$Name is required." + } + return $Object.$Name +} + +function Assert-ExactProperties([object] $Object, [string[]] $Expected, [string] $Context) { + if ($null -eq $Object) { Fail "$Context is required." } + $actual = @($Object.PSObject.Properties.Name) + if ($actual.Count -ne $Expected.Count) { Fail "$Context property count is not exact." } + foreach ($name in $Expected) { + if (@($actual | Where-Object { $_ -ceq $name }).Count -ne 1) { + Fail "$Context property set is not exact: $name." + } + } +} + +function Assert-JsonInt([object] $Value, [int] $Expected, [string] $Name) { + if ($Value -isnot [int] -or [int]$Value -ne $Expected) { Fail "$Name must be JSON integer $Expected." } +} + +function Assert-JsonBool([object] $Value, [bool] $Expected, [string] $Name) { + if ($Value -isnot [bool] -or [bool]$Value -ne $Expected) { Fail "$Name must be JSON boolean $Expected." } +} + +function Assert-JsonString([object] $Value, [string] $Expected, [string] $Name) { + if ($Value -isnot [string] -or [string]$Value -cne $Expected) { Fail "$Name must be the exact JSON string." } +} + +function New-FilePin([string] $Path, [string] $Name) { + $full = FullPath $Path $Name + if (-not [IO.File]::Exists($full)) { Fail "$Name does not exist." } + Assert-NoReparse $full $Name + $item = Get-Item -LiteralPath $full -Force + return [ordered]@{ + path = $full + length = [int64]$item.Length + sha256 = (Get-FileHash -LiteralPath $full -Algorithm SHA256).Hash + } +} + +function Get-RuntimeTreePin([string] $Root) { + $fullRoot = FullPath $Root 'package.installLocation' + if (-not [IO.Directory]::Exists($fullRoot)) { Fail 'package.installLocation does not exist.' } + Assert-NoReparse $fullRoot 'package.installLocation' + foreach ($directory in [IO.Directory]::GetDirectories($fullRoot, '*', [IO.SearchOption]::AllDirectories)) { + Assert-NoReparse $directory 'package runtime directory' + } + $files = @([IO.Directory]::GetFiles($fullRoot, '*', [IO.SearchOption]::AllDirectories)) + if ($files.Count -eq 0 -or $files.Count -gt 4096) { Fail 'The package runtime tree count is unsafe.' } + $relative = New-Object 'Collections.Generic.List[string]' + $rootPrefix = $fullRoot.TrimEnd('\') + '\' + foreach ($file in $files) { + Assert-NoReparse $file 'package runtime file' + if (-not $file.StartsWith($rootPrefix, [StringComparison]::OrdinalIgnoreCase)) { + Fail 'A package runtime file escaped the install root.' + } + $name = $file.Substring($rootPrefix.Length).Replace('\', '/') + if ($name.Contains('|') -or $name.Contains("`r") -or $name.Contains("`n")) { + Fail 'A package runtime relative path cannot be represented safely.' + } + [void]$relative.Add($name) + } + $names = $relative.ToArray() + [Array]::Sort($names, [StringComparer]::Ordinal) + $builder = [Text.StringBuilder]::new() + foreach ($name in $names) { + $file = Join-Path $fullRoot $name.Replace('/', '\') + $item = Get-Item -LiteralPath $file -Force + $hash = (Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash + [void]$builder.Append($name).Append('|').Append(([int64]$item.Length).ToString([Globalization.CultureInfo]::InvariantCulture)).Append('|').Append($hash).Append("`n") + } + $manifestBytes = $utf8NoBom.GetBytes($builder.ToString()) + try { + $algorithm = [Security.Cryptography.SHA256]::Create() + try { $digest = ([BitConverter]::ToString($algorithm.ComputeHash($manifestBytes))).Replace('-', '') } + finally { $algorithm.Dispose() } + } + finally { [Array]::Clear($manifestBytes, 0, $manifestBytes.Length) } + return [ordered]@{ fileCount = $names.Length; manifestSha256 = $digest } +} + +function Assert-GateConfig([string] $Path, [string] $SceneDirectory) { + $config = Read-Json $Path 'configuration.gate' + $names = @( + 'mode','host','port','tcpMode','clientPort','outputChannel','layoutIndex', + 'legacySceneFadeDuration','legacySceneBackgroundKind', + 'legacySceneBackgroundAssetPath','legacySceneBackgroundVideoLoopCount', + 'legacySceneBackgroundVideoLoopInfinite','legacyBackgroundDirectory', + 'sceneDirectory','testProcessWindowTitlePattern','testSceneAllowlist', + 'trustedLiveOutputEnabled','queueCapacity','connectTimeoutMilliseconds', + 'operationTimeoutMilliseconds','disconnectTimeoutMilliseconds', + 'processPollIntervalMilliseconds','reconnectDelayMilliseconds', + 'maximumReconnectAttempts','reconnectEnabled', + 'maximumAutomaticRefreshesPerTakeIn') + Assert-ExactProperties $config $names 'configuration.gate JSON' + if ($config.testSceneAllowlist -isnot [Array]) { + Fail 'configuration.gate.testSceneAllowlist must be a JSON array.' + } + $allowlist = @(Require $config 'testSceneAllowlist' 'configuration.gate JSON') + Assert-JsonString $config.mode 'DryRun' 'configuration.gate.mode' + Assert-JsonString $config.host '127.0.0.1' 'configuration.gate.host' + Assert-JsonInt $config.port 30001 'configuration.gate.port' + Assert-JsonInt $config.tcpMode 1 'configuration.gate.tcpMode' + Assert-JsonInt $config.clientPort 0 'configuration.gate.clientPort' + Assert-JsonInt $config.layoutIndex 10 'configuration.gate.layoutIndex' + Assert-JsonInt $config.legacySceneFadeDuration 6 'configuration.gate.legacySceneFadeDuration' + Assert-JsonString $config.legacySceneBackgroundKind 'None' 'configuration.gate.legacySceneBackgroundKind' + Assert-JsonInt $config.legacySceneBackgroundVideoLoopCount 2004 'configuration.gate.legacySceneBackgroundVideoLoopCount' + Assert-JsonBool $config.legacySceneBackgroundVideoLoopInfinite $true 'configuration.gate.legacySceneBackgroundVideoLoopInfinite' + Assert-JsonBool $config.trustedLiveOutputEnabled $true 'configuration.gate.trustedLiveOutputEnabled' + Assert-JsonInt $config.queueCapacity 64 'configuration.gate.queueCapacity' + Assert-JsonInt $config.connectTimeoutMilliseconds 5000 'configuration.gate.connectTimeoutMilliseconds' + Assert-JsonInt $config.operationTimeoutMilliseconds 5000 'configuration.gate.operationTimeoutMilliseconds' + Assert-JsonInt $config.disconnectTimeoutMilliseconds 3000 'configuration.gate.disconnectTimeoutMilliseconds' + Assert-JsonInt $config.processPollIntervalMilliseconds 1000 'configuration.gate.processPollIntervalMilliseconds' + Assert-JsonInt $config.reconnectDelayMilliseconds 1000 'configuration.gate.reconnectDelayMilliseconds' + Assert-JsonInt $config.maximumReconnectAttempts 0 'configuration.gate.maximumReconnectAttempts' + Assert-JsonBool $config.reconnectEnabled $false 'configuration.gate.reconnectEnabled' + Assert-JsonInt $config.maximumAutomaticRefreshesPerTakeIn 0 'configuration.gate.maximumAutomaticRefreshesPerTakeIn' + if ( + $null -ne (Require $config 'outputChannel' 'configuration.gate JSON') -or + $null -ne (Require $config 'legacySceneBackgroundAssetPath' 'configuration.gate JSON') -or + $null -ne (Require $config 'legacyBackgroundDirectory' 'configuration.gate JSON') -or + -not (SamePath ([string](Require $config 'sceneDirectory' 'configuration.gate JSON')) $SceneDirectory) -or + $null -ne (Require $config 'testProcessWindowTitlePattern' 'configuration.gate JSON') -or + $allowlist.Count -ne 1 -or $allowlist[0] -isnot [string] -or [string]$allowlist[0] -cne '5001') { + Fail 'configuration.gate JSON is not the closed 5001-only persistent-DryRun configuration.' + } +} + +$specificationPath = FullPath $SpecificationPath 'SpecificationPath' +$outputPath = FullPath $OutputPath 'OutputPath' +if ($outputPath.StartsWith($repositoryRoot.TrimEnd('\') + '\', [StringComparison]::OrdinalIgnoreCase)) { + Fail 'OutputPath must remain outside the Git repository so the execution checkout can be clean.' +} +if ([IO.File]::Exists($outputPath) -or [IO.Directory]::Exists($outputPath)) { + Fail 'OutputPath already exists; a frozen plan is never overwritten.' +} +if (SamePath $specificationPath $outputPath) { Fail 'SpecificationPath and OutputPath must differ.' } +if (-not [IO.File]::Exists($orchestratorPath) -or -not [IO.File]::Exists($nodeHelperPath)) { + Fail 'The committed Gate orchestrator and Node helper must both exist before sealing a plan.' +} + +$spec = Read-Json $specificationPath 'SpecificationPath' +Assert-ExactProperties $spec @( + 'schemaVersion','roundId','repository','package','automation','configuration', + 'cut','k3d','pgm','webView') 'specification' +Assert-JsonInt (Require $spec 'schemaVersion' 'specification') 1 'specification.schemaVersion' +$roundId = [string](Require $spec 'roundId' 'specification') +if ($roundId -cnotmatch '^MBNWEB-[0-9]{8}-[A-Z0-9][A-Z0-9-]{0,31}$') { Fail 'roundId is invalid.' } + +$repo = Require $spec 'repository' 'specification' +Assert-ExactProperties $repo @('path','branch','commit','upstream','remoteUrl') 'repository' +if (-not (SamePath ([string]$repo.path) $repositoryRoot) -or + [string]$repo.branch -cne 'main' -or [string]$repo.upstream -cne 'origin/main' -or + [string]$repo.remoteUrl -cne $expectedRemote -or + [string]$repo.commit -cnotmatch '^[0-9a-f]{40}$') { Fail 'repository pin is not exact.' } + +$package = Require $spec 'package' 'specification' +Assert-ExactProperties $package @( + 'name','familyName','fullName','publisher','version','architecture', + 'applicationId','installLocation','executablePath','msixPath') 'package' +if ([string]$package.name -cne 'Wickedness.MBNStockWebView.LegacyParity' -or + [string]$package.familyName -cne 'Wickedness.MBNStockWebView.LegacyParity_qbv3jkvsn3aj0' -or + [string]$package.fullName -cne 'Wickedness.MBNStockWebView.LegacyParity_0.1.0.0_x64__qbv3jkvsn3aj0' -or + [string]$package.publisher -cne 'CN=Comtrophy' -or [string]$package.version -cne '0.1.0.0' -or + [string]$package.architecture -cne 'X64' -or [string]$package.applicationId -cne 'LegacyParityApp') { + Fail 'package identity is not the exact LegacyParity Release x64 package.' +} +$installLocation = FullPath ([string]$package.installLocation) 'package.installLocation' +$expectedInstallLocation = Join-Path $repositoryRoot 'src\MBN_STOCK_WEBVIEW.LegacyParityApp\bin\x64\Release\net8.0-windows10.0.19041.0\win-x64' +if (-not (SamePath $installLocation $expectedInstallLocation)) { + Fail 'package.installLocation is not the exact Release x64 output.' +} +$executablePath = FullPath ([string]$package.executablePath) 'package.executablePath' +if (-not (SamePath $executablePath (Join-Path $installLocation 'MBN_STOCK_WEBVIEW.LegacyParityApp.exe'))) { + Fail 'package executable is outside the exact install root.' +} +$msixPath = FullPath ([string]$package.msixPath) 'package.msixPath' +if (-not (SamePath $msixPath $approvedMsixPath)) { + Fail 'package.msixPath is not the exact Release x64 AppPackages MSIX.' +} +if ((Get-FileHash -LiteralPath $msixPath -Algorithm SHA256).Hash -cne $approvedMsixSha256) { + Fail 'The approved Release x64 MSIX SHA-256 changed.' +} + +$automation = Require $spec 'automation' 'specification' +Assert-ExactProperties $automation @('nodePath') 'automation' +if (-not (SamePath ([string]$automation.nodePath) $approvedNodePath)) { + Fail 'automation.nodePath is not the approved bundled Node runtime.' +} +$configuration = Require $spec 'configuration' 'specification' +Assert-ExactProperties $configuration @('originalPath','gatePath','sceneDirectory') 'configuration' +$originalConfigPath = FullPath ([string]$configuration.originalPath) 'configuration.originalPath' +$gateConfigPath = FullPath ([string]$configuration.gatePath) 'configuration.gatePath' +$sceneDirectory = FullPath ([string]$configuration.sceneDirectory) 'configuration.sceneDirectory' +if (-not (SamePath $sceneDirectory $approvedSceneDirectory)) { + Fail 'configuration.sceneDirectory is not the approved read-only original Cuts root.' +} +if (SamePath $originalConfigPath $gateConfigPath) { Fail 'Original and Gate config paths must differ.' } +Assert-GateConfig $gateConfigPath $sceneDirectory + +$cut = Require $spec 'cut' 'specification' +Assert-ExactProperties $cut @('path') 'cut' +$cutPath = FullPath ([string]$cut.path) 'cut.path' +if (-not (SamePath $cutPath $approvedCutPath)) { Fail 'cut.path is not the approved read-only original 5001.t2s.' } +if ((Get-FileHash -LiteralPath $cutPath -Algorithm SHA256).Hash -cne $approvedCutSha256) { + Fail 'The approved original 5001.t2s SHA-256 changed.' +} + +$k3d = Require $spec 'k3d' 'specification' +Assert-ExactProperties $k3d @('nativePath','interopPath') 'k3d' +$pgm = Require $spec 'pgm' 'specification' +Assert-ExactProperties $pgm @( + 'processId','startTimeUtc','processName','executablePath','windowTitle', + 'listenerAddress','host','port') 'pgm' +if ($pgm.processId -isnot [int] -or [int]$pgm.processId -le 0 -or $pgm.port -isnot [int] -or [string]$pgm.startTimeUtc -cnotmatch '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{7}Z$' -or + [string]$pgm.windowTitle -cne 'PGM' -or [string]$pgm.listenerAddress -cne '0.0.0.0' -or + [string]$pgm.host -cne '127.0.0.1' -or [int]$pgm.port -ne 30001) { Fail 'PGM pin is not the exact current PGM listener contract.' } + +$webView = Require $spec 'webView' 'specification' +Assert-ExactProperties $webView @('address','port','url') 'webView' +if ($webView.port -isnot [int] -or [string]$webView.address -cne '127.0.0.1' -or [int]$webView.port -lt 1024 -or [int]$webView.port -gt 65535 -or + [int]$webView.port -eq 30001 -or [string]$webView.url -cne $expectedUrl) { Fail 'WebView pin is invalid.' } + +$plan = [ordered]@{ + schemaVersion = 1 + kind = 'LegacyPgmPrepareGatePlan' + roundId = $roundId + createdAtUtc = [DateTime]::UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss.fffffff'Z'", [Globalization.CultureInfo]::InvariantCulture) + repository = [ordered]@{ + path = $repositoryRoot; branch = 'main'; commit = [string]$repo.commit + upstream = 'origin/main'; remoteUrl = $expectedRemote + } + package = [ordered]@{ + name = [string]$package.name; familyName = [string]$package.familyName + fullName = [string]$package.fullName; publisher = [string]$package.publisher + version = [string]$package.version; architecture = 'X64' + applicationId = [string]$package.applicationId; installLocation = $installLocation + executable = New-FilePin $executablePath 'package executable' + msix = New-FilePin $msixPath 'package MSIX' + runtimeTree = Get-RuntimeTreePin $installLocation + } + automation = [ordered]@{ + orchestrator = New-FilePin $orchestratorPath 'Gate orchestrator' + node = New-FilePin ([string]$automation.nodePath) 'Node runtime' + helper = New-FilePin $nodeHelperPath 'Node Gate helper' + } + configuration = [ordered]@{ + original = New-FilePin $originalConfigPath 'original DryRun config' + gate = New-FilePin $gateConfigPath 'Gate DryRun config' + database = New-FilePin $approvedDatabaseConfigPath 'database config' + sceneDirectory = $sceneDirectory + } + cut = [ordered]@{ + sceneCode = '5001'; pageNumberOneBased = 1 + file = New-FilePin $cutPath '5001 cut' + } + selector = [ordered]@{ + marketText = $expectedMarketText; groupCode = 'KOSPI'; stockName = $expectedStockName + stockCode = '005930'; cutLabel = $expectedCutLabel + graphicType = $expectedGraphicType; subtype = $expectedSubtype + normalizedSubtype = 'CURRENT'; sceneAlias = '5001' + } + k3d = [ordered]@{ + native = New-FilePin ([string]$k3d.nativePath) 'K3D native binary' + interop = New-FilePin ([string]$k3d.interopPath) 'K3D interop assembly' + } + pgm = [ordered]@{ + processId = [int]$pgm.processId; startTimeUtc = [string]$pgm.startTimeUtc + processName = [string]$pgm.processName + executable = New-FilePin ([string]$pgm.executablePath) 'PGM executable' + windowTitle = 'PGM'; listenerAddress = '0.0.0.0'; host = '127.0.0.1'; port = 30001 + } + webView = [ordered]@{ address = '127.0.0.1'; port = [int]$webView.port; url = $expectedUrl } + approvalPolicy = [ordered]@{ + requiresSeparateAuthorization = $true; maximumAgeSeconds = 900; maximumClockSkewSeconds = 5 + } + budgets = [ordered]@{ + connect = 1; prepare = 1; takeIn = 0; next = 0; pageNext = 0 + takeOut = 0; databaseWrite = 0; appClose = 0; retry = 0 + } + safety = [ordered]@{ + automaticConnectOnly = $true; nodeHelperInvocationMaximum = 1 + nodeHelperFailStopRequired = $true + packageWrapperFailStopRequired = $true + persistentGateConfigModeDryRun = $true; restoreOriginalBeforePrepare = $true + nativeOneShotPrepareGateRequired = $true + leaveApplicationAndSessionUntouched = $true; forceCloseForbidden = $true + automaticSessionCleanupForbiddenAfterLaunch = $true; retryForbidden = $true + } +} + +$parent = [IO.Path]::GetDirectoryName($outputPath) +if ([string]::IsNullOrWhiteSpace($parent)) { Fail 'OutputPath parent is invalid.' } +[void][IO.Directory]::CreateDirectory($parent) +Assert-NoReparse $parent 'OutputPath parent' +$json = ($plan | ConvertTo-Json -Depth 12) + [Environment]::NewLine +$bytes = $utf8NoBom.GetBytes($json) +try { + $stream = [IO.File]::Open($outputPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::Read) + try { $stream.Write($bytes, 0, $bytes.Length); $stream.Flush($true) } + finally { $stream.Dispose() } +} +finally { [Array]::Clear($bytes, 0, $bytes.Length) } + +[pscustomobject][ordered]@{ + result = 'PLAN_CREATED_COMMAND_FREE' + roundId = $roundId + planPath = $outputPath + planLength = (Get-Item -LiteralPath $outputPath).Length + planSha256 = (Get-FileHash -LiteralPath $outputPath -Algorithm SHA256).Hash + authorizationCreated = $false + appCommandsIssued = 0 + vendorCommandsIssued = 0 +} diff --git a/scripts/Test-LegacyPgmPrepareGate.mjs b/scripts/Test-LegacyPgmPrepareGate.mjs new file mode 100644 index 0000000..c4a6f8d --- /dev/null +++ b/scripts/Test-LegacyPgmPrepareGate.mjs @@ -0,0 +1,821 @@ +import fs from "node:fs"; +import net from "node:net"; +import path from "node:path"; +import { createHash } from "node:crypto"; + +const exactUrl = "https://legacy-parity.mbn.local/index.html"; +const requiredSelector = Object.freeze({ + searchText: "삼성전자", + marketText: "코스피", + stockName: "삼성전자", + cutLabel: "1열판기본_현재가", + graphicType: "1열판기본", + subtype: "현재가", + preparedCode: "5001", + pageNumberOneBased: 1 +}); +const acceptedArguments = new Set([ + "port", "plan", "plan-sha256", "authorization", "authorization-sha256", + "output", "screenshot", "capability-sha256", "permit-pipe", "timeout-ms" +]); +const forbiddenCdpMethods = new Set([ + "Browser.close", "Page.close", "Runtime.terminateExecution", "Target.closeTarget" +]); + +class GateFailure extends Error { + constructor(category, message) { + super(`${category}: ${message}`); + this.name = "GateFailure"; + this.category = category; + } +} + +const known = message => { throw new GateFailure("KNOWN_FAILURE", message); }; +const unknown = message => { throw new GateFailure("OUTCOME_UNKNOWN", message); }; +const sha256 = bytes => createHash("sha256").update(bytes).digest("hex").toUpperCase(); +const isSha256 = value => /^[0-9A-F]{64}$/u.test(String(value || "")); +const isSafeId = value => /^[A-Za-z0-9._:-]{1,128}$/u.test(String(value || "")); + +function parseArguments(argv) { + if (argv.length === 0 || argv.length % 2 !== 0) known("Arguments must be --name value pairs."); + const values = new Map(); + for (let index = 0; index < argv.length; index += 2) { + const raw = argv[index]; + const value = argv[index + 1]; + if (!raw?.startsWith("--") || !value) known("Every argument requires a value."); + const name = raw.slice(2); + if (!acceptedArguments.has(name) || values.has(name)) known(`Invalid argument --${name}.`); + values.set(name, value); + } + for (const name of [ + "port", "plan", "plan-sha256", "authorization", "authorization-sha256", + "output", "screenshot", "capability-sha256", "permit-pipe" + ]) { + if (!values.has(name)) known(`--${name} is required.`); + } + const port = Number(values.get("port")); + if (!Number.isInteger(port) || port < 1 || port > 65535) known("--port is invalid."); + const timeoutMilliseconds = values.has("timeout-ms") ? Number(values.get("timeout-ms")) : 90000; + if (!Number.isInteger(timeoutMilliseconds) || timeoutMilliseconds < 15000 || + timeoutMilliseconds > 180000) known("--timeout-ms must be 15000 through 180000."); + const result = { + port, + timeoutMilliseconds, + planPath: path.resolve(values.get("plan")), + planSha256: String(values.get("plan-sha256")).toUpperCase(), + authorizationPath: path.resolve(values.get("authorization")), + authorizationSha256: String(values.get("authorization-sha256")).toUpperCase(), + outputPath: path.resolve(values.get("output")), + screenshotPath: path.resolve(values.get("screenshot")), + capabilitySha256: String(values.get("capability-sha256")).toUpperCase(), + permitPipeName: String(values.get("permit-pipe")) + }; + for (const [name, candidate, extension] of [ + ["plan", result.planPath, ".json"], + ["authorization", result.authorizationPath, ".json"], + ["output", result.outputPath, ".json"], + ["screenshot", result.screenshotPath, ".png"] + ]) { + if (!path.isAbsolute(values.get(name)) || path.extname(candidate).toLowerCase() !== extension) { + known(`--${name} must be an absolute ${extension} path.`); + } + } + if (!isSha256(result.planSha256) || !isSha256(result.authorizationSha256) || + !isSha256(result.capabilitySha256)) { + known("A supplied SHA-256 is invalid."); + } + if (!/^MBN_STOCK_WEBVIEW\.GateA\.Node\.[0-9A-F]{32}$/u.test(result.permitPipeName)) { + known("The parent-owned JIT permit pipe name is invalid."); + } + if (new Set([result.planPath, result.authorizationPath, result.outputPath, result.screenshotPath] + .map(value => value.toLowerCase())).size !== 4) known("Evidence paths must be distinct."); + if (fs.existsSync(result.outputPath) || fs.existsSync(result.screenshotPath)) { + known("Output evidence is never overwritten."); + } + return result; +} + +function readFrozenJson(filePath, label, expectedHash = null) { + const bytes = fs.readFileSync(filePath); + const actualHash = sha256(bytes); + if (expectedHash && actualHash !== expectedHash) known(`${label} SHA-256 changed.`); + let value; + try { value = JSON.parse(bytes.toString("utf8")); } + catch { known(`${label} is not strict JSON.`); } + return { bytes, hash: actualHash, value }; +} + +const configuration = parseArguments(process.argv.slice(2)); +if (Object.keys(process.env).some(name => name.startsWith("MBN_LEGACY_PGM_GATE_A_"))) { + known("Gate A bearer material must not be inherited through the Node environment."); +} +const startedAtUtc = new Date().toISOString(); +const hardDeadline = Date.now() + configuration.timeoutMilliseconds; +const evidence = { + schemaVersion: 1, + result: "RUNNING", + startedAtUtc, + completedAtUtc: null, + planSha256: configuration.planSha256, + roundId: null, + target: { expectedUrl: exactUrl, port: configuration.port, id: null }, + commandBudget: { connect: 1, prepare: 1, takeIn: 0, next: 0, pageNext: 0, takeOut: 0, databaseWrite: 0, appClose: 0, retry: 0 }, + observed: { + prepareDispatchPossible: false, + prepareIssued: false, + busyObserved: false, + jitPermitRequested: false, + jitPermitReceived: false, + outboundTypes: [], + blocked: [] + }, + checkpoints: [], + screenshot: null, + finalState: null, + error: null +}; + +let socket = null; +let nextRpcId = 1; +const pending = new Map(); +let prepareDispatchPossible = false; +let prepareIssued = false; + +function assertDeadline(label) { + if (Date.now() >= hardDeadline) unknown(`Global Gate A deadline expired ${label}; no retry.`); +} + +async function sleep(milliseconds) { + assertDeadline("before wait"); + await new Promise(resolve => setTimeout(resolve, Math.min(milliseconds, hardDeadline - Date.now()))); + assertDeadline("after wait"); +} + +function validateFrozenContract() { + const planFile = readFrozenJson(configuration.planPath, "plan", configuration.planSha256); + const plan = planFile.value; + if (plan?.schemaVersion !== 1 || plan?.kind !== "LegacyPgmPrepareGatePlan" || + !isSafeId(plan.roundId) || plan?.webView?.url !== exactUrl || + plan?.webView?.address !== "127.0.0.1" || + plan?.webView?.port !== configuration.port || + plan?.safety?.nodeHelperInvocationMaximum !== 1 || + plan?.safety?.nodeHelperFailStopRequired !== true || + plan?.safety?.packageWrapperFailStopRequired !== true || + plan?.safety?.forceCloseForbidden !== true || + plan?.safety?.automaticSessionCleanupForbiddenAfterLaunch !== true) { + known("Plan identity or target contract is invalid."); + } + const databasePin = plan?.configuration?.database; + if (typeof databasePin?.path !== "string" || !path.isAbsolute(databasePin.path) || + !Number.isSafeInteger(databasePin?.length) || databasePin.length <= 0 || + !isSha256(databasePin?.sha256)) { + known("Plan database configuration pin is invalid."); + } + const selectorMap = { + marketText: requiredSelector.marketText, + stockName: requiredSelector.stockName, + cutLabel: requiredSelector.cutLabel, + graphicType: requiredSelector.graphicType, + subtype: requiredSelector.subtype, + sceneAlias: requiredSelector.preparedCode + }; + for (const [name, value] of Object.entries(selectorMap)) { + if (plan?.selector?.[name] !== value) known(`Plan selector ${name} is not exact.`); + } + if (plan?.selector?.groupCode !== "KOSPI" || plan?.selector?.stockCode !== "005930" || + plan?.selector?.normalizedSubtype !== "CURRENT" || + plan?.cut?.sceneCode !== "5001" || + plan?.cut?.pageNumberOneBased !== requiredSelector.pageNumberOneBased) { + known("Plan selector identity is incomplete."); + } + const limits = plan?.budgets; + if (limits?.connect !== 1 || limits?.prepare !== 1 || limits?.takeIn !== 0 || + limits?.next !== 0 || limits?.pageNext !== 0 || limits?.takeOut !== 0 || + limits?.databaseWrite !== 0 || limits?.appClose !== 0 || limits?.retry !== 0) { + known("Plan command budget is not exact Gate A."); + } + const authorizationFile = readFrozenJson( + configuration.authorizationPath, + "authorization", + configuration.authorizationSha256); + const authorization = authorizationFile.value; + const approvedAt = Date.parse(authorization?.authorizedAtUtc || ""); + const expiresAt = Date.parse(authorization?.expiresAtUtc || ""); + if (authorization?.schemaVersion !== 1 || + authorization?.kind !== "LegacyPgmPrepareGateAuthorization" || + authorization?.roundId !== plan.roundId || + String(authorization?.planSha256 || "").toUpperCase() !== configuration.planSha256 || + authorization?.authorizationStatement !== + "I_AUTHORIZE_CURRENT_PGM_LIVE_CONNECT_ONCE_AND_PREPARE_5001_PAGE_1_ONCE" || + authorization?.currentK3dLicenseValid !== true || + authorization?.runningTornadoIsLicensedMainProgram !== true || + authorization?.currentPgmApproved !== true || + !Number.isFinite(approvedAt) || !Number.isFinite(expiresAt) || + expiresAt <= approvedAt || expiresAt - approvedAt > 15 * 60 * 1000 || + Date.now() < approvedAt - 5000 || Date.now() >= expiresAt - 5000) { + known("Gate A authorization is invalid, expired, or too near expiry."); + } + const authorizationBudget = authorization?.budgets; + if (authorizationBudget?.connect !== 1 || authorizationBudget?.prepare !== 1 || + authorizationBudget?.takeIn !== 0 || authorizationBudget?.next !== 0 || + authorizationBudget?.pageNext !== 0 || authorizationBudget?.takeOut !== 0 || + authorizationBudget?.databaseWrite !== 0 || authorizationBudget?.appClose !== 0 || + authorizationBudget?.retry !== 0) { + known("Authorization command budget is not exact Gate A."); + } + evidence.roundId = plan.roundId; + return { plan, authorization, authorizationHash: authorizationFile.hash, expiresAt }; +} + +async function discoverTarget() { + let lastError = null; + while (Date.now() < hardDeadline) { + try { + const response = await fetch(`http://127.0.0.1:${configuration.port}/json/list`, { + redirect: "error", signal: AbortSignal.timeout(1000) + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const targets = await response.json(); + const exact = Array.isArray(targets) ? targets.filter(target => + target?.type === "page" && target.url === exactUrl) : []; + if (exact.length > 1) known("More than one exact WebView target exists."); + if (exact.length === 1) return exact[0]; + } catch (error) { lastError = String(error?.message || error); } + await sleep(100); + } + known(`Exact WebView target was not found${lastError ? `: ${lastError}` : "."}`); +} + +function validateEndpoint(target) { + if (!target?.id || !target.webSocketDebuggerUrl) known("Target has no CDP endpoint."); + const endpoint = new URL(target.webSocketDebuggerUrl); + if (endpoint.protocol !== "ws:" || endpoint.hostname !== "127.0.0.1" || + Number(endpoint.port) !== configuration.port || + endpoint.pathname !== `/devtools/page/${target.id}`) known("CDP endpoint is not exact loopback."); + evidence.target.id = target.id; + return endpoint; +} + +async function connect(endpoint) { + if (typeof WebSocket !== "function") known("Node WebSocket API is unavailable."); + socket = new WebSocket(endpoint.href); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new GateFailure("OUTCOME_UNKNOWN", "CDP open timed out.")), 5000); + socket.addEventListener("open", () => { clearTimeout(timer); resolve(); }, { once: true }); + socket.addEventListener("error", () => { + clearTimeout(timer); reject(new GateFailure("OUTCOME_UNKNOWN", "CDP open failed.")); + }, { once: true }); + }); + socket.addEventListener("message", event => { + let message; + try { message = JSON.parse(String(event.data)); } catch { return; } + if (!Object.hasOwn(message, "id")) return; + const request = pending.get(message.id); + if (!request) return; + pending.delete(message.id); + clearTimeout(request.timer); + if (message.error) request.reject(new GateFailure("KNOWN_FAILURE", `${request.method}: ${message.error.message}`)); + else request.resolve(message.result); + }); + socket.addEventListener("close", () => { + for (const request of pending.values()) { + clearTimeout(request.timer); + request.reject(new GateFailure("OUTCOME_UNKNOWN", `${request.method} interrupted by CDP close.`)); + } + pending.clear(); + }); +} + +function rpc(method, params = {}, timeoutMilliseconds = 10000) { + assertDeadline(`before ${method}`); + if (forbiddenCdpMethods.has(method)) known(`Forbidden CDP method ${method}.`); + if (!socket || socket.readyState !== WebSocket.OPEN) unknown(`CDP is unavailable for ${method}.`); + const id = nextRpcId++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pending.delete(id); + reject(new GateFailure("OUTCOME_UNKNOWN", `${method} timed out; no retry.`)); + }, Math.min(timeoutMilliseconds, hardDeadline - Date.now())); + pending.set(id, { method, resolve, reject, timer }); + socket.send(JSON.stringify({ id, method, params })); + }); +} + +async function evaluate(expression) { + const response = await rpc("Runtime.evaluate", { + expression, awaitPromise: true, returnByValue: true, userGesture: false + }); + if (response.exceptionDetails) known(response.exceptionDetails.exception?.description || + response.exceptionDetails.text || "Runtime evaluation failed."); + return response.result?.value; +} + +async function installGate(planSha256, capabilitySha256) { + const token = `legacy-pgm-prepare-${Date.now()}`; + const result = await evaluate(`(() => { + if (!window.chrome?.webview) throw new Error("WebView bridge unavailable"); + if (globalThis.__legacyPgmPrepareGate) throw new Error("Gate already installed"); + const allowedBeforePrepare = new Set([ + "ready", "search-stocks", "select-stock", "cut-pointer-down", "drop-selected-cuts" + ]); + const gate = { + version: 1, + token: ${JSON.stringify(token)}, + planSha256: ${JSON.stringify(planSha256)}, + capabilityHash: ${JSON.stringify(capabilitySha256)}, + original: window.chrome.webview.postMessage, + wrapper: null, + listener: null, + states: [], + outbound: [], + blocked: [], + armed: false, + prepareCapability: null, + prepareCount: 0, + prepareIssuedAtUtc: null, + stateCountAtPrepare: null, + expectedPreRevision: null, + busyObservedAtPreRevision: false + }; + const hashText = async value => { + const bytes = new TextEncoder().encode(value); + const digest = await crypto.subtle.digest("SHA-256", bytes); + return Array.from(new Uint8Array(digest), b => b.toString(16).padStart(2, "0")) + .join("").toUpperCase(); + }; + gate.listener = event => { + if (event.data?.type !== "state" || !event.data.payload) return; + const state = structuredClone(event.data.payload); + gate.states.push(state); + if (gate.states.length > 500) gate.states.shift(); + if (gate.prepareCount === 1 && state?.isBusy === true && + state?.revision === gate.expectedPreRevision) { + gate.busyObservedAtPreRevision = true; + } + }; + gate.wrapper = message => { + const type = typeof message?.type === "string" ? message.type : null; + const payload = message?.payload; + const record = { type, atUtc: new Date().toISOString() }; + gate.outbound.push(record); + let allowed = false; + if (type === "gate-a-prepare") { + const empty = payload && typeof payload === "object" && + !Array.isArray(payload) && Object.keys(payload).length === 1 && + typeof payload.capability === "string" && + payload.capability === gate.prepareCapability; + allowed = gate.armed && gate.prepareCount === 0 && empty; + if (allowed) { + gate.armed = false; + gate.prepareCapability = null; + gate.prepareCount = 1; + gate.prepareIssuedAtUtc = record.atUtc; + gate.stateCountAtPrepare = gate.states.length; + } + } else { + allowed = !gate.armed && gate.prepareCount === 0 && allowedBeforePrepare.has(type); + } + if (!allowed) { + if (gate.armed) { + gate.armed = false; + gate.prepareCapability = null; + } + gate.blocked.push(record); + return; + } + return gate.original.call(window.chrome.webview, message); + }; + gate.arm = async (supplied, expectedPreRevision) => { + if (gate.armed || gate.prepareCount !== 0 || gate.blocked.length !== 0 || + !Number.isSafeInteger(expectedPreRevision) || expectedPreRevision < 0 || + await hashText(String(supplied || "")) !== gate.capabilityHash) { + throw new Error("One-shot PREPARE capability rejected"); + } + gate.expectedPreRevision = expectedPreRevision; + gate.prepareCapability = String(supplied); + gate.armed = true; + return { armed: true, stateCount: gate.states.length }; + }; + gate.inspect = () => ({ + version: gate.version, + token: gate.token, + planSha256: gate.planSha256, + wrapperCurrent: window.chrome.webview.postMessage === gate.wrapper, + stateCount: gate.states.length, + states: gate.states.slice(), + outbound: gate.outbound.slice(), + blocked: gate.blocked.slice(), + armed: gate.armed, + prepareCount: gate.prepareCount, + prepareIssuedAtUtc: gate.prepareIssuedAtUtc, + stateCountAtPrepare: gate.stateCountAtPrepare, + expectedPreRevision: gate.expectedPreRevision, + busyObservedAtPreRevision: gate.busyObservedAtPreRevision + }); + window.chrome.webview.postMessage = gate.wrapper; + if (window.chrome.webview.postMessage !== gate.wrapper) throw new Error("Gate identity failed"); + window.chrome.webview.addEventListener("message", gate.listener); + globalThis.__legacyPgmPrepareGate = Object.freeze({ + arm: gate.arm, + inspect: gate.inspect + }); + window.chrome.webview.postMessage({ type: "ready", payload: {} }); + return { token: gate.token, wrapperCurrent: window.chrome.webview.postMessage === gate.wrapper }; + })()`); + if (result?.token !== token || result.wrapperCurrent !== true) unknown("Page gate did not install exactly."); +} + +function projectedState(state) { + if (!state) return null; + const playout = state.playout || {}; + return { + revision: state.revision, isBusy: state.isBusy, statusMessage: state.statusMessage, + statusKind: state.statusKind, + playlist: (state.playlist || []).map(row => ({ + rowId: row.rowId, isEnabled: row.isEnabled, isActive: row.isActive, + marketText: row.marketText, stockName: row.stockName, + graphicType: row.graphicType, subtype: row.subtype, pageText: row.pageText + })), + playout: { + mode: playout.mode, connectionState: playout.connectionState, phase: playout.phase, + isConnected: playout.isConnected, isCommandAvailable: playout.isCommandAvailable, + liveTakeInAllowed: playout.liveTakeInAllowed, + isPlayCompletionPending: playout.isPlayCompletionPending, + isTakeOutCompletionPending: playout.isTakeOutCompletionPending, + outcomeUnknown: playout.outcomeUnknown, preparedCode: playout.preparedCode, + onAirCode: playout.onAirCode, currentEntryId: playout.currentEntryId, + pageIndexZeroBased: playout.pageIndexZeroBased, pageCount: playout.pageCount, + pageSize: playout.pageSize, itemCount: playout.itemCount, + currentPageItemCount: playout.currentPageItemCount, isLastPage: playout.isLastPage, + nextKind: playout.nextKind, isBusy: playout.isBusy, + refreshActive: playout.refreshActive, refreshCompletedCount: playout.refreshCompletedCount, + refreshMaximumCount: playout.refreshMaximumCount, + refreshLimitReached: playout.refreshLimitReached, message: playout.message + } + }; +} + +async function snapshot() { + return await evaluate(`(() => { + const gate = globalThis.__legacyPgmPrepareGate; + if (!gate || typeof gate.inspect !== "function") return null; + const audit = gate.inspect(); + const state = audit.states.at(-1) || null; + return { + audit, + state, + dom: { + readyState: document.readyState, + url: location.href, + connection: (document.getElementById("playout-connection-state")?.textContent || "").trim(), + status: (document.getElementById("playout-status")?.textContent || "").trim(), + dialogHidden: document.getElementById("legacy-dialog")?.hidden === true, + dialogText: (document.getElementById("legacy-dialog-message")?.textContent || "").trim() + } + }; + })()`); +} + +function assertCommon(value, label) { + if (!value?.state?.playout || value.dom?.url !== exactUrl || + value.dom.readyState !== "complete" || value.audit?.wrapperCurrent !== true) { + unknown(`Authoritative state or gate missing at ${label}.`); + } + if (value.audit.blocked.length !== 0) unknown(`An unapproved message was blocked at ${label}.`); + if (value.state.playout.outcomeUnknown === true) unknown(`Native outcome is unknown at ${label}.`); + if (value.state.playout.onAirCode != null || value.state.playout.phase === "program") { + unknown(`PROGRAM output was observed at ${label}.`); + } + if (value.dom.dialogHidden !== true) known(`Legacy dialog is visible at ${label}: ${value.dom.dialogText}`); +} + +async function waitFor(label, predicate, timeoutMilliseconds = null) { + const deadline = Math.min(hardDeadline, Date.now() + (timeoutMilliseconds || configuration.timeoutMilliseconds)); + let last = null; + while (Date.now() < deadline) { + last = await snapshot(); + if (last?.state?.playout) assertCommon(last, label); + if (predicate(last)) return last; + await sleep(100); + } + unknown(`Timed out waiting for ${label}; no retry. Last=${JSON.stringify(projectedState(last?.state))}`); +} + +function isConnectedIdle(value) { + const p = value?.state?.playout; + return p?.mode === "live" && p.connectionState === "connected" && p.phase === "idle" && + p.isConnected === true && p.isCommandAvailable === true && p.liveTakeInAllowed === false && + p.outcomeUnknown !== true && p.preparedCode == null && p.onAirCode == null && + p.isBusy !== true && p.isPlayCompletionPending !== true && + p.isTakeOutCompletionPending !== true && p.refreshActive !== true && + value.state.isBusy !== true; +} + +function exactPlaylist(state) { + const rows = state?.playlist || []; + return rows.length === 1 && rows[0].isEnabled === true && rows[0].isActive === true && + rows[0].marketText === requiredSelector.marketText && + rows[0].stockName === requiredSelector.stockName && + rows[0].graphicType === requiredSelector.graphicType && + rows[0].subtype === requiredSelector.subtype && rows[0].pageText === "1/1"; +} + +function assertExactOutbound(audit, expectPrepare) { + const types = audit?.outbound?.map(item => item.type) || []; + const count = type => types.filter(value => value === type).length; + const selectedCount = count("select-stock"); + if (count("ready") !== 1 || count("search-stocks") !== 1 || + selectedCount > 1 || count("cut-pointer-down") !== 1 || + count("drop-selected-cuts") !== 1 || + count("gate-a-prepare") !== (expectPrepare ? 1 : 0) || + audit?.blocked?.length !== 0 || + audit?.prepareCount !== (expectPrepare ? 1 : 0)) { + unknown("Gate A outbound ledger counts are not exact."); + } + const expected = ["ready", "search-stocks"]; + if (selectedCount === 1) expected.push("select-stock"); + expected.push("cut-pointer-down", "drop-selected-cuts"); + if (expectPrepare) expected.push("gate-a-prepare"); + if (types.length !== expected.length || + types.some((type, index) => type !== expected[index])) { + unknown(`Gate A outbound ledger order is not exact: ${JSON.stringify(types)}`); + } +} + +function isPrepared5001(value) { + const p = value?.state?.playout; + const row = value?.state?.playlist?.[0]; + return exactPlaylist(value?.state) && p?.mode === "live" && + p.connectionState === "connected" && p.phase === "prepared" && p.isConnected === true && + p.isCommandAvailable === true && p.liveTakeInAllowed === false && p.outcomeUnknown !== true && + p.preparedCode === "5001" && p.onAirCode == null && p.currentEntryId === row?.rowId && + p.pageIndexZeroBased === 0 && p.pageCount === 1 && p.itemCount === 1 && + p.currentPageItemCount === 1 && p.isLastPage === true && + String(p.nextKind).toLowerCase() === "endofplaylist" && + p.isBusy !== true && p.isPlayCompletionPending !== true && + p.isTakeOutCompletionPending !== true && p.refreshActive !== true && + value.state.isBusy !== true; +} + +async function click(expression, label) { + const geometry = await evaluate(`(() => { + const element = ${expression}; + if (!element || element.disabled || element.getAttribute("aria-disabled") === "true") return null; + const r = element.getBoundingClientRect(); + if (r.width <= 0 || r.height <= 0) return null; + return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; + })()`); + if (!geometry) known(`${label} is unavailable.`); + await rpc("Input.dispatchMouseEvent", { type: "mouseMoved", ...geometry }); + await rpc("Input.dispatchMouseEvent", { + type: "mousePressed", ...geometry, button: "left", buttons: 1, clickCount: 1 + }); + await rpc("Input.dispatchMouseEvent", { + type: "mouseReleased", ...geometry, button: "left", buttons: 0, clickCount: 1 + }); +} + +async function configurePlaylist() { + await evaluate(`(() => { + const input = document.getElementById("stock-search"); + if (!input) throw new Error("stock search unavailable"); + input.value = ${JSON.stringify(requiredSelector.searchText)}; + })()`); + await click('document.getElementById("stock-search-button")', "stock search button"); + let current = await waitFor("exact stock search", value => { + const state = value?.state; + return state?.isBusy === false && state.searchText === requiredSelector.searchText && + (state.searchResults || []).filter(row => row.displayName === requiredSelector.stockName).length === 1; + }, 60000); + const exact = current.state.searchResults.filter(row => row.displayName === requiredSelector.stockName)[0]; + if (current.state.selectedStockIndex !== exact.index) { + await click(`document.querySelector('#stock-results button[data-result-index="${exact.index}"]')`, + "exact stock result"); + current = await waitFor("exact stock selection", value => + value?.state?.selectedStockIndex === exact.index && value.state.isBusy === false, 15000); + } + const geometry = await evaluate(`(() => { + const state = globalThis.__legacyPgmPrepareGate.inspect().states.at(-1); + const cut = (state?.cutRows || []).find(row => row.rawLabel === + ${JSON.stringify(requiredSelector.cutLabel)} && row.isSeparator !== true); + if (!cut) return null; + const source = document.querySelector('#cut-list .cut-row[data-physical-index="' + + String(cut.physicalIndex) + '"]'); + const target = document.getElementById("playlist-drop-zone"); + if (!source || !target) return null; + const a = source.getBoundingClientRect(); + const b = target.getBoundingClientRect(); + return { + physicalIndex: cut.physicalIndex, + source: { x: a.left + a.width / 2, y: a.top + a.height / 2 }, + threshold: { x: a.left + a.width / 2 + 20, y: a.top + a.height / 2 + 20 }, + target: { x: b.left + b.width / 2, y: b.top + Math.min(80, b.height / 2) } + }; + })()`); + if (!geometry) known("Exact 5001 cut row or drop target is unavailable."); + await rpc("Input.dispatchMouseEvent", { type: "mouseMoved", ...geometry.source }); + await rpc("Input.dispatchMouseEvent", { + type: "mousePressed", ...geometry.source, button: "left", buttons: 1, clickCount: 1 + }); + await rpc("Input.dispatchMouseEvent", { + type: "mouseMoved", ...geometry.threshold, button: "left", buttons: 1 + }); + await rpc("Input.dispatchMouseEvent", { + type: "mouseMoved", ...geometry.target, button: "left", buttons: 1 + }); + await rpc("Input.dispatchMouseEvent", { + type: "mouseReleased", ...geometry.target, button: "left", buttons: 0, clickCount: 1 + }); + current = await waitFor("exact 5001 playlist", value => + value?.state?.isBusy === false && exactPlaylist(value.state), 30000); + assertExactOutbound(current.audit, false); + evidence.checkpoints.push({ name: "playlist-5001", state: projectedState(current.state) }); + return current; +} + +async function receiveJitCapability(expiresAt) { + if (Date.now() >= expiresAt - 5000) known("Authorization is too near expiry before JIT permit."); + evidence.observed.jitPermitRequested = true; + const remaining = Math.min(15000, hardDeadline - Date.now(), expiresAt - 5000 - Date.now()); + if (remaining <= 0) known("No safe time remains for the JIT permit."); + const pipePath = `\\\\.\\pipe\\${configuration.permitPipeName}`; + const capability = await new Promise((resolve, reject) => { + let settled = false; + let total = 0; + const chunks = []; + const client = net.createConnection(pipePath); + const finish = (error, value = null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + client.destroy(); + if (error) reject(error); + else resolve(value); + }; + const timer = setTimeout(() => finish(new GateFailure( + "KNOWN_FAILURE", "The parent did not grant the one-shot JIT permit.")), remaining); + client.on("data", chunk => { + total += chunk.length; + if (total > 64) { + finish(new GateFailure("KNOWN_FAILURE", "The JIT permit payload length is invalid.")); + return; + } + chunks.push(chunk); + }); + client.on("end", () => { + const bytes = Buffer.concat(chunks, total); + try { + const value = bytes.toString("ascii"); + if (bytes.length !== 64 || !/^[0-9A-F]{64}$/u.test(value) || + sha256(bytes) !== configuration.capabilitySha256) { + finish(new GateFailure("KNOWN_FAILURE", "The JIT permit did not match the sealed capability hash.")); + return; + } + finish(null, value); + } finally { + bytes.fill(0); + for (const chunk of chunks) chunk.fill(0); + } + }); + client.on("error", () => finish(new GateFailure( + "KNOWN_FAILURE", "The parent-owned JIT permit pipe closed without authority."))); + }); + evidence.observed.jitPermitReceived = true; + return capability; +} + +async function prepareOnce(expiresAt) { + if (Date.now() >= expiresAt - 5000) known("Authorization is too near expiry before PREPARE."); + let before = await snapshot(); + assertCommon(before, "PREPARE boundary"); + if (!isConnectedIdle(before) || !exactPlaylist(before.state)) known("PREPARE boundary is not exact IDLE 5001."); + const preRevision = before.state.revision; + let capability = await receiveJitCapability(expiresAt); + before = await snapshot(); + assertCommon(before, "post-permit PREPARE boundary"); + if (before.state.revision !== preRevision || !isConnectedIdle(before) || + !exactPlaylist(before.state)) { + capability = null; + known("The exact PREPARE boundary changed while the parent granted the JIT permit."); + } + const armed = await evaluate(`globalThis.__legacyPgmPrepareGate.arm( + ${JSON.stringify(capability)}, ${JSON.stringify(preRevision)})`); + if (armed?.armed !== true) unknown("PREPARE permit did not arm."); + if (Date.now() >= expiresAt - 5000) known("Authorization expired at PREPARE boundary."); + prepareDispatchPossible = true; + evidence.observed.prepareDispatchPossible = true; + const dispatched = await evaluate(`(() => { + if (!globalThis.__legacyPgmPrepareGate) throw new Error("PREPARE gate unavailable"); + window.chrome.webview.postMessage({ + type: "gate-a-prepare", + payload: { capability: ${JSON.stringify(capability)} } + }); + return true; + })()`); + capability = null; + if (dispatched !== true) unknown("PREPARE bridge dispatch did not return exactly."); + prepareIssued = true; + evidence.observed.prepareIssued = true; + let current; + let busyObserved = false; + while (Date.now() < hardDeadline) { + current = await snapshot(); + assertCommon(current, "PREPARE observation"); + if (current.audit.prepareCount !== 1) unknown("PREPARE one-shot ledger changed."); + if (current.audit.busyObservedAtPreRevision === true) { + busyObserved = true; + evidence.observed.busyObserved = true; + } + if (busyObserved && isPrepared5001(current) && current.state.revision > preRevision) { + evidence.checkpoints.push({ name: "prepared-5001", state: projectedState(current.state) }); + return current; + } + const p = current.state.playout; + if (busyObserved && current.state.isBusy === false && current.state.revision > preRevision && + p.phase === "idle" && p.preparedCode == null && p.onAirCode == null && + p.outcomeUnknown !== true && String(current.state.statusKind).toLowerCase() === "error" && + String(current.state.statusMessage || "").length > 0) { + throw new GateFailure("KNOWN_FAILURE", `PREPARE rejected: ${current.state.statusMessage}`); + } + await sleep(100); + } + unknown("PREPARE outcome timed out; no retry or cleanup command was sent."); +} + +async function captureScreenshot() { + const response = await rpc("Page.captureScreenshot", { + format: "png", fromSurface: true, captureBeyondViewport: false + }, 15000); + if (!response?.data) known("Screenshot data is unavailable."); + const bytes = Buffer.from(response.data, "base64"); + const pngSignature = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); + if (bytes.length < 1024 || !bytes.subarray(0, pngSignature.length).equals(pngSignature)) { + unknown("Screenshot is not a nontrivial PNG image."); + } + fs.mkdirSync(path.dirname(configuration.screenshotPath), { recursive: true }); + fs.writeFileSync(configuration.screenshotPath, bytes, { flag: "wx" }); + evidence.screenshot = { + path: configuration.screenshotPath, bytes: bytes.length, sha256: sha256(bytes) + }; +} + +async function execute() { + const contract = validateFrozenContract(); + evidence.authorizationSha256 = contract.authorizationHash; + const target = await discoverTarget(); + await connect(validateEndpoint(target)); + await rpc("Runtime.enable"); + await rpc("Page.enable"); + await installGate(configuration.planSha256, configuration.capabilitySha256); + const connected = await waitFor("Live connected IDLE", value => isConnectedIdle(value), 60000); + if ((connected.state.playlist || []).length !== 0) known("Gate A requires an empty playlist."); + evidence.checkpoints.push({ name: "connected-idle", state: projectedState(connected.state) }); + await configurePlaylist(); + const prepared = await prepareOnce(contract.expiresAt); + assertExactOutbound(prepared.audit, true); + await captureScreenshot(); + const final = await snapshot(); + assertCommon(final, "post-screenshot final verification"); + assertExactOutbound(final.audit, true); + if (final.audit.busyObservedAtPreRevision !== true || !isPrepared5001(final)) { + unknown("Post-screenshot state is not the exact prepared 5001 result."); + } + evidence.finalState = projectedState(final.state); + evidence.observed.outboundTypes = final.audit.outbound.map(item => item.type); + evidence.observed.blocked = final.audit.blocked; + evidence.result = "PASS_PREPARED"; +} + +try { + await execute(); +} catch (error) { + let category = error instanceof GateFailure ? error.category : "HARNESS_ERROR"; + if (socket?.readyState === WebSocket.OPEN) { + try { + const final = await snapshot(); + evidence.finalState = projectedState(final?.state); + evidence.observed.outboundTypes = final?.audit?.outbound?.map(item => item.type) || []; + evidence.observed.blocked = final?.audit?.blocked || []; + if (final?.audit?.prepareCount === 1) { + prepareIssued = true; + evidence.observed.prepareIssued = true; + } + if (!fs.existsSync(configuration.screenshotPath)) await captureScreenshot(); + } catch (captureError) { + evidence.captureError = String(captureError?.message || captureError); + } + } + const isExactKnownRejection = prepareIssued && category === "KNOWN_FAILURE" && + String(error?.message || "").includes("PREPARE rejected:"); + if (prepareDispatchPossible && !isExactKnownRejection) category = "OUTCOME_UNKNOWN"; + evidence.result = category; + evidence.error = { name: error?.name || "Error", message: String(error?.message || error) }; + process.exitCode = category === "KNOWN_FAILURE" ? 2 : 3; +} finally { + evidence.completedAtUtc = new Date().toISOString(); + fs.mkdirSync(path.dirname(configuration.outputPath), { recursive: true }); + fs.writeFileSync(configuration.outputPath, JSON.stringify(evidence, null, 2) + "\n", { + encoding: "utf8", flag: "wx" + }); + try { socket?.close(); } catch { /* The page and its installed guard stay open. */ } +} diff --git a/src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/DatabaseMutationAuthorization.cs b/src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/DatabaseMutationAuthorization.cs new file mode 100644 index 0000000..49f4318 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/DatabaseMutationAuthorization.cs @@ -0,0 +1,100 @@ +#nullable enable + +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Infrastructure; + +/// +/// Identifies the native database-write boundary that is requesting +/// authorization. The request deliberately contains no operator-entered data. +/// +public enum DatabaseMutationTarget +{ + OperatorCatalog, + NamedPlaylist, + ManualFinancial +} + +/// +/// Describes one validated transaction immediately before its executor can +/// create a database connection. +/// +public sealed record DatabaseMutationAuthorizationRequest( + DatabaseMutationTarget Target, + DataSourceKind Source, + Guid OperationId, + string MutationKind); + +/// +/// Final authorization seam for state-changing database operations. Returning +/// false denies the transaction before a connection or transaction is created. +/// Throwing also fails closed and is never converted into an allow decision. +/// +public interface IDatabaseMutationAuthorization +{ + bool IsAuthorized(DatabaseMutationAuthorizationRequest request); +} + +/// +/// Provides the compatibility policy used when existing constructors do not +/// supply an explicit database-mutation authorization policy. +/// +public static class DatabaseMutationAuthorization +{ + public static IDatabaseMutationAuthorization AllowAll { get; } = + new AllowAllDatabaseMutationAuthorization(); + + private sealed class AllowAllDatabaseMutationAuthorization + : IDatabaseMutationAuthorization + { + public bool IsAuthorized(DatabaseMutationAuthorizationRequest request) + { + ArgumentNullException.ThrowIfNull(request); + return true; + } + } +} + +/// +/// Raised when the active policy denies a database mutation before any native +/// database connection is created. +/// +public sealed class DatabaseMutationAuthorizationException : InvalidOperationException +{ + public DatabaseMutationAuthorizationException( + DatabaseMutationAuthorizationRequest request, + Exception? innerException = null) + : base( + $"The {request?.Target} database mutation was denied before a connection was opened.", + innerException) + { + Request = request ?? throw new ArgumentNullException(nameof(request)); + } + + public DatabaseMutationAuthorizationRequest Request { get; } +} + +internal static class DatabaseMutationAuthorizationGuard +{ + internal static void Demand( + IDatabaseMutationAuthorization authorization, + DatabaseMutationAuthorizationRequest request) + { + ArgumentNullException.ThrowIfNull(authorization); + ArgumentNullException.ThrowIfNull(request); + bool authorized; + try + { + authorized = authorization.IsAuthorized(request); + } + catch (Exception exception) + { + throw new DatabaseMutationAuthorizationException(request, exception); + } + + if (!authorized) + { + throw new DatabaseMutationAuthorizationException(request); + } + } +} diff --git a/src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OperatorCatalogMutationExecutor.cs b/src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OperatorCatalogMutationExecutor.cs index 45fb024..54e3245 100644 --- a/src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OperatorCatalogMutationExecutor.cs +++ b/src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OperatorCatalogMutationExecutor.cs @@ -17,6 +17,7 @@ public sealed class OperatorCatalogMutationExecutor : IOperatorCatalogMutationEx { private readonly IDatabaseConnectionFactory _connectionFactory; private readonly ITransientDatabaseErrorDetector _errorDetector; + private readonly IDatabaseMutationAuthorization _mutationAuthorization; private readonly TimeSpan _operationTimeout; private readonly Action _configureCommand; @@ -28,7 +29,22 @@ public sealed class OperatorCatalogMutationExecutor : IOperatorCatalogMutationEx connectionFactory, resilienceOptions, errorDetector ?? new TransientDatabaseErrorDetector(), - ConfigureProviderCommand) + ConfigureProviderCommand, + DatabaseMutationAuthorization.AllowAll) + { + } + + public OperatorCatalogMutationExecutor( + IDatabaseConnectionFactory connectionFactory, + DatabaseResilienceOptions resilienceOptions, + ITransientDatabaseErrorDetector? errorDetector, + IDatabaseMutationAuthorization mutationAuthorization) + : this( + connectionFactory, + resilienceOptions, + errorDetector ?? new TransientDatabaseErrorDetector(), + ConfigureProviderCommand, + mutationAuthorization) { } @@ -37,6 +53,21 @@ public sealed class OperatorCatalogMutationExecutor : IOperatorCatalogMutationEx DatabaseResilienceOptions resilienceOptions, ITransientDatabaseErrorDetector errorDetector, Action configureCommand) + : this( + connectionFactory, + resilienceOptions, + errorDetector, + configureCommand, + DatabaseMutationAuthorization.AllowAll) + { + } + + internal OperatorCatalogMutationExecutor( + IDatabaseConnectionFactory connectionFactory, + DatabaseResilienceOptions resilienceOptions, + ITransientDatabaseErrorDetector errorDetector, + Action configureCommand, + IDatabaseMutationAuthorization mutationAuthorization) { _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); @@ -44,6 +75,8 @@ public sealed class OperatorCatalogMutationExecutor : IOperatorCatalogMutationEx _errorDetector = errorDetector ?? throw new ArgumentNullException(nameof(errorDetector)); _configureCommand = configureCommand ?? throw new ArgumentNullException(nameof(configureCommand)); + _mutationAuthorization = mutationAuthorization ?? + throw new ArgumentNullException(nameof(mutationAuthorization)); var validationOptions = new DatabaseOptions { Resilience = resilienceOptions }; validationOptions.ValidateRuntimeSettings(); @@ -56,6 +89,23 @@ public sealed class OperatorCatalogMutationExecutor : IOperatorCatalogMutationEx { ValidateTransaction(transaction); cancellationToken.ThrowIfCancellationRequested(); + try + { + DatabaseMutationAuthorizationGuard.Demand( + _mutationAuthorization, + new DatabaseMutationAuthorizationRequest( + DatabaseMutationTarget.OperatorCatalog, + transaction.Source, + transaction.OperationId, + transaction.Kind.ToString())); + } + catch (DatabaseMutationAuthorizationException exception) + { + throw new OperatorCatalogMutationException( + $"The {transaction.Kind} transaction was denied before it started and did not commit.", + outcomeUnknown: false, + exception); + } var commandTimeoutSeconds = _connectionFactory.GetCommandTimeoutSeconds(transaction.Source); if (commandTimeoutSeconds is < 1 or > 600) { diff --git a/src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleManualFinancialMutationExecutor.cs b/src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleManualFinancialMutationExecutor.cs index 8625afe..f47918f 100644 --- a/src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleManualFinancialMutationExecutor.cs +++ b/src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleManualFinancialMutationExecutor.cs @@ -19,6 +19,7 @@ public sealed partial class OracleManualFinancialMutationExecutor { private readonly IDatabaseConnectionFactory _connectionFactory; private readonly ITransientDatabaseErrorDetector _errorDetector; + private readonly IDatabaseMutationAuthorization _mutationAuthorization; private readonly TimeSpan _operationTimeout; private readonly int _commandTimeoutSeconds; private readonly Action _configureCommand; @@ -31,7 +32,22 @@ public sealed partial class OracleManualFinancialMutationExecutor connectionFactory, resilienceOptions, errorDetector ?? new TransientDatabaseErrorDetector(), - ConfigureOracleCommand) + ConfigureOracleCommand, + DatabaseMutationAuthorization.AllowAll) + { + } + + public OracleManualFinancialMutationExecutor( + IDatabaseConnectionFactory connectionFactory, + DatabaseResilienceOptions resilienceOptions, + ITransientDatabaseErrorDetector? errorDetector, + IDatabaseMutationAuthorization mutationAuthorization) + : this( + connectionFactory, + resilienceOptions, + errorDetector ?? new TransientDatabaseErrorDetector(), + ConfigureOracleCommand, + mutationAuthorization) { } @@ -40,12 +56,29 @@ public sealed partial class OracleManualFinancialMutationExecutor DatabaseResilienceOptions resilienceOptions, ITransientDatabaseErrorDetector errorDetector, Action configureCommand) + : this( + connectionFactory, + resilienceOptions, + errorDetector, + configureCommand, + DatabaseMutationAuthorization.AllowAll) + { + } + + internal OracleManualFinancialMutationExecutor( + IDatabaseConnectionFactory connectionFactory, + DatabaseResilienceOptions resilienceOptions, + ITransientDatabaseErrorDetector errorDetector, + Action configureCommand, + IDatabaseMutationAuthorization mutationAuthorization) { _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); ArgumentNullException.ThrowIfNull(resilienceOptions); _errorDetector = errorDetector ?? throw new ArgumentNullException(nameof(errorDetector)); _configureCommand = configureCommand ?? throw new ArgumentNullException(nameof(configureCommand)); + _mutationAuthorization = mutationAuthorization ?? + throw new ArgumentNullException(nameof(mutationAuthorization)); var validationOptions = new DatabaseOptions { Resilience = resilienceOptions }; validationOptions.ValidateRuntimeSettings(); @@ -73,6 +106,23 @@ public sealed partial class OracleManualFinancialMutationExecutor ValidateTransaction(transaction); cancellationToken.ThrowIfCancellationRequested(); + try + { + DatabaseMutationAuthorizationGuard.Demand( + _mutationAuthorization, + new DatabaseMutationAuthorizationRequest( + DatabaseMutationTarget.ManualFinancial, + DataSourceKind.Oracle, + transaction.OperationId, + transaction.Kind.ToString())); + } + catch (DatabaseMutationAuthorizationException exception) + { + throw new ManualFinancialMutationException( + $"The {transaction.Kind} transaction was denied before it started and did not commit.", + outcomeUnknown: false, + exception); + } using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeoutSource.CancelAfter(_operationTimeout); var operationToken = timeoutSource.Token; diff --git a/src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleNamedPlaylistMutationExecutor.cs b/src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleNamedPlaylistMutationExecutor.cs index 059320c..ad9bf6d 100644 --- a/src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleNamedPlaylistMutationExecutor.cs +++ b/src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleNamedPlaylistMutationExecutor.cs @@ -17,6 +17,7 @@ public sealed class OracleNamedPlaylistMutationExecutor : INamedPlaylistMutation { private readonly IDatabaseConnectionFactory _connectionFactory; private readonly ITransientDatabaseErrorDetector _errorDetector; + private readonly IDatabaseMutationAuthorization _mutationAuthorization; private readonly TimeSpan _operationTimeout; private readonly int _commandTimeoutSeconds; private readonly Action _configureCommand; @@ -29,7 +30,22 @@ public sealed class OracleNamedPlaylistMutationExecutor : INamedPlaylistMutation connectionFactory, resilienceOptions, errorDetector ?? new TransientDatabaseErrorDetector(), - ConfigureOracleCommand) + ConfigureOracleCommand, + DatabaseMutationAuthorization.AllowAll) + { + } + + public OracleNamedPlaylistMutationExecutor( + IDatabaseConnectionFactory connectionFactory, + DatabaseResilienceOptions resilienceOptions, + ITransientDatabaseErrorDetector? errorDetector, + IDatabaseMutationAuthorization mutationAuthorization) + : this( + connectionFactory, + resilienceOptions, + errorDetector ?? new TransientDatabaseErrorDetector(), + ConfigureOracleCommand, + mutationAuthorization) { } @@ -38,6 +54,21 @@ public sealed class OracleNamedPlaylistMutationExecutor : INamedPlaylistMutation DatabaseResilienceOptions resilienceOptions, ITransientDatabaseErrorDetector errorDetector, Action configureCommand) + : this( + connectionFactory, + resilienceOptions, + errorDetector, + configureCommand, + DatabaseMutationAuthorization.AllowAll) + { + } + + internal OracleNamedPlaylistMutationExecutor( + IDatabaseConnectionFactory connectionFactory, + DatabaseResilienceOptions resilienceOptions, + ITransientDatabaseErrorDetector errorDetector, + Action configureCommand, + IDatabaseMutationAuthorization mutationAuthorization) { _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); @@ -45,6 +76,8 @@ public sealed class OracleNamedPlaylistMutationExecutor : INamedPlaylistMutation _errorDetector = errorDetector ?? throw new ArgumentNullException(nameof(errorDetector)); _configureCommand = configureCommand ?? throw new ArgumentNullException(nameof(configureCommand)); + _mutationAuthorization = mutationAuthorization ?? + throw new ArgumentNullException(nameof(mutationAuthorization)); var validationOptions = new DatabaseOptions { Resilience = resilienceOptions }; validationOptions.ValidateRuntimeSettings(); @@ -72,6 +105,23 @@ public sealed class OracleNamedPlaylistMutationExecutor : INamedPlaylistMutation ValidateTransaction(transaction); cancellationToken.ThrowIfCancellationRequested(); + try + { + DatabaseMutationAuthorizationGuard.Demand( + _mutationAuthorization, + new DatabaseMutationAuthorizationRequest( + DatabaseMutationTarget.NamedPlaylist, + DataSourceKind.Oracle, + transaction.OperationId, + transaction.Kind.ToString())); + } + catch (DatabaseMutationAuthorizationException exception) + { + throw new NamedPlaylistMutationException( + $"The {transaction.Kind} transaction was denied before it started and did not commit.", + outcomeUnknown: false, + exception); + } using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeoutSource.CancelAfter(_operationTimeout); diff --git a/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyPlayoutUiIntents.cs b/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyPlayoutUiIntents.cs index b989609..12ddf6f 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyPlayoutUiIntents.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyPlayoutUiIntents.cs @@ -6,6 +6,12 @@ namespace MBN_STOCK_WEBVIEW.LegacyBridge; public sealed record LegacyExecutePlayoutIntent(LegacyOperatorPlayoutCommand Command) : LegacyUiIntent; +/// +/// Process-scoped Gate A PREPARE request. The opaque capability is validated and +/// consumed by the native launch authorization; it is never emitted in state. +/// +public sealed record LegacyGateAPrepareIntent(string Capability) : LegacyUiIntent; + public sealed record LegacySetFadeDurationIntent(int Duration) : LegacyUiIntent; @@ -17,6 +23,7 @@ internal static class LegacyPlayoutUiIntentParser { public static LegacyUiIntent? Parse(string type, JsonElement payload) => type switch { + "gate-a-prepare" => ParseGateAPrepare(payload), "prepare-playout" => ParseEmpty( payload, static () => new LegacyExecutePlayoutIntent( @@ -43,6 +50,29 @@ internal static class LegacyPlayoutUiIntentParser _ => null }; + private static LegacyUiIntent? ParseGateAPrepare(JsonElement payload) + { + if (payload.ValueKind != JsonValueKind.Object || + payload.GetRawText().Length > 128 || + payload.EnumerateObject().Count() != 1 || + !payload.TryGetProperty("capability", out var capabilityElement) || + capabilityElement.ValueKind != JsonValueKind.String) + { + return null; + } + + var capability = capabilityElement.GetString(); + if (capability is null || capability.Length != 64 || + capability.Any(character => + character is not (>= '0' and <= '9') and + not (>= 'A' and <= 'F'))) + { + return null; + } + + return new LegacyGateAPrepareIntent(capability); + } + private static LegacyUiIntent? ParseFadeDuration(JsonElement payload) { if (payload.ValueKind != JsonValueKind.Object || diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs index e993889..d5d0b90 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs @@ -5,6 +5,7 @@ using MBN_STOCK_WEBVIEW.LegacyApplication; using MBN_STOCK_WEBVIEW.LegacyBridge; using MBN_STOCK_WEBVIEW.Playout; using MBN_STOCK_WEBVIEW.Playout.Configuration; +using MBN_STOCK_WEBVIEW.Playout.Safety; using Microsoft.UI.Xaml; using Windows.Storage.Pickers; using WinRT.Interop; @@ -45,7 +46,9 @@ public sealed partial class MainWindow : null; _compositionOptions = new MutableLegacySceneCueCompositionOptionsSource( initialComposition); - _playoutEngine = PlayoutEngineFactory.Create(_playoutOptions); + _playoutEngine = PlayoutEngineFactory.Create( + _playoutOptions, + _playoutLaunchAuthorization); _playoutEngine.StatusChanged += OnPlayoutStatusChanged; if (_databaseRuntime is not null) { @@ -118,8 +121,19 @@ public sealed partial class MainWindow private async Task ExecuteOperatorPlayoutAsync( LegacyOperatorPlayoutCommand command, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + bool gateAPrepareClaimed = false, + LegacyOperatorPlayoutRequest? gateAPrepareRequest = null) { + if (_playoutLaunchAuthorization.IsGateA && + (!gateAPrepareClaimed || + command != LegacyOperatorPlayoutCommand.Prepare || + gateAPrepareRequest is null)) + { + return _controller.ReportError( + "Gate A 검증 회차에서는 승인된 5001 PREPARE 한 번만 실행할 수 있습니다."); + } + var engine = _playoutEngine; var workflow = _playoutWorkflow; if (engine is null || workflow is null) @@ -163,8 +177,9 @@ public sealed partial class MainWindow switch (command) { case LegacyOperatorPlayoutCommand.Prepare: - var request = _controller.CreatePlayoutRequest( - _compositionOptions!.Current.FadeDuration); + var request = gateAPrepareRequest ?? + _controller.CreatePlayoutRequest( + _compositionOptions!.Current.FadeDuration); result = await workflow.PrepareAsync( request.Playlist, request.SelectedIndexZeroBased, @@ -265,10 +280,112 @@ public sealed partial class MainWindow } } + private async Task ExecuteGateAPrepareAsync( + string capability, + CancellationToken cancellationToken) + { + if (!_playoutLaunchAuthorization.IsGateA || + !_playoutLaunchAuthorization.TryClaimGateAPrepare(capability)) + { + return _controller.ReportError( + "Gate A PREPARE 승인이 없거나 이미 사용되었습니다."); + } + + try + { + if (!TryCreateExactGateAPrepareRequest(out var request)) + { + return _controller.ReportError( + "Gate A PREPARE 직전 상태가 승인된 삼성전자 5001 page 1 조건과 일치하지 않습니다."); + } + + return await ExecuteOperatorPlayoutAsync( + LegacyOperatorPlayoutCommand.Prepare, + cancellationToken, + gateAPrepareClaimed: true, + gateAPrepareRequest: request).ConfigureAwait(false); + } + finally + { + // Success, rejection, cancellation and unknown outcome all consume the + // one process-lifetime capability. There is deliberately no retry path. + _playoutLaunchAuthorization.CompleteGateAPrepare(); + } + } + + private bool TryCreateExactGateAPrepareRequest( + out LegacyOperatorPlayoutRequest? request) + { + request = null; + var snapshot = _controller.Current; + if (snapshot.Playlist.Count != 1 || + _compositionOptions?.Current is not { } composition || + composition.FadeDuration != 6 || + composition.BackgroundKind != LegacySceneBackgroundKind.None) + { + return false; + } + + var row = snapshot.Playlist[0]; + if (!row.IsEnabled || !row.IsActive || + !string.Equals(row.MarketText, "코스피", StringComparison.Ordinal) || + !string.Equals(row.StockName, "삼성전자", StringComparison.Ordinal) || + !string.Equals(row.GraphicType, "1열판기본", StringComparison.Ordinal) || + !string.Equals(row.Subtype, "현재가", StringComparison.Ordinal) || + !string.Equals(row.PageText, "1/1", StringComparison.Ordinal) || + !string.Equals(row.StockCode, "005930", StringComparison.Ordinal)) + { + return false; + } + + LegacyOperatorPlayoutRequest candidate; + try + { + candidate = _controller.CreatePlayoutRequest(6); + } + catch (InvalidOperationException) + { + return false; + } + + if (candidate.SelectedIndexZeroBased != 0 || + candidate.Playlist.Count != 1) + { + return false; + } + + var entry = candidate.Playlist[0]; + var selection = entry.Selection; + if (!entry.IsEnabled || + !string.Equals(entry.EntryId, row.RowId, StringComparison.Ordinal) || + !string.Equals(entry.CutCode, "5001", StringComparison.Ordinal) || + entry.FadeDuration != 6 || + entry.PageNavigation?.IsEnabled == true || + entry.MovingAverageSelectionSource is not null || + selection is null || + !string.Equals(selection.GroupCode, "코스피", StringComparison.Ordinal) || + !string.Equals(selection.Subject, "삼성전자", StringComparison.Ordinal) || + !string.Equals(selection.GraphicType, "1열판기본", StringComparison.Ordinal) || + !string.Equals(selection.Subtype, "현재가", StringComparison.Ordinal) || + !string.Equals(selection.DataCode, "005930", StringComparison.Ordinal)) + { + return false; + } + + request = candidate; + return true; + } + private async Task SetFadeDurationAsync( int fadeDuration, CancellationToken cancellationToken) { + if (!_playoutLaunchAuthorization.AllowsCompositionMutation) + { + return _controller.ReportError( + "Gate A 검증 회차에서는 Fade 설정을 변경할 수 없습니다."); + } + if (fadeDuration is < 0 or > 19) { return _controller.ReportError("DissolveTime 값이 올바르지 않습니다."); @@ -308,6 +425,12 @@ public sealed partial class MainWindow private async Task ToggleBackgroundAsync( CancellationToken cancellationToken) { + if (!_playoutLaunchAuthorization.AllowsCompositionMutation) + { + return _controller.ReportError( + "Gate A 검증 회차에서는 배경 설정을 변경할 수 없습니다."); + } + if (!await _playoutCommandGate.WaitAsync(0, cancellationToken).ConfigureAwait(false)) { return _controller.ReportError("송출 명령 처리 중에는 배경을 변경할 수 없습니다."); @@ -349,6 +472,12 @@ public sealed partial class MainWindow private async Task ChooseBackgroundAsync( CancellationToken cancellationToken) { + if (!_playoutLaunchAuthorization.AllowsCompositionMutation) + { + return _controller.ReportError( + "Gate A 검증 회차에서는 배경 파일을 선택할 수 없습니다."); + } + if (!await _playoutCommandGate.WaitAsync(0, cancellationToken).ConfigureAwait(false)) { return _controller.ReportError("송출 명령 처리 중에는 배경을 변경할 수 없습니다."); @@ -534,6 +663,27 @@ public sealed partial class MainWindow LegacyConfirmManualListIntent or LegacyImportManualListsIntent; + private static bool IsPersistentWriteIntent(LegacyUiIntent intent) => intent is + LegacyDeleteUc4SelectedThemeIntent or + LegacyDeleteUc6SelectedExpertIntent or + LegacySaveOperatorCatalogIntent or + LegacyConfirmNativeDialogIntent or + LegacyAddComparisonPairIntent or + LegacyMoveComparisonPairIntent or + LegacyDeleteSelectedComparisonPairIntent or + LegacyDeleteAllComparisonPairsIntent or + LegacySaveManualFinancialIntent or + LegacyDeleteManualFinancialIntent or + LegacyDeleteAllManualFinancialIntent or + LegacySaveManualNetSellIntent or + LegacySaveManualViIntent or + LegacyConfirmManualListIntent or + LegacyImportManualListsIntent or + LegacyCreateNamedPlaylistIntent or + LegacySaveCurrentNamedPlaylistIntent or + LegacySaveCurrentNamedPlaylistToIntent or + LegacyDeleteSelectedNamedPlaylistIntent; + private static bool IsFixedSectionBatchMutationIntent(LegacyUiIntent intent) => intent is LegacySetManualNetSellCellIntent or LegacyAddManualViResultIntent or diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs index c44b6f2..11c1900 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs @@ -2,6 +2,7 @@ using System.Runtime.InteropServices; using MBN_STOCK_WEBVIEW.Infrastructure; using MBN_STOCK_WEBVIEW.LegacyApplication; using MBN_STOCK_WEBVIEW.LegacyBridge; +using MBN_STOCK_WEBVIEW.Playout.Safety; using Microsoft.UI.Windowing; using Microsoft.Web.WebView2.Core; using MMoneyCoderSharp.Data; @@ -21,6 +22,7 @@ public sealed partial class MainWindow : Window private readonly CancellationTokenSource _lifetimeCancellation = new(); private readonly SemaphoreSlim _intentGate = new(1, 1); + private readonly PlayoutLaunchAuthorization _playoutLaunchAuthorization; private readonly WindowSubclassProcedure _windowSubclassProcedure; private readonly LegacyOperatorController _controller; private readonly LegacyIndustrySelectionWorkflow _industryWorkflow; @@ -36,6 +38,10 @@ public sealed partial class MainWindow : Window public MainWindow() { + // Capture and remove Gate A bearer material before XAML can create WebView2. + // Only the native process keeps the capability digest for this launch. + _playoutLaunchAuthorization = + PlayoutLaunchAuthorization.CaptureFromEnvironment(); _windowSubclassProcedure = OnWindowSubclassMessage; InitializeComponent(); EnsureCloseConfirmationAttached(); @@ -72,6 +78,8 @@ public sealed partial class MainWindow : Window { _databaseRuntime = DatabaseRuntime.CreateDefault(); DataQueryExecutor.Configure(_databaseRuntime.Executor); + var databaseMutationAuthorization = + new LaunchDatabaseMutationAuthorization(_playoutLaunchAuthorization); stockLookup = new LegacyParityStockSearchService(_databaseRuntime.Executor); industrySelectionService = new LegacyIndustrySelectionService( _databaseRuntime.Executor); @@ -91,17 +99,23 @@ public sealed partial class MainWindow : Window _databaseRuntime.Executor, new OracleManualFinancialMutationExecutor( _databaseRuntime.ConnectionFactory, - _databaseRuntime.Options.Resilience)); + _databaseRuntime.Options.Resilience, + errorDetector: null, + mutationAuthorization: databaseMutationAuthorization)); manualFinancialStockSearchService = new LegacyStockSearchService( _databaseRuntime.Executor); namedPlaylistPersistenceService = new LegacyNamedPlaylistPersistenceService( _databaseRuntime.Executor, new OracleNamedPlaylistMutationExecutor( _databaseRuntime.ConnectionFactory, - _databaseRuntime.Options.Resilience)); + _databaseRuntime.Options.Resilience, + errorDetector: null, + mutationAuthorization: databaseMutationAuthorization)); var operatorCatalogMutationExecutor = new OperatorCatalogMutationExecutor( _databaseRuntime.ConnectionFactory, - _databaseRuntime.Options.Resilience); + _databaseRuntime.Options.Resilience, + errorDetector: null, + mutationAuthorization: databaseMutationAuthorization); themeCatalogPersistenceService = new LegacyThemeCatalogPersistenceService( _databaseRuntime.Executor, operatorCatalogMutationExecutor); @@ -377,6 +391,15 @@ public sealed partial class MainWindow : Window return; } + if (_playoutLaunchAuthorization.IsGateA && + IsPersistentWriteIntent(intent)) + { + PostState(ReportIntentFailure( + intent, + "Gate A 검증 회차에서는 DB 및 로컬 영구 저장 변경이 허용되지 않습니다.")); + return; + } + if (_controller.Current.Dialog?.IsConfirmation == true && intent is not LegacyReadyIntent and not LegacyConfirmNativeDialogIntent and @@ -469,6 +492,7 @@ public sealed partial class MainWindow : Window LegacySaveCurrentNamedPlaylistToIntent or LegacyDeleteSelectedNamedPlaylistIntent or LegacyExecutePlayoutIntent or + LegacyGateAPrepareIntent or LegacySetFadeDurationIntent or LegacyToggleBackgroundIntent or LegacyChooseBackgroundIntent) @@ -961,6 +985,10 @@ public sealed partial class MainWindow : Window await _controller.ConfirmManualListsAsync(cancellationToken), LegacyImportManualListsIntent => await _controller.ImportLegacyManualListsAsync(cancellationToken), + LegacyGateAPrepareIntent prepare => + await ExecuteGateAPrepareAsync( + prepare.Capability, + cancellationToken), LegacyExecutePlayoutIntent playout => await ExecuteOperatorPlayoutAsync( playout.Command, @@ -1038,6 +1066,25 @@ public sealed partial class MainWindow : Window outcome is LegacyNamedPlaylistMutationOutcome.CommittedFresh or LegacyNamedPlaylistMutationOutcome.CommittedOptimistic; + private sealed class LaunchDatabaseMutationAuthorization + : IDatabaseMutationAuthorization + { + private readonly PlayoutLaunchAuthorization _authorization; + + public LaunchDatabaseMutationAuthorization( + PlayoutLaunchAuthorization authorization) + { + _authorization = authorization ?? + throw new ArgumentNullException(nameof(authorization)); + } + + public bool IsAuthorized(DatabaseMutationAuthorizationRequest request) + { + ArgumentNullException.ThrowIfNull(request); + return _authorization.AllowsDatabaseWrites; + } + } + private void PostState(LegacyOperatorSnapshot snapshot) { if (!_closing && _webViewReady && Browser.CoreWebView2 is not null) diff --git a/src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dSession.cs b/src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dSession.cs index 1d0c998..b59d6fb 100644 --- a/src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dSession.cs +++ b/src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dSession.cs @@ -71,12 +71,23 @@ internal sealed class DynamicK3dSessionFactory : IK3dSessionFactory private readonly ILateBoundComActivator _activator; private readonly ILateBoundComMethodInvoker _invoker; private readonly IK3dEventHandlerFactory _eventHandlerFactory; + private readonly bool _rollbackOnPrepareFailure; public DynamicK3dSessionFactory() : this( new InstalledK3dInteropComActivator(), new InstalledK3dInteropMethodInvoker(), - new InstalledK3dEventHandlerFactory()) + new InstalledK3dEventHandlerFactory(), + rollbackOnPrepareFailure: true) + { + } + + internal DynamicK3dSessionFactory(bool rollbackOnPrepareFailure) + : this( + new InstalledK3dInteropComActivator(), + new InstalledK3dInteropMethodInvoker(), + new InstalledK3dEventHandlerFactory(), + rollbackOnPrepareFailure) { } @@ -84,32 +95,40 @@ internal sealed class DynamicK3dSessionFactory : IK3dSessionFactory : this( activator, new InstalledK3dInteropMethodInvoker(), - new ActivatorK3dEventHandlerFactory(activator)) + new ActivatorK3dEventHandlerFactory(activator), + rollbackOnPrepareFailure: true) { } internal DynamicK3dSessionFactory( ILateBoundComActivator activator, ILateBoundComMethodInvoker invoker) - : this(activator, invoker, new ActivatorK3dEventHandlerFactory(activator)) + : this( + activator, + invoker, + new ActivatorK3dEventHandlerFactory(activator), + rollbackOnPrepareFailure: true) { } internal DynamicK3dSessionFactory( ILateBoundComActivator activator, ILateBoundComMethodInvoker invoker, - IK3dEventHandlerFactory eventHandlerFactory) + IK3dEventHandlerFactory eventHandlerFactory, + bool rollbackOnPrepareFailure = true) { _activator = activator; _invoker = invoker; _eventHandlerFactory = eventHandlerFactory; + _rollbackOnPrepareFailure = rollbackOnPrepareFailure; } public IK3dSession Create() => new DynamicK3dSession( _activator, new RuntimeComObjectReleaser(), _invoker, - _eventHandlerFactory); + _eventHandlerFactory, + _rollbackOnPrepareFailure); } internal interface ILateBoundComActivator @@ -155,6 +174,7 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession private readonly ILateBoundComObjectReleaser _releaser; private readonly ILateBoundComMethodInvoker _invoker; private readonly IK3dEventHandlerFactory _eventHandlerFactory; + private readonly bool _rollbackOnPrepareFailure; private readonly K3dEventQueue _eventQueue = new(); private object? _engine; private object? _eventHandler; @@ -177,7 +197,8 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession activator, new RuntimeComObjectReleaser(), new InstalledK3dInteropMethodInvoker(), - new ActivatorK3dEventHandlerFactory(activator)) + new ActivatorK3dEventHandlerFactory(activator), + rollbackOnPrepareFailure: true) { } @@ -204,12 +225,14 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession ILateBoundComActivator activator, ILateBoundComObjectReleaser releaser, ILateBoundComMethodInvoker invoker, - IK3dEventHandlerFactory eventHandlerFactory) + IK3dEventHandlerFactory eventHandlerFactory, + bool rollbackOnPrepareFailure = true) { _activator = activator; _releaser = releaser; _invoker = invoker; _eventHandlerFactory = eventHandlerFactory; + _rollbackOnPrepareFailure = rollbackOnPrepareFailure; } public bool IsConnected => _engine is not null && _player is not null; @@ -484,7 +507,7 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession } catch { - if (transactionStarted) + if (transactionStarted && _rollbackOnPrepareFailure) { try { diff --git a/src/MBN_STOCK_WEBVIEW.Playout/PlayoutEngineFactory.cs b/src/MBN_STOCK_WEBVIEW.Playout/PlayoutEngineFactory.cs index 67ac22d..7ec51cc 100644 --- a/src/MBN_STOCK_WEBVIEW.Playout/PlayoutEngineFactory.cs +++ b/src/MBN_STOCK_WEBVIEW.Playout/PlayoutEngineFactory.cs @@ -1,32 +1,135 @@ using MBN_STOCK_WEBVIEW.Core.Playout; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; using MBN_STOCK_WEBVIEW.Playout.Configuration; +using MBN_STOCK_WEBVIEW.Playout.Diagnostics; using MBN_STOCK_WEBVIEW.Playout.Interop; using MBN_STOCK_WEBVIEW.Playout.Registration; using MBN_STOCK_WEBVIEW.Playout.Runtime; using MBN_STOCK_WEBVIEW.Playout.Safety; +using System.Net; namespace MBN_STOCK_WEBVIEW.Playout; public static class PlayoutEngineFactory { public static IPlayoutEngine CreateDefault() => - Create(PlayoutOptionsLoader.Load()); + Create( + PlayoutOptionsLoader.Load(), + PlayoutLaunchAuthorization.CaptureFromEnvironment()); - public static IPlayoutEngine Create(PlayoutOptions options) + public static IPlayoutEngine Create(PlayoutOptions options) => + Create(options, PlayoutLaunchAuthorization.CaptureFromEnvironment()); + + public static IPlayoutEngine Create( + PlayoutOptions options, + PlayoutLaunchAuthorization launchAuthorization) { ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(launchAuthorization); + ValidateGateAOptions(options, launchAuthorization); var validated = ValidatedPlayoutOptions.Create(options); + ITornadoProcessProbe processProbe = new TornadoProcessProbe(); + Func? finalConnectSafetyCheck = null; + var abandonOnTargetMismatch = false; + var startMonitor = validated.Mode != PlayoutMode.Disabled; + + if (launchAuthorization.IsGateA) + { + var safetyProbe = new WindowsPgmDiagnosticSafetyProbe(); + var request = new PgmConnectDiagnosticRequest( + validated.Host, + validated.Port, + "PGM"); + var parsedHost = IPAddress.Parse(validated.Host); + var baseline = safetyProbe.Capture(request, parsedHost); + if (!baseline.IsEligible || + baseline.Identity is not { } identity || + identity.ProcessId != launchAuthorization.ExpectedPgmProcessId || + identity.StartTimeUtcTicks != + launchAuthorization.ExpectedPgmStartTimeUtcTicks) + { + throw new PlayoutConfigurationException( + "Gate A could not confirm the exact approved PGM process and listener."); + } + + bool HasExactTarget() + { + try + { + var current = safetyProbe.Capture(request, parsedHost); + return current.IsEligible && baseline.HasSameTarget(current); + } + catch + { + return false; + } + } + + processProbe = new PgmCutsSequenceProcessProbe( + safetyProbe, + request, + parsedHost, + baseline); + finalConnectSafetyCheck = HasExactTarget; + abandonOnTargetMismatch = true; + startMonitor = false; + } + IStaDispatcher? dispatcher = validated.Mode is PlayoutMode.Test or PlayoutMode.Live ? new StaDispatcher(validated.QueueCapacity) : null; return new TornadoPlayoutEngine( validated, - new DynamicK3dSessionFactory(), + new DynamicK3dSessionFactory( + rollbackOnPrepareFailure: !launchAuthorization.IsGateA), new K3dRegistrationProbe(), - new TornadoProcessProbe(), + processProbe, dispatcher, - new EnvironmentLiveAuthorization(), - TimeProvider.System); + launchAuthorization, + TimeProvider.System, + startMonitor, + finalConnectSafetyCheck, + abandonOnTargetMismatch, + beforeSdkDispatchClaim: launchAuthorization.IsGateA + ? launchAuthorization.EnsureGateASdkDispatchAuthorized + : null, + launchAuthorization: launchAuthorization); + } + + internal static void ValidateGateAOptions( + PlayoutOptions options, + PlayoutLaunchAuthorization launchAuthorization) + { + if (!launchAuthorization.IsGateA) + { + return; + } + + var allowlist = (options.TestSceneAllowlist ?? []) + .Select(static value => value?.Trim()) + .ToArray(); + if (options.Mode != PlayoutMode.Live || + !options.TrustedLiveOutputEnabled || + !string.Equals(options.Host?.Trim(), "127.0.0.1", StringComparison.Ordinal) || + options.Port != 30001 || + options.TcpMode != 1 || + options.ClientPort != 0 || + options.OutputChannel is not null || + options.LayoutIndex != 10 || + !PlayoutLaunchAuthorization.IsExactGateASceneDirectory( + options.SceneDirectory) || + allowlist.Length != 1 || + !string.Equals(allowlist[0], "5001", StringComparison.Ordinal) || + options.ReconnectEnabled || + options.MaximumReconnectAttempts != 0 || + options.MaximumAutomaticRefreshesPerTakeIn != 0 || + options.LegacySceneFadeDuration != 6 || + options.LegacySceneBackgroundKind != LegacySceneBackgroundKind.None || + !string.IsNullOrWhiteSpace(options.LegacySceneBackgroundAssetPath)) + { + throw new PlayoutConfigurationException( + "Gate A requires the exact closed 5001-only Live configuration."); + } } /// diff --git a/src/MBN_STOCK_WEBVIEW.Playout/Safety/PlayoutLaunchAuthorization.cs b/src/MBN_STOCK_WEBVIEW.Playout/Safety/PlayoutLaunchAuthorization.cs new file mode 100644 index 0000000..557265b --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Playout/Safety/PlayoutLaunchAuthorization.cs @@ -0,0 +1,632 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using MBN_STOCK_WEBVIEW.Core.Playout; +using MBN_STOCK_WEBVIEW.Playout.Configuration; +using MBN_STOCK_WEBVIEW.Playout.Interop; + +namespace MBN_STOCK_WEBVIEW.Playout.Safety; + +/// +/// Process-lifetime authorization captured before WebView2 is created. Gate A is +/// deliberately narrower than normal Live mode: one CONNECT and one claimed +/// PREPARE of the approved 5001 cut are the only requests that can cross the +/// native engine boundary. +/// +public sealed class PlayoutLaunchAuthorization : ILiveAuthorization, IDisposable +{ + public const string GateACapabilityEnvironmentVariable = + "MBN_LEGACY_PGM_GATE_A_PREPARE_CAPABILITY"; + + public const string GateAPgmProcessIdEnvironmentVariable = + "MBN_LEGACY_PGM_GATE_A_PGM_PID"; + + public const string GateAPgmStartTimeUtcTicksEnvironmentVariable = + "MBN_LEGACY_PGM_GATE_A_PGM_START_TIME_UTC_TICKS"; + + public const string GateAExpiresAtUtcTicksEnvironmentVariable = + "MBN_LEGACY_PGM_GATE_A_EXPIRES_AT_UTC_TICKS"; + + public const string GateAApprovedSceneFilePath = + @"C:\Users\MD\source\repos\MBN_STOCK_N\MBN_STOCK_N\bin\Debug\Cuts\5001.t2s"; + + public const string GateAApprovedSceneSha256 = + "99CE3B689A42D8C42BEB09A86FA10C2D7C1AEF4F50D324D81276C1A1E4C4D8A7"; + + private const int GateAArmed = 0; + private const int GateAOperatorClaimed = 1; + private const int GateAEnginePrepareClaimed = 2; + private const int GateATerminal = 3; + private const int CapabilityLength = 64; + private static readonly long MaximumGateALifetimeTicks = TimeSpan.FromMinutes(15).Ticks; + private static readonly object GateACaptureSync = new(); + private static bool _gateAObservedForProcess; + + private readonly object _gateASync = new(); + private readonly byte[]? _gateACapabilityHash; + private readonly TimeProvider _timeProvider; + private readonly long _authorizationCapturedTimestamp; + private readonly TimeSpan _authorizedLifetime; + private FileStream? _gateASceneLease; + private int _gateAState; + private bool _connectClaimed; + private bool _capabilityHashCleared; + + private PlayoutLaunchAuthorization( + bool liveAuthorized, + byte[]? gateACapabilityHash, + int? expectedPgmProcessId, + long? expectedPgmStartTimeUtcTicks, + long? expiresAtUtcTicks, + TimeProvider timeProvider, + long authorizationCapturedTimestamp, + TimeSpan authorizedLifetime, + FileStream? gateASceneLease) + { + IsAuthorizedForThisLaunch = liveAuthorized; + _gateACapabilityHash = gateACapabilityHash; + ExpectedPgmProcessId = expectedPgmProcessId; + ExpectedPgmStartTimeUtcTicks = expectedPgmStartTimeUtcTicks; + ExpiresAtUtcTicks = expiresAtUtcTicks; + _timeProvider = timeProvider; + _authorizationCapturedTimestamp = authorizationCapturedTimestamp; + _authorizedLifetime = authorizedLifetime; + _gateASceneLease = gateASceneLease; + } + + public bool IsAuthorizedForThisLaunch { get; } + + public bool IsGateA => _gateACapabilityHash is not null; + + public bool AllowsDatabaseWrites => !IsGateA; + + public bool AllowsCompositionMutation => !IsGateA; + + public int? ExpectedPgmProcessId { get; } + + public long? ExpectedPgmStartTimeUtcTicks { get; } + + public long? ExpiresAtUtcTicks { get; } + + public static string GateAApprovedSceneDirectory => + Path.GetDirectoryName(GateAApprovedSceneFilePath)!; + + internal bool AllowsAutomaticReconnect => !IsGateA; + + internal bool AllowsSdkDisconnect => !IsGateA; + + internal static PlayoutLaunchAuthorization Unrestricted { get; } = + new( + liveAuthorized: false, + gateACapabilityHash: null, + expectedPgmProcessId: null, + expectedPgmStartTimeUtcTicks: null, + expiresAtUtcTicks: null, + TimeProvider.System, + authorizationCapturedTimestamp: 0, + authorizedLifetime: TimeSpan.Zero, + gateASceneLease: null); + + /// + /// Captures all authorization material once, removes the bearer values from + /// the process environment, then acquires and hashes the approved cut through + /// a read-only/non-write-sharing handle. The handle remains owned by this + /// authorization for the native application lifetime. + /// + public static PlayoutLaunchAuthorization CaptureFromEnvironment() => + CaptureFromEnvironment( + TimeProvider.System, + GateAApprovedSceneFilePath, + GateAApprovedSceneSha256); + + internal static PlayoutLaunchAuthorization CaptureFromEnvironment( + TimeProvider timeProvider, + string approvedSceneFilePath, + string approvedSceneSha256) + { + ArgumentNullException.ThrowIfNull(timeProvider); + ArgumentException.ThrowIfNullOrWhiteSpace(approvedSceneFilePath); + ArgumentException.ThrowIfNullOrWhiteSpace(approvedSceneSha256); + + lock (GateACaptureSync) + { + return CaptureFromEnvironmentLocked( + timeProvider, + approvedSceneFilePath, + approvedSceneSha256); + } + } + + internal static void ResetGateACaptureLatchForTests() + { + lock (GateACaptureSync) + { + _gateAObservedForProcess = false; + } + } + + private static PlayoutLaunchAuthorization CaptureFromEnvironmentLocked( + TimeProvider timeProvider, + string approvedSceneFilePath, + string approvedSceneSha256) + { + var liveAuthorized = PlayoutOptionsLoader.IsLiveAuthorizedForThisLaunch(); + var capability = Environment.GetEnvironmentVariable( + GateACapabilityEnvironmentVariable, + EnvironmentVariableTarget.Process); + var processIdText = Environment.GetEnvironmentVariable( + GateAPgmProcessIdEnvironmentVariable, + EnvironmentVariableTarget.Process); + var startTimeText = Environment.GetEnvironmentVariable( + GateAPgmStartTimeUtcTicksEnvironmentVariable, + EnvironmentVariableTarget.Process); + var expiresAtText = Environment.GetEnvironmentVariable( + GateAExpiresAtUtcTicksEnvironmentVariable, + EnvironmentVariableTarget.Process); + var anyGateAValue = capability is not null || + processIdText is not null || + startTimeText is not null || + expiresAtText is not null; + var gateAWasAlreadyObserved = _gateAObservedForProcess; + if (anyGateAValue) + { + // Latch before validation, cleanup, or file I/O so even an exception in + // those paths cannot make a second process-local capture eligible. + _gateAObservedForProcess = true; + } + + ClearGateAEnvironment(); + if (gateAWasAlreadyObserved || anyGateAValue) + { + ClearLiveAuthorizationEnvironment(); + } + + if (gateAWasAlreadyObserved) + { + throw new PlayoutConfigurationException( + "Gate A launch authorization was already observed by this process."); + } + + if (!anyGateAValue) + { + return new PlayoutLaunchAuthorization( + liveAuthorized, + gateACapabilityHash: null, + expectedPgmProcessId: null, + expectedPgmStartTimeUtcTicks: null, + expiresAtUtcTicks: null, + timeProvider, + authorizationCapturedTimestamp: 0, + authorizedLifetime: TimeSpan.Zero, + gateASceneLease: null); + } + + // Observing any Gate A launch value permanently consumes the process-wide + // capture slot before validation or file I/O. A malformed, expired, or + // otherwise rejected first attempt must never be corrected or re-injected + // within the same native process. + // The already-captured Live decision is retained only in the returned native + // object. Its static bearer was removed above before validation or file I/O. + + var nowUtcTicks = timeProvider.GetUtcNow().UtcTicks; + var capturedTimestamp = timeProvider.GetTimestamp(); + if (!IsExactCapability(capability) || + !int.TryParse( + processIdText, + NumberStyles.None, + CultureInfo.InvariantCulture, + out var processId) || + processId <= 0 || + !long.TryParse( + startTimeText, + NumberStyles.None, + CultureInfo.InvariantCulture, + out var startTimeUtcTicks) || + startTimeUtcTicks <= 0 || + !long.TryParse( + expiresAtText, + NumberStyles.None, + CultureInfo.InvariantCulture, + out var expiresAtUtcTicks) || + expiresAtUtcTicks <= nowUtcTicks || + expiresAtUtcTicks - nowUtcTicks > MaximumGateALifetimeTicks) + { + throw new PlayoutConfigurationException( + "Gate A launch authorization is incomplete, expired, or invalid."); + } + + var sceneLease = AcquireApprovedSceneLease( + approvedSceneFilePath, + approvedSceneSha256); + return new PlayoutLaunchAuthorization( + liveAuthorized, + HashCapability(capability!), + processId, + startTimeUtcTicks, + expiresAtUtcTicks, + timeProvider, + capturedTimestamp, + TimeSpan.FromTicks(expiresAtUtcTicks - nowUtcTicks), + sceneLease); + } + + /// + /// Atomically consumes the browser-to-native PREPARE capability. Invalid + /// values do not consume it. Expiry is checked immediately before the state + /// transition and permanently closes the gate. + /// + public bool TryClaimGateAPrepare(string? capability) + { + if (!IsGateA || !IsExactCapability(capability)) + { + return false; + } + + var candidateHash = HashCapability(capability!); + try + { + lock (_gateASync) + { + if (TerminateIfExpiredLocked() || + _gateAState != GateAArmed || + !CryptographicOperations.FixedTimeEquals( + candidateHash, + _gateACapabilityHash!)) + { + return false; + } + + // Re-read native time at the exact state boundary. A claim at the + // expiry tick is rejected and can never be rearmed. + if (TerminateIfExpiredLocked()) + { + return false; + } + + _gateAState = GateAOperatorClaimed; + return true; + } + } + finally + { + CryptographicOperations.ZeroMemory(candidateHash); + } + } + + /// + /// Permanently closes the PREPARE capability after the native request, + /// regardless of success, rejection, cancellation, timeout, or unknown outcome. + /// The approved cut lease intentionally remains held until native shutdown. + /// + public void CompleteGateAPrepare() + { + if (!IsGateA) + { + return; + } + + lock (_gateASync) + { + EnterTerminalLocked(); + } + } + + internal bool TryClaimEngineCommand( + PlayoutOperation operation, + PlayoutCue? cue) + { + if (!IsGateA) + { + return true; + } + + lock (_gateASync) + { + if (TerminateIfExpiredLocked()) + { + return false; + } + + switch (operation) + { + case PlayoutOperation.Connect when !_connectClaimed: + _connectClaimed = true; + return true; + case PlayoutOperation.Prepare: + return TryClaimEnginePrepareLocked(cue); + default: + return false; + } + } + } + + /// + /// Final native guard invoked on the STA immediately before an SDK dispatch + /// latch can be claimed. This closes the registration/queue-delay window after + /// the outer command claim. Expiry or a terminal gate is reported as a safety + /// exception so the engine quarantines without issuing cleanup commands. + /// + internal void EnsureGateASdkDispatchAuthorized() + { + if (!IsGateA) + { + return; + } + + lock (_gateASync) + { + if (TerminateIfExpiredLocked() || _gateAState == GateATerminal) + { + throw new K3dConnectSafetyException(); + } + } + } + + internal bool CanTakeIn => !IsGateA; + + internal string RejectionMessage(PlayoutOperation operation) => + IsGateA + ? $"Gate A does not authorize another {operation} command for this process." + : "The playout command is not authorized for this launch."; + + internal static PlayoutLaunchAuthorization CreateGateAForTests( + string capability, + bool liveAuthorized = true, + int expectedPgmProcessId = 1, + long expectedPgmStartTimeUtcTicks = 1, + TimeProvider? timeProvider = null, + long? expiresAtUtcTicks = null, + FileStream? gateASceneLease = null) + { + if (!IsExactCapability(capability)) + { + throw new ArgumentException( + "A test Gate A capability must be exactly 64 uppercase hexadecimal characters.", + nameof(capability)); + } + + var clock = timeProvider ?? TimeProvider.System; + var nowUtcTicks = clock.GetUtcNow().UtcTicks; + var expiry = expiresAtUtcTicks ?? + nowUtcTicks + MaximumGateALifetimeTicks; + return new PlayoutLaunchAuthorization( + liveAuthorized, + HashCapability(capability), + expectedPgmProcessId, + expectedPgmStartTimeUtcTicks, + expiry, + clock, + clock.GetTimestamp(), + TimeSpan.FromTicks(Math.Max(0, expiry - nowUtcTicks)), + gateASceneLease); + } + + internal static bool IsExactGateASceneDirectory(string? directory) + { + if (string.IsNullOrWhiteSpace(directory)) + { + return false; + } + + try + { + return string.Equals( + Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory)), + Path.TrimEndingDirectorySeparator( + Path.GetFullPath(GateAApprovedSceneDirectory)), + StringComparison.OrdinalIgnoreCase); + } + catch (Exception exception) when (exception is + ArgumentException or + NotSupportedException or + PathTooLongException) + { + return false; + } + } + + public void Dispose() + { + if (IsGateA) + { + lock (_gateASync) + { + EnterTerminalLocked(); + } + } + + Interlocked.Exchange(ref _gateASceneLease, null)?.Dispose(); + } + + private bool TryClaimEnginePrepareLocked(PlayoutCue? cue) + { + if (_gateAState != GateAOperatorClaimed) + { + return false; + } + + if (!IsExactGateAPrepareCue(cue)) + { + EnterTerminalLocked(); + return false; + } + + if (TerminateIfExpiredLocked()) + { + return false; + } + + _gateAState = GateAEnginePrepareClaimed; + return true; + } + + private bool TerminateIfExpiredLocked() + { + if (ExpiresAtUtcTicks is not { } expiry) + { + return false; + } + + var expired = _timeProvider.GetUtcNow().UtcTicks >= expiry; + if (!expired) + { + try + { + expired = _timeProvider.GetElapsedTime( + _authorizationCapturedTimestamp, + _timeProvider.GetTimestamp()) >= _authorizedLifetime; + } + catch + { + // A broken monotonic clock must never extend live authority. + expired = true; + } + } + + if (!expired) + { + return false; + } + + EnterTerminalLocked(); + return true; + } + + private void EnterTerminalLocked() + { + _gateAState = GateATerminal; + if (!_capabilityHashCleared && _gateACapabilityHash is not null) + { + CryptographicOperations.ZeroMemory(_gateACapabilityHash); + _capabilityHashCleared = true; + } + } + + private static bool IsExactGateAPrepareCue(PlayoutCue? cue) + { + if (cue is null || + !string.Equals(cue.SceneName, "5001", StringComparison.Ordinal) || + !string.Equals(cue.SceneFile, "5001.t2s", StringComparison.Ordinal) || + cue.FadeDuration != 6) + { + return false; + } + + var disabledBackgroundCount = 0; + foreach (var mutation in cue.Mutations ?? []) + { + switch (mutation) + { + case PlayoutUseBackground + { + IsEnabled: false, + Timing: PlayoutMutationTiming.BeforeTransaction + }: + disabledBackgroundCount++; + break; + case PlayoutUseBackground or + PlayoutSetBackgroundTexture or + PlayoutSetBackgroundVideo: + return false; + } + } + + return disabledBackgroundCount == 1; + } + + private static bool IsExactCapability(string? value) => + value is { Length: CapabilityLength } && value.All(static character => + character is >= '0' and <= '9' or >= 'A' and <= 'F'); + + private static byte[] HashCapability(string capability) => + SHA256.HashData(Encoding.ASCII.GetBytes(capability)); + + private static FileStream AcquireApprovedSceneLease( + string sceneFilePath, + string expectedSha256) + { + if (!IsExactCapability(expectedSha256)) + { + throw new PlayoutConfigurationException( + "The approved Gate A scene hash is invalid."); + } + + FileStream? lease = null; + byte[]? expectedHash = null; + byte[]? actualHash = null; + try + { + lease = new FileStream( + Path.GetFullPath(sceneFilePath), + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: 64 * 1024, + FileOptions.SequentialScan); + expectedHash = Convert.FromHexString(expectedSha256); + actualHash = SHA256.HashData(lease); + lease.Position = 0; + if (!CryptographicOperations.FixedTimeEquals(actualHash, expectedHash)) + { + throw new PlayoutConfigurationException( + "The approved Gate A scene hash does not match the leased file."); + } + + return lease; + } + catch (PlayoutConfigurationException) + { + lease?.Dispose(); + throw; + } + catch (Exception exception) when (exception is + IOException or + UnauthorizedAccessException or + ArgumentException or + NotSupportedException) + { + lease?.Dispose(); + throw new PlayoutConfigurationException( + "The approved Gate A scene could not be leased and verified.", + exception); + } + finally + { + if (expectedHash is not null) + { + CryptographicOperations.ZeroMemory(expectedHash); + } + + if (actualHash is not null) + { + CryptographicOperations.ZeroMemory(actualHash); + } + } + } + + private static void ClearGateAEnvironment() + { + Environment.SetEnvironmentVariable( + GateACapabilityEnvironmentVariable, + null, + EnvironmentVariableTarget.Process); + Environment.SetEnvironmentVariable( + GateAPgmProcessIdEnvironmentVariable, + null, + EnvironmentVariableTarget.Process); + Environment.SetEnvironmentVariable( + GateAPgmStartTimeUtcTicksEnvironmentVariable, + null, + EnvironmentVariableTarget.Process); + Environment.SetEnvironmentVariable( + GateAExpiresAtUtcTicksEnvironmentVariable, + null, + EnvironmentVariableTarget.Process); + } + + private static void ClearLiveAuthorizationEnvironment() => + Environment.SetEnvironmentVariable( + PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable, + null, + EnvironmentVariableTarget.Process); +} diff --git a/src/MBN_STOCK_WEBVIEW.Playout/TornadoPlayoutEngine.cs b/src/MBN_STOCK_WEBVIEW.Playout/TornadoPlayoutEngine.cs index 73342d3..b6d2002 100644 --- a/src/MBN_STOCK_WEBVIEW.Playout/TornadoPlayoutEngine.cs +++ b/src/MBN_STOCK_WEBVIEW.Playout/TornadoPlayoutEngine.cs @@ -18,6 +18,7 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine private readonly ITornadoProcessProbe _processProbe; private readonly IStaDispatcher? _dispatcher; private readonly ILiveAuthorization _liveAuthorization; + private readonly PlayoutLaunchAuthorization _launchAuthorization; private readonly TimeProvider _timeProvider; private readonly Func? _finalConnectSafetyCheck; private readonly bool _abandonOnTargetMismatch; @@ -55,7 +56,8 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine bool startMonitor = true, Func? finalConnectSafetyCheck = null, bool abandonOnTargetMismatch = false, - Action? beforeSdkDispatchClaim = null) + Action? beforeSdkDispatchClaim = null, + PlayoutLaunchAuthorization? launchAuthorization = null) { _options = options; _sessionFactory = sessionFactory; @@ -63,6 +65,8 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine _processProbe = processProbe; _dispatcher = dispatcher; _liveAuthorization = liveAuthorization; + _launchAuthorization = launchAuthorization ?? + PlayoutLaunchAuthorization.Unrestricted; _timeProvider = timeProvider; _finalConnectSafetyCheck = finalConnectSafetyCheck; _abandonOnTargetMismatch = abandonOnTargetMismatch; @@ -102,7 +106,8 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine return SerializedAsync( PlayoutOperation.Prepare, cancellationToken, - token => PrepareCoreAsync(cue, token)); + token => PrepareCoreAsync(cue, token), + cue); } public Task TakeInAsync(CancellationToken cancellationToken = default) => @@ -116,7 +121,8 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine return SerializedAsync( PlayoutOperation.Next, cancellationToken, - token => NextCoreAsync(cue, token)); + token => NextCoreAsync(cue, token), + cue); } public Task UpdateOnAirAsync( @@ -127,7 +133,8 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine return SerializedAsync( PlayoutOperation.UpdateOnAir, cancellationToken, - token => UpdateOnAirCoreAsync(cue, token)); + token => UpdateOnAirCoreAsync(cue, token), + cue); } public Task TakeOutAsync( @@ -337,7 +344,9 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine return; } - if (_session is not null || !_connectionRequested || !_options.ReconnectEnabled || + if (_session is not null || !_connectionRequested || + !_launchAuthorization.AllowsAutomaticReconnect || + !_options.ReconnectEnabled || _outcomeUnknown || _reconnectAttempts >= _options.MaximumReconnectAttempts || _timeProvider.GetUtcNow() < _nextReconnectAt) { @@ -362,7 +371,8 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine private async Task SerializedAsync( PlayoutOperation operation, CancellationToken cancellationToken, - Func> callback) + Func> callback, + PlayoutCue? cue = null) { try { @@ -407,6 +417,14 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine return ExistingOutcomeUnknown(operation); } + if (!_launchAuthorization.TryClaimEngineCommand(operation, cue)) + { + return Result( + operation, + PlayoutResultCode.Rejected, + _launchAuthorization.RejectionMessage(operation)); + } + return await callback(cancellationToken).ConfigureAwait(false); } finally @@ -1412,6 +1430,12 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine return false; } + if (_session is not null && !_launchAuthorization.AllowsSdkDisconnect) + { + await AbandonCurrentSessionAsync().ConfigureAwait(false); + return false; + } + var session = _session; if (session?.HasPendingLifecycleCallbacks == true) { @@ -1526,6 +1550,11 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine IK3dSession session, CancellationToken cancellationToken) { + if (!_launchAuthorization.AllowsSdkDisconnect) + { + throw new K3dConnectSafetyException(); + } + var dispatchLatch = new SdkDispatchLatch(_beforeSdkDispatchClaim); return _dispatcher!.InvokeAsync( () => @@ -1759,7 +1788,8 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine private bool CanTakeIn() => _options.Mode switch { PlayoutMode.Test => true, - PlayoutMode.Live => IsLiveAuthorized(), + PlayoutMode.Live => + IsLiveAuthorized() && _launchAuthorization.CanTakeIn, _ => false }; diff --git a/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/DatabaseMutationAuthorizationTestDoubles.cs b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/DatabaseMutationAuthorizationTestDoubles.cs new file mode 100644 index 0000000..653c422 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/DatabaseMutationAuthorizationTestDoubles.cs @@ -0,0 +1,15 @@ +#nullable enable + +namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests; + +internal sealed class DenyAllDatabaseMutationAuthorization + : IDatabaseMutationAuthorization +{ + internal List Requests { get; } = []; + + public bool IsAuthorized(DatabaseMutationAuthorizationRequest request) + { + Requests.Add(request); + return false; + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/OperatorCatalogMutationExecutorTests.cs b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/OperatorCatalogMutationExecutorTests.cs index 2356eaf..fc869c4 100644 --- a/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/OperatorCatalogMutationExecutorTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/OperatorCatalogMutationExecutorTests.cs @@ -290,8 +290,37 @@ public sealed class OperatorCatalogMutationExecutorTests Assert.Equal(0, plan.BeginCount); } + [Fact] + public async Task ExecuteTransactionAsync_DeniedAuthorizationCreatesNoConnectionOrTransaction() + { + var plan = new CatalogMutationConnectionPlan(); + var factory = new CatalogMutationConnectionFactory(plan); + var authorization = new DenyAllDatabaseMutationAuthorization(); + var service = new LegacyExpertCatalogPersistenceService( + new CatalogNoQueryExecutor(), + CreateExecutor(factory, authorization)); + + var exception = await Assert.ThrowsAsync(() => + service.CreateAsync(new ExpertCatalogDefinition( + new ExpertSelectionIdentity("0001", "Expert"), + []))); + + Assert.False(exception.OutcomeUnknown); + var denied = Assert.IsType( + exception.InnerException); + Assert.Equal(DatabaseMutationTarget.OperatorCatalog, denied.Request.Target); + Assert.Equal(DataSourceKind.Oracle, denied.Request.Source); + Assert.Equal(nameof(OperatorCatalogMutationKind.CreateExpert), denied.Request.MutationKind); + Assert.NotEqual(Guid.Empty, denied.Request.OperationId); + Assert.Single(authorization.Requests); + Assert.Equal(0, factory.CreateCount); + Assert.Equal(0, plan.BeginCount); + Assert.Empty(plan.Commands); + } + private static OperatorCatalogMutationExecutor CreateExecutor( - IDatabaseConnectionFactory factory) => + IDatabaseConnectionFactory factory, + IDatabaseMutationAuthorization? mutationAuthorization = null) => new( factory, new DatabaseResilienceOptions @@ -301,7 +330,8 @@ public sealed class OperatorCatalogMutationExecutorTests InitialRetryDelayMilliseconds = 1 }, new StubErrorDetector(transient: true), - static (_, _) => { }); + static (_, _) => { }, + mutationAuthorization ?? DatabaseMutationAuthorization.AllowAll); private static ObservedCatalogMutationParameter Parameter( ObservedCatalogMutationCommand command, diff --git a/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/OracleManualFinancialMutationExecutorTests.cs b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/OracleManualFinancialMutationExecutorTests.cs index 28969ac..7462cff 100644 --- a/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/OracleManualFinancialMutationExecutorTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/OracleManualFinancialMutationExecutorTests.cs @@ -68,6 +68,31 @@ public sealed class OracleManualFinancialMutationExecutorTests Assert.True(plan.TransactionDisposed); } + [Fact] + public async Task DeniedAuthorizationCreatesNoConnectionOrTransaction() + { + var plan = new Plan(); + var factory = new Factory(plan); + var authorization = new DenyAllDatabaseMutationAuthorization(); + var service = Service(Executor(factory, mutationAuthorization: authorization)); + + var exception = await Assert.ThrowsAsync(() => + service.CreateAsync(Sales("Alpha"))); + + Assert.False(exception.OutcomeUnknown); + var denied = Assert.IsType( + exception.InnerException); + Assert.Equal(DatabaseMutationTarget.ManualFinancial, denied.Request.Target); + Assert.Equal(DataSourceKind.Oracle, denied.Request.Source); + Assert.Equal(nameof(ManualFinancialMutationKind.Create), denied.Request.MutationKind); + Assert.NotEqual(Guid.Empty, denied.Request.OperationId); + Assert.Single(authorization.Requests); + Assert.Equal(0, factory.CreateCount); + Assert.Equal(0, plan.OpenCount); + Assert.Equal(0, plan.BeginCount); + Assert.Empty(plan.Commands); + } + [Fact] public async Task Zero_affected_create_rolls_back_as_known_duplicate_before_commit() { @@ -141,7 +166,8 @@ public sealed class OracleManualFinancialMutationExecutorTests private static OracleManualFinancialMutationExecutor Executor( Factory factory, - int operationTimeoutSeconds = 30) => + int operationTimeoutSeconds = 30, + IDatabaseMutationAuthorization? mutationAuthorization = null) => new( factory, new DatabaseResilienceOptions @@ -151,7 +177,8 @@ public sealed class OracleManualFinancialMutationExecutorTests InitialRetryDelayMilliseconds = 30_000 }, new StubErrorDetector(false), - command => Assert.IsType(command).BindByName = true); + command => Assert.IsType(command).BindByName = true, + mutationAuthorization ?? DatabaseMutationAuthorization.AllowAll); private static ManualSalesRecord Sales(string stockName) => new( diff --git a/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/OracleNamedPlaylistMutationExecutorTests.cs b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/OracleNamedPlaylistMutationExecutorTests.cs index 9b056c0..794e376 100644 --- a/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/OracleNamedPlaylistMutationExecutorTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/OracleNamedPlaylistMutationExecutorTests.cs @@ -283,6 +283,31 @@ public sealed class OracleNamedPlaylistMutationExecutorTests Assert.Empty(plan.Commands); } + [Fact] + public async Task DeniedAuthorizationCreatesNoConnectionOrTransaction() + { + var plan = new MutationPlan(); + var factory = new MutationConnectionFactory(plan); + var authorization = new DenyAllDatabaseMutationAuthorization(); + var service = CreateService(CreateExecutor(factory, mutationAuthorization: authorization)); + + var exception = await Assert.ThrowsAsync( + () => service.CreateDefinitionAsync("00000011", "Morning")); + + Assert.False(exception.OutcomeUnknown); + var denied = Assert.IsType( + exception.InnerException); + Assert.Equal(DatabaseMutationTarget.NamedPlaylist, denied.Request.Target); + Assert.Equal(DataSourceKind.Oracle, denied.Request.Source); + Assert.Equal(nameof(NamedPlaylistMutationKind.CreateDefinition), denied.Request.MutationKind); + Assert.NotEqual(Guid.Empty, denied.Request.OperationId); + Assert.Single(authorization.Requests); + Assert.Equal(0, factory.CreateCount); + Assert.Equal(0, plan.OpenCount); + Assert.Equal(0, plan.BeginCount); + Assert.Empty(plan.Commands); + } + [Fact] public void Constructor_RejectsInvalidCommandTimeout() { @@ -296,7 +321,8 @@ public sealed class OracleNamedPlaylistMutationExecutorTests private static OracleNamedPlaylistMutationExecutor CreateExecutor( MutationConnectionFactory factory, - int operationTimeoutSeconds = 30) => + int operationTimeoutSeconds = 30, + IDatabaseMutationAuthorization? mutationAuthorization = null) => new( factory, new DatabaseResilienceOptions @@ -306,7 +332,8 @@ public sealed class OracleNamedPlaylistMutationExecutorTests InitialRetryDelayMilliseconds = 30_000 }, new TransientDatabaseErrorDetector(), - command => Assert.IsType(command).BindByName = true); + command => Assert.IsType(command).BindByName = true, + mutationAuthorization ?? DatabaseMutationAuthorization.AllowAll); private static LegacyNamedPlaylistPersistenceService CreateService( INamedPlaylistMutationExecutor mutations) => diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/LegacyPlayoutBridgeTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/LegacyPlayoutBridgeTests.cs index 99269d5..3770101 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/LegacyPlayoutBridgeTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/LegacyPlayoutBridgeTests.cs @@ -8,6 +8,47 @@ namespace MBN_STOCK_WEBVIEW.LegacyBridge.Tests; public sealed class LegacyPlayoutBridgeTests { + private const string GateACapability = + "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF"; + + [Fact] + public void GateAPrepare_ParsesOnlyExactUppercaseCapabilityShape() + { + Assert.True(LegacyBridgeProtocol.TryParseIntent( + "{\"type\":\"gate-a-prepare\",\"payload\":{\"capability\":\"" + + GateACapability + "\"}}", + out var intent, + out var error), error); + + Assert.Equal( + GateACapability, + Assert.IsType(intent).Capability); + } + + [Theory] + [InlineData("")] + [InlineData("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")] + [InlineData("0123456789ABCDEF")] + [InlineData("G123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF")] + public void GateAPrepare_RejectsInvalidCapabilities(string capability) + { + Assert.False(LegacyBridgeProtocol.TryParseIntent( + "{\"type\":\"gate-a-prepare\",\"payload\":{\"capability\":\"" + + capability + "\"}}", + out _, + out _)); + } + + [Fact] + public void GateAPrepare_RejectsExtraProperties() + { + Assert.False(LegacyBridgeProtocol.TryParseIntent( + "{\"type\":\"gate-a-prepare\",\"payload\":{\"capability\":\"" + + GateACapability + "\",\"retry\":false}}", + out _, + out _)); + } + [Theory] [InlineData("prepare-playout", LegacyOperatorPlayoutCommand.Prepare)] [InlineData("take-in", LegacyOperatorPlayoutCommand.TakeIn)] diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyGateAAppIntegrationContractTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyGateAAppIntegrationContractTests.cs new file mode 100644 index 0000000..02f2ec2 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyGateAAppIntegrationContractTests.cs @@ -0,0 +1,361 @@ +namespace MBN_STOCK_WEBVIEW.LegacyWeb.Tests; + +public sealed class LegacyGateAAppIntegrationContractTests +{ + private static readonly string RepositoryRoot = FindRepositoryRoot(); + private static readonly string AppRoot = Path.Combine( + RepositoryRoot, + "src", + "MBN_STOCK_WEBVIEW.LegacyParityApp"); + private static readonly string Window = Read(AppRoot, "MainWindow.xaml.cs"); + private static readonly string Playout = Read(AppRoot, "MainWindow.Playout.cs"); + private static readonly string IntentParser = Read( + RepositoryRoot, + "src", + "MBN_STOCK_WEBVIEW.LegacyBridge", + "LegacyPlayoutUiIntents.cs"); + private static readonly string Factory = Read( + RepositoryRoot, + "src", + "MBN_STOCK_WEBVIEW.Playout", + "PlayoutEngineFactory.cs"); + private static readonly string LaunchAuthorization = Read( + RepositoryRoot, + "src", + "MBN_STOCK_WEBVIEW.Playout", + "Safety", + "PlayoutLaunchAuthorization.cs"); + private static readonly string InfrastructureRoot = Path.Combine( + RepositoryRoot, + "src", + "MBN_STOCK_WEBVIEW.Infrastructure", + "Execution"); + + [Fact] + public void Launch_authorization_is_captured_before_WebView_creation_and_forwarded_unchanged() + { + var constructor = Slice( + Window, + " public MainWindow()", + " private async void OnRootLoaded("); + var capture = RequiredIndex( + constructor, + "PlayoutLaunchAuthorization.CaptureFromEnvironment()"); + var initializeComponent = RequiredIndex(constructor, "InitializeComponent();"); + + Assert.True(capture < initializeComponent); + Assert.Contains( + "_playoutLaunchAuthorization =\n" + + " PlayoutLaunchAuthorization.CaptureFromEnvironment();", + Normalize(constructor), + StringComparison.Ordinal); + + var initialization = Normalize(Slice( + Playout, + " private void InitializePlayoutRuntime()", + " private async Task ConnectPlayoutAsync(")); + Assert.Contains( + "_playoutEngine = PlayoutEngineFactory.Create(\n" + + " _playoutOptions,\n" + + " _playoutLaunchAuthorization);", + initialization, + StringComparison.Ordinal); + + var factoryCreate = Slice( + Normalize(Factory), + " public static IPlayoutEngine Create(\n" + + " PlayoutOptions options,\n" + + " PlayoutLaunchAuthorization launchAuthorization)", + " internal static void ValidateGateAOptions("); + Assert.Contains("dispatcher,\n launchAuthorization,", factoryCreate, + StringComparison.Ordinal); + Assert.Contains("launchAuthorization: launchAuthorization", factoryCreate, + StringComparison.Ordinal); + } + + [Fact] + public void Gate_A_has_a_dedicated_capability_intent_and_exact_Samsung_5001_prepare_handler() + { + Assert.Contains( + "public sealed record LegacyGateAPrepareIntent(string Capability)", + IntentParser, + StringComparison.Ordinal); + Assert.Contains("\"gate-a-prepare\" => ParseGateAPrepare(payload)", IntentParser, + StringComparison.Ordinal); + var parser = Slice( + IntentParser, + " private static LegacyUiIntent? ParseGateAPrepare(JsonElement payload)", + " private static LegacyUiIntent? ParseFadeDuration(JsonElement payload)"); + Assert.Contains("payload.EnumerateObject().Count() != 1", parser, + StringComparison.Ordinal); + Assert.Contains("capability.Length != 64", parser, StringComparison.Ordinal); + Assert.Contains("not (>= 'A' and <= 'F')", parser, StringComparison.Ordinal); + + var dispatch = Normalize(Slice( + Window, + " private async Task HandleIntentAsync(", + " private async Task RefreshNamedPlaylistsForDispatchAsync(")); + Assert.Contains( + "LegacyGateAPrepareIntent prepare =>\n" + + " await ExecuteGateAPrepareAsync(\n" + + " prepare.Capability,", + dispatch, + StringComparison.Ordinal); + + var handler = Slice( + Playout, + " private async Task ExecuteGateAPrepareAsync(", + " private bool TryCreateExactGateAPrepareRequest("); + Assert.Contains("TryClaimGateAPrepare(capability)", handler, + StringComparison.Ordinal); + Assert.Contains("TryCreateExactGateAPrepareRequest(out var request)", handler, + StringComparison.Ordinal); + Assert.Contains("gateAPrepareClaimed: true", handler, StringComparison.Ordinal); + Assert.Contains("gateAPrepareRequest: request", handler, StringComparison.Ordinal); + Assert.Contains("CompleteGateAPrepare()", handler, StringComparison.Ordinal); + + var exact = Slice( + Playout, + " private bool TryCreateExactGateAPrepareRequest(", + " private async Task SetFadeDurationAsync("); + foreach (var required in new[] + { + "snapshot.Playlist.Count != 1", + "composition.FadeDuration != 6", + "composition.BackgroundKind != LegacySceneBackgroundKind.None", + "row.MarketText, \"코스피\"", + "row.StockName, \"삼성전자\"", + "row.PageText, \"1/1\"", + "row.StockCode, \"005930\"", + "_controller.CreatePlayoutRequest(6)", + "candidate.SelectedIndexZeroBased != 0", + "entry.CutCode, \"5001\"", + "entry.FadeDuration != 6", + "entry.PageNavigation?.IsEnabled == true", + "selection.GroupCode, \"코스피\"", + "selection.Subject, \"삼성전자\"", + "selection.DataCode, \"005930\"" + }) + { + Assert.Contains(required, exact, StringComparison.Ordinal); + } + } + + [Fact] + public void Ordinary_playout_intents_are_rejected_in_Gate_A_before_workflow_access() + { + var execute = Slice( + Playout, + " private async Task ExecuteOperatorPlayoutAsync(", + " private async Task ExecuteGateAPrepareAsync("); + var workflowRead = RequiredIndex(execute, "var workflow = _playoutWorkflow;"); + var preWorkflowGuard = execute[..workflowRead]; + + Assert.Contains("_playoutLaunchAuthorization.IsGateA", preWorkflowGuard, + StringComparison.Ordinal); + Assert.Contains("!gateAPrepareClaimed", preWorkflowGuard, StringComparison.Ordinal); + Assert.Contains("command != LegacyOperatorPlayoutCommand.Prepare", preWorkflowGuard, + StringComparison.Ordinal); + Assert.Contains("gateAPrepareRequest is null", preWorkflowGuard, + StringComparison.Ordinal); + Assert.Contains("return _controller.ReportError(", preWorkflowGuard, + StringComparison.Ordinal); + Assert.DoesNotContain("_playoutWorkflow", preWorkflowGuard, + StringComparison.Ordinal); + Assert.DoesNotContain("CreatePlayoutRequest", preWorkflowGuard, + StringComparison.Ordinal); + Assert.DoesNotContain("_playoutCommandGate.WaitAsync", preWorkflowGuard, + StringComparison.Ordinal); + + var dispatch = Slice( + Window, + " private async Task HandleIntentAsync(", + " private async Task RefreshNamedPlaylistsForDispatchAsync("); + Assert.Contains("LegacyExecutePlayoutIntent playout =>", dispatch, + StringComparison.Ordinal); + Assert.Contains("ExecuteOperatorPlayoutAsync(\n playout.Command,", + Normalize(dispatch), StringComparison.Ordinal); + } + + [Fact] + public void All_database_mutation_executors_receive_the_same_launch_denial_policy() + { + var constructor = Slice( + Window, + " public MainWindow()", + " private async void OnRootLoaded("); + Assert.Contains( + "new LaunchDatabaseMutationAuthorization(_playoutLaunchAuthorization)", + constructor, + StringComparison.Ordinal); + Assert.True( + CountOccurrences( + constructor, + "mutationAuthorization: databaseMutationAuthorization") == 3, + "The same launch authorization must be injected into all three DB mutation executors."); + + var adapter = Slice( + Window, + " private sealed class LaunchDatabaseMutationAuthorization", + " private void PostState("); + Assert.Contains(": IDatabaseMutationAuthorization", adapter, + StringComparison.Ordinal); + Assert.Contains("return _authorization.AllowsDatabaseWrites;", adapter, + StringComparison.Ordinal); + Assert.Contains("_authorization = authorization ??", adapter, + StringComparison.Ordinal); + + foreach (var executorFile in new[] + { + "OracleManualFinancialMutationExecutor.cs", + "OracleNamedPlaylistMutationExecutor.cs", + "OperatorCatalogMutationExecutor.cs" + }) + { + var executor = File.ReadAllText(Path.Combine(InfrastructureRoot, executorFile)); + var demand = RequiredIndex(executor, "DatabaseMutationAuthorizationGuard.Demand("); + var open = RequiredIndex(executor, "await connection.OpenAsync("); + Assert.True(demand < open, $"{executorFile} must deny before opening a DB connection."); + Assert.Contains("_mutationAuthorization", executor, StringComparison.Ordinal); + Assert.Contains("IDatabaseMutationAuthorization mutationAuthorization", executor, + StringComparison.Ordinal); + } + } + + [Fact] + public void Gate_A_blocks_persistent_write_intents_before_dispatch() + { + var process = Normalize(Slice( + Window, + " private async Task ProcessIntentAsync(LegacyUiIntent intent)", + " private static bool IsNamedPlaylistModalIntent(")); + var guard = RequiredIndex( + process, + "_playoutLaunchAuthorization.IsGateA &&\n IsPersistentWriteIntent(intent)"); + var dispatchStarted = RequiredIndex(process, "dispatchStarted = true;"); + var dispatch = RequiredIndex(process, "HandleIntentAsync(intent"); + Assert.True(guard < dispatchStarted); + Assert.True(guard < dispatch); + + var guardEnd = process.IndexOf("return;", guard, StringComparison.Ordinal); + Assert.True(guardEnd > guard); + Assert.Contains("ReportIntentFailure(", process[guard..guardEnd], + StringComparison.Ordinal); + + var persistent = Slice( + Playout, + " private static bool IsPersistentWriteIntent(LegacyUiIntent intent)", + " private static bool IsFixedSectionBatchMutationIntent("); + foreach (var intent in new[] + { + "LegacyDeleteUc4SelectedThemeIntent", + "LegacyDeleteUc6SelectedExpertIntent", + "LegacySaveOperatorCatalogIntent", + "LegacyConfirmNativeDialogIntent", + "LegacyAddComparisonPairIntent", + "LegacySaveManualFinancialIntent", + "LegacySaveManualNetSellIntent", + "LegacySaveManualViIntent", + "LegacyImportManualListsIntent", + "LegacyCreateNamedPlaylistIntent", + "LegacySaveCurrentNamedPlaylistIntent", + "LegacyDeleteSelectedNamedPlaylistIntent" + }) + { + Assert.Contains(intent, persistent, StringComparison.Ordinal); + } + } + + [Fact] + public void Gate_A_blocks_fade_and_background_mutation_before_any_command_gate() + { + Assert.Contains("public bool AllowsCompositionMutation => !IsGateA;", + LaunchAuthorization, StringComparison.Ordinal); + + var fade = Slice( + Playout, + " private async Task SetFadeDurationAsync(", + " private LegacyOperatorSnapshot SelectPlaylistBoundaryAndStopRefresh("); + AssertCompositionMutationGuard( + fade, + "Gate A 검증 회차에서는 Fade 설정을 변경할 수 없습니다."); + + var toggle = Slice( + Playout, + " private async Task ToggleBackgroundAsync(", + " private async Task ChooseBackgroundAsync("); + AssertCompositionMutationGuard( + toggle, + "Gate A 검증 회차에서는 배경 설정을 변경할 수 없습니다."); + + var choose = Slice( + Playout, + " private async Task ChooseBackgroundAsync(", + " private bool CanChangeComposition()"); + AssertCompositionMutationGuard( + choose, + "Gate A 검증 회차에서는 배경 파일을 선택할 수 없습니다."); + } + + private static void AssertCompositionMutationGuard(string method, string message) + { + var authorization = RequiredIndex( + method, + "if (!_playoutLaunchAuthorization.AllowsCompositionMutation)"); + var commandGate = RequiredIndex(method, "_playoutCommandGate.WaitAsync"); + Assert.True(authorization < commandGate); + Assert.Contains(message, method[authorization..commandGate], + StringComparison.Ordinal); + Assert.Contains("return _controller.ReportError(", method[authorization..commandGate], + StringComparison.Ordinal); + } + + private static int CountOccurrences(string source, string marker) + { + var count = 0; + var index = 0; + while ((index = source.IndexOf(marker, index, StringComparison.Ordinal)) >= 0) + { + count++; + index += marker.Length; + } + + return count; + } + + private static string Slice(string source, string startMarker, string endMarker) + { + var start = RequiredIndex(source, startMarker); + var end = RequiredIndex(source, endMarker, start + startMarker.Length); + return source[start..end]; + } + + private static int RequiredIndex(string source, string marker, int startIndex = 0) + { + var index = source.IndexOf(marker, startIndex, StringComparison.Ordinal); + Assert.True(index >= 0, $"Required source marker was not found: {marker}"); + return index; + } + + private static string Normalize(string source) => + source.Replace("\r\n", "\n", StringComparison.Ordinal); + + private static string Read(params string[] parts) => + File.ReadAllText(Path.Combine(parts)); + + private static string FindRepositoryRoot() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current is not null) + { + if (File.Exists(Path.Combine(current.FullName, "MBN_STOCK_WEBVIEW.sln"))) + { + return current.FullName; + } + + current = current.Parent; + } + + throw new DirectoryNotFoundException("Repository root was not found."); + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyGateAHarnessContractTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyGateAHarnessContractTests.cs new file mode 100644 index 0000000..13a50b7 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyGateAHarnessContractTests.cs @@ -0,0 +1,318 @@ +namespace MBN_STOCK_WEBVIEW.LegacyWeb.Tests; + +public sealed class LegacyGateAHarnessContractTests +{ + private const string CurrentMsixSha256 = + "0E44F87FDD31852B7DDFCD6CF3A6325DA674F1F5A23AD39CB262A725AEB84367"; + + private static readonly string RepositoryRoot = FindRepositoryRoot(); + private static readonly string Runner = ReadScript("Invoke-LegacyPgmPrepareGate.ps1"); + private static readonly string PlanBuilder = ReadScript("New-LegacyPgmPreparePlan.ps1"); + private static readonly string NodeHelper = ReadScript("Test-LegacyPgmPrepareGate.mjs"); + + [Fact] + public void Node_receives_the_bearer_only_from_the_parent_owned_JIT_pipe() + { + Assert.Contains("import net from \"node:net\";", NodeHelper, StringComparison.Ordinal); + Assert.Contains("\"capability-sha256\", \"permit-pipe\"", NodeHelper, + StringComparison.Ordinal); + Assert.Contains( + "name.startsWith(\"MBN_LEGACY_PGM_GATE_A_\")", + NodeHelper, + StringComparison.Ordinal); + Assert.DoesNotContain( + "process.env[capabilityName]", + NodeHelper, + StringComparison.Ordinal); + + var receive = Slice( + NodeHelper, + "async function receiveJitCapability(expiresAt)", + "async function prepareOnce(expiresAt)"); + Assert.Contains("net.createConnection(pipePath)", receive, StringComparison.Ordinal); + Assert.Contains("bytes.length !== 64", receive, StringComparison.Ordinal); + Assert.Contains("sha256(bytes) !== configuration.capabilitySha256", receive, + StringComparison.Ordinal); + Assert.Contains("jitPermitReceived = true", receive, StringComparison.Ordinal); + + var prepare = Slice( + NodeHelper, + "async function prepareOnce(expiresAt)", + "async function captureScreenshot()"); + var boundary = RequiredIndex(prepare, "const preRevision = before.state.revision;"); + var permit = RequiredIndex(prepare, "await receiveJitCapability(expiresAt)"); + var arm = RequiredIndex(prepare, "__legacyPgmPrepareGate.arm("); + var dispatchPossible = RequiredIndex(prepare, "prepareDispatchPossible = true;"); + Assert.True(boundary < permit && permit < arm && arm < dispatchPossible); + + var execute = Slice(NodeHelper, "async function execute()", "try {"); + Assert.Contains( + "installGate(configuration.planSha256, configuration.capabilitySha256)", + execute, + StringComparison.Ordinal); + } + + [Fact] + public void Runner_authenticates_the_exact_Node_client_before_one_JIT_delivery() + { + var start = Slice( + Runner, + "function Start-NodeHelper([object] $Plan)", + "function Write-Evidence([string] $Path)"); + Assert.Contains("New-CurrentUserOneShotPipe 'Node'", start, StringComparison.Ordinal); + Assert.Contains("'--capability-sha256'", start, StringComparison.Ordinal); + Assert.Contains("'--permit-pipe'", start, StringComparison.Ordinal); + Assert.DoesNotContain( + "$info.EnvironmentVariables[$capabilityName]", + start, + StringComparison.Ordinal); + Assert.Contains("MBN_LEGACY_PGM_GATE_A_*", start, StringComparison.Ordinal); + Assert.Contains("Assert-NodePermitClient", start, StringComparison.Ordinal); + + var client = Slice( + Runner, + "function Assert-NodePermitClient(", + "function Stop-NodeHelperFailStop("); + Assert.Contains("ReadNamedPipeClientProcessId", client, StringComparison.Ordinal); + Assert.Contains("$clientProcessId -ne $Process.Id", client, StringComparison.Ordinal); + Assert.Contains("$Process.StartTime.ToUniversalTime().Ticks", client, + StringComparison.Ordinal); + Assert.Contains("[int]$row.ParentProcessId -ne $PID", client, + StringComparison.Ordinal); + Assert.Contains("SamePath ([string]$row.ExecutablePath) $ExpectedExecutable", client, + StringComparison.Ordinal); + + var delivery = RequiredIndex(start, "$permitPipe.Stream.Write($bytes,0,$bytes.Length)"); + foreach (var marker in new[] + { + "Assert-NodePermitClient", + "Assert-Authorization $script:authorization $Plan", + "Assert-Application $script:application", + "Assert-Pgm $Plan.pgm", + "Assert-Cdp ([int]$Plan.webView.port)" + }) + { + Assert.True(RequiredIndex(start, marker) < delivery, marker); + } + + var stop = Slice( + Runner, + "function Stop-NodeHelperFailStop(", + "function Start-NodeHelper([object] $Plan)"); + Assert.Contains("Close-OneShotPipe $Pipe", stop, StringComparison.Ordinal); + Assert.Contains("$Process.Kill()", stop, StringComparison.Ordinal); + Assert.Contains("$Process.WaitForExit(0)", stop, StringComparison.Ordinal); + Assert.Contains("$Process.WaitForExit(5000)", stop, StringComparison.Ordinal); + Assert.Contains("was not proven stopped", stop, StringComparison.Ordinal); + Assert.Contains("only the control-plane Node helper", stop, StringComparison.Ordinal); + Assert.DoesNotContain("$script:application", stop, StringComparison.Ordinal); + Assert.DoesNotContain("Assert-Pgm", stop, StringComparison.Ordinal); + + var catchBlock = start[RequiredIndex(start, " catch {")..]; + Assert.Contains("Stop-NodeHelperFailStop $process $permitPipe", catchBlock, + StringComparison.Ordinal); + Assert.Contains("exceeded its outer timeout; parent fail-stop is required", start, + StringComparison.Ordinal); + var monitorTry = Slice(start, " try {\n $process.Refresh()", " catch {"); + Assert.True( + RequiredIndex(monitorTry, "$process.StartTime.ToUniversalTime().Ticks") < + RequiredIndex(monitorTry, "while (-not $process.HasExited")); + Assert.Contains("if (-not $process.HasExited)", monitorTry, StringComparison.Ordinal); + Assert.Contains("$process.WaitForExit(0)", monitorTry, StringComparison.Ordinal); + } + + [Fact] + public void Package_wrapper_receives_launch_environment_over_an_authenticated_pipe_not_its_command_line() + { + var package = Slice( + Runner, + "function Start-Package([object] $Plan)", + "function Quote-Argument([string] $Value)"); + Assert.DoesNotContain("$encodedEnvironment", package, StringComparison.Ordinal); + Assert.Contains("New-CurrentUserOneShotPipe 'Package'", package, + StringComparison.Ordinal); + Assert.Contains("Assert-PackagePermitClient", package, StringComparison.Ordinal); + Assert.Contains("[LegacyPgmPreparePackageIdentity]::Read($process.Handle)", + Runner, + StringComparison.Ordinal); + Assert.Contains("$payload = $utf8NoBom.GetBytes(($environment | ConvertTo-Json -Compress))", + package, + StringComparison.Ordinal); + Assert.Contains("$launchPipe.Stream.Write($payload,0,$payload.Length)", package, + StringComparison.Ordinal); + Assert.Contains("$client.ReadByte() -ne -1", package, StringComparison.Ordinal); + var wrapperStop = Slice( + Runner, + "function Stop-PackageWrapperFailStop(", + "function Start-Package([object] $Plan)"); + Assert.Contains("$Process.Kill()", wrapperStop, StringComparison.Ordinal); + Assert.Contains("$Process.WaitForExit(0)", wrapperStop, StringComparison.Ordinal); + Assert.Contains("$Process.WaitForExit(5000)", wrapperStop, StringComparison.Ordinal); + Assert.Contains("was not proven stopped", wrapperStop, StringComparison.Ordinal); + Assert.DoesNotContain("catch { return }", wrapperStop, StringComparison.Ordinal); + Assert.Contains("$wrapper.WaitForExit(0)", package, StringComparison.Ordinal); + Assert.Contains("without proving the exact wrapper exited", package, + StringComparison.Ordinal); + var child = Slice(package, " $child = @\"", "\"@\n $encodedChild"); + Assert.DoesNotContain("$encodedEnvironment", child, StringComparison.Ordinal); + Assert.DoesNotContain("$script:gateCapability", child, StringComparison.Ordinal); + Assert.DoesNotContain("$capabilityName", child, StringComparison.Ordinal); + Assert.DoesNotContain("$environment", child, StringComparison.Ordinal); + + var pipes = Slice( + Runner, + "function New-CurrentUserOneShotPipe([string] $Purpose)", + "function Close-OneShotPipe([object] $Pipe)"); + Assert.Contains("[IO.Pipes.PipeDirection]::InOut", pipes, StringComparison.Ordinal); + Assert.Contains("[IO.Pipes.PipeSecurity]::new()", pipes, StringComparison.Ordinal); + Assert.Contains("$identity.User", pipes, StringComparison.Ordinal); + Assert.Contains("SetAccessRuleProtection($true, $false)", pipes, + StringComparison.Ordinal); + } + + [Fact] + public void Managed_runtime_injection_environment_is_rejected_and_removed_from_children() + { + var preflight = Slice( + Runner, + "function Assert-NoUnsafeEnvironment", + "function Assert-Pgm([object] $Pgm)"); + var package = Slice( + Runner, + "function Start-Package([object] $Plan)", + "function Quote-Argument([string] $Value)"); + var child = Slice(package, " $child = @\"", "\"@\n $encodedChild"); + var node = Slice( + Runner, + "function Start-NodeHelper([object] $Plan)", + "function Write-Evidence([string] $Path)"); + + foreach (var prefix in new[] { "DOTNET_*", "CORECLR_*", "COR_*", "COMPlus_*" }) + { + Assert.Contains(prefix, preflight, StringComparison.Ordinal); + Assert.True(Count(child, prefix) >= 2, $"{prefix} must be removed before and after app launch."); + Assert.Contains(prefix, node, StringComparison.Ordinal); + } + + foreach (var scope in new[] + { + "[EnvironmentVariableTarget]::Process", + "[EnvironmentVariableTarget]::User", + "[EnvironmentVariableTarget]::Machine" + }) + { + Assert.Contains(scope, preflight, StringComparison.Ordinal); + } + } + + [Fact] + public void Database_source_is_pinned_leased_and_cannot_be_overridden_by_environment() + { + Assert.Contains("approvedDatabaseConfigPath", PlanBuilder, StringComparison.Ordinal); + Assert.Contains( + "database = New-FilePin $approvedDatabaseConfigPath 'database config'", + PlanBuilder, + StringComparison.Ordinal); + Assert.Contains( + "Assert-FilePin $script:plan.configuration.database 'database config'", + Runner, + StringComparison.Ordinal); + Assert.Contains( + "Add-ReadLease ([string]$script:plan.configuration.database.path) 'database config'", + Runner, + StringComparison.Ordinal); + Assert.Contains("filePinsValidated = 12", Runner, StringComparison.Ordinal); + Assert.Contains("const databasePin = plan?.configuration?.database;", NodeHelper, + StringComparison.Ordinal); + Assert.Contains("!isSha256(databasePin?.sha256)", NodeHelper, + StringComparison.Ordinal); + + var preflight = Slice( + Runner, + "function Assert-NoUnsafeEnvironment", + "function Assert-Pgm([object] $Pgm)"); + var package = Slice( + Runner, + "function Start-Package([object] $Plan)", + "function Quote-Argument([string] $Value)"); + var child = Slice(package, " $child = @\"", "\"@\n $encodedChild"); + foreach (var prefix in new[] + { + "MBN_STOCK_ORACLE_*", "MBN_STOCK_MARIADB_*", "MBN_STOCK_DB_*" + }) + { + Assert.Contains(prefix, preflight, StringComparison.Ordinal); + Assert.True(Count(child, prefix) >= 2, $"{prefix} must be removed before and after app launch."); + } + } + + [Fact] + public void Plan_contract_requires_helper_fail_stop_and_the_current_package_pin() + { + foreach (var source in new[] { Runner, PlanBuilder }) + { + Assert.Contains(CurrentMsixSha256, source, StringComparison.Ordinal); + } + + Assert.Contains("nodeHelperFailStopRequired = $true", PlanBuilder, + StringComparison.Ordinal); + Assert.Contains("packageWrapperFailStopRequired = $true", PlanBuilder, + StringComparison.Ordinal); + Assert.Contains("'nodeHelperFailStopRequired'", Runner, StringComparison.Ordinal); + Assert.Contains("'packageWrapperFailStopRequired'", Runner, StringComparison.Ordinal); + Assert.Contains("plan?.safety?.nodeHelperFailStopRequired !== true", NodeHelper, + StringComparison.Ordinal); + Assert.Contains("plan?.safety?.packageWrapperFailStopRequired !== true", NodeHelper, + StringComparison.Ordinal); + Assert.Contains( + "Assert-JsonInt $Plan.safety.nodeHelperInvocationMaximum 1", + Runner, + StringComparison.Ordinal); + } + + private static string Slice(string source, string startMarker, string endMarker) + { + var start = RequiredIndex(source, startMarker); + var end = RequiredIndex(source, endMarker, start + startMarker.Length); + return source[start..end]; + } + + private static int RequiredIndex(string source, string marker, int startIndex = 0) + { + var index = source.IndexOf(marker, startIndex, StringComparison.Ordinal); + Assert.True(index >= 0, $"Required source marker was not found: {marker}"); + return index; + } + + private static int Count(string source, string marker) + { + var count = 0; + var index = 0; + while ((index = source.IndexOf(marker, index, StringComparison.Ordinal)) >= 0) + { + count++; + index += marker.Length; + } + + return count; + } + + private static string ReadScript(string fileName) => + File.ReadAllText(Path.Combine(RepositoryRoot, "scripts", fileName)); + + private static string FindRepositoryRoot() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current is not null) + { + if (File.Exists(Path.Combine(current.FullName, "MBN_STOCK_WEBVIEW.sln"))) + { + return current.FullName; + } + + current = current.Parent; + } + + throw new DirectoryNotFoundException("Repository root was not found."); + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dSessionTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dSessionTests.cs index 55f4503..cd50e12 100644 --- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dSessionTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dSessionTests.cs @@ -4,6 +4,7 @@ using MBN_STOCK_WEBVIEW.Playout.Configuration; using MBN_STOCK_WEBVIEW.Playout.Diagnostics; using MBN_STOCK_WEBVIEW.Playout.Interop; using MBN_STOCK_WEBVIEW.Playout.Runtime; +using MBN_STOCK_WEBVIEW.Playout.Safety; namespace MBN_STOCK_WEBVIEW.Playout.Tests; @@ -1678,6 +1679,128 @@ public sealed class DynamicK3dSessionTests Assert.Equal(1, log.Names.Count(name => name == "Release:EventHandler")); } + [Theory] + [InlineData("mutation")] + [InlineData("query")] + [InlineData("end")] + public async Task GateA_PrepareStageFailureNeverRollsBackDisconnectsOrRetries( + string failureStage) + { + const string capability = + "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF"; + using var scenes = TemporarySceneDirectory.Create("5001.t2s"); + var rawOptions = new PlayoutOptions + { + Mode = PlayoutMode.Live, + Host = "127.0.0.1", + Port = 30001, + TcpMode = 1, + LayoutIndex = 10, + SceneDirectory = scenes.Path, + TestSceneAllowlist = ["5001"], + TrustedLiveOutputEnabled = true, + ReconnectEnabled = false, + MaximumReconnectAttempts = 0, + MaximumAutomaticRefreshesPerTakeIn = 0, + LegacySceneFadeDuration = 6, + ConnectTimeoutMilliseconds = 500, + OperationTimeoutMilliseconds = 500, + DisconnectTimeoutMilliseconds = 500, + ProcessPollIntervalMilliseconds = 100 + }; + var options = ValidatedPlayoutOptions.Create(rawOptions); + var failure = new COMException($"fake Gate A {failureStage} failure"); + var log = new FakeComLog(); + var scene = new FakeScene(log, "5001"); + var player = new FakePlayer(log); + var vendorEngine = new FakeEngine(log, player, scene); + switch (failureStage) + { + case "mutation": + scene.GetObjectFailure = failure; + break; + case "query": + scene.QueryVariablesFailure = failure; + break; + case "end": + vendorEngine.EndTransactionFailure = failure; + break; + default: + throw new InvalidOperationException("Unknown test failure stage."); + } + + var activator = new FakeActivator(log, vendorEngine); + var releaser = new FakeReleaser( + log, + (scene, "5001"), + (player, "Player"), + (vendorEngine, "Engine"), + (activator.EventHandler, "EventHandler")); + var sessionFactory = new DelegateK3dSessionFactory(() => + new DynamicK3dSession( + activator, + releaser, + new InstalledK3dInteropMethodInvoker(), + new ActivatorK3dEventHandlerFactory(activator), + rollbackOnPrepareFailure: false)); + using var authorization = PlayoutLaunchAuthorization.CreateGateAForTests( + capability); + var dispatcher = new StaDispatcher(options.QueueCapacity); + var engine = new TornadoPlayoutEngine( + options, + sessionFactory, + new FakeRegistrationProbe(), + new FakeTornadoProcessProbe( + ProcessSnapshot("approved-pgm"), + ProcessSnapshot("approved-pgm")), + dispatcher, + authorization, + TimeProvider.System, + startMonitor: false, + finalConnectSafetyCheck: () => true, + abandonOnTargetMismatch: true, + beforeSdkDispatchClaim: authorization.EnsureGateASdkDispatchAuthorized, + launchAuthorization: authorization); + + try + { + Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); + Assert.True(authorization.TryClaimGateAPrepare(capability)); + var cue = new PlayoutCue( + "5001.t2s", + "5001", + [new PlayoutField("headline", "test", true)], + FadeDuration: 6, + Mutations: [new PlayoutUseBackground(false)]); + + var result = await engine.PrepareAsync(cue, CancellationToken.None); + var retry = await engine.PrepareAsync(cue, CancellationToken.None); + + Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code); + Assert.Equal(PlayoutResultCode.OutcomeUnknown, retry.Code); + } + finally + { + authorization.CompleteGateAPrepare(); + await engine.DisposeAsync(); + } + + Assert.Equal(1, log.Names.Count(name => name == "BeginTransaction")); + Assert.DoesNotContain("RollbackTransaction", log.Names); + Assert.DoesNotContain("Disconnect", log.Names); + Assert.DoesNotContain("Prepare:10", log.Names); + Assert.Equal(1, log.Names.Count(name => name == "Release:5001")); + Assert.Equal(1, log.Names.Count(name => name == "Release:Player")); + Assert.Equal(1, log.Names.Count(name => name == "Release:Engine")); + Assert.Equal(1, log.Names.Count(name => name == "Release:EventHandler")); + } + + private static TornadoProcessSnapshot ProcessSnapshot(string generation) => + new(1, 1, 0) + { + EligibleProcessGeneration = new TornadoProcessGeneration(generation) + }; + private static PlayoutOptions TestOptions(string sceneDirectory) => new() { Mode = PlayoutMode.Test, @@ -1715,6 +1838,12 @@ public sealed class DynamicK3dSessionTests name.StartsWith("Unload:", StringComparison.Ordinal) || name.StartsWith("Release:500", StringComparison.Ordinal)).ToArray(); + private sealed class DelegateK3dSessionFactory(Func create) + : IK3dSessionFactory + { + public IK3dSession Create() => create(); + } + public sealed class FakeComLog { private readonly ConcurrentQueue _names = new(); @@ -1865,6 +1994,8 @@ public sealed class DynamicK3dSessionTests public Func? SceneResolver { get; init; } + public Exception? EndTransactionFailure { get; set; } + public int KTAPConnect( int tcpMode, string host, @@ -1913,10 +2044,23 @@ public sealed class DynamicK3dSessionTests public void BeginTransaction() => _log.Add("BeginTransaction"); - public void EndTransaction() => _log.Add("EndTransaction"); + public void EndTransaction() + { + _log.Add("EndTransaction"); + if (EndTransactionFailure is not null) + { + throw EndTransactionFailure; + } + } - public void EndTransactionOnChannel(int channel) => + public void EndTransactionOnChannel(int channel) + { _log.Add($"EndTransactionOnChannel:{channel}"); + if (EndTransactionFailure is not null) + { + throw EndTransactionFailure; + } + } public void RollbackTransaction() => _log.Add("RollbackTransaction"); @@ -2026,6 +2170,11 @@ public sealed class DynamicK3dSessionTests public object GetObject(string objectName) { _log.Add($"GetObject:{objectName}"); + if (GetObjectFailure is not null) + { + throw GetObjectFailure; + } + return new FakeSceneObject(_log, objectName); } @@ -2044,6 +2193,8 @@ public sealed class DynamicK3dSessionTests set => _queryVariablesFailure = value; } + public Exception? GetObjectFailure { get; set; } + public void Unload() { _log.Add(_name is null ? "Unload" : $"Unload:{_name}"); diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutLaunchAuthorizationTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutLaunchAuthorizationTests.cs new file mode 100644 index 0000000..3d4efe0 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutLaunchAuthorizationTests.cs @@ -0,0 +1,747 @@ +using System.Security.Cryptography; +using MBN_STOCK_WEBVIEW.Core.Playout; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; +using MBN_STOCK_WEBVIEW.Playout.Safety; + +namespace MBN_STOCK_WEBVIEW.Playout.Tests; + +public sealed class PlayoutLaunchAuthorizationTests +{ + private const string Capability = + "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF"; + + [Fact] + public void Missing_gate_environment_preserves_the_standard_launch_profile() + { + using var environment = ClearGateEnvironment(); + + var authorization = PlayoutLaunchAuthorization.CaptureFromEnvironment(); + + Assert.False(authorization.IsGateA); + Assert.True(authorization.AllowsDatabaseWrites); + Assert.True(authorization.AllowsCompositionMutation); + Assert.False(authorization.TryClaimGateAPrepare(Capability)); + } + + [Fact] + public void Non_gate_capture_preserves_existing_live_authorization_semantics() + { + using var environment = ClearGateEnvironment() + .Set( + PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable, + PlayoutOptionsLoader.RequiredLiveAuthorizationValue); + + using var authorization = PlayoutLaunchAuthorization.CaptureFromEnvironment(); + + Assert.False(authorization.IsGateA); + Assert.True(authorization.IsAuthorizedForThisLaunch); + Assert.Equal( + PlayoutOptionsLoader.RequiredLiveAuthorizationValue, + Environment.GetEnvironmentVariable( + PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable)); + } + + [Fact] + public void Gate_capture_consumes_static_live_bearer_and_second_capture_fails_closed() + { + using var scenes = TemporarySceneDirectory.Create("5001.t2s"); + var scenePath = Path.Combine(scenes.Path, "5001.t2s"); + var clock = new ManualGateTimeProvider(); + var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(5).Ticks; + using var environment = GateEnvironment( + Capability, + "41308", + "638878000000000000", + expiry.ToString(System.Globalization.CultureInfo.InvariantCulture)) + .Set( + PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable, + PlayoutOptionsLoader.RequiredLiveAuthorizationValue); + + using var first = PlayoutLaunchAuthorization.CaptureFromEnvironment( + clock, + scenePath, + SceneHash(scenePath)); + PlayoutLaunchAuthorization? second = null; + Assert.Throws(() => + second = PlayoutLaunchAuthorization.CaptureFromEnvironment()); + + Assert.True(first.IsGateA); + Assert.True(first.IsAuthorizedForThisLaunch); + Assert.Null(Environment.GetEnvironmentVariable( + PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable)); + Assert.Null(second); + Assert.Null(Environment.GetEnvironmentVariable( + PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable)); + Assert.Null(Environment.GetEnvironmentVariable( + PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable)); + Assert.Null(Environment.GetEnvironmentVariable( + PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable)); + Assert.Null(Environment.GetEnvironmentVariable( + PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable)); + } + + [Fact] + public void Gate_environment_is_captured_once_and_cleared_before_browser_startup() + { + using var scenes = TemporarySceneDirectory.Create("5001.t2s"); + var scenePath = Path.Combine(scenes.Path, "5001.t2s"); + var clock = new ManualGateTimeProvider(); + var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(5).Ticks; + using var environment = GateEnvironment( + Capability, + "41308", + "638878000000000000", + expiry.ToString(System.Globalization.CultureInfo.InvariantCulture)); + + using var authorization = PlayoutLaunchAuthorization.CaptureFromEnvironment( + clock, + scenePath, + SceneHash(scenePath)); + + Assert.True(authorization.IsGateA); + Assert.Equal(41308, authorization.ExpectedPgmProcessId); + Assert.Equal(638878000000000000, authorization.ExpectedPgmStartTimeUtcTicks); + Assert.Equal(expiry, authorization.ExpiresAtUtcTicks); + Assert.False(authorization.AllowsDatabaseWrites); + Assert.False(authorization.AllowsCompositionMutation); + Assert.Null(Environment.GetEnvironmentVariable( + PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable)); + Assert.Null(Environment.GetEnvironmentVariable( + PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable)); + Assert.Null(Environment.GetEnvironmentVariable( + PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable)); + Assert.Null(Environment.GetEnvironmentVariable( + PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable)); + + environment.Set( + PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable, + new string('F', 64)); + + Assert.True(authorization.TryClaimGateAPrepare(Capability)); + Assert.False(authorization.TryClaimGateAPrepare(new string('F', 64))); + } + + [Theory] + [InlineData("lowercase0123456789abcdef0123456789abcdef0123456789abcdef0123456789ab")] + [InlineData("ABCDEF")] + [InlineData(null)] + public void Invalid_or_partial_gate_environment_fails_closed_and_is_cleared( + string? capability) + { + using var environment = ClearGateEnvironment() + .Set( + PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable, + capability) + .Set( + PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable, + "41308"); + + Assert.Throws( + PlayoutLaunchAuthorization.CaptureFromEnvironment); + Assert.Null(Environment.GetEnvironmentVariable( + PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable)); + Assert.Null(Environment.GetEnvironmentVariable( + PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable)); + Assert.Null(Environment.GetEnvironmentVariable( + PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable)); + Assert.Null(Environment.GetEnvironmentVariable( + PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable)); + } + + [Fact] + public void Malformed_first_capture_permanently_blocks_reinjection_and_clears_every_bearer() + { + using var scenes = TemporarySceneDirectory.Create("5001.t2s"); + var scenePath = Path.Combine(scenes.Path, "5001.t2s"); + var clock = new ManualGateTimeProvider(); + var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(5).Ticks; + using var environment = ClearGateEnvironment() + .Set( + PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable, + "ABCDEF") + .Set( + PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable, + "41308") + .Set( + PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable, + PlayoutOptionsLoader.RequiredLiveAuthorizationValue); + + Assert.Throws(() => + PlayoutLaunchAuthorization.CaptureFromEnvironment( + clock, + scenePath, + SceneHash(scenePath))); + + environment + .Set( + PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable, + Capability) + .Set( + PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable, + "41308") + .Set( + PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable, + "638878000000000000") + .Set( + PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable, + expiry.ToString(System.Globalization.CultureInfo.InvariantCulture)) + .Set( + PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable, + PlayoutOptionsLoader.RequiredLiveAuthorizationValue); + + PlayoutLaunchAuthorization? second = null; + Assert.Throws(() => + second = PlayoutLaunchAuthorization.CaptureFromEnvironment( + clock, + scenePath, + SceneHash(scenePath))); + + Assert.Null(second); + Assert.Null(Environment.GetEnvironmentVariable( + PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable)); + Assert.Null(Environment.GetEnvironmentVariable( + PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable)); + Assert.Null(Environment.GetEnvironmentVariable( + PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable)); + Assert.Null(Environment.GetEnvironmentVariable( + PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable)); + Assert.Null(Environment.GetEnvironmentVariable( + PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable)); + } + + [Fact] + public async Task Concurrent_gate_captures_have_one_success_and_all_later_calls_fail_closed() + { + using var scenes = TemporarySceneDirectory.Create("5001.t2s"); + var scenePath = Path.Combine(scenes.Path, "5001.t2s"); + var clock = new ManualGateTimeProvider(); + var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(5).Ticks; + using var environment = GateEnvironment( + Capability, + "41308", + "638878000000000000", + expiry.ToString(System.Globalization.CultureInfo.InvariantCulture)) + .Set( + PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable, + PlayoutOptionsLoader.RequiredLiveAuthorizationValue); + using var start = new ManualResetEventSlim(); + var tasks = Enumerable.Range(0, 8) + .Select(_ => Task.Run(() => + { + start.Wait(); + try + { + return ( + Authorization: PlayoutLaunchAuthorization.CaptureFromEnvironment( + clock, + scenePath, + SceneHash(scenePath)), + Error: (Exception?)null); + } + catch (Exception exception) + { + return (Authorization: (PlayoutLaunchAuthorization?)null, Error: exception); + } + })) + .ToArray(); + + start.Set(); + var results = await Task.WhenAll(tasks); + + var successful = Assert.Single(results, static result => result.Authorization is not null); + Assert.True(successful.Authorization!.IsGateA); + foreach (var failed in results.Where(static result => result.Authorization is null)) + { + Assert.IsType(failed.Error); + } + + successful.Authorization.Dispose(); + Assert.Null(Environment.GetEnvironmentVariable( + PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable)); + } + + [Theory] + [InlineData(0L)] + [InlineData(-1L)] + [InlineData(9000000001L)] + public void Expired_or_overlong_gate_environment_fails_closed_and_clears_every_value( + long expiryOffsetTicks) + { + using var scenes = TemporarySceneDirectory.Create("5001.t2s"); + var scenePath = Path.Combine(scenes.Path, "5001.t2s"); + var clock = new ManualGateTimeProvider(); + var expiry = clock.GetUtcNow().UtcTicks + expiryOffsetTicks; + using var environment = GateEnvironment( + Capability, + "41308", + "638878000000000000", + expiry.ToString(System.Globalization.CultureInfo.InvariantCulture)); + + Assert.Throws(() => + PlayoutLaunchAuthorization.CaptureFromEnvironment( + clock, + scenePath, + SceneHash(scenePath))); + + Assert.Null(Environment.GetEnvironmentVariable( + PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable)); + Assert.Null(Environment.GetEnvironmentVariable( + PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable)); + Assert.Null(Environment.GetEnvironmentVariable( + PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable)); + Assert.Null(Environment.GetEnvironmentVariable( + PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable)); + } + + [Fact] + public void Gate_scene_wrong_hash_fails_closed_and_does_not_leave_a_lease() + { + using var scenes = TemporarySceneDirectory.Create("5001.t2s"); + var scenePath = Path.Combine(scenes.Path, "5001.t2s"); + var clock = new ManualGateTimeProvider(); + var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(5).Ticks; + using var environment = GateEnvironment( + Capability, + "41308", + "638878000000000000", + expiry.ToString(System.Globalization.CultureInfo.InvariantCulture)); + + Assert.Throws(() => + PlayoutLaunchAuthorization.CaptureFromEnvironment( + clock, + scenePath, + new string('A', 64))); + + using var write = File.Open( + scenePath, + FileMode.Open, + FileAccess.Write, + FileShare.ReadWrite | FileShare.Delete); + Assert.True(write.CanWrite); + } + + [Fact] + public void Gate_scene_native_read_lease_blocks_write_and_delete_until_native_shutdown() + { + using var scenes = TemporarySceneDirectory.Create("5001.t2s"); + var scenePath = Path.Combine(scenes.Path, "5001.t2s"); + var clock = new ManualGateTimeProvider(); + var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(5).Ticks; + using var environment = GateEnvironment( + Capability, + "41308", + "638878000000000000", + expiry.ToString(System.Globalization.CultureInfo.InvariantCulture)); + var authorization = PlayoutLaunchAuthorization.CaptureFromEnvironment( + clock, + scenePath, + SceneHash(scenePath)); + + using (var secondReader = File.Open( + scenePath, + FileMode.Open, + FileAccess.Read, + FileShare.Read)) + { + Assert.True(secondReader.CanRead); + } + + Assert.ThrowsAny(() => File.Open( + scenePath, + FileMode.Open, + FileAccess.Write, + FileShare.ReadWrite | FileShare.Delete)); + Assert.ThrowsAny(() => File.Delete(scenePath)); + + authorization.CompleteGateAPrepare(); + Assert.ThrowsAny(() => File.Delete(scenePath)); + + authorization.Dispose(); + File.Delete(scenePath); + Assert.False(File.Exists(scenePath)); + } + + [Fact] + public void Native_expiry_at_operator_claim_is_terminal_and_cannot_be_rearmed() + { + var clock = new ManualGateTimeProvider(); + var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(1).Ticks; + using var authorization = PlayoutLaunchAuthorization.CreateGateAForTests( + Capability, + timeProvider: clock, + expiresAtUtcTicks: expiry); + + clock.Advance(TimeSpan.FromMinutes(1)); + + Assert.False(authorization.TryClaimGateAPrepare(Capability)); + clock.SetUtcNow(clock.GetUtcNow().AddHours(-1)); + Assert.False(authorization.TryClaimGateAPrepare(Capability)); + Assert.False(authorization.TryClaimEngineCommand( + PlayoutOperation.Connect, + cue: null)); + } + + [Fact] + public void Expiry_between_operator_claim_and_engine_prepare_blocks_vendor_boundary() + { + var clock = new ManualGateTimeProvider(); + var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(1).Ticks; + using var authorization = PlayoutLaunchAuthorization.CreateGateAForTests( + Capability, + timeProvider: clock, + expiresAtUtcTicks: expiry); + + clock.Advance(TimeSpan.FromMinutes(1) - TimeSpan.FromTicks(1)); + Assert.True(authorization.TryClaimGateAPrepare(Capability)); + clock.Advance(TimeSpan.FromTicks(1)); + + Assert.False(authorization.TryClaimEngineCommand( + PlayoutOperation.Prepare, + GateACue())); + Assert.False(authorization.TryClaimEngineCommand( + PlayoutOperation.Prepare, + GateACue())); + } + + [Fact] + public void Connect_just_before_expiry_does_not_extend_later_prepare_authority() + { + var clock = new ManualGateTimeProvider(); + var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(1).Ticks; + using var authorization = PlayoutLaunchAuthorization.CreateGateAForTests( + Capability, + timeProvider: clock, + expiresAtUtcTicks: expiry); + + clock.Advance(TimeSpan.FromMinutes(1) - TimeSpan.FromTicks(1)); + Assert.True(authorization.TryClaimEngineCommand( + PlayoutOperation.Connect, + cue: null)); + clock.Advance(TimeSpan.FromTicks(1)); + + Assert.False(authorization.TryClaimGateAPrepare(Capability)); + Assert.False(authorization.TryClaimEngineCommand( + PlayoutOperation.Prepare, + GateACue())); + } + + [Fact] + public void Connect_claim_uses_monotonic_deadline_so_utc_rollback_cannot_extend_gate() + { + var clock = new ManualGateTimeProvider(); + var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(1).Ticks; + using var authorization = PlayoutLaunchAuthorization.CreateGateAForTests( + Capability, + timeProvider: clock, + expiresAtUtcTicks: expiry); + + clock.SetUtcNow(clock.GetUtcNow().AddHours(-1)); + clock.AdvanceMonotonic(TimeSpan.FromMinutes(1)); + + Assert.False(authorization.TryClaimEngineCommand( + PlayoutOperation.Connect, + cue: null)); + Assert.False(authorization.TryClaimGateAPrepare(Capability)); + } + + [Fact] + public void Capability_and_engine_prepare_are_each_one_shot_and_never_rearm() + { + var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(Capability); + var cue = GateACue(); + + Assert.False(authorization.TryClaimEngineCommand(PlayoutOperation.Prepare, cue)); + Assert.False(authorization.TryClaimGateAPrepare(new string('F', 64))); + Assert.True(authorization.TryClaimGateAPrepare(Capability)); + Assert.False(authorization.TryClaimGateAPrepare(Capability)); + Assert.True(authorization.TryClaimEngineCommand(PlayoutOperation.Prepare, cue)); + Assert.False(authorization.TryClaimEngineCommand(PlayoutOperation.Prepare, cue)); + + authorization.CompleteGateAPrepare(); + + Assert.False(authorization.TryClaimGateAPrepare(Capability)); + Assert.False(authorization.TryClaimEngineCommand(PlayoutOperation.Prepare, cue)); + } + + [Fact] + public void Wrong_prepare_target_consumes_the_engine_phase_and_blocks_correction() + { + var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(Capability); + Assert.True(authorization.TryClaimGateAPrepare(Capability)); + + Assert.False(authorization.TryClaimEngineCommand( + PlayoutOperation.Prepare, + GateACue() with { SceneName = "5006", SceneFile = "5006.t2s" })); + Assert.False(authorization.TryClaimEngineCommand( + PlayoutOperation.Prepare, + GateACue())); + } + + [Fact] + public void GateA_rejects_operator_fade_and_background_changes_before_engine_dispatch() + { + var blockedCues = new[] + { + GateACue() with { FadeDuration = 5 }, + GateACue() with { Mutations = [] }, + GateACue() with + { + Mutations = [new PlayoutUseBackground(true)] + }, + GateACue() with + { + Mutations = + [ + new PlayoutUseBackground(false), + new PlayoutUseBackground(false) + ] + }, + GateACue() with + { + Mutations = [new PlayoutSetBackgroundTexture("background.png")] + }, + GateACue() with + { + Mutations = [new PlayoutSetBackgroundVideo("background.mp4", 0, 1)] + } + }; + + foreach (var cue in blockedCues) + { + var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(Capability); + Assert.True(authorization.TryClaimGateAPrepare(Capability)); + Assert.False(authorization.TryClaimEngineCommand( + PlayoutOperation.Prepare, + cue)); + Assert.False(authorization.TryClaimEngineCommand( + PlayoutOperation.Prepare, + GateACue())); + } + } + + [Fact] + public async Task Real_5001_provider_cue_with_exact_disabled_background_claims_native_prepare() + { + var provider = new RegisteredLegacySceneCueProvider( + new SinglePageDataSource(new LegacySceneDataPage( + "s5001", + new S5001DomesticStockSceneData( + LegacyDomesticEquityMarket.Kospi, + S5001DomesticStockMode.Current, + S5001NxtBadge.None, + "Samsung Electronics", + ScenePriceDirection.Up, + 70_000, + 500, + 0.72m), + 1))); + var page = await provider.CreatePageAsync( + new LegacyPlaylistEntry("gate-a-5001", "5001", FadeDuration: 6), + pageIndexZeroBased: 0); + using var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(Capability); + + Assert.Equal(new PlayoutUseBackground(false), page.Cue.Mutations![0]); + Assert.True(authorization.TryClaimGateAPrepare(Capability)); + Assert.True(authorization.TryClaimEngineCommand( + PlayoutOperation.Prepare, + page.Cue)); + } + + [Fact] + public async Task Concurrent_operator_claims_have_exactly_one_winner() + { + var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(Capability); + using var start = new ManualResetEventSlim(); + var tasks = Enumerable.Range(0, 16) + .Select(_ => Task.Run(() => + { + start.Wait(); + return authorization.TryClaimGateAPrepare(Capability); + })) + .ToArray(); + + start.Set(); + var results = await Task.WhenAll(tasks); + + Assert.Single(results, static result => result); + } + + [Fact] + public void GateA_options_require_the_exact_closed_profile() + { + var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(Capability); + var valid = GateAOptions(); + + PlayoutEngineFactory.ValidateGateAOptions(valid, authorization); + + foreach (var invalid in new[] + { + GateAOptions().WithMode(PlayoutMode.DryRun), + GateAOptions().WithAllowlist("5001", "5006"), + GateAOptions().WithReconnect(true, 0), + GateAOptions().WithReconnect(false, 1), + GateAOptions().WithRefresh(null), + GateAOptions().WithFade(5), + GateAOptions().WithBackground(LegacySceneBackgroundKind.Texture), + GateAOptions().WithSceneDirectory(Path.GetTempPath()), + GateAOptions().WithClientPort(1), + GateAOptions().WithOutputChannel(0) + }) + { + Assert.Throws(() => + PlayoutEngineFactory.ValidateGateAOptions(invalid, authorization)); + } + } + + private static PlayoutCue GateACue() => new( + "5001.t2s", + "5001", + FadeDuration: 6, + Mutations: [new PlayoutUseBackground(false)]); + + private static PlayoutOptions GateAOptions() => new() + { + Mode = PlayoutMode.Live, + Host = "127.0.0.1", + Port = 30001, + TcpMode = 1, + LayoutIndex = 10, + TestSceneAllowlist = ["5001"], + TrustedLiveOutputEnabled = true, + ReconnectEnabled = false, + MaximumReconnectAttempts = 0, + MaximumAutomaticRefreshesPerTakeIn = 0, + LegacySceneFadeDuration = 6, + LegacySceneBackgroundKind = LegacySceneBackgroundKind.None, + SceneDirectory = PlayoutLaunchAuthorization.GateAApprovedSceneDirectory + }; + + private static string SceneHash(string path) => + Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))); + + private sealed class SinglePageDataSource(LegacySceneDataPage page) + : ILegacySceneDataSource + { + public Task LoadPageAsync( + LegacyPlaylistEntry entry, + int pageIndexZeroBased, + CancellationToken cancellationToken = default) => + Task.FromResult(page); + } + + private static EnvironmentScope ClearGateEnvironment() + { + PlayoutLaunchAuthorization.ResetGateACaptureLatchForTests(); + return new EnvironmentScope() + .Set(PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable, null) + .Set(PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable, null) + .Set(PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable, null) + .Set(PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable, null) + .Set(PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable, null); + } + + private static EnvironmentScope GateEnvironment( + string capability, + string processId, + string startTimeUtcTicks, + string expiresAtUtcTicks) => + ClearGateEnvironment() + .Set(PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable, capability) + .Set(PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable, processId) + .Set(PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable, startTimeUtcTicks) + .Set(PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable, expiresAtUtcTicks); +} + +internal sealed class ManualGateTimeProvider : TimeProvider +{ + private DateTimeOffset _utcNow = new(2026, 7, 18, 0, 0, 0, TimeSpan.Zero); + private long _timestamp; + + public override long TimestampFrequency => TimeSpan.TicksPerSecond; + + public override DateTimeOffset GetUtcNow() => _utcNow; + + public override long GetTimestamp() => _timestamp; + + public void Advance(TimeSpan duration) + { + _utcNow = _utcNow.Add(duration); + _timestamp += duration.Ticks; + } + + public void AdvanceMonotonic(TimeSpan duration) => + _timestamp += duration.Ticks; + + public void SetUtcNow(DateTimeOffset value) => _utcNow = value; +} + +internal static class GateAOptionTestExtensions +{ + public static PlayoutOptions WithMode(this PlayoutOptions options, PlayoutMode mode) + { + options.Mode = mode; + return options; + } + + public static PlayoutOptions WithAllowlist( + this PlayoutOptions options, + params string[] scenes) + { + options.TestSceneAllowlist = [.. scenes]; + return options; + } + + public static PlayoutOptions WithReconnect( + this PlayoutOptions options, + bool enabled, + int attempts) + { + options.ReconnectEnabled = enabled; + options.MaximumReconnectAttempts = attempts; + return options; + } + + public static PlayoutOptions WithRefresh(this PlayoutOptions options, int? maximum) + { + options.MaximumAutomaticRefreshesPerTakeIn = maximum; + return options; + } + + public static PlayoutOptions WithFade(this PlayoutOptions options, int fade) + { + options.LegacySceneFadeDuration = fade; + return options; + } + + public static PlayoutOptions WithBackground( + this PlayoutOptions options, + LegacySceneBackgroundKind kind) + { + options.LegacySceneBackgroundKind = kind; + return options; + } + + public static PlayoutOptions WithSceneDirectory( + this PlayoutOptions options, + string sceneDirectory) + { + options.SceneDirectory = sceneDirectory; + return options; + } + + public static PlayoutOptions WithClientPort( + this PlayoutOptions options, + int clientPort) + { + options.ClientPort = clientPort; + return options; + } + + public static PlayoutOptions WithOutputChannel( + this PlayoutOptions options, + int outputChannel) + { + options.OutputChannel = outputChannel; + return options; + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/TornadoPlayoutEngineTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/TornadoPlayoutEngineTests.cs index 9986008..da45f98 100644 --- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/TornadoPlayoutEngineTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/TornadoPlayoutEngineTests.cs @@ -3,11 +3,15 @@ using System.Runtime.InteropServices; using MBN_STOCK_WEBVIEW.Playout.Configuration; using MBN_STOCK_WEBVIEW.Playout.Interop; using MBN_STOCK_WEBVIEW.Playout.Runtime; +using MBN_STOCK_WEBVIEW.Playout.Safety; namespace MBN_STOCK_WEBVIEW.Playout.Tests; public sealed class TornadoPlayoutEngineTests { + private const string GateACapability = + "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF"; + [Theory] [InlineData(0, false)] [InlineData(1, true)] @@ -2568,6 +2572,339 @@ public sealed class TornadoPlayoutEngineTests Assert.Contains("Disconnect", session.Calls); } + [Fact] + public async Task GateA_AllowsExactlyOneConnectAndClaimed5001Prepare_ThenAbandonsWithoutBye() + { + using var scenes = TemporarySceneDirectory.Create("5001.t2s"); + var session = new FakeK3dSession(); + var authorization = PlayoutLaunchAuthorization.CreateGateAForTests( + GateACapability); + var engine = CreateEngine( + GateAOptions(scenes.Path), + new FakeK3dSessionFactory(() => session), + new FakeRegistrationProbe(), + new FakeTornadoProcessProbe(ProcessSnapshot("approved-pgm")), + liveAuthorized: true, + launchAuthorization: authorization); + + try + { + var cue = GateACue(); + + Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); + Assert.Equal( + PlayoutResultCode.Rejected, + (await engine.ConnectAsync(CancellationToken.None)).Code); + Assert.Equal( + PlayoutResultCode.Rejected, + (await engine.PrepareAsync(cue, CancellationToken.None)).Code); + Assert.Equal( + PlayoutResultCode.Rejected, + (await engine.TakeInAsync(CancellationToken.None)).Code); + Assert.Equal( + PlayoutResultCode.Rejected, + (await engine.NextAsync(cue, CancellationToken.None)).Code); + Assert.Equal( + PlayoutResultCode.Rejected, + (await engine.UpdateOnAirAsync(cue, CancellationToken.None)).Code); + Assert.Equal( + PlayoutResultCode.Rejected, + (await engine.TakeOutAsync( + PlayoutTakeOutScope.All, + CancellationToken.None)).Code); + Assert.Equal( + PlayoutResultCode.Rejected, + (await engine.DisconnectAsync(CancellationToken.None)).Code); + Assert.False(engine.Status.LiveTakeInAllowed); + + Assert.True(authorization.TryClaimGateAPrepare(GateACapability)); + Assert.True((await engine.PrepareAsync(cue, CancellationToken.None)).IsSuccess); + Assert.Equal( + PlayoutResultCode.Rejected, + (await engine.PrepareAsync(cue, CancellationToken.None)).Code); + } + finally + { + authorization.CompleteGateAPrepare(); + await engine.DisposeAsync(); + } + + Assert.Equal(new[] { "Connect", "Prepare:5001", "Abandon" }, session.Calls); + Assert.Equal(1, session.KtapDispatchCount); + Assert.DoesNotContain("Disconnect", session.Calls); + Assert.DoesNotContain("Dispose", session.Calls); + } + + [Fact] + public async Task GateA_WrongPrepareTargetPermanentlyConsumesPrepareWithoutSdkMutation() + { + using var scenes = TemporarySceneDirectory.Create("5001.t2s"); + var session = new FakeK3dSession(); + var authorization = PlayoutLaunchAuthorization.CreateGateAForTests( + GateACapability); + var engine = CreateEngine( + GateAOptions(scenes.Path), + new FakeK3dSessionFactory(() => session), + new FakeRegistrationProbe(), + new FakeTornadoProcessProbe(ProcessSnapshot("approved-pgm")), + liveAuthorized: true, + launchAuthorization: authorization); + + try + { + Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); + Assert.True(authorization.TryClaimGateAPrepare(GateACapability)); + + var wrong = await engine.PrepareAsync( + new PlayoutCue("5006.t2s", "5006", FadeDuration: 6), + CancellationToken.None); + var correction = await engine.PrepareAsync( + GateACue(), + CancellationToken.None); + + Assert.Equal(PlayoutResultCode.Rejected, wrong.Code); + Assert.Equal(PlayoutResultCode.Rejected, correction.Code); + } + finally + { + authorization.CompleteGateAPrepare(); + await engine.DisposeAsync(); + } + + Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls); + Assert.DoesNotContain( + session.Calls, + static call => call.StartsWith("Prepare:", StringComparison.Ordinal)); + Assert.DoesNotContain("Disconnect", session.Calls); + } + + [Fact] + public async Task GateA_PgmGenerationChangeAbandonsWithoutReconnectOrBye() + { + using var scenes = TemporarySceneDirectory.Create("5001.t2s"); + var sessionFactory = new FakeK3dSessionFactory(); + var authorization = PlayoutLaunchAuthorization.CreateGateAForTests( + GateACapability); + var options = GateAOptions(scenes.Path); + options.ReconnectEnabled = true; + options.MaximumReconnectAttempts = 3; + var engine = CreateEngine( + options, + sessionFactory, + new FakeRegistrationProbe(), + new FakeTornadoProcessProbe( + ProcessSnapshot("approved-pgm"), + ProcessSnapshot("approved-pgm"), + ProcessSnapshot("replacement-pgm")), + liveAuthorized: true, + launchAuthorization: authorization); + + try + { + Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); + + await engine.PollProcessOnceAsync(CancellationToken.None); + + Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State); + Assert.Equal(1, sessionFactory.CreateCount); + var session = Assert.Single(sessionFactory.Sessions); + Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls); + Assert.DoesNotContain("Disconnect", session.Calls); + } + finally + { + authorization.CompleteGateAPrepare(); + await engine.DisposeAsync(); + } + + Assert.Equal(1, sessionFactory.CreateCount); + } + + [Fact] + public async Task GateA_ExpiredConnectIsRejectedBeforeProcessRegistrationOrVendorBoundary() + { + using var scenes = TemporarySceneDirectory.Create("5001.t2s"); + var clock = new ManualGateTimeProvider(); + var authorization = PlayoutLaunchAuthorization.CreateGateAForTests( + GateACapability, + timeProvider: clock, + expiresAtUtcTicks: + clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(1).Ticks); + clock.Advance(TimeSpan.FromMinutes(1)); + var sessionFactory = new FakeK3dSessionFactory(); + var registration = new FakeRegistrationProbe(); + var process = new FakeTornadoProcessProbe(ProcessSnapshot("approved-pgm")); + var engine = CreateEngine( + GateAOptions(scenes.Path), + sessionFactory, + registration, + process, + liveAuthorized: true, + launchAuthorization: authorization); + + try + { + var result = await engine.ConnectAsync(CancellationToken.None); + + Assert.Equal(PlayoutResultCode.Rejected, result.Code); + Assert.Equal(0, process.CaptureCount); + Assert.Equal(0, registration.ProbeCount); + Assert.Equal(0, sessionFactory.CreateCount); + } + finally + { + await engine.DisposeAsync(); + authorization.Dispose(); + } + } + + [Fact] + public async Task GateA_PrepareFailureBecomesOutcomeUnknownAndCannotRetryOrDisconnect() + { + using var scenes = TemporarySceneDirectory.Create("5001.t2s"); + var session = new FakeK3dSession + { + PrepareAction = (_, _) => throw new COMException( + "fake ambiguous Gate A prepare failure") + }; + var authorization = PlayoutLaunchAuthorization.CreateGateAForTests( + GateACapability); + var engine = CreateEngine( + GateAOptions(scenes.Path), + new FakeK3dSessionFactory(() => session), + new FakeRegistrationProbe(), + new FakeTornadoProcessProbe(ProcessSnapshot("approved-pgm")), + liveAuthorized: true, + abandonOnTargetMismatch: true, + launchAuthorization: authorization); + + try + { + Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); + Assert.True(authorization.TryClaimGateAPrepare(GateACapability)); + + var failed = await engine.PrepareAsync(GateACue(), CancellationToken.None); + var retry = await engine.PrepareAsync(GateACue(), CancellationToken.None); + + Assert.Equal(PlayoutResultCode.OutcomeUnknown, failed.Code); + Assert.Equal(PlayoutResultCode.OutcomeUnknown, retry.Code); + } + finally + { + authorization.CompleteGateAPrepare(); + await engine.DisposeAsync(); + authorization.Dispose(); + } + + Assert.Equal(new[] { "Connect", "Prepare:5001", "Abandon" }, session.Calls); + Assert.Equal(1, session.Calls.Count(call => call == "Prepare:5001")); + Assert.DoesNotContain("Disconnect", session.Calls); + Assert.DoesNotContain("Dispose", session.Calls); + } + + [Fact] + public async Task GateA_ConnectExpiryAtSdkBoundaryPreventsKtapDispatch() + { + using var scenes = TemporarySceneDirectory.Create("5001.t2s"); + var clock = new ManualGateTimeProvider(); + var authorization = PlayoutLaunchAuthorization.CreateGateAForTests( + GateACapability, + timeProvider: clock, + expiresAtUtcTicks: + clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(1).Ticks); + var session = new FakeK3dSession + { + BeforeKtapDispatchAction = () => clock.Advance(TimeSpan.FromMinutes(1)) + }; + var engine = CreateEngine( + GateAOptions(scenes.Path), + new FakeK3dSessionFactory(() => session), + new FakeRegistrationProbe(), + new FakeTornadoProcessProbe(ProcessSnapshot("approved-pgm")), + liveAuthorized: true, + finalConnectSafetyCheck: () => true, + abandonOnTargetMismatch: true, + beforeSdkDispatchClaim: authorization.EnsureGateASdkDispatchAuthorized, + launchAuthorization: authorization); + + try + { + var result = await engine.ConnectAsync(CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal(0, session.KtapDispatchCount); + } + finally + { + await engine.DisposeAsync(); + authorization.Dispose(); + } + + Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls); + Assert.DoesNotContain("Disconnect", session.Calls); + Assert.DoesNotContain("Dispose", session.Calls); + } + + [Fact] + public async Task GateA_PrepareExpiryAtSdkBoundaryPreventsPrepareDispatchAndQuarantines() + { + using var scenes = TemporarySceneDirectory.Create("5001.t2s"); + var clock = new ManualGateTimeProvider(); + var authorization = PlayoutLaunchAuthorization.CreateGateAForTests( + GateACapability, + timeProvider: clock, + expiresAtUtcTicks: + clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(1).Ticks); + var dispatchBoundaryCount = 0; + void DelayedDispatchGuard() + { + if (Interlocked.Increment(ref dispatchBoundaryCount) == 2) + { + clock.Advance(TimeSpan.FromMinutes(1)); + } + + authorization.EnsureGateASdkDispatchAuthorized(); + } + + var session = new FakeK3dSession(); + var engine = CreateEngine( + GateAOptions(scenes.Path), + new FakeK3dSessionFactory(() => session), + new FakeRegistrationProbe(), + new FakeTornadoProcessProbe(ProcessSnapshot("approved-pgm")), + liveAuthorized: true, + finalConnectSafetyCheck: () => true, + abandonOnTargetMismatch: true, + beforeSdkDispatchClaim: DelayedDispatchGuard, + launchAuthorization: authorization); + + try + { + Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); + Assert.True(authorization.TryClaimGateAPrepare(GateACapability)); + + var result = await engine.PrepareAsync(GateACue(), CancellationToken.None); + var retry = await engine.PrepareAsync(GateACue(), CancellationToken.None); + + Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code); + Assert.Equal(PlayoutResultCode.OutcomeUnknown, retry.Code); + Assert.Equal(2, dispatchBoundaryCount); + } + finally + { + authorization.CompleteGateAPrepare(); + await engine.DisposeAsync(); + authorization.Dispose(); + } + + Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls); + Assert.DoesNotContain( + session.Calls, + static call => call.StartsWith("Prepare:", StringComparison.Ordinal)); + Assert.DoesNotContain("Disconnect", session.Calls); + Assert.DoesNotContain("Dispose", session.Calls); + } + private static TornadoPlayoutEngine CreateEngine( PlayoutOptions rawOptions, FakeK3dSessionFactory sessionFactory, @@ -2577,7 +2914,8 @@ public sealed class TornadoPlayoutEngineTests Func? finalConnectSafetyCheck = null, bool abandonOnTargetMismatch = false, Action? beforeSdkDispatchClaim = null, - FakeLiveAuthorization? liveAuthorization = null) + FakeLiveAuthorization? liveAuthorization = null, + PlayoutLaunchAuthorization? launchAuthorization = null) { var options = ValidatedPlayoutOptions.Create(rawOptions); IStaDispatcher? dispatcher = options.Mode is PlayoutMode.Test or PlayoutMode.Live @@ -2594,7 +2932,8 @@ public sealed class TornadoPlayoutEngineTests startMonitor: false, finalConnectSafetyCheck: finalConnectSafetyCheck, abandonOnTargetMismatch: abandonOnTargetMismatch, - beforeSdkDispatchClaim: beforeSdkDispatchClaim); + beforeSdkDispatchClaim: beforeSdkDispatchClaim, + launchAuthorization: launchAuthorization); } private static PlayoutOptions TestOptions( @@ -2630,6 +2969,33 @@ public sealed class TornadoPlayoutEngineTests MaximumReconnectAttempts = 3 }; + private static PlayoutOptions GateAOptions(string sceneDirectory) => new() + { + Mode = PlayoutMode.Live, + Host = "127.0.0.1", + Port = 30001, + TcpMode = 1, + LayoutIndex = 10, + SceneDirectory = sceneDirectory, + TestSceneAllowlist = ["5001"], + TrustedLiveOutputEnabled = true, + ReconnectEnabled = false, + MaximumReconnectAttempts = 0, + MaximumAutomaticRefreshesPerTakeIn = 0, + LegacySceneFadeDuration = 6, + ConnectTimeoutMilliseconds = 500, + OperationTimeoutMilliseconds = 500, + DisconnectTimeoutMilliseconds = 500, + ProcessPollIntervalMilliseconds = 100, + ReconnectDelayMilliseconds = 0 + }; + + private static PlayoutCue GateACue() => new( + "5001.t2s", + "5001", + FadeDuration: 6, + Mutations: [new PlayoutUseBackground(false)]); + private static PlayoutCue Cue(string sceneName) => new( $"{sceneName}.t2s", sceneName,