test(ft546): verify read-only shadow operation

This commit is contained in:
zgw
2026-08-14 19:08:01 +08:00
parent 240f5b0808
commit 01bdf37b06
22 changed files with 2377 additions and 6 deletions

View File

@@ -0,0 +1,54 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$StableArtifact,
[int]$StablePid = 0,
[string]$ServiceName,
[string[]]$Configuration = @(),
[Parameter(Mandatory = $true)][string]$OutputJson
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
. (Join-Path $PSScriptRoot 'ShadowScriptCommon.ps1')
$artifact = Resolve-ShadowFile $StableArtifact
$service = $null
if ($ServiceName) {
$service = Get-CimInstance Win32_Service -Filter "Name='$ServiceName'" `
-ErrorAction Stop
if ($StablePid -eq 0) { $StablePid = [int]$service.ProcessId }
}
elseif ($StablePid -gt 0) {
try {
$service = Get-CimInstance Win32_Service -Filter "ProcessId=$StablePid" `
-ErrorAction Stop | Select-Object -First 1
}
catch { $service = $null }
}
if ($StablePid -le 0) { throw 'STABLE_PID_OR_SERVICE_REQUIRED' }
$process = Get-Process -Id $StablePid -ErrorAction Stop
$configurationEvidence = @($Configuration | ForEach-Object {
$path = Resolve-ShadowFile $_
[pscustomobject]@{
path = $path
sha256 = Get-ShadowSha256 $path
}
})
$manifest = [pscustomobject]@{
schemaVersion = 1
capturedAtUtc = [DateTime]::UtcNow.ToString('o')
status = 'PASS'
artifactPath = $artifact
artifactSha256 = Get-ShadowSha256 $artifact
pid = [int]$process.Id
processStartTimeUtc = $process.StartTime.ToUniversalTime().ToString('o')
executablePath = $process.Path
commandLine = Get-ShadowCommandLine $process.Id
serviceName = if ($null -eq $service) { $null } else { $service.Name }
serviceState = if ($null -eq $service) { $null } else { $service.State }
configuration = $configurationEvidence
listeningPorts = @(Get-ShadowListeningPorts $process.Id)
}
Write-ShadowJson -Value $manifest -Path $OutputJson
return $manifest

View File

