[CmdletBinding(DefaultParameterSetName = 'Snapshot')] param( [Parameter(Mandatory = $true, ParameterSetName = 'Snapshot')] [string]$SnapshotDirectory, [Parameter(Mandatory = $true, ParameterSetName = 'Database')] [string]$EvidenceServer, [Parameter(Mandatory = $true, ParameterSetName = 'Database')] [string]$EvidenceDatabase, [Parameter(ParameterSetName = 'Database')][string]$EvidenceUsername, [Parameter(ParameterSetName = 'Database')][switch]$IntegratedSecurity, [Parameter(Mandatory = $true)][guid]$RunId, [Parameter(Mandatory = $true)][string]$OutputDirectory ) $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest . (Join-Path $PSScriptRoot 'ShadowScriptCommon.ps1') $requiredFiles = @( 'cycles.csv', 'cycles.json', 'protocol-differences.csv', 'algorithm-differences.csv', 'insufficient-facts.csv', 'startup-checks.json' ) $output = Resolve-ShadowDirectory $OutputDirectory -Create if (@(Get-ChildItem -LiteralPath $output -Force).Count -gt 0) { throw 'EVIDENCE_EXPORT_DIRECTORY_MUST_BE_EMPTY' } function Convert-DataTableRows { param([System.Data.DataTable]$Table) return @($Table.Rows | ForEach-Object { $value = [ordered]@{} foreach ($column in $Table.Columns) { $cell = $_[$column.ColumnName] $value[$column.ColumnName] = if ($cell -is [DBNull]) { $null } else { $cell } } [pscustomobject]$value }) } if ($PSCmdlet.ParameterSetName -eq 'Snapshot') { $snapshot = Resolve-ShadowDirectory $SnapshotDirectory foreach ($name in $requiredFiles) { $source = Resolve-ShadowFile (Join-Path $snapshot $name) Copy-Item -LiteralPath $source -Destination (Join-Path $output $name) } } else { $builder = New-Object System.Data.SqlClient.SqlConnectionStringBuilder $builder.DataSource = $EvidenceServer $builder.InitialCatalog = $EvidenceDatabase $builder.Encrypt = $true $builder.TrustServerCertificate = $false $builder.ConnectTimeout = 5 if ($IntegratedSecurity.IsPresent) { $builder.IntegratedSecurity = $true } else { if ([string]::IsNullOrWhiteSpace($EvidenceUsername)) { throw 'EVIDENCE_USERNAME_REQUIRED' } $password = [Environment]::GetEnvironmentVariable( 'FT546_SHADOW_EVIDENCE_PASSWORD', 'Process') if ([string]::IsNullOrWhiteSpace($password)) { throw 'FT546_SHADOW_EVIDENCE_PASSWORD_REQUIRED' } $builder.UserID = $EvidenceUsername $builder.Password = $password } $connection = New-Object System.Data.SqlClient.SqlConnection $builder.ConnectionString function Invoke-EvidenceQuery { param([string]$Sql) $command = $connection.CreateCommand() $command.CommandTimeout = 10 $command.CommandText = $Sql $parameter = $command.Parameters.Add('@run_id', [System.Data.SqlDbType]::UniqueIdentifier) $parameter.Value = $RunId $adapter = New-Object System.Data.SqlClient.SqlDataAdapter $command $table = New-Object System.Data.DataTable [void]$adapter.Fill($table) return $table } try { $connection.Open() $cycles = Invoke-EvidenceQuery @' SELECT cycle_id,run_id,input_batch_hash,parent_task_id,phase,route_version, status,reason_code,database_captured_at,completed_at,evidence_hash FROM dbo.shadow_cycle WHERE run_id=@run_id ORDER BY completed_at,cycle_id '@ Convert-DataTableRows $cycles | Export-Csv -NoTypeInformation ` -Encoding UTF8 -LiteralPath (Join-Path $output 'cycles.csv') $protocol = Invoke-EvidenceQuery @' SELECT d.difference_id,d.cycle_id,d.category,d.reason_code, d.expected_json,d.actual_json FROM dbo.shadow_difference d JOIN dbo.shadow_cycle c ON c.cycle_id=d.cycle_id WHERE c.run_id=@run_id AND d.category LIKE 'PROTOCOL_%' ORDER BY d.difference_id '@ Convert-DataTableRows $protocol | Export-Csv -NoTypeInformation ` -Encoding UTF8 -LiteralPath (Join-Path $output 'protocol-differences.csv') $algorithm = Invoke-EvidenceQuery @' SELECT d.difference_id,d.cycle_id,d.category,d.reason_code, d.expected_json,d.actual_json FROM dbo.shadow_difference d JOIN dbo.shadow_cycle c ON c.cycle_id=d.cycle_id WHERE c.run_id=@run_id AND d.category LIKE 'ALGORITHM_%' ORDER BY d.difference_id '@ Convert-DataTableRows $algorithm | Export-Csv -NoTypeInformation ` -Encoding UTF8 -LiteralPath (Join-Path $output 'algorithm-differences.csv') $insufficient = Invoke-EvidenceQuery @' SELECT cycle_id,parent_task_id,phase,status,reason_code,database_captured_at, completed_at,evidence_hash FROM dbo.shadow_cycle WHERE run_id=@run_id AND status='INSUFFICIENT_FACTS' ORDER BY completed_at,cycle_id '@ Convert-DataTableRows $insufficient | Export-Csv -NoTypeInformation ` -Encoding UTF8 -LiteralPath (Join-Path $output 'insufficient-facts.csv') $startup = Invoke-EvidenceQuery @' SELECT check_id,check_name,status,checked_at FROM dbo.shadow_startup_check WHERE checked_at >= (SELECT started_at FROM dbo.shadow_run WHERE run_id=@run_id) ORDER BY check_id '@ Convert-DataTableRows $startup | ConvertTo-Json -Depth 6 | ` Set-Content -LiteralPath (Join-Path $output 'startup-checks.json') ` -Encoding UTF8 $cycleJson = [ordered]@{ cycles = Convert-DataTableRows $cycles decisions = Convert-DataTableRows (Invoke-EvidenceQuery ` 'SELECT d.* FROM dbo.shadow_decision d JOIN dbo.shadow_cycle c ON c.cycle_id=d.cycle_id WHERE c.run_id=@run_id ORDER BY d.cycle_id') encodedWrites = Convert-DataTableRows (Invoke-EvidenceQuery ` 'SELECT w.* FROM dbo.shadow_encoded_write w JOIN dbo.shadow_cycle c ON c.cycle_id=w.cycle_id WHERE c.run_id=@run_id ORDER BY w.cycle_id,w.write_sequence') differences = Convert-DataTableRows (Invoke-EvidenceQuery ` 'SELECT d.* FROM dbo.shadow_difference d JOIN dbo.shadow_cycle c ON c.cycle_id=d.cycle_id WHERE c.run_id=@run_id ORDER BY d.difference_id') } $cycleJson | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath ` (Join-Path $output 'cycles.json') -Encoding UTF8 } finally { if ($null -ne $connection) { $connection.Dispose() } [Environment]::SetEnvironmentVariable( 'FT546_SHADOW_EVIDENCE_PASSWORD', $null, 'Process') } } $files = @($requiredFiles | Sort-Object | ForEach-Object { $path = Resolve-ShadowFile (Join-Path $output $_) [pscustomobject]@{ name = $_ length = (Get-Item -LiteralPath $path).Length sha256 = Get-ShadowSha256 $path } }) $manifest = [pscustomobject]@{ schemaVersion = 1 runId = $RunId.ToString() exportedAtUtc = [DateTime]::UtcNow.ToString('o') sourceMode = $PSCmdlet.ParameterSetName files = $files } $manifestPath = Join-Path $output 'manifest.json' Write-ShadowJson -Value $manifest -Path $manifestPath $manifestHash = Get-ShadowSha256 $manifestPath "$manifestHash manifest.json" | Set-Content -LiteralPath ` (Join-Path $output 'manifest.sha256') -Encoding ASCII return $manifest