refactor(ng2): add diagnostics workflow module as single UI-facing seam

诊断区收敛为 DiagnosticsWorkflow 深 module(Candidate 03,issue #43):事实
采样、轮询代际、raw 日志投影、报告准备与导出完成登记集中一个 seam,视图只
负责生命周期触发、呈现、剪贴板、保存面板与报告文件写入。侧栏与详情共享同
一份安全摘要;收集只读,不触碰目录、凭据、runtime 与系统代理设置。

目录不再把原始 ConfigurationCatalog 写入诊断快照:CatalogWorkflow 只输出
domain-neutral 聚合事实(计数 + 已知无效数),报告 builder 只接受已裁剪的
report facts。事件导出改为显式白名单派生(diagnosticReportLine):自由
detail、主机公布地址与 runtime 错误原文一律清洗,数量/端口/领域点名原因
保留;新增事件漏 case 无法编译。raw GUI 事件行与 wrapper agent.log 尾部仅
经显式查看/复制提供,永不进入报告(ADR-0006)。导出完成事件只在用户选择的
文件实际写入成功后登记。

RuntimeEventStore 事件改为携带封闭枚举原值,渲染发生在呈现边缘(白名单清
洗只可能作用于原值)。测试:新增 workflow interface 覆盖投毒导出、白名单、
raw/报告分离、缺失标注、只读、代际覆盖与取消;ADR-0006 与 CONTEXT.md 术语
同步入库。
This commit is contained in:
Qiu Yuzhou
2026-09-22 19:22:31 +08:00
parent 3df1624d91
commit fd9be86149
15 changed files with 994 additions and 214 deletions
+9
View File
@@ -24,6 +24,9 @@
- **Sensitive information**: Server passwords, plugin options (sensitive in their entirety), and nonempty user-provided remote URLs, including subscription, external PAC, and custom GFW List URLs.
- **Credential exposure boundary**: This product reduces accidental disclosure and exposure to other user accounts, but does not guarantee protection against a compromised same-user process, root access, or APFS snapshots and backups; it does not promise secure erasure.
- **Credential reference**: A non-secret association from a server configuration, subscription, or remote-URL setting to its durable credential; the credential value is kept outside the configuration tree and resolved only when needed.
- **Diagnostic report**: A user-requested, redacted document containing only approved runtime state, endpoint metadata, aggregate catalog facts, managed-plugin presence, file metadata, and safe GUI event lines. It never contains passwords, plugin options, Keychain values, full remote URLs or tokens, runtime JSON, server addresses, remarks, or raw wrapper log text.
- **Diagnostic log view**: The explicit view/copy surface for GUI event lines and the raw wrapper `agent.log` tail. Raw wrapper log text is not part of a diagnostic report.
- **Diagnostic facts**: Aggregate, redacted facts about the catalog and proxy runtime that can enter a diagnostic projection without exposing catalog entries, server addresses, remarks, credential references, URLs, paths, or other sensitive information.
- **System-level shortcut**: A user-configurable keyboard action registered by the app that can trigger an app action even when the app has no focus.
_Avoid_: global hotkey, global shortcut.
- **Window-local default action shortcut**: A keyboard action bound to the focused app window or form context, such as confirming a dialog; it is distinct from a system-level shortcut.
@@ -57,6 +60,12 @@
- The PAC HTTP endpoint and the external tunnel service's inbound listeners follow one shared listen scope, so a PAC URL or proxy address shared to another machine always points at a reachable proxy; loopback is the default scope.
- The ports of the locally provided proxy endpoints are explicit user configuration: the system never rewrites a port on its own, and a port conflict rejects proxy start with an error naming the endpoint until the user explicitly chooses a new port. A recovery suggestion only proposes candidate ports (32768–65535, free and distinct from the other endpoints' configured values) and applies only after the user confirms and saves; changing the PAC port warns that previously shared PAC URLs become invalid.
- The PAC HTTP endpoint and the external tunnel service start and stop together inside the proxy runtime: both are reachable exactly while proxying is on, and neither depends on the GUI being open. The GUI writes the system proxy configuration and gates that write on endpoint health.
- Diagnostic collection is read-only: it never changes the configuration catalog, credentials, proxy runtime, or system proxy settings. A diagnostic report is best-effort; an unavailable fact source is represented as unavailable rather than causing unrelated available facts to be discarded.
- A diagnostic report contains aggregate catalog facts only. The complete configuration catalog, catalog entries, server addresses, remarks, and credential references never cross into the diagnostic report projection.
- Raw wrapper log text may be viewed or copied only through the explicit diagnostic log view and never enters a diagnostic report or safe diagnostic projection.
- Exported GUI event lines are an allowlisted, field-cleaned subset of runtime events; raw event detail remains available only through the explicit diagnostic log view.
- A diagnostic report presents fixed proxy-state categories rather than free-form failure detail. If its safe projection cannot be constructed, no report is emitted; an unavailable individual fact source remains a marked partial fact instead.
- A diagnostic export is considered complete only after the user-selected report file is successfully written; opening the export panel or preparing report text is not an export completion.
- Proxy modes are mutually exclusive: PAC writes the runtime PAC URL, global writes only the local SOCKS proxy, manual restores the user's prior system proxy state, and external PAC writes a validated HTTP/HTTPS URL. System proxy writes are allowed only after endpoint health; 2.0 stores the complete per-service Proxies dictionaries it owns and restores them only while the applied values still match, otherwise it reports an ownership conflict.
- The proxy mode selector's choice persists with the settings snapshot and survives GUI restarts; a persistence failure keeps the previous mode in force and names the reason. The built-in modes are always available; the external PAC mode requires a configured valid URL. Resetting preferences returns the mode to PAC.
- The GUI is the user-facing manager; the Shadowsocks tunnel service is an external runtime boundary rather than part of the GUI's domain model.
@@ -1,11 +1,13 @@
import Foundation
/// 诊断导出的目录事实(issue #34/#41,story 40):目录计数经 module 内部填充,
/// 原始目录与凭据不出 module;快照只含脱敏数量元数据(D5)。
/// 诊断事实缝(issue #43,story 32):目录只向诊断提供 domain-neutral 的
/// 聚合事实(数量与已知无效数)。诊断报告模型(DiagnosticSnapshot)不经
/// 本类型,原始目录与凭据不出 module。
extension CatalogWorkflow {
/// 把目录数量事实填入诊断快照(服务器/分组/来源/插件计数与已知无效数)。
func fillDiagnosticCatalogFacts(into snapshot: inout DiagnosticSnapshot) {
snapshot.catalog = coordinator.committedCatalog
snapshot.knownInvalidServerCount = tree.invalidServerCount
/// 当前目录的聚合诊断事实(服务器/分组/来源/插件计数与已知无效数)。
var diagnosticCatalogFacts: DiagnosticCatalogFacts {
DiagnosticCatalogFacts(
counts: DiagnosticReportBuilder.counts(in: coordinator.committedCatalog),
knownInvalidServerCount: tree.invalidServerCount)
}
}
+38 -149
View File
@@ -1,14 +1,13 @@
import SwiftUI
import UniformTypeIdentifiers
/// 主窗口诊断区(spec #21 D11,issue #34/#41):日志查看器(GUI 事件流实时
/// 呈现 + wrapper 收敛日志尾部,均可复制)与显式触发的脱敏诊断导出。导出仅由
/// 用户点击触发,内容只含 D5 允许的元数据类目;目录计数经工作流 module 的
/// 诊断事实填充,原始目录不出 module。
/// 主窗口诊断区(spec #21 D11,issue #34/#43):日志查看器(GUI 事件流实时
/// 呈现 + wrapper 收敛日志尾部,均可复制)与显式触发的脱敏诊断导出。事实
/// 采样、轮询代际与报告准备全部经 DiagnosticsWorkflow(issue #43 的唯一
/// UI-facing seam);本视图只负责生命周期触发、呈现、剪贴板、保存面板与
/// 报告文件写入——文件实际写入成功后才登记导出完成事件。
struct DiagnosticsView: View {
@ObservedObject var workflow: CatalogWorkflow
@ObservedObject var proxyController: ProxyRuntimeController
let eventStore: RuntimeEventStore
@ObservedObject var diagnostics: DiagnosticsWorkflow
let errors: ErrorAlertPresenter
enum LogSource: String, CaseIterable, Identifiable {
@@ -28,8 +27,6 @@ struct DiagnosticsView: View {
}
@State private var source: LogSource = .guiEvents
@State private var guiLines: [String] = []
@State private var agentLogText: String?
@State private var exportedPath: String?
var body: some View {
@@ -40,7 +37,7 @@ struct DiagnosticsView: View {
Divider()
logPane
}
.task { await refreshLoop() }
.task { await diagnostics.readWhileActive() }
.alert(
"诊断已导出",
isPresented: Binding(
@@ -85,22 +82,27 @@ struct DiagnosticsView: View {
.padding(8)
}
@ViewBuilder
private var logPane: some View {
/// 当前日志来源的文本与空态提示(呈现与复制共用同一来源,story 21)。
private var activeLog: (text: String, emptyMessage: String) {
switch source {
case .guiEvents:
logText(
guiLines.joined(separator: "\n"),
emptyMessage: "暂无 GUI 事件;开关代理或激活服务器后,运行事件会实时出现在这里。")
return (
diagnostics.logView.guiEventLines.joined(separator: "\n"),
"暂无 GUI 事件;开关代理或激活服务器后,运行事件会实时出现在这里。"
)
case .agentLog:
logText(
agentLogText ?? "",
emptyMessage:
"暂无运行日志;代理运行时(wrapper)启动后,它与 sslocal 的输出会收敛到 agent.log 并在此呈现。"
return (
diagnostics.logView.agentLogTail ?? "",
"暂无运行日志;代理运行时(wrapper)启动后,它与 sslocal 的输出会收敛到 agent.log 并在此呈现。"
)
}
}
@ViewBuilder
private var logPane: some View {
logText(activeLog.text, emptyMessage: activeLog.emptyMessage)
}
private func logText(_ text: String, emptyMessage: String) -> some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 2) {
@@ -119,25 +121,8 @@ struct DiagnosticsView: View {
}
}
// MARK: - 实时刷新
/// 两个日志来源都以轮询刷新(GUI 事件走内存缓冲,agent.log 走尾部读取),
/// 一秒粒度对诊断足够,也避免引入跨线程 @Published。
private func refreshLoop() async {
while !Task.isCancelled {
guiLines = eventStore.snapshot.map(\.renderedLine)
agentLogText = AgentLogTail.readLastLines(of: RuntimePaths.agentLogURL())
try? await Task.sleep(for: .seconds(1))
}
}
private var activeText: String {
switch source {
case .guiEvents:
return guiLines.joined(separator: "\n")
case .agentLog:
return agentLogText ?? ""
}
activeLog.text
}
private func copyToClipboard(_ text: String) {
@@ -150,130 +135,34 @@ struct DiagnosticsView: View {
private func exportReport() {
let panel = NSSavePanel()
panel.allowedContentTypes = [.plainText]
panel.nameFieldStringValue = "ShadowsocksX-NG-诊断-\(fileStamp()).txt"
panel.nameFieldStringValue = diagnostics.suggestedReportFileName()
guard panel.runModal() == .OK, let url = panel.url else { return }
do {
let report = DiagnosticReportBuilder.markdown(from: makeSnapshot())
guard let data = report.data(using: .utf8) else {
errors.present(text: "导出失败:报告无法编码为 UTF-8")
return
switch diagnostics.prepareReport() {
case .ready(let draft):
do {
try draft.data.write(to: url)
diagnostics.noteExportCompleted()
exportedPath = url.path
} catch {
errors.present(text: "导出失败:\(error.localizedDescription)")
}
try data.write(to: url)
RuntimeLog.emit(.diagnosticsExported)
exportedPath = url.path
} catch {
errors.present(text: "导出失败:\(error.localizedDescription)")
}
}
private func makeSnapshot() -> DiagnosticSnapshot {
var snapshot = DiagnosticSnapshot()
snapshot.appVersion = Self.appVersion()
snapshot.systemSummary = Self.systemSummary()
snapshot.proxyState = proxyController.diagnosticState
snapshot.hasActiveTarget = proxyController.isActiveTargetPresent
snapshot.listen = proxyController.listenSettings
snapshot.runtimeDocumentSummary = proxyController.runtimeDocumentSummary()
workflow.fillDiagnosticCatalogFacts(into: &snapshot)
snapshot.fileFacts = Self.fileFacts()
snapshot.managedPlugins = Self.managedPluginFacts()
snapshot.eventLines = eventStore.snapshot.suffix(200).map(\.renderedLine)
snapshot.homePathForRedaction = NSHomeDirectory()
return snapshot
}
/// 受管插件清单(issue #38):静态事实表 + 可执行文件存在性(D10 生成配置
/// 时的同一检查;参数与路径不入导出)。
private static func managedPluginFacts() -> [DiagnosticPluginFacts] {
let provider = BundleManagedPluginProvider()
return ManagedPluginCatalog.plugins.map { info in
DiagnosticPluginFacts(
program: info.program,
version: info.release,
present: provider.executablePath(forProgram: info.program) != nil)
}
}
private static func fileFacts() -> [DiagnosticFileFacts] {
[
DiagnosticFileCollector.collect(label: "运行时目录", url: RuntimePaths.runtimeDirectory()),
DiagnosticFileCollector.collect(
label: "catalog.json", url: CatalogFileStore.defaultFileURL()),
DiagnosticFileCollector.collect(
label: "activation.json", url: ActivationStateFileStore.defaultFileURL()),
DiagnosticFileCollector.collect(
label: "sslocal-active.json", url: RuntimePaths.runtimeFileURL()),
DiagnosticFileCollector.collect(label: "agent.pid", url: RuntimePaths.agentPIDFileURL()),
DiagnosticFileCollector.collect(label: "agent.log", url: RuntimePaths.agentLogURL()),
]
}
private static func appVersion() -> String? {
guard let info = Bundle.main.infoDictionary else { return nil }
let parts = [
(info["CFBundleShortVersionString"] as? String).map { "版本 \($0)" },
(info["CFBundleVersion"] as? String).map { "构建 \($0)" },
].compactMap { $0 }
return parts.isEmpty ? nil : parts.joined(separator: ",")
}
private static func systemSummary() -> String {
let arch: String
#if arch(arm64)
arch = "arm64"
#elseif arch(x86_64)
arch = "x86_64"
#else
arch = "未知架构"
#endif
return "macOS \(ProcessInfo.processInfo.operatingSystemVersionString),\(arch)"
}
private func fileStamp() -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyyMMdd-HHmmss"
return formatter.string(from: Date())
}
}
extension ProxyRuntimeController {
/// 控制器状态 → 诊断安全呈现(D5:不透传任意错误 detail;监听地址只以
/// 回环/非回环两态进入导出,原始错误文本一律丢弃)。
var diagnosticState: DiagnosticProxyState {
switch state {
case .off:
return .off
case .starting:
return .starting
case .running:
return .running
case .firewallBlocked:
return .firewallBlocked
case .launchFailed:
return .launchFailed
case .activationFailed(let reason):
return .activationFailed(reason: reason)
case .requiresApproval:
return .requiresApproval
case .serviceFailed:
return .serviceFailed
case .systemProxyFailed:
return .systemProxyFailed
case .failed:
errors.present(text: "导出失败:诊断报告无法安全构造")
}
}
}
/// 诊断分区侧栏摘要(D11):代理状态一览与脱敏说明。
/// 诊断分区侧栏摘要(D11,story 25):与详情共用 DiagnosticsWorkflow 的同一
/// 份安全摘要 projection。
struct DiagnosticsSummarySidebar: View {
@ObservedObject var proxyController: ProxyRuntimeController
@ObservedObject var workflow: DiagnosticsWorkflow
var body: some View {
List {
Section("状态摘要") {
LabeledContent("代理状态", value: proxyController.diagnosticState.label)
LabeledContent("代理状态", value: workflow.summary.proxyState.label)
LabeledContent(
"活动目标", value: proxyController.isActiveTargetPresent ? "已设置" : "未设置")
"活动目标", value: workflow.summary.hasActiveTarget ? "已设置" : "未设置")
}
Section {
Text(
@@ -0,0 +1,336 @@
import Foundation
/// 代理运行时的只读诊断事实缝(issue #43,story 33):runtime 只以固定状态
/// 类别、活动目标存在性、监听事实与已脱敏契约摘要进入诊断 workflow;生产
/// 实现是 `ProxyRuntimeController` 的诊断只读面,测试注入替身。诊断 module
/// 不直接操作 runtime controller。
@MainActor
protocol ProxyRuntimeDiagnosticFacts: AnyObject {
/// 固定代理状态类别(不透传任意错误 detail)。
var proxyState: DiagnosticProxyState { get }
var hasActiveTarget: Bool { get }
/// 当前监听设置;监听地址在报告中只以回环/非回环两态呈现。
var listen: SslocalListenSettings { get }
/// 运行时契约的脱敏摘要(数量与模式);契约缺失或无效为 nil。
var contractSummary: String? { get }
}
/// 报告事件白名单(issue #43,story 15/45,ADR-0006):封闭的 RuntimeLogEvent
/// 不自动获得导出资格——诊断 module 以只读派生属性显式决定每个事件的报告
/// 呈现,自由错误 detail 与对外公布的主机地址一律清洗掉;数量、端口与领域
/// 点名原因可以进入。新增事件必须先在此补充决策(漏 case 无法编译),
/// 隐私承诺不因新增字段自动扩大。
extension RuntimeLogEvent {
/// 事件在安全报告中的呈现文本;nil = 不进报告。
var diagnosticReportLine: String? {
switch self {
case .contractWritten(let serverCount):
return "contract written (servers=\(serverCount))"
case .contractUnchanged:
return "contract unchanged, write skipped"
case .runtimeFilesDeleted:
return "runtime files deleted"
case .runtimePersistFailed:
return "runtime metadata persist failed"
case .agentRegistered:
return "launch agent registered"
case .agentRegisterFailed:
return "launch agent register failed"
case .agentUnregistered:
return "launch agent unregistered"
case .agentUnregisterFailed:
return "launch agent unregister failed"
case .sslocalSpawned(let pid):
return "sslocal spawned (pid=\(pid))"
case .sslocalSpawnFailed:
return "sslocal spawn failed"
case .sslocalExitedUnexpectedly(let status):
return "sslocal exited unexpectedly (status=\(status))"
case .sslocalStopRequested:
return "sslocal stop requested"
case .listenNotEstablished:
return "listen not established within deadline"
case .pacStarted(let port):
return "PAC endpoint started (port=\(port))"
case .pacStartFailed(let port, _):
return "PAC endpoint failed (port=\(port))"
case .pacStopped:
return "PAC endpoint stopped"
case .contractMissing:
return "contract missing"
case .contractInvalidRemoved:
return "contract invalid, removed"
case .reloadForwarded:
return "reload forwarded to sslocal"
case .reloadRestarted:
return "reload: listen change, restarting sslocal"
// 本机监听端点事实只保留端口:主机地址态的对外公布地址不进报告(D7)。
case .endpointProbeFailed(_, let port, _):
return "local endpoint not ready (port=\(port))"
// 激活原因是领域的点名文案(短节点片段 + 固定文本,无秘密),与代理
// 状态类别同源;任意运行时错误 detail 不在此列。
case .activationFailed(let reason):
return "activation failed: \(reason)"
case .diagnosticsExported:
return "diagnostics report exported (redacted)"
case .listenSettingsUnreadable:
return "listen settings unreadable, falling back to factory defaults"
}
}
}
/// 诊断侧栏与详情共用的安全摘要(story 25):同一份最新 projection。
struct DiagnosticSummary: Equatable, Sendable {
var proxyState: DiagnosticProxyState
var hasActiveTarget: Bool
}
/// 日志视图 projection(issue #43,story 18–20,ADR-0006):raw GUI 事件行与
/// raw wrapper `agent.log` 尾部只经显式查看/复制呈现,永不进入诊断报告。
struct DiagnosticLogView: Equatable, Sendable {
var guiEventLines: [String] = []
var agentLogTail: String?
}
/// 报告准备的结构化产物(issue #43):UI 只负责把 data 写入用户选择的文件。
struct DiagnosticReportDraft: Equatable {
let data: Data
let suggestedFileName: String
}
enum DiagnosticReportFailure: Equatable, Error {
/// 报告文本无法编码为 UTF-8(module 不产生本地化 UI 字符串)。
case encodingFailed
}
/// 报告准备的 typed 结果:ready 携带可写入的草稿;safe projection 无法构造
/// 时返回 typed failure,不产生报告(story 42)。
enum DiagnosticReportOutcome: Equatable {
case ready(DiagnosticReportDraft)
case failed(DiagnosticReportFailure)
}
/// 诊断工作流 module(issue #43,Candidate 03):诊断区的唯一 UI-facing
/// seam。内部组合 runtime 事实、目录聚合事实、GUI 事件源、agent.log 尾部、
/// 文件元数据、受管插件事实、时钟与纯报告 builder;内部事实源不暴露给 UI。
/// 向 UI 提供:侧栏/详情共用的安全摘要、raw 日志视图 projection、读取生命
/// 周期(代际守卫 + 取消)、报告准备(typed 结果)与导出完成登记。
///
/// 诊断收集是只读的:不启停代理、不写系统代理设置、不改目录与凭据。文件
/// 写入、保存面板、剪贴板与 alert 属于 UI;只有用户选择的报告文件实际写入
/// 成功后,UI 才调用 `noteExportCompleted()` 登记既有的导出完成事件。
@MainActor
final class DiagnosticsWorkflow: ObservableObject {
/// 侧栏摘要与详情共用的最新安全 projection(story 25)。
@Published private(set) var summary: DiagnosticSummary
/// 日志视图 projection:读取期间持续更新(story 22)。
@Published private(set) var logView = DiagnosticLogView()
private let runtimeFacts: any ProxyRuntimeDiagnosticFacts
private let events: RuntimeEventStore
private let catalogFacts: @MainActor () -> DiagnosticCatalogFacts?
private let agentLogTail: () -> String?
private let fileFacts: () -> [DiagnosticFileFacts]
private let managedPlugins: () -> [DiagnosticPluginFacts]
private let homePath: () -> String?
private let clock: () -> Date
private let reportRendering: (DiagnosticSnapshot) -> String?
/// 轮询间隔(生产 1 秒;测试注入更细粒度)。
private let pollInterval: Duration
/// 读取代际:新一轮读取使旧一轮的在途结果作废(story 24)。
private var readGeneration = 0
init(
runtimeFacts: any ProxyRuntimeDiagnosticFacts,
events: RuntimeEventStore = .shared,
catalogFacts: @escaping @MainActor () -> DiagnosticCatalogFacts?,
agentLogTail: @escaping () -> String? = {
AgentLogTail.readLastLines(of: RuntimePaths.agentLogURL())
},
fileFacts: @escaping () -> [DiagnosticFileFacts] = DiagnosticsWorkflow.productionFileFacts,
managedPlugins: @escaping () -> [DiagnosticPluginFacts] =
DiagnosticsWorkflow.productionManagedPlugins,
homePath: @escaping () -> String? = { NSHomeDirectory() },
clock: @escaping () -> Date = { Date() },
reportRendering: @escaping (DiagnosticSnapshot) -> String? = {
DiagnosticReportBuilder.markdown(from: $0)
},
pollInterval: Duration = .seconds(1)
) {
self.runtimeFacts = runtimeFacts
self.events = events
self.catalogFacts = catalogFacts
self.agentLogTail = agentLogTail
self.fileFacts = fileFacts
self.managedPlugins = managedPlugins
self.homePath = homePath
self.clock = clock
self.reportRendering = reportRendering
self.pollInterval = pollInterval
summary = DiagnosticSummary(
proxyState: runtimeFacts.proxyState,
hasActiveTarget: runtimeFacts.hasActiveTarget)
}
/// 诊断读取生命周期(UI 在诊断区出现时驱动):循环轮询直到任务被取消
/// (story 23)。每次进入递增代际,旧一轮的在途结果不再发布(story 24);
/// 共享实例保证侧栏与详情不重复轮询(story 25)。
func readWhileActive() async {
readGeneration += 1
let generation = readGeneration
while !Task.isCancelled, generation == readGeneration {
refresh(generation: generation)
try? await Task.sleep(for: pollInterval)
}
}
/// 保存面板的建议文件名(UI 在打开面板前取用;时间戳来自注入时钟)。
func suggestedReportFileName() -> String {
"ShadowsocksX-NG-诊断-\(fileStamp()).txt"
}
/// 准备脱敏诊断报告:只读采样各事实源(best-effort,单一来源缺失不阻止
/// 其他事实),经纯 builder 渲染并编码为 UTF-8。safe projection 无法构造
/// 时返回 typed failure(story 42),不生成报告。
func prepareReport() -> DiagnosticReportOutcome {
let text = reportRendering(makeSnapshot())
guard let data = text?.data(using: .utf8) else {
return .failed(.encodingFailed)
}
return .ready(DiagnosticReportDraft(data: data, suggestedFileName: suggestedReportFileName()))
}
/// 导出完成登记(story 27/44):只有用户选择的报告文件实际写入成功后由
/// UI 调用;打开导出面板或准备报告文本不算导出。
func noteExportCompleted() {
RuntimeLog.emit(.diagnosticsExported)
}
// MARK: - 事实采样与渲染(implementation,UI 不可见)
private func refresh(generation: Int) {
summary = DiagnosticSummary(
proxyState: runtimeFacts.proxyState,
hasActiveTarget: runtimeFacts.hasActiveTarget)
let lines = events.snapshot.map(\.renderedLine)
let tail = agentLogTail()
guard generation == readGeneration, !Task.isCancelled else { return }
logView = DiagnosticLogView(guiEventLines: lines, agentLogTail: tail)
}
private func makeSnapshot() -> DiagnosticSnapshot {
var snapshot = DiagnosticSnapshot()
snapshot.generatedAt = clock()
snapshot.appVersion = Self.appVersion()
snapshot.systemSummary = Self.systemSummary()
snapshot.proxyState = runtimeFacts.proxyState
snapshot.hasActiveTarget = runtimeFacts.hasActiveTarget
snapshot.listen = runtimeFacts.listen
snapshot.runtimeDocumentSummary = runtimeFacts.contractSummary
snapshot.catalogFacts = catalogFacts()
snapshot.fileFacts = fileFacts()
snapshot.managedPlugins = managedPlugins()
snapshot.eventLines = Self.reportEventLines(from: events.snapshot)
snapshot.homePathForRedaction = homePath()
return snapshot
}
/// 报告侧事件行:白名单清洗后的文本复用原始行的时间戳格式(原始 detail
/// 只留在日志视图 projection,ADR-0006)。
static func reportEventLines(from entries: [RuntimeEventStore.Entry]) -> [String] {
entries.suffix(reportEventLimit).compactMap { entry in
entry.event.diagnosticReportLine.map { entry.renderedLine(eventText: $0) }
}
}
private static let reportEventLimit = 200
/// 受管插件清单(issue #38):静态事实表 + 可执行文件存在性(D10 生成配置
/// 时的同一检查;参数与路径不入导出)。
private static func productionManagedPlugins() -> [DiagnosticPluginFacts] {
let provider = BundleManagedPluginProvider()
return ManagedPluginCatalog.plugins.map { info in
DiagnosticPluginFacts(
program: info.program,
version: info.release,
present: provider.executablePath(forProgram: info.program) != nil)
}
}
private static func productionFileFacts() -> [DiagnosticFileFacts] {
[
DiagnosticFileCollector.collect(label: "运行时目录", url: RuntimePaths.runtimeDirectory()),
DiagnosticFileCollector.collect(
label: "catalog.json", url: CatalogFileStore.defaultFileURL()),
DiagnosticFileCollector.collect(
label: "activation.json", url: ActivationStateFileStore.defaultFileURL()),
DiagnosticFileCollector.collect(
label: "sslocal-active.json", url: RuntimePaths.runtimeFileURL()),
DiagnosticFileCollector.collect(label: "agent.pid", url: RuntimePaths.agentPIDFileURL()),
DiagnosticFileCollector.collect(label: "agent.log", url: RuntimePaths.agentLogURL()),
]
}
private static func appVersion() -> String? {
guard let info = Bundle.main.infoDictionary else { return nil }
let parts = [
(info["CFBundleShortVersionString"] as? String).map { "版本 \($0)" },
(info["CFBundleVersion"] as? String).map { "构建 \($0)" },
].compactMap { $0 }
return parts.isEmpty ? nil : parts.joined(separator: ",")
}
private static func systemSummary() -> String {
let arch: String
#if arch(arm64)
arch = "arm64"
#elseif arch(x86_64)
arch = "x86_64"
#else
arch = "未知架构"
#endif
return "macOS \(ProcessInfo.processInfo.operatingSystemVersionString),\(arch)"
}
private func fileStamp() -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyyMMdd-HHmmss"
return formatter.string(from: clock())
}
}
// MARK: - 生产 runtime 事实适配
extension ProxyRuntimeController: ProxyRuntimeDiagnosticFacts {
/// 控制器状态 → 诊断安全呈现(D5:不透传任意错误 detail;监听地址只以
/// 回环/非回环两态进入导出,原始错误文本一律丢弃)。
var proxyState: DiagnosticProxyState {
switch state {
case .off:
return .off
case .starting:
return .starting
case .running:
return .running
case .firewallBlocked:
return .firewallBlocked
case .launchFailed:
return .launchFailed
case .activationFailed(let reason):
return .activationFailed(reason: reason)
case .requiresApproval:
return .requiresApproval
case .serviceFailed:
return .serviceFailed
case .systemProxyFailed:
return .systemProxyFailed
}
}
var hasActiveTarget: Bool { isActiveTargetPresent }
var listen: SslocalListenSettings { listenSettings }
var contractSummary: String? { runtimeDocumentSummary() }
}
+14 -8
View File
@@ -7,6 +7,7 @@ struct ShadowsocksXNG2App: App {
@StateObject private var catalogWorkflow: CatalogWorkflow
@StateObject private var loginController: LaunchAtLoginController
@StateObject private var settingsWorkflow: SettingsWorkflow
@StateObject private var diagnosticsWorkflow: DiagnosticsWorkflow
init() {
let settingsStore = ProxySettingsFileStore()
@@ -24,14 +25,20 @@ struct ShadowsocksXNG2App: App {
let coordinator = CatalogCommitCoordinator(
fileStore: CatalogFileStore(fileURL: CatalogFileStore.defaultFileURL()),
runtime: ProxyRuntimeSyncAdapter(controller: controller))
_catalogWorkflow = StateObject(
wrappedValue: CatalogWorkflow(
coordinator: coordinator,
postLegacyImport: { _ in
await controller.legacyImportDidCommit()
}))
let catalogWorkflow = CatalogWorkflow(
coordinator: coordinator,
postLegacyImport: { _ in
await controller.legacyImportDidCommit()
})
_catalogWorkflow = StateObject(wrappedValue: catalogWorkflow)
// 设置工作流 module(Candidate 02):设置窗口的唯一 seam,组合根接线一次。
_settingsWorkflow = StateObject(wrappedValue: SettingsWorkflow(controller: controller))
// 诊断工作流 module(issue #43):诊断区唯一 seam,组合根接线一次;共享
// 实例供诊断侧栏与详情共同使用。
_diagnosticsWorkflow = StateObject(
wrappedValue: DiagnosticsWorkflow(
runtimeFacts: controller,
catalogFacts: { catalogWorkflow.diagnosticCatalogFacts }))
// GUI 事件接入内存环形缓冲(spec #21 D5,issue #34):主窗口日志查看器与
// 诊断导出的来源;wrapper 侧不注册,仍走 stderr → agent.log 收敛。
RuntimeLog.setSink(RuntimeEventStore.shared)
@@ -49,8 +56,7 @@ struct ShadowsocksXNG2App: App {
Window("ShadowsocksX-NG 2.0", id: "main") {
MainWindowView(
workflow: catalogWorkflow, proxyController: proxyController,
eventStore: .shared
)
diagnostics: diagnosticsWorkflow)
}
.defaultLaunchBehavior(
catalogWorkflow.legacyImportState.shouldOffer ? .automatic : .suppressed)
+4 -5
View File
@@ -16,7 +16,8 @@ struct MainWindowView: View {
@ObservedObject var workflow: CatalogWorkflow
@ObservedObject var proxyController: ProxyRuntimeController
let eventStore: RuntimeEventStore
/// 诊断工作流 module(issue #43):诊断侧栏与详情共用的唯一 seam。
@ObservedObject var diagnostics: DiagnosticsWorkflow
/// 共享错误弹窗呈现(UI 持有;typed error → 本地化文案的呈现边缘)。
@StateObject private var errors = ErrorAlertPresenter()
@@ -134,7 +135,7 @@ struct MainWindowView: View {
/// 诊断分区侧栏:代理状态摘要与脱敏说明(详情与导出入口在右侧日志区)。
private var diagnosticsSidebar: some View {
DiagnosticsSummarySidebar(proxyController: proxyController)
DiagnosticsSummarySidebar(workflow: diagnostics)
}
/// 订阅分区侧栏:订阅摘要说明(卡片与操作全在右侧详情区)。
@@ -231,9 +232,7 @@ struct MainWindowView: View {
private var detailPane: some View {
switch pane {
case .diagnostics:
DiagnosticsView(
workflow: workflow, proxyController: proxyController, eventStore: eventStore,
errors: errors)
DiagnosticsView(diagnostics: diagnostics, errors: errors)
case .subscriptions:
SubscriptionsView(
workflow: workflow, errors: errors,
+24 -14
View File
@@ -79,9 +79,20 @@ enum DiagnosticProxyState: Equatable, Sendable {
// MARK: - 快照与构建器
/// 目录聚合诊断事实(issue #43,story 32/36):数量元数据与已知无效数。原始
/// 目录、目录条目、服务器地址、备注与凭据引用不进入本类型——构造它的唯一
/// 生产入口是目录 workflow 的 domain-neutral 事实缝,报告 builder 只接受
/// 已经裁剪的聚合事实。
struct DiagnosticCatalogFacts: Equatable, Sendable {
var counts = DiagnosticReportBuilder.CatalogCounts()
/// 已知无效服务器数(应用层以当前凭据与插件供应事实计算;nil = 未知)。
var knownInvalidServerCount: Int?
}
/// 诊断快照(issue #34):导出报告的全部输入。字段要么是数量/布尔/时间等
/// 元数据,要么是已脱敏文本(由夹具测试验证),构建器不再接触任何秘密来源
/// ——不读 Keychain、不读契约与目录的文件内容。
/// ——不读 Keychain、不读契约与目录的文件内容;目录事实只以聚合计数进入
/// (issue #43)。
struct DiagnosticSnapshot: Sendable {
var generatedAt = Date()
var appVersion: String?
@@ -91,11 +102,11 @@ struct DiagnosticSnapshot: Sendable {
var listen: SslocalListenSettings?
/// `Redactor.documentSummary` 产出的契约脱敏摘要(数量与模式)。
var runtimeDocumentSummary: String?
var catalog: ConfigurationCatalog?
/// 由应用层用当前凭据与插件供应事实计算的已知无效服务器数。
var knownInvalidServerCount: Int?
/// 目录聚合事实;nil = 目录事实源不可用(报告中明确标注,不静默丢失)。
var catalogFacts: DiagnosticCatalogFacts?
var fileFacts: [DiagnosticFileFacts] = []
/// 渲染好的运行事件行(旧→新,调用方负责封顶)。
/// 渲染好的运行事件行(旧→新,调用方负责封顶;行文本必须来自白名单
/// 清洗投影,issue #43)。
var eventLines: [String] = []
/// bundle 内受管插件清单(issue #38;名称/版本/存在性,不含参数与路径)。
var managedPlugins: [DiagnosticPluginFacts] = []
@@ -190,14 +201,14 @@ enum DiagnosticReportBuilder {
}
if let summary = snapshot.runtimeDocumentSummary {
lines.append("- 运行时契约摘要:\(summary)")
} else {
lines.append("- 运行时契约摘要:不可用")
}
lines.append("")
lines.append("## 配置目录(数量)")
lines.append("")
if let catalog = snapshot.catalog {
lines.append(
contentsOf: catalogCountLines(
catalog, knownInvalidServerCount: snapshot.knownInvalidServerCount))
if let facts = snapshot.catalogFacts {
lines.append(contentsOf: catalogCountLines(facts))
} else {
lines.append("- 配置目录不可用")
}
@@ -229,11 +240,10 @@ enum DiagnosticReportBuilder {
return lines
}
private static func catalogCountLines(
_ catalog: ConfigurationCatalog, knownInvalidServerCount: Int?
) -> [String] {
let counts = counts(in: catalog)
let invalid = knownInvalidServerCount.map { "已知无效 \($0);" } ?? ""
/// 目录行只呈现聚合事实(issue #43):目录条目本身永不进入快照与渲染。
private static func catalogCountLines(_ facts: DiagnosticCatalogFacts) -> [String] {
let counts = facts.counts
let invalid = facts.knownInvalidServerCount.map { "已知无效 \($0);" } ?? ""
return [
"- 服务器:\(counts.servers)(\(invalid)"
+ "配置插件 \(counts.serversWithPlugin);"
@@ -8,12 +8,22 @@ final class RuntimeEventStore: RuntimeEventSink, @unchecked Sendable {
struct Entry: Equatable, Identifiable {
let id: Int
let timestamp: Date
let text: String
/// 事件原值(封闭枚举):原始呈现与报告白名单投影共用同一事实。
let event: RuntimeLogEvent
/// 查看器与导出共用的行格式;固定 POSIX locale 保证报障对照不随用户
/// 事件文本(查看器与原始行共用)。
var text: String { event.description }
/// 查看器共用的行格式;固定 POSIX locale 保证报障对照不随用户
/// 区域漂移。
var renderedLine: String {
"\(Self.timestampFormatter.string(from: timestamp)) \(text)"
renderedLine(eventText: text)
}
/// 指定事件文本的行格式(报告侧经白名单清洗后复用同一时间戳与间隔,
/// issue #43)。
func renderedLine(eventText: String) -> String {
"\(Self.timestampFormatter.string(from: timestamp)) \(eventText)"
}
/// DateFormatter 在现代 Foundation 中线程安全;本格式器只被读取。
@@ -44,10 +54,10 @@ final class RuntimeEventStore: RuntimeEventSink, @unchecked Sendable {
return entries
}
func append(text: String, timestamp: Date = Date()) {
func append(event: RuntimeLogEvent, timestamp: Date = Date()) {
lock.lock()
defer { lock.unlock() }
entries.append(Entry(id: nextID, timestamp: timestamp, text: text))
entries.append(Entry(id: nextID, timestamp: timestamp, event: event))
nextID += 1
if entries.count > capacity {
entries.removeFirst(entries.count - capacity)
+7 -6
View File
@@ -5,7 +5,7 @@ import Foundation
/// 内容;服务器地址与备注默认不进普通日志(端点探测失败点名的 host:port
/// 是本机监听端点,属 D8 要求呈现的事实字段,不受此限)。日志查看与诊断
/// 导出由 #34 在本事件流上接出。
enum RuntimeLogEvent: CustomStringConvertible, Sendable {
enum RuntimeLogEvent: Equatable, CustomStringConvertible, Sendable {
/// 契约文件已原子写入(只携带数量元数据,非内容)。
case contractWritten(serverCount: Int)
/// 契约内容与磁盘一致,跳过写入(幂等)。
@@ -97,11 +97,12 @@ enum RuntimeLogEvent: CustomStringConvertible, Sendable {
}
}
/// 事件接收缝(issue #34):GUI 进程把渲染后的事件文本接入 RuntimeEventStore
/// (内存环形缓冲),供主窗口日志查看器实时呈现与诊断导出取用;wrapper 进程
/// 不注册,仅走 stderr → agent.log 收敛。
/// 事件接收缝(issue #34):GUI 进程把事件接入 RuntimeEventStore(内存环形
/// 缓冲),供主窗口日志查看器实时呈现与诊断导出取用;wrapper 进程不注册,
/// 仅走 stderr → agent.log 收敛。接收方拿到的是封闭枚举原值,渲染发生在
/// 呈现边缘(诊断报告的白名单清洗只可能作用于原值,issue #43)。
protocol RuntimeEventSink: Sendable {
func append(text: String, timestamp: Date)
func append(event: RuntimeLogEvent, timestamp: Date)
}
/// 敏感信息脱敏工具(D5「敏感信息」定义的读取面)。
@@ -135,7 +136,7 @@ enum RuntimeLog {
static func emit(_ event: RuntimeLogEvent) {
let line = "ssxng: \(event.description)\n"
FileHandle.standardError.write(Data(line.utf8))
sinkHolder.current?.append(text: event.description, timestamp: Date())
sinkHolder.current?.append(event: event, timestamp: Date())
}
/// 可变静态的锁保护持有者:emit 可能来自任意线程。
@@ -61,7 +61,8 @@ final class DiagnosticReportTests: XCTestCase {
snapshot.runtimeDocumentSummary = Redactor.documentSummary(
ProxyRuntimeFixture.makeDocument(
serverAddress: address, password: password, pluginOpts: pluginOpts))
snapshot.catalog = catalog
snapshot.catalogFacts = DiagnosticCatalogFacts(
counts: DiagnosticReportBuilder.counts(in: catalog))
snapshot.fileFacts = [
DiagnosticFileFacts(
label: "sslocal-active.json", exists: true, isDirectory: false,
@@ -160,21 +161,19 @@ final class DiagnosticReportTests: XCTestCase {
XCTAssertEqual(summary, "https://example.com/…")
}
/// 导出动作自身不泄露:事件行是唯一允许进入报告的自由文本,其中的家目录
/// 前缀必须改写为「~」;serviceFailed 等原始错误 detail 不入导出。
func testEventLinesRedactHomePath() {
var snapshot = DiagnosticSnapshot()
snapshot.eventLines = [
RuntimeLogEvent.runtimePersistFailed(
detail: "/Users/SECRETUSER/Library/Application Support/ShadowsocksX-NG2: errno 13"
).description
]
snapshot.homePathForRedaction = "/Users/SECRETUSER"
/// 家目录前缀改写是报告渲染的兜底防线(issue #43 后自由 detail 已被上游
/// 白名单清洗,此防御保留给任何仍携带路径的文本)。
func testHomePathRedactionRewritesPrefix() {
let text = RuntimeLogEvent.runtimePersistFailed(
detail: "/Users/SECRETUSER/Library/Application Support/ShadowsocksX-NG2: errno 13"
).description
let report = DiagnosticReportBuilder.markdown(from: snapshot)
let redacted = DiagnosticReportBuilder.redactingHomePaths(text, home: "/Users/SECRETUSER")
XCTAssertTrue(report.contains("~/Library/Application Support"))
XCTAssertFalse(report.contains("/Users/SECRETUSER"))
XCTAssertTrue(redacted.contains("~/Library/Application Support"))
XCTAssertFalse(redacted.contains("/Users/SECRETUSER"))
XCTAssertEqual(
DiagnosticReportBuilder.redactingHomePaths(text, home: nil), text)
}
/// 原始错误文本不属 D5 白名单类目:serviceFailed 只呈现固定状态文案。
@@ -0,0 +1,168 @@
import XCTest
@testable import ShadowsocksX_NG2
/// 诊断报告准备的安全验收(issue #43):投毒夹具验证导出只含白名单清洗后的
/// 事件与聚合事实;raw 日志视图与报告严格分离;safe projection 无法构造时
/// 返回 typed failure 且不产生报告(ADR-0006)。
@MainActor
final class DiagnosticsReportWorkflowTests: DiagnosticsWorkflowTestCase {
// MARK: - 报告安全验收(story 3–8/13/16)
func testReportContainsSafeFactsAndExcludesPoisonedSources() throws {
appendPoisonedEvents()
let workflow = makeWorkflow(
catalog: { try? self.poisonedCatalogFacts() },
agentLog: {
"sslocal ERROR: connect to \(self.address):8388 failed: auth \(self.password)\n"
},
fileFacts: {
[
DiagnosticFileFacts(
label: "sslocal-active.json", exists: true, isDirectory: false,
permissionsOctal: "0600", sizeBytes: 512,
modifiedAt: Date(timeIntervalSince1970: 1_758_000_000)),
DiagnosticFileFacts(
label: "agent.pid", exists: false, isDirectory: false,
permissionsOctal: nil, sizeBytes: nil, modifiedAt: nil),
]
},
managedPlugins: {
[
DiagnosticPluginFacts(program: "v2ray-plugin", version: "v1.3.2", present: true),
DiagnosticPluginFacts(program: "shadow-tls", version: "v3", present: false),
]
},
homePath: { self.home })
facts.proxyState = .running
facts.hasActiveTarget = true
facts.contractSummary = "servers=1 protocols=socks mode=tcp_only"
let report = try reportText(workflow.prepareReport())
assertNoSecrets(report)
// 允许类目的事实必须在场:状态、端口、契约摘要、数量、插件、文件行、生成时间。
XCTAssertTrue(report.contains("- 代理状态:运行中"))
XCTAssertTrue(report.contains("- 活动目标:已设置"))
XCTAssertTrue(report.contains("- 监听范围:回环"))
XCTAssertTrue(report.contains("SOCKS5 端口 11086"))
XCTAssertTrue(report.contains("PAC 端口 11089"))
XCTAssertTrue(report.contains("servers=1 protocols=socks mode=tcp_only"))
XCTAssertTrue(report.contains("服务器:2(配置插件 1;手动 1 / 订阅 1)"))
XCTAssertTrue(report.contains("分组:2"))
XCTAssertTrue(report.contains("- v2ray-plugin v1.3.2:已提供"))
XCTAssertTrue(report.contains("- shadow-tls v3:缺失"))
XCTAssertTrue(report.contains("| sslocal-active.json | 是 | 0600 | 512 |"))
XCTAssertTrue(report.contains("| agent.pid | 否 | — | — |"))
let expectedTime = Self.reportTimestamp(Date(timeIntervalSince1970: 1_758_000_000))
XCTAssertTrue(report.contains("生成时间:\(expectedTime)"), "报告应携带生成时间")
}
// MARK: - 事件白名单(story 15/45)
func testEventAllowlistKeepsCountsAndPortsButDropsFreeDetail() throws {
appendPoisonedEvents()
let workflow = makeWorkflow(homePath: { self.home })
let report = try reportText(workflow.prepareReport())
// 白名单允许的类别与安全字段(数量、端口、领域点名原因)保留。
XCTAssertTrue(report.contains("contract written (servers=3)"))
XCTAssertTrue(report.contains("local endpoint not ready (port=11086)"))
XCTAssertTrue(report.contains("PAC endpoint failed (port=11089)"))
XCTAssertTrue(report.contains("launch agent register failed"))
XCTAssertTrue(report.contains("listen not established within deadline"))
XCTAssertTrue(report.contains("runtime metadata persist failed"))
XCTAssertTrue(report.contains("listen settings unreadable, falling back to factory defaults"))
XCTAssertTrue(
report.contains("activation failed: 激活目标 leaf-9 的插件 simple-obfs 未随 app 提供"),
"领域点名原因按白名单决策进入报告")
assertNoSecrets(report)
}
// MARK: - raw 日志视图与报告的分离(story 18–20,ADR-0006)
func testRawLogViewServesExplicitViewButReportExcludesIt() async throws {
appendPoisonedEvents()
let rawTail = "wrapper stderr \(password) \(address)\n"
let workflow = makeWorkflow(agentLog: { rawTail }, homePath: { self.home })
// 读取循环提供 raw 投影(显式查看/复制的唯一来源)。
let task = Task { await workflow.readWhileActive() }
await waitUntil(workflow.logView.agentLogTail == rawTail)
XCTAssertTrue(
workflow.logView.guiEventLines.joined().contains("SECRET-PROBE-DETAIL"),
"raw 事件行保留 detail 供本地排障")
task.cancel()
await task.value
let report = try reportText(workflow.prepareReport())
XCTAssertFalse(report.contains("wrapper stderr"), "raw wrapper 日志不得进入报告")
assertNoSecrets(report)
}
// MARK: - best-effort 缺失标注(story 11/12/41)
func testUnavailableFactSourcesAreMarkedAndOthersRemain() throws {
facts.proxyState = .running
facts.contractSummary = nil
let workflow = makeWorkflow(
agentLog: { nil },
fileFacts: {
[
DiagnosticFileFacts(
label: "agent.pid", exists: false, isDirectory: false,
permissionsOctal: nil, sizeBytes: nil, modifiedAt: nil)
]
})
let report = try reportText(workflow.prepareReport())
XCTAssertTrue(report.contains("- 配置目录不可用"), "目录缺失须明确标注")
XCTAssertTrue(report.contains("- 运行时契约摘要:不可用"), "契约缺失须明确标注")
XCTAssertTrue(report.contains("| agent.pid | 否 | — | — |"))
XCTAssertTrue(report.contains("- 代理状态:运行中"), "其余可用事实保留")
}
// MARK: - 报告 builder 只接受已裁剪事实(story 36/42)
func testRenderingReceivesTrimmedFactsOnly() throws {
appendPoisonedEvents()
var captured: [DiagnosticSnapshot] = []
let workflow = makeWorkflow(
catalog: { try? self.poisonedCatalogFacts() },
agentLog: { "SECRET raw tail" },
homePath: { self.home },
capture: { captured.append($0) })
_ = workflow.prepareReport()
XCTAssertEqual(captured.count, 1)
let snapshot = try XCTUnwrap(captured.first)
// 快照只携带聚合事实:计数不含任何敏感值,事件行是白名单清洗文本。
XCTAssertEqual(snapshot.catalogFacts?.counts.servers, 2)
XCTAssertEqual(snapshot.catalogFacts?.counts.groups, 2)
XCTAssertEqual(snapshot.eventLines.count, events.snapshot.count)
for line in snapshot.eventLines {
for secret in secrets {
XCTAssertFalse(line.contains(secret), "快照事件行不得携带「\(secret)」")
}
}
XCTAssertEqual(snapshot.homePathForRedaction, home, "脱敏基准随事实源注入")
}
func testRenderingFailureYieldsTypedFailureWithoutCompletion() {
let workflow = makeWorkflow(render: { _ in nil })
guard case .failed(.encodingFailed) = workflow.prepareReport() else {
XCTFail("safe projection 无法构造时应返回 typed failure")
return
}
XCTAssertFalse(
events.snapshot.contains {
if case .diagnosticsExported = $0.event { return true } else { return false }
},
"报告未生成就不得登记导出完成")
}
}
@@ -0,0 +1,168 @@
import XCTest
@testable import ShadowsocksX_NG2
/// 诊断工作流测试共享夹具(issue #43):runtime 事实替身、投毒事件集、聚合
/// 目录事实与 workflow 构造(事实源全部注入,时钟固定,轮询 10ms)。
@MainActor
class DiagnosticsWorkflowTestCase: XCTestCase {
let password = "SECRET-PASSWORD"
let address = "203.0.113.7"
let remark = "香港 01"
let home = "/Users/SECRETUSER"
let lanAddress = "192.0.2.77"
/// 投毒事件 detail:白名单必须整体清洗,不得进入报告。
let poisonDetails = [
"SECRET-PERSIST-DETAIL", "SECRET-PROBE-DETAIL", "SECRET-PAC-DETAIL",
"SECRET-LISTEN-DETAIL", "SECRET-AGENT-DETAIL", "SECRET-UNREADABLE-DETAIL",
]
lazy var secrets: [String] = {
[password, address, remark, home, lanAddress] + poisonDetails
}()
var workDir: URL!
var facts: FakeRuntimeFacts!
var events: RuntimeEventStore!
override func setUp() {
super.setUp()
workDir = FileManager.default.temporaryDirectory
.appendingPathComponent("ssxng-diagwf-\(UUID().uuidString)", isDirectory: true)
try? FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true)
facts = FakeRuntimeFacts()
events = RuntimeEventStore()
// 导出完成事件经 RuntimeLog 发射:测试期间把替身缓冲注册为接收缝。
RuntimeLog.setSink(events)
}
override func tearDownWithError() throws {
RuntimeLog.setSink(RuntimeEventStore.shared)
try? FileManager.default.removeItem(at: workDir)
try super.tearDownWithError()
}
/// 标准 workflow:事实源注入替身、固定时钟(报告生成时间可精确断言)。
func makeWorkflow(
catalog: @escaping @MainActor () -> DiagnosticCatalogFacts? = { nil },
agentLog: @escaping () -> String? = { nil },
fileFacts: @escaping () -> [DiagnosticFileFacts] = { [] },
managedPlugins: @escaping () -> [DiagnosticPluginFacts] = { [] },
homePath: @escaping () -> String? = { nil },
render: @escaping (DiagnosticSnapshot) -> String? = {
DiagnosticReportBuilder.markdown(from: $0)
},
capture: ((DiagnosticSnapshot) -> Void)? = nil
) -> DiagnosticsWorkflow {
DiagnosticsWorkflow(
runtimeFacts: facts,
events: events,
catalogFacts: catalog,
agentLogTail: agentLog,
fileFacts: fileFacts,
managedPlugins: managedPlugins,
homePath: homePath,
clock: { Date(timeIntervalSince1970: 1_758_000_000) },
reportRendering: { snapshot in
capture?(snapshot)
return render(snapshot)
},
pollInterval: .milliseconds(10))
}
func waitUntil(
_ condition: @autoclosure () -> Bool,
timeout: TimeInterval = 2,
file: StaticString = #filePath,
line: UInt = #line
) async {
let deadline = Date().addingTimeInterval(timeout)
while !condition() && Date() < deadline {
try? await Task.sleep(for: .milliseconds(10))
}
XCTAssertTrue(condition(), "等待条件超时", file: file, line: line)
}
func assertNoSecrets(
_ text: String, file: StaticString = #filePath, line: UInt = #line
) {
for secret in secrets {
XCTAssertFalse(
text.contains(secret),
"诊断报告不得包含敏感值「\(secret)」\n实际:\(text)",
file: file, line: line)
}
XCTAssertFalse(text.contains("{"), "诊断报告不得携带 JSON 内容", file: file, line: line)
}
/// 投毒目录:地址、备注、分组名全为敏感值(聚合后这些值在结构上进不了
/// facts)。
func poisonedCatalogFacts() throws -> DiagnosticCatalogFacts {
var catalog = ConfigurationCatalog()
let group = try catalog.addGroup("SECRET-GROUP")
_ = try catalog.addServer(
ServerFields(
address: address, port: 8388, encryptionMethod: "aes-256-gcm",
passwordRef: CredentialReference(rawValue: "pw-ref-1"),
remark: remark,
pluginProgram: "v2ray-plugin",
pluginOptionsRef: CredentialReference(rawValue: "opts-ref-1")),
to: group)
let subscriptionGroup = try catalog.addGroup("SECRET-GROUP-sub", source: .subscription)
_ = try catalog.addServer(
ServerFields(
address: address, port: 8389, encryptionMethod: "chacha20-ietf-poly1305",
passwordRef: CredentialReference(rawValue: "pw-ref-2"), remark: remark),
source: .subscription,
to: subscriptionGroup)
return DiagnosticCatalogFacts(counts: DiagnosticReportBuilder.counts(in: catalog))
}
/// 投毒事件集:白名单内类别 + 自由 detail 携带敏感值。
func appendPoisonedEvents() {
let stamp = Date(timeIntervalSince1970: 1_758_000_000)
events.append(event: .contractWritten(serverCount: 3), timestamp: stamp)
events.append(
event: .runtimePersistFailed(detail: "\(home): SECRET-PERSIST-DETAIL"), timestamp: stamp)
events.append(
event: .endpointProbeFailed(host: lanAddress, port: 11086, detail: "SECRET-PROBE-DETAIL"),
timestamp: stamp)
events.append(
event: .pacStartFailed(port: 11089, detail: "SECRET-PAC-DETAIL"), timestamp: stamp)
events.append(event: .listenNotEstablished(detail: "SECRET-LISTEN-DETAIL"), timestamp: stamp)
events.append(
event: .agentRegisterFailed(detail: "SECRET-AGENT-DETAIL"), timestamp: stamp)
events.append(
event: .listenSettingsUnreadable(detail: "SECRET-UNREADABLE-DETAIL"), timestamp: stamp)
// 激活原因是领域点名文案(显式白名单决策,story 14):可在场,但只允许
// 这类固定文本。
events.append(
event: .activationFailed(reason: "激活目标 leaf-9 的插件 simple-obfs 未随 app 提供"),
timestamp: stamp)
}
func reportText(_ outcome: DiagnosticReportOutcome) throws -> String {
guard case .ready(let draft) = outcome else {
XCTFail("应产出 ready 报告,实际 \(outcome)")
throw XCTSkip("无报告文本")
}
return try XCTUnwrap(String(data: draft.data, encoding: .utf8))
}
static func reportTimestamp(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
return formatter.string(from: date)
}
}
/// runtime 事实替身:可编程的安全事实值。
@MainActor
final class FakeRuntimeFacts: ProxyRuntimeDiagnosticFacts {
var proxyState: DiagnosticProxyState = .off
var hasActiveTarget = false
var listen = SslocalListenSettings()
var contractSummary: String?
}
@@ -0,0 +1,157 @@
import XCTest
@testable import ShadowsocksX_NG2
/// 诊断工作流的读取生命周期与导出登记测试(issue #43):安全摘要共用
/// projection、只读收集、读取代际与取消语义、导出完成只在显式确认后登记、
/// 目录事实缝只输出聚合事实(story 10/23/25/26/27/32/44)。
@MainActor
final class DiagnosticsWorkflowLifecycleTests: DiagnosticsWorkflowTestCase {
// MARK: - 安全摘要(story 1/2/25)
func testSummaryTracksRuntimeFactsWhileReading() async throws {
let workflow = makeWorkflow()
XCTAssertEqual(workflow.summary.proxyState, .off)
XCTAssertFalse(workflow.summary.hasActiveTarget)
let task = Task { await workflow.readWhileActive() }
facts.proxyState = .running
facts.hasActiveTarget = true
await waitUntil(
workflow.summary == DiagnosticSummary(proxyState: .running, hasActiveTarget: true))
task.cancel()
await task.value
}
// MARK: - 导出完成登记(story 26/27/44)
func testExportCompletionRecordedOnlyAfterExplicitConfirmation() throws {
let workflow = makeWorkflow(catalog: { DiagnosticCatalogFacts() })
guard case .ready = workflow.prepareReport() else {
XCTFail("标准事实源应产出 ready 报告")
return
}
XCTAssertTrue(
events.snapshot.isEmpty, "打开面板/准备报告不得登记导出完成")
workflow.noteExportCompleted()
XCTAssertEqual(events.snapshot.last?.event, .diagnosticsExported)
}
// MARK: - 只读与读取生命周期(story 10/23)
func testCollectionIsReadOnly() async throws {
let fileURL = workDir.appendingPathComponent("catalog.json")
try Data("{\"servers\":1}".utf8).write(to: fileURL)
try FileManager.default.setAttributes(
[.posixPermissions: 0o600], ofItemAtPath: fileURL.path)
let fileFacts: () -> [DiagnosticFileFacts] = {
[DiagnosticFileCollector.collect(label: "catalog.json", url: fileURL)]
}
let beforeFacts = fileFacts()
let beforeContents = try Data(contentsOf: fileURL)
var counter = 0
let workflow = makeWorkflow(
catalog: { DiagnosticCatalogFacts() },
agentLog: {
counter += 1
return "tail-\(counter)"
},
fileFacts: fileFacts)
let initialSummary = workflow.summary
let task = Task { await workflow.readWhileActive() }
// 计数闭包每次轮询都产生新值:等待「首个 tail 投影到达」而非具体值。
await waitUntil(workflow.logView.agentLogTail?.hasPrefix("tail-") == true)
_ = workflow.prepareReport()
task.cancel()
await task.value
XCTAssertEqual(fileFacts(), beforeFacts, "收集不得改变文件元数据")
XCTAssertEqual(try Data(contentsOf: fileURL), beforeContents, "收集不得改变文件内容")
XCTAssertEqual(workflow.summary, initialSummary, "收集不得改变 runtime 事实")
// 凭据与系统代理设置不在 workflow 的依赖面内:module 结构上不可触达。
}
/// 读取代际(story 24):新一代读取进入后,旧循环必须退场,不得再发布
/// 旧代结果覆盖最新 projection。
func testNewerReadGenerationSupersedesOlderLoop() async throws {
var counter = 0
let workflow = makeWorkflow(agentLog: {
counter += 1
return "tail-\(counter)"
})
let older = Task { await workflow.readWhileActive() }
await waitUntil(workflow.logView.agentLogTail?.hasPrefix("tail-") == true)
let newer = Task { await workflow.readWhileActive() }
// 旧循环最迟在下一个轮询间隔随代际守卫退出。
let olderExited = await withTaskGroup(of: Bool.self) { group in
group.addTask {
await older.value
return true
}
group.addTask {
try? await Task.sleep(for: .seconds(2))
return false
}
let first = await group.next() ?? false
group.cancelAll()
return first
}
XCTAssertTrue(olderExited, "旧一代读取循环应在新一代进入后退场")
newer.cancel()
await newer.value
XCTAssertTrue(
workflow.logView.agentLogTail?.hasPrefix("tail-") == true, "最新 projection 保持")
}
func testCancellingReadStopsPublishingNewProjections() async throws {
var counter = 0
let workflow = makeWorkflow(agentLog: {
counter += 1
return "tail-\(counter)"
})
let task = Task { await workflow.readWhileActive() }
await waitUntil(workflow.logView.agentLogTail?.hasPrefix("tail-") == true)
task.cancel()
await task.value
let frozen = workflow.logView
events.append(event: .agentRegistered, timestamp: Date())
try await Task.sleep(for: .milliseconds(100))
XCTAssertEqual(workflow.logView, frozen, "取消读取后不得再发布新 projection")
}
// MARK: - 目录事实缝(story 32)
func testCatalogWorkflowProvidesAggregateDiagnosticFactsOnly() async throws {
let fileURL = workDir.appendingPathComponent("catalog.json")
let coordinator = CatalogCommitCoordinator(
fileStore: CatalogFileStore(fileURL: fileURL), runtime: FakeCatalogRuntime())
let catalogWorkflow = CatalogWorkflow(
coordinator: coordinator,
credentials: InMemoryCredentialStore(),
plugins: ActivationFixture.TestManagedPlugins(pathsByProgram: [:]))
// 带受管插件引用的服务器:程序在集内但可执行文件缺失 → 已知无效。
let uri =
"ss://YWVzLTI1Ni1nY206cGFzc3dvcmQxMjM@\(address):8388"
+ "/?plugin=v2ray-plugin%3Bmode%3Dwebsocket"
_ = try await catalogWorkflow.createServers(fromURIs: uri, into: nil)
let facts = catalogWorkflow.diagnosticCatalogFacts
XCTAssertEqual(facts.counts.servers, 1)
XCTAssertEqual(facts.counts.groups, 0)
XCTAssertEqual(facts.counts.serversWithPlugin, 1)
XCTAssertEqual(facts.counts.manualServers, 1)
XCTAssertEqual(facts.counts.subscriptionServers, 0)
XCTAssertEqual(facts.knownInvalidServerCount, 1, "插件缺失的服务器计为已知无效")
}
}
@@ -16,28 +16,36 @@ final class RuntimeEventStoreTests: XCTestCase {
let first = Date(timeIntervalSince1970: 1_000)
let second = Date(timeIntervalSince1970: 2_000)
store.append(text: "first event", timestamp: first)
store.append(text: "second event", timestamp: second)
store.append(event: .contractWritten(serverCount: 42), timestamp: first)
store.append(event: .agentRegistered, timestamp: second)
let snapshot = store.snapshot
XCTAssertEqual(snapshot.map(\.text), ["first event", "second event"])
XCTAssertEqual(
snapshot.map(\.text),
["contract written (servers=42)", "launch agent registered"])
XCTAssertEqual(snapshot.map(\.id), [0, 1])
// 行格式 = 固定格式时间戳 + 两空格 + 事件文本(不锚定时区,格式器随本机)。
XCTAssertEqual(
snapshot[0].renderedLine,
RuntimeEventStore.Entry.timestampFormatter.string(from: first) + " first event")
RuntimeEventStore.Entry.timestampFormatter.string(from: first)
+ " contract written (servers=42)")
XCTAssertEqual(
snapshot[1].renderedLine,
RuntimeEventStore.Entry.timestampFormatter.string(from: second) + " second event")
RuntimeEventStore.Entry.timestampFormatter.string(from: second) + " launch agent registered")
}
func testCapacityKeepsMostRecentEvents() {
let store = RuntimeEventStore(capacity: 3)
for index in 0..<5 {
store.append(text: "event-\(index)", timestamp: Date())
store.append(event: .contractWritten(serverCount: index), timestamp: Date())
}
XCTAssertEqual(store.snapshot.map(\.text), ["event-2", "event-3", "event-4"])
XCTAssertEqual(
store.snapshot.map(\.text),
[
"contract written (servers=2)", "contract written (servers=3)",
"contract written (servers=4)",
])
}
func testEmitRoutesToRegisteredSink() {
@@ -0,0 +1,18 @@
# Keep raw diagnostic logs separate from diagnostic reports
## Status
Accepted.
The diagnostic log view may expose raw wrapper `agent.log` text and raw GUI event detail only through an explicit view/copy action, while a diagnostic report is built exclusively from safe diagnostic facts and allowlisted, field-cleaned event projections. Reports never include raw logs, the configuration catalog, server addresses or remarks, credential references, URLs or tokens, runtime JSON, paths, or free-form failure detail. This preserves useful local troubleshooting without turning user-shared export files into an uncontrolled disclosure channel.
## Considered options
- Include the raw log tail in the exported report: rejected because wrapper output and future event details are not guaranteed to satisfy the report's sensitive-information policy.
- Remove raw log viewing entirely: rejected because explicit local viewing and copying is useful for troubleshooting and is a narrower exposure than an exported report.
## Consequences
- Diagnostics must maintain separate raw-log and safe-report projections.
- Adding a new event field does not automatically make it eligible for export; the report allowlist must be updated deliberately.
- Export completion is recorded only after the user-selected report file is successfully written.