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

372 lines
14 KiB
PowerShell

#requires -Version 5.1
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][ValidateLength(1, 128)]
[string]$AccountBook,
[Parameter(Mandatory = $true)][ValidateLength(1, 128)]
[string]$SubSystemId,
[Parameter(Mandatory = $true)]
[ValidatePattern('^[0-9a-f]{64}$')]
[string]$DatabaseScopeFingerprint,
[Parameter(Mandatory = $true)]
[ValidatePattern('^[A-Za-z0-9_.:-]{1,128}$')]
[string]$AdapterId,
[Parameter(Mandatory = $true)]
[ValidatePattern('^[A-Za-z0-9_.:-]{1,64}$')]
[string]$AdapterVersion,
[Parameter(Mandatory = $true)]
[ValidatePattern('^[A-Za-z0-9_.:-]{8,128}$')]
[string]$EvidenceId,
[Parameter(Mandatory = $true)][string]$ModulesPath,
[Parameter(Mandatory = $true)][ValidateLength(1, 128)]
[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,
[Parameter(Mandatory = $true)][switch]$CustomerConfigurationValidated,
[Parameter(Mandatory = $true)][switch]$RecordResolutionVerified,
[Parameter(Mandatory = $true)][switch]$SnapshotBindingVerified,
[Parameter(Mandatory = $true)][switch]$OptimisticConcurrencyVerified,
[Parameter(Mandatory = $true)][switch]$PartialUpdateVerified,
[Parameter(Mandatory = $true)][switch]$NativeValidationVerified,
[Parameter(Mandatory = $true)][switch]$ModuleHooksVerified,
[Parameter(Mandatory = $true)][switch]$TransactionalWriteVerified,
[Parameter(Mandatory = $true)][switch]$PersistentIdempotencyVerified,
[Parameter(Mandatory = $true)][switch]$PermissionRecheckVerified,
[Parameter(Mandatory = $true)][switch]$ConfigurationBindingVerified,
[Parameter(Mandatory = $true)][switch]$WindowsIntegrationVerified
)
Set-StrictMode -Version 2.0
$ErrorActionPreference = 'Stop'
function Assert-SafeText([string]$Value, [int]$Maximum, [string]$Name) {
if ([string]::IsNullOrWhiteSpace($Value) -or
$Value.Length -gt $Maximum -or
$Value -cne $Value.Trim()) {
throw "$Name is blank, padded or too long."
}
foreach ($character in $Value.ToCharArray()) {
if ([char]::IsControl($character)) {
throw "$Name contains a control character."
}
}
}
function Test-ExactProperties([object]$Value, [string[]]$Expected) {
if ($null -eq $Value) { return $false }
$actual = @($Value.PSObject.Properties | ForEach-Object { $_.Name })
if ($actual.Count -ne $Expected.Count) { return $false }
foreach ($name in $Expected) {
if ($actual -cnotcontains $name) { return $false }
}
return $true
}
function Get-Sha256Hex([byte[]]$Bytes) {
$sha = [Security.Cryptography.SHA256]::Create()
try {
return ([BitConverter]::ToString(
$sha.ComputeHash($Bytes))).Replace('-', '').ToLowerInvariant()
}
finally { $sha.Dispose() }
}
function Assert-StrictJsonText([string]$Text) {
$textReader = New-Object IO.StringReader($Text)
$jsonReader = New-Object Newtonsoft.Json.JsonTextReader($textReader)
$jsonReader.DateParseHandling = [Newtonsoft.Json.DateParseHandling]::None
$jsonReader.SupportMultipleContent = $true
$stack = New-Object Collections.Stack
$rootValues = 0
try {
while ($jsonReader.Read()) {
$token = [string]$jsonReader.TokenType
if ($token -eq 'Comment') {
throw 'Modules JSON comments are forbidden.'
}
if ($token -eq 'StartObject' -or $token -eq 'StartArray') {
if ($stack.Count -eq 0) { $rootValues++ }
$names = if ($token -eq 'StartObject') {
New-Object 'Collections.Generic.HashSet[string]' `
([StringComparer]::Ordinal)
}
else { $null }
$stack.Push([pscustomobject]@{
Kind = if ($token -eq 'StartObject') {
'object'
}
else { 'array' }
Names = $names
})
continue
}
if ($token -eq 'EndObject' -or $token -eq 'EndArray') {
if ($stack.Count -eq 0) {
throw 'Modules JSON container nesting is invalid.'
}
$expected = if ($token -eq 'EndObject') {
'object'
}
else { 'array' }
if ([string]$stack.Peek().Kind -cne $expected) {
throw 'Modules JSON container nesting is invalid.'
}
[void]$stack.Pop()
continue
}
if ($token -eq 'PropertyName') {
if ($stack.Count -eq 0 -or
[string]$stack.Peek().Kind -cne 'object' -or
-not $stack.Peek().Names.Add([string]$jsonReader.Value)) {
throw 'Modules JSON contains a duplicate or misplaced property.'
}
continue
}
if ($stack.Count -eq 0) { $rootValues++ }
}
if ($stack.Count -ne 0 -or $rootValues -ne 1) {
throw 'Modules JSON must contain exactly one complete root value.'
}
}
finally {
$jsonReader.Close()
$textReader.Dispose()
}
$insideString = $false
$escaped = $false
for ($index = 0; $index -lt $Text.Length; $index++) {
$character = $Text[$index]
if ($insideString) {
if ($escaped) { $escaped = $false; continue }
if ($character -eq '\') { $escaped = $true; continue }
if ($character -eq '"') { $insideString = $false }
continue
}
if ($character -eq '"') { $insideString = $true; continue }
if ($character -ne ',') { continue }
$next = $index + 1
while ($next -lt $Text.Length -and
[char]::IsWhiteSpace($Text[$next])) { $next++ }
if ($next -lt $Text.Length -and
($Text[$next] -eq '}' -or $Text[$next] -eq ']')) {
throw 'Modules JSON trailing commas are forbidden.'
}
}
}
function Read-StrictBaseModuleList([string]$Path) {
$full = [IO.Path]::GetFullPath($Path)
if (-not [IO.File]::Exists($full)) {
throw "Modules file does not exist: $full"
}
$info = New-Object IO.FileInfo($full)
if ($info.Length -le 0 -or $info.Length -gt 256KB -or
(($info.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) {
throw 'Modules file must be a non-empty ordinary file no larger than 256 KiB.'
}
$utf8 = New-Object Text.UTF8Encoding($false, $true)
$bytes = [IO.File]::ReadAllBytes($full)
if ($bytes.Length -ne $info.Length) {
throw 'Modules file changed while it was being read.'
}
try {
$rawText = $utf8.GetString($bytes)
Assert-StrictJsonText $rawText
$document = ($rawText | ConvertFrom-Json)
}
catch { throw 'Modules file is not valid strict UTF-8 JSON.' }
if (-not (Test-ExactProperties $document @('schemaVersion', 'modules')) -or
[string]$document.schemaVersion -cne '1.0') {
throw 'Modules file must contain only schemaVersion=1.0 and modules.'
}
$items = @($document.modules)
if ($items.Count -lt 1 -or $items.Count -gt 256) {
throw 'Modules list must contain between 1 and 256 entries.'
}
$seen = New-Object 'Collections.Generic.HashSet[string]' `
([StringComparer]::Ordinal)
$result = New-Object Collections.Generic.List[object]
foreach ($item in $items) {
if (-not (Test-ExactProperties $item `
@('moduleCode', 'moduleKind', 'configurationFingerprint',
'nativeSaveFamily',
'nativeExecutionProfileFingerprint'))) {
throw 'Every module must contain the exact configuration and native execution profile fields.'
}
$moduleCode = [string]$item.moduleCode
$configuration = [string]$item.configurationFingerprint
$nativeSaveFamily = [string]$item.nativeSaveFamily
$nativeExecution = [string]$item.nativeExecutionProfileFingerprint
if ($moduleCode -cnotmatch '^[A-Za-z0-9_.:-]{1,64}$' -or
-not $seen.Add($moduleCode) -or
[string]$item.moduleKind -cne 'base' -or
$configuration -cnotmatch '^[0-9a-f]{64}$' -or
$nativeSaveFamily -cnotmatch '^[A-Za-z0-9_.:-]{1,64}$' -or
$nativeExecution -cnotmatch '^[0-9a-f]{64}$') {
throw 'Dynamic update accepts only unique base modules with exact configuration and native execution hashes.'
}
$result.Add([ordered]@{
moduleCode = $moduleCode
moduleKind = 'base'
configurationFingerprint = $configuration
nativeSaveFamily = $nativeSaveFamily
nativeExecutionProfileFingerprint = $nativeExecution
})
}
return $result.ToArray()
}
function Find-SigningCertificate([string]$Thumbprint) {
$normalized = ($Thumbprint -replace '\s+', '').ToUpperInvariant()
if ($normalized -cnotmatch '^[A-F0-9]{40}$') {
throw 'Certificate thumbprint is invalid.'
}
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"
}
$now = Get-Date
if ($now -lt $certificate.NotBefore -or
$now -gt $certificate.NotAfter) {
throw "TrustedPeople certificate is not currently valid: $normalized"
}
return $certificate
}
}
throw "Certificate not found in CurrentUser/LocalMachine TrustedPeople: $normalized"
}
$confirmations = @(
$CustomerConfigurationValidated,
$RecordResolutionVerified,
$SnapshotBindingVerified,
$OptimisticConcurrencyVerified,
$PartialUpdateVerified,
$NativeValidationVerified,
$ModuleHooksVerified,
$TransactionalWriteVerified,
$PersistentIdempotencyVerified,
$PermissionRecheckVerified,
$ConfigurationBindingVerified,
$WindowsIntegrationVerified)
foreach ($confirmation in $confirmations) {
if (-not $confirmation.IsPresent) {
throw 'All twelve update acceptance confirmations must be explicitly supplied.'
}
}
Assert-SafeText $AccountBook 128 'AccountBook'
Assert-SafeText $SubSystemId 128 'SubSystemId'
Assert-SafeText $ValidatedBy 128 'ValidatedBy'
[object[]]$modules = @(Read-StrictBaseModuleList $ModulesPath)
$thumbprint = ($CertificateThumbprint -replace '\s+', '').ToUpperInvariant()
$issuedAt = [DateTime]::UtcNow
$expiresAt = $issuedAt.AddDays($ValidDays)
$content = [ordered]@{
packageType = 'dynamic_module_update_acceptance'
adapterId = $AdapterId
adapterVersion = $AdapterVersion
evidenceId = $EvidenceId
erpScope = [ordered]@{
accountBook = $AccountBook
subSystemId = $SubSystemId
databaseScopeFingerprint = $DatabaseScopeFingerprint
}
modules = $modules
requirements = [ordered]@{
customerConfigurationValidated = $true
recordResolutionVerified = $true
snapshotBindingVerified = $true
optimisticConcurrencyVerified = $true
partialUpdateVerified = $true
nativeValidationVerified = $true
moduleHooksVerified = $true
transactionalWriteVerified = $true
persistentIdempotencyVerified = $true
permissionRecheckVerified = $true
configurationBindingVerified = $true
windowsIntegrationVerified = $true
}
issuedAtUtc = $issuedAt.ToString('o')
expiresAtUtc = $expiresAt.ToString('o')
validatedBy = $ValidatedBy
note = '客户已在当前 Windows ERP、SQL Server 和精确低代码配置上完成基础档案并发修改验收。'
}
$utf8 = New-Object Text.UTF8Encoding($false, $true)
$canonical = $content | ConvertTo-Json -Compress -Depth 10
$contentBytes = $utf8.GetBytes($canonical)
$contentSha256 = Get-Sha256Hex $contentBytes
$certificate = Find-SigningCertificate $thumbprint
$rsa = $certificate.PrivateKey -as `
[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 = [Security.Cryptography.SHA256]::Create()
try { $digest = $sha.ComputeHash($contentBytes) }
finally { $sha.Dispose() }
$signature = $rsa.SignHash(
$digest,
[Security.Cryptography.CryptoConfig]::MapNameToOID('SHA256'))
$package = [ordered]@{
schemaVersion = '1.0'
contentSha256 = $contentSha256
signatureAlgorithm = 'rsa-sha256'
certificateThumbprint = $thumbprint
signatureBase64 = [Convert]::ToBase64String($signature)
content = $content
}
$body = $utf8.GetBytes(($package | ConvertTo-Json -Depth 10))
$fullOutput = [IO.Path]::GetFullPath($OutputPath)
$directory = [IO.Path]::GetDirectoryName($fullOutput)
if ([string]::IsNullOrWhiteSpace($directory) -or
-not [IO.Directory]::Exists($directory)) {
throw "Output directory does not exist: $directory"
}
$stream = [IO.File]::Open(
$fullOutput,
[IO.FileMode]::CreateNew,
[IO.FileAccess]::Write,
[IO.FileShare]::None)
try {
$stream.Write($body, 0, $body.Length)
$stream.Flush()
}
finally { $stream.Dispose() }
[ordered]@{
outputFile = $fullOutput
packageType = 'dynamic_module_update_acceptance'
moduleCount = $modules.Count
evidenceId = $EvidenceId
evidenceSha256 = $contentSha256
validatedAtUtc = $issuedAt.ToString('o')
expiresAtUtc = $expiresAt.ToString('o')
certificateThumbprint = $thumbprint
readinessProcedure = 'dbo.p_lserp_agent_module_update_acceptance_v2'
runtimeEnvironment = [ordered]@{
LSERP_DYNAMIC_MODULE_UPDATE_ENABLED = '1'
LSERP_DYNAMIC_MODULE_UPDATE_READINESS_SHA256 = $contentSha256
LSERP_DYNAMIC_MODULE_UPDATE_ACCEPTANCE_PATH = $fullOutput
}
nextStep = '由客户 DBA 使用每个基础档案的精确配置指纹、本输出 evidenceSha256 和 validatedAtUtc 调用更新验收过程;禁止授权给 ERP 日常账号。'
} | ConvertTo-Json -Depth 6