diff --git a/.gitignore b/.gitignore index 72a39a3..c88f4a3 100644 --- a/.gitignore +++ b/.gitignore @@ -400,3 +400,8 @@ Config/Data/* .dotnet-home/* codex-dotnet-home/* .dotnet-sdk8/* + +# Flyshot 混淆发布产物、映射文件和临时工作目录 +outputs/obfuscated/ +outputs/obfuscation-maps/ +.tmp/obfuscation/ diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 140df5e..9041cf7 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -142,19 +142,16 @@ } }, { - // Release 配置发布到 publish/,用于现场部署包打包 + // 调用环境中的 Obfuscar,生成 Windows/Linux 两套自包含混淆发布包 "label": "publish", - "command": "dotnet", + "command": "pwsh", "type": "process", "args": [ - "publish", - "${workspaceFolder}/src/Flyshot.Server.Host/Flyshot.Server.Host.csproj", - "-c", - "Release", - "-o", - "${workspaceFolder}/publish" + "-NoProfile", + "-File", + "${workspaceFolder}/scripts/Publish-Obfuscated.ps1" ], - "problemMatcher": "$msCompile" + "problemMatcher": [] } ] } diff --git a/README.md b/README.md index a0499e7..a5f9a40 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ - [x] 将 `ExecuteTrajectory` / `ExecuteFlyShotTraj` 接入 FANUC 运行时链路 - [x] 落地 Web 状态页 - [x] 落地浏览器内 OpenAPI 自动驱动的接口调试页(`/debug`),与 `Swagger:Enabled` 同步可见 +- [x] 调试页的人类可读说明文本已静态内嵌到 `debug.html`,即使发布产物删除 XML 文档也能继续展示接口提示 - [x] 固化 `10010 / 10012 / 60015` FANUC 基础协议帧编解码,确认 `10010` 状态帧为 90B - [x] 使用本地 TCP/UDP 模拟器覆盖命令通道、状态通道和 J519 基础收发 - [x] 补齐 `Get/SetSpeedRatio`、`Get/SetTCP`、`Get/SetIO` 真机命令体与响应解析 @@ -122,6 +123,48 @@ - [ ] 增加运行日志、协议摘要日志和状态快照导出,便于现场排查。 7. 发布与部署 + - [x] 增加 Obfuscar 跨平台混淆发布脚本,全部 `Flyshot.*` 自研程序集会生成一份不区分运行平台的 .NET 8 框架依赖部署包。 - [ ] 固化 Windows / Linux 启动脚本和 systemd 服务配置。 - [ ] 补充生产配置模板、端口说明和现场部署检查表。 - [ ] 给 Web 状态页增加程序状态和最近报警显示;J519 状态位已通过快照和状态页显示。 + +## 混淆发布 + +发布脚本直接依赖构建环境,不在仓库中引用或还原 Obfuscar 包。首次使用前安装全局工具: + +```powershell +dotnet tool install --global Obfuscar.GlobalTool --version 2.2.50 +``` + +安装后确认 `obfuscar.console` 已加入当前终端的 `PATH`,然后在仓库根目录执行: + +```powershell +pwsh -NoProfile -File .\scripts\Publish-Obfuscated.ps1 +``` + +也可以在 VS Code 中运行 `publish` 任务。脚本不再接受 RID 或运行平台参数,默认生成: + +- `outputs/obfuscated/framework-dependent/`:Windows 与 Linux 共用的 .NET 8 框架依赖混淆部署包。 +- `outputs/obfuscation-maps/framework-dependent/Mapping.txt`:仅供内部还原异常堆栈使用,不得交付给用户。 + +Visual Studio 的 `FolderProfile` 已接入同一套混淆流程。在“发布”页面选择 `FolderProfile` 并点击“发布”后,Visual Studio 会生成 .NET 8 框架依赖多文件包,再自动混淆发布目录中的全部 `Flyshot.*.dll`。如果环境缺少 `obfuscar.console`、缺少对应的 .NET 8 共享框架或混淆失败,Visual Studio 发布会直接失败,不会静默留下未保护产物。 + +混淆范围包含发布目录中的全部 `Flyshot.*.dll` 自研程序集,并校验当前八个核心程序集不可缺失;后续新增的 `Flyshot.*` 模块也会自动纳入。第三方程序集不会被处理;公共 API、属性和事件名称会保留,以维持 ASP.NET Core 控制器发现、依赖注入、配置绑定、JSON 序列化和 Swagger 行为,内部类型、私有方法及字段会执行重命名。 + +若环境缺少 `obfuscar.console`、发布目录缺少任一自研程序集、混淆器返回失败或未生成完整输出,脚本会返回非零退出码并停止,不会把未完整保护的产物作为成功结果交付。 + +混淆完成后会删除发布目录中的 `Flyshot*.xml` XML 文档和 `Flyshot*.pdb` 调试符号,避免反编译器自动显示源码文档注释。生产环境 Swagger 因此不再展示来自 XML 文档的接口说明,但接口路由和调用不受影响。 + +启动部署包时必须先进入对应 RID 的发布目录,确保宿主能从当前目录加载 `appsettings.json`、`NLog.config` 和 `Config/`: + +```powershell +Set-Location .\outputs\obfuscated\framework-dependent +dotnet .\Flyshot.Server.Host.dll +``` + +```bash +cd outputs/obfuscated/framework-dependent +dotnet Flyshot.Server.Host.dll +``` + +Windows 与 Linux 目标机器都必须预先安装 .NET 8 ASP.NET Core Runtime。 diff --git a/docs/superpowers/plans/2026-06-22-cross-platform-obfuscation.md b/docs/superpowers/plans/2026-06-22-cross-platform-obfuscation.md new file mode 100644 index 0000000..3911cc7 --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-cross-platform-obfuscation.md @@ -0,0 +1,55 @@ +# Flyshot 跨平台混淆发布 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 为全部 `Flyshot.*` 自研程序集建立依赖环境 Obfuscar 的跨平台框架依赖混淆发布流程。 + +**Architecture:** 使用一个 PowerShell 入口编排 `dotnet publish`、绝对路径 Obfuscar XML 生成、混淆执行、产物完整性检查和映射文件隔离。发布脚本不修改项目运行时代码,也不引入混淆器包依赖。 + +**Tech Stack:** PowerShell 7、.NET 8 SDK、Obfuscar.GlobalTool 2.2.50、ASP.NET Core 自包含发布。 + +--- + +### Task 1: 发布脚本契约测试 + +**Files:** +- Create: `tests/Publishing/Publish-Obfuscated.Tests.ps1` + +- [ ] 编写测试夹具,创建包含八个 `Flyshot.*.dll` 和一个第三方 DLL 的临时发布目录。 +- [ ] 调用尚不存在的配置生成入口,断言测试因缺少 `scripts/Publish-Obfuscated.ps1` 而失败。 +- [ ] 保留测试入口,使后续实现可以在 `-GenerateConfigOnly` 模式下验证配置内容而不执行真实发布。 + +### Task 2: 双平台混淆发布脚本 + +**Files:** +- Create: `scripts/Publish-Obfuscated.ps1` + +- [ ] 定义带完整注释的参数、静态程序集清单和辅助函数。 +- [ ] 检查 PowerShell、`dotnet` 与 `obfuscar.console` 环境命令。 +- [ ] 使用 `dotnet publish -c Release -r --self-contained true -p:PublishSingleFile=false` 生成原始产物。 +- [ ] 为八个 `Flyshot.*.dll` 生成使用绝对路径的 Obfuscar XML。 +- [ ] 执行 Obfuscar,并检查每个输入模块都有混淆输出。 +- [ ] 将混淆 DLL/PDB 替换进发布目录,并将 `Mapping.txt` 隔离到 `outputs/obfuscation-maps//`。 +- [ ] 不传 RID,生成一份 Windows/Linux 共用的 .NET 8 框架依赖发布包。 + +### Task 3: 发布任务与文档 + +**Files:** +- Modify: `.vscode/tasks.json` +- Modify: `README.md` + +- [ ] 将 VS Code `publish` 任务改为调用 PowerShell 混淆发布脚本。 +- [ ] 在 README 发布章节记录环境安装、发布命令、输出目录和保护边界。 + +### Task 4: 验证 + +**Files:** +- Modify: `docs/superpowers/plans/2026-06-22-cross-platform-obfuscation.md` + +- [ ] 运行脚本契约测试并确认通过。 +- [ ] 运行 `dotnet test FlyshotReplacement.sln -c Release -v minimal`。 +- [ ] 运行 `dotnet build FlyshotReplacement.sln -c Release --no-restore -v minimal`。 +- [ ] 环境安装 Obfuscar 后执行 `pwsh -File scripts/Publish-Obfuscated.ps1`。 +- [ ] 启动 Windows 混淆产物并请求 `/healthz`。 +- [ ] 检查 Linux 混淆产物结构;存在 WSL 时启动并请求 `/healthz`。 +- [ ] 检查 `git diff`,确认没有第三方程序集或映射文件进入部署包。 diff --git a/docs/superpowers/specs/2026-06-22-cross-platform-obfuscation-design.md b/docs/superpowers/specs/2026-06-22-cross-platform-obfuscation-design.md new file mode 100644 index 0000000..860a24e --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-cross-platform-obfuscation-design.md @@ -0,0 +1,44 @@ +# Flyshot 跨平台混淆发布设计 + +## 目标 + +为全部 `Flyshot.*` 自研程序集增加统一的 Obfuscar 混淆发布流程,生成一份 Windows 与 Linux 共用的 .NET 8 框架依赖部署包。混淆流程必须显式失败,不能在工具缺失或混淆失败时留下看似成功的未保护发布包。 + +## 工具与环境边界 + +- 使用环境中安装的 Obfuscar 全局工具,命令名为 `obfuscar.console`。 +- 仓库不增加 Obfuscar NuGet `PackageReference`,也不增加 `.config/dotnet-tools.json`。 +- 构建环境需预先执行 `dotnet tool install --global Obfuscar.GlobalTool --version 2.2.50`。 +- 发布脚本只依赖 PowerShell 7、.NET 8 SDK 和环境中的 Obfuscar。 + +## 发布结构 + +发布脚本不接受运行平台或 RID,固定执行一次框架依赖发布: + +1. 使用 `dotnet publish` 生成自包含、多文件的原始发布目录。 +2. 动态生成包含绝对路径的 Obfuscar XML 配置。 +3. 校验当前八个核心程序集不可缺失,并将发布目录中的全部 `Flyshot.*.dll` 加入混淆模块列表。 +4. Obfuscar 输出到独立临时目录。 +5. 仅在混淆成功且输出程序集完整时,用混淆程序集替换原始发布目录中的对应文件。 +6. 将映射文件移入不随部署包交付的 `outputs/obfuscation-maps//`。 +7. 最终部署包输出到 `outputs/obfuscated/framework-dependent/`。 + +## 混淆边界 + +- `KeepPublicApi=true`:保留公共 API 名称,保护 ASP.NET Core 控制器发现、配置绑定、DI、JSON DTO、Swagger 和程序集间调用。 +- `HidePrivateApi=true`:重命名内部类型、私有方法和字段。 +- `RenameProperties=false`、`RenameEvents=false`:避免配置绑定、JSON 序列化和框架约定受属性或事件重命名影响。 +- 不处理 `Microsoft.*`、`System.*`、NLog、Swashbuckle 等第三方程序集。 +- 所有 `Flyshot.*.dll` 必须被发现并加入配置;缺少任何当前核心程序集都视为发布失败,未来新增模块会自动纳入。 + +## 验证 + +- 脚本契约测试验证:RID 列表、环境工具缺失失败、全部自研程序集进入配置、第三方程序集不进入配置、映射文件不进入部署目录。 +- 执行现有解决方案测试与 Release 构建。 +- 真实执行框架依赖发布及混淆,并分别在 Windows 与 Linux 的 .NET 8 运行时上启动。 +- Windows 包实际启动并请求 `/healthz`。 +- Linux 包至少验证 ELF 启动文件、依赖文件和全部混淆程序集完整;环境存在 WSL 时再实际启动并请求 `/healthz`。 + +## 风险与限制 + +Obfuscar 能显著提高普通反编译的阅读成本,但不能绝对阻止有经验的逆向分析。保留公共 API 是当前 ASP.NET Core 稳定运行所需的安全边界;若未来需要更强的控制流混淆、字符串加密或防篡改,应评估商业混淆器。 diff --git a/scripts/Publish-Obfuscated.ps1 b/scripts/Publish-Obfuscated.ps1 new file mode 100644 index 0000000..99ef877 --- /dev/null +++ b/scripts/Publish-Obfuscated.ps1 @@ -0,0 +1,635 @@ +<# +.SYNOPSIS +发布并混淆 Flyshot 跨平台框架依赖部署包。 + +.DESCRIPTION +脚本先通过 dotnet publish 生成不区分运行平台的框架依赖产物,再调用环境中安装的 +Obfuscar 全局工具混淆全部 Flyshot.* 自研程序集。第三方程序集不会进入混淆模块, +映射文件会保存到独立目录,不会随部署包交付。目标机器需要安装 .NET 8 运行时。 + +.PARAMETER OutputRoot +最终混淆部署包目录。 + +.PARAMETER MappingRoot +Obfuscar 映射文件的保存根目录。该目录不得作为部署包交付。 + +.PARAMETER GenerateConfigOnly +仅生成 Obfuscar 配置,供自动化测试和问题诊断使用,不执行发布或混淆。 + +.PARAMETER InputDirectory +GenerateConfigOnly 模式下的程序集输入目录。 + +.PARAMETER ObfuscatedDirectory +GenerateConfigOnly 模式下的混淆输出目录。 + +.PARAMETER ConfigPath +GenerateConfigOnly 模式下生成的 Obfuscar XML 文件路径。 + +.PARAMETER ObfuscateExistingPublish +仅混淆已经由 Visual Studio 或 dotnet publish 生成的现有发布目录,不再次执行发布。 + +.PARAMETER ExistingPublishDirectory +ObfuscateExistingPublish 模式下需要原地替换自研程序集的发布目录。 +#> + +[CmdletBinding()] +param( + [string]$OutputRoot, + [string]$MappingRoot, + [switch]$GenerateConfigOnly, + [string]$InputDirectory, + [string]$ObfuscatedDirectory, + [string]$ConfigPath, + [switch]$ObfuscateExistingPublish, + [string]$ExistingPublishDirectory +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +# 该清单固定了当前解决方案必须保护的全部自研程序集,避免新增或丢失模块时静默发布。 +$script:FlyshotAssemblyNames = @( + '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' +) + +<# +.SYNOPSIS +将路径转换为规范化绝对路径。 + +.PARAMETER Path +需要规范化的文件或目录路径,可以是相对路径或绝对路径。 + +.OUTPUTS +返回由 System.IO.Path 解析后的绝对路径字符串。 +#> +function ConvertTo-AbsolutePath +{ + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Path + ) + + return [System.IO.Path]::GetFullPath($Path) +} + +<# +.SYNOPSIS +检查发布目录是否包含全部预期的 Flyshot 自研程序集。 + +.PARAMETER Directory +待检查的发布输入目录。 + +.OUTPUTS +按固定清单顺序返回每个程序集的绝对路径。 + +.EXCEPTION +当目录不存在或任一预期程序集缺失时抛出异常,阻止生成未完整保护的发布包。 +#> +function Get-FlyshotAssemblyPaths +{ + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Directory + ) + + $absoluteDirectory = ConvertTo-AbsolutePath -Path $Directory + if (-not (Test-Path -LiteralPath $absoluteDirectory -PathType Container)) + { + throw "程序集输入目录不存在:$absoluteDirectory" + } + + foreach ($assemblyName in $script:FlyshotAssemblyNames) + { + $assemblyPath = Join-Path $absoluteDirectory $assemblyName + if (-not (Test-Path -LiteralPath $assemblyPath -PathType Leaf)) + { + throw "发布目录缺少必须混淆的自研程序集:$assemblyName" + } + } + + # 固定清单负责阻止当前核心模块缺失,通配发现负责自动覆盖未来新增的 Flyshot 自研程序集。 + $assemblyPaths = Get-ChildItem -LiteralPath $absoluteDirectory -Filter 'Flyshot.*.dll' -File | + Sort-Object Name | + ForEach-Object { ConvertTo-AbsolutePath -Path $_.FullName } + + return @($assemblyPaths) +} + +<# +.SYNOPSIS +发现当前环境中与项目目标框架匹配的 .NET 8 共享框架目录。 + +.DESCRIPTION +框架依赖发布不会把 Microsoft.NETCore.App 与 Microsoft.AspNetCore.App 的程序集复制到发布目录。 +Obfuscar 仍需要这些目录解析继承关系,因此通过 dotnet --list-runtimes 动态发现实际安装位置。 + +.OUTPUTS +返回 Microsoft.NETCore.App 与 Microsoft.AspNetCore.App 最新 .NET 8 运行时的绝对目录。 + +.EXCEPTION +当环境未安装所需的 .NET 8 共享框架时抛出异常,避免生成无法解析依赖的混淆配置。 +#> +function Get-DotNetSharedFrameworkSearchPaths +{ + [CmdletBinding()] + param() + + $runtimeEntries = & dotnet --list-runtimes + if ($LASTEXITCODE -ne 0) + { + throw "无法查询已安装的 .NET 运行时,dotnet --list-runtimes 退出码为 $LASTEXITCODE。" + } + + $requiredFrameworks = @( + 'Microsoft.NETCore.App' + 'Microsoft.AspNetCore.App' + ) + + $searchPaths = foreach ($frameworkName in $requiredFrameworks) + { + $candidates = foreach ($runtimeEntry in $runtimeEntries) + { + if ($runtimeEntry -match '^(?\S+)\s+(?8\.\d+\.\d+)\s+\[(?.+)\]$') + { + if ($Matches.name -eq $frameworkName) + { + [pscustomobject]@{ + Version = [version]$Matches.version + Path = Join-Path $Matches.root $Matches.version + } + } + } + } + + $selectedRuntime = $candidates | + Sort-Object Version -Descending | + Select-Object -First 1 + + if ($null -eq $selectedRuntime -or -not (Test-Path -LiteralPath $selectedRuntime.Path -PathType Container)) + { + throw "环境缺少 Obfuscar 解析依赖所需的 .NET 8 共享框架:$frameworkName" + } + + ConvertTo-AbsolutePath -Path $selectedRuntime.Path + } + + return @($searchPaths) +} + +<# +.SYNOPSIS +生成仅包含 Flyshot 自研程序集的 Obfuscar XML 配置。 + +.PARAMETER SourceDirectory +包含发布程序集及其依赖项的输入目录。 + +.PARAMETER TargetDirectory +Obfuscar 写入混淆程序集的独立输出目录。 + +.PARAMETER DestinationPath +要写入的 Obfuscar XML 配置文件路径。 + +.OUTPUTS +返回生成后的 Obfuscar XML 配置绝对路径。 +#> +function New-ObfuscarConfiguration +{ + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$SourceDirectory, + + [Parameter(Mandatory)] + [string]$TargetDirectory, + + [Parameter(Mandatory)] + [string]$DestinationPath + ) + + $absoluteSourceDirectory = ConvertTo-AbsolutePath -Path $SourceDirectory + $absoluteTargetDirectory = ConvertTo-AbsolutePath -Path $TargetDirectory + $absoluteDestinationPath = ConvertTo-AbsolutePath -Path $DestinationPath + $assemblyPaths = Get-FlyshotAssemblyPaths -Directory $absoluteSourceDirectory + $assemblySearchPaths = Get-DotNetSharedFrameworkSearchPaths + + $destinationParent = Split-Path -Parent $absoluteDestinationPath + New-Item -ItemType Directory -Path $destinationParent -Force | Out-Null + New-Item -ItemType Directory -Path $absoluteTargetDirectory -Force | Out-Null + + $settings = [ordered]@{ + InPath = $absoluteSourceDirectory + OutPath = $absoluteTargetDirectory + KeepPublicApi = 'true' + HidePrivateApi = 'true' + RenameProperties = 'false' + RenameEvents = 'false' + RenameFields = 'true' + SkipGenerated = 'true' + SkipSpecialName = 'true' + } + + $xmlSettings = [System.Xml.XmlWriterSettings]::new() + $xmlSettings.Indent = $true + $xmlSettings.Encoding = [System.Text.UTF8Encoding]::new($false) + + $writer = [System.Xml.XmlWriter]::Create($absoluteDestinationPath, $xmlSettings) + try + { + $writer.WriteStartDocument() + $writer.WriteStartElement('Obfuscator') + + foreach ($setting in $settings.GetEnumerator()) + { + $writer.WriteStartElement('Var') + $writer.WriteAttributeString('name', [string]$setting.Key) + $writer.WriteAttributeString('value', [string]$setting.Value) + $writer.WriteEndElement() + } + + foreach ($assemblySearchPath in $assemblySearchPaths) + { + # 框架依赖发布不携带共享框架 DLL,Obfuscar 通过这些目录解析 ASP.NET Core 与运行时依赖。 + $writer.WriteStartElement('AssemblySearchPath') + $writer.WriteAttributeString('path', $assemblySearchPath) + $writer.WriteEndElement() + } + + foreach ($assemblyPath in $assemblyPaths) + { + # 使用明确的绝对路径,兼容 Obfuscar 当前版本并避免工作目录差异。 + $writer.WriteStartElement('Module') + $writer.WriteAttributeString('file', $assemblyPath) + + # 匿名类型会被 ASP.NET Core 的 System.Text.Json 反射序列化,必须完整保留类型及构造参数元数据。 + $writer.WriteStartElement('SkipType') + $writer.WriteAttributeString('name', '^<>f__AnonymousType.*') + $writer.WriteAttributeString('skipMethods', 'true') + $writer.WriteAttributeString('skipFields', 'true') + $writer.WriteAttributeString('skipProperties', 'true') + $writer.WriteAttributeString('skipEvents', 'true') + $writer.WriteEndElement() + + $writer.WriteEndElement() + } + + $writer.WriteEndElement() + $writer.WriteEndDocument() + } + finally + { + $writer.Dispose() + } + + return $absoluteDestinationPath +} + +<# +.SYNOPSIS +确认发布依赖的外部命令已安装并可从 PATH 调用。 + +.PARAMETER CommandName +要检查的命令名称。 + +.PARAMETER InstallHint +命令缺失时展示给构建人员的安装提示。 +#> +function Assert-EnvironmentCommand +{ + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$CommandName, + + [Parameter(Mandatory)] + [string]$InstallHint + ) + + if (-not (Get-Command $CommandName -ErrorAction SilentlyContinue)) + { + throw "环境缺少命令 '$CommandName'。$InstallHint" + } +} + +<# +.SYNOPSIS +执行外部命令并在非零退出码时终止发布。 + +.PARAMETER FilePath +需要执行的外部命令。 + +.PARAMETER ArgumentList +按顺序传入外部命令的参数数组。 +#> +function Invoke-CheckedCommand +{ + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$FilePath, + + [Parameter(Mandatory)] + [string[]]$ArgumentList + ) + + & $FilePath @ArgumentList + if ($LASTEXITCODE -ne 0) + { + throw "命令执行失败,退出码为 $LASTEXITCODE:$FilePath $($ArgumentList -join ' ')" + } +} + +<# +.SYNOPSIS +将 Obfuscar 输出安全替换进发布目录并隔离映射文件。 + +.PARAMETER PublishedDirectory +dotnet publish 生成的最终部署目录。 + +.PARAMETER ObfuscatedOutputDirectory +Obfuscar 独立输出目录。 + +.PARAMETER RuntimeMappingDirectory +框架依赖发布对应的映射文件保存目录。 +#> +function Install-ObfuscatedAssemblies +{ + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$PublishedDirectory, + + [Parameter(Mandatory)] + [string]$ObfuscatedOutputDirectory, + + [Parameter(Mandatory)] + [string]$RuntimeMappingDirectory + ) + + $assemblyNames = Get-FlyshotAssemblyPaths -Directory $PublishedDirectory | + ForEach-Object { [System.IO.Path]::GetFileName($_) } + + foreach ($assemblyName in $assemblyNames) + { + $obfuscatedAssemblyPath = Join-Path $ObfuscatedOutputDirectory $assemblyName + if (-not (Test-Path -LiteralPath $obfuscatedAssemblyPath -PathType Leaf)) + { + throw "Obfuscar 未生成预期程序集:$assemblyName" + } + } + + foreach ($assemblyName in $assemblyNames) + { + Copy-Item ` + -LiteralPath (Join-Path $ObfuscatedOutputDirectory $assemblyName) ` + -Destination (Join-Path $PublishedDirectory $assemblyName) ` + -Force + + $pdbName = [System.IO.Path]::ChangeExtension($assemblyName, '.pdb') + $obfuscatedPdbPath = Join-Path $ObfuscatedOutputDirectory $pdbName + if (Test-Path -LiteralPath $obfuscatedPdbPath -PathType Leaf) + { + Copy-Item ` + -LiteralPath $obfuscatedPdbPath ` + -Destination (Join-Path $PublishedDirectory $pdbName) ` + -Force + } + } + + $mappingPath = Join-Path $ObfuscatedOutputDirectory 'Mapping.txt' + if (-not (Test-Path -LiteralPath $mappingPath -PathType Leaf)) + { + throw 'Obfuscar 未生成 Mapping.txt,无法确认混淆映射结果。' + } + + New-Item -ItemType Directory -Path $RuntimeMappingDirectory -Force | Out-Null + Copy-Item -LiteralPath $mappingPath -Destination (Join-Path $RuntimeMappingDirectory 'Mapping.txt') -Force + + # 部署目录不得包含符号映射,避免把重命名关系直接交付给最终用户。 + $publishedMappingPath = Join-Path $PublishedDirectory 'Mapping.txt' + if (Test-Path -LiteralPath $publishedMappingPath) + { + Remove-Item -LiteralPath $publishedMappingPath -Force + } + + # XML 文档包含源码 /// 注释,PDB 包含调试符号;生产混淆包不得向反编译器暴露这些辅助信息。 + Get-ChildItem -LiteralPath $PublishedDirectory -File | + Where-Object { + $_.Name -like 'Flyshot*.xml' -or + $_.Name -like 'Flyshot*.pdb' + } | + Remove-Item -Force +} + +<# +.SYNOPSIS +发布并混淆 Flyshot 框架依赖主程序。 + +.PARAMETER RepositoryRoot +Flyshot Replacement 仓库根目录。 + +.PARAMETER PublishedOutputDirectory +最终混淆部署包目录。 + +.PARAMETER MappingOutputRoot +全部混淆映射文件的根目录。 +#> +function Publish-ObfuscatedFrameworkDependent +{ + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$RepositoryRoot, + + [Parameter(Mandatory)] + [string]$PublishedOutputDirectory, + + [Parameter(Mandatory)] + [string]$MappingOutputRoot + ) + + $hostProject = Join-Path $RepositoryRoot 'src\Flyshot.Server.Host\Flyshot.Server.Host.csproj' + $workingDirectory = Join-Path $RepositoryRoot '.tmp\obfuscation\framework-dependent' + $obfuscatedOutputDirectory = Join-Path $workingDirectory 'obfuscated' + $configurationPath = Join-Path $workingDirectory 'obfuscar.xml' + $runtimeMappingDirectory = Join-Path $MappingOutputRoot 'framework-dependent' + + # 每次发布使用干净目录,避免旧程序集或旧映射掩盖本轮失败。 + foreach ($directory in @($PublishedOutputDirectory, $workingDirectory, $runtimeMappingDirectory)) + { + if (Test-Path -LiteralPath $directory) + { + Remove-Item -LiteralPath $directory -Recurse -Force + } + } + + New-Item -ItemType Directory -Path $PublishedOutputDirectory -Force | Out-Null + + Write-Host '正在发布跨平台框架依赖包 ...' + Invoke-CheckedCommand -FilePath 'dotnet' -ArgumentList @( + 'publish' + $hostProject + '-c' + 'Release' + '--self-contained' + 'false' + '-p:PublishSingleFile=false' + '-p:UseAppHost=false' + '-p:DebugType=none' + '-o' + $PublishedOutputDirectory + ) + + New-ObfuscarConfiguration ` + -SourceDirectory $PublishedOutputDirectory ` + -TargetDirectory $obfuscatedOutputDirectory ` + -DestinationPath $configurationPath | Out-Null + + Write-Host '正在混淆框架依赖包中的 Flyshot 自研程序集 ...' + Invoke-CheckedCommand -FilePath 'obfuscar.console' -ArgumentList @($configurationPath) + + Install-ObfuscatedAssemblies ` + -PublishedDirectory $PublishedOutputDirectory ` + -ObfuscatedOutputDirectory $obfuscatedOutputDirectory ` + -RuntimeMappingDirectory $runtimeMappingDirectory + + Write-Host "已生成跨平台框架依赖混淆包:$PublishedOutputDirectory" +} + +<# +.SYNOPSIS +混淆 Visual Studio 已生成的现有发布目录。 + +.PARAMETER PublishedDirectory +包含全部发布文件的目录;混淆成功后会原地替换其中的 Flyshot 自研程序集。 + +.PARAMETER RepositoryRoot +Flyshot Replacement 仓库根目录。 + +.PARAMETER MappingOutputRoot +混淆映射文件的根目录;映射文件不会进入发布目录。 +#> +function Protect-ExistingPublishedDirectory +{ + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$PublishedDirectory, + + [Parameter(Mandatory)] + [string]$RepositoryRoot, + + [Parameter(Mandatory)] + [string]$MappingOutputRoot + ) + + $absolutePublishedDirectory = ConvertTo-AbsolutePath -Path $PublishedDirectory + $workingDirectory = Join-Path $RepositoryRoot '.tmp\obfuscation\visual-studio\framework-dependent' + $obfuscatedOutputDirectory = Join-Path $workingDirectory 'obfuscated' + $configurationPath = Join-Path $workingDirectory 'obfuscar.xml' + $runtimeMappingDirectory = Join-Path $MappingOutputRoot 'framework-dependent' + + if (-not (Test-Path -LiteralPath $absolutePublishedDirectory -PathType Container)) + { + throw "Visual Studio 发布目录不存在:$absolutePublishedDirectory" + } + + # 后处理必须使用独立输出目录,避免 Obfuscar 读写同一个程序集导致文件锁或半成品。 + foreach ($directory in @($workingDirectory, $runtimeMappingDirectory)) + { + if (Test-Path -LiteralPath $directory) + { + Remove-Item -LiteralPath $directory -Recurse -Force + } + } + + New-ObfuscarConfiguration ` + -SourceDirectory $absolutePublishedDirectory ` + -TargetDirectory $obfuscatedOutputDirectory ` + -DestinationPath $configurationPath | Out-Null + + Write-Host "正在混淆 Visual Studio 发布目录中的 Flyshot 自研程序集:$absolutePublishedDirectory" + Invoke-CheckedCommand -FilePath 'obfuscar.console' -ArgumentList @($configurationPath) + + Install-ObfuscatedAssemblies ` + -PublishedDirectory $absolutePublishedDirectory ` + -ObfuscatedOutputDirectory $obfuscatedOutputDirectory ` + -RuntimeMappingDirectory $runtimeMappingDirectory + + Write-Host "Visual Studio 发布目录混淆完成:$absolutePublishedDirectory" +} + +try +{ + if ($GenerateConfigOnly) + { + foreach ($requiredValue in @{ + InputDirectory = $InputDirectory + ObfuscatedDirectory = $ObfuscatedDirectory + ConfigPath = $ConfigPath + }.GetEnumerator()) + { + if ([string]::IsNullOrWhiteSpace([string]$requiredValue.Value)) + { + throw "GenerateConfigOnly 模式缺少参数:$($requiredValue.Key)" + } + } + + New-ObfuscarConfiguration ` + -SourceDirectory $InputDirectory ` + -TargetDirectory $ObfuscatedDirectory ` + -DestinationPath $ConfigPath | Out-Null + exit 0 + } + + $repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path + if ([string]::IsNullOrWhiteSpace($OutputRoot)) + { + $OutputRoot = Join-Path $repositoryRoot 'outputs\obfuscated\framework-dependent' + } + + if ([string]::IsNullOrWhiteSpace($MappingRoot)) + { + $MappingRoot = Join-Path $repositoryRoot 'outputs\obfuscation-maps' + } + + Assert-EnvironmentCommand -CommandName 'dotnet' -InstallHint '请安装仓库 global.json 指定的 .NET SDK。' + Assert-EnvironmentCommand ` + -CommandName 'obfuscar.console' ` + -InstallHint '请执行:dotnet tool install --global Obfuscar.GlobalTool --version 2.2.50' + + if ($ObfuscateExistingPublish) + { + if ([string]::IsNullOrWhiteSpace($ExistingPublishDirectory)) + { + throw 'ObfuscateExistingPublish 模式缺少参数:ExistingPublishDirectory' + } + + Protect-ExistingPublishedDirectory ` + -PublishedDirectory $ExistingPublishDirectory ` + -RepositoryRoot $repositoryRoot ` + -MappingOutputRoot (ConvertTo-AbsolutePath -Path $MappingRoot) + exit 0 + } + + Publish-ObfuscatedFrameworkDependent ` + -RepositoryRoot $repositoryRoot ` + -PublishedOutputDirectory (ConvertTo-AbsolutePath -Path $OutputRoot) ` + -MappingOutputRoot (ConvertTo-AbsolutePath -Path $MappingRoot) + + Write-Host 'Flyshot 跨平台框架依赖混淆发布包已生成。' + exit 0 +} +catch +{ + Write-Error $_ + exit 1 +} diff --git a/src/Flyshot.Server.Host/Flyshot.Server.Host.csproj b/src/Flyshot.Server.Host/Flyshot.Server.Host.csproj index 3dbd0b7..2065feb 100644 --- a/src/Flyshot.Server.Host/Flyshot.Server.Host.csproj +++ b/src/Flyshot.Server.Host/Flyshot.Server.Host.csproj @@ -1,7 +1,7 @@ - + true $(NoWarn);1591 diff --git a/src/Flyshot.Server.Host/Program.cs b/src/Flyshot.Server.Host/Program.cs index e49f392..43ee907 100644 --- a/src/Flyshot.Server.Host/Program.cs +++ b/src/Flyshot.Server.Host/Program.cs @@ -40,7 +40,8 @@ try Version = swaggerOptions.Version }); - // 把控制器与 DTO 上的 /// summary 注释纳入 OpenAPI 文档;调试页据此渲染端点标题。 + // 把控制器与 DTO 上的 /// summary 注释纳入 OpenAPI 文档;当前主要服务 Swagger UI, + // 调试页的人类可读说明已经静态内嵌在 debug.html 中,不再依赖发布目录中的 XML 文档。 var xmlDocumentationPath = Path.Combine(AppContext.BaseDirectory, $"{typeof(Program).Assembly.GetName().Name}.xml"); if (File.Exists(xmlDocumentationPath)) { diff --git a/src/Flyshot.Server.Host/wwwroot/assets/debug.js b/src/Flyshot.Server.Host/wwwroot/assets/debug.js index c1175ad..82f87c4 100644 --- a/src/Flyshot.Server.Host/wwwroot/assets/debug.js +++ b/src/Flyshot.Server.Host/wwwroot/assets/debug.js @@ -2,6 +2,7 @@ const DEBUG_CONFIG_URL = "/api/debug/config"; const STORAGE_PREFIX = "flyshot.debug."; const HISTORY_LIMIT = 10; +const STATIC_DOCS_ELEMENT_ID = "debug-static-docs"; const groupTitleByPrefix = [ // 基础与状态分组:探活和状态快照两个固定 API 路径 @@ -13,7 +14,8 @@ const groupTitleByPrefix = [ const state = { spec: null, operations: [], - history: [] + history: [], + staticDocsByKey: loadStaticDocs() }; const requestBodySamples = { @@ -34,6 +36,29 @@ function escapeHtml(value) { }); } +/** 为 METHOD PATH 文档块构造统一索引键。 */ +function buildOperationKey(method, path) { + return String(method || "").toUpperCase() + " " + String(path || ""); +} + +/** 读取 debug.html 中内嵌的静态说明 JSON,并建立 METHOD PATH 索引。 */ +function loadStaticDocs() { + const docNode = document.getElementById(STATIC_DOCS_ELEMENT_ID); + if (!docNode) return {}; + + try { + const parsed = JSON.parse(docNode.textContent || "{}"); + const indexed = {}; + Object.keys(parsed).forEach(function (key) { + indexed[key] = parsed[key]; + }); + return indexed; + } catch (error) { + console.warn("读取静态调试说明失败:", error); + return {}; + } +} + /** 解析 OpenAPI 中的 $ref 引用,仅支持本地 components.schemas 形式。 */ function resolveRef(ref) { if (!ref || !state.spec) return null; @@ -49,34 +74,49 @@ function resolveRef(ref) { return cursor; } +/** 展开 schema,统一处理 $ref / oneOf / anyOf / allOf。 */ +function resolveSchema(schema, depth) { + depth = depth || 0; + if (!schema || depth > 4) return null; + + if (schema.$ref) { + return resolveSchema(resolveRef(schema.$ref), depth + 1); + } + + if (Array.isArray(schema.oneOf) && schema.oneOf.length > 0) return resolveSchema(schema.oneOf[0], depth + 1); + if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) return resolveSchema(schema.anyOf[0], depth + 1); + if (Array.isArray(schema.allOf) && schema.allOf.length > 0) { + const merged = { type: "object", properties: {}, required: [] }; + schema.allOf.forEach(function (subSchema) { + const resolved = resolveSchema(subSchema, depth + 1); + if (!resolved) return; + if (resolved.properties) Object.assign(merged.properties, resolved.properties); + if (Array.isArray(resolved.required)) { + resolved.required.forEach(function (name) { + if (merged.required.indexOf(name) < 0) merged.required.push(name); + }); + } + }); + return merged; + } + + return schema; +} + /** 根据 schema 生成默认 JSON 模板,用于自动填充请求体编辑器。 */ function buildSampleFromSchema(schema, depth) { depth = depth || 0; // 防御递归:复杂自引用 schema 在 4 层后停下,避免栈爆。 if (!schema || depth > 4) return null; - if (schema.$ref) { - const resolved = resolveRef(schema.$ref); - return resolved ? buildSampleFromSchema(resolved, depth + 1) : null; - } + const resolvedSchema = resolveSchema(schema, depth); + if (!resolvedSchema) return null; - // 部分 schema 只标 oneOf/anyOf/allOf,挑第一个分支即可,调试场景够用。 - if (Array.isArray(schema.oneOf) && schema.oneOf.length > 0) return buildSampleFromSchema(schema.oneOf[0], depth + 1); - if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) return buildSampleFromSchema(schema.anyOf[0], depth + 1); - if (Array.isArray(schema.allOf) && schema.allOf.length > 0) { - const merged = {}; - schema.allOf.forEach(function (sub) { - const value = buildSampleFromSchema(sub, depth + 1); - if (value && typeof value === "object" && !Array.isArray(value)) Object.assign(merged, value); - }); - return merged; - } - - const type = schema.type || (schema.properties ? "object" : "string"); + const type = resolvedSchema.type || (resolvedSchema.properties ? "object" : "string"); switch (type) { case "object": { const result = {}; - const props = schema.properties || {}; + const props = resolvedSchema.properties || {}; Object.keys(props).forEach(function (key) { result[key] = buildSampleFromSchema(props[key], depth + 1); }); @@ -91,7 +131,7 @@ function buildSampleFromSchema(schema, depth) { return false; case "string": default: - if (schema.enum && schema.enum.length > 0) return schema.enum[0]; + if (resolvedSchema.enum && resolvedSchema.enum.length > 0) return resolvedSchema.enum[0]; return ""; } } @@ -99,13 +139,65 @@ function buildSampleFromSchema(schema, depth) { /** 把 schema.type 翻译成 input type 与展示文本。 */ function inputKindForType(schema) { if (!schema) return { kind: "text", label: "string" }; - const type = schema.type || "string"; + const resolvedSchema = resolveSchema(schema, 0) || schema; + const type = resolvedSchema.type || "string"; if (type === "boolean") return { kind: "checkbox", label: "boolean" }; if (type === "integer" || type === "number") return { kind: "number", label: type }; return { kind: "text", label: type }; } -/** 把 OpenAPI 的 paths 节点展开成扁平的 operation 列表。 */ +/** 把 schema 转成适合字段表展示的简短类型文本。 */ +function describeSchemaType(schema) { + const resolvedSchema = resolveSchema(schema, 0); + if (!resolvedSchema) return "unknown"; + + if (resolvedSchema.type === "array") { + const itemType = describeSchemaType(resolvedSchema.items); + return itemType === "unknown" ? "array" : "array<" + itemType + ">"; + } + + if (resolvedSchema.type) return resolvedSchema.type; + if (resolvedSchema.properties) return "object"; + return "unknown"; +} + +/** 当静态说明缺失时,从普通 object schema 推导字段表。 */ +function buildFieldDocsFromSchema(schema) { + const resolvedSchema = resolveSchema(schema, 0); + if (!resolvedSchema) return []; + + const schemaType = resolvedSchema.type || (resolvedSchema.properties ? "object" : ""); + if (schemaType !== "object" || !resolvedSchema.properties) return []; + + const requiredSet = new Set(resolvedSchema.required || []); + return Object.keys(resolvedSchema.properties).map(function (name) { + const propertySchema = resolvedSchema.properties[name]; + return { + name: name, + type: describeSchemaType(propertySchema), + required: requiredSet.has(name), + description: propertySchema && propertySchema.description ? propertySchema.description : "暂无说明" + }; + }); +} + +/** 规范化静态字段说明,避免缺字段时前端渲染异常。 */ +function normalizeFieldDocs(fieldDocs) { + if (!Array.isArray(fieldDocs)) return []; + + return fieldDocs.map(function (fieldDoc) { + return { + name: fieldDoc && fieldDoc.name ? String(fieldDoc.name) : "", + type: fieldDoc && fieldDoc.type ? String(fieldDoc.type) : "unknown", + required: Boolean(fieldDoc && fieldDoc.required), + description: fieldDoc && fieldDoc.description ? String(fieldDoc.description) : "暂无说明" + }; + }).filter(function (fieldDoc) { + return fieldDoc.name !== ""; + }); +} + +/** 把 OpenAPI 的 paths 节点展开成扁平的 operation 列表,并合并静态说明。 */ function extractOperations(spec) { const operations = []; const paths = spec.paths || {}; @@ -120,6 +212,11 @@ function extractOperations(spec) { const json = op.requestBody.content["application/json"]; if (json && json.schema) bodySchema = json.schema; } + + const operationKey = buildOperationKey(method, path); + const staticDoc = state.staticDocsByKey[operationKey] || null; + const staticFieldDocs = staticDoc ? normalizeFieldDocs(staticDoc.requestBodyFields) : []; + const schemaFieldDocs = staticFieldDocs.length === 0 ? buildFieldDocsFromSchema(bodySchema) : []; operations.push({ method: method.toUpperCase(), path: path, @@ -127,7 +224,11 @@ function extractOperations(spec) { description: op.description || "", tags: op.tags || [], parameters: parameters, - bodySchema: bodySchema + bodySchema: bodySchema, + staticDoc: staticDoc, + mergedSummary: staticDoc && staticDoc.summary ? staticDoc.summary : "暂无说明", + mergedDescription: staticDoc && staticDoc.description ? staticDoc.description : "暂无说明", + requestBodyFieldDocs: staticFieldDocs.length > 0 ? staticFieldDocs : schemaFieldDocs }); }); }); @@ -150,7 +251,8 @@ function storageKey(op) { /** 返回指定端点的手工调试样例,处理 JsonElement 等 OpenAPI 无法自动推断字段的接口。 */ function getRequestBodySample(op) { - return requestBodySamples[op.method + " " + op.path] || null; + const operationKey = op.method + " " + op.path; + return requestBodySamples[operationKey] || null; } /** 读取本端点最近一次输入;解析失败则当作空。 */ @@ -268,10 +370,80 @@ function renderParameterRows(container, op, savedValues) { }; } +/** 渲染请求体字段说明表;静态说明缺失时退回 schema 推导。 */ +function renderRequestBodyFieldDocs(container, op) { + if (!op.bodySchema && op.requestBodyFieldDocs.length === 0) return; + + const wrapper = document.createElement("div"); + wrapper.className = "body-block"; + + const title = document.createElement("div"); + title.className = "body-label"; + const left = document.createElement("div"); + left.className = "left"; + left.textContent = "请求体字段说明"; + title.appendChild(left); + wrapper.appendChild(title); + + if (op.requestBodyFieldDocs.length === 0) { + const empty = document.createElement("div"); + empty.style.color = "var(--muted)"; + empty.style.fontSize = "13px"; + empty.textContent = "暂无说明"; + wrapper.appendChild(empty); + container.appendChild(wrapper); + return; + } + + const table = document.createElement("table"); + table.style.width = "100%"; + table.style.borderCollapse = "collapse"; + table.style.fontSize = "13px"; + + const headerRow = document.createElement("tr"); + ["字段名", "类型", "必填", "说明"].forEach(function (text) { + const th = document.createElement("th"); + th.textContent = text; + th.style.textAlign = "left"; + th.style.padding = "6px 8px"; + th.style.borderBottom = "1px solid rgba(148, 163, 184, 0.25)"; + th.style.color = "var(--muted)"; + headerRow.appendChild(th); + }); + table.appendChild(headerRow); + + op.requestBodyFieldDocs.forEach(function (fieldDoc) { + const row = document.createElement("tr"); + [ + fieldDoc.name, + fieldDoc.type, + fieldDoc.required ? "是" : "否", + fieldDoc.description || "暂无说明" + ].forEach(function (value, index) { + const cell = document.createElement("td"); + cell.style.padding = "8px"; + cell.style.verticalAlign = "top"; + cell.style.borderBottom = "1px solid rgba(148, 163, 184, 0.12)"; + if (index === 0 || index === 1) { + cell.style.fontFamily = "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"; + } + cell.textContent = value; + row.appendChild(cell); + }); + table.appendChild(row); + }); + + wrapper.appendChild(table); + container.appendChild(wrapper); +} + /** 渲染请求体编辑器,返回收集函数。 */ function renderBodyEditor(container, op, savedBody) { if (!op.bodySchema) return function () { return null; }; + // 字段说明先于 JSON 编辑器出现,便于现场先看约定再改样例。 + renderRequestBodyFieldDocs(container, op); + const block = document.createElement("div"); block.className = "body-block"; @@ -342,6 +514,7 @@ function pushHistory(entry) { renderHistory(); } +/** 渲染请求历史列表。 */ function renderHistory() { const list = document.getElementById("history-list"); list.innerHTML = ""; @@ -376,7 +549,7 @@ function renderHistory() { /** 渲染单个端点的卡片。 */ function renderOperationCard(op) { const card = document.createElement("section"); - card.className = "card collapsed"; + card.className = "card"; const head = document.createElement("div"); head.className = "card-head"; @@ -393,13 +566,13 @@ function renderOperationCard(op) { const summary = document.createElement("span"); summary.className = "card-summary"; - summary.textContent = op.summary; - summary.title = op.summary; + summary.textContent = op.mergedSummary; + summary.title = op.mergedSummary; head.appendChild(summary); const toggle = document.createElement("span"); toggle.className = "card-toggle"; - toggle.textContent = "展开 ▾"; + toggle.textContent = "收起 ▴"; head.appendChild(toggle); head.addEventListener("click", function () { @@ -411,15 +584,12 @@ function renderOperationCard(op) { const body = document.createElement("div"); body.className = "card-body"; - // 描述(来自 XML summary)独立成一段 - if (op.summary) { - const desc = document.createElement("div"); - desc.style.color = "var(--muted)"; - desc.style.marginBottom = "10px"; - desc.style.fontSize = "13px"; - desc.textContent = op.summary; - body.appendChild(desc); - } + const desc = document.createElement("div"); + desc.style.color = "var(--muted)"; + desc.style.marginBottom = "10px"; + desc.style.fontSize = "13px"; + desc.textContent = op.mergedDescription; + body.appendChild(desc); const saved = loadInputs(op) || {}; @@ -464,6 +634,7 @@ function renderOperationCard(op) { responseBlock.style.display = "none"; body.appendChild(responseBlock); + /** 在卡片内渲染 HTTP 响应摘要、头和正文。 */ function renderResponse(payload) { responseBlock.style.display = "block"; responseBlock.innerHTML = ""; diff --git a/src/Flyshot.Server.Host/wwwroot/debug.html b/src/Flyshot.Server.Host/wwwroot/debug.html index 3b85ee4..8f8859d 100644 --- a/src/Flyshot.Server.Host/wwwroot/debug.html +++ b/src/Flyshot.Server.Host/wwwroot/debug.html @@ -39,6 +39,247 @@
    + diff --git a/tests/Flyshot.Server.IntegrationTests/DebugConsoleEndpointTests.cs b/tests/Flyshot.Server.IntegrationTests/DebugConsoleEndpointTests.cs index 0fcf321..7107038 100644 --- a/tests/Flyshot.Server.IntegrationTests/DebugConsoleEndpointTests.cs +++ b/tests/Flyshot.Server.IntegrationTests/DebugConsoleEndpointTests.cs @@ -33,6 +33,9 @@ public sealed class DebugConsoleEndpointTests(FlyshotServerFactory factory) : IC Assert.Contains("Flyshot Replacement 接口调试", html, StringComparison.Ordinal); Assert.Contains("id=\"debug-console-app\"", html, StringComparison.Ordinal); + Assert.Contains("id=\"debug-static-docs\"", html, StringComparison.Ordinal); + Assert.Contains("POST /move_pose/", html, StringComparison.Ordinal); + Assert.Contains("POST /upload_flyshot/", html, StringComparison.Ordinal); Assert.DoesNotContain("__SWAGGER_JSON_URL__", html, StringComparison.Ordinal); Assert.Contains("/assets/debug.css", html, StringComparison.Ordinal); Assert.Contains("/assets/debug.js", html, StringComparison.Ordinal); @@ -66,6 +69,45 @@ public sealed class DebugConsoleEndpointTests(FlyshotServerFactory factory) : IC Assert.Contains("r: 0.0", script, StringComparison.Ordinal); } + /// + /// 调试脚本应当优先读取静态说明并在缺失时回退到 OpenAPI / “暂无说明”。 + /// + [Fact] + public async Task GetDebugScript_ContainsStaticDocumentationMergeLogic() + { + using var configuredFactory = CreateFactoryWithSwaggerEnabled(true); + using var client = configuredFactory.CreateClient(); + + using var scriptResponse = await client.GetAsync("/assets/debug.js"); + Assert.Equal(HttpStatusCode.OK, scriptResponse.StatusCode); + + var script = await scriptResponse.Content.ReadAsStringAsync(); + Assert.Contains("debug-static-docs", script, StringComparison.Ordinal); + Assert.Contains("op.method + \" \" + op.path", script, StringComparison.Ordinal); + Assert.Contains("mergedSummary", script, StringComparison.Ordinal); + Assert.Contains("mergedDescription", script, StringComparison.Ordinal); + Assert.Contains("requestBodyFieldDocs", script, StringComparison.Ordinal); + Assert.Contains("暂无说明", script, StringComparison.Ordinal); + } + + /// + /// 调试卡片默认应直接展开,确保静态说明和字段表在初始页面就可见。 + /// + [Fact] + public async Task GetDebugScript_DefaultsCardsToExpandedForVisibleDocumentation() + { + using var configuredFactory = CreateFactoryWithSwaggerEnabled(true); + using var client = configuredFactory.CreateClient(); + + using var scriptResponse = await client.GetAsync("/assets/debug.js"); + Assert.Equal(HttpStatusCode.OK, scriptResponse.StatusCode); + + var script = await scriptResponse.Content.ReadAsStringAsync(); + Assert.DoesNotContain("card.className = \"card collapsed\"", script, StringComparison.Ordinal); + Assert.Contains("card.className = \"card\"", script, StringComparison.Ordinal); + Assert.Contains("toggle.textContent = \"收起 ▴\"", script, StringComparison.Ordinal); + } + /// /// 当 Swagger 启用时,调试配置 API 应当返回实际 Swagger JSON 地址。 /// diff --git a/tests/Publishing/Publish-Obfuscated.Tests.ps1 b/tests/Publishing/Publish-Obfuscated.Tests.ps1 new file mode 100644 index 0000000..1608f25 --- /dev/null +++ b/tests/Publishing/Publish-Obfuscated.Tests.ps1 @@ -0,0 +1,252 @@ +<# +.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 + } +} diff --git a/tests/Publishing/Test-PublishedArtifactPrivacy.ps1 b/tests/Publishing/Test-PublishedArtifactPrivacy.ps1 new file mode 100644 index 0000000..c0e33b5 --- /dev/null +++ b/tests/Publishing/Test-PublishedArtifactPrivacy.ps1 @@ -0,0 +1,35 @@ +<# +.SYNOPSIS +验证混淆发布包不会泄露 Flyshot 源码文档或调试符号。 + +.PARAMETER PublishDirectory +需要检查的框架依赖混淆发布目录。 +#> + +[CmdletBinding()] +param( + [string]$PublishDirectory = (Join-Path $PSScriptRoot '..\..\outputs\obfuscated\framework-dependent') +) + +$ErrorActionPreference = 'Stop' +$absolutePublishDirectory = [System.IO.Path]::GetFullPath($PublishDirectory) + +if (-not (Test-Path -LiteralPath $absolutePublishDirectory -PathType Container)) +{ + throw "混淆发布目录不存在:$absolutePublishDirectory" +} + +$sensitiveFiles = @( + Get-ChildItem -LiteralPath $absolutePublishDirectory -File | + Where-Object { + $_.Name -like 'Flyshot*.xml' -or + $_.Name -like 'Flyshot*.pdb' + } +) + +if ($sensitiveFiles.Count -gt 0) +{ + throw "混淆发布包仍包含源码文档或调试符号:$($sensitiveFiles.Name -join ', ')" +} + +Write-Host 'Test-PublishedArtifactPrivacy.ps1: PASS'