372 lines
14 KiB
PowerShell
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
|