@@ -0,0 +1,167 @@
[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

View File

@@ -0,0 +1,157 @@
function Resolve-ShadowFile {
param([Parameter(Mandatory = $true)][string]$Path)
$item = Get-Item -LiteralPath $Path -ErrorAction Stop
if ($item.PSIsContainer) { throw "FILE_REQUIRED:$Path" }
return $item.FullName
}
function Resolve-ShadowDirectory {
param(
[Parameter(Mandatory = $true)][string]$Path,
[switch]$Create
)
if ($Create -and -not (Test-Path -LiteralPath $Path)) {
New-Item -ItemType Directory -Path $Path -Force | Out-Null
}
$item = Get-Item -LiteralPath $Path -ErrorAction Stop
if (-not $item.PSIsContainer) { throw "DIRECTORY_REQUIRED:$Path" }
return $item.FullName
}
function Write-ShadowJson {
param(
[Parameter(Mandatory = $true)]$Value,
[Parameter(Mandatory = $true)][string]$Path,
[int]$Depth = 12
)
$parent = Split-Path -Parent ([IO.Path]::GetFullPath($Path))
if (-not (Test-Path -LiteralPath $parent)) {
New-Item -ItemType Directory -Path $parent -Force | Out-Null
}
$Value | ConvertTo-Json -Depth $Depth | Set-Content -LiteralPath $Path `
-Encoding UTF8
}
function Read-ShadowJson {
param([Parameter(Mandatory = $true)][string]$Path)
$resolved = Resolve-ShadowFile $Path
return Get-Content -LiteralPath $resolved -Raw | ConvertFrom-Json
}
function Get-ShadowSha256 {
param([Parameter(Mandatory = $true)][string]$Path)
return (Get-FileHash -LiteralPath (Resolve-ShadowFile $Path) `
-Algorithm SHA256).Hash.ToLowerInvariant()
}
function Get-ShadowCommandLine {
param([Parameter(Mandatory = $true)][int]$ProcessId)
try {
$row = Get-CimInstance Win32_Process -Filter "ProcessId=$ProcessId" `
-ErrorAction Stop
return $row.CommandLine
}
catch { return $null }
}
function Get-ShadowListeningPorts {
param([Parameter(Mandatory = $true)][int]$ProcessId)
try {
return @(Get-NetTCPConnection -OwningProcess $ProcessId -State Listen `
-ErrorAction Stop | Sort-Object LocalPort | ForEach-Object {
[pscustomobject]@{
address = $_.LocalAddress
port = [int]$_.LocalPort
state = $_.State.ToString()
}
})
}
catch { return @() }
}
function Test-ShadowPathWithin {
param(
[Parameter(Mandatory = $true)][string]$Candidate,
[Parameter(Mandatory = $true)][string]$Directory
)
$candidatePath = [IO.Path]::GetFullPath($Candidate)
$directoryPath = [IO.Path]::GetFullPath($Directory).TrimEnd('\', '/')
if ($candidatePath.Equals($directoryPath,
[StringComparison]::OrdinalIgnoreCase)) { return $true }
$prefix = $directoryPath + [IO.Path]::DirectorySeparatorChar
return $candidatePath.StartsWith($prefix,
[StringComparison]::OrdinalIgnoreCase)
}
function Get-ShadowFreeBytes {
param([Parameter(Mandatory = $true)][string]$Path)
$root = [IO.Path]::GetPathRoot([IO.Path]::GetFullPath($Path))
return ([IO.DriveInfo]::new($root)).AvailableFreeSpace
}
function Resolve-ShadowScalarPlaceholder {
param([AllowNull()][string]$Value)
if ($null -eq $Value) { return $null }
if ($Value -match '^\$\{([A-Za-z_][A-Za-z0-9_]*):(.*)\}$') {
$environmentValue = [Environment]::GetEnvironmentVariable($matches[1])
return if ($null -eq $environmentValue) { $matches[2] } `
else { $environmentValue }
}
return $Value
}
function Read-ShadowYamlScalars {
param([Parameter(Mandatory = $true)][string]$Path)
$resolved = Resolve-ShadowFile $Path
$parents = @{}
$values = @{}
foreach ($line in Get-Content -LiteralPath $resolved) {
if ($line -match '^\s*(#.*)?$') { continue }
if ($line -notmatch '^(\s*)([A-Za-z0-9_-]+):(?:\s*(.*))?$') {
continue
}
$indent = $matches[1].Length
$key = $matches[2]
$raw = if ($null -eq $matches[3]) { '' } else { $matches[3].Trim() }
foreach ($level in @($parents.Keys | Where-Object { [int]$_ -ge $indent })) {
$parents.Remove($level)
}
if ([string]::IsNullOrWhiteSpace($raw)) {
$parents[$indent] = $key
continue
}
$segments = @($parents.Keys | Sort-Object { [int]$_ } | ForEach-Object {
$parents[$_]
}) + $key
if (($raw.StartsWith('"') -and $raw.EndsWith('"')) -or `
($raw.StartsWith("'") -and $raw.EndsWith("'"))) {
$raw = $raw.Substring(1, $raw.Length - 2)
}
$values[($segments -join '.')] = Resolve-ShadowScalarPlaceholder $raw
}
return $values
}
function Get-ShadowSqlServerJdbcIdentity {
param([Parameter(Mandatory = $true)][string]$JdbcUrl)
if ($JdbcUrl -notmatch '(?i)^jdbc:sqlserver://([^;]+)(?:;(.*))?$') {
throw 'SQLSERVER_JDBC_URL_REQUIRED'
}
$server = $matches[1].Trim()
$properties = @{}
foreach ($part in @($matches[2] -split ';')) {
if ($part -match '^\s*([^=]+)=(.*)$') {
$properties[$matches[1].Trim().ToLowerInvariant()] =
$matches[2].Trim()
}
}
$database = $properties['databasename']
if ([string]::IsNullOrWhiteSpace($database)) {
$database = $properties['database']
}
if ([string]::IsNullOrWhiteSpace($server) -or `
[string]::IsNullOrWhiteSpace($database)) {
throw 'SQLSERVER_JDBC_IDENTITY_INCOMPLETE'
}
return [pscustomobject]@{ server = $server; database = $database }
}

View File

@@ -0,0 +1,112 @@
[CmdletBinding()]
param(
[switch]$ConfirmReadOnlyShadow,
[Parameter(Mandatory = $true)][string]$PreflightJson,
[string]$JavaExecutable = 'java',
[int]$MaximumPreflightAgeMinutes = 30
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
. (Join-Path $PSScriptRoot 'ShadowScriptCommon.ps1')
if (-not $ConfirmReadOnlyShadow.IsPresent) {
throw 'CONFIRM_READ_ONLY_SHADOW_REQUIRED'
}
$preflightPath = Resolve-ShadowFile $PreflightJson
$preflight = Read-ShadowJson $preflightPath
if (-not [bool]$preflight.success) { throw 'SUCCESSFUL_PREFLIGHT_REQUIRED' }
$generatedAt = [DateTime]::Parse($preflight.generatedAtUtc).ToUniversalTime()
if (([DateTime]::UtcNow - $generatedAt).TotalMinutes -gt `
$MaximumPreflightAgeMinutes) {
throw 'PREFLIGHT_EXPIRED'
}
if ($null -eq (Get-Process -Id ([int]$preflight.stable.pid) `
-ErrorAction SilentlyContinue)) {
throw 'STABLE_PROCESS_NOT_HEALTHY'
}
$artifact = Resolve-ShadowFile $preflight.shadowArtifact.path
$config = Resolve-ShadowFile $preflight.config.path
$deployment = Resolve-ShadowDirectory $preflight.deploymentDirectory
if (-not (Test-ShadowPathWithin $artifact $deployment) -or `
-not (Test-ShadowPathWithin $config $deployment)) {
throw 'SHADOW_FILES_MUST_BE_INSIDE_DEPLOYMENT_DIRECTORY'
}
if ((Get-ShadowSha256 $artifact) -ne $preflight.shadowArtifact.sha256) {
throw 'SHADOW_ARTIFACT_CHANGED_AFTER_PREFLIGHT'
}
if ((Get-ShadowSha256 $config) -ne $preflight.config.sha256) {
throw 'SHADOW_CONFIG_CHANGED_AFTER_PREFLIGHT'
}
if ((Get-ShadowSha256 $preflight.permissionReport.path) -ne `
$preflight.permissionReport.sha256) {
throw 'PERMISSION_REPORT_CHANGED_AFTER_PREFLIGHT'
}
if ((Get-ShadowSha256 $preflight.networkDenyReport.path) -ne `
$preflight.networkDenyReport.sha256) {
throw 'NETWORK_DENY_REPORT_CHANGED_AFTER_PREFLIGHT'
}
$pidFile = [IO.Path]::GetFullPath($preflight.pidFile)
if (-not (Test-ShadowPathWithin $pidFile $deployment)) {
throw 'PID_FILE_MUST_BE_INSIDE_DEPLOYMENT_DIRECTORY'
}
if (Test-Path -LiteralPath $pidFile) { throw 'SHADOW_PID_FILE_ALREADY_EXISTS' }
$java = (Get-Command $JavaExecutable -ErrorAction Stop).Source
$logs = Join-Path $deployment 'logs'
New-Item -ItemType Directory -Path $logs -Force | Out-Null
$stdout = Join-Path $logs 'shadow-stdout.log'
$stderr = Join-Path $logs 'shadow-stderr.log'
$configUri = ([uri]$config).AbsoluteUri
$statusUri = [uri]$preflight.statusUri
$runtimeProperties = @(
'--spring.profiles.active=shadow',
'--shadow.enabled=true',
"--spring.config.additional-location=$configUri",
"--server.address=$($statusUri.Host)",
"--server.port=$($statusUri.Port)",
"--shadow.evidence.run.artifact-sha256=$($preflight.shadowArtifact.sha256)",
"--shadow.evidence.run.configuration-sha256=$($preflight.config.sha256)",
"--shadow.evidence.run.source-server=$($preflight.sourceIdentity.server)",
"--shadow.evidence.run.source-database=$($preflight.sourceIdentity.database)",
"--shadow.evidence.minimum-free-bytes=$($preflight.minimumFreeBytes)"
)
$quotedProperties = @($runtimeProperties | ForEach-Object {
'"{0}"' -f $_.Replace('"', '\"')
}) -join ' '
$arguments = '-jar "{0}" {1}' -f $artifact.Replace('"', '\"'), `
$quotedProperties
$process = $null
try {
$process = Start-Process -FilePath $java -ArgumentList $arguments `
-WorkingDirectory $deployment -WindowStyle Hidden -PassThru `
-RedirectStandardOutput $stdout -RedirectStandardError $stderr
Start-Sleep -Milliseconds 300
if ($process.HasExited) {
throw "SHADOW_PROCESS_EXITED_EARLY:$($process.ExitCode)"
}
$record = [pscustomobject]@{
schemaVersion = 1
pid = [int]$process.Id
processStartTimeUtc = $process.StartTime.ToUniversalTime().ToString('o')
javaExecutable = [IO.Path]::GetFullPath($java)
shadowArtifact = $artifact
shadowArtifactSha256 = Get-ShadowSha256 $artifact
config = $config
configSha256 = Get-ShadowSha256 $config
deploymentDirectory = $deployment
preflightJson = $preflightPath
preflightSha256 = Get-ShadowSha256 $preflightPath
startedAtUtc = [DateTime]::UtcNow.ToString('o')
}
Write-ShadowJson -Value $record -Path $pidFile
return $record
}
catch {
if ($null -ne $process -and -not $process.HasExited) {
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
}
throw
}

View File

@@ -0,0 +1,58 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$PidFile,
[int]$WaitSeconds = 10
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
. (Join-Path $PSScriptRoot 'ShadowScriptCommon.ps1')
$pidPath = Resolve-ShadowFile $PidFile
$record = Read-ShadowJson $pidPath
$deployment = Resolve-ShadowDirectory $record.deploymentDirectory
if (-not (Test-ShadowPathWithin $pidPath $deployment)) {
throw 'PID_FILE_OUTSIDE_RECORDED_DEPLOYMENT'
}
$artifact = Resolve-ShadowFile $record.shadowArtifact
if ((Get-ShadowSha256 $artifact) -ne $record.shadowArtifactSha256) {
throw 'RECORDED_SHADOW_ARTIFACT_HASH_MISMATCH'
}
$process = Get-Process -Id ([int]$record.pid) -ErrorAction Stop
$actualExecutable = [IO.Path]::GetFullPath($process.Path)
$recordedExecutable = [IO.Path]::GetFullPath($record.javaExecutable)
if (-not $actualExecutable.Equals($recordedExecutable,
[StringComparison]::OrdinalIgnoreCase)) {
throw 'RECORDED_PROCESS_EXECUTABLE_MISMATCH'
}
$recordedStart = [DateTime]::Parse($record.processStartTimeUtc).ToUniversalTime()
$actualStart = $process.StartTime.ToUniversalTime()
if ([Math]::Abs(($actualStart - $recordedStart).TotalSeconds) -gt 1) {
throw 'RECORDED_PROCESS_START_TIME_MISMATCH'
}
$commandLine = Get-ShadowCommandLine $process.Id
if ([string]::IsNullOrWhiteSpace($commandLine) -or `
$commandLine -notlike '*-jar*' -or `
($commandLine -notlike "*$artifact*" -and `
$commandLine -notlike "*$([IO.Path]::GetFileName($artifact))*")) {
throw 'RECORDED_PROCESS_COMMAND_MISMATCH'
}
Stop-Process -Id $process.Id -Force -ErrorAction Stop
$deadline = [DateTime]::UtcNow.AddSeconds($WaitSeconds)
while ([DateTime]::UtcNow -lt $deadline -and `
$null -ne (Get-Process -Id $process.Id -ErrorAction SilentlyContinue)) {
Start-Sleep -Milliseconds 100
}
if ($null -ne (Get-Process -Id $process.Id -ErrorAction SilentlyContinue)) {
throw 'SHADOW_PROCESS_DID_NOT_STOP'
}
$archive = Join-Path $deployment `
("shadow.stopped.{0}.json" -f [DateTime]::UtcNow.ToString('yyyyMMddHHmmssfff'))
Move-Item -LiteralPath $pidPath -Destination $archive
return [pscustomobject]@{
status = 'STOPPED'
pid = [int]$record.pid
stoppedAtUtc = [DateTime]::UtcNow.ToString('o')
archivedPidRecord = $archive
}

View File

@@ -0,0 +1,194 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$StableManifest,
[Parameter(Mandatory = $true)][string]$ShadowArtifact,
[Parameter(Mandatory = $true)][string]$Config,
[Parameter(Mandatory = $true)][string]$DeploymentDirectory,
[Parameter(Mandatory = $true)][string]$PermissionReport,
[Parameter(Mandatory = $true)][string]$NetworkDenyReport,
[uri]$StatusUri = 'http://127.0.0.1:18091/shadow/status',
[long]$MinimumFreeBytes = 1073741824,
[int]$MaximumEvidenceAgeMinutes = 60,
[Parameter(Mandatory = $true)][string]$OutputJson
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
. (Join-Path $PSScriptRoot 'ShadowScriptCommon.ps1')
$checks = New-Object System.Collections.ArrayList
function Add-PreflightCheck {
param([string]$Name, [bool]$Passed, [string]$Detail)
[void]$checks.Add([pscustomobject]@{
name = $Name
status = if ($Passed) { 'PASS' } else { 'FAIL' }
detail = $Detail
})
}
$stablePath = Resolve-ShadowFile $StableManifest
$stable = Read-ShadowJson $stablePath
$artifact = Resolve-ShadowFile $ShadowArtifact
$configPath = Resolve-ShadowFile $Config
$deployment = Resolve-ShadowDirectory $DeploymentDirectory -Create
$permissions = Read-ShadowJson $PermissionReport
$network = Read-ShadowJson $NetworkDenyReport
$configValues = Read-ShadowYamlScalars $configPath
$stableAlive = $null -ne (Get-Process -Id ([int]$stable.pid) `
-ErrorAction SilentlyContinue)
Add-PreflightCheck 'STABLE_PROCESS_HEALTHY' $stableAlive `
"pid=$($stable.pid)"
$stableArtifactExists = Test-Path -LiteralPath $stable.artifactPath -PathType Leaf
$stableHashMatches = $stableArtifactExists -and `
((Get-ShadowSha256 $stable.artifactPath) -eq $stable.artifactSha256)
Add-PreflightCheck 'STABLE_ARTIFACT_HASH_UNCHANGED' $stableHashMatches `
$stable.artifactPath
$stableConfigMatches = $true
foreach ($stableConfig in @($stable.configuration)) {
$stableConfigMatches = $stableConfigMatches -and `
(Test-Path -LiteralPath $stableConfig.path -PathType Leaf) -and `
((Get-ShadowSha256 $stableConfig.path) -eq $stableConfig.sha256)
}
Add-PreflightCheck 'STABLE_CONFIG_HASHES_UNCHANGED' $stableConfigMatches `
"count=$(@($stable.configuration).Count)"
$artifactHash = Get-ShadowSha256 $artifact
$configHash = Get-ShadowSha256 $configPath
$artifactInDeployment = Test-ShadowPathWithin $artifact $deployment
$configInDeployment = Test-ShadowPathWithin $configPath $deployment
Add-PreflightCheck 'SHADOW_ARTIFACT_IN_DEPLOYMENT' $artifactInDeployment $artifact
Add-PreflightCheck 'SHADOW_CONFIG_IN_DEPLOYMENT' $configInDeployment $configPath
$stableDirectory = Split-Path -Parent $stable.artifactPath
$deploymentDistinct = -not ([IO.Path]::GetFullPath($deployment).Equals(
[IO.Path]::GetFullPath($stableDirectory),
[StringComparison]::OrdinalIgnoreCase))
Add-PreflightCheck 'DEPLOYMENT_DISTINCT_FROM_STABLE' $deploymentDistinct `
"stable=$stableDirectory;shadow=$deployment"
$permissionPassed = $permissions.status -eq 'PASS' -and `
[bool]$permissions.source.approvedViewsReadable -and `
([int]$permissions.source.productionWritePermissionCount -eq 0) -and `
[bool]$permissions.evidence.requiredWritesGranted
Add-PreflightCheck 'DATABASE_PERMISSIONS_APPROVED' $permissionPassed `
"source=$($permissions.source.login);evidence=$($permissions.evidence.login)"
$permissionFresh = $false
try {
$permissionAge = ([DateTime]::UtcNow -
[DateTime]::Parse($permissions.generatedAtUtc).ToUniversalTime()).TotalMinutes
$permissionFresh = $permissionAge -ge 0 -and `
$permissionAge -le $MaximumEvidenceAgeMinutes
}
catch { $permissionFresh = $false }
Add-PreflightCheck 'DATABASE_PERMISSION_REPORT_FRESH' $permissionFresh `
"maximumMinutes=$MaximumEvidenceAgeMinutes"
$configSource = $null
$configEvidence = $null
$configIdentityError = $null
try {
$configSource = Get-ShadowSqlServerJdbcIdentity `
$configValues['shadow.source.jdbc-url']
$configEvidence = Get-ShadowSqlServerJdbcIdentity `
$configValues['shadow.evidence.jdbc-url']
}
catch { $configIdentityError = $_.Exception.Message }
$sourceConfigMatches = $null -ne $configSource -and `
$configSource.server.Equals([string]$permissions.source.server,
[StringComparison]::OrdinalIgnoreCase) -and `
$configSource.database.Equals([string]$permissions.source.database,
[StringComparison]::OrdinalIgnoreCase) -and `
([string]$configValues['shadow.source.username']).Equals(
[string]$permissions.source.login,
[StringComparison]::OrdinalIgnoreCase)
Add-PreflightCheck 'CONFIG_SOURCE_IDENTITY_MATCHES_PERMISSION_REPORT' `
$sourceConfigMatches $(if ($configIdentityError) { $configIdentityError } `
else { "config=$($configSource.server)|$($configSource.database)" })
$evidenceConfigMatches = $null -ne $configEvidence -and `
$configEvidence.server.Equals([string]$permissions.evidence.server,
[StringComparison]::OrdinalIgnoreCase) -and `
$configEvidence.database.Equals([string]$permissions.evidence.database,
[StringComparison]::OrdinalIgnoreCase) -and `
([string]$configValues['shadow.evidence.username']).Equals(
[string]$permissions.evidence.login,
[StringComparison]::OrdinalIgnoreCase)
Add-PreflightCheck 'CONFIG_EVIDENCE_IDENTITY_MATCHES_PERMISSION_REPORT' `
$evidenceConfigMatches $(if ($configIdentityError) { $configIdentityError } `
else { "config=$($configEvidence.server)|$($configEvidence.database)" })
$sourceIdentity = ("{0}|{1}" -f $permissions.source.server,
$permissions.source.database).ToLowerInvariant()
$evidenceIdentity = ("{0}|{1}" -f $permissions.evidence.server,
$permissions.evidence.database).ToLowerInvariant()
$databasesDistinct = $sourceIdentity -ne $evidenceIdentity
Add-PreflightCheck 'SOURCE_EVIDENCE_DATABASES_DISTINCT' $databasesDistinct `
"source=$sourceIdentity;evidence=$evidenceIdentity"
$networkPassed = $network.status -eq 'PASS' -and `
[bool]$network.plcEgressDenied -and [bool]$network.wmsEgressDenied
Add-PreflightCheck 'PLC_WMS_EGRESS_DENIED' $networkPassed `
"plc=$($network.plcEgressDenied);wms=$($network.wmsEgressDenied)"
$networkFresh = $false
try {
$networkAge = ([DateTime]::UtcNow -
[DateTime]::Parse($network.generatedAtUtc).ToUniversalTime()).TotalMinutes
$networkFresh = $networkAge -ge 0 -and `
$networkAge -le $MaximumEvidenceAgeMinutes
}
catch { $networkFresh = $false }
Add-PreflightCheck 'NETWORK_DENY_REPORT_FRESH' $networkFresh `
"maximumMinutes=$MaximumEvidenceAgeMinutes"
$loopback = $StatusUri.Host -in @('127.0.0.1', 'localhost', '::1')
Add-PreflightCheck 'STATUS_ENDPOINT_LOOPBACK' $loopback $StatusUri.AbsoluteUri
$stablePorts = @($stable.listeningPorts | ForEach-Object { [int]$_.port })
$portDistinct = $stablePorts -notcontains [int]$StatusUri.Port
Add-PreflightCheck 'SHADOW_PORT_DISTINCT_FROM_STABLE' $portDistinct `
"shadow=$($StatusUri.Port);stable=$($stablePorts -join ',')"
$configText = Get-Content -LiteralPath $configPath -Raw
$outboundKeyPattern = '(?im)^\s*[^#\r\n]*(plc|wms)[^:\r\n]*(ip|host|port|url)\s*:'
$noOutboundEndpoints = -not [regex]::IsMatch($configText, $outboundKeyPattern)
Add-PreflightCheck 'NO_PLC_WMS_ENDPOINT_IN_CONFIG' $noOutboundEndpoints $configPath
$freeBytes = Get-ShadowFreeBytes $deployment
$spaceAvailable = $MinimumFreeBytes -ge 0 -and $freeBytes -ge $MinimumFreeBytes
Add-PreflightCheck 'OUTPUT_FREE_SPACE' $spaceAvailable `
"available=$freeBytes;required=$MinimumFreeBytes"
$pidFile = Join-Path $deployment 'shadow.pid.json'
$noShadowPidFile = -not (Test-Path -LiteralPath $pidFile)
Add-PreflightCheck 'NO_EXISTING_SHADOW_PID_FILE' $noShadowPidFile $pidFile
$failed = @($checks | Where-Object { $_.status -ne 'PASS' })
$result = [pscustomobject]@{
schemaVersion = 1
generatedAtUtc = [DateTime]::UtcNow.ToString('o')
success = $failed.Count -eq 0
checks = @($checks)
stable = $stable
shadowArtifact = [pscustomobject]@{ path = $artifact; sha256 = $artifactHash }
config = [pscustomobject]@{ path = $configPath; sha256 = $configHash }
deploymentDirectory = $deployment
pidFile = $pidFile
statusUri = $StatusUri.AbsoluteUri
sourceIdentity = $permissions.source
evidenceIdentity = $permissions.evidence
configuredSourceIdentity = $configSource
configuredEvidenceIdentity = $configEvidence
minimumFreeBytes = $MinimumFreeBytes
permissionReport = [pscustomobject]@{
path = Resolve-ShadowFile $PermissionReport
sha256 = Get-ShadowSha256 $PermissionReport
}
networkDenyReport = [pscustomobject]@{
path = Resolve-ShadowFile $NetworkDenyReport
sha256 = Get-ShadowSha256 $NetworkDenyReport
}
freeBytes = $freeBytes
}
Write-ShadowJson -Value $result -Path $OutputJson
if ($failed.Count -gt 0) {
throw "SHADOW_PREFLIGHT_FAILED:$($failed.name -join ',')"
}
return $result

View File

@@ -0,0 +1,254 @@
[CmdletBinding()]
param()
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
function Assert-True {
param([bool]$Condition, [string]$Message)
if (-not $Condition) { throw "ASSERTION_FAILED: $Message" }
}
function Assert-Throws {
param([scriptblock]$Action, [string]$Reason)
try {
& $Action
}
catch {
if ($_.Exception.Message -like "*$Reason*") { return }
throw "EXPECTED_$Reason, ACTUAL=$($_.Exception.Message)"
}
throw "EXPECTED_$Reason, BUT_COMMAND_SUCCEEDED"
}
$toolsDirectory = Split-Path -Parent $PSScriptRoot
$capture = Join-Path $toolsDirectory 'Capture-StableWcsManifest.ps1'
$preflight = Join-Path $toolsDirectory 'Test-Ft546ShadowPreflight.ps1'
$start = Join-Path $toolsDirectory 'Start-Ft546Shadow.ps1'
$stop = Join-Path $toolsDirectory 'Stop-Ft546Shadow.ps1'
$export = Join-Path $toolsDirectory 'Export-Ft546ShadowEvidence.ps1'
foreach ($script in @($capture, $preflight, $start, $stop, $export)) {
Assert-True (Test-Path -LiteralPath $script -PathType Leaf) `
"missing script $script"
}
$testRoot = Join-Path ([IO.Path]::GetTempPath()) `
("ft546-shadow-script-test-" + [guid]::NewGuid().ToString('N'))
$deployment = Join-Path $testRoot 'shadow-deployment'
$stableDirectory = Join-Path $testRoot 'stable-deployment'
$snapshot = Join-Path $testRoot 'evidence-snapshot'
$exportDirectory = Join-Path $testRoot 'evidence-export'
$startedPid = $null
try {
New-Item -ItemType Directory -Path $deployment, $stableDirectory, $snapshot `
-Force | Out-Null
$sourceFile = Join-Path $testRoot 'ShadowSleeper.java'
@'
public final class ShadowSleeper {
public static void main(String[] args) throws Exception {
Thread.sleep(300000L);
}
}
'@ | Set-Content -LiteralPath $sourceFile -Encoding ASCII
& javac -d $testRoot $sourceFile
if ($LASTEXITCODE -ne 0) { throw 'JAVAC_FAILED' }
$shadowArtifact = Join-Path $deployment 'ruoyi-wcs-shadow.jar'
& jar cfe $shadowArtifact ShadowSleeper -C $testRoot 'ShadowSleeper.class'
if ($LASTEXITCODE -ne 0) { throw 'JAR_FAILED' }
$stableArtifact = Join-Path $stableDirectory 'ruoyi-admin.jar'
Copy-Item -LiteralPath $shadowArtifact -Destination $stableArtifact
$stableConfig = Join-Path $stableDirectory 'application.yml'
'spring.profiles.active: stable' | Set-Content -LiteralPath $stableConfig `
-Encoding ASCII
$shadowConfig = Join-Path $deployment 'application-shadow-site.yml'
@'
server:
address: 127.0.0.1
port: 18091
shadow:
enabled: true
source:
jdbc-url: jdbc:sqlserver://source.example.invalid;databaseName=FT546-wcs
username: shadow_reader
evidence:
jdbc-url: jdbc:sqlserver://evidence.example.invalid;databaseName=FT546-shadow
username: shadow_writer
'@ | Set-Content -LiteralPath $shadowConfig -Encoding ASCII
$stableManifest = Join-Path $testRoot 'stable-manifest.json'
& $capture -StableArtifact $stableArtifact -StablePid $PID `
-Configuration $stableConfig -OutputJson $stableManifest | Out-Null
$stable = Get-Content -LiteralPath $stableManifest -Raw | ConvertFrom-Json
Assert-True ($stable.status -eq 'PASS') 'stable manifest must pass'
Assert-True ($stable.pid -eq $PID) 'stable PID must be captured exactly'
$permissionReport = Join-Path $testRoot 'permission-report.json'
[ordered]@{
generatedAtUtc = [DateTime]::UtcNow.ToString('o')
status = 'PASS'
source = [ordered]@{
server = 'source.example.invalid'; database = 'FT546-wcs'
login = 'shadow_reader'; approvedViewsReadable = $true
productionWritePermissionCount = 0
}
evidence = [ordered]@{
server = 'evidence.example.invalid'; database = 'FT546-shadow'
login = 'shadow_writer'; requiredWritesGranted = $true
}
} | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $permissionReport `
-Encoding UTF8
$networkReport = Join-Path $testRoot 'network-deny-report.json'
[ordered]@{
generatedAtUtc = [DateTime]::UtcNow.ToString('o')
status = 'PASS'; plcEgressDenied = $true; wmsEgressDenied = $true
} | ConvertTo-Json | Set-Content -LiteralPath $networkReport -Encoding UTF8
$wrongPermissionReport = Join-Path $testRoot 'wrong-permission-report.json'
$wrongPermissions = Get-Content -LiteralPath $permissionReport -Raw | `
ConvertFrom-Json
$wrongPermissions.source.database = 'WRONG_DATABASE'
$wrongPermissions | ConvertTo-Json -Depth 5 | Set-Content `
-LiteralPath $wrongPermissionReport -Encoding UTF8
Assert-Throws {
& $preflight -StableManifest $stableManifest `
-ShadowArtifact $shadowArtifact -Config $shadowConfig `
-DeploymentDirectory $deployment `
-PermissionReport $wrongPermissionReport `
-NetworkDenyReport $networkReport `
-StatusUri 'http://127.0.0.1:18091/shadow/status' `
-MinimumFreeBytes 0 `
-OutputJson (Join-Path $testRoot 'wrong-preflight.json') | Out-Null
} 'CONFIG_SOURCE_IDENTITY_MATCHES_PERMISSION_REPORT'
$stalePidFile = Join-Path $deployment 'shadow.pid.json'
[ordered]@{ pid = 2147483000 } | ConvertTo-Json | Set-Content `
-LiteralPath $stalePidFile -Encoding UTF8
Assert-Throws {
& $preflight -StableManifest $stableManifest `
-ShadowArtifact $shadowArtifact -Config $shadowConfig `
-DeploymentDirectory $deployment `
-PermissionReport $permissionReport `
-NetworkDenyReport $networkReport `
-StatusUri 'http://127.0.0.1:18091/shadow/status' `
-MinimumFreeBytes 0 `
-OutputJson (Join-Path $testRoot 'stale-pid-preflight.json') | Out-Null
} 'NO_EXISTING_SHADOW_PID_FILE'
Remove-Item -LiteralPath $stalePidFile
$preflightJson = Join-Path $testRoot 'shadow-preflight.json'
& $preflight -StableManifest $stableManifest `
-ShadowArtifact $shadowArtifact -Config $shadowConfig `
-DeploymentDirectory $deployment -PermissionReport $permissionReport `
-NetworkDenyReport $networkReport -StatusUri 'http://127.0.0.1:18091/shadow/status' `
-MinimumFreeBytes 0 -OutputJson $preflightJson | Out-Null
$preflightResult = Get-Content -LiteralPath $preflightJson -Raw | ConvertFrom-Json
Assert-True ([bool]$preflightResult.success) 'preflight must pass'
Assert-True ($preflightResult.stable.pid -eq $PID) `
'preflight must preserve stable PID'
Assert-Throws {
& $start -PreflightJson $preflightJson | Out-Null
} 'CONFIRM_READ_ONLY_SHADOW_REQUIRED'
$configBytes = [IO.File]::ReadAllBytes($shadowConfig)
Add-Content -LiteralPath $shadowConfig -Value '# changed after preflight' `
-Encoding ASCII
Assert-Throws {
& $start -ConfirmReadOnlyShadow -PreflightJson $preflightJson | Out-Null
} 'SHADOW_CONFIG_CHANGED_AFTER_PREFLIGHT'
[IO.File]::WriteAllBytes($shadowConfig, $configBytes)
$startResult = & $start -ConfirmReadOnlyShadow `
-PreflightJson $preflightJson
$startedPid = [int]$startResult.pid
Assert-True ($startedPid -ne $PID) 'shadow PID must differ from stable PID'
Assert-True ($null -ne (Get-Process -Id $startedPid -ErrorAction Stop)) `
'shadow process must be alive after start'
Assert-True ($null -ne (Get-Process -Id $PID -ErrorAction Stop)) `
'stable process must remain alive after shadow start'
$shadowCommandLine = (Get-CimInstance Win32_Process `
-Filter "ProcessId=$startedPid").CommandLine
Assert-True ($shadowCommandLine -like `
'*--shadow.evidence.run.artifact-sha256=*') `
'start must pass the verified artifact hash'
Assert-True ($shadowCommandLine -like `
'*--shadow.evidence.run.configuration-sha256=*') `
'start must pass the verified configuration hash'
Assert-True ($shadowCommandLine -like `
'*--shadow.evidence.run.source-database=FT546-wcs*') `
'start must pass the verified source database'
Assert-True ($shadowCommandLine -like '*--server.address=127.0.0.1*') `
'start must force the preflight loopback address'
Assert-True ($shadowCommandLine -like '*--server.port=18091*') `
'start must force the preflight status port'
$pidFile = Join-Path $deployment 'shadow.pid.json'
& $stop -PidFile $pidFile | Out-Null
Assert-True ($null -eq (Get-Process -Id $startedPid -ErrorAction SilentlyContinue)) `
'recorded shadow PID must stop'
$startedPid = $null
Assert-True ($null -ne (Get-Process -Id $PID -ErrorAction Stop)) `
'stable process must remain alive after shadow stop'
$stableProcess = Get-Process -Id $PID
[ordered]@{
pid = $PID
processStartTimeUtc = $stableProcess.StartTime.ToUniversalTime().ToString('o')
javaExecutable = $stableProcess.Path
shadowArtifact = $shadowArtifact
shadowArtifactSha256 = (Get-FileHash -LiteralPath $shadowArtifact -Algorithm SHA256).Hash
deploymentDirectory = $deployment
} | ConvertTo-Json | Set-Content -LiteralPath $pidFile -Encoding UTF8
Assert-Throws { & $stop -PidFile $pidFile | Out-Null } `
'RECORDED_PROCESS_COMMAND_MISMATCH'
Assert-True ($null -ne (Get-Process -Id $PID -ErrorAction Stop)) `
'mismatched PID must never be stopped'
$requiredEvidence = @(
'cycles.csv', 'cycles.json', 'protocol-differences.csv',
'algorithm-differences.csv', 'insufficient-facts.csv',
'startup-checks.json'
)
foreach ($name in $requiredEvidence) {
"fixture:$name" | Set-Content -LiteralPath (Join-Path $snapshot $name) `
-Encoding UTF8
}
& $export -SnapshotDirectory $snapshot -OutputDirectory $exportDirectory `
-RunId ([guid]::NewGuid()) | Out-Null
Assert-True (Test-Path -LiteralPath (Join-Path $exportDirectory 'manifest.json')) `
'evidence manifest must exist'
Assert-True (Test-Path -LiteralPath (Join-Path $exportDirectory 'manifest.sha256')) `
'evidence manifest hash must exist'
$expectedManifestHash = ((Get-Content -LiteralPath `
(Join-Path $exportDirectory 'manifest.sha256')) -split '\s+')[0]
$actualManifestHash = (Get-FileHash -LiteralPath `
(Join-Path $exportDirectory 'manifest.json') -Algorithm SHA256).Hash
Assert-True ($expectedManifestHash -eq $actualManifestHash.ToLowerInvariant()) `
'evidence manifest hash must verify'
foreach ($name in $requiredEvidence) {
Assert-True (Test-Path -LiteralPath (Join-Path $exportDirectory $name)) `
"evidence export missing $name"
}
[pscustomobject]@{
status = 'PASS'
stablePid = $PID
preflight = $preflightJson
exportManifest = (Join-Path $exportDirectory 'manifest.json')
}
}
finally {
if ($null -ne $startedPid) {
Stop-Process -Id $startedPid -Force -ErrorAction SilentlyContinue
}
$resolvedTemp = [IO.Path]::GetFullPath($testRoot)
$tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath())
if ($resolvedTemp.StartsWith($tempRoot, [StringComparison]::OrdinalIgnoreCase) `
-and (Test-Path -LiteralPath $resolvedTemp)) {
Remove-Item -LiteralPath $resolvedTemp -Recurse -Force
}
}