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

754 lines
29 KiB
PowerShell

[CmdletBinding(DefaultParameterSetName = 'SqlCredential')]
param(
[Parameter(Mandatory = $true)]
[ValidateLength(1, 260)]
[string]$Server,
[Parameter(Mandatory = $true)]
[ValidatePattern('^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$')]
[string]$Database,
[Parameter(Mandatory = $true, ParameterSetName = 'SqlCredential')]
[Management.Automation.PSCredential]$Credential,
[Parameter(Mandatory = $true, ParameterSetName = 'WindowsCredential')]
[switch]$UseWindowsAuthentication,
[Parameter(Mandatory = $true)]
[string]$OutputPath,
[ValidateRange(5, 60)]
[int]$ConnectionTimeoutSeconds = 15,
[ValidateRange(5, 120)]
[int]$CommandTimeoutSeconds = 60
)
Set-StrictMode -Version 2.0
$ErrorActionPreference = 'Stop'
if ($PSVersionTable.PSVersion -lt [Version]'5.1' -or
[string]$PSVersionTable.PSEdition -ne 'Desktop' -or
[string]::IsNullOrWhiteSpace($env:SystemRoot)) {
throw 'lserp_select_only_catalog_failed:windows_powershell_51_required'
}
$utf8 = [Text.UTF8Encoding]::new($false, $true)
$maximumCatalogEntries = 100000
$maximumOutputBytes = 16 * 1024 * 1024
$maximumToolBytes = 2 * 1024 * 1024
$toolPath = [string]$PSCommandPath
$connection = $null
$connectionBuilder = $null
$metadataCommand = $null
$metadataReader = $null
$outputFull = $null
$outputCreated = $false
$published = $false
function Throw-CatalogError([string]$Code) {
throw ('lserp_select_only_catalog_failed:' + $Code)
}
function Assert-NoReparseDirectoryChain([string]$Directory, [string]$Code) {
try {
$current = [IO.DirectoryInfo]::new([IO.Path]::GetFullPath($Directory))
while ($null -ne $current) {
if (-not $current.Exists -or
(($current.Attributes -band
[IO.FileAttributes]::ReparsePoint) -ne 0)) {
Throw-CatalogError $Code
}
$current = $current.Parent
}
}
catch {
if ($_.Exception.Message.StartsWith(
'lserp_select_only_catalog_failed:')) { throw }
Throw-CatalogError $Code
}
}
function Resolve-NewJsonPath([string]$Path) {
try {
$full = [IO.Path]::GetFullPath($Path)
if ([IO.Path]::GetExtension($full) -ine '.json' -or
[IO.File]::Exists($full) -or
[IO.Directory]::Exists($full)) {
Throw-CatalogError 'output_path_invalid'
}
$directory = [IO.Path]::GetDirectoryName($full)
if ([string]::IsNullOrWhiteSpace($directory)) {
Throw-CatalogError 'output_path_invalid'
}
Assert-NoReparseDirectoryChain $directory 'output_path_invalid'
return $full
}
catch {
if ($_.Exception.Message.StartsWith(
'lserp_select_only_catalog_failed:')) { throw }
Throw-CatalogError 'output_path_invalid'
}
}
function Assert-SafeServer([string]$Value) {
if (-not [Text.RegularExpressions.Regex]::IsMatch(
$Value,
'^[A-Za-z0-9][A-Za-z0-9.-]{0,252}(?:,[1-9][0-9]{0,4})?$')) {
Throw-CatalogError 'server_invalid'
}
$comma = $Value.LastIndexOf(',')
if ($comma -ge 0) {
$port = 0
if (-not [int]::TryParse($Value.Substring($comma + 1), [ref]$port) -or
$port -lt 1 -or $port -gt 65535) {
Throw-CatalogError 'server_invalid'
}
}
}
function Assert-SafeText(
[string]$Value,
[int]$MinimumLength,
[int]$MaximumLength,
[string]$Code
) {
if ($null -eq $Value -or
$Value.Length -lt $MinimumLength -or
$Value.Length -gt $MaximumLength -or
-not [string]::Equals(
$Value,
$Value.Trim(),
[StringComparison]::Ordinal)) {
Throw-CatalogError $Code
}
foreach ($character in $Value.ToCharArray()) {
if ([char]::IsControl($character)) { Throw-CatalogError $Code }
}
}
function Get-Sha256Text([string]$Value) {
$algorithm = [Security.Cryptography.SHA256]::Create()
try {
$bytes = $utf8.GetBytes($Value)
return ([BitConverter]::ToString(
$algorithm.ComputeHash($bytes))).Replace('-', '').ToLowerInvariant()
}
finally {
$algorithm.Dispose()
}
}
function Read-Int64(
[Data.Common.DbDataReader]$Reader,
[string]$Name,
[long]$Minimum,
[long]$Maximum
) {
try {
$ordinal = $Reader.GetOrdinal($Name)
if ($Reader.IsDBNull($ordinal)) {
Throw-CatalogError 'query_contract_invalid'
}
$value = [Convert]::ToInt64(
$Reader.GetValue($ordinal),
[Globalization.CultureInfo]::InvariantCulture)
if ($value -lt $Minimum -or $value -gt $Maximum) {
Throw-CatalogError 'query_contract_invalid'
}
return $value
}
catch {
if ($_.Exception.Message.StartsWith(
'lserp_select_only_catalog_failed:')) { throw }
Throw-CatalogError 'query_contract_invalid'
}
}
function Read-Text(
[Data.Common.DbDataReader]$Reader,
[string]$Name,
[int]$MinimumLength,
[int]$MaximumLength
) {
try {
$ordinal = $Reader.GetOrdinal($Name)
if ($Reader.IsDBNull($ordinal)) {
Throw-CatalogError 'query_contract_invalid'
}
$value = [Convert]::ToString(
$Reader.GetValue($ordinal),
[Globalization.CultureInfo]::InvariantCulture)
Assert-SafeText $value $MinimumLength $MaximumLength `
'query_contract_invalid'
return $value
}
catch {
if ($_.Exception.Message.StartsWith(
'lserp_select_only_catalog_failed:')) { throw }
Throw-CatalogError 'query_contract_invalid'
}
}
function Get-CurrentToolFileSha256([string]$Path) {
try {
if ([string]::IsNullOrWhiteSpace($Path)) {
Throw-CatalogError 'tool_source_invalid'
}
$full = [IO.Path]::GetFullPath($Path)
$item = Get-Item -LiteralPath $full -Force
if ($item.PSIsContainer -or
$item.Length -le 0 -or
$item.Length -gt $maximumToolBytes -or
(($item.Attributes -band
[IO.FileAttributes]::ReparsePoint) -ne 0)) {
Throw-CatalogError 'tool_source_invalid'
}
Assert-NoReparseDirectoryChain $item.DirectoryName `
'tool_source_invalid'
$hash = (Get-FileHash -LiteralPath $full `
-Algorithm SHA256).Hash.ToLowerInvariant()
if ($hash -cnotmatch '^[0-9a-f]{64}$') {
Throw-CatalogError 'tool_source_invalid'
}
return $hash
}
catch {
if ($_.Exception.Message.StartsWith(
'lserp_select_only_catalog_failed:')) { throw }
Throw-CatalogError 'tool_source_invalid'
}
}
function Assert-SelectOnlyPermissionGate(
[Data.Common.DbConnection]$connection,
[string]$permissionQuery,
[int]$commandTimeoutSeconds
) {
$permissionCommand = $null
$permissionReader = $null
try {
$permissionCommand = $connection.CreateCommand()
$permissionCommand.CommandType = [Data.CommandType]::Text
$permissionCommand.CommandText = $permissionQuery
$permissionCommand.CommandTimeout = $commandTimeoutSeconds
$permissionReader = $permissionCommand.ExecuteReader(
[Data.CommandBehavior]::SingleResult)
if (-not $permissionReader.Read()) {
Throw-CatalogError 'permission_contract_invalid'
}
$permissionColumns = @(
'database_control', 'database_alter', 'database_insert',
'database_update', 'database_delete', 'database_execute',
'database_create_table', 'database_create_procedure',
'database_create_view', 'database_create_function',
'server_control', 'server_alter_login', 'server_impersonate_login',
'sysadmin_member', 'db_owner_member', 'db_ddladmin_member',
'db_datawriter_member', 'writable_object_count',
'executable_object_count', 'writable_schema_count'
)
foreach ($column in $permissionColumns) {
if ((Read-Int64 $permissionReader $column 0 10000000) -ne 0) {
Throw-CatalogError 'database_principal_not_select_only'
}
}
if ($permissionReader.Read() -or $permissionReader.NextResult()) {
Throw-CatalogError 'permission_contract_invalid'
}
}
finally {
if ($null -ne $permissionReader) { $permissionReader.Dispose() }
if ($null -ne $permissionCommand) { $permissionCommand.Dispose() }
}
}
function New-RestrictedFileSecurity {
try {
$currentUser = [Security.Principal.WindowsIdentity]::GetCurrent().User
$localSystem = [Security.Principal.SecurityIdentifier]::new(
[Security.Principal.WellKnownSidType]::LocalSystemSid,
$null)
$security = New-Object Security.AccessControl.FileSecurity
$security.SetOwner($currentUser)
$security.SetAccessRuleProtection($true, $false)
$allow = [Security.AccessControl.AccessControlType]::Allow
foreach ($identity in @($currentUser, $localSystem)) {
$rule = [Security.AccessControl.FileSystemAccessRule]::new(
$identity,
[Security.AccessControl.FileSystemRights]::FullControl,
$allow)
[void]$security.AddAccessRule($rule)
}
return $security
}
catch {
Throw-CatalogError 'output_acl_invalid'
}
}
function Publish-RestrictedJson([string]$Path, [string]$Json) {
$stream = $null
$writer = $null
try {
$bytes = $utf8.GetByteCount($Json)
if ($bytes -le 0 -or $bytes -gt $maximumOutputBytes) {
Throw-CatalogError 'output_size_invalid'
}
$security = New-RestrictedFileSecurity
$stream = [IO.FileStream]::new(
$Path,
[IO.FileMode]::CreateNew,
[Security.AccessControl.FileSystemRights]::Write,
[IO.FileShare]::None,
4096,
[IO.FileOptions]::WriteThrough,
$security)
$script:outputCreated = $true
$writer = [IO.StreamWriter]::new($stream, $utf8, 4096, $false)
$stream = $null
$writer.Write($Json)
$writer.Flush()
$writer.Dispose()
$writer = $null
$item = Get-Item -LiteralPath $Path -Force
if ($item.Length -le 0 -or $item.Length -gt $maximumOutputBytes -or
(($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) {
Throw-CatalogError 'output_publish_invalid'
}
$acl = [IO.File]::GetAccessControl($Path)
if (-not $acl.AreAccessRulesProtected) {
Throw-CatalogError 'output_acl_invalid'
}
}
finally {
if ($null -ne $writer) { $writer.Dispose() }
if ($null -ne $stream) { $stream.Dispose() }
}
}
$permissionQuery = @'
SELECT
CONVERT(int, ISNULL(HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'CONTROL'), 0)) AS database_control,
CONVERT(int, ISNULL(HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'ALTER'), 0)) AS database_alter,
CONVERT(int, ISNULL(HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'INSERT'), 0)) AS database_insert,
CONVERT(int, ISNULL(HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'UPDATE'), 0)) AS database_update,
CONVERT(int, ISNULL(HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'DELETE'), 0)) AS database_delete,
CONVERT(int, ISNULL(HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'EXECUTE'), 0)) AS database_execute,
CONVERT(int, ISNULL(HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'CREATE TABLE'), 0)) AS database_create_table,
CONVERT(int, ISNULL(HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'CREATE PROCEDURE'), 0)) AS database_create_procedure,
CONVERT(int, ISNULL(HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'CREATE VIEW'), 0)) AS database_create_view,
CONVERT(int, ISNULL(HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'CREATE FUNCTION'), 0)) AS database_create_function,
CONVERT(int, ISNULL(HAS_PERMS_BY_NAME(NULL, NULL, 'CONTROL SERVER'), 0)) AS server_control,
CONVERT(int, ISNULL(HAS_PERMS_BY_NAME(NULL, NULL, 'ALTER ANY LOGIN'), 0)) AS server_alter_login,
CONVERT(int, ISNULL(HAS_PERMS_BY_NAME(NULL, NULL, 'IMPERSONATE ANY LOGIN'), 0)) AS server_impersonate_login,
CONVERT(int, ISNULL(IS_SRVROLEMEMBER('sysadmin'), 0)) AS sysadmin_member,
CONVERT(int, ISNULL(IS_MEMBER('db_owner'), 0)) AS db_owner_member,
CONVERT(int, ISNULL(IS_MEMBER('db_ddladmin'), 0)) AS db_ddladmin_member,
CONVERT(int, ISNULL(IS_MEMBER('db_datawriter'), 0)) AS db_datawriter_member,
CONVERT(bigint,
(
SELECT COUNT_BIG(1)
FROM sys.objects AS writable_object
WHERE writable_object.is_ms_shipped = 0
AND writable_object.type IN ('U', 'V')
AND
(
ISNULL(HAS_PERMS_BY_NAME(
QUOTENAME(SCHEMA_NAME(writable_object.schema_id)) + N'.' +
QUOTENAME(writable_object.name), 'OBJECT', 'INSERT'), 0) = 1
OR ISNULL(HAS_PERMS_BY_NAME(
QUOTENAME(SCHEMA_NAME(writable_object.schema_id)) + N'.' +
QUOTENAME(writable_object.name), 'OBJECT', 'UPDATE'), 0) = 1
OR ISNULL(HAS_PERMS_BY_NAME(
QUOTENAME(SCHEMA_NAME(writable_object.schema_id)) + N'.' +
QUOTENAME(writable_object.name), 'OBJECT', 'DELETE'), 0) = 1
OR ISNULL(HAS_PERMS_BY_NAME(
QUOTENAME(SCHEMA_NAME(writable_object.schema_id)) + N'.' +
QUOTENAME(writable_object.name), 'OBJECT', 'ALTER'), 0) = 1
OR ISNULL(HAS_PERMS_BY_NAME(
QUOTENAME(SCHEMA_NAME(writable_object.schema_id)) + N'.' +
QUOTENAME(writable_object.name), 'OBJECT', 'CONTROL'), 0) = 1
)
)) AS writable_object_count,
CONVERT(bigint,
(
SELECT COUNT_BIG(1)
FROM sys.objects AS executable_object
WHERE executable_object.is_ms_shipped = 0
AND executable_object.type IN ('P', 'PC', 'FN', 'IF', 'TF')
AND
(
ISNULL(HAS_PERMS_BY_NAME(
QUOTENAME(SCHEMA_NAME(executable_object.schema_id)) + N'.' +
QUOTENAME(executable_object.name), 'OBJECT', 'EXECUTE'), 0) = 1
OR ISNULL(HAS_PERMS_BY_NAME(
QUOTENAME(SCHEMA_NAME(executable_object.schema_id)) + N'.' +
QUOTENAME(executable_object.name), 'OBJECT', 'ALTER'), 0) = 1
OR ISNULL(HAS_PERMS_BY_NAME(
QUOTENAME(SCHEMA_NAME(executable_object.schema_id)) + N'.' +
QUOTENAME(executable_object.name), 'OBJECT', 'CONTROL'), 0) = 1
)
)) AS executable_object_count,
CONVERT(bigint,
(
SELECT COUNT_BIG(1)
FROM sys.schemas AS writable_schema
WHERE writable_schema.name NOT IN (N'sys', N'INFORMATION_SCHEMA')
AND
(
ISNULL(HAS_PERMS_BY_NAME(
QUOTENAME(writable_schema.name), 'SCHEMA', 'ALTER'), 0) = 1
OR ISNULL(HAS_PERMS_BY_NAME(
QUOTENAME(writable_schema.name), 'SCHEMA', 'CONTROL'), 0) = 1
)
)) AS writable_schema_count;
'@
$metadataQuery = @'
SELECT
CONVERT(nvarchar(128), DB_NAME()) AS database_name,
CONVERT(nvarchar(128), SERVERPROPERTY('ServerName')) AS actual_server_name,
CONVERT(nvarchar(128), SUSER_SNAME()) AS effective_principal,
CONVERT(int, SERVERPROPERTY('ProductMajorVersion')) AS sql_server_major_version,
CONVERT(int, current_database.compatibility_level) AS compatibility_level,
CONVERT(bigint, (SELECT COUNT_BIG(1) FROM sys.tables WHERE is_ms_shipped = 0)) AS user_table_count,
CONVERT(bigint, (SELECT COUNT_BIG(1) FROM sys.views WHERE is_ms_shipped = 0)) AS user_view_count,
CONVERT(bigint, (SELECT COUNT_BIG(1) FROM sys.procedures WHERE is_ms_shipped = 0)) AS user_procedure_count,
CONVERT(bigint, (SELECT COUNT_BIG(1) FROM sys.triggers WHERE is_ms_shipped = 0)) AS user_trigger_count,
CONVERT(bigint,
(
SELECT COUNT_BIG(1)
FROM sys.objects
WHERE schema_id = SCHEMA_ID(N'dbo')
AND
(
(type = 'U' AND name IN
(
N'p_agent_business_audit',
N'p_agent_business_source_document',
N'p_agent_command_idempotency',
N'p_agent_integration_outbox',
N'p_agent_purchase_currency_crosswalk',
N'p_agent_purchase_row_scope',
N'p_agent_workflow_adapter_evidence',
N'p_agent_workflow_adapter_evidence_v2'
))
OR
(type = 'P' AND name IN
(
N'p_lserp_agent_workflow_read',
N'p_lserp_agent_workflow_read_compat100',
N'p_lserp_agent_workflow_readiness',
N'p_lserp_agent_workflow_readiness_v2',
N'p_lserp_agent_workflow_readiness_v3',
N'p_lserp_agent_workflow_write',
N'p_lserp_agent_workflow_write_leave_compat100',
N'p_lserp_agent_workflow_write_purchase_compat100'
))
)
)) AS agent_workflow_object_count
FROM sys.databases AS current_database
WHERE current_database.database_id = DB_ID();
SELECT TOP (100001)
catalog_entry.entry_kind,
catalog_entry.schema_name,
catalog_entry.object_name,
catalog_entry.object_kind,
catalog_entry.member_name
FROM
(
SELECT
CONVERT(varchar(16), 'object') AS entry_kind,
CONVERT(nvarchar(128), SCHEMA_NAME(catalog_object.schema_id)) AS schema_name,
CONVERT(nvarchar(128), catalog_object.name) AS object_name,
CONVERT(varchar(16), CASE catalog_object.type
WHEN 'U' THEN 'table'
WHEN 'V' THEN 'view'
WHEN 'P' THEN 'procedure'
ELSE 'invalid' END) AS object_kind,
CONVERT(nvarchar(128), N'') AS member_name
FROM sys.objects AS catalog_object
WHERE catalog_object.is_ms_shipped = 0
AND catalog_object.type IN ('U', 'V', 'P')
UNION ALL
SELECT
CONVERT(varchar(16), 'column'),
CONVERT(nvarchar(128), SCHEMA_NAME(catalog_object.schema_id)),
CONVERT(nvarchar(128), catalog_object.name),
CONVERT(varchar(16), CASE catalog_object.type
WHEN 'U' THEN 'table'
WHEN 'V' THEN 'view'
ELSE 'invalid' END),
CONVERT(nvarchar(128), catalog_column.name)
FROM sys.objects AS catalog_object
INNER JOIN sys.columns AS catalog_column
ON catalog_column.object_id = catalog_object.object_id
WHERE catalog_object.is_ms_shipped = 0
AND catalog_object.type IN ('U', 'V')
UNION ALL
SELECT
CONVERT(varchar(16), 'parameter'),
CONVERT(nvarchar(128), SCHEMA_NAME(catalog_procedure.schema_id)),
CONVERT(nvarchar(128), catalog_procedure.name),
CONVERT(varchar(16), 'procedure'),
CONVERT(nvarchar(128), catalog_parameter.name)
FROM sys.procedures AS catalog_procedure
INNER JOIN sys.parameters AS catalog_parameter
ON catalog_parameter.object_id = catalog_procedure.object_id
AND catalog_parameter.parameter_id > 0
WHERE catalog_procedure.is_ms_shipped = 0
) AS catalog_entry
ORDER BY
catalog_entry.object_kind,
catalog_entry.schema_name,
catalog_entry.object_name,
catalog_entry.entry_kind,
catalog_entry.member_name;
'@
try {
Assert-SafeServer $Server
$outputFull = Resolve-NewJsonPath $OutputPath
$executedScriptText = $null
try {
$executedScriptText = [string]
$MyInvocation.MyCommand.ScriptBlock.Ast.Extent.Text
}
catch {
Throw-CatalogError 'tool_source_invalid'
}
if ([string]::IsNullOrWhiteSpace($executedScriptText) -or
$utf8.GetByteCount($executedScriptText) -gt $maximumToolBytes) {
Throw-CatalogError 'tool_source_invalid'
}
$executedToolSha256 = Get-Sha256Text $executedScriptText
$initialToolFileSha256 = Get-CurrentToolFileSha256 $toolPath
if ($initialToolFileSha256 -cne $executedToolSha256) {
Throw-CatalogError 'tool_source_changed'
}
if ($null -ne $Credential) {
Assert-SafeText $Credential.UserName 1 128 'credential_user_invalid'
}
$connectionBuilder = [System.Data.SqlClient.SqlConnectionStringBuilder]::new()
$connectionBuilder.DataSource = $Server
$connectionBuilder.InitialCatalog = $Database
$connectionBuilder.ConnectTimeout = $ConnectionTimeoutSeconds
$connectionBuilder.Encrypt = $true
$connectionBuilder.TrustServerCertificate = $false
$connectionBuilder.ApplicationIntent =
[System.Data.SqlClient.ApplicationIntent]::ReadOnly
$connectionBuilder.PersistSecurityInfo = $false
$connectionBuilder.Pooling = $false
$connectionBuilder.MultipleActiveResultSets = $false
$connectionBuilder.ApplicationName =
'Langsu Lserp SELECT-only catalog snapshot'
if ($PSCmdlet.ParameterSetName -eq 'WindowsCredential') {
$connectionBuilder.IntegratedSecurity = $true
}
else {
$connectionBuilder.IntegratedSecurity = $false
$connectionBuilder.UserID = $Credential.UserName
$connectionBuilder.Password = $Credential.GetNetworkCredential().Password
}
$connection = [System.Data.SqlClient.SqlConnection]::new(
$connectionBuilder.ConnectionString)
$connection.Open()
Assert-SelectOnlyPermissionGate `
$connection $permissionQuery $CommandTimeoutSeconds
$metadataCommand = $connection.CreateCommand()
$metadataCommand.CommandType = [Data.CommandType]::Text
$metadataCommand.CommandText = $metadataQuery
$metadataCommand.CommandTimeout = $CommandTimeoutSeconds
$metadataReader = $metadataCommand.ExecuteReader(
[Data.CommandBehavior]::SequentialAccess)
if (-not $metadataReader.Read()) {
Throw-CatalogError 'metadata_contract_invalid'
}
$actualDatabase = Read-Text $metadataReader 'database_name' 1 128
$actualServer = Read-Text $metadataReader 'actual_server_name' 1 128
$effectivePrincipal = Read-Text $metadataReader 'effective_principal' 1 128
if (-not [string]::Equals(
$actualDatabase,
$Database,
[StringComparison]::OrdinalIgnoreCase)) {
Throw-CatalogError 'database_identity_mismatch'
}
$sqlServerMajorVersion = Read-Int64 `
$metadataReader 'sql_server_major_version' 9 99
$compatibilityLevel = Read-Int64 `
$metadataReader 'compatibility_level' 80 200
$userTableCount = Read-Int64 `
$metadataReader 'user_table_count' 0 10000000
$userViewCount = Read-Int64 `
$metadataReader 'user_view_count' 0 10000000
$userProcedureCount = Read-Int64 `
$metadataReader 'user_procedure_count' 0 10000000
$userTriggerCount = Read-Int64 `
$metadataReader 'user_trigger_count' 0 10000000
$agentWorkflowObjectCount = Read-Int64 `
$metadataReader 'agent_workflow_object_count' 0 16
if ($metadataReader.Read() -or -not $metadataReader.NextResult()) {
Throw-CatalogError 'metadata_contract_invalid'
}
$catalogHashSet = [Collections.Generic.HashSet[string]]::new(
[StringComparer]::Ordinal)
while ($metadataReader.Read()) {
if ($catalogHashSet.Count -ge $maximumCatalogEntries) {
Throw-CatalogError 'catalog_too_large'
}
$entryKind = Read-Text $metadataReader 'entry_kind' 1 16
$schemaName = Read-Text $metadataReader 'schema_name' 1 128
$objectName = Read-Text $metadataReader 'object_name' 1 128
$objectKind = Read-Text $metadataReader 'object_kind' 1 16
$memberMinimum = if ($entryKind -eq 'object') { 0 } else { 1 }
$memberName = Read-Text `
$metadataReader 'member_name' $memberMinimum 128
if ($entryKind -cnotin @('object', 'column', 'parameter') -or
$objectKind -cnotin @('table', 'view', 'procedure') -or
($entryKind -eq 'object' -and $memberName.Length -ne 0) -or
($entryKind -eq 'column' -and $objectKind -eq 'procedure') -or
($entryKind -eq 'parameter' -and $objectKind -ne 'procedure')) {
Throw-CatalogError 'catalog_contract_invalid'
}
$catalogKey = [string]::Join(
[char]0x1f,
@(
$entryKind,
$schemaName.ToLowerInvariant(),
$objectName.ToLowerInvariant(),
$objectKind,
$memberName.ToLowerInvariant()
))
$entryHash = Get-Sha256Text $catalogKey
if (-not $catalogHashSet.Add($entryHash)) {
Throw-CatalogError 'catalog_contract_invalid'
}
}
if ($catalogHashSet.Count -le 0 -or $metadataReader.NextResult()) {
Throw-CatalogError 'metadata_contract_invalid'
}
$metadataReader.Dispose()
$metadataReader = $null
Assert-SelectOnlyPermissionGate `
$connection $permissionQuery $CommandTimeoutSeconds
$finalToolFileSha256 = Get-CurrentToolFileSha256 $toolPath
if ($finalToolFileSha256 -cne $initialToolFileSha256 -or
$finalToolFileSha256 -cne $executedToolSha256) {
Throw-CatalogError 'tool_source_changed'
}
[string[]]$catalogHashes = @($catalogHashSet)
[Array]::Sort($catalogHashes, [StringComparer]::Ordinal)
$catalogSetSha256 = Get-Sha256Text(
[string]::Join("`n", $catalogHashes))
$toolSha256 = $executedToolSha256
$databaseScopeFingerprint = Get-Sha256Text([string]::Join(
"`n",
@(
'sqlserver-select-only-catalog-v1',
$Server.ToLowerInvariant(),
$actualServer.ToLowerInvariant(),
$actualDatabase.ToLowerInvariant(),
$effectivePrincipal.ToLowerInvariant()
)))
$snapshot = [ordered]@{
schemaVersion = '1.1'
snapshotType = 'select_only_sqlserver_catalog_hashes'
generatedAtUtc = [DateTime]::UtcNow.ToString(
'yyyy-MM-ddTHH:mm:ss.fffffffZ',
[Globalization.CultureInfo]::InvariantCulture)
toolSha256 = $toolSha256
databaseScopeFingerprint = $databaseScopeFingerprint
identity = [ordered]@{
requestedServerSha256 = Get-Sha256Text ($Server.ToLowerInvariant())
actualServerSha256 = Get-Sha256Text ($actualServer.ToLowerInvariant())
databaseNameSha256 = Get-Sha256Text ($actualDatabase.ToLowerInvariant())
effectivePrincipalSha256 = Get-Sha256Text `
($effectivePrincipal.ToLowerInvariant())
}
database = [ordered]@{
sqlServerMajorVersion = $sqlServerMajorVersion
compatibilityLevel = $compatibilityLevel
userTableCount = $userTableCount
userViewCount = $userViewCount
userProcedureCount = $userProcedureCount
userTriggerCount = $userTriggerCount
agentWorkflowObjectCount = $agentWorkflowObjectCount
agentWorkflowObjectsPresent = ($agentWorkflowObjectCount -eq 16)
}
permissionGate = [ordered]@{
passed = $true
checkedBeforeCatalogRead = $true
checkedAfterCatalogRead = $true
databaseWritePermissionCount = 0
serverWritePermissionCount = 0
writableObjectCount = 0
executableObjectCount = 0
writableSchemaCount = 0
}
catalog = [ordered]@{
canonicalization = 'lower_invariant_unit_separator_v1'
entryCount = $catalogHashes.Count
setSha256 = $catalogSetSha256
entrySha256 = $catalogHashes
}
safety = [ordered]@{
connectionEncrypted = $true
serverCertificateValidated = $true
applicationIntent = 'ReadOnly'
effectivePrincipalSelectOnly = $true
toolSourceBytesStable = $true
businessRowsRead = $false
storedProceduresExecuted = $false
writesAttempted = $false
}
}
$json = ConvertTo-Json -InputObject $snapshot -Depth 8 -Compress
Publish-RestrictedJson $outputFull $json
$published = $true
$snapshotSha256 = (Get-FileHash -LiteralPath $outputFull `
-Algorithm SHA256).Hash.ToLowerInvariant()
[pscustomobject][ordered]@{
schemaVersion = '1.1'
passed = $true
code = 'ok'
outputPath = $outputFull
snapshotSha256 = $snapshotSha256
databaseScopeFingerprint = $databaseScopeFingerprint
catalogEntryCount = $catalogHashes.Count
catalogSetSha256 = $catalogSetSha256
}
}
catch {
if ($_.Exception.Message.StartsWith(
'lserp_select_only_catalog_failed:')) { throw }
Throw-CatalogError 'catalog_snapshot_failed'
}
finally {
if ($null -ne $metadataReader) { $metadataReader.Dispose() }
if ($null -ne $metadataCommand) { $metadataCommand.Dispose() }
if ($null -ne $connection) {
try { $connection.Close() } catch { }
$connection.Dispose()
}
if ($null -ne $connectionBuilder) {
$connectionBuilder.Password = [string]::Empty
$connectionBuilder.UserID = [string]::Empty
}
if ($outputCreated -and -not $published -and
$null -ne $outputFull -and [IO.File]::Exists($outputFull)) {
try { [IO.File]::Delete($outputFull) } catch { }
}
}