[CmdletBinding()] param( [Parameter(Mandatory = $true)][ValidateSet('purchase', 'leave')][string]$Workflow, [Parameter(Mandatory = $true)][ValidatePattern('^[A-Za-z0-9_.:-]{1,64}$')][string]$ModuleCode, [Parameter(Mandatory = $true)][string]$AccountBook, [Parameter(Mandatory = $true)][string]$SubSystemId, [Parameter(Mandatory = $true)][ValidatePattern('^[A-Fa-f0-9]{40}$')][string]$SourceCommit, [Parameter(Mandatory = $true)][ValidatePattern('^[A-Fa-f0-9]{64}$')][string]$PackageSha256, [Parameter(Mandatory = $true)][string]$RuntimeConfigurationFile, [Parameter(Mandatory = $true)][ValidatePattern('^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$')][string]$RolloutCustomerId, [Parameter(Mandatory = $true)][ValidatePattern('^[A-Za-z0-9_.:-]{8,128}$')][string]$EnvironmentId, [Parameter(Mandatory = $true)][ValidatePattern('^[A-Za-z0-9_.:-]{1,128}$')][string]$TestedBy, [Parameter(Mandatory = $true)][string]$CasesFile, [Parameter(Mandatory = $true)][string]$UatAuthorizationFile, [Parameter(Mandatory = $true)][string]$VerifierCliPath, [Parameter(Mandatory = $true)][string]$OutputPath ) Set-StrictMode -Version 2.0 $ErrorActionPreference = 'Stop' function Get-RegularFile([string]$Path, [long]$MaximumBytes, [string]$Label) { $full = [IO.Path]::GetFullPath($Path) if (-not [IO.File]::Exists($full)) { throw "$Label file does not exist." } $info = Get-Item -LiteralPath $full -Force if ($info.Length -le 0 -or $info.Length -gt $MaximumBytes -or (($info.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) { throw "$Label must be a non-empty ordinary file within the size limit." } return $full } function Get-Sha256([byte[]]$Bytes) { $sha = [Security.Cryptography.SHA256]::Create() try { return ([BitConverter]::ToString($sha.ComputeHash($Bytes))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() } } 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 } $strictUtf8 = New-Object Text.UTF8Encoding($false, $true) $runtimePath = Get-RegularFile $RuntimeConfigurationFile 64KB 'Runtime configuration' $casesPath = Get-RegularFile $CasesFile 2MB 'Cases' $uatAuthorizationPath = Get-RegularFile ` $UatAuthorizationFile 512KB 'UAT authorization' $cliPath = Get-RegularFile $VerifierCliPath 64MB 'Verifier CLI' if ([IO.Path]::GetFileName($cliPath) -ne 'lserp-cli.exe') { throw 'Verifier CLI filename must be lserp-cli.exe.' } $runtimeBytes = [IO.File]::ReadAllBytes($runtimePath) $runtimeHash = Get-Sha256 $runtimeBytes $uatAuthorizationHash = Get-Sha256 ` ([IO.File]::ReadAllBytes($uatAuthorizationPath)) $cliHash = Get-Sha256 ([IO.File]::ReadAllBytes($cliPath)) $uatOutput = @(& $cliPath 'acceptance' 'verify-uat-authorization' ` '--input' $uatAuthorizationPath '--json' 2>&1) if ($LASTEXITCODE -ne 0) { throw 'UAT authorization failed final CLI verification.' } try { $uatVerification = (($uatOutput | ForEach-Object { [string]$_ }) -join ` [Environment]::NewLine) | ConvertFrom-Json } catch { throw 'UAT authorization verifier returned invalid JSON.' } if (-not (Test-ExactProperties $uatVerification @('ok', 'correlationId', 'data')) -or -not (Test-ExactProperties $uatVerification.data.erpScope @( 'accountBook', 'subSystemId', 'userIdSha256', 'userNameSha256', 'databaseScopeFingerprint', 'isAdministrator')) -or -not (Test-ExactProperties $uatVerification.data.runtimeCli @( 'fileName', 'version', 'sha256', 'signerThumbprint', 'requiresElevation', 'bridgeOnly', 'databaseDirectAccess', 'sessionSource')) -or -not (Test-ExactProperties $uatVerification.data.verifierCli @( 'fileName', 'sha256', 'signerThumbprint', 'requiresElevation')) -or $uatVerification.ok -ne $true -or $uatVerification.data.packageType -cne 'workflow_write_uat_authorization' -or $uatVerification.data.schemaVersion -cne '1.2' -or $uatVerification.data.sourceSha256 -cne $uatAuthorizationHash -or $uatVerification.data.runtimeConfigurationSha256 -cne $runtimeHash -or $uatVerification.data.sourceCommit -cne $SourceCommit.ToLowerInvariant() -or $uatVerification.data.packageSha256 -cne $PackageSha256.ToLowerInvariant() -or $uatVerification.data.customerId -cne $RolloutCustomerId -or $uatVerification.data.environmentId -cne $EnvironmentId -or $uatVerification.data.erpScope.accountBook -cne $AccountBook -or $uatVerification.data.erpScope.subSystemId -cne $SubSystemId -or $uatVerification.data.runtimeCli.fileName -cne 'lserp-agent-cli.exe' -or [string]$uatVerification.data.runtimeCli.version -cnotmatch '^[0-9]{1,4}\.[0-9]{1,4}\.[0-9]{1,4}$' -or [string]$uatVerification.data.runtimeCli.sha256 -cnotmatch '^[a-f0-9]{64}$' -or [string]$uatVerification.data.runtimeCli.signerThumbprint -cnotmatch '^[A-F0-9]{40}$' -or $uatVerification.data.runtimeCli.requiresElevation -ne $false -or $uatVerification.data.runtimeCli.bridgeOnly -ne $true -or $uatVerification.data.runtimeCli.databaseDirectAccess -ne $false -or $uatVerification.data.runtimeCli.sessionSource -cne 'current_logged_in_erp_process' -or $uatVerification.data.verifierCli.fileName -cne 'lserp-cli.exe' -or $uatVerification.data.verifierCli.sha256 -cne $cliHash -or $uatVerification.data.verifierCli.requiresElevation -ne $true -or $uatVerification.data.signatureVerified -ne $true -or $uatVerification.data.uatAuthorized -ne $true -or $uatVerification.data.productionReady -ne $false) { throw 'UAT authorization does not bind the final test scope and verifier.' } $authorizedWorkflows = @($uatVerification.data.workflows | Where-Object { [string]$_.workflow -ceq $Workflow -and [string]$_.moduleCode -ceq $ModuleCode }) if ($authorizedWorkflows.Count -ne 1) { throw 'UAT authorization does not contain the exact workflow and module.' } $casesText = [IO.File]::ReadAllText($casesPath, $strictUtf8) $casesHash = Get-Sha256 ($strictUtf8.GetBytes($casesText)) try { $cases = @($casesText | ConvertFrom-Json) } catch { throw 'Cases file must be a UTF-8 JSON array.' } if ($cases.Count -le 0 -or $cases.Count -gt 32 -or $casesText.TrimStart()[0] -ne '[') { throw 'Cases file must contain a JSON array with 1-32 items.' } $runtimeCliVersion = [string]$uatVerification.data.runtimeCli.version $runtimeCliSha256 = [string]$uatVerification.data.runtimeCli.sha256 $runtimeCliSigner = [string]$uatVerification.data.runtimeCli.signerThumbprint foreach ($case in $cases) { if ([string]$case.runtimeCliVersion -cne $runtimeCliVersion -or [string]$case.runtimeCliSha256 -cne $runtimeCliSha256 -or [string]$case.runtimeCliSignerThumbprint -cne $runtimeCliSigner) { throw 'A projected case does not bind the authorized runtime CLI.' } } $testedAt = [DateTime]::UtcNow $content = [ordered]@{ evidenceType = 'workflow_write_integration' workflow = $Workflow moduleCode = $ModuleCode erpScope = [ordered]@{ accountBook = $AccountBook subSystemId = $SubSystemId } sourceCommit = $SourceCommit.ToLowerInvariant() packageSha256 = $PackageSha256.ToLowerInvariant() runtimeConfigurationSha256 = $runtimeHash runtimeCli = [ordered]@{ fileName = 'lserp-agent-cli.exe' version = $runtimeCliVersion sha256 = $runtimeCliSha256 signerThumbprint = $runtimeCliSigner requiresElevation = $false bridgeOnly = $true databaseDirectAccess = $false sessionSource = 'current_logged_in_erp_process' } uatAuthorizationSourceSha256 = [string]$uatVerification.data.sourceSha256 uatAuthorizationContentSha256 = [string]$uatVerification.data.contentSha256 uatAuthorizationIdSha256 = [string]$uatVerification.data.authorizationIdSha256 environmentId = $EnvironmentId testedAtUtc = $testedAt.ToString('yyyy-MM-ddTHH:mm:ss.fffZ') testedBy = $TestedBy cases = $cases } $canonical = $content | ConvertTo-Json -Compress -Depth 12 $contentHash = Get-Sha256 $strictUtf8.GetBytes($canonical) $envelope = [ordered]@{ schemaVersion = '1.6' contentSha256 = $contentHash content = $content } $body = $strictUtf8.GetBytes(($envelope | ConvertTo-Json -Depth 12) + [Environment]::NewLine) $bodyHash = Get-Sha256 $body $target = [IO.Path]::GetFullPath($OutputPath) $directory = [IO.Path]::GetDirectoryName($target) if ([string]::IsNullOrWhiteSpace($directory) -or -not [IO.Directory]::Exists($directory) -or [IO.File]::Exists($target) -or [IO.Directory]::Exists($target)) { throw 'Output must be a new file in an existing directory.' } $temporary = Join-Path $directory ('.lserp-write-evidence-' + [Guid]::NewGuid().ToString('N') + '.tmp') $published = $false try { $stream = [IO.File]::Open( $temporary, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) try { $stream.Write($body, 0, $body.Length); $stream.Flush() } finally { $stream.Dispose() } $arguments = @( 'adapters', 'verify-write-integration-evidence', '--input', $temporary, '--workflow', $Workflow, '--module', $ModuleCode, '--account-book', $AccountBook, '--subsystem', $SubSystemId, '--runtime-sha256', $runtimeHash, '--source-commit', $SourceCommit.ToLowerInvariant(), '--package-sha256', $PackageSha256.ToLowerInvariant() ) $verificationOutput = @(& $cliPath @arguments 2>&1) if ($LASTEXITCODE -ne 0) { throw 'Generated write integration evidence failed CLI verification.' } $verificationText = (($verificationOutput | ForEach-Object { [string]$_ }) -join [Environment]::NewLine) try { $verification = $verificationText | ConvertFrom-Json } catch { throw 'Verifier CLI returned invalid JSON.' } $expectedData = @( 'evidenceType', 'schemaVersion', 'contentSha256', 'workflow', 'moduleCode', 'erpScope', 'sourceCommit', 'packageSha256', 'runtimeConfigurationSha256', 'runtimeCli', 'uatAuthorizationSourceSha256', 'uatAuthorizationContentSha256', 'uatAuthorizationIdSha256', 'environmentId', 'testedAtUtc', 'testedBy', 'caseCount', 'verified', 'registrationReady' ) if (-not (Test-ExactProperties $verification @('ok', 'correlationId', 'data')) -or -not (Test-ExactProperties $verification.data $expectedData) -or -not (Test-ExactProperties $verification.data.erpScope @( 'accountBook', 'subSystemId', 'userIdSha256', 'userNameSha256', 'databaseScopeFingerprint', 'isAdministrator')) -or -not (Test-ExactProperties $verification.data.runtimeCli @( 'fileName', 'version', 'sha256', 'signerThumbprint', 'requiresElevation', 'bridgeOnly', 'databaseDirectAccess', 'sessionSource')) -or $verification.ok -ne $true -or $verification.data.verified -ne $true -or $verification.data.schemaVersion -ne '1.6' -or $verification.data.registrationReady -ne $false -or $verification.data.contentSha256 -ne $contentHash -or $verification.data.workflow -ne $Workflow -or $verification.data.moduleCode -ne $ModuleCode -or $verification.data.erpScope.accountBook -ne $AccountBook -or $verification.data.erpScope.subSystemId -ne $SubSystemId -or $verification.data.erpScope.userIdSha256 -cne $uatVerification.data.erpScope.userIdSha256 -or $verification.data.erpScope.userNameSha256 -cne $uatVerification.data.erpScope.userNameSha256 -or $verification.data.erpScope.databaseScopeFingerprint -cne $uatVerification.data.erpScope.databaseScopeFingerprint -or $verification.data.erpScope.isAdministrator -ne $uatVerification.data.erpScope.isAdministrator -or $verification.data.runtimeConfigurationSha256 -ne $runtimeHash -or $verification.data.runtimeCli.fileName -cne 'lserp-agent-cli.exe' -or $verification.data.runtimeCli.version -cne $runtimeCliVersion -or $verification.data.runtimeCli.sha256 -cne $runtimeCliSha256 -or $verification.data.runtimeCli.signerThumbprint -cne $runtimeCliSigner -or $verification.data.runtimeCli.requiresElevation -ne $false -or $verification.data.runtimeCli.bridgeOnly -ne $true -or $verification.data.runtimeCli.databaseDirectAccess -ne $false -or $verification.data.runtimeCli.sessionSource -cne 'current_logged_in_erp_process' -or $verification.data.uatAuthorizationSourceSha256 -ne $uatVerification.data.sourceSha256 -or $verification.data.uatAuthorizationContentSha256 -ne $uatVerification.data.contentSha256 -or $verification.data.uatAuthorizationIdSha256 -ne $uatVerification.data.authorizationIdSha256 -or $verification.data.sourceCommit -ne $SourceCommit.ToLowerInvariant() -or $verification.data.packageSha256 -ne $PackageSha256.ToLowerInvariant() -or [int]$verification.data.caseCount -ne $cases.Count -or (Get-Sha256 ([IO.File]::ReadAllBytes($temporary))) -ne $bodyHash) { throw 'Verifier CLI response is not bound to the generated evidence.' } if ((Get-Sha256 ([IO.File]::ReadAllBytes($uatAuthorizationPath))) -ne $uatAuthorizationHash -or (Get-Sha256 ([IO.File]::ReadAllBytes($runtimePath))) -ne $runtimeHash -or (Get-Sha256 ([IO.File]::ReadAllBytes($casesPath))) -ne $casesHash -or (Get-Sha256 ([IO.File]::ReadAllBytes($cliPath))) -ne $cliHash) { throw 'A locked evidence input changed during final verification.' } [IO.File]::Move($temporary, $target) $published = $true } finally { if (-not $published -and [IO.File]::Exists($temporary)) { [IO.File]::Delete($temporary) } } [ordered]@{ outputFile = $target workflow = $Workflow moduleCode = $ModuleCode accountBook = $AccountBook subSystemId = $SubSystemId sourceCommit = $SourceCommit.ToLowerInvariant() packageSha256 = $PackageSha256.ToLowerInvariant() runtimeConfigurationSha256 = $runtimeHash runtimeCliVersion = $runtimeCliVersion runtimeCliSha256 = $runtimeCliSha256 runtimeCliSignerThumbprint = $runtimeCliSigner uatAuthorizationSourceSha256 = [string]$uatVerification.data.sourceSha256 uatAuthorizationContentSha256 = [string]$uatVerification.data.contentSha256 uatAuthorizationIdSha256 = [string]$uatVerification.data.authorizationIdSha256 contentSha256 = $contentHash caseCount = $cases.Count testedAtUtc = $testedAt.ToString('o') nextStep = 'Run lserp-cli adapters verify-write-integration-evidence, then sign the workflow acceptance manifest.' } | ConvertTo-Json -Depth 5