676 lines
28 KiB
PowerShell
676 lines
28 KiB
PowerShell
[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-z0-9_.-]{1,128}$')][string]$AdapterId,
|
|
[Parameter(Mandatory = $true)][ValidatePattern('^[a-z0-9_.-]{1,128}$')][string]$AdapterVersion,
|
|
[Parameter(Mandatory = $true)][ValidatePattern('^[A-Za-z0-9_.:-]{8,128}$')][string]$EvidenceId,
|
|
[Parameter(Mandatory = $true)][string]$RuntimeConfigurationFile,
|
|
[Parameter(Mandatory = $true)][string]$CustomerProfileFile,
|
|
[Parameter(Mandatory = $true)][string]$FieldMappingEvidence,
|
|
[Parameter(Mandatory = $true)][string]$ReadContractEvidence,
|
|
[Parameter(Mandatory = $true)][string]$WriteIntegrationEvidence,
|
|
[Parameter(Mandatory = $true)][string]$VerifierCliPath,
|
|
[Parameter(Mandatory = $true)][ValidateLength(1, 128)][string]$ErpUser,
|
|
[Parameter(Mandatory = $true)][System.Security.SecureString]$ErpPassword,
|
|
[Parameter(Mandatory = $true)][ValidatePattern('^[A-Fa-f0-9]{40}$')][string]$ExpectedSourceCommit,
|
|
[Parameter(Mandatory = $true)][ValidatePattern('^[A-Fa-f0-9]{64}$')][string]$ExpectedPackageSha256,
|
|
[Parameter(Mandatory = $true)][string]$ValidatedBy,
|
|
[Parameter(Mandatory = $true)][ValidatePattern('^[A-Fa-f0-9 ]{40,59}$')][string]$CertificateThumbprint,
|
|
[Parameter(Mandatory = $true)][string]$OutputPath,
|
|
[ValidateRange(1, 366)][int]$ValidDays = 90
|
|
)
|
|
|
|
Set-StrictMode -Version 2.0
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
function Get-Sha256Hex([byte[]]$Bytes) {
|
|
$sha = [System.Security.Cryptography.SHA256]::Create()
|
|
try {
|
|
$hash = $sha.ComputeHash($Bytes)
|
|
return ([System.BitConverter]::ToString($hash)).Replace('-', '').ToLowerInvariant()
|
|
}
|
|
finally {
|
|
$sha.Dispose()
|
|
}
|
|
}
|
|
|
|
function Get-FileSha256(
|
|
[string]$Path,
|
|
[long]$MaximumBytes,
|
|
[string]$Label) {
|
|
$full = [System.IO.Path]::GetFullPath($Path)
|
|
if (-not [System.IO.File]::Exists($full)) {
|
|
throw "$Label file not found: $full"
|
|
}
|
|
$info = New-Object System.IO.FileInfo($full)
|
|
if ($info.Length -le 0 -or $info.Length -gt $MaximumBytes -or
|
|
(($info.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0)) {
|
|
throw "$Label must be a non-empty regular file no larger than $MaximumBytes bytes: $full"
|
|
}
|
|
$bytes = [System.IO.File]::ReadAllBytes($full)
|
|
if ($bytes.Length -le 0 -or $bytes.Length -gt $MaximumBytes) {
|
|
throw "$Label changed size while it was read: $full"
|
|
}
|
|
return Get-Sha256Hex $bytes
|
|
}
|
|
|
|
function Open-InputLock(
|
|
[string]$Path,
|
|
[long]$MaximumBytes,
|
|
[string]$Label) {
|
|
$full = [System.IO.Path]::GetFullPath($Path)
|
|
if (-not [System.IO.File]::Exists($full)) {
|
|
throw "$Label file not found: $full"
|
|
}
|
|
$info = New-Object System.IO.FileInfo($full)
|
|
if ($info.Length -le 0 -or $info.Length -gt $MaximumBytes -or
|
|
(($info.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0)) {
|
|
throw "$Label must be a non-empty ordinary file within the size limit: $full"
|
|
}
|
|
$stream = [System.IO.File]::Open(
|
|
$full,
|
|
[System.IO.FileMode]::Open,
|
|
[System.IO.FileAccess]::Read,
|
|
[System.IO.FileShare]::Read)
|
|
if ($stream.Length -le 0 -or $stream.Length -gt $MaximumBytes) {
|
|
$stream.Dispose()
|
|
throw "$Label changed size while it was locked: $full"
|
|
}
|
|
return $stream
|
|
}
|
|
|
|
function Find-SigningCertificate([string]$Thumbprint) {
|
|
$normalized = ($Thumbprint -replace '\s+', '').ToUpperInvariant()
|
|
foreach ($location in @('CurrentUser', 'LocalMachine')) {
|
|
$path = "Cert:\$location\TrustedPeople\$normalized"
|
|
if (Test-Path -LiteralPath $path) {
|
|
$certificate = Get-Item -LiteralPath $path
|
|
if (-not $certificate.HasPrivateKey) {
|
|
throw "TrustedPeople certificate has no private key: $normalized"
|
|
}
|
|
if ((Get-Date) -lt $certificate.NotBefore -or (Get-Date) -gt $certificate.NotAfter) {
|
|
throw "TrustedPeople certificate is not currently valid: $normalized"
|
|
}
|
|
return $certificate
|
|
}
|
|
}
|
|
throw "Certificate not found in CurrentUser/LocalMachine TrustedPeople: $normalized"
|
|
}
|
|
|
|
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 Invoke-AuthenticatedCli(
|
|
[string]$CliPath,
|
|
[string[]]$Arguments,
|
|
[string]$User,
|
|
[System.Security.SecureString]$Password,
|
|
[string]$Ledger,
|
|
[string]$Subsystem) {
|
|
$pointer = [IntPtr]::Zero
|
|
$plainText = $null
|
|
try {
|
|
$pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Password)
|
|
$plainText = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer)
|
|
$allArguments = @($Arguments) + @(
|
|
'--user', $User,
|
|
'--password-stdin',
|
|
'--ledger', $Ledger,
|
|
'--subsystem', $Subsystem
|
|
)
|
|
$output = @($plainText | & $CliPath @allArguments 2>&1)
|
|
$exitCode = $LASTEXITCODE
|
|
return [pscustomobject]@{
|
|
ExitCode = $exitCode
|
|
Text = (($output | ForEach-Object { [string]$_ }) -join `
|
|
[Environment]::NewLine)
|
|
}
|
|
}
|
|
finally {
|
|
$plainText = $null
|
|
if ($pointer -ne [IntPtr]::Zero) {
|
|
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer)
|
|
}
|
|
}
|
|
}
|
|
|
|
function Invoke-FieldMappingVerifier(
|
|
[string]$CliPath,
|
|
[string]$MappingPath,
|
|
[string]$ExpectedWorkflow,
|
|
[string]$ExpectedModule,
|
|
[string]$ExpectedAccountBook,
|
|
[string]$ExpectedSubsystem,
|
|
[string]$User,
|
|
[System.Security.SecureString]$Password) {
|
|
$result = Invoke-AuthenticatedCli `
|
|
$CliPath `
|
|
@('adapters', 'validate-fields', $ExpectedWorkflow, '--input',
|
|
[System.IO.Path]::GetFullPath($MappingPath)) `
|
|
$User $Password $ExpectedAccountBook $ExpectedSubsystem
|
|
if ($result.ExitCode -ne 0) {
|
|
throw 'Field mapping failed live ERP metadata validation.'
|
|
}
|
|
try { $response = $result.Text | ConvertFrom-Json }
|
|
catch { throw 'Field mapping verifier did not return valid JSON.' }
|
|
$expectedData = @(
|
|
'workflow', 'moduleCode', 'moduleKind', 'erpScope', 'fieldMapReady',
|
|
'registrationReady', 'issues', 'requiredRuntimeEvidence', 'storage', 'note'
|
|
)
|
|
if (-not (Test-ExactProperties $response @('ok', 'correlationId', 'data')) -or
|
|
$response.ok -ne $true -or
|
|
-not (Test-ExactProperties $response.data $expectedData) -or
|
|
-not (Test-ExactProperties $response.data.erpScope `
|
|
@('accountBook', 'subSystemId', 'validatedByUserId')) -or
|
|
$response.data.workflow -cne $ExpectedWorkflow -or
|
|
$response.data.moduleCode -cne $ExpectedModule -or
|
|
$response.data.erpScope.accountBook -ne $ExpectedAccountBook -or
|
|
$response.data.erpScope.subSystemId -ne $ExpectedSubsystem -or
|
|
[string]::IsNullOrWhiteSpace(
|
|
[string]$response.data.erpScope.validatedByUserId) -or
|
|
$response.data.fieldMapReady -ne $true -or
|
|
@($response.data.issues).Count -ne 0 -or
|
|
$response.data.registrationReady -ne $false) {
|
|
throw 'Field mapping verifier response is not ready or not bound to this ERP scope.'
|
|
}
|
|
return $response.data
|
|
}
|
|
|
|
function Invoke-ReadContractEvidenceVerifier(
|
|
[string]$CliPath,
|
|
[string]$EvidencePath,
|
|
[string]$ExpectedWorkflow,
|
|
[string]$ExpectedModule,
|
|
[string]$ExpectedAccountBook,
|
|
[string]$ExpectedSubsystem) {
|
|
$output = @(& $CliPath `
|
|
'adapters' 'verify-contract-evidence' `
|
|
'--input' ([System.IO.Path]::GetFullPath($EvidencePath)) `
|
|
'--workflow' $ExpectedWorkflow `
|
|
'--module' $ExpectedModule `
|
|
'--account-book' $ExpectedAccountBook `
|
|
'--subsystem' $ExpectedSubsystem 2>&1)
|
|
$exitCode = $LASTEXITCODE
|
|
$text = (($output | ForEach-Object { [string]$_ }) -join [Environment]::NewLine)
|
|
if ($exitCode -ne 0) {
|
|
throw 'Read contract evidence failed strict CLI verification.'
|
|
}
|
|
try { $response = $text | ConvertFrom-Json }
|
|
catch { throw 'Read contract verifier did not return valid JSON.' }
|
|
$expectedData = @(
|
|
'packageType', 'schemaVersion', 'workflow', 'moduleCode',
|
|
'contentSha256', 'integrityValid', 'verified', 'signatureVerified',
|
|
'registrationReady', 'note', 'scopeBindingVerified'
|
|
)
|
|
if (-not (Test-ExactProperties $response @('ok', 'correlationId', 'data')) -or
|
|
$response.ok -ne $true -or
|
|
-not (Test-ExactProperties $response.data $expectedData) -or
|
|
$response.data.packageType -ne 'workflow_read_contract_evidence' -or
|
|
$response.data.schemaVersion -ne '1.0' -or
|
|
$response.data.workflow -cne $ExpectedWorkflow -or
|
|
$response.data.moduleCode -cne $ExpectedModule -or
|
|
$response.data.integrityValid -ne $true -or
|
|
$response.data.verified -ne $true -or
|
|
$response.data.scopeBindingVerified -ne $true -or
|
|
$response.data.signatureVerified -ne $false -or
|
|
$response.data.registrationReady -ne $false) {
|
|
throw 'Read contract evidence is not verified or not bound to this acceptance scope.'
|
|
}
|
|
return $response.data
|
|
}
|
|
|
|
function Invoke-WriteEvidenceVerifier(
|
|
[string]$CliPath,
|
|
[string]$EvidencePath,
|
|
[string]$RuntimeHash) {
|
|
$cli = [System.IO.Path]::GetFullPath($CliPath)
|
|
if (-not [System.IO.File]::Exists($cli)) { throw 'Verifier CLI does not exist.' }
|
|
$info = New-Object System.IO.FileInfo($cli)
|
|
if ($info.Length -le 0 -or $info.Length -gt 64MB -or
|
|
(($info.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) -or
|
|
[System.IO.Path]::GetFileName($cli) -ne 'lserp-cli.exe') {
|
|
throw 'Verifier CLI must be a non-empty ordinary lserp-cli.exe file no larger than 64 MB.'
|
|
}
|
|
$arguments = @(
|
|
'adapters', 'verify-write-integration-evidence',
|
|
'--input', [System.IO.Path]::GetFullPath($EvidencePath),
|
|
'--workflow', $Workflow,
|
|
'--module', $ModuleCode,
|
|
'--account-book', $AccountBook,
|
|
'--subsystem', $SubSystemId,
|
|
'--runtime-sha256', $RuntimeHash,
|
|
'--source-commit', $ExpectedSourceCommit.ToLowerInvariant(),
|
|
'--package-sha256', $ExpectedPackageSha256.ToLowerInvariant()
|
|
)
|
|
$output = @(& $cli @arguments 2>&1)
|
|
$exitCode = $LASTEXITCODE
|
|
$text = (($output | ForEach-Object { [string]$_ }) -join [Environment]::NewLine)
|
|
if ($exitCode -ne 0) { throw 'Write integration evidence failed strict CLI verification.' }
|
|
try { $response = $text | ConvertFrom-Json }
|
|
catch { throw 'Verifier CLI did not return valid JSON.' }
|
|
$expectedData = @(
|
|
'evidenceType', 'schemaVersion', 'contentSha256', 'workflow', 'moduleCode',
|
|
'erpScope', 'sourceCommit', 'packageSha256', 'runtimeConfigurationSha256',
|
|
'uatAuthorizationSourceSha256', 'uatAuthorizationContentSha256',
|
|
'uatAuthorizationIdSha256',
|
|
'environmentId', 'testedAtUtc', 'testedBy', 'caseCount', 'verified',
|
|
'registrationReady'
|
|
)
|
|
if (-not (Test-ExactProperties $response @('ok', 'correlationId', 'data')) -or
|
|
$response.ok -ne $true -or
|
|
-not (Test-ExactProperties $response.data $expectedData) -or
|
|
-not (Test-ExactProperties $response.data.erpScope @(
|
|
'accountBook', 'subSystemId', 'userIdSha256', 'userNameSha256',
|
|
'databaseScopeFingerprint', 'isAdministrator')) -or
|
|
$response.data.evidenceType -ne 'workflow_write_integration' -or
|
|
$response.data.schemaVersion -ne '1.5' -or
|
|
$response.data.verified -ne $true -or
|
|
$response.data.registrationReady -ne $false -or
|
|
$response.data.workflow -ne $Workflow -or
|
|
$response.data.moduleCode -ne $ModuleCode -or
|
|
$response.data.erpScope.accountBook -ne $AccountBook -or
|
|
$response.data.erpScope.subSystemId -ne $SubSystemId -or
|
|
$response.data.runtimeConfigurationSha256 -ne $RuntimeHash.ToLowerInvariant() -or
|
|
$response.data.sourceCommit -ne $ExpectedSourceCommit.ToLowerInvariant() -or
|
|
$response.data.packageSha256 -ne $ExpectedPackageSha256.ToLowerInvariant() -or
|
|
[int]$response.data.caseCount -ne $(if ($Workflow -eq 'purchase') { 13 } else { 19 })) {
|
|
throw 'Verifier CLI response is not bound to this acceptance scope and artifact.'
|
|
}
|
|
return $response.data
|
|
}
|
|
|
|
function Invoke-CustomerProfileVerifier(
|
|
[string]$CliPath,
|
|
[string]$ProfilePath,
|
|
[string]$ExpectedHash,
|
|
[string]$ExpectedWorkflow,
|
|
[string]$ExpectedAccountBook,
|
|
[string]$ExpectedSubsystem,
|
|
[string]$User,
|
|
[System.Security.SecureString]$Password) {
|
|
$cli = [System.IO.Path]::GetFullPath($CliPath)
|
|
$profile = [System.IO.Path]::GetFullPath($ProfilePath)
|
|
$result = Invoke-AuthenticatedCli `
|
|
$cli `
|
|
@('adapters', 'revalidate-profile', '--input', $profile) `
|
|
$User $Password $ExpectedAccountBook $ExpectedSubsystem
|
|
if ($result.ExitCode -notin @(0, 6)) {
|
|
throw 'Customer profile failed online catalog revalidation.'
|
|
}
|
|
try { $response = $result.Text | ConvertFrom-Json }
|
|
catch { throw 'Customer profile verifier did not return valid JSON.' }
|
|
$expectedData = @(
|
|
'schemaVersion', 'profileType', 'profileSha256', 'profileSafetyValidated',
|
|
'metadataQueryScope', 'onlineMetadataMatches',
|
|
'criticalCatalogContractMatches', 'driftCodes', 'openActivationBlockerCount',
|
|
'workflowActivation', 'activationAllowed', 'registrationReady', 'note'
|
|
)
|
|
$allowedNonCriticalDriftCodes = @(
|
|
'profile_table_count_changed',
|
|
'profile_view_count_changed',
|
|
'profile_procedure_count_changed',
|
|
'profile_trigger_count_changed',
|
|
'profile_agent_object_state_changed'
|
|
)
|
|
$unexpectedDriftCodes = @($response.data.driftCodes | Where-Object {
|
|
[string]$_ -notin $allowedNonCriticalDriftCodes
|
|
})
|
|
try {
|
|
$strictUtf8 = New-Object System.Text.UTF8Encoding($false, $true)
|
|
$profileDocument = [System.IO.File]::ReadAllText($profile, $strictUtf8) |
|
|
ConvertFrom-Json
|
|
}
|
|
catch { throw 'Customer profile cannot be reread as strict UTF-8 JSON.' }
|
|
if (@($response.data.driftCodes) -contains 'profile_agent_object_state_changed' -and
|
|
$profileDocument.database.agentWorkflowObjectsPresent -ne $false) {
|
|
$unexpectedDriftCodes += 'profile_agent_object_state_changed'
|
|
}
|
|
$selectedActivation = if ($ExpectedWorkflow -eq 'purchase') {
|
|
$response.data.workflowActivation.purchase
|
|
}
|
|
else {
|
|
$response.data.workflowActivation.leave
|
|
}
|
|
if (-not (Test-ExactProperties $response @('ok', 'correlationId', 'data')) -or
|
|
$response.ok -ne $true -or
|
|
-not (Test-ExactProperties $response.data $expectedData) -or
|
|
-not (Test-ExactProperties $response.data.workflowActivation `
|
|
@('purchase', 'leave')) -or
|
|
-not (Test-ExactProperties $response.data.workflowActivation.purchase `
|
|
@('approved', 'openBlockerCount')) -or
|
|
-not (Test-ExactProperties $response.data.workflowActivation.leave `
|
|
@('approved', 'openBlockerCount')) -or
|
|
$response.data.schemaVersion -ne '1.2' -or
|
|
$response.data.profileType -ne 'readonly_low_code_metadata_review' -or
|
|
$response.data.profileSha256 -cne $ExpectedHash -or
|
|
$response.data.profileSafetyValidated -ne $true -or
|
|
$response.data.metadataQueryScope -ne 'system_catalog_only' -or
|
|
$response.data.criticalCatalogContractMatches -ne $true -or
|
|
$unexpectedDriftCodes.Count -ne 0 -or
|
|
$selectedActivation.approved -ne $true -or
|
|
[int]$selectedActivation.openBlockerCount -ne 0 -or
|
|
$response.data.activationAllowed -ne $false -or
|
|
$response.data.registrationReady -ne $false) {
|
|
throw 'Customer profile verifier response has unsafe identity, compatibility, or critical catalog drift.'
|
|
}
|
|
return $response.data
|
|
}
|
|
|
|
function Assert-ProfileResolutionBindings(
|
|
[string]$ProfilePath,
|
|
[string]$ExpectedWorkflow,
|
|
[string]$ExpectedModule,
|
|
[string]$FieldMappingHash,
|
|
[string]$ReadContractHash,
|
|
[string]$WriteIntegrationHash) {
|
|
try {
|
|
$strictUtf8 = New-Object System.Text.UTF8Encoding($false, $true)
|
|
$profile = [System.IO.File]::ReadAllText(
|
|
[System.IO.Path]::GetFullPath($ProfilePath),
|
|
$strictUtf8) | ConvertFrom-Json
|
|
}
|
|
catch {
|
|
throw 'Customer profile resolution bindings cannot be read as strict UTF-8 JSON.'
|
|
}
|
|
if ($profile.schemaVersion -cne '1.2') {
|
|
throw 'Customer profile resolution bindings require schemaVersion 1.2.'
|
|
}
|
|
|
|
if ($ExpectedWorkflow -ceq 'purchase') {
|
|
$profileModule = [string]$profile.purchaseTargetSelection.selectedModuleCode
|
|
$blockers = @($profile.purchaseActivationBlockers)
|
|
$expectedCodes = @(
|
|
'purchase_currency_field_not_configured',
|
|
'purchase_currency_crosswalk_not_approved',
|
|
'purchase_row_scope_not_approved',
|
|
'purchase_compat100_write_contract_not_approved',
|
|
'purchase_windows_integration_not_verified'
|
|
)
|
|
}
|
|
else {
|
|
$profileModule = [string]$profile.modules.leave.moduleCode
|
|
$blockers = @($profile.leaveActivationBlockers)
|
|
$expectedCodes = @(
|
|
'leave_flow_type_rules_stale',
|
|
'leave_agent_schema_not_deployed',
|
|
'leave_compat100_write_contract_not_approved',
|
|
'leave_windows_integration_not_verified'
|
|
)
|
|
}
|
|
if ($profileModule -cne $ExpectedModule) {
|
|
throw 'Customer profile workflow module is not bound to the acceptance module.'
|
|
}
|
|
if ($blockers.Count -ne $expectedCodes.Count) {
|
|
throw 'Customer profile blocker resolution set is incomplete or contains unexpected entries.'
|
|
}
|
|
|
|
$seenCodes = @()
|
|
foreach ($blocker in $blockers) {
|
|
if (-not (Test-ExactProperties $blocker `
|
|
@('code', 'status', 'resolution', 'evidence'))) {
|
|
throw 'Customer profile blocker resolution has an invalid fixed contract.'
|
|
}
|
|
$code = [string]$blocker.code
|
|
if ($expectedCodes -cnotcontains $code -or
|
|
$seenCodes -ccontains $code -or
|
|
[string]$blocker.status -cne 'resolved' -or
|
|
-not (Test-ExactProperties $blocker.resolution `
|
|
@('evidenceArtifact', 'evidenceSha256', 'approvedBy', 'approvedAtUtc'))) {
|
|
throw 'Customer profile blocker resolution set or status is invalid.'
|
|
}
|
|
$seenCodes += $code
|
|
|
|
$expectedArtifact = if ($code -ceq 'purchase_currency_field_not_configured') {
|
|
'field_mapping'
|
|
}
|
|
else {
|
|
'write_integration'
|
|
}
|
|
$artifact = [string]$blocker.resolution.evidenceArtifact
|
|
$expectedHash = $null
|
|
if ($artifact -ceq 'field_mapping') {
|
|
$expectedHash = $FieldMappingHash
|
|
}
|
|
elseif ($artifact -ceq 'read_contract') {
|
|
$expectedHash = $ReadContractHash
|
|
}
|
|
elseif ($artifact -ceq 'write_integration') {
|
|
$expectedHash = $WriteIntegrationHash
|
|
}
|
|
$approvedAt = [DateTime]::MinValue
|
|
$approvedAtValue = $blocker.resolution.approvedAtUtc
|
|
if ($approvedAtValue -is [DateTime]) {
|
|
# PowerShell 7 ConvertFrom-Json parses ISO timestamps eagerly. The
|
|
# locked profile has already passed the CLI's raw exact-format gate.
|
|
$approvedAt = [DateTime]$approvedAtValue
|
|
$approvedAtValid = $approvedAt.Kind -eq [DateTimeKind]::Utc
|
|
}
|
|
else {
|
|
$approvedAtValid = [DateTime]::TryParseExact(
|
|
[string]$approvedAtValue,
|
|
'o',
|
|
[Globalization.CultureInfo]::InvariantCulture,
|
|
[Globalization.DateTimeStyles]::RoundtripKind,
|
|
[ref]$approvedAt)
|
|
}
|
|
if ($artifact -cne $expectedArtifact) {
|
|
throw 'Customer profile blocker resolution uses the wrong evidence artifact kind.'
|
|
}
|
|
if ([string]::IsNullOrWhiteSpace([string]$expectedHash) -or
|
|
[string]$blocker.resolution.evidenceSha256 -cne $expectedHash) {
|
|
throw 'Customer profile blocker resolution is not bound to the exact signed evidence artifact.'
|
|
}
|
|
if ([string]::IsNullOrWhiteSpace([string]$blocker.resolution.approvedBy)) {
|
|
throw 'Customer profile blocker resolution has no approving identity.'
|
|
}
|
|
if (-not $approvedAtValid -or
|
|
$approvedAt.Kind -ne [DateTimeKind]::Utc) {
|
|
throw 'Customer profile blocker resolution approval time is not exact UTC round-trip format.'
|
|
}
|
|
}
|
|
return [pscustomobject]@{
|
|
Verified = $true
|
|
ResolvedBlockerCount = $seenCodes.Count
|
|
}
|
|
}
|
|
|
|
$inputDefinitions = @(
|
|
@($VerifierCliPath, 64MB, 'Verifier CLI'),
|
|
@($RuntimeConfigurationFile, 64KB, 'Runtime configuration'),
|
|
@($CustomerProfileFile, 1MB, 'Customer profile'),
|
|
@($FieldMappingEvidence, 1MB, 'Field mapping'),
|
|
@($ReadContractEvidence, 4MB, 'Read contract evidence'),
|
|
@($WriteIntegrationEvidence, 4MB, 'Write integration evidence')
|
|
)
|
|
$inputPaths = @($inputDefinitions | ForEach-Object {
|
|
[System.IO.Path]::GetFullPath([string]$_[0])
|
|
})
|
|
$fullOutputCandidate = [System.IO.Path]::GetFullPath($OutputPath)
|
|
if (@($inputPaths | Sort-Object -Unique).Count -ne $inputPaths.Count -or
|
|
$inputPaths -contains $fullOutputCandidate) {
|
|
throw 'Verifier, evidence inputs and output must all use distinct files.'
|
|
}
|
|
$inputLocks = New-Object 'System.Collections.Generic.List[System.IDisposable]'
|
|
try {
|
|
foreach ($definition in $inputDefinitions) {
|
|
$inputLocks.Add((Open-InputLock `
|
|
([string]$definition[0]) `
|
|
([long]$definition[1]) `
|
|
([string]$definition[2])))
|
|
}
|
|
}
|
|
catch {
|
|
foreach ($lock in $inputLocks) { $lock.Dispose() }
|
|
throw
|
|
}
|
|
|
|
try {
|
|
$issuedAt = [DateTime]::UtcNow
|
|
$expiresAt = $issuedAt.AddDays($ValidDays)
|
|
$thumbprint = ($CertificateThumbprint -replace '\s+', '').ToUpperInvariant()
|
|
$runtimeConfigurationHash = Get-FileSha256 `
|
|
$RuntimeConfigurationFile 64KB 'Runtime configuration'
|
|
$customerProfileHash = Get-FileSha256 `
|
|
$CustomerProfileFile 1MB 'Customer profile'
|
|
$profileVerification = Invoke-CustomerProfileVerifier `
|
|
$VerifierCliPath $CustomerProfileFile $customerProfileHash $Workflow `
|
|
$AccountBook $SubSystemId $ErpUser $ErpPassword
|
|
if ((Get-FileSha256 $CustomerProfileFile 1MB 'Customer profile') -ne
|
|
$customerProfileHash) {
|
|
throw 'Customer profile changed while it was being verified.'
|
|
}
|
|
$fieldMappingHash = Get-FileSha256 `
|
|
$FieldMappingEvidence 1MB 'Field mapping'
|
|
$fieldMappingVerification = Invoke-FieldMappingVerifier `
|
|
$VerifierCliPath $FieldMappingEvidence $Workflow $ModuleCode `
|
|
$AccountBook $SubSystemId $ErpUser $ErpPassword
|
|
if ((Get-FileSha256 $FieldMappingEvidence 1MB 'Field mapping') -ne
|
|
$fieldMappingHash) {
|
|
throw 'Field mapping changed while it was being verified.'
|
|
}
|
|
$readContractHash = Get-FileSha256 `
|
|
$ReadContractEvidence 4MB 'Read contract evidence'
|
|
$readContractVerification = Invoke-ReadContractEvidenceVerifier `
|
|
$VerifierCliPath $ReadContractEvidence $Workflow $ModuleCode `
|
|
$AccountBook $SubSystemId
|
|
if ((Get-FileSha256 $ReadContractEvidence 4MB 'Read contract evidence') -ne
|
|
$readContractHash) {
|
|
throw 'Read contract evidence changed while it was being verified.'
|
|
}
|
|
$writeIntegrationHash = Get-FileSha256 `
|
|
$WriteIntegrationEvidence 4MB 'Write integration evidence'
|
|
$writeVerification = Invoke-WriteEvidenceVerifier `
|
|
$VerifierCliPath $WriteIntegrationEvidence $runtimeConfigurationHash
|
|
if ((Get-FileSha256 $WriteIntegrationEvidence 4MB 'Write integration evidence') -ne
|
|
$writeIntegrationHash) {
|
|
throw 'Write integration evidence changed while it was being verified.'
|
|
}
|
|
$profileResolutionVerification = Assert-ProfileResolutionBindings `
|
|
$CustomerProfileFile $Workflow $ModuleCode $fieldMappingHash `
|
|
$readContractHash $writeIntegrationHash
|
|
$content = [ordered]@{
|
|
packageType = 'workflow_write_acceptance_evidence'
|
|
workflow = $Workflow
|
|
moduleCode = $ModuleCode
|
|
erpScope = [ordered]@{
|
|
accountBook = $AccountBook
|
|
subSystemId = $SubSystemId
|
|
}
|
|
adapterId = $AdapterId
|
|
adapterVersion = $AdapterVersion
|
|
evidenceId = $EvidenceId
|
|
runtimeConfigurationSha256 = $runtimeConfigurationHash
|
|
customerProfileSha256 = $customerProfileHash
|
|
fieldMappingSha256 = $fieldMappingHash
|
|
readContractEvidenceSha256 = $readContractHash
|
|
writeIntegrationEvidenceSha256 = $writeIntegrationHash
|
|
requirements = [ordered]@{
|
|
customerConfigurationValidated = $true
|
|
parameterizedReadQueriesVerified = $true
|
|
transactionalWriteVerified = $true
|
|
persistentIdempotencyVerified = $true
|
|
permissionRecheckVerified = $true
|
|
windowsIntegrationVerified = $true
|
|
criticalCatalogRuntimeRecheckVerified = $true
|
|
}
|
|
issuedAtUtc = $issuedAt.ToString('o')
|
|
expiresAtUtc = $expiresAt.ToString('o')
|
|
validatedBy = $ValidatedBy
|
|
note = '客户 Windows 验收完成;当前工作流画像阻断项已关闭,运行时配置、客户画像在线目录复核和三个输入证据文件的 SHA-256 已绑定。'
|
|
}
|
|
|
|
$canonicalContent = $content | ConvertTo-Json -Compress -Depth 10
|
|
$utf8 = New-Object System.Text.UTF8Encoding($false, $true)
|
|
$contentBytes = $utf8.GetBytes($canonicalContent)
|
|
$contentHash = Get-Sha256Hex $contentBytes
|
|
$certificate = Find-SigningCertificate $thumbprint
|
|
$rsa = $certificate.PrivateKey -as [System.Security.Cryptography.RSACryptoServiceProvider]
|
|
if ($null -eq $rsa) {
|
|
throw 'Signing certificate must expose an RSA CSP private key for the .NET Framework 4.0 client.'
|
|
}
|
|
$sha = [System.Security.Cryptography.SHA256]::Create()
|
|
try {
|
|
$digest = $sha.ComputeHash($contentBytes)
|
|
}
|
|
finally {
|
|
$sha.Dispose()
|
|
}
|
|
$signature = $rsa.SignHash(
|
|
$digest,
|
|
[System.Security.Cryptography.CryptoConfig]::MapNameToOID('SHA256'))
|
|
|
|
$package = [ordered]@{
|
|
schemaVersion = '1.1'
|
|
contentSha256 = $contentHash
|
|
signatureAlgorithm = 'rsa-sha256'
|
|
certificateThumbprint = $thumbprint
|
|
signatureBase64 = [Convert]::ToBase64String($signature)
|
|
content = $content
|
|
}
|
|
$body = $utf8.GetBytes(($package | ConvertTo-Json -Depth 10))
|
|
$fullOutput = [System.IO.Path]::GetFullPath($OutputPath)
|
|
$directory = [System.IO.Path]::GetDirectoryName($fullOutput)
|
|
if ([string]::IsNullOrWhiteSpace($directory) -or -not [System.IO.Directory]::Exists($directory)) {
|
|
throw "Output directory does not exist: $directory"
|
|
}
|
|
$stream = [System.IO.File]::Open(
|
|
$fullOutput,
|
|
[System.IO.FileMode]::CreateNew,
|
|
[System.IO.FileAccess]::Write,
|
|
[System.IO.FileShare]::None)
|
|
try {
|
|
$stream.Write($body, 0, $body.Length)
|
|
$stream.Flush()
|
|
}
|
|
finally {
|
|
$stream.Dispose()
|
|
}
|
|
|
|
[ordered]@{
|
|
outputFile = $fullOutput
|
|
workflow = $Workflow
|
|
moduleCode = $ModuleCode
|
|
accountBook = $AccountBook
|
|
subSystemId = $SubSystemId
|
|
evidenceId = $EvidenceId
|
|
evidenceSha256 = $contentHash
|
|
runtimeConfigurationSha256 = $content.runtimeConfigurationSha256
|
|
customerProfileSha256 = $content.customerProfileSha256
|
|
customerProfileOnlineMetadataMatches = $profileVerification.onlineMetadataMatches
|
|
customerProfileCriticalCatalogMatches = `
|
|
$profileVerification.criticalCatalogContractMatches
|
|
customerProfileResolutionBindingVerified = `
|
|
$profileResolutionVerification.Verified
|
|
customerProfileResolvedBlockerCount = `
|
|
$profileResolutionVerification.ResolvedBlockerCount
|
|
fieldMapReady = $fieldMappingVerification.fieldMapReady
|
|
readContractVerified = $readContractVerification.verified
|
|
validatedAtUtc = $issuedAt.ToString('o')
|
|
expiresAtUtc = $expiresAt.ToString('o')
|
|
certificateThumbprint = $thumbprint
|
|
sourceCommit = $writeVerification.sourceCommit
|
|
packageSha256 = $writeVerification.packageSha256
|
|
writeIntegrationCaseCount = $writeVerification.caseCount
|
|
nextStep = 'Use these exact values when inserting the V2 readiness row, then run lserp-cli adapters verify-acceptance-evidence.'
|
|
} | ConvertTo-Json -Depth 5
|
|
}
|
|
finally {
|
|
foreach ($lock in $inputLocks) { $lock.Dispose() }
|
|
}
|