feat: verify Tornado PGM cut sequence

This commit is contained in:
2026-07-10 16:05:06 +09:00
parent 930424b752
commit d12a5d8095
15 changed files with 4344 additions and 74 deletions

View File

@@ -0,0 +1,846 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$SmokeDll,
[Parameter(Mandatory = $true)]
[string]$SceneRoot,
[Parameter(Mandatory = $true)]
[ValidatePattern('^[0-9A-Fa-f]{64}$')]
[string]$ApprovedNativeSha256,
[Parameter(Mandatory = $true)]
[ValidatePattern('^[0-9A-Fa-f]{64}$')]
[string]$ApprovedInteropSha256,
[Parameter(Mandatory = $true)]
[ValidatePattern('^[0-9A-Fa-f]{64}$')]
[string]$ApprovedSmokeSha256,
[Parameter(Mandatory = $true)]
[ValidatePattern('^[0-9A-Fa-f]{64}$')]
[string]$ApprovedPlayoutSha256,
[Parameter(Mandatory = $true)]
[ValidatePattern('^[0-9A-Fa-f]{64}$')]
[string]$ApprovedCoreSha256,
[Parameter(Mandatory = $true)]
[ValidatePattern('^[0-9A-Fa-f]{64}$')]
[string]$ApprovedDepsSha256,
[Parameter(Mandatory = $true)]
[ValidatePattern('^[0-9A-Fa-f]{64}$')]
[string]$ApprovedRuntimeConfigSha256,
[Parameter(Mandatory = $true)]
[ValidatePattern('^[0-9A-Fa-f]{64}$')]
[string]$ApprovedWindowsSdkSha256,
[Parameter(Mandatory = $true)]
[ValidatePattern('^[0-9A-Fa-f]{64}$')]
[string]$ApprovedWinRtSha256,
[string]$ExpectedPgmWindowTitle = 'PGM',
[ValidateRange(1, 65535)]
[int]$Port = 30001,
[string]$EvidenceDirectory,
[switch]$IUnderstandPgmWillRenderCuts
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$acknowledgement =
'--i-understand-this-will-render-current-pgm-tornado-cuts-5001-then-5006-and-take-them-out'
$expectedCuts = @{
'5001.t2s' = '99CE3B689A42D8C42BEB09A86FA10C2D7C1AEF4F50D324D81276C1A1E4C4D8A7'
'5006.t2s' = '25CD0AE931F51E4E3B84CE3E6FD21A40DB85464F157A23CC3511D63B336D8757'
}
if (-not $IUnderstandPgmWillRenderCuts) {
throw 'The explicit PGM render acknowledgement switch is required.'
}
$playoutOverrides = @(Get-ChildItem Env: | Where-Object {
$_.Name -like 'MBN_STOCK_PLAYOUT_*'
})
if ($playoutOverrides.Count -ne 0) {
throw 'MBN_STOCK_PLAYOUT environment overrides must be absent.'
}
$runtimeInjectionOverrides = @(Get-ChildItem Env: | Where-Object {
$_.Name -in @(
'DOTNET_STARTUP_HOOKS',
'DOTNET_ADDITIONAL_DEPS',
'DOTNET_SHARED_STORE',
'DOTNET_HOST_PATH',
'CORECLR_ENABLE_PROFILING',
'COR_ENABLE_PROFILING') -or
$_.Name -like 'DOTNET_ROOT*' -or
$_.Name -like 'CORECLR_*PROFILER*' -or
$_.Name -like 'COR_*PROFILER*'
})
if ($runtimeInjectionOverrides.Count -ne 0) {
throw '.NET startup-hook, additional-dependency, or profiler overrides must be absent.'
}
$SmokeDll = [IO.Path]::GetFullPath($SmokeDll)
$SceneRoot = [IO.Path]::GetFullPath($SceneRoot)
if (-not (Test-Path -LiteralPath $SmokeDll -PathType Leaf)) {
throw 'The playout smoke assembly does not exist.'
}
if ([IO.Path]::GetFileName($SmokeDll) -cne 'MBN_STOCK_WEBVIEW.PlayoutSmoke.dll') {
throw 'The expected playout smoke assembly name is required.'
}
$runnerDirectory = [IO.Path]::GetDirectoryName($SmokeDll)
$runnerFiles = [ordered]@{
$SmokeDll = $ApprovedSmokeSha256
(Join-Path $runnerDirectory 'MBN_STOCK_WEBVIEW.Playout.dll') = $ApprovedPlayoutSha256
(Join-Path $runnerDirectory 'MBN_STOCK_WEBVIEW.Core.dll') = $ApprovedCoreSha256
(Join-Path $runnerDirectory 'MBN_STOCK_WEBVIEW.PlayoutSmoke.deps.json') = $ApprovedDepsSha256
(Join-Path $runnerDirectory 'MBN_STOCK_WEBVIEW.PlayoutSmoke.runtimeconfig.json') = $ApprovedRuntimeConfigSha256
(Join-Path $runnerDirectory 'Microsoft.Windows.SDK.NET.dll') = $ApprovedWindowsSdkSha256
(Join-Path $runnerDirectory 'WinRT.Runtime.dll') = $ApprovedWinRtSha256
}
foreach ($entry in $expectedCuts.GetEnumerator()) {
$path = Join-Path $SceneRoot $entry.Key
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
throw "Required cut file is missing: $($entry.Key)"
}
$actual = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash
if ($actual -cne $entry.Value) {
throw "Required cut file hash does not match: $($entry.Key)"
}
}
$tornado = @(Get-Process -Name 'Tornado2*' -ErrorAction SilentlyContinue)
if ($tornado.Count -ne 1 -or $tornado[0].MainWindowTitle -cne $ExpectedPgmWindowTitle) {
throw 'Exactly one Tornado2 process with the expected PGM title is required.'
}
$listener = @(Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction Stop)
if ($listener.Count -ne 1 -or $listener[0].OwningProcess -ne $tornado[0].Id) {
throw 'The exact Tornado2 process must own the requested KTAP listener.'
}
if ([string]::IsNullOrWhiteSpace($EvidenceDirectory)) {
$stamp = Get-Date -Format 'yyyyMMdd_HHmmss'
$EvidenceDirectory = Join-Path $env:TEMP "MBN_STOCK_WEBVIEW_PGM_EVIDENCE_$stamp"
}
$EvidenceDirectory = [IO.Path]::GetFullPath($EvidenceDirectory)
if (Test-Path -LiteralPath $EvidenceDirectory) {
throw 'The evidence directory must not already exist.'
}
[void](New-Item -ItemType Directory -Path $EvidenceDirectory)
Add-Type -AssemblyName System.Drawing
Add-Type -AssemblyName System.Windows.Forms
Add-Type @'
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text;
public static class PgmEvidenceWindows
{
private const int GwlExStyle = -20;
private const long WsExTopmost = 0x00000008L;
public const int SwRestore = 9;
public static readonly IntPtr HwndTopmost = new IntPtr(-1);
public static readonly IntPtr HwndNotTopmost = new IntPtr(-2);
public const uint SwpNoSize = 0x0001;
public const uint SwpNoMove = 0x0002;
public const uint SwpNoActivate = 0x0010;
public const uint SwpShowWindow = 0x0040;
[StructLayout(LayoutKind.Sequential)]
public struct Point { public int X; public int Y; }
[StructLayout(LayoutKind.Sequential)]
public struct Rect { public int Left; public int Top; public int Right; public int Bottom; }
[StructLayout(LayoutKind.Sequential)]
public struct WindowPlacement
{
public int Length;
public int Flags;
public int ShowCommand;
public Point MinimumPosition;
public Point MaximumPosition;
public Rect NormalPosition;
}
private delegate bool EnumWindowsCallback(IntPtr window, IntPtr parameter);
[DllImport("user32.dll")]
private static extern bool EnumWindows(EnumWindowsCallback callback, IntPtr parameter);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern int GetWindowText(IntPtr window, StringBuilder text, int count);
[DllImport("user32.dll")]
private static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId);
[DllImport("user32.dll")]
public static extern bool GetWindowPlacement(IntPtr window, ref WindowPlacement placement);
[DllImport("user32.dll")]
public static extern bool GetWindowRect(IntPtr window, out Rect rectangle);
[DllImport("user32.dll")]
public static extern bool SetWindowPlacement(IntPtr window, ref WindowPlacement placement);
[DllImport("user32.dll")]
public static extern bool ShowWindow(IntPtr window, int command);
[DllImport("user32.dll")]
public static extern bool SetWindowPos(
IntPtr window,
IntPtr insertAfter,
int x,
int y,
int width,
int height,
uint flags);
[DllImport("user32.dll", EntryPoint = "GetWindowLong")]
private static extern int GetWindowLong32(IntPtr window, int index);
[DllImport("user32.dll", EntryPoint = "GetWindowLongPtr")]
private static extern IntPtr GetWindowLongPtr64(IntPtr window, int index);
public static IntPtr FindTopLevelWindow(uint processId, string exactTitle)
{
IntPtr match = IntPtr.Zero;
EnumWindows((window, parameter) =>
{
uint owner;
GetWindowThreadProcessId(window, out owner);
if (owner != processId)
{
return true;
}
var title = new StringBuilder(512);
GetWindowText(window, title, title.Capacity);
if (String.Equals(title.ToString(), exactTitle, StringComparison.Ordinal))
{
match = window;
return false;
}
return true;
}, IntPtr.Zero);
return match;
}
public static bool IsTopmost(IntPtr window)
{
var style = IntPtr.Size == 8
? GetWindowLongPtr64(window, GwlExStyle).ToInt64()
: GetWindowLong32(window, GwlExStyle);
return (style & WsExTopmost) != 0;
}
public static string QuoteArgument(string argument)
{
if (argument == null)
{
throw new ArgumentNullException("argument");
}
if (argument.Length > 0 &&
argument.IndexOfAny(new[] { ' ', '\t', '\n', '\v', '"' }) < 0)
{
return argument;
}
var commandLine = new StringBuilder();
commandLine.Append('"');
var backslashes = 0;
foreach (var character in argument)
{
if (character == '\\')
{
backslashes++;
continue;
}
if (character == '"')
{
commandLine.Append('\\', backslashes * 2 + 1);
commandLine.Append('"');
backslashes = 0;
continue;
}
commandLine.Append('\\', backslashes);
backslashes = 0;
commandLine.Append(character);
}
commandLine.Append('\\', backslashes * 2);
commandLine.Append('"');
return commandLine.ToString();
}
}
'@
function Get-Placement([IntPtr]$Handle) {
$placement = New-Object PgmEvidenceWindows+WindowPlacement
$placement.Length = [Runtime.InteropServices.Marshal]::SizeOf($placement)
if (-not [PgmEvidenceWindows]::GetWindowPlacement($Handle, [ref]$placement)) {
throw 'Unable to read Tornado window placement.'
}
return $placement
}
function Open-ApprovedFileLease(
[string]$Path,
[string]$ApprovedSha256) {
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
throw "Required runner file is missing: $([IO.Path]::GetFileName($Path))"
}
$stream = [IO.File]::Open(
$Path,
[IO.FileMode]::Open,
[IO.FileAccess]::Read,
[IO.FileShare]::Read)
try {
$sha = [Security.Cryptography.SHA256]::Create()
try {
$bytes = $sha.ComputeHash($stream)
}
finally {
$sha.Dispose()
}
$actual = -join ($bytes | ForEach-Object { $_.ToString('X2') })
if ($actual -cne $ApprovedSha256.ToUpperInvariant()) {
throw "Runner file hash does not match: $([IO.Path]::GetFileName($Path))"
}
return [pscustomobject]@{
Path = $Path
Sha256 = $actual
Stream = $stream
}
}
catch {
$stream.Dispose()
throw
}
}
function Save-Frame([int]$Index, [long]$ElapsedMilliseconds) {
$name = 'frame_{0:D4}_{1:D7}ms.png' -f $Index, $ElapsedMilliseconds
$path = Join-Path $EvidenceDirectory $name
$bitmap = New-Object Drawing.Bitmap `
$script:captureWidth, `
$script:captureHeight, `
([Drawing.Imaging.PixelFormat]::Format24bppRgb)
try {
$graphics = [Drawing.Graphics]::FromImage($bitmap)
try {
$graphics.Clear([Drawing.Color]::Black)
$graphics.CopyFromScreen(
$script:networkCaptureRect.X,
$script:networkCaptureRect.Y,
0,
0,
$script:networkCaptureRect.Size)
$graphics.CopyFromScreen(
$script:pgmCaptureRect.X,
$script:pgmCaptureRect.Y,
$script:networkCaptureRect.Width + 20,
0,
$script:pgmCaptureRect.Size)
}
finally {
$graphics.Dispose()
}
$bitmap.Save($path, [Drawing.Imaging.ImageFormat]::Png)
}
finally {
$bitmap.Dispose()
}
return [pscustomobject]@{
file = $name
elapsedMilliseconds = $ElapsedMilliseconds
sha256 = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash
}
}
$pgmHandle = [IntPtr]$tornado[0].MainWindowHandle
$networkTitle = -join ([char[]]@(
0xB124, 0xD2B8, 0xC6CC, 0xD06C, 0x20,
0xBAA8, 0xB2C8, 0xD130, 0xB9C1, 0x20, 0xCC3D))
$networkHandle = [PgmEvidenceWindows]::FindTopLevelWindow(
[uint32]$tornado[0].Id,
$networkTitle)
if ($pgmHandle -eq [IntPtr]::Zero -or $networkHandle -eq [IntPtr]::Zero) {
throw 'Both the PGM and Network Monitoring windows must already be open.'
}
$script:networkCaptureRect = New-Object Drawing.Rectangle 20, 20, 1183, 648
$script:pgmCaptureRect = New-Object Drawing.Rectangle 1760, 70, 720, 543
$script:captureWidth =
$script:networkCaptureRect.Width + 20 + $script:pgmCaptureRect.Width
$script:captureHeight = [Math]::Max(
$script:networkCaptureRect.Height,
$script:pgmCaptureRect.Height)
$primaryBounds = [Windows.Forms.Screen]::PrimaryScreen.Bounds
if (-not $primaryBounds.Contains($script:networkCaptureRect) -or
-not $primaryBounds.Contains($script:pgmCaptureRect)) {
throw 'The primary display cannot contain both evidence windows.'
}
function Assert-WindowRectangle(
[IntPtr]$Handle,
[Drawing.Rectangle]$Expected) {
$actual = New-Object PgmEvidenceWindows+Rect
if (-not [PgmEvidenceWindows]::GetWindowRect($Handle, [ref]$actual) -or
$actual.Left -ne $Expected.Left -or
$actual.Top -ne $Expected.Top -or
($actual.Right - $actual.Left) -ne $Expected.Width -or
($actual.Bottom - $actual.Top) -ne $Expected.Height) {
throw 'A Tornado evidence window was not placed at the verified capture rectangle.'
}
}
$pgmPlacement = Get-Placement $pgmHandle
$networkPlacement = Get-Placement $networkHandle
$pgmWasTopmost = [PgmEvidenceWindows]::IsTopmost($pgmHandle)
$networkWasTopmost = [PgmEvidenceWindows]::IsTopmost($networkHandle)
$dotnet = 'C:\Program Files\dotnet\dotnet.exe'
if (-not (Test-Path -LiteralPath $dotnet -PathType Leaf)) {
throw 'The trusted 64-bit dotnet host was not found.'
}
$dotnetSignature = Get-AuthenticodeSignature -LiteralPath $dotnet
if ($dotnetSignature.Status -ne [System.Management.Automation.SignatureStatus]::Valid -or
$null -eq $dotnetSignature.SignerCertificate -or
$dotnetSignature.SignerCertificate.Subject -notmatch
'(^|,\s*)O=Microsoft Corporation(,|$)') {
throw 'The dotnet host signature is not trusted.'
}
$runnerLeases = [Collections.Generic.List[object]]::new()
try {
foreach ($runnerFile in $runnerFiles.GetEnumerator()) {
$runnerLeases.Add((Open-ApprovedFileLease `
([string]$runnerFile.Key) `
([string]$runnerFile.Value)))
}
}
catch {
foreach ($lease in $runnerLeases) {
$lease.Stream.Dispose()
}
throw
}
$frames = [Collections.Generic.List[object]]::new()
$output = [Collections.Generic.List[object]]::new()
$captureErrors = [Collections.Generic.List[string]]::new()
$restorationErrors = [Collections.Generic.List[string]]::new()
$process = $null
$exitCode = $null
$evidenceExitCode = $null
$result = $null
$stopwatch = [Diagnostics.Stopwatch]::StartNew()
try {
[void][PgmEvidenceWindows]::ShowWindow($networkHandle, [PgmEvidenceWindows]::SwRestore)
[void][PgmEvidenceWindows]::ShowWindow($pgmHandle, [PgmEvidenceWindows]::SwRestore)
if (-not [PgmEvidenceWindows]::SetWindowPos(
$networkHandle,
[PgmEvidenceWindows]::HwndTopmost,
$script:networkCaptureRect.X,
$script:networkCaptureRect.Y,
$script:networkCaptureRect.Width,
$script:networkCaptureRect.Height,
[PgmEvidenceWindows]::SwpNoActivate -bor
[PgmEvidenceWindows]::SwpShowWindow)) {
throw 'Unable to place the Network Monitoring window for evidence capture.'
}
if (-not [PgmEvidenceWindows]::SetWindowPos(
$pgmHandle,
[PgmEvidenceWindows]::HwndTopmost,
$script:pgmCaptureRect.X,
$script:pgmCaptureRect.Y,
$script:pgmCaptureRect.Width,
$script:pgmCaptureRect.Height,
[PgmEvidenceWindows]::SwpNoActivate -bor
[PgmEvidenceWindows]::SwpShowWindow)) {
throw 'Unable to place the PGM window for evidence capture.'
}
Assert-WindowRectangle $networkHandle $script:networkCaptureRect
Assert-WindowRectangle $pgmHandle $script:pgmCaptureRect
Start-Sleep -Milliseconds 750
Assert-WindowRectangle $networkHandle $script:networkCaptureRect
Assert-WindowRectangle $pgmHandle $script:pgmCaptureRect
for ($index = 0; $index -lt 4; $index++) {
$frames.Add((Save-Frame $index $stopwatch.ElapsedMilliseconds))
Start-Sleep -Milliseconds 500
}
$start = [Diagnostics.ProcessStartInfo]::new()
$start.FileName = $dotnet
$start.UseShellExecute = $false
$start.CreateNoWindow = $true
$start.RedirectStandardOutput = $true
$start.RedirectStandardError = $true
$runnerArguments = @(
$SmokeDll,
'--pgm-cuts-sequence',
$acknowledgement,
'--host',
'127.0.0.1',
'--port',
$Port.ToString([Globalization.CultureInfo]::InvariantCulture),
'--expected-pgm-window-title',
$ExpectedPgmWindowTitle,
'--scene-root',
$SceneRoot,
'--observe-ms',
'5000')
$start.Arguments = ($runnerArguments | ForEach-Object {
[PgmEvidenceWindows]::QuoteArgument([string]$_)
}) -join ' '
$start.EnvironmentVariables['MBN_STOCK_K3D_NATIVE_SHA256'] =
$ApprovedNativeSha256.ToUpperInvariant()
$start.EnvironmentVariables['MBN_STOCK_K3D_INTEROP_SHA256'] =
$ApprovedInteropSha256.ToUpperInvariant()
$process = [Diagnostics.Process]::new()
$process.StartInfo = $start
if (-not $process.Start()) {
throw 'Unable to launch the PGM sequence runner.'
}
$processStartElapsed = $stopwatch.ElapsedMilliseconds
$frameIndex = 4
$captureEnabled = $true
try {
$stdoutRead = $process.StandardOutput.ReadLineAsync()
$stderrRead = $process.StandardError.ReadLineAsync()
$nextFrameAt = $stopwatch.ElapsedMilliseconds
$stdoutEnded = $false
$stderrEnded = $false
while (-not $process.HasExited -or -not $stdoutEnded -or -not $stderrEnded) {
if (-not $stdoutEnded -and $stdoutRead.IsCompleted) {
try {
$line = $stdoutRead.GetAwaiter().GetResult()
}
catch {
$captureErrors.Add('stdout-read-failed')
$stdoutEnded = $true
$line = $null
}
if ($null -eq $line) {
$stdoutEnded = $true
}
else {
$output.Add([pscustomobject]@{
stream = 'stdout'
elapsedMilliseconds = $stopwatch.ElapsedMilliseconds
line = $line
})
$stdoutRead = $process.StandardOutput.ReadLineAsync()
}
}
if (-not $stderrEnded -and $stderrRead.IsCompleted) {
try {
$line = $stderrRead.GetAwaiter().GetResult()
}
catch {
$captureErrors.Add('stderr-read-failed')
$stderrEnded = $true
$line = $null
}
if ($null -eq $line) {
$stderrEnded = $true
}
else {
$output.Add([pscustomobject]@{
stream = 'stderr'
elapsedMilliseconds = $stopwatch.ElapsedMilliseconds
line = $line
})
$stderrRead = $process.StandardError.ReadLineAsync()
}
}
if ($captureEnabled -and $stopwatch.ElapsedMilliseconds -ge $nextFrameAt) {
try {
$frames.Add((Save-Frame $frameIndex $stopwatch.ElapsedMilliseconds))
$frameIndex++
}
catch {
$captureErrors.Add('frame-capture-failed')
$captureEnabled = $false
}
$nextFrameAt += 500
}
Start-Sleep -Milliseconds 20
}
}
catch {
$captureErrors.Add('supervision-loop-failed')
while (-not $process.HasExited) {
Start-Sleep -Milliseconds 100
}
}
$process.WaitForExit()
$exitCode = $process.ExitCode
Start-Sleep -Milliseconds 500
if ($captureEnabled) {
try {
$frames.Add((Save-Frame $frameIndex $stopwatch.ElapsedMilliseconds))
}
catch {
$captureErrors.Add('final-frame-capture-failed')
}
}
$validationErrors = [Collections.Generic.List[string]]::new()
$stdoutEntries = @($output | Where-Object { $_.stream -eq 'stdout' })
$markers = [Collections.Generic.List[object]]::new()
$lastMarkerIndex = -1
for ($index = 0; $index -lt $stdoutEntries.Count; $index++) {
$entry = $stdoutEntries[$index]
if ($entry.line -notmatch '"event"\s*:\s*"observation-start"') {
continue
}
try {
$marker = $entry.line | ConvertFrom-Json -ErrorAction Stop
$markers.Add([pscustomobject]@{
stage = [string]$marker.stage
elapsedMilliseconds = [long]$entry.elapsedMilliseconds
})
$lastMarkerIndex = $index
}
catch {
$validationErrors.Add('observation-marker-json-invalid')
}
}
$markerStages = @($markers | ForEach-Object { $_.stage })
$expectedMarkerStages = @('first', 'next', 'take-out')
if ($markerStages.Count -ne $expectedMarkerStages.Count -or
($markerStages -join '|') -cne ($expectedMarkerStages -join '|')) {
$validationErrors.Add('observation-marker-order-invalid')
}
$terminalEntries = if ($lastMarkerIndex + 1 -lt $stdoutEntries.Count) {
@($stdoutEntries[($lastMarkerIndex + 1)..($stdoutEntries.Count - 1)])
}
else {
@()
}
$terminal = $null
if ($terminalEntries.Count -ne 0) {
try {
$terminal = ($terminalEntries.line -join [Environment]::NewLine) |
ConvertFrom-Json -ErrorAction Stop
}
catch {
$validationErrors.Add('terminal-report-json-invalid')
}
}
$observationIntervals = @()
if ($markers.Count -eq 3 -and $terminalEntries.Count -ne 0) {
$observationIntervals = @(
([long]$markers[1].elapsedMilliseconds -
[long]$markers[0].elapsedMilliseconds),
([long]$markers[2].elapsedMilliseconds -
[long]$markers[1].elapsedMilliseconds),
([long]$terminalEntries[0].elapsedMilliseconds -
[long]$markers[2].elapsedMilliseconds)
)
if (@($observationIntervals | Where-Object { $_ -lt 4900 }).Count -ne 0) {
$validationErrors.Add('observation-duration-too-short')
}
}
else {
$validationErrors.Add('observation-duration-unavailable')
}
if ($null -eq $terminal -or $terminal.command -ne 'pgm-cuts-sequence') {
$validationErrors.Add('terminal-report-missing-or-invalid')
}
else {
$actualCommandStages = @($terminal.commands | ForEach-Object {
'{0}:{1}' -f $_.stage, $_.code
})
$expectedCommandStages = @(
'connect:Success',
'prepare-first:Success',
'take-in:Success',
'next:Success',
'take-out:Success',
'disconnect:Success')
if ($actualCommandStages.Count -ne $expectedCommandStages.Count -or
($actualCommandStages -join '|') -cne ($expectedCommandStages -join '|')) {
$validationErrors.Add('terminal-command-order-invalid')
}
if ($terminal.completed -ne $true -or
$terminal.outcomeUnknown -ne $false -or
$terminal.renderCommandAttempted -ne $true -or
$terminal.disconnectAttempted -ne $true -or
$terminal.quarantineAttempted -ne $false) {
$validationErrors.Add('terminal-safety-state-invalid')
}
}
if ($exitCode -ne 0) {
$validationErrors.Add('runner-exit-code-nonzero')
}
if ($frames.Count -lt 30) {
$validationErrors.Add('insufficient-frame-count')
}
if ($captureErrors.Count -ne 0) {
$validationErrors.Add('evidence-capture-incomplete')
}
$terminalValidated = $validationErrors.Count -eq 0
$evidenceExitCode = if ($exitCode -ne 0) { $exitCode } elseif (
-not $terminalValidated) { 6 } else { 0 }
$manifest = [ordered]@{
schemaVersion = 1
createdAtUtc = [DateTimeOffset]::UtcNow.ToString('O')
processStartElapsedMilliseconds = $processStartElapsed
runnerExitCode = $exitCode
evidenceExitCode = $evidenceExitCode
terminalValidated = $terminalValidated
runnerFilesSha256 = @($runnerLeases | ForEach-Object {
[ordered]@{
file = [IO.Path]::GetFileName($_.Path)
sha256 = $_.Sha256
}
})
cutSha256 = $expectedCuts
frames = $frames
captureErrors = $captureErrors
validationErrors = $validationErrors
observationIntervalsMilliseconds = $observationIntervals
runnerOutput = $output
}
$manifestPath = Join-Path $EvidenceDirectory 'manifest.json'
[IO.File]::WriteAllText(
$manifestPath,
($manifest | ConvertTo-Json -Depth 8),
[Text.UTF8Encoding]::new($false))
$manifestHash = (Get-FileHash -LiteralPath $manifestPath -Algorithm SHA256).Hash
$result = [pscustomobject]@{
EvidenceDirectory = $EvidenceDirectory
RunnerExitCode = $exitCode
TerminalValidated = $terminalValidated
FrameCount = $frames.Count
ManifestSha256 = $manifestHash
}
}
finally {
if ($null -ne $process) {
$process.Dispose()
}
foreach ($lease in $runnerLeases) {
$lease.Stream.Dispose()
}
if (-not [PgmEvidenceWindows]::SetWindowPos(
$networkHandle,
$(if ($networkWasTopmost) {
[PgmEvidenceWindows]::HwndTopmost
} else {
[PgmEvidenceWindows]::HwndNotTopmost
}),
0,
0,
0,
0,
[PgmEvidenceWindows]::SwpNoActivate -bor
[PgmEvidenceWindows]::SwpNoMove -bor
[PgmEvidenceWindows]::SwpNoSize)) {
$restorationErrors.Add('network-topmost-restore-failed')
}
if (-not [PgmEvidenceWindows]::SetWindowPos(
$pgmHandle,
$(if ($pgmWasTopmost) {
[PgmEvidenceWindows]::HwndTopmost
} else {
[PgmEvidenceWindows]::HwndNotTopmost
}),
0,
0,
0,
0,
[PgmEvidenceWindows]::SwpNoActivate -bor
[PgmEvidenceWindows]::SwpNoMove -bor
[PgmEvidenceWindows]::SwpNoSize)) {
$restorationErrors.Add('pgm-topmost-restore-failed')
}
if (-not [PgmEvidenceWindows]::SetWindowPlacement(
$networkHandle,
[ref]$networkPlacement)) {
$restorationErrors.Add('network-placement-restore-failed')
}
if (-not [PgmEvidenceWindows]::SetWindowPlacement(
$pgmHandle,
[ref]$pgmPlacement)) {
$restorationErrors.Add('pgm-placement-restore-failed')
}
}
if ($null -ne $result) {
$result | Add-Member -NotePropertyName RestorationSucceeded `
-NotePropertyValue ($restorationErrors.Count -eq 0)
}
if ($restorationErrors.Count -ne 0) {
Write-Warning ($restorationErrors -join ',')
}
$result
if ($evidenceExitCode -ne 0) {
exit $evidenceExitCode
}