1609 lines
63 KiB
PowerShell
1609 lines
63 KiB
PowerShell
[CmdletBinding()]
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$CliPath,
|
|
|
|
[Parameter(Mandatory = $true)]
|
|
[ValidatePattern('^[A-Fa-f0-9]{64}$')]
|
|
[string]$ExpectedCliSha256,
|
|
|
|
[Parameter(Mandatory = $true)]
|
|
[ValidatePattern('^[0-9]{1,4}\.[0-9]{1,4}\.[0-9]{1,4}$')]
|
|
[string]$ExpectedCliVersion,
|
|
|
|
[Parameter(Mandatory = $true)]
|
|
[ValidatePattern('^[A-Fa-f0-9]{40}$')]
|
|
[string]$ExpectedSignerThumbprint,
|
|
|
|
[Parameter(Mandatory = $true)]
|
|
[ValidateRange(1, 2147483647)]
|
|
[int]$ErpProcessId,
|
|
|
|
[Parameter(Mandatory = $true)]
|
|
[ValidatePattern('^[A-Fa-f0-9]{64}$')]
|
|
[string]$ExpectedErpSha256,
|
|
|
|
[Parameter(Mandatory = $true)]
|
|
[ValidatePattern('^[A-Fa-f0-9]{64}$')]
|
|
[string]$ExpectedDatabaseScopeFingerprint,
|
|
|
|
[Parameter(Mandatory = $true)]
|
|
[ValidateLength(1, 256)]
|
|
[string]$ExpectedUserId,
|
|
|
|
[Parameter(Mandatory = $true)]
|
|
[ValidateLength(1, 256)]
|
|
[string]$ExpectedUserName,
|
|
|
|
[Parameter(Mandatory = $true)]
|
|
[ValidateLength(1, 256)]
|
|
[string]$ExpectedAccountBook,
|
|
|
|
[Parameter(Mandatory = $true)]
|
|
[ValidateLength(1, 256)]
|
|
[string]$ExpectedSubSystemId,
|
|
|
|
[Parameter(Mandatory = $true)]
|
|
[bool]$ExpectedIsAdministrator,
|
|
|
|
[Parameter(Mandatory = $true)]
|
|
[ValidatePattern('^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$')]
|
|
[string]$ExpectedRolloutCustomerId,
|
|
|
|
[Parameter(Mandatory = $true)]
|
|
[ValidatePattern('^[A-Fa-f0-9]{64}$')]
|
|
[string]$ExpectedRolloutPolicySha256,
|
|
|
|
[Parameter(Mandatory = $true)]
|
|
[ValidateCount(1, 16)]
|
|
[string[]]$ModuleCodes,
|
|
|
|
[Parameter(Mandatory = $true)][string]$OutputPath,
|
|
|
|
[switch]$RequirePurchaseWorkflow,
|
|
[switch]$RequireLeaveWorkflow,
|
|
[switch]$RequireDiagnosisWorkflow,
|
|
|
|
[ValidateRange(1000, 300000)]
|
|
[int]$BridgeTimeoutMilliseconds = 180000
|
|
)
|
|
|
|
Set-StrictMode -Version 2.0
|
|
$ErrorActionPreference = 'Stop'
|
|
if ($PSVersionTable.PSVersion -lt [Version]'5.1' -or
|
|
[string]$PSVersionTable.PSEdition -ne 'Desktop' -or
|
|
[string]::IsNullOrWhiteSpace($env:SystemRoot)) {
|
|
throw 'lserp_readonly_session_preflight_failed:windows_powershell_51_required'
|
|
}
|
|
|
|
$utf8 = [Text.UTF8Encoding]::new($false, $true)
|
|
$maximumResponseCharacters = 4 * 1024 * 1024
|
|
$cliLock = $null
|
|
$erpLock = $null
|
|
$workingDirectory = $null
|
|
$outputFull = $null
|
|
$published = $false
|
|
$safeModuleCode = '^[A-Za-z0-9_.:-]{1,64}$'
|
|
$safeCommandName = '^[a-z0-9][a-z0-9_.:-]{0,127}$'
|
|
$safeVersion = '^[0-9]+(?:\.[0-9]+){0,3}$'
|
|
$safeCorrelationId = '^[A-Za-z0-9_.:-]{8,128}$'
|
|
$safeSha256 = '^[a-f0-9]{64}$'
|
|
$expectedAdministratorText = if ($ExpectedIsAdministrator) {
|
|
'true'
|
|
} else { 'false' }
|
|
|
|
function Throw-PreflightError([string]$Code) {
|
|
throw ('lserp_readonly_session_preflight_failed:' + $Code)
|
|
}
|
|
|
|
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-PreflightError $Code
|
|
}
|
|
$current = $current.Parent
|
|
}
|
|
}
|
|
catch {
|
|
if ($_.Exception.Message.StartsWith(
|
|
'lserp_readonly_session_preflight_failed:')) { throw }
|
|
Throw-PreflightError $Code
|
|
}
|
|
}
|
|
|
|
function Resolve-RegularFile(
|
|
[string]$Path,
|
|
[long]$MaximumBytes,
|
|
[string]$Code
|
|
) {
|
|
try {
|
|
$full = [IO.Path]::GetFullPath($Path)
|
|
if (-not [IO.File]::Exists($full)) { Throw-PreflightError $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-PreflightError $Code
|
|
}
|
|
Assert-NoReparseDirectoryChain `
|
|
([IO.Path]::GetDirectoryName($full)) $Code
|
|
return $full
|
|
}
|
|
catch {
|
|
if ($_.Exception.Message.StartsWith(
|
|
'lserp_readonly_session_preflight_failed:')) { throw }
|
|
Throw-PreflightError $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-PreflightError $Code
|
|
}
|
|
$directory = [IO.Path]::GetDirectoryName($full)
|
|
Assert-NoReparseDirectoryChain $directory $Code
|
|
return $full
|
|
}
|
|
catch {
|
|
if ($_.Exception.Message.StartsWith(
|
|
'lserp_readonly_session_preflight_failed:')) { throw }
|
|
Throw-PreflightError $Code
|
|
}
|
|
}
|
|
|
|
function New-RestrictedWorkingDirectory {
|
|
$path = $null
|
|
try {
|
|
$localApplicationData = [Environment]::GetFolderPath(
|
|
[Environment+SpecialFolder]::LocalApplicationData)
|
|
if ([string]::IsNullOrWhiteSpace($localApplicationData)) {
|
|
Throw-PreflightError 'restricted_working_root_invalid'
|
|
}
|
|
$root = Join-Path $localApplicationData `
|
|
'Langsu\Lserp\ReadOnlySessionPreflight'
|
|
[IO.Directory]::CreateDirectory($root) | Out-Null
|
|
Assert-NoReparseDirectoryChain $root 'restricted_working_root_invalid'
|
|
$path = Join-Path $root ('run-' + [Guid]::NewGuid().ToString('N'))
|
|
[IO.Directory]::CreateDirectory($path) | Out-Null
|
|
|
|
$currentUser = [Security.Principal.WindowsIdentity]::GetCurrent().User
|
|
$localSystem = [Security.Principal.SecurityIdentifier]::new(
|
|
[Security.Principal.WellKnownSidType]::LocalSystemSid,
|
|
$null)
|
|
$security = New-Object Security.AccessControl.DirectorySecurity
|
|
$security.SetOwner($currentUser)
|
|
$security.SetAccessRuleProtection($true, $false)
|
|
$inheritance = [Security.AccessControl.InheritanceFlags]::ContainerInherit `
|
|
-bor [Security.AccessControl.InheritanceFlags]::ObjectInherit
|
|
$propagation = [Security.AccessControl.PropagationFlags]::None
|
|
$allow = [Security.AccessControl.AccessControlType]::Allow
|
|
foreach ($identity in @($currentUser, $localSystem)) {
|
|
$rule = [Security.AccessControl.FileSystemAccessRule]::new(
|
|
$identity,
|
|
[Security.AccessControl.FileSystemRights]::FullControl,
|
|
$inheritance,
|
|
$propagation,
|
|
$allow)
|
|
$security.AddAccessRule($rule)
|
|
}
|
|
[IO.Directory]::SetAccessControl($path, $security)
|
|
$verified = [IO.Directory]::GetAccessControl($path)
|
|
if (-not $verified.AreAccessRulesProtected) {
|
|
Throw-PreflightError 'restricted_working_acl_invalid'
|
|
}
|
|
return $path
|
|
}
|
|
catch {
|
|
if ($null -ne $path -and [IO.Directory]::Exists($path)) {
|
|
try { [IO.Directory]::Delete($path, $true) } catch { }
|
|
}
|
|
if ($_.Exception.Message.StartsWith(
|
|
'lserp_readonly_session_preflight_failed:')) { throw }
|
|
Throw-PreflightError 'restricted_working_acl_invalid'
|
|
}
|
|
}
|
|
|
|
function ConvertTo-WindowsProcessArgument([string]$Value) {
|
|
if ($null -eq $Value -or $Value.Length -eq 0) { return '""' }
|
|
if (-not [Text.RegularExpressions.Regex]::IsMatch($Value, '[\s"]')) {
|
|
return $Value
|
|
}
|
|
$builder = New-Object Text.StringBuilder
|
|
[void]$builder.Append([char]34)
|
|
$slashes = 0
|
|
foreach ($character in $Value.ToCharArray()) {
|
|
if ([int]$character -eq 92) {
|
|
$slashes++
|
|
continue
|
|
}
|
|
if ([int]$character -eq 34) {
|
|
for ($index = 0; $index -lt (($slashes * 2) + 1); $index++) {
|
|
[void]$builder.Append([char]92)
|
|
}
|
|
[void]$builder.Append([char]34)
|
|
}
|
|
else {
|
|
for ($index = 0; $index -lt $slashes; $index++) {
|
|
[void]$builder.Append([char]92)
|
|
}
|
|
[void]$builder.Append($character)
|
|
}
|
|
$slashes = 0
|
|
}
|
|
for ($index = 0; $index -lt ($slashes * 2); $index++) {
|
|
[void]$builder.Append([char]92)
|
|
}
|
|
[void]$builder.Append([char]34)
|
|
return $builder.ToString()
|
|
}
|
|
|
|
function Test-ExactProperties([object]$Value, [string[]]$Expected) {
|
|
if ($null -eq $Value) { return $false }
|
|
$names = @($Value.PSObject.Properties | ForEach-Object { $_.Name })
|
|
if ($names.Count -ne $Expected.Count) { return $false }
|
|
foreach ($name in $Expected) {
|
|
if ($names -cnotcontains $name) { return $false }
|
|
}
|
|
return $true
|
|
}
|
|
|
|
function Test-JsonArray([object]$Value) {
|
|
return $null -ne $Value -and $Value -is [array]
|
|
}
|
|
|
|
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-PreflightError $Code
|
|
}
|
|
return [int]$Raw
|
|
}
|
|
|
|
function Assert-SafeDisplayText(
|
|
[object]$Raw,
|
|
[int]$MinimumLength,
|
|
[int]$MaximumLength,
|
|
[string]$Code
|
|
) {
|
|
if ($null -eq $Raw -or $Raw -isnot [string]) {
|
|
Throw-PreflightError $Code
|
|
}
|
|
$value = [string]$Raw
|
|
if ($value.Length -lt $MinimumLength -or
|
|
$value.Length -gt $MaximumLength) {
|
|
Throw-PreflightError $Code
|
|
}
|
|
foreach ($character in $value.ToCharArray()) {
|
|
if ([char]::IsControl($character)) { Throw-PreflightError $Code }
|
|
}
|
|
}
|
|
|
|
function Assert-ExpectedSessionValue([string]$Value, [string]$Code) {
|
|
Assert-SafeDisplayText $Value 1 256 $Code
|
|
if ([string]::IsNullOrWhiteSpace($Value) -or
|
|
$Value -cne $Value.Trim()) {
|
|
Throw-PreflightError $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-ScopedValueSha256([string]$Scope, [string]$Value) {
|
|
return Get-Sha256Hex ($utf8.GetBytes(
|
|
'lserp-readonly-preflight-v1|' + $Scope + '|' + $Value))
|
|
}
|
|
|
|
function Read-CliEnvelope([string]$Text, [int]$ExitCode) {
|
|
try {
|
|
if ([string]::IsNullOrWhiteSpace($Text) -or
|
|
$Text.Length -gt $maximumResponseCharacters) {
|
|
Throw-PreflightError 'cli_response_invalid'
|
|
}
|
|
$document = $Text | ConvertFrom-Json
|
|
if ($null -eq $document -or
|
|
$null -eq $document.ok -or
|
|
$document.ok -isnot [bool] -or
|
|
([string]$document.correlationId) -cnotmatch $safeCorrelationId) {
|
|
Throw-PreflightError 'cli_response_invalid'
|
|
}
|
|
if ($document.ok) {
|
|
if ($ExitCode -ne 0 -or
|
|
-not (Test-ExactProperties $document `
|
|
@('ok', 'correlationId', 'data')) -or
|
|
$null -eq $document.data) {
|
|
Throw-PreflightError 'cli_response_invalid'
|
|
}
|
|
}
|
|
else {
|
|
if ($ExitCode -eq 0 -or
|
|
-not (Test-ExactProperties $document `
|
|
@('ok', 'correlationId', 'error')) -or
|
|
-not (Test-ExactProperties $document.error `
|
|
@('code', 'message', 'exitCode')) -or
|
|
([string]$document.error.code) -cnotmatch `
|
|
'^[a-z0-9_.-]{1,128}$' -or
|
|
[int]$document.error.exitCode -ne $ExitCode) {
|
|
Throw-PreflightError 'cli_response_invalid'
|
|
}
|
|
}
|
|
return $document
|
|
}
|
|
catch {
|
|
if ($_.Exception.Message.StartsWith(
|
|
'lserp_readonly_session_preflight_failed:')) { throw }
|
|
Throw-PreflightError 'cli_response_invalid'
|
|
}
|
|
}
|
|
|
|
function Assert-TrustedCliUnchanged {
|
|
$actual = (Get-FileHash -LiteralPath $script:cliFull `
|
|
-Algorithm SHA256).Hash.ToLowerInvariant()
|
|
if ($actual -cne $script:expectedCliHash) {
|
|
Throw-PreflightError 'cli_hash_changed'
|
|
}
|
|
}
|
|
|
|
function Read-ExactErpProcessIdentity {
|
|
$process = $null
|
|
try {
|
|
$process = Get-Process -Id $ErpProcessId -ErrorAction Stop
|
|
if ($process.HasExited) {
|
|
Throw-PreflightError 'erp_process_identity_invalid'
|
|
}
|
|
$executablePath = [string]$process.MainModule.FileName
|
|
$startTimeUtcTicks = $process.StartTime.ToUniversalTime().Ticks
|
|
if ([string]::IsNullOrWhiteSpace($executablePath) -or
|
|
[IO.Path]::GetFileName($executablePath) -cne 'Ls_ERP.exe') {
|
|
Throw-PreflightError 'erp_process_identity_invalid'
|
|
}
|
|
return [pscustomobject]@{
|
|
ExecutablePath = [IO.Path]::GetFullPath($executablePath)
|
|
StartTimeUtcTicks = $startTimeUtcTicks
|
|
}
|
|
}
|
|
catch {
|
|
if ($_.Exception.Message.StartsWith(
|
|
'lserp_readonly_session_preflight_failed:')) { throw }
|
|
Throw-PreflightError 'erp_process_identity_invalid'
|
|
}
|
|
finally {
|
|
if ($null -ne $process) { $process.Dispose() }
|
|
}
|
|
}
|
|
|
|
function Assert-TrustedErpUnchanged {
|
|
$current = Read-ExactErpProcessIdentity
|
|
if ($current.ExecutablePath -cne $script:erpFull -or
|
|
$current.StartTimeUtcTicks -ne $script:erpStartTimeUtcTicks) {
|
|
Throw-PreflightError 'erp_process_changed'
|
|
}
|
|
$actual = (Get-FileHash -LiteralPath $script:erpFull `
|
|
-Algorithm SHA256).Hash.ToLowerInvariant()
|
|
if ($actual -cne $script:expectedErpHash) {
|
|
Throw-PreflightError 'erp_hash_changed'
|
|
}
|
|
}
|
|
|
|
function Assert-ReadOnlyArguments([string[]]$Arguments) {
|
|
$identityShape = $Arguments.Count -eq 3 -and
|
|
$Arguments[0] -ceq 'version' -and
|
|
$Arguments[1] -ceq '--correlation-id' -and
|
|
$Arguments[2] -cmatch $safeCorrelationId
|
|
if ($identityShape) { return }
|
|
|
|
$pidText = [string]$ErpProcessId
|
|
$timeoutText = [string]$BridgeTimeoutMilliseconds
|
|
$readShape = $Arguments.Count -eq 20 -and
|
|
$Arguments[0] -ceq 'bridge' -and
|
|
$Arguments[1] -cin @('health', 'context', 'capabilities') -and
|
|
$Arguments[2] -ceq '--erp-process-id' -and
|
|
$Arguments[3] -ceq $pidText -and
|
|
$Arguments[4] -ceq '--expected-database-scope-fingerprint' -and
|
|
$Arguments[5] -ceq
|
|
$ExpectedDatabaseScopeFingerprint.ToLowerInvariant() -and
|
|
$Arguments[6] -ceq '--expected-user-id' -and
|
|
$Arguments[7] -ceq $ExpectedUserId -and
|
|
$Arguments[8] -ceq '--expected-user-name' -and
|
|
$Arguments[9] -ceq $ExpectedUserName -and
|
|
$Arguments[10] -ceq '--expected-account-book' -and
|
|
$Arguments[11] -ceq $ExpectedAccountBook -and
|
|
$Arguments[12] -ceq '--expected-subsystem-id' -and
|
|
$Arguments[13] -ceq $ExpectedSubSystemId -and
|
|
$Arguments[14] -ceq '--expected-is-administrator' -and
|
|
$Arguments[15] -ceq $expectedAdministratorText -and
|
|
$Arguments[16] -ceq '--timeout-ms' -and
|
|
$Arguments[17] -ceq $timeoutText -and
|
|
$Arguments[18] -ceq '--correlation-id' -and
|
|
$Arguments[19] -cmatch $safeCorrelationId
|
|
if ($readShape) { return }
|
|
|
|
$planShape = $Arguments.Count -eq 23 -and
|
|
$Arguments[0] -ceq 'bridge' -and
|
|
$Arguments[1] -ceq 'plan' -and
|
|
$Arguments[2] -ceq 'module.parameters' -and
|
|
$Arguments[3] -ceq '--input' -and
|
|
$Arguments[5] -ceq '--erp-process-id' -and
|
|
$Arguments[6] -ceq $pidText -and
|
|
$Arguments[7] -ceq '--expected-database-scope-fingerprint' -and
|
|
$Arguments[8] -ceq
|
|
$ExpectedDatabaseScopeFingerprint.ToLowerInvariant() -and
|
|
$Arguments[9] -ceq '--expected-user-id' -and
|
|
$Arguments[10] -ceq $ExpectedUserId -and
|
|
$Arguments[11] -ceq '--expected-user-name' -and
|
|
$Arguments[12] -ceq $ExpectedUserName -and
|
|
$Arguments[13] -ceq '--expected-account-book' -and
|
|
$Arguments[14] -ceq $ExpectedAccountBook -and
|
|
$Arguments[15] -ceq '--expected-subsystem-id' -and
|
|
$Arguments[16] -ceq $ExpectedSubSystemId -and
|
|
$Arguments[17] -ceq '--expected-is-administrator' -and
|
|
$Arguments[18] -ceq $expectedAdministratorText -and
|
|
$Arguments[19] -ceq '--timeout-ms' -and
|
|
$Arguments[20] -ceq $timeoutText -and
|
|
$Arguments[21] -ceq '--correlation-id' -and
|
|
$Arguments[22] -cmatch $safeCorrelationId
|
|
if ($planShape) {
|
|
$input = Resolve-RegularFile $Arguments[4] (4KB) `
|
|
'readonly_module_input_invalid'
|
|
$workingRoot = [IO.Path]::GetFullPath($script:workingDirectory)
|
|
$expectedPrefix = $workingRoot.TrimEnd(
|
|
[IO.Path]::DirectorySeparatorChar,
|
|
[IO.Path]::AltDirectorySeparatorChar) +
|
|
[IO.Path]::DirectorySeparatorChar
|
|
if (-not $input.StartsWith(
|
|
$expectedPrefix,
|
|
[StringComparison]::OrdinalIgnoreCase) -or
|
|
[IO.Path]::GetExtension($input) -ine '.json') {
|
|
Throw-PreflightError 'readonly_module_input_invalid'
|
|
}
|
|
return
|
|
}
|
|
Throw-PreflightError 'readonly_command_shape_denied'
|
|
}
|
|
|
|
function Invoke-TrustedReadOnlyCli([string[]]$Arguments, [string]$Step) {
|
|
Assert-ReadOnlyArguments $Arguments
|
|
Assert-TrustedCliUnchanged
|
|
$process = New-Object Diagnostics.Process
|
|
try {
|
|
$start = New-Object Diagnostics.ProcessStartInfo
|
|
$start.FileName = $script:cliFull
|
|
$start.WorkingDirectory = [IO.Path]::GetDirectoryName($script:cliFull)
|
|
$start.UseShellExecute = $false
|
|
$start.CreateNoWindow = $true
|
|
$start.RedirectStandardOutput = $true
|
|
$start.RedirectStandardError = $true
|
|
$start.RedirectStandardInput = $true
|
|
$start.StandardOutputEncoding = $utf8
|
|
$start.StandardErrorEncoding = $utf8
|
|
$start.Arguments = (($Arguments | ForEach-Object {
|
|
ConvertTo-WindowsProcessArgument ([string]$_)
|
|
}) -join ' ')
|
|
$process.StartInfo = $start
|
|
if (-not $process.Start()) {
|
|
Throw-PreflightError ($Step + '_process_start_failed')
|
|
}
|
|
$stdoutTask = $process.StandardOutput.ReadToEndAsync()
|
|
$stderrTask = $process.StandardError.ReadToEndAsync()
|
|
$process.StandardInput.Close()
|
|
$processTimeout = [Math]::Min(
|
|
330000,
|
|
$BridgeTimeoutMilliseconds + 30000)
|
|
if (-not $process.WaitForExit($processTimeout)) {
|
|
try { $process.Kill() } catch { }
|
|
Throw-PreflightError ($Step + '_timeout')
|
|
}
|
|
$process.WaitForExit()
|
|
$stdout = $stdoutTask.Result
|
|
$stderr = $stderrTask.Result
|
|
if (($process.ExitCode -eq 0 -and
|
|
([string]::IsNullOrWhiteSpace($stdout) -or
|
|
-not [string]::IsNullOrWhiteSpace($stderr))) -or
|
|
($process.ExitCode -ne 0 -and
|
|
([string]::IsNullOrWhiteSpace($stderr) -or
|
|
-not [string]::IsNullOrWhiteSpace($stdout)))) {
|
|
Throw-PreflightError ($Step + '_stream_contract_invalid')
|
|
}
|
|
$raw = if ($process.ExitCode -eq 0) { $stdout } else { $stderr }
|
|
$envelope = Read-CliEnvelope $raw $process.ExitCode
|
|
if (-not $envelope.ok) {
|
|
Throw-PreflightError (
|
|
$Step + '_' + [string]$envelope.error.code)
|
|
}
|
|
return $envelope
|
|
}
|
|
catch {
|
|
if ($_.Exception.Message.StartsWith(
|
|
'lserp_readonly_session_preflight_failed:')) { throw }
|
|
Throw-PreflightError ($Step + '_process_failed')
|
|
}
|
|
finally { $process.Dispose() }
|
|
}
|
|
|
|
function Invoke-ReadBridge([string]$Action, [string]$Step) {
|
|
if ($Action -cnotin @('health', 'context', 'capabilities')) {
|
|
Throw-PreflightError 'readonly_bridge_action_denied'
|
|
}
|
|
return Invoke-TrustedReadOnlyCli @(
|
|
'bridge', $Action,
|
|
'--erp-process-id', [string]$ErpProcessId,
|
|
'--expected-database-scope-fingerprint',
|
|
$ExpectedDatabaseScopeFingerprint.ToLowerInvariant(),
|
|
'--expected-user-id', $ExpectedUserId,
|
|
'--expected-user-name', $ExpectedUserName,
|
|
'--expected-account-book', $ExpectedAccountBook,
|
|
'--expected-subsystem-id', $ExpectedSubSystemId,
|
|
'--expected-is-administrator', $expectedAdministratorText,
|
|
'--timeout-ms', [string]$BridgeTimeoutMilliseconds,
|
|
'--correlation-id',
|
|
('readonly-' + $Action + '-' + [Guid]::NewGuid().ToString('N'))
|
|
) $Step
|
|
}
|
|
|
|
function Read-RuntimeCliIdentity([object]$Data) {
|
|
if (-not (Test-ExactProperties $Data @(
|
|
'component', 'version', 'protocolVersion', 'bridgeOnly',
|
|
'databaseDirectAccess', 'sessionSource')) -or
|
|
[string]$Data.component -cne 'lserp-agent-cli' -or
|
|
[string]$Data.version -cne $ExpectedCliVersion -or
|
|
[string]$Data.protocolVersion -cne '1.0' -or
|
|
$Data.bridgeOnly -isnot [bool] -or
|
|
$Data.bridgeOnly -ne $true -or
|
|
$Data.databaseDirectAccess -isnot [bool] -or
|
|
$Data.databaseDirectAccess -ne $false -or
|
|
[string]$Data.sessionSource -cne
|
|
'current_logged_in_erp_process') {
|
|
Throw-PreflightError 'cli_runtime_identity_invalid'
|
|
}
|
|
return [pscustomobject]@{
|
|
Component = [string]$Data.component
|
|
Version = [string]$Data.version
|
|
ProtocolVersion = [string]$Data.protocolVersion
|
|
BridgeOnly = [bool]$Data.bridgeOnly
|
|
DatabaseDirectAccess = [bool]$Data.databaseDirectAccess
|
|
SessionSource = [string]$Data.sessionSource
|
|
}
|
|
}
|
|
|
|
function Write-NewUtf8File([string]$Path, [string]$Text) {
|
|
$bytes = $utf8.GetBytes($Text)
|
|
$stream = [IO.FileStream]::new(
|
|
$Path,
|
|
[IO.FileMode]::CreateNew,
|
|
[IO.FileAccess]::Write,
|
|
[IO.FileShare]::None)
|
|
try {
|
|
$stream.Write($bytes, 0, $bytes.Length)
|
|
$stream.Flush()
|
|
}
|
|
finally { $stream.Dispose() }
|
|
}
|
|
|
|
function Invoke-ModuleParameterPlan([string]$ModuleCode, [int]$Index) {
|
|
$inputPath = Join-Path $script:workingDirectory (
|
|
'module-' + $Index.ToString('D2') + '.json')
|
|
$input = [ordered]@{ moduleCode = $ModuleCode }
|
|
Write-NewUtf8File $inputPath (
|
|
$input | ConvertTo-Json -Compress -Depth 4)
|
|
return Invoke-TrustedReadOnlyCli @(
|
|
'bridge', 'plan', 'module.parameters',
|
|
'--input', $inputPath,
|
|
'--erp-process-id', [string]$ErpProcessId,
|
|
'--expected-database-scope-fingerprint',
|
|
$ExpectedDatabaseScopeFingerprint.ToLowerInvariant(),
|
|
'--expected-user-id', $ExpectedUserId,
|
|
'--expected-user-name', $ExpectedUserName,
|
|
'--expected-account-book', $ExpectedAccountBook,
|
|
'--expected-subsystem-id', $ExpectedSubSystemId,
|
|
'--expected-is-administrator', $expectedAdministratorText,
|
|
'--timeout-ms', [string]$BridgeTimeoutMilliseconds,
|
|
'--correlation-id',
|
|
('readonly-module-' + [Guid]::NewGuid().ToString('N'))
|
|
) ('module_parameters_' + $Index.ToString('D2'))
|
|
}
|
|
|
|
function Assert-SafeContextModule([object]$Module, [string]$Code) {
|
|
if (-not (Test-ExactProperties $Module `
|
|
@('moduleCode', 'navigationCode', 'moduleName')) -or
|
|
([string]$Module.moduleCode) -cnotmatch $safeModuleCode -or
|
|
([string]$Module.navigationCode) -cnotmatch $safeModuleCode) {
|
|
Throw-PreflightError $Code
|
|
}
|
|
Assert-SafeDisplayText $Module.moduleName 1 128 $Code
|
|
}
|
|
|
|
function Read-SessionContext([object]$Data, [string]$Code) {
|
|
if (-not (Test-ExactProperties $Data @(
|
|
'userId', 'userName', 'accountBook', 'subSystemId',
|
|
'databaseScopeFingerprint', 'subSystemName',
|
|
'isAdministrator', 'activeModule', 'openModuleCount',
|
|
'openModulesTruncated', 'openModules'))) {
|
|
Throw-PreflightError $Code
|
|
}
|
|
foreach ($field in @('userId', 'userName', 'accountBook',
|
|
'subSystemId', 'subSystemName')) {
|
|
Assert-SafeDisplayText $Data.$field 1 256 $Code
|
|
}
|
|
$databaseScope = ([string]$Data.databaseScopeFingerprint).ToLowerInvariant()
|
|
if ($databaseScope -cnotmatch $safeSha256 -or
|
|
$databaseScope -cne
|
|
$ExpectedDatabaseScopeFingerprint.ToLowerInvariant() -or
|
|
$Data.isAdministrator -isnot [bool] -or
|
|
$Data.openModulesTruncated -isnot [bool]) {
|
|
Throw-PreflightError $Code
|
|
}
|
|
if (([string]$Data.userId) -cne $ExpectedUserId -or
|
|
([string]$Data.userName) -cne $ExpectedUserName -or
|
|
([string]$Data.accountBook) -cne $ExpectedAccountBook -or
|
|
([string]$Data.subSystemId) -cne $ExpectedSubSystemId -or
|
|
[bool]$Data.isAdministrator -ne $ExpectedIsAdministrator) {
|
|
Throw-PreflightError 'expected_session_scope_mismatch'
|
|
}
|
|
$openCount = 0
|
|
if (-not [int]::TryParse(
|
|
[string]$Data.openModuleCount,
|
|
[ref]$openCount) -or
|
|
$openCount -lt 0 -or $openCount -gt 10000) {
|
|
Throw-PreflightError $Code
|
|
}
|
|
$openModules = @($Data.openModules)
|
|
if ($openModules.Count -gt 50 -or
|
|
$openCount -lt $openModules.Count -or
|
|
[bool]$Data.openModulesTruncated -ne
|
|
($openCount -gt $openModules.Count)) {
|
|
Throw-PreflightError $Code
|
|
}
|
|
$navigationCodes = New-Object `
|
|
'System.Collections.Generic.HashSet[string]' `
|
|
([StringComparer]::OrdinalIgnoreCase)
|
|
foreach ($module in $openModules) {
|
|
Assert-SafeContextModule $module $Code
|
|
if (-not $navigationCodes.Add([string]$module.navigationCode)) {
|
|
Throw-PreflightError $Code
|
|
}
|
|
}
|
|
if ($null -ne $Data.activeModule) {
|
|
Assert-SafeContextModule $Data.activeModule $Code
|
|
if (-not $Data.openModulesTruncated -and
|
|
-not $navigationCodes.Contains(
|
|
[string]$Data.activeModule.navigationCode)) {
|
|
Throw-PreflightError $Code
|
|
}
|
|
}
|
|
return [pscustomobject]@{
|
|
UserId = [string]$Data.userId
|
|
UserName = [string]$Data.userName
|
|
AccountBook = [string]$Data.accountBook
|
|
SubSystemId = [string]$Data.subSystemId
|
|
SubSystemName = [string]$Data.subSystemName
|
|
DatabaseScopeFingerprint = $databaseScope
|
|
IsAdministrator = [bool]$Data.isAdministrator
|
|
ActiveModule = $Data.activeModule
|
|
OpenModuleCount = $openCount
|
|
}
|
|
}
|
|
|
|
function Test-SameSession([object]$First, [object]$Second) {
|
|
return $First.UserId -ceq $Second.UserId -and
|
|
$First.UserName -ceq $Second.UserName -and
|
|
$First.AccountBook -ceq $Second.AccountBook -and
|
|
$First.SubSystemId -ceq $Second.SubSystemId -and
|
|
$First.SubSystemName -ceq $Second.SubSystemName -and
|
|
$First.DatabaseScopeFingerprint -ceq
|
|
$Second.DatabaseScopeFingerprint -and
|
|
$First.IsAdministrator -eq $Second.IsAdministrator
|
|
}
|
|
|
|
function Read-Health([object]$Data) {
|
|
if (-not (Test-ExactProperties $Data @(
|
|
'status', 'protocolVersion', 'serverTimeUtc',
|
|
'commandCount', 'enabledCommandCount',
|
|
'operationalPolicy', 'rolloutPolicy', 'workflowUat')) -or
|
|
[string]$Data.status -cne 'ready' -or
|
|
[string]$Data.protocolVersion -cne '1.0' -or
|
|
-not (Test-ExactProperties $Data.rolloutPolicy @(
|
|
'configured', 'failClosed', 'customerId',
|
|
'databaseScopeFingerprint', 'sourceSha256',
|
|
'defaultAction', 'ruleCount')) -or
|
|
$Data.rolloutPolicy.configured -ne $true -or
|
|
$Data.rolloutPolicy.failClosed -ne $true -or
|
|
[string]$Data.rolloutPolicy.defaultAction -cne 'deny' -or
|
|
[string]$Data.rolloutPolicy.customerId -cne
|
|
$ExpectedRolloutCustomerId -or
|
|
([string]$Data.rolloutPolicy.databaseScopeFingerprint).ToLowerInvariant() `
|
|
-cne $ExpectedDatabaseScopeFingerprint.ToLowerInvariant() -or
|
|
([string]$Data.rolloutPolicy.sourceSha256).ToLowerInvariant() -cne
|
|
$ExpectedRolloutPolicySha256.ToLowerInvariant()) {
|
|
Throw-PreflightError 'bridge_health_invalid'
|
|
}
|
|
$total = 0
|
|
$enabled = 0
|
|
$ruleCount = 0
|
|
if (-not [int]::TryParse([string]$Data.commandCount, [ref]$total) -or
|
|
-not [int]::TryParse(
|
|
[string]$Data.enabledCommandCount,
|
|
[ref]$enabled) -or
|
|
-not [int]::TryParse(
|
|
[string]$Data.rolloutPolicy.ruleCount,
|
|
[ref]$ruleCount) -or
|
|
$total -lt 1 -or $total -gt 512 -or
|
|
$enabled -lt 1 -or $enabled -gt $total -or
|
|
$ruleCount -lt 1 -or $ruleCount -gt 128) {
|
|
Throw-PreflightError 'bridge_health_invalid'
|
|
}
|
|
return [pscustomobject]@{
|
|
CommandCount = $total
|
|
EnabledCommandCount = $enabled
|
|
RuleCount = $ruleCount
|
|
}
|
|
}
|
|
|
|
function Read-Capabilities([object]$Data) {
|
|
if (-not (Test-ExactProperties $Data @('commands'))) {
|
|
Throw-PreflightError 'bridge_capabilities_invalid'
|
|
}
|
|
$commands = @($Data.commands)
|
|
if ($commands.Count -lt 1 -or $commands.Count -gt 512) {
|
|
Throw-PreflightError 'bridge_capabilities_invalid'
|
|
}
|
|
$map = [Collections.Generic.Dictionary[string, object]]::new(
|
|
[StringComparer]::Ordinal)
|
|
foreach ($command in $commands) {
|
|
if (-not (Test-ExactProperties $command @(
|
|
'name', 'version', 'description', 'schemaVersion',
|
|
'inputSchema', 'risk', 'requiresConfirmation',
|
|
'requiresIdempotencyKey')) -or
|
|
([string]$command.name) -cnotmatch $safeCommandName -or
|
|
([string]$command.version) -cnotmatch $safeVersion -or
|
|
([string]$command.schemaVersion) -cnotmatch $safeVersion -or
|
|
([string]$command.risk) -cnotin @(
|
|
'read', 'draft', 'navigate', 'write', 'critical') -or
|
|
$command.requiresConfirmation -isnot [bool] -or
|
|
$command.requiresIdempotencyKey -isnot [bool] -or
|
|
$null -eq $command.inputSchema -or
|
|
$map.ContainsKey([string]$command.name)) {
|
|
Throw-PreflightError 'bridge_capabilities_invalid'
|
|
}
|
|
Assert-SafeDisplayText $command.description 1 512 `
|
|
'bridge_capabilities_invalid'
|
|
$map.Add([string]$command.name, $command)
|
|
}
|
|
return $map
|
|
}
|
|
|
|
function Test-CapabilityContract(
|
|
[object]$Actual,
|
|
[string]$Version,
|
|
[string]$SchemaVersion,
|
|
[string]$Risk,
|
|
[bool]$Confirmation,
|
|
[bool]$Idempotency
|
|
) {
|
|
return $null -ne $Actual -and
|
|
[string]$Actual.version -ceq $Version -and
|
|
[string]$Actual.schemaVersion -ceq $SchemaVersion -and
|
|
[string]$Actual.risk -ceq $Risk -and
|
|
[bool]$Actual.requiresConfirmation -eq $Confirmation -and
|
|
[bool]$Actual.requiresIdempotencyKey -eq $Idempotency
|
|
}
|
|
|
|
function Read-NativeExecutionProfile(
|
|
[object]$Profile,
|
|
[string]$Action,
|
|
[string]$ModuleKind
|
|
) {
|
|
if ($null -eq $Profile -or
|
|
$Profile.available -isnot [bool]) {
|
|
Throw-PreflightError 'module_native_execution_profile_invalid'
|
|
}
|
|
if ([bool]$Profile.available) {
|
|
if (-not (Test-ExactProperties $Profile @(
|
|
'available', 'nativeSaveFamily',
|
|
'profileFingerprint')) -or
|
|
([string]$Profile.profileFingerprint) -cnotmatch $safeSha256) {
|
|
Throw-PreflightError 'module_native_execution_profile_invalid'
|
|
}
|
|
$family = [string]$Profile.nativeSaveFamily
|
|
$allowedFamilies = if ($ModuleKind -ceq 'document') {
|
|
@(
|
|
'legacy.bill-save.p-bill-save-pr3',
|
|
'legacy.bill-save.p-bill-save-pr70'
|
|
)
|
|
} else {
|
|
@(
|
|
'legacy.base-save.p-base-save',
|
|
'legacy.base-save.p-base-save70'
|
|
)
|
|
}
|
|
if ($family -cnotin $allowedFamilies -or
|
|
($Action -ceq 'update' -and $ModuleKind -ceq 'document')) {
|
|
Throw-PreflightError 'module_native_execution_profile_invalid'
|
|
}
|
|
return [ordered]@{
|
|
available = $true
|
|
nativeSaveFamily = $family
|
|
profileFingerprint =
|
|
([string]$Profile.profileFingerprint).ToLowerInvariant()
|
|
code = $null
|
|
}
|
|
}
|
|
|
|
if (-not (Test-ExactProperties $Profile @('available', 'code')) -or
|
|
([string]$Profile.code) -cnotmatch '^[a-z0-9_.-]{1,128}$' -or
|
|
($Action -ceq 'update' -and $ModuleKind -ceq 'document' -and
|
|
[string]$Profile.code -cne
|
|
'dynamic_module_update_bill_unsupported')) {
|
|
Throw-PreflightError 'module_native_execution_profile_invalid'
|
|
}
|
|
return [ordered]@{
|
|
available = $false
|
|
nativeSaveFamily = $null
|
|
profileFingerprint = $null
|
|
code = [string]$Profile.code
|
|
}
|
|
}
|
|
|
|
function Assert-ExactParameterIds(
|
|
[object]$Raw,
|
|
[string[]]$Expected,
|
|
[string]$Code
|
|
) {
|
|
if (-not (Test-JsonArray $Raw)) {
|
|
Throw-PreflightError $Code
|
|
}
|
|
$actual = @($Raw)
|
|
$expectedValues = @($Expected)
|
|
if ($actual.Count -ne $expectedValues.Count) {
|
|
Throw-PreflightError $Code
|
|
}
|
|
for ($index = 0; $index -lt $actual.Count; $index++) {
|
|
if ($actual[$index] -isnot [string] -or
|
|
[string]$actual[$index] -cne [string]$expectedValues[$index]) {
|
|
Throw-PreflightError $Code
|
|
}
|
|
}
|
|
}
|
|
|
|
function Read-ParameterPayloadContract(
|
|
[object]$Payload,
|
|
[string]$ModuleKind,
|
|
[string]$ConfigurationFingerprint,
|
|
[object[]]$MasterParameters,
|
|
[object[]]$DetailParameters
|
|
) {
|
|
if (-not (Test-ExactProperties $Payload @(
|
|
'format', 'valueEncoding', 'moduleCodeRequired',
|
|
'configurationFingerprint', 'masterValuesRequired',
|
|
'detailRowsRequired', 'minimumDetailRows',
|
|
'maximumDetailRows', 'masterParameterIds',
|
|
'requiredMasterParameterIds', 'detailParameterIds',
|
|
'requiredDetailParameterIds', 'unknownParameterPolicy',
|
|
'duplicateParameterPolicy', 'lookupPolicy',
|
|
'fieldConstraintPolicy', 'configurationDriftPolicy')) -or
|
|
[string]$Payload.format -cne 'parameter_entries_v1' -or
|
|
[string]$Payload.valueEncoding -cne 'invariant_text' -or
|
|
$Payload.moduleCodeRequired -isnot [bool] -or
|
|
$Payload.moduleCodeRequired -ne $true -or
|
|
([string]$Payload.configurationFingerprint).ToLowerInvariant() `
|
|
-cne $ConfigurationFingerprint -or
|
|
$Payload.masterValuesRequired -isnot [bool] -or
|
|
$Payload.masterValuesRequired -ne $true -or
|
|
$Payload.detailRowsRequired -isnot [bool] -or
|
|
[string]$Payload.unknownParameterPolicy -cne 'reject' -or
|
|
[string]$Payload.duplicateParameterPolicy -cne 'reject' -or
|
|
[string]$Payload.lookupPolicy -cne
|
|
'server_resolve_unique_or_stop' -or
|
|
[string]$Payload.fieldConstraintPolicy -cne
|
|
'server_enforced_from_current_low_code_configuration' -or
|
|
[string]$Payload.configurationDriftPolicy -cne
|
|
'reject_and_replan') {
|
|
Throw-PreflightError 'module_parameter_payload_contract_invalid'
|
|
}
|
|
|
|
$document = $ModuleKind -ceq 'document'
|
|
$minimumRows = Read-JsonInt32 `
|
|
$Payload.minimumDetailRows 0 1 `
|
|
'module_parameter_payload_contract_invalid'
|
|
$maximumRows = Read-JsonInt32 `
|
|
$Payload.maximumDetailRows 0 1000 `
|
|
'module_parameter_payload_contract_invalid'
|
|
$expectedMinimumRows = if ($document) { 1 } else { 0 }
|
|
$expectedMaximumRows = if ($document) { 1000 } else { 0 }
|
|
if ([bool]$Payload.detailRowsRequired -ne $document -or
|
|
$minimumRows -ne $expectedMinimumRows -or
|
|
$maximumRows -ne $expectedMaximumRows) {
|
|
Throw-PreflightError 'module_parameter_payload_contract_invalid'
|
|
}
|
|
|
|
$editableMaster = @($MasterParameters | Where-Object {
|
|
$_.editable -eq $true
|
|
} | ForEach-Object { [string]$_.parameterId })
|
|
$requiredMaster = @($MasterParameters | Where-Object {
|
|
$_.required -eq $true
|
|
} | ForEach-Object { [string]$_.parameterId })
|
|
$editableDetail = @($DetailParameters | Where-Object {
|
|
$_.editable -eq $true
|
|
} | ForEach-Object { [string]$_.parameterId })
|
|
$requiredDetail = @($DetailParameters | Where-Object {
|
|
$_.required -eq $true
|
|
} | ForEach-Object { [string]$_.parameterId })
|
|
Assert-ExactParameterIds `
|
|
$Payload.masterParameterIds $editableMaster `
|
|
'module_parameter_payload_contract_invalid'
|
|
Assert-ExactParameterIds `
|
|
$Payload.requiredMasterParameterIds $requiredMaster `
|
|
'module_parameter_payload_contract_invalid'
|
|
Assert-ExactParameterIds `
|
|
$Payload.detailParameterIds $editableDetail `
|
|
'module_parameter_payload_contract_invalid'
|
|
Assert-ExactParameterIds `
|
|
$Payload.requiredDetailParameterIds $requiredDetail `
|
|
'module_parameter_payload_contract_invalid'
|
|
|
|
return [ordered]@{
|
|
format = 'parameter_entries_v1'
|
|
valueEncoding = 'invariant_text'
|
|
moduleCodeRequired = $true
|
|
masterValuesRequired = $true
|
|
detailRowsRequired = $document
|
|
minimumDetailRows = $minimumRows
|
|
maximumDetailRows = $maximumRows
|
|
masterParameterIdCount = $editableMaster.Count
|
|
requiredMasterParameterIdCount = $requiredMaster.Count
|
|
detailParameterIdCount = $editableDetail.Count
|
|
requiredDetailParameterIdCount = $requiredDetail.Count
|
|
unknownParameterPolicy = 'reject'
|
|
duplicateParameterPolicy = 'reject'
|
|
lookupPolicy = 'server_resolve_unique_or_stop'
|
|
fieldConstraintPolicy =
|
|
'server_enforced_from_current_low_code_configuration'
|
|
configurationDriftPolicy = 'reject_and_replan'
|
|
}
|
|
}
|
|
|
|
function Read-ModuleExecutionReadiness(
|
|
[object]$Available,
|
|
[object]$Command,
|
|
[object]$ReadinessCode,
|
|
[object]$Blocker,
|
|
[string]$Action,
|
|
[string]$ModuleKind,
|
|
[object]$NativeProfile
|
|
) {
|
|
$expectedCommand = if ($Action -ceq 'create') {
|
|
'module.record.create'
|
|
} else {
|
|
'module.record.resolve-update'
|
|
}
|
|
$expectedReadyCode = if ($Action -ceq 'create') {
|
|
'dynamic_module_write_ready'
|
|
} else {
|
|
'dynamic_module_update_ready'
|
|
}
|
|
if ($Available -isnot [bool] -or
|
|
$ReadinessCode -isnot [string] -or
|
|
[string]$ReadinessCode -cnotmatch '^[a-z0-9_.-]{1,128}$') {
|
|
Throw-PreflightError 'module_parameter_readiness_invalid'
|
|
}
|
|
|
|
if ([bool]$Available) {
|
|
if ($Command -isnot [string] -or
|
|
[string]$Command -cne $expectedCommand -or
|
|
[string]$ReadinessCode -cne $expectedReadyCode -or
|
|
$null -ne $Blocker -or
|
|
$NativeProfile.available -ne $true -or
|
|
($Action -ceq 'update' -and $ModuleKind -ceq 'document')) {
|
|
Throw-PreflightError 'module_parameter_readiness_invalid'
|
|
}
|
|
}
|
|
else {
|
|
if ($null -ne $Command -or
|
|
[string]$ReadinessCode -ceq $expectedReadyCode) {
|
|
Throw-PreflightError 'module_parameter_readiness_invalid'
|
|
}
|
|
Assert-SafeDisplayText `
|
|
$Blocker 1 512 'module_parameter_readiness_invalid'
|
|
}
|
|
|
|
if ($Action -ceq 'update' -and $ModuleKind -ceq 'document' -and
|
|
([bool]$Available -or
|
|
[string]$ReadinessCode -cne
|
|
'dynamic_module_update_bill_unsupported')) {
|
|
Throw-PreflightError 'module_parameter_readiness_invalid'
|
|
}
|
|
|
|
return [ordered]@{
|
|
available = [bool]$Available
|
|
blocked = -not [bool]$Available
|
|
command = if ($null -eq $Command) { $null } else {
|
|
[string]$Command
|
|
}
|
|
readinessCode = [string]$ReadinessCode
|
|
}
|
|
}
|
|
|
|
function Read-Parameter(
|
|
[object]$Parameter,
|
|
[string]$ExpectedScope,
|
|
[Collections.Generic.HashSet[string]]$Ids,
|
|
[hashtable]$Counts
|
|
) {
|
|
if (-not (Test-ExactProperties $Parameter @(
|
|
'parameterId', 'label', 'scope', 'valueType', 'valueFormat',
|
|
'controlTypeId', 'required', 'editable', 'inputSupported',
|
|
'inputMode', 'requiresDedicatedAdapter', 'hasDefault',
|
|
'requiresLookup', 'maximumEncodedBytes',
|
|
'maximumDecimalPlaces')) -or
|
|
([string]$Parameter.parameterId) -cnotmatch '^[md][a-f0-9]{16}$' -or
|
|
[string]$Parameter.scope -cne $ExpectedScope -or
|
|
([string]$Parameter.inputMode) -cnotin @(
|
|
'scalar', 'lookup-single', 'unsupported') -or
|
|
$Parameter.required -isnot [bool] -or
|
|
$Parameter.editable -isnot [bool] -or
|
|
$Parameter.inputSupported -isnot [bool] -or
|
|
$Parameter.requiresDedicatedAdapter -isnot [bool] -or
|
|
$Parameter.hasDefault -isnot [bool] -or
|
|
$Parameter.requiresLookup -isnot [bool] -or
|
|
-not $Ids.Add([string]$Parameter.parameterId)) {
|
|
Throw-PreflightError 'module_parameter_contract_invalid'
|
|
}
|
|
Assert-SafeDisplayText $Parameter.label 1 128 `
|
|
'module_parameter_contract_invalid'
|
|
if ($Parameter.valueType -isnot [string] -or
|
|
$Parameter.valueFormat -isnot [string] -or
|
|
(([string]$Parameter.valueType + '|' +
|
|
[string]$Parameter.valueFormat) -cnotin @(
|
|
'string|utf8-text',
|
|
'number|invariant-decimal',
|
|
'integer|invariant-integer',
|
|
'boolean|true-or-false',
|
|
'date|yyyy-MM-dd',
|
|
'local-date-time|yyyy-MM-ddTHH:mm:ss',
|
|
'local-date-time|yyyy-MM-ddTHH:mm',
|
|
'time|HH:mm:ss',
|
|
'time|HH:mm',
|
|
'year-month|yyyy-MM',
|
|
'local-date-half-day|yyyy-MM-dd|am-or-pm'
|
|
))) {
|
|
Throw-PreflightError 'module_parameter_contract_invalid'
|
|
}
|
|
[void](Read-JsonInt32 `
|
|
$Parameter.controlTypeId 0 100000 `
|
|
'module_parameter_contract_invalid')
|
|
if ($null -ne $Parameter.maximumEncodedBytes) {
|
|
[void](Read-JsonInt32 `
|
|
$Parameter.maximumEncodedBytes 1 1048576 `
|
|
'module_parameter_contract_invalid')
|
|
}
|
|
if ($null -ne $Parameter.maximumDecimalPlaces) {
|
|
[void](Read-JsonInt32 `
|
|
$Parameter.maximumDecimalPlaces 0 28 `
|
|
'module_parameter_contract_invalid')
|
|
}
|
|
|
|
$mode = [string]$Parameter.inputMode
|
|
if (($mode -ceq 'scalar' -and
|
|
(-not $Parameter.inputSupported -or
|
|
$Parameter.requiresDedicatedAdapter -or
|
|
$Parameter.requiresLookup)) -or
|
|
($mode -ceq 'lookup-single' -and
|
|
(-not $Parameter.inputSupported -or
|
|
$Parameter.requiresDedicatedAdapter -or
|
|
-not $Parameter.requiresLookup)) -or
|
|
($mode -ceq 'unsupported' -and
|
|
($Parameter.inputSupported -or $Parameter.editable -or
|
|
-not $Parameter.requiresDedicatedAdapter)) -or
|
|
($Parameter.required -and
|
|
(-not $Parameter.editable -or $Parameter.hasDefault)) -or
|
|
($null -ne $Parameter.maximumDecimalPlaces -and
|
|
[string]$Parameter.valueType -cne 'number')) {
|
|
Throw-PreflightError 'module_parameter_contract_invalid'
|
|
}
|
|
$Counts[$mode] = [int]$Counts[$mode] + 1
|
|
if ($Parameter.editable) { $Counts.editable++ }
|
|
if ($Parameter.required) { $Counts.required++ }
|
|
if ($Parameter.requiresDedicatedAdapter) { $Counts.dedicated++ }
|
|
}
|
|
|
|
function Read-ModuleParameterContract(
|
|
[object]$Data,
|
|
[string]$RequestedCode,
|
|
[object]$Session
|
|
) {
|
|
if (-not (Test-ExactProperties $Data @('plan'))) {
|
|
Throw-PreflightError 'module_parameter_plan_invalid'
|
|
}
|
|
$plan = $Data.plan
|
|
if (-not (Test-ExactProperties $plan @(
|
|
'planId', 'commandName', 'commandVersion', 'moduleCode',
|
|
'risk', 'createdAtUtc', 'expiresAtUtc', 'valid',
|
|
'executionAllowed', 'inputFingerprint', 'outcomeCode',
|
|
'title', 'preview', 'data', 'warnings')) -or
|
|
([string]$plan.planId) -cnotmatch '^[A-Fa-f0-9]{32}$' -or
|
|
[string]$plan.commandName -cne 'module.parameters' -or
|
|
[string]$plan.commandVersion -cne '1.1' -or
|
|
([string]$plan.moduleCode) -cnotmatch $safeModuleCode -or
|
|
[string]$plan.risk -cne 'read' -or
|
|
$plan.valid -ne $true -or
|
|
$plan.executionAllowed -ne $false -or
|
|
([string]$plan.inputFingerprint) -cnotmatch $safeSha256 -or
|
|
[string]$plan.outcomeCode -cne 'module_parameters_ready' -or
|
|
$null -ne $plan.preview -or
|
|
-not (Test-JsonArray $plan.warnings) -or
|
|
@($plan.warnings).Count -ne 0 -or
|
|
-not (Test-ExactProperties $plan.data @(
|
|
'title', 'outcomeCode', 'parameterContract')) -or
|
|
[string]$plan.data.outcomeCode -cne 'module_parameters_ready') {
|
|
Throw-PreflightError 'module_parameter_plan_invalid'
|
|
}
|
|
|
|
$contract = $plan.data.parameterContract
|
|
if (-not (Test-ExactProperties $contract @(
|
|
'schemaVersion', 'source', 'metadataTrust', 'moduleCode',
|
|
'moduleName', 'moduleKind', 'configurationFingerprint',
|
|
'sessionScope', 'masterParameterCount',
|
|
'detailParameterCount', 'masterParameters',
|
|
'detailParameters', 'nativeExecutionProfiles',
|
|
'payloadContract', 'genericWriteExecutionAvailable',
|
|
'writeExecutionBlocker', 'contractFingerprint',
|
|
'writeCommand', 'writeReadinessCode',
|
|
'genericUpdateExecutionAvailable', 'updateCommand',
|
|
'updateReadinessCode', 'updateExecutionBlocker')) -or
|
|
[string]$contract.schemaVersion -cne '1.1' -or
|
|
[string]$contract.source -cne
|
|
'current_erp_database_low_code_configuration' -or
|
|
[string]$contract.metadataTrust -cne 'untrusted_display_data' -or
|
|
[string]$contract.moduleCode -cne [string]$plan.moduleCode -or
|
|
([string]$contract.moduleCode) -cnotmatch $safeModuleCode -or
|
|
([string]$contract.moduleKind) -cnotin @(
|
|
'document', 'master_data') -or
|
|
([string]$contract.configurationFingerprint) -cnotmatch $safeSha256 -or
|
|
([string]$contract.contractFingerprint) -cnotmatch $safeSha256 -or
|
|
-not (Test-JsonArray $contract.masterParameters) -or
|
|
-not (Test-JsonArray $contract.detailParameters) -or
|
|
-not (Test-ExactProperties $contract.nativeExecutionProfiles @(
|
|
'create', 'update')) -or
|
|
$contract.genericWriteExecutionAvailable -isnot [bool] -or
|
|
$contract.genericUpdateExecutionAvailable -isnot [bool]) {
|
|
Throw-PreflightError 'module_parameter_contract_invalid'
|
|
}
|
|
Assert-SafeDisplayText $contract.moduleName 1 128 `
|
|
'module_parameter_contract_invalid'
|
|
if (-not (Test-ExactProperties $contract.sessionScope @(
|
|
'userId', 'accountBook', 'subSystemId',
|
|
'databaseScopeFingerprint')) -or
|
|
[string]$contract.sessionScope.userId -cne $Session.UserId -or
|
|
[string]$contract.sessionScope.accountBook -cne
|
|
$Session.AccountBook -or
|
|
[string]$contract.sessionScope.subSystemId -cne
|
|
$Session.SubSystemId -or
|
|
([string]$contract.sessionScope.databaseScopeFingerprint).ToLowerInvariant() `
|
|
-cne $Session.DatabaseScopeFingerprint) {
|
|
Throw-PreflightError 'module_parameter_scope_mismatch'
|
|
}
|
|
|
|
$masterCount = Read-JsonInt32 `
|
|
$contract.masterParameterCount 0 512 `
|
|
'module_parameter_contract_invalid'
|
|
$detailCount = Read-JsonInt32 `
|
|
$contract.detailParameterCount 0 512 `
|
|
'module_parameter_contract_invalid'
|
|
$masterParameters = @($contract.masterParameters)
|
|
$detailParameters = @($contract.detailParameters)
|
|
if ($masterCount + $detailCount -gt 512 -or
|
|
([string]$contract.moduleKind -ceq 'master_data' -and
|
|
$detailCount -ne 0) -or
|
|
$masterParameters.Count -ne $masterCount -or
|
|
$detailParameters.Count -ne $detailCount) {
|
|
Throw-PreflightError 'module_parameter_contract_invalid'
|
|
}
|
|
$ids = New-Object 'System.Collections.Generic.HashSet[string]' `
|
|
([StringComparer]::Ordinal)
|
|
$counts = @{
|
|
scalar = 0
|
|
'lookup-single' = 0
|
|
unsupported = 0
|
|
editable = 0
|
|
required = 0
|
|
dedicated = 0
|
|
}
|
|
foreach ($parameter in $masterParameters) {
|
|
Read-Parameter $parameter 'master' $ids $counts
|
|
}
|
|
foreach ($parameter in $detailParameters) {
|
|
Read-Parameter $parameter 'detail' $ids $counts
|
|
}
|
|
|
|
$moduleKind = [string]$contract.moduleKind
|
|
$configurationFingerprint =
|
|
([string]$contract.configurationFingerprint).ToLowerInvariant()
|
|
$createProfile = Read-NativeExecutionProfile `
|
|
$contract.nativeExecutionProfiles.create 'create' $moduleKind
|
|
$updateProfile = Read-NativeExecutionProfile `
|
|
$contract.nativeExecutionProfiles.update 'update' $moduleKind
|
|
$payloadSummary = Read-ParameterPayloadContract `
|
|
$contract.payloadContract `
|
|
$moduleKind `
|
|
$configurationFingerprint `
|
|
$masterParameters `
|
|
$detailParameters
|
|
$writeReadiness = Read-ModuleExecutionReadiness `
|
|
$contract.genericWriteExecutionAvailable `
|
|
$contract.writeCommand `
|
|
$contract.writeReadinessCode `
|
|
$contract.writeExecutionBlocker `
|
|
'create' `
|
|
$moduleKind `
|
|
$createProfile
|
|
$updateReadiness = Read-ModuleExecutionReadiness `
|
|
$contract.genericUpdateExecutionAvailable `
|
|
$contract.updateCommand `
|
|
$contract.updateReadinessCode `
|
|
$contract.updateExecutionBlocker `
|
|
'update' `
|
|
$moduleKind `
|
|
$updateProfile
|
|
|
|
return [ordered]@{
|
|
requestedCode = $RequestedCode
|
|
moduleCode = [string]$contract.moduleCode
|
|
moduleKind = $moduleKind
|
|
contractSource =
|
|
'current_erp_database_low_code_configuration'
|
|
sessionScopeBound = $true
|
|
configurationFingerprint = $configurationFingerprint
|
|
contractFingerprint =
|
|
([string]$contract.contractFingerprint).ToLowerInvariant()
|
|
masterParameterCount = $masterCount
|
|
detailParameterCount = $detailCount
|
|
inputModeCounts = [ordered]@{
|
|
scalar = [int]$counts.scalar
|
|
lookupSingle = [int]$counts['lookup-single']
|
|
unsupported = [int]$counts.unsupported
|
|
}
|
|
editableParameterCount = [int]$counts.editable
|
|
requiredParameterCount = [int]$counts.required
|
|
dedicatedAdapterParameterCount = [int]$counts.dedicated
|
|
nativeExecutionProfiles = [ordered]@{
|
|
create = $createProfile
|
|
update = $updateProfile
|
|
}
|
|
payloadContract = $payloadSummary
|
|
genericWriteExecutionAvailable = $writeReadiness.available
|
|
writeExecutionBlocked = $writeReadiness.blocked
|
|
writeCommand = $writeReadiness.command
|
|
writeReadinessCode = $writeReadiness.readinessCode
|
|
genericUpdateExecutionAvailable = $updateReadiness.available
|
|
updateExecutionBlocked = $updateReadiness.blocked
|
|
updateCommand = $updateReadiness.command
|
|
updateReadinessCode = $updateReadiness.readinessCode
|
|
planExecutionAllowed = $false
|
|
}
|
|
}
|
|
|
|
function Write-ReportCreateNew([object]$Report, [string]$Path) {
|
|
$json = ($Report | ConvertTo-Json -Depth 12) + [Environment]::NewLine
|
|
Write-NewUtf8File $Path $json
|
|
}
|
|
|
|
try {
|
|
Assert-ExpectedSessionValue `
|
|
$ExpectedUserId 'expected_user_id_invalid'
|
|
Assert-ExpectedSessionValue `
|
|
$ExpectedUserName 'expected_user_name_invalid'
|
|
Assert-ExpectedSessionValue `
|
|
$ExpectedAccountBook 'expected_account_book_invalid'
|
|
Assert-ExpectedSessionValue `
|
|
$ExpectedSubSystemId 'expected_subsystem_id_invalid'
|
|
if ($RequireDiagnosisWorkflow -and -not $ExpectedIsAdministrator) {
|
|
Throw-PreflightError 'diagnosis_requires_expected_administrator'
|
|
}
|
|
|
|
$normalizedModules = New-Object System.Collections.Generic.List[string]
|
|
$seenModules = New-Object 'System.Collections.Generic.HashSet[string]' `
|
|
([StringComparer]::OrdinalIgnoreCase)
|
|
foreach ($rawCode in $ModuleCodes) {
|
|
$code = if ($null -eq $rawCode) { '' } else { $rawCode.Trim() }
|
|
if ($code -cnotmatch $safeModuleCode -or
|
|
-not $seenModules.Add($code)) {
|
|
Throw-PreflightError 'module_codes_invalid'
|
|
}
|
|
$normalizedModules.Add($code)
|
|
}
|
|
|
|
$cliFull = Resolve-RegularFile $CliPath 128MB 'cli_file_invalid'
|
|
if ([IO.Path]::GetFileName($cliFull) -ine 'lserp-agent-cli.exe') {
|
|
Throw-PreflightError 'cli_filename_invalid'
|
|
}
|
|
$outputFull = Resolve-NewJsonPath $OutputPath 'output_path_invalid'
|
|
if ($cliFull -ieq $outputFull) {
|
|
Throw-PreflightError 'trusted_path_conflict'
|
|
}
|
|
$erpIdentity = Read-ExactErpProcessIdentity
|
|
$erpFull = Resolve-RegularFile `
|
|
$erpIdentity.ExecutablePath 1GB 'erp_file_invalid'
|
|
if ($erpFull -ieq $cliFull -or $erpFull -ieq $outputFull) {
|
|
Throw-PreflightError 'trusted_path_conflict'
|
|
}
|
|
$erpStartTimeUtcTicks = $erpIdentity.StartTimeUtcTicks
|
|
$cliLock = [IO.File]::Open(
|
|
$cliFull,
|
|
[IO.FileMode]::Open,
|
|
[IO.FileAccess]::Read,
|
|
[IO.FileShare]::Read)
|
|
$expectedCliHash = $ExpectedCliSha256.ToLowerInvariant()
|
|
Assert-TrustedCliUnchanged
|
|
$signature = Get-AuthenticodeSignature -LiteralPath $cliFull
|
|
$actualSignerThumbprint = if ($null -eq $signature.SignerCertificate) {
|
|
''
|
|
} else {
|
|
([string]$signature.SignerCertificate.Thumbprint).Replace(
|
|
' ', '').ToUpperInvariant()
|
|
}
|
|
if ($signature.Status -ne
|
|
[System.Management.Automation.SignatureStatus]::Valid -or
|
|
$actualSignerThumbprint -cne
|
|
$ExpectedSignerThumbprint.ToUpperInvariant()) {
|
|
Throw-PreflightError 'cli_signature_invalid'
|
|
}
|
|
$erpLock = [IO.File]::Open(
|
|
$erpFull,
|
|
[IO.FileMode]::Open,
|
|
[IO.FileAccess]::Read,
|
|
[IO.FileShare]::Read)
|
|
$expectedErpHash = $ExpectedErpSha256.ToLowerInvariant()
|
|
Assert-TrustedErpUnchanged
|
|
$erpSignature = Get-AuthenticodeSignature -LiteralPath $erpFull
|
|
$actualErpSignerThumbprint = if (
|
|
$null -eq $erpSignature.SignerCertificate) {
|
|
''
|
|
} else {
|
|
([string]$erpSignature.SignerCertificate.Thumbprint).Replace(
|
|
' ', '').ToUpperInvariant()
|
|
}
|
|
if ($erpSignature.Status -ne
|
|
[System.Management.Automation.SignatureStatus]::Valid -or
|
|
$actualErpSignerThumbprint -cne
|
|
$ExpectedSignerThumbprint.ToUpperInvariant()) {
|
|
Throw-PreflightError 'erp_signature_invalid'
|
|
}
|
|
$workingDirectory = New-RestrictedWorkingDirectory
|
|
|
|
$identityEnvelope = Invoke-TrustedReadOnlyCli @(
|
|
'version', '--correlation-id',
|
|
('readonly-version-' + [Guid]::NewGuid().ToString('N'))
|
|
) 'cli_runtime_identity'
|
|
$runtimeCliIdentity = Read-RuntimeCliIdentity $identityEnvelope.data
|
|
|
|
$healthEnvelope = Invoke-ReadBridge 'health' 'bridge_health'
|
|
$health = Read-Health $healthEnvelope.data
|
|
$firstContextEnvelope = Invoke-ReadBridge 'context' 'bridge_context_before'
|
|
$firstSession = Read-SessionContext `
|
|
$firstContextEnvelope.data 'bridge_context_before_invalid'
|
|
$capabilitiesEnvelope = Invoke-ReadBridge `
|
|
'capabilities' 'bridge_capabilities'
|
|
$capabilities = Read-Capabilities $capabilitiesEnvelope.data
|
|
|
|
$targetContracts = [ordered]@{
|
|
'module.parameters' = @('1.1', '1.1', 'read', $false, $false)
|
|
'purchase.invoice.resolve' = @('1.4', '1.4', 'draft', $false, $false)
|
|
'purchase.invoice.create' = @('1.4', '1.4', 'write', $true, $true)
|
|
'hr.leave.resolve' = @('1.4', '1.4', 'draft', $false, $false)
|
|
'hr.leave.create' = @('1.2', '1.2', 'write', $true, $true)
|
|
'hr.leave.submit' = @('1.0', '1.0', 'write', $true, $true)
|
|
'module.diagnose' = @('1.0', '1.0', 'read', $false, $false)
|
|
'module.trace-initialization' = @(
|
|
'1.2', '1.0', 'critical', $true, $true)
|
|
}
|
|
$requiredNames = New-Object 'System.Collections.Generic.HashSet[string]' `
|
|
([StringComparer]::Ordinal)
|
|
[void]$requiredNames.Add('module.parameters')
|
|
if ($RequirePurchaseWorkflow) {
|
|
[void]$requiredNames.Add('purchase.invoice.resolve')
|
|
[void]$requiredNames.Add('purchase.invoice.create')
|
|
}
|
|
if ($RequireLeaveWorkflow) {
|
|
[void]$requiredNames.Add('hr.leave.resolve')
|
|
[void]$requiredNames.Add('hr.leave.create')
|
|
[void]$requiredNames.Add('hr.leave.submit')
|
|
}
|
|
if ($RequireDiagnosisWorkflow) {
|
|
[void]$requiredNames.Add('module.diagnose')
|
|
[void]$requiredNames.Add('module.trace-initialization')
|
|
}
|
|
|
|
$capabilityReadiness = New-Object System.Collections.Generic.List[object]
|
|
foreach ($entry in $targetContracts.GetEnumerator()) {
|
|
$actual = if ($capabilities.ContainsKey([string]$entry.Key)) {
|
|
$capabilities[[string]$entry.Key]
|
|
} else { $null }
|
|
$contract = @($entry.Value)
|
|
$semanticsValid = Test-CapabilityContract `
|
|
$actual $contract[0] $contract[1] $contract[2] `
|
|
([bool]$contract[3]) ([bool]$contract[4])
|
|
$required = $requiredNames.Contains([string]$entry.Key)
|
|
if (($null -ne $actual -and -not $semanticsValid) -or
|
|
($required -and $null -eq $actual)) {
|
|
Throw-PreflightError `
|
|
('required_capability_invalid_' + [string]$entry.Key)
|
|
}
|
|
$capabilityReadiness.Add([ordered]@{
|
|
command = [string]$entry.Key
|
|
required = $required
|
|
available = $null -ne $actual
|
|
contractValid = $semanticsValid
|
|
version = if ($null -eq $actual) { $null } else {
|
|
[string]$actual.version
|
|
}
|
|
schemaVersion = if ($null -eq $actual) { $null } else {
|
|
[string]$actual.schemaVersion
|
|
}
|
|
risk = if ($null -eq $actual) { $null } else {
|
|
[string]$actual.risk
|
|
}
|
|
})
|
|
}
|
|
|
|
$moduleResults = New-Object System.Collections.Generic.List[object]
|
|
$resolvedModules = New-Object `
|
|
'System.Collections.Generic.HashSet[string]' `
|
|
([StringComparer]::OrdinalIgnoreCase)
|
|
for ($index = 0; $index -lt $normalizedModules.Count; $index++) {
|
|
$moduleEnvelope = Invoke-ModuleParameterPlan `
|
|
$normalizedModules[$index] ($index + 1)
|
|
$moduleResult = Read-ModuleParameterContract `
|
|
$moduleEnvelope.data `
|
|
$normalizedModules[$index] `
|
|
$firstSession
|
|
if (-not $resolvedModules.Add([string]$moduleResult.moduleCode)) {
|
|
Throw-PreflightError 'resolved_module_codes_duplicate'
|
|
}
|
|
$moduleResults.Add($moduleResult)
|
|
}
|
|
|
|
$lastContextEnvelope = Invoke-ReadBridge 'context' 'bridge_context_after'
|
|
$lastSession = Read-SessionContext `
|
|
$lastContextEnvelope.data 'bridge_context_after_invalid'
|
|
if (-not (Test-SameSession $firstSession $lastSession)) {
|
|
Throw-PreflightError 'erp_session_changed_during_preflight'
|
|
}
|
|
Assert-TrustedCliUnchanged
|
|
Assert-TrustedErpUnchanged
|
|
|
|
$activeModule = if ($null -eq $lastSession.ActiveModule) {
|
|
$null
|
|
} else {
|
|
[ordered]@{
|
|
moduleCode = [string]$lastSession.ActiveModule.moduleCode
|
|
navigationCode = [string]$lastSession.ActiveModule.navigationCode
|
|
}
|
|
}
|
|
$report = [ordered]@{
|
|
schemaVersion = '1.5'
|
|
evidenceType = 'lserp_readonly_session_preflight'
|
|
generatedAtUtc = [DateTime]::UtcNow.ToString('o')
|
|
passed = $true
|
|
readOnlySessionReady = $true
|
|
productionWriteAuthorized = $false
|
|
erpProcessId = $ErpProcessId
|
|
erpExecutable = [ordered]@{
|
|
sha256 = $expectedErpHash
|
|
signerThumbprint = $actualErpSignerThumbprint
|
|
}
|
|
cli = [ordered]@{
|
|
component = $runtimeCliIdentity.Component
|
|
version = $runtimeCliIdentity.Version
|
|
protocolVersion = $runtimeCliIdentity.ProtocolVersion
|
|
bridgeOnly = $runtimeCliIdentity.BridgeOnly
|
|
databaseDirectAccess = $runtimeCliIdentity.DatabaseDirectAccess
|
|
sessionSource = $runtimeCliIdentity.SessionSource
|
|
sha256 = $expectedCliHash
|
|
signerThumbprint = $actualSignerThumbprint
|
|
}
|
|
rolloutPolicy = [ordered]@{
|
|
customerId = $ExpectedRolloutCustomerId
|
|
databaseScopeFingerprint =
|
|
$ExpectedDatabaseScopeFingerprint.ToLowerInvariant()
|
|
sourceSha256 =
|
|
$ExpectedRolloutPolicySha256.ToLowerInvariant()
|
|
defaultAction = 'deny'
|
|
ruleCount = $health.RuleCount
|
|
}
|
|
session = [ordered]@{
|
|
databaseScopeFingerprint =
|
|
$lastSession.DatabaseScopeFingerprint
|
|
userIdSha256 = Get-ScopedValueSha256 `
|
|
'user-id' $lastSession.UserId
|
|
userNameSha256 = Get-ScopedValueSha256 `
|
|
'user-name' $lastSession.UserName
|
|
accountBookSha256 = Get-ScopedValueSha256 `
|
|
'account-book' $lastSession.AccountBook
|
|
subSystemIdSha256 = Get-ScopedValueSha256 `
|
|
'subsystem-id' $lastSession.SubSystemId
|
|
subSystemNameSha256 = Get-ScopedValueSha256 `
|
|
'subsystem-name' $lastSession.SubSystemName
|
|
isAdministrator = $lastSession.IsAdministrator
|
|
activeModule = $activeModule
|
|
openModuleCount = $lastSession.OpenModuleCount
|
|
}
|
|
bridge = [ordered]@{
|
|
protocolVersion = '1.0'
|
|
registeredCommandCount = $health.CommandCount
|
|
enabledCommandCount = $health.EnabledCommandCount
|
|
}
|
|
requestedReadiness = [ordered]@{
|
|
purchaseWorkflow = [bool]$RequirePurchaseWorkflow
|
|
leaveWorkflow = [bool]$RequireLeaveWorkflow
|
|
diagnosisWorkflow = [bool]$RequireDiagnosisWorkflow
|
|
}
|
|
capabilityReadiness = @($capabilityReadiness)
|
|
modules = @($moduleResults)
|
|
readOnlyBoundary = [ordered]@{
|
|
allowedOperations = @(
|
|
'cli.version',
|
|
'bridge.health',
|
|
'bridge.context',
|
|
'bridge.capabilities',
|
|
'bridge.plan:module.parameters'
|
|
)
|
|
commandExecuteInvoked = $false
|
|
workflowPlanInvoked = $false
|
|
directDatabaseConnectionUsed = $false
|
|
businessWriteAttempted = $false
|
|
rawBusinessValuesEmitted = $false
|
|
rawParameterLabelsEmitted = $false
|
|
temporaryModuleInputsRetained = $false
|
|
}
|
|
checks = @(
|
|
[ordered]@{ code = 'cli_integrity'; passed = $true },
|
|
[ordered]@{ code = 'cli_runtime_identity'; passed = $true },
|
|
[ordered]@{ code = 'exact_erp_process'; passed = $true },
|
|
[ordered]@{ code = 'default_deny_rollout'; passed = $true },
|
|
[ordered]@{ code = 'rollout_database_scope'; passed = $true },
|
|
[ordered]@{ code = 'expected_database_scope'; passed = $true },
|
|
[ordered]@{ code = 'expected_session_scope'; passed = $true },
|
|
[ordered]@{ code = 'capability_contracts'; passed = $true },
|
|
[ordered]@{ code = 'dynamic_module_contracts'; passed = $true },
|
|
[ordered]@{ code = 'dynamic_module_execution_contracts'; passed = $true },
|
|
[ordered]@{ code = 'session_stability'; passed = $true },
|
|
[ordered]@{ code = 'read_only_boundary'; passed = $true }
|
|
)
|
|
note = '本报告只证明指定 ERP 会话的只读桥、发布范围、动态参数载荷和原生执行就绪合同;不授权生产写入,也不替代客户 UAT、事务、幂等、审计及恢复验收。'
|
|
}
|
|
Write-ReportCreateNew $report $outputFull
|
|
$published = $true
|
|
}
|
|
finally {
|
|
if ($null -ne $cliLock) { $cliLock.Dispose() }
|
|
if ($null -ne $erpLock) { $erpLock.Dispose() }
|
|
if ($null -ne $workingDirectory -and
|
|
[IO.Directory]::Exists($workingDirectory)) {
|
|
try { [IO.Directory]::Delete($workingDirectory, $true) }
|
|
catch {
|
|
if ($published -and [IO.File]::Exists($outputFull)) {
|
|
try { [IO.File]::Delete($outputFull) } catch { }
|
|
}
|
|
Throw-PreflightError 'restricted_working_cleanup_failed'
|
|
}
|
|
}
|
|
}
|
|
|
|
Write-Output $outputFull
|