#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]$NativeValidationVerified, [Parameter(Mandatory = $true)][switch]$ServerDefaultsVerified, [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) { throw "$Name is blank 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 { $hex = [BitConverter]::ToString($sha.ComputeHash($Bytes)) return $hex.Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() } } function Assert-StrictJsonText([string]$Text) { # ConvertFrom-Json 在某些 Windows PowerShell 版本会容忍重复键或 # 尾随逗号。签发前先用 PowerShell 自带 JSON.NET 的流式令牌 # 读取器建立独立失败关闭门禁。 $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-StrictModuleList([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.' } $rawText = $utf8.GetString($bytes) try { 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 = [Collections.Generic.HashSet[string]]::new( [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 $moduleKind = [string]$item.moduleKind $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 $moduleKind -cnotin @('base', 'bill') -or $configuration -cnotmatch '^[0-9a-f]{64}$' -or $nativeSaveFamily -cnotmatch '^[A-Za-z0-9_.:-]{1,64}$' -or $nativeExecution -cnotmatch '^[0-9a-f]{64}$') { throw 'A module code, kind, configuration hash or native execution profile is invalid.' } $result.Add([ordered]@{ moduleCode = $moduleCode moduleKind = $moduleKind 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" } foreach ($confirmation in @( $CustomerConfigurationValidated, $NativeValidationVerified, $ServerDefaultsVerified, $ModuleHooksVerified, $TransactionalWriteVerified, $PersistentIdempotencyVerified, $PermissionRecheckVerified, $ConfigurationBindingVerified, $WindowsIntegrationVerified)) { if (-not $confirmation.IsPresent) { throw 'All nine acceptance confirmations must be explicitly supplied.' } } Assert-SafeText $AccountBook 128 'AccountBook' Assert-SafeText $SubSystemId 128 'SubSystemId' Assert-SafeText $ValidatedBy 128 'ValidatedBy' [object[]]$modules = @(Read-StrictModuleList $ModulesPath) $thumbprint = ($CertificateThumbprint -replace '\s+', '').ToUpperInvariant() $issuedAt = [DateTime]::UtcNow $expiresAt = $issuedAt.AddDays($ValidDays) $content = [ordered]@{ packageType = 'dynamic_module_write_acceptance' adapterId = $AdapterId adapterVersion = $AdapterVersion evidenceId = $EvidenceId erpScope = [ordered]@{ accountBook = $AccountBook subSystemId = $SubSystemId databaseScopeFingerprint = $DatabaseScopeFingerprint } modules = $modules requirements = [ordered]@{ customerConfigurationValidated = $true nativeValidationVerified = $true serverDefaultsVerified = $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_write_acceptance' moduleCount = $modules.Count evidenceId = $EvidenceId evidenceSha256 = $contentSha256 validatedAtUtc = $issuedAt.ToString('o') expiresAtUtc = $expiresAt.ToString('o') certificateThumbprint = $thumbprint readinessProcedure = 'dbo.p_lserp_agent_module_write_acceptance_v2' runtimeEnvironment = [ordered]@{ LSERP_DYNAMIC_MODULE_WRITE_ENABLED = '1' LSERP_DYNAMIC_MODULE_WRITE_READINESS_SHA256 = $contentSha256 LSERP_DYNAMIC_MODULE_WRITE_ACCEPTANCE_PATH = $fullOutput } nextStep = '由客户 DBA 使用每个模块的精确配置指纹、本输出 evidenceSha256 和 validatedAtUtc 调用验收过程;禁止授权给 ERP 日常账号。' } | ConvertTo-Json -Depth 6