840 lines
33 KiB
PowerShell
840 lines
33 KiB
PowerShell
[CmdletBinding()]
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$InputPath,
|
|
|
|
[Parameter(Mandatory = $true)]
|
|
[ValidatePattern('^[A-Fa-f0-9]{64}$')]
|
|
[string]$ExpectedInputSha256,
|
|
|
|
[Parameter(Mandatory = $true)][string]$OutputPath,
|
|
|
|
[string]$PreflightScriptPath = '',
|
|
|
|
[switch]$ValidateInputOnly
|
|
)
|
|
|
|
Set-StrictMode -Version 2.0
|
|
$ErrorActionPreference = 'Stop'
|
|
$strictUtf8 = [Text.UTF8Encoding]::new($false, $true)
|
|
$inputLock = $null
|
|
$preflightLock = $null
|
|
$outputFull = $null
|
|
$published = $false
|
|
$executionAttempted = $false
|
|
$safeSha256 = '^[0-9a-f]{64}$'
|
|
$safeCliVersion = '^[0-9]{1,4}\.[0-9]{1,4}\.[0-9]{1,4}$'
|
|
$safeModuleCode = '^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$'
|
|
|
|
function Throw-FieldValidationError([string]$Code) {
|
|
throw ('lserp_field_readonly_validation_failed:' + $Code)
|
|
}
|
|
|
|
function Test-ExactProperties([object]$Value, [string[]]$Expected) {
|
|
if ($null -eq $Value) { return $false }
|
|
$actual = @($Value.PSObject.Properties | ForEach-Object { $_.Name })
|
|
if ($actual.Count -ne $Expected.Count) { return $false }
|
|
foreach ($name in $Expected) {
|
|
if ($actual -cnotcontains $name) { return $false }
|
|
}
|
|
return $true
|
|
}
|
|
|
|
function Test-JsonArray([object]$Value) {
|
|
return $null -ne $Value -and $Value -is [array]
|
|
}
|
|
|
|
function Assert-NoReparseDirectoryChain([string]$Directory, [string]$Code) {
|
|
try {
|
|
$current = [IO.DirectoryInfo]::new([IO.Path]::GetFullPath($Directory))
|
|
while ($null -ne $current) {
|
|
if (-not $current.Exists -or
|
|
(($current.Attributes -band
|
|
[IO.FileAttributes]::ReparsePoint) -ne 0)) {
|
|
Throw-FieldValidationError $Code
|
|
}
|
|
$current = $current.Parent
|
|
}
|
|
}
|
|
catch {
|
|
if ($_.Exception.Message.StartsWith(
|
|
'lserp_field_readonly_validation_failed:')) { throw }
|
|
Throw-FieldValidationError $Code
|
|
}
|
|
}
|
|
|
|
function Resolve-RegularFile(
|
|
[string]$Path,
|
|
[long]$MaximumBytes,
|
|
[string]$Code
|
|
) {
|
|
try {
|
|
$full = [IO.Path]::GetFullPath($Path)
|
|
if (-not [IO.File]::Exists($full)) {
|
|
Throw-FieldValidationError $Code
|
|
}
|
|
$item = Get-Item -LiteralPath $full -Force
|
|
if ($item.Length -le 0 -or $item.Length -gt $MaximumBytes -or
|
|
(($item.Attributes -band
|
|
[IO.FileAttributes]::ReparsePoint) -ne 0)) {
|
|
Throw-FieldValidationError $Code
|
|
}
|
|
Assert-NoReparseDirectoryChain `
|
|
([IO.Path]::GetDirectoryName($full)) $Code
|
|
return $full
|
|
}
|
|
catch {
|
|
if ($_.Exception.Message.StartsWith(
|
|
'lserp_field_readonly_validation_failed:')) { throw }
|
|
Throw-FieldValidationError $Code
|
|
}
|
|
}
|
|
|
|
function Resolve-NewJsonPath([string]$Path, [string]$Code) {
|
|
try {
|
|
$full = [IO.Path]::GetFullPath($Path)
|
|
if ([IO.Path]::GetExtension($full) -ine '.json' -or
|
|
[IO.File]::Exists($full) -or
|
|
[IO.Directory]::Exists($full)) {
|
|
Throw-FieldValidationError $Code
|
|
}
|
|
$directory = [IO.Path]::GetDirectoryName($full)
|
|
Assert-NoReparseDirectoryChain $directory $Code
|
|
return $full
|
|
}
|
|
catch {
|
|
if ($_.Exception.Message.StartsWith(
|
|
'lserp_field_readonly_validation_failed:')) { throw }
|
|
Throw-FieldValidationError $Code
|
|
}
|
|
}
|
|
|
|
function Get-Sha256Hex([byte[]]$Bytes) {
|
|
$sha = [Security.Cryptography.SHA256]::Create()
|
|
try {
|
|
return ([BitConverter]::ToString(
|
|
$sha.ComputeHash($Bytes))).Replace('-', '').ToLowerInvariant()
|
|
}
|
|
finally { $sha.Dispose() }
|
|
}
|
|
|
|
function Get-StreamSha256([IO.Stream]$Stream) {
|
|
$position = $Stream.Position
|
|
$sha = [Security.Cryptography.SHA256]::Create()
|
|
try {
|
|
$Stream.Position = 0
|
|
return ([BitConverter]::ToString(
|
|
$sha.ComputeHash($Stream))).Replace('-', '').ToLowerInvariant()
|
|
}
|
|
finally {
|
|
$Stream.Position = $position
|
|
$sha.Dispose()
|
|
}
|
|
}
|
|
|
|
function Get-ScopedValueSha256([string]$Scope, [string]$Value) {
|
|
return Get-Sha256Hex ($strictUtf8.GetBytes(
|
|
'lserp-field-readonly-validation-v1|' + $Scope + '|' + $Value))
|
|
}
|
|
|
|
function Read-LockedUtf8Bytes(
|
|
[IO.FileStream]$Stream,
|
|
[long]$MaximumBytes,
|
|
[string]$Code
|
|
) {
|
|
try {
|
|
if ($Stream.Length -le 0 -or $Stream.Length -gt $MaximumBytes -or
|
|
$Stream.Length -gt [int]::MaxValue) {
|
|
Throw-FieldValidationError $Code
|
|
}
|
|
$bytes = New-Object byte[] ([int]$Stream.Length)
|
|
$Stream.Position = 0
|
|
$offset = 0
|
|
while ($offset -lt $bytes.Length) {
|
|
$read = $Stream.Read($bytes, $offset, $bytes.Length - $offset)
|
|
if ($read -le 0) { Throw-FieldValidationError $Code }
|
|
$offset += $read
|
|
}
|
|
if ($bytes.Length -ge 3 -and
|
|
$bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and
|
|
$bytes[2] -eq 0xBF) {
|
|
Throw-FieldValidationError ($Code + '_utf8_bom_forbidden')
|
|
}
|
|
return $bytes
|
|
}
|
|
catch {
|
|
if ($_.Exception.Message.StartsWith(
|
|
'lserp_field_readonly_validation_failed:')) { throw }
|
|
Throw-FieldValidationError $Code
|
|
}
|
|
}
|
|
|
|
function Assert-StrictJsonText([string]$Text) {
|
|
$textReader = New-Object IO.StringReader($Text)
|
|
$jsonReader = New-Object Newtonsoft.Json.JsonTextReader($textReader)
|
|
$jsonReader.DateParseHandling = [Newtonsoft.Json.DateParseHandling]::None
|
|
$jsonReader.SupportMultipleContent = $true
|
|
$stack = New-Object Collections.Stack
|
|
$rootValues = 0
|
|
try {
|
|
while ($jsonReader.Read()) {
|
|
$token = [string]$jsonReader.TokenType
|
|
if ($token -eq 'Comment') {
|
|
Throw-FieldValidationError 'input_json_comment_forbidden'
|
|
}
|
|
if ($token -eq 'StartObject' -or $token -eq 'StartArray') {
|
|
if ($stack.Count -eq 0) { $rootValues++ }
|
|
$names = if ($token -eq 'StartObject') {
|
|
New-Object 'Collections.Generic.HashSet[string]' `
|
|
([StringComparer]::Ordinal)
|
|
} else { $null }
|
|
$stack.Push([pscustomobject]@{
|
|
Kind = if ($token -eq 'StartObject') {
|
|
'object'
|
|
} else { 'array' }
|
|
Names = $names
|
|
})
|
|
continue
|
|
}
|
|
if ($token -eq 'EndObject' -or $token -eq 'EndArray') {
|
|
if ($stack.Count -eq 0) {
|
|
Throw-FieldValidationError 'input_json_nesting_invalid'
|
|
}
|
|
$expectedKind = if ($token -eq 'EndObject') {
|
|
'object'
|
|
} else { 'array' }
|
|
if ([string]$stack.Peek().Kind -cne $expectedKind) {
|
|
Throw-FieldValidationError 'input_json_nesting_invalid'
|
|
}
|
|
[void]$stack.Pop()
|
|
continue
|
|
}
|
|
if ($token -eq 'PropertyName') {
|
|
if ($stack.Count -eq 0 -or
|
|
[string]$stack.Peek().Kind -cne 'object' -or
|
|
-not $stack.Peek().Names.Add([string]$jsonReader.Value)) {
|
|
Throw-FieldValidationError `
|
|
'input_json_duplicate_property'
|
|
}
|
|
continue
|
|
}
|
|
if ($stack.Count -eq 0) { $rootValues++ }
|
|
}
|
|
if ($stack.Count -ne 0 -or $rootValues -ne 1) {
|
|
Throw-FieldValidationError 'input_json_root_invalid'
|
|
}
|
|
}
|
|
catch {
|
|
if ($_.Exception.Message.StartsWith(
|
|
'lserp_field_readonly_validation_failed:')) { throw }
|
|
Throw-FieldValidationError 'input_json_invalid'
|
|
}
|
|
finally {
|
|
$jsonReader.Close()
|
|
$textReader.Dispose()
|
|
}
|
|
|
|
$insideString = $false
|
|
$escaped = $false
|
|
for ($index = 0; $index -lt $Text.Length; $index++) {
|
|
$character = $Text[$index]
|
|
if ($insideString) {
|
|
if ($escaped) { $escaped = $false; continue }
|
|
if ($character -eq '\') { $escaped = $true; continue }
|
|
if ($character -eq '"') { $insideString = $false }
|
|
continue
|
|
}
|
|
if ($character -eq '"') { $insideString = $true; continue }
|
|
if ($character -ne ',') { continue }
|
|
$next = $index + 1
|
|
while ($next -lt $Text.Length -and
|
|
[char]::IsWhiteSpace($Text[$next])) { $next++ }
|
|
if ($next -lt $Text.Length -and
|
|
($Text[$next] -eq '}' -or $Text[$next] -eq ']')) {
|
|
Throw-FieldValidationError 'input_json_trailing_comma'
|
|
}
|
|
}
|
|
}
|
|
|
|
function Read-StrictRootObjectToken([string]$Text) {
|
|
$textReader = New-Object IO.StringReader($Text)
|
|
$jsonReader = New-Object Newtonsoft.Json.JsonTextReader($textReader)
|
|
$jsonReader.DateParseHandling = [Newtonsoft.Json.DateParseHandling]::None
|
|
try {
|
|
$token = [Newtonsoft.Json.Linq.JToken]::ReadFrom($jsonReader)
|
|
if ($token -isnot [Newtonsoft.Json.Linq.JObject]) {
|
|
Throw-FieldValidationError 'input_json_root_invalid'
|
|
}
|
|
Write-Output -NoEnumerate $token
|
|
}
|
|
catch {
|
|
if ($_.Exception.Message.StartsWith(
|
|
'lserp_field_readonly_validation_failed:')) { throw }
|
|
Throw-FieldValidationError 'input_json_invalid'
|
|
}
|
|
finally {
|
|
$jsonReader.Close()
|
|
$textReader.Dispose()
|
|
}
|
|
}
|
|
|
|
function Read-RawTopLevelString(
|
|
[Newtonsoft.Json.Linq.JObject]$Root,
|
|
[string]$Name,
|
|
[string]$Code
|
|
) {
|
|
$token = $Root.GetValue($Name, [StringComparison]::Ordinal)
|
|
if ($null -eq $token -or
|
|
$token.Type -ne [Newtonsoft.Json.Linq.JTokenType]::String) {
|
|
Throw-FieldValidationError $Code
|
|
}
|
|
return [string]$token.Value
|
|
}
|
|
|
|
function Assert-SafeText(
|
|
[object]$Raw,
|
|
[int]$MinimumLength,
|
|
[int]$MaximumLength,
|
|
[string]$Code
|
|
) {
|
|
if ($Raw -isnot [string]) { Throw-FieldValidationError $Code }
|
|
$value = [string]$Raw
|
|
if ($value.Length -lt $MinimumLength -or
|
|
$value.Length -gt $MaximumLength -or
|
|
$value -cne $value.Trim()) {
|
|
Throw-FieldValidationError $Code
|
|
}
|
|
foreach ($character in $value.ToCharArray()) {
|
|
if ([char]::IsControl($character)) {
|
|
Throw-FieldValidationError $Code
|
|
}
|
|
}
|
|
}
|
|
|
|
function Read-JsonInt32(
|
|
[object]$Raw,
|
|
[int]$Minimum,
|
|
[int]$Maximum,
|
|
[string]$Code
|
|
) {
|
|
if (($Raw -isnot [int] -and $Raw -isnot [long]) -or
|
|
[long]$Raw -lt $Minimum -or [long]$Raw -gt $Maximum) {
|
|
Throw-FieldValidationError $Code
|
|
}
|
|
return [int]$Raw
|
|
}
|
|
|
|
function Read-ApprovalUtc([object]$Raw, [string]$Code) {
|
|
if ($Raw -isnot [string]) { Throw-FieldValidationError $Code }
|
|
try {
|
|
$value = [DateTimeOffset]::ParseExact(
|
|
[string]$Raw,
|
|
"yyyy-MM-dd'T'HH:mm:ss'Z'",
|
|
[Globalization.CultureInfo]::InvariantCulture,
|
|
[Globalization.DateTimeStyles]::AssumeUniversal `
|
|
-bor [Globalization.DateTimeStyles]::AdjustToUniversal)
|
|
}
|
|
catch { Throw-FieldValidationError $Code }
|
|
if ($value.ToUniversalTime().ToString(
|
|
"yyyy-MM-dd'T'HH:mm:ss'Z'",
|
|
[Globalization.CultureInfo]::InvariantCulture) -cne
|
|
[string]$Raw) {
|
|
Throw-FieldValidationError $Code
|
|
}
|
|
return $value.ToUniversalTime()
|
|
}
|
|
|
|
function Write-NewUtf8File([string]$Path, [string]$Text) {
|
|
$stream = $null
|
|
$writer = $null
|
|
$created = $false
|
|
$succeeded = $false
|
|
$failure = $null
|
|
try {
|
|
$stream = [IO.File]::Open(
|
|
$Path,
|
|
[IO.FileMode]::CreateNew,
|
|
[IO.FileAccess]::Write,
|
|
[IO.FileShare]::None)
|
|
$created = $true
|
|
$writer = New-Object IO.StreamWriter($stream, $strictUtf8)
|
|
$writer.Write($Text)
|
|
$writer.Flush()
|
|
$stream.Flush($true)
|
|
$succeeded = $true
|
|
}
|
|
catch { $failure = $_ }
|
|
finally {
|
|
if ($null -ne $writer) { $writer.Dispose() }
|
|
elseif ($null -ne $stream) { $stream.Dispose() }
|
|
if ($created -and -not $succeeded -and [IO.File]::Exists($Path)) {
|
|
try { [IO.File]::Delete($Path) } catch { }
|
|
}
|
|
}
|
|
if ($null -ne $failure -or -not $succeeded) {
|
|
Throw-FieldValidationError 'output_publish_failed'
|
|
}
|
|
}
|
|
|
|
try {
|
|
$inputFull = Resolve-RegularFile $InputPath 256KB 'input_file_invalid'
|
|
$outputFull = Resolve-NewJsonPath $OutputPath 'output_path_invalid'
|
|
if ([string]::IsNullOrWhiteSpace($PreflightScriptPath)) {
|
|
$PreflightScriptPath = Join-Path $PSScriptRoot `
|
|
'Invoke-LserpReadOnlySessionPreflight.ps1'
|
|
}
|
|
$preflightFull = Resolve-RegularFile `
|
|
$PreflightScriptPath 2MB 'preflight_script_invalid'
|
|
if ([IO.Path]::GetFileName($preflightFull) -cne
|
|
'Invoke-LserpReadOnlySessionPreflight.ps1' -or
|
|
$inputFull -ieq $outputFull -or
|
|
$inputFull -ieq $preflightFull -or
|
|
$outputFull -ieq $preflightFull) {
|
|
Throw-FieldValidationError 'trusted_path_conflict'
|
|
}
|
|
|
|
$inputLock = [IO.File]::Open(
|
|
$inputFull,
|
|
[IO.FileMode]::Open,
|
|
[IO.FileAccess]::Read,
|
|
[IO.FileShare]::Read)
|
|
$inputBytes = Read-LockedUtf8Bytes `
|
|
$inputLock 256KB 'input_file_invalid'
|
|
$inputHash = Get-Sha256Hex $inputBytes
|
|
if ($inputHash -cne $ExpectedInputSha256.ToLowerInvariant()) {
|
|
Throw-FieldValidationError 'input_hash_mismatch'
|
|
}
|
|
try { $inputText = $strictUtf8.GetString($inputBytes) }
|
|
catch { Throw-FieldValidationError 'input_utf8_invalid' }
|
|
Assert-StrictJsonText $inputText
|
|
$rawDocument = Read-StrictRootObjectToken $inputText
|
|
try { $document = $inputText | ConvertFrom-Json }
|
|
catch { Throw-FieldValidationError 'input_json_invalid' }
|
|
|
|
$topLevelProperties = @(
|
|
'schemaVersion', 'evidenceType', 'validationStage', 'approved',
|
|
'approvedBy', 'approvedAtUtc', 'approvalExpiresAtUtc',
|
|
'expectedPreflightScriptSha256', 'cli', 'erp', 'session',
|
|
'rollout', 'moduleBindings', 'requirements',
|
|
'bridgeTimeoutMilliseconds', 'databaseCredentialsIncluded',
|
|
'readOnlyEvidenceOutputPath'
|
|
)
|
|
if (-not (Test-ExactProperties $document $topLevelProperties) -or
|
|
[string]$document.schemaVersion -cne '1.1' -or
|
|
[string]$document.evidenceType -cne
|
|
'lserp_field_readonly_validation_input' -or
|
|
([string]$document.validationStage) -cnotin @(
|
|
'discovery', 'final') -or
|
|
$document.approved -isnot [bool] -or
|
|
$document.approved -ne $true -or
|
|
$document.databaseCredentialsIncluded -isnot [bool] -or
|
|
$document.databaseCredentialsIncluded -ne $false) {
|
|
Throw-FieldValidationError 'input_contract_invalid'
|
|
}
|
|
|
|
Assert-SafeText $document.approvedBy 1 128 'approval_identity_invalid'
|
|
if ([string]$document.approvedBy -cmatch '(?i)^replace(?:_|$)') {
|
|
Throw-FieldValidationError 'approval_placeholder_forbidden'
|
|
}
|
|
$approvedAt = Read-ApprovalUtc `
|
|
(Read-RawTopLevelString `
|
|
$rawDocument 'approvedAtUtc' 'approval_time_invalid') `
|
|
'approval_time_invalid'
|
|
$approvalExpiresAt = Read-ApprovalUtc `
|
|
(Read-RawTopLevelString `
|
|
$rawDocument 'approvalExpiresAtUtc' 'approval_expiry_invalid') `
|
|
'approval_expiry_invalid'
|
|
$now = [DateTimeOffset]::UtcNow
|
|
if ($approvedAt -gt $now.AddMinutes(5) -or
|
|
$approvalExpiresAt -le $approvedAt -or
|
|
$approvalExpiresAt -gt $approvedAt.AddHours(24) -or
|
|
$approvalExpiresAt -le $now) {
|
|
Throw-FieldValidationError 'approval_window_invalid'
|
|
}
|
|
|
|
$expectedPreflightHash =
|
|
([string]$document.expectedPreflightScriptSha256).ToLowerInvariant()
|
|
if ($expectedPreflightHash -cnotmatch $safeSha256 -or
|
|
$expectedPreflightHash -ceq ('0' * 64)) {
|
|
Throw-FieldValidationError 'preflight_hash_invalid'
|
|
}
|
|
|
|
if (-not (Test-ExactProperties $document.cli @(
|
|
'path', 'version', 'sha256', 'signerThumbprint')) -or
|
|
-not (Test-ExactProperties $document.erp @(
|
|
'processId', 'sha256')) -or
|
|
-not (Test-ExactProperties $document.session @(
|
|
'databaseScopeFingerprint', 'userId', 'userName', 'accountBook',
|
|
'subSystemId', 'expectedIsAdministrator')) -or
|
|
-not (Test-ExactProperties $document.rollout @(
|
|
'customerId', 'policySha256')) -or
|
|
-not (Test-ExactProperties $document.requirements @(
|
|
'purchaseWorkflow', 'leaveWorkflow',
|
|
'diagnosisWorkflow'))) {
|
|
Throw-FieldValidationError 'input_nested_contract_invalid'
|
|
}
|
|
|
|
Assert-SafeText $document.cli.path 1 1024 'cli_path_invalid'
|
|
if ([string]$document.cli.path -cnotmatch
|
|
'(?i)(?:^|[\\/])lserp-agent-cli\.exe$' -or
|
|
[string]$document.cli.path -cmatch '(?i)replace(?:_|[\\/])') {
|
|
Throw-FieldValidationError 'cli_path_invalid'
|
|
}
|
|
$cliVersion = [string]$document.cli.version
|
|
$cliHash = ([string]$document.cli.sha256).ToLowerInvariant()
|
|
$signerThumbprint = [string]$document.cli.signerThumbprint
|
|
if ($cliVersion -cnotmatch $safeCliVersion -or
|
|
$cliHash -cnotmatch $safeSha256 -or
|
|
$cliHash -ceq ('0' * 64) -or
|
|
$signerThumbprint -cnotmatch '^[A-F0-9]{40}$' -or
|
|
$signerThumbprint -ceq ('0' * 40)) {
|
|
Throw-FieldValidationError 'cli_identity_invalid'
|
|
}
|
|
|
|
$erpProcessId = Read-JsonInt32 `
|
|
$document.erp.processId 1 2147483647 'erp_process_id_invalid'
|
|
$erpHash = ([string]$document.erp.sha256).ToLowerInvariant()
|
|
if ($erpHash -cnotmatch $safeSha256 -or
|
|
$erpHash -ceq ('0' * 64)) {
|
|
Throw-FieldValidationError 'erp_hash_invalid'
|
|
}
|
|
|
|
$databaseScope =
|
|
([string]$document.session.databaseScopeFingerprint).ToLowerInvariant()
|
|
if ($databaseScope -cnotmatch $safeSha256 -or
|
|
$databaseScope -ceq ('0' * 64) -or
|
|
$document.session.expectedIsAdministrator -isnot [bool]) {
|
|
Throw-FieldValidationError 'session_scope_invalid'
|
|
}
|
|
foreach ($property in @(
|
|
'userId', 'userName', 'accountBook', 'subSystemId')) {
|
|
Assert-SafeText `
|
|
$document.session.$property 1 256 'session_scope_invalid'
|
|
if ([string]$document.session.$property -cmatch
|
|
'(?i)^replace(?:_|$)') {
|
|
Throw-FieldValidationError 'session_scope_placeholder_forbidden'
|
|
}
|
|
}
|
|
|
|
Assert-SafeText $document.rollout.customerId 1 128 `
|
|
'rollout_identity_invalid'
|
|
$rolloutCustomerId = [string]$document.rollout.customerId
|
|
$rolloutPolicyHash =
|
|
([string]$document.rollout.policySha256).ToLowerInvariant()
|
|
if ($rolloutCustomerId -cnotmatch
|
|
'^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$' -or
|
|
$rolloutCustomerId -cmatch '(?i)^replace(?:_|$)' -or
|
|
$rolloutPolicyHash -cnotmatch $safeSha256 -or
|
|
$rolloutPolicyHash -ceq ('0' * 64)) {
|
|
Throw-FieldValidationError 'rollout_identity_invalid'
|
|
}
|
|
|
|
foreach ($name in @(
|
|
'purchaseWorkflow', 'leaveWorkflow', 'diagnosisWorkflow')) {
|
|
if ($document.requirements.$name -isnot [bool]) {
|
|
Throw-FieldValidationError 'workflow_requirements_invalid'
|
|
}
|
|
}
|
|
$requirePurchase = [bool]$document.requirements.purchaseWorkflow
|
|
$requireLeave = [bool]$document.requirements.leaveWorkflow
|
|
$requireDiagnosis = [bool]$document.requirements.diagnosisWorkflow
|
|
if ($requireDiagnosis -and
|
|
-not [bool]$document.session.expectedIsAdministrator) {
|
|
Throw-FieldValidationError `
|
|
'diagnosis_requires_expected_administrator'
|
|
}
|
|
|
|
if (-not (Test-JsonArray $document.moduleBindings)) {
|
|
Throw-FieldValidationError 'module_bindings_invalid'
|
|
}
|
|
$rawBindings = @($document.moduleBindings)
|
|
if ($rawBindings.Count -lt 1 -or $rawBindings.Count -gt 16) {
|
|
Throw-FieldValidationError 'module_bindings_invalid'
|
|
}
|
|
$pairSet = New-Object 'Collections.Generic.HashSet[string]' `
|
|
([StringComparer]::Ordinal)
|
|
$moduleSet = New-Object 'Collections.Generic.HashSet[string]' `
|
|
([StringComparer]::OrdinalIgnoreCase)
|
|
$moduleCodes = New-Object System.Collections.Generic.List[string]
|
|
$normalizedBindings = New-Object System.Collections.Generic.List[string]
|
|
$roleCounts = [ordered]@{
|
|
purchase = 0
|
|
leave = 0
|
|
diagnosis = 0
|
|
support = 0
|
|
}
|
|
foreach ($binding in $rawBindings) {
|
|
if (-not (Test-ExactProperties $binding @('role', 'moduleCode')) -or
|
|
$binding.role -isnot [string] -or
|
|
$binding.moduleCode -isnot [string]) {
|
|
Throw-FieldValidationError 'module_binding_contract_invalid'
|
|
}
|
|
$role = [string]$binding.role
|
|
$moduleCode = [string]$binding.moduleCode
|
|
if ($role -cnotin @('purchase', 'leave', 'diagnosis', 'support') -or
|
|
$moduleCode -cnotmatch $safeModuleCode -or
|
|
$moduleCode -cmatch '(?i)^replace(?:_|$)' -or
|
|
-not $pairSet.Add($role + '|' + $moduleCode.ToUpperInvariant())) {
|
|
Throw-FieldValidationError 'module_binding_contract_invalid'
|
|
}
|
|
$roleCounts[$role] = [int]$roleCounts[$role] + 1
|
|
$normalizedBindings.Add($role + '|' + $moduleCode)
|
|
if ($moduleSet.Add($moduleCode)) { $moduleCodes.Add($moduleCode) }
|
|
}
|
|
if ($moduleCodes.Count -lt 1 -or $moduleCodes.Count -gt 16 -or
|
|
($requirePurchase -and [int]$roleCounts.purchase -eq 0) -or
|
|
($requireLeave -and [int]$roleCounts.leave -eq 0) -or
|
|
($requireDiagnosis -and [int]$roleCounts.diagnosis -eq 0)) {
|
|
Throw-FieldValidationError 'required_module_role_missing'
|
|
}
|
|
if ([string]$document.validationStage -ceq 'final') {
|
|
if (-not ($requirePurchase -or $requireLeave -or $requireDiagnosis) -or
|
|
([int]$roleCounts.purchase -gt 0) -ne $requirePurchase -or
|
|
([int]$roleCounts.leave -gt 0) -ne $requireLeave -or
|
|
([int]$roleCounts.diagnosis -gt 0) -ne $requireDiagnosis) {
|
|
Throw-FieldValidationError 'final_workflow_role_mismatch'
|
|
}
|
|
}
|
|
|
|
$bridgeTimeout = Read-JsonInt32 `
|
|
$document.bridgeTimeoutMilliseconds 1000 300000 `
|
|
'bridge_timeout_invalid'
|
|
Assert-SafeText $document.readOnlyEvidenceOutputPath 1 1024 `
|
|
'readonly_evidence_output_path_invalid'
|
|
$readOnlyEvidenceOutputPath =
|
|
[string]$document.readOnlyEvidenceOutputPath
|
|
if ($readOnlyEvidenceOutputPath -cnotmatch '(?i)\.json$' -or
|
|
$readOnlyEvidenceOutputPath -cmatch '(?i)replace(?:_|[\\/])') {
|
|
Throw-FieldValidationError 'readonly_evidence_output_path_invalid'
|
|
}
|
|
|
|
$preflightLock = [IO.File]::Open(
|
|
$preflightFull,
|
|
[IO.FileMode]::Open,
|
|
[IO.FileAccess]::Read,
|
|
[IO.FileShare]::Read)
|
|
$actualPreflightHash = Get-StreamSha256 $preflightLock
|
|
if ($actualPreflightHash -cne $expectedPreflightHash) {
|
|
Throw-FieldValidationError 'preflight_hash_mismatch'
|
|
}
|
|
|
|
$orderedBindings = $normalizedBindings.ToArray()
|
|
[Array]::Sort($orderedBindings, [StringComparer]::Ordinal)
|
|
$moduleBindingFingerprint = Get-ScopedValueSha256 `
|
|
'module-bindings' ($orderedBindings -join "`n")
|
|
$sessionApprovalFingerprint = Get-ScopedValueSha256 `
|
|
'session-approval' (@(
|
|
$databaseScope,
|
|
[string]$document.session.userId,
|
|
[string]$document.session.userName,
|
|
[string]$document.session.accountBook,
|
|
[string]$document.session.subSystemId,
|
|
([string][bool]$document.session.expectedIsAdministrator)
|
|
) -join "`n")
|
|
$handoffContractSha256 = Get-ScopedValueSha256 `
|
|
'preflight-handoff' (@(
|
|
$expectedPreflightHash,
|
|
[string]$document.cli.path,
|
|
$cliVersion,
|
|
$cliHash,
|
|
$signerThumbprint,
|
|
[string]$erpProcessId,
|
|
$erpHash,
|
|
$databaseScope,
|
|
[string]$document.session.userId,
|
|
[string]$document.session.userName,
|
|
[string]$document.session.accountBook,
|
|
[string]$document.session.subSystemId,
|
|
([string][bool]$document.session.expectedIsAdministrator),
|
|
$rolloutCustomerId,
|
|
$rolloutPolicyHash,
|
|
($moduleCodes.ToArray() -join ','),
|
|
([string]$requirePurchase),
|
|
([string]$requireLeave),
|
|
([string]$requireDiagnosis),
|
|
[string]$bridgeTimeout,
|
|
$readOnlyEvidenceOutputPath
|
|
) -join "`n")
|
|
|
|
$preflightEvidenceHash = $null
|
|
if (-not $ValidateInputOnly) {
|
|
if ($PSVersionTable.PSVersion -lt [Version]'5.1' -or
|
|
[string]$PSVersionTable.PSEdition -ne 'Desktop' -or
|
|
[string]::IsNullOrWhiteSpace($env:SystemRoot)) {
|
|
Throw-FieldValidationError `
|
|
'windows_powershell_51_required_for_execution'
|
|
}
|
|
$preflightEvidenceFull = Resolve-NewJsonPath `
|
|
$readOnlyEvidenceOutputPath 'readonly_evidence_output_path_invalid'
|
|
foreach ($trustedPath in @(
|
|
$inputFull, $outputFull, $preflightFull)) {
|
|
if ($preflightEvidenceFull -ieq $trustedPath) {
|
|
Throw-FieldValidationError 'trusted_path_conflict'
|
|
}
|
|
}
|
|
$preflightArguments = @{
|
|
CliPath = [string]$document.cli.path
|
|
ExpectedCliVersion = $cliVersion
|
|
ExpectedCliSha256 = $cliHash
|
|
ExpectedSignerThumbprint = $signerThumbprint
|
|
ErpProcessId = $erpProcessId
|
|
ExpectedErpSha256 = $erpHash
|
|
ExpectedDatabaseScopeFingerprint = $databaseScope
|
|
ExpectedUserId = [string]$document.session.userId
|
|
ExpectedUserName = [string]$document.session.userName
|
|
ExpectedAccountBook = [string]$document.session.accountBook
|
|
ExpectedSubSystemId = [string]$document.session.subSystemId
|
|
ExpectedIsAdministrator =
|
|
[bool]$document.session.expectedIsAdministrator
|
|
ExpectedRolloutCustomerId = $rolloutCustomerId
|
|
ExpectedRolloutPolicySha256 = $rolloutPolicyHash
|
|
ModuleCodes = $moduleCodes.ToArray()
|
|
OutputPath = $preflightEvidenceFull
|
|
BridgeTimeoutMilliseconds = $bridgeTimeout
|
|
}
|
|
if ($requirePurchase) {
|
|
$preflightArguments.RequirePurchaseWorkflow = $true
|
|
}
|
|
if ($requireLeave) {
|
|
$preflightArguments.RequireLeaveWorkflow = $true
|
|
}
|
|
if ($requireDiagnosis) {
|
|
$preflightArguments.RequireDiagnosisWorkflow = $true
|
|
}
|
|
$executionAttempted = $true
|
|
$preflightOutput = @(& $preflightFull @preflightArguments)
|
|
if ($preflightOutput.Count -ne 1 -or
|
|
$preflightOutput[0] -isnot [string] -or
|
|
[IO.Path]::GetFullPath([string]$preflightOutput[0]) -ine
|
|
$preflightEvidenceFull) {
|
|
Throw-FieldValidationError 'preflight_output_invalid'
|
|
}
|
|
$evidenceFull = Resolve-RegularFile `
|
|
$preflightEvidenceFull 4MB 'preflight_evidence_invalid'
|
|
$evidenceLock = $null
|
|
try {
|
|
$evidenceLock = [IO.File]::Open(
|
|
$evidenceFull,
|
|
[IO.FileMode]::Open,
|
|
[IO.FileAccess]::Read,
|
|
[IO.FileShare]::Read)
|
|
$evidenceBytes = Read-LockedUtf8Bytes `
|
|
$evidenceLock 4MB 'preflight_evidence_invalid'
|
|
try { $evidenceText = $strictUtf8.GetString($evidenceBytes) }
|
|
catch { Throw-FieldValidationError 'preflight_evidence_invalid' }
|
|
Assert-StrictJsonText $evidenceText
|
|
try { $evidence = $evidenceText | ConvertFrom-Json }
|
|
catch { Throw-FieldValidationError 'preflight_evidence_invalid' }
|
|
if ([string]$evidence.schemaVersion -cne '1.5' -or
|
|
[string]$evidence.evidenceType -cne
|
|
'lserp_readonly_session_preflight' -or
|
|
$evidence.passed -ne $true -or
|
|
$evidence.readOnlySessionReady -ne $true -or
|
|
$evidence.productionWriteAuthorized -ne $false -or
|
|
-not (Test-ExactProperties $evidence.cli @(
|
|
'component', 'version', 'protocolVersion',
|
|
'bridgeOnly', 'databaseDirectAccess', 'sessionSource',
|
|
'sha256', 'signerThumbprint')) -or
|
|
[string]$evidence.cli.component -cne 'lserp-agent-cli' -or
|
|
[string]$evidence.cli.version -cne $cliVersion -or
|
|
[string]$evidence.cli.protocolVersion -cne '1.0' -or
|
|
$evidence.cli.bridgeOnly -ne $true -or
|
|
$evidence.cli.databaseDirectAccess -ne $false -or
|
|
[string]$evidence.cli.sessionSource -cne
|
|
'current_logged_in_erp_process' -or
|
|
[string]$evidence.cli.sha256 -cne $cliHash -or
|
|
[string]$evidence.cli.signerThumbprint -cne
|
|
$signerThumbprint) {
|
|
Throw-FieldValidationError 'preflight_evidence_invalid'
|
|
}
|
|
$preflightEvidenceHash = Get-Sha256Hex $evidenceBytes
|
|
}
|
|
finally {
|
|
if ($null -ne $evidenceLock) { $evidenceLock.Dispose() }
|
|
}
|
|
}
|
|
|
|
if ((Get-StreamSha256 $inputLock) -cne $inputHash) {
|
|
Throw-FieldValidationError 'input_changed_during_validation'
|
|
}
|
|
if ((Get-StreamSha256 $preflightLock) -cne $expectedPreflightHash) {
|
|
Throw-FieldValidationError 'preflight_changed_during_validation'
|
|
}
|
|
|
|
$report = [ordered]@{
|
|
schemaVersion = '1.1'
|
|
evidenceType = 'lserp_field_readonly_validation_handoff'
|
|
generatedAtUtc = [DateTime]::UtcNow.ToString('o')
|
|
passed = $true
|
|
validationStage = [string]$document.validationStage
|
|
inputSha256 = $inputHash
|
|
approvedBySha256 = Get-ScopedValueSha256 `
|
|
'approved-by' ([string]$document.approvedBy)
|
|
approvedAtUtc = $approvedAt.ToString(
|
|
"yyyy-MM-dd'T'HH:mm:ss'Z'",
|
|
[Globalization.CultureInfo]::InvariantCulture)
|
|
approvalExpiresAtUtc = $approvalExpiresAt.ToString(
|
|
"yyyy-MM-dd'T'HH:mm:ss'Z'",
|
|
[Globalization.CultureInfo]::InvariantCulture)
|
|
expectedPreflightScriptSha256 = $expectedPreflightHash
|
|
handoffContractSha256 = $handoffContractSha256
|
|
runtimeCli = [ordered]@{
|
|
component = 'lserp-agent-cli'
|
|
version = $cliVersion
|
|
sha256 = $cliHash
|
|
signerThumbprint = $signerThumbprint
|
|
}
|
|
sessionApprovalFingerprint = $sessionApprovalFingerprint
|
|
erpProcessId = $erpProcessId
|
|
expectedIsAdministrator =
|
|
[bool]$document.session.expectedIsAdministrator
|
|
moduleBindingFingerprint = $moduleBindingFingerprint
|
|
moduleBindingCount = $rawBindings.Count
|
|
uniqueModuleCount = $moduleCodes.Count
|
|
moduleRoleCounts = $roleCounts
|
|
requestedReadiness = [ordered]@{
|
|
purchaseWorkflow = $requirePurchase
|
|
leaveWorkflow = $requireLeave
|
|
diagnosisWorkflow = $requireDiagnosis
|
|
}
|
|
databaseCredentialsIncluded = $false
|
|
validationOnly = [bool]$ValidateInputOnly
|
|
executionAttempted = $executionAttempted
|
|
preflightEvidenceProduced = -not [bool]$ValidateInputOnly
|
|
preflightEvidenceSha256 = $preflightEvidenceHash
|
|
productionWriteAuthorized = $false
|
|
readOnlyBoundary = [ordered]@{
|
|
directDatabaseConnectionUsed = $false
|
|
databaseCredentialAccepted = $false
|
|
businessWriteAttempted = $false
|
|
rawSessionValuesEmitted = $false
|
|
rawModuleCodesEmitted = $false
|
|
preflightExecuteCommandAllowed = $false
|
|
}
|
|
checks = @(
|
|
[ordered]@{ code = 'strict_json'; passed = $true },
|
|
[ordered]@{ code = 'input_integrity'; passed = $true },
|
|
[ordered]@{ code = 'independent_approval_window'; passed = $true },
|
|
[ordered]@{ code = 'database_credentials_absent'; passed = $true },
|
|
[ordered]@{ code = 'exact_session_expectations'; passed = $true },
|
|
[ordered]@{ code = 'dynamic_module_role_bindings'; passed = $true },
|
|
[ordered]@{ code = 'preflight_source_integrity'; passed = $true },
|
|
[ordered]@{ code = 'readonly_parameter_handoff'; passed = $true }
|
|
)
|
|
note = '本报告证明经独立审核并锁定哈希的 PID、会话范围和动态模块角色已完整交给只读预检;它不包含数据库凭据,不自动信任 Bridge 自报范围,也不授权任何业务写入。'
|
|
}
|
|
Write-NewUtf8File `
|
|
$outputFull (($report | ConvertTo-Json -Depth 8) +
|
|
[Environment]::NewLine)
|
|
$published = $true
|
|
}
|
|
finally {
|
|
if ($null -ne $preflightLock) { $preflightLock.Dispose() }
|
|
if ($null -ne $inputLock) { $inputLock.Dispose() }
|
|
if (-not $published -and $null -ne $outputFull -and
|
|
[IO.File]::Exists($outputFull)) {
|
|
try { [IO.File]::Delete($outputFull) } catch { }
|
|
}
|
|
}
|
|
|
|
Write-Output $outputFull
|