Files
lserp_cs_6.0/插件库/Lskj.AgentBridge/Deployment/Invoke-LserpFieldReadOnlyValidation.ps1
2026-08-14 14:28:28 +08:00

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