<# .SYNOPSIS 验证混淆发布脚本生成的 Obfuscar 配置满足仓库发布契约。 .DESCRIPTION 该测试使用临时空文件模拟发布目录,仅验证模块筛选、绝对路径和失败语义, 不会执行 dotnet publish,也不会要求测试环境安装 Obfuscar。 #> $ErrorActionPreference = 'Stop' $repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path $publishScriptPath = Join-Path $repositoryRoot 'scripts\Publish-Obfuscated.ps1' $publishProfilePath = Join-Path $repositoryRoot 'src\Flyshot.Server.Host\Properties\PublishProfiles\FolderProfile.pubxml' $temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) "flyshot-obfuscation-test-$([guid]::NewGuid().ToString('N'))" $inputDirectory = Join-Path $temporaryRoot 'input' $outputDirectory = Join-Path $temporaryRoot 'obfuscated' $configPath = Join-Path $temporaryRoot 'obfuscar.xml' $expectedAssemblies = @( 'Flyshot.Server.Host.dll' 'Flyshot.ControllerClientCompat.dll' 'Flyshot.Core.Config.dll' 'Flyshot.Core.Domain.dll' 'Flyshot.Core.Planning.dll' 'Flyshot.Core.Triggering.dll' 'Flyshot.Runtime.Fanuc.dll' 'Flyshot.Runtime.Common.dll' ) $futureAssemblyName = 'Flyshot.Future.Module.dll' try { $publishScriptContent = Get-Content -LiteralPath $publishScriptPath -Raw $publishScriptTokens = $null $publishScriptErrors = $null $publishScriptAst = [System.Management.Automation.Language.Parser]::ParseInput( $publishScriptContent, [ref]$publishScriptTokens, [ref]$publishScriptErrors) if ($publishScriptErrors.Count -ne 0) { throw "Publish-Obfuscated.ps1 存在 PowerShell 语法错误:$($publishScriptErrors[0].Message)" } $scriptParameters = @($publishScriptAst.ParamBlock.Parameters | ForEach-Object { $_.Name.VariablePath.UserPath }) if ('RuntimeIdentifiers' -in $scriptParameters -or 'ExistingRuntimeIdentifier' -in $scriptParameters) { throw '发布脚本不应再暴露运行平台或 RID 参数。' } if ($publishScriptContent -match "'-r'|--self-contained'\s*,?\s*'true'|win-x64|linux-x64") { throw '发布脚本默认发布流程不得再指定 RID 或生成自包含平台包。' } if ($publishScriptContent -notmatch '-p:UseAppHost=false') { throw '框架依赖发布必须关闭平台相关 apphost,只保留跨平台 DLL 入口。' } [xml]$publishProfile = Get-Content -LiteralPath $publishProfilePath -Raw $publishNamespace = [System.Xml.XmlNamespaceManager]::new($publishProfile.NameTable) $publishNamespace.AddNamespace('msb', $publishProfile.Project.NamespaceURI) $useAppHost = $publishProfile.SelectSingleNode( '/msb:Project/msb:PropertyGroup/msb:UseAppHost', $publishNamespace) if ($null -eq $useAppHost -or $useAppHost.InnerText -ne 'false') { throw 'FolderProfile.pubxml 必须关闭平台相关 apphost。' } $afterPublishTarget = $publishProfile.SelectSingleNode( '/msb:Project/msb:Target[@Name="ObfuscatePublishedFlyshotAssemblies"]', $publishNamespace) if ($null -eq $afterPublishTarget -or $afterPublishTarget.AfterTargets -ne 'Publish') { throw 'FolderProfile.pubxml 必须注册 Publish 完成后的混淆 Target。' } $publishExec = $afterPublishTarget.SelectSingleNode('msb:Exec', $publishNamespace) if ( $null -eq $publishExec -or $publishExec.Command -notmatch '-ObfuscateExistingPublish' -or $publishExec.Command -notmatch '\$\(PublishDir\)\.' -or $publishExec.Command -match 'RuntimeIdentifier' ) { throw 'Visual Studio 发布 Target 必须把 PublishDir 以安全的非反斜杠结尾形式交给混淆模式。' } New-Item -ItemType Directory -Path $inputDirectory -Force | Out-Null foreach ($assemblyName in $expectedAssemblies) { New-Item -ItemType File -Path (Join-Path $inputDirectory $assemblyName) -Force | Out-Null } # 模拟未来新增模块,证明脚本会自动保护所有 Flyshot.* 程序集,而不是只处理当前固定清单。 New-Item -ItemType File -Path (Join-Path $inputDirectory $futureAssemblyName) -Force | Out-Null # 第三方程序集用于证明配置生成器只选择 Flyshot 自研模块。 New-Item -ItemType File -Path (Join-Path $inputDirectory 'NLog.dll') -Force | Out-Null & pwsh -NoProfile -File $publishScriptPath ` -GenerateConfigOnly ` -InputDirectory $inputDirectory ` -ObfuscatedDirectory $outputDirectory ` -ConfigPath $configPath if ($LASTEXITCODE -ne 0) { throw "配置生成命令退出码不是 0:$LASTEXITCODE" } [xml]$configuration = Get-Content -LiteralPath $configPath -Raw $modulePaths = @($configuration.Obfuscator.Module | ForEach-Object { [string]$_.file }) $allExpectedAssemblies = @($expectedAssemblies) + $futureAssemblyName if ($modulePaths.Count -ne $allExpectedAssemblies.Count) { throw "预期生成 $($allExpectedAssemblies.Count) 个模块,实际为 $($modulePaths.Count) 个。" } foreach ($assemblyName in $allExpectedAssemblies) { $expectedPath = [System.IO.Path]::GetFullPath((Join-Path $inputDirectory $assemblyName)) if ($expectedPath -notin $modulePaths) { throw "Obfuscar 配置缺少自研程序集:$assemblyName" } } if ($modulePaths | Where-Object { [System.IO.Path]::GetFileName($_) -eq 'NLog.dll' }) { throw '第三方程序集 NLog.dll 不应进入 Obfuscar 模块列表。' } foreach ($path in $modulePaths) { if (-not [System.IO.Path]::IsPathFullyQualified($path)) { throw "Obfuscar 模块路径不是绝对路径:$path" } } foreach ($module in @($configuration.Obfuscator.Module)) { $anonymousTypeRule = @($module.SkipType | Where-Object { [string]$_.name -eq '^<>f__AnonymousType.*' }) if ($anonymousTypeRule.Count -ne 1) { throw "模块 $($module.file) 必须显式保留匿名类型元数据。" } if ( [string]$anonymousTypeRule[0].skipMethods -ne 'true' -or [string]$anonymousTypeRule[0].skipFields -ne 'true' -or [string]$anonymousTypeRule[0].skipProperties -ne 'true' -or [string]$anonymousTypeRule[0].skipEvents -ne 'true' ) { throw "模块 $($module.file) 的匿名类型排除规则不完整。" } } $assemblySearchPaths = @($configuration.Obfuscator.AssemblySearchPath | ForEach-Object { [string]$_.path }) if ($assemblySearchPaths.Count -lt 2) { throw 'Obfuscar 配置必须包含 .NETCore.App 与 ASP.NET Core 共享框架搜索目录。' } foreach ($assemblySearchPath in $assemblySearchPaths) { if (-not [System.IO.Path]::IsPathFullyQualified($assemblySearchPath)) { throw "共享框架搜索目录不是绝对路径:$assemblySearchPath" } } if (-not ($assemblySearchPaths | Where-Object { $_ -match 'Microsoft\.NETCore\.App' })) { throw 'Obfuscar 配置缺少 Microsoft.NETCore.App 搜索目录。' } if (-not ($assemblySearchPaths | Where-Object { $_ -match 'Microsoft\.AspNetCore\.App' })) { throw 'Obfuscar 配置缺少 Microsoft.AspNetCore.App 搜索目录。' } $variables = @{} foreach ($variable in $configuration.Obfuscator.Var) { $variables[[string]$variable.name] = [string]$variable.value } if ($variables['KeepPublicApi'] -ne 'true') { throw 'KeepPublicApi 必须为 true,以保护 ASP.NET Core 和公共契约。' } if ($variables['HidePrivateApi'] -ne 'true') { throw 'HidePrivateApi 必须为 true,以混淆内部实现。' } if ($variables['RenameProperties'] -ne 'false' -or $variables['RenameEvents'] -ne 'false') { throw '属性和事件重命名必须关闭,以保护配置绑定及序列化契约。' } if ($variables['SkipGenerated'] -ne 'true') { throw '编译器生成类型必须跳过混淆,以保护匿名 JSON 响应和异步状态机。' } if ($variables['SkipSpecialName'] -ne 'true') { throw 'SpecialName 成员必须跳过混淆,以保护属性、事件和框架反射约定。' } Remove-Item -LiteralPath (Join-Path $inputDirectory 'Flyshot.Runtime.Common.dll') -Force $missingAssemblyConfigPath = Join-Path $temporaryRoot 'missing-assembly.xml' & pwsh -NoProfile -File $publishScriptPath ` -GenerateConfigOnly ` -InputDirectory $inputDirectory ` -ObfuscatedDirectory $outputDirectory ` -ConfigPath $missingAssemblyConfigPath *> $null if ($LASTEXITCODE -eq 0) { throw '缺少预期的 Flyshot 程序集时,配置生成必须失败。' } Write-Host 'Publish-Obfuscated.Tests.ps1: PASS' } finally { if (Test-Path -LiteralPath $temporaryRoot) { Remove-Item -LiteralPath $temporaryRoot -Recurse -Force } }