diff --git a/GLOSSARY.md b/GLOSSARY.md index 26c573d3..88cf170a 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -218,7 +218,7 @@ _避免用词_:系统级 UDP 代理、自动 UDP 代理。 用户保存的本地与局域网代理入口配置,包含端口、局域网启用状态、监听模式、UDP 中继和身份验证资料。它不包含代理模式、代理服务开关、系统代理开关或附加系统代理例外,也不同于派生的运行配置文件。 **局域网身份验证(LAN authentication)**: -局域网 SOCKS 与 HTTP 入口共用的用户名和密码验证,不表示 UDP 中继具备同等保护。身份验证启用时,保存要求用户名形状有效且密码引用存在;新写入 keychain 的密码还需符合形状。身份验证关闭时凭据是保留值,不拦其他局域网设置的保存。 +局域网 SOCKS 与 HTTP 入口共用的用户名和密码验证,不表示 UDP 中继具备同等保护。身份验证启用时,保存要求用户名和密码符合形状。身份验证关闭时凭据是保留值,不拦其他局域网设置的保存。 **监听模式(Listener mode)**: 局域网监听启用时采用的地址族选项,包括所有 IPv4 接口、所有 IPv4 与 IPv6 接口、所有 IPv6 接口;与是否启用局域网监听独立。 @@ -285,9 +285,6 @@ _避免用词_:菜单栏形态(Menu bar form,应用形态并非恒定) **凭据暴露边界(Credential exposure boundary)**: 产品降低意外泄露及向其他用户账户暴露凭据的保护范围。该范围不包含同一用户进程被攻破、root 访问或快照与备份,也不承诺安全擦除。 -**凭据引用(Credential reference)**: -设置与其独立保存的持久凭据之间的非秘密关联,例如局域网身份验证设置与认证密码的关联,与凭据值本身不同。服务器密码、完整插件参数和订阅 URL 属于服务器库中的相应记录,不以凭据引用表达。 - **诊断报告(Diagnostic report)**: 用户请求生成的脱敏文档,包含允许披露的运行状态、端点元数据、配置汇总及安全事件资料。它排除敏感信息、服务器地址、备注和原始日志。 diff --git a/ShadowsocksX-NG2/App/Application/CatalogWorkflow/ServerLibraryFailure.swift b/ShadowsocksX-NG2/App/Application/CatalogWorkflow/ServerLibraryFailure.swift index 43f51bc6..fcda02eb 100644 --- a/ShadowsocksX-NG2/App/Application/CatalogWorkflow/ServerLibraryFailure.swift +++ b/ShadowsocksX-NG2/App/Application/CatalogWorkflow/ServerLibraryFailure.swift @@ -12,7 +12,7 @@ enum ServerLibraryFailure: Equatable { init(_ error: Error) { switch error { - case CredentialStoreError.keychainStatus(let status): self = .authorization(status) + case MasterKeyStoreError.keychainStatus(let status): self = .authorization(status) case ServerLibraryError.keyUnavailable: self = .keyUnavailable case ServerLibraryError.authenticationFailed: self = .authenticationFailed case ServerLibraryError.unsupportedVersion: self = .unsupportedVersion diff --git a/ShadowsocksX-NG2/App/Application/DiagnosticsWorkflow/DiagnosticsWorkflow.swift b/ShadowsocksX-NG2/App/Application/DiagnosticsWorkflow/DiagnosticsWorkflow.swift index f997dd47..493e43ca 100644 --- a/ShadowsocksX-NG2/App/Application/DiagnosticsWorkflow/DiagnosticsWorkflow.swift +++ b/ShadowsocksX-NG2/App/Application/DiagnosticsWorkflow/DiagnosticsWorkflow.swift @@ -10,7 +10,7 @@ protocol ProxyRuntimeDiagnosticFacts: AnyObject { var proxyState: DiagnosticProxyState { get } var hasActiveTarget: Bool { get } /// 当前监听设置;监听地址在报告中只以回环/非回环两态呈现。 - var listen: ProxyListeningConfiguration { get } + var listen: RuntimeListenFacts { get } /// 运行时契约的脱敏摘要(数量与模式);契约缺失或无效为 nil。 var contractSummary: String? { get } /// 自定义规则安全摘要:数量 + 内容版本,无原始域名。 @@ -342,7 +342,7 @@ extension ProxyRuntimeController: ProxyRuntimeDiagnosticFacts { var hasActiveTarget: Bool { isActiveTargetPresent } - var listen: ProxyListeningConfiguration { listenSettings } + var listen: RuntimeListenFacts { RuntimeListenFacts(listen: listenSettings) } var contractSummary: String? { runtimeDocumentSummary() } diff --git a/ShadowsocksX-NG2/App/Application/ProxyControlWorkflow/ProxyControlAdapters.swift b/ShadowsocksX-NG2/App/Application/ProxyControlWorkflow/ProxyControlAdapters.swift index a0181e3a..1e01a1ab 100644 --- a/ShadowsocksX-NG2/App/Application/ProxyControlWorkflow/ProxyControlAdapters.swift +++ b/ShadowsocksX-NG2/App/Application/ProxyControlWorkflow/ProxyControlAdapters.swift @@ -44,8 +44,7 @@ final class ControllerProxyRuntimeAdapter: ProxyRuntimeAdapting { /// `password()` 契约 = 可直接用于 userinfo 的秘密;此处不再做形状校验 /// (凭据暴露边界不承诺防同用户篡改;写明文凭据文件前另有 `auth.isValid` 把关)。 func terminalLANPassword() throws -> String { - guard let raw = controller.listenSettings.lan.passwordReference, - let password = try controller.credentials.secret(for: CredentialReference(rawValue: raw)) + guard let password = controller.listenSettings.lan.password else { throw TerminalCommandFailure.credentialUnreadable } diff --git a/ShadowsocksX-NG2/App/Application/ProxyRuntime/ProxyRuntimeController+Commands.swift b/ShadowsocksX-NG2/App/Application/ProxyRuntime/ProxyRuntimeController+Commands.swift index c1aba7e9..6d60586b 100644 --- a/ShadowsocksX-NG2/App/Application/ProxyRuntime/ProxyRuntimeController+Commands.swift +++ b/ShadowsocksX-NG2/App/Application/ProxyRuntime/ProxyRuntimeController+Commands.swift @@ -115,6 +115,10 @@ extension ProxyRuntimeController { /// GUI startup restores the Agent. Healthy proxy intent only inspects; unhealthy /// proxy intent suspends; disabled intent never writes SystemConfiguration. func resyncOnLaunch() async { + guard !settingsUnreadable else { + state = .launchFailed(.unreadableSettings) + return + } guard catalogSnapshotReader.isCatalogAvailable else { return } refreshPluginSecurityFacts() systemProxyObserver.beginStartupResync() diff --git a/ShadowsocksX-NG2/App/Application/ProxyRuntime/ProxyRuntimeController+Convergence.swift b/ShadowsocksX-NG2/App/Application/ProxyRuntime/ProxyRuntimeController+Convergence.swift index 48282d5d..251af927 100644 --- a/ShadowsocksX-NG2/App/Application/ProxyRuntime/ProxyRuntimeController+Convergence.swift +++ b/ShadowsocksX-NG2/App/Application/ProxyRuntime/ProxyRuntimeController+Convergence.swift @@ -73,6 +73,10 @@ extension ProxyRuntimeController { /// 幂等跳过由 apply 层的 plan 空判定承担;目录重校验(跳过名单、目标失效 /// 清除)已在 `reexpand` 完成,不受其影响。 func convergeToReexpanded(_ catalog: ConfigurationCatalog) async { + guard !settingsUnreadable else { + state = .launchFailed(.unreadableSettings) + return + } guard catalogSnapshotReader.isCatalogAvailable else { return } switch reexpand(in: catalog) { case .deployed(let configuration): @@ -102,6 +106,10 @@ extension ProxyRuntimeController { @discardableResult func convergeRuntime(_ intent: RuntimeConvergenceIntent) async -> ConvergenceResult { + guard !settingsUnreadable else { + state = .launchFailed(.unreadableSettings) + return .failed + } guard catalogSnapshotReader.isCatalogAvailable else { return .failed } switch intent { case .deployment(let source): return await convergeDeployment(source) @@ -111,15 +119,6 @@ extension ProxyRuntimeController { } private func convergeDeployment(_ source: SslocalRuntimeDocument) async -> ConvergenceResult { - if settingsUnreadable { - RuntimeLog.emit(.activationFailed(reason: "settings unreadable")) - _ = await execute(.stop, document: nil) - lastDocument = nil - skippedServers = [] - state = .launchFailed(.unreadableSettings) - await systemProxyObserver.holdSystemProxyIntent() - return .failed - } let contract: PreparedRuntimeContract do { contract = try PreparedRuntimeContract(await runtimeDocument(source, for: proxyMode)) diff --git a/ShadowsocksX-NG2/App/Application/ProxyRuntime/ProxyRuntimeController+Preparation.swift b/ShadowsocksX-NG2/App/Application/ProxyRuntime/ProxyRuntimeController+Preparation.swift index 7721e2be..198124d2 100644 --- a/ShadowsocksX-NG2/App/Application/ProxyRuntime/ProxyRuntimeController+Preparation.swift +++ b/ShadowsocksX-NG2/App/Application/ProxyRuntime/ProxyRuntimeController+Preparation.swift @@ -37,9 +37,7 @@ extension ProxyRuntimeController { guard preparationIsCurrent(ticket) else { throw RulePreparationError.superseded } let lan = ticket.settings.listen.lan guard lan.isEnabled && lan.authenticationEnabled else { return result.authenticatingLAN(nil) } - guard let raw = lan.passwordReference, - let password = try credentials.secret(for: CredentialReference(rawValue: raw)) - else { + guard let password = lan.password else { throw ProxyListeningStoreError.credentialFailure(detail: String(localized: "局域网身份验证密码不可读")) } let auth = RuntimeListenerAuthentication( diff --git a/ShadowsocksX-NG2/App/Application/ProxyRuntime/ProxyRuntimeController+SettingsSync.swift b/ShadowsocksX-NG2/App/Application/ProxyRuntime/ProxyRuntimeController+SettingsSync.swift index 30f64cdf..1f43be2f 100644 --- a/ShadowsocksX-NG2/App/Application/ProxyRuntime/ProxyRuntimeController+SettingsSync.swift +++ b/ShadowsocksX-NG2/App/Application/ProxyRuntime/ProxyRuntimeController+SettingsSync.swift @@ -70,6 +70,7 @@ extension ProxyRuntimeController { /// 保存监听配置后更新内存并收敛;文件失败不发布新值。 func updateListening(_ proposed: ProxyListeningConfiguration) async throws { + guard !settingsUnreadable else { throw ProxyListeningStoreError.encryptedFile(.notLoaded) } try listeningStore.save(proposed) settings.listen = proposed settingsUnreadable = false diff --git a/ShadowsocksX-NG2/App/Application/ProxyRuntime/ProxyRuntimeController.swift b/ShadowsocksX-NG2/App/Application/ProxyRuntime/ProxyRuntimeController.swift index 74139c80..21e88ff1 100644 --- a/ShadowsocksX-NG2/App/Application/ProxyRuntime/ProxyRuntimeController.swift +++ b/ShadowsocksX-NG2/App/Application/ProxyRuntime/ProxyRuntimeController.swift @@ -49,7 +49,6 @@ final class ProxyRuntimeController { let catalogSnapshotReader: RuntimeCatalogSnapshotReading let activationFileStore: ActivationStateFileStore let runtimeFileStore: RuntimeFileStore - let credentials: CredentialStoring let plugins: PluginExecutableResolving let refreshPluginSecurityFacts: @MainActor () -> Void let controlPreferences: ProxyControlPreferences @@ -122,7 +121,6 @@ final class ProxyRuntimeController { activationFileStore: ActivationStateFileStore = ActivationStateFileStore( fileURL: ActivationStateFileStore.defaultFileURL()), runtimeFileStore: RuntimeFileStore = RuntimeFileStore(), - credentials: CredentialStoring = KeychainCredentialStore(), plugins: PluginExecutableResolving = BundleManagedPluginProvider(), refreshPluginSecurityFacts: @escaping @MainActor () -> Void = {}, listeningStore: ProxyListeningStoring = ProxyListeningFileStore(), @@ -155,7 +153,6 @@ final class ProxyRuntimeController { self.catalogSnapshotReader = catalogSnapshotReader self.activationFileStore = activationFileStore self.runtimeFileStore = runtimeFileStore - self.credentials = credentials self.plugins = plugins self.refreshPluginSecurityFacts = refreshPluginSecurityFacts self.controlPreferences = controlPreferences diff --git a/ShadowsocksX-NG2/App/Application/SettingsWorkflow/ProxyListeningStartup.swift b/ShadowsocksX-NG2/App/Application/SettingsWorkflow/ProxyListeningStartup.swift new file mode 100644 index 00000000..b4cb6963 --- /dev/null +++ b/ShadowsocksX-NG2/App/Application/SettingsWorkflow/ProxyListeningStartup.swift @@ -0,0 +1,21 @@ +import Foundation + +/// 正常应用装配之前完成读取或显式重置,失败期间不构造运行时控制器。 +@MainActor +enum ProxyListeningStartup { + static func load( + store: ProxyListeningFileStore, + presentReset: (ProxyListeningStoreError) -> Void + ) -> ProxyListeningConfiguration { + var failure: ProxyListeningStoreError + do { return try store.load() } catch { failure = listeningFailure(error) } + while true { + presentReset(failure) + do { return try store.reset() } catch { failure = listeningFailure(error) } + } + } + + private static func listeningFailure(_ error: Error) -> ProxyListeningStoreError { + (error as? ProxyListeningStoreError) ?? .ioFailure(detail: String(describing: error)) + } +} diff --git a/ShadowsocksX-NG2/App/Application/SettingsWorkflow/SettingsWorkflow+LANListening.swift b/ShadowsocksX-NG2/App/Application/SettingsWorkflow/SettingsWorkflow+LANListening.swift index 051eef54..ced4e8b7 100644 --- a/ShadowsocksX-NG2/App/Application/SettingsWorkflow/SettingsWorkflow+LANListening.swift +++ b/ShadowsocksX-NG2/App/Application/SettingsWorkflow/SettingsWorkflow+LANListening.swift @@ -1,20 +1,15 @@ import Foundation extension SettingsWorkflow { - var committedLANListening: LANListeningSettings { committing.committedSettings.listen.lan } + var committedLANListening: LANListeningFacts { + LANListeningFacts(committing.committedSettings.listen.lan) + } - func beginLANListeningEditing() throws -> LANListeningDraft { - let lan = committedLANListening - let password: String - if let raw = lan.passwordReference { - guard let saved = try credentials.secret(for: CredentialReference(rawValue: raw)) else { - throw ProxyListeningStoreError.credentialFailure(detail: String(localized: "局域网身份验证密码不可读")) - } - password = saved - } else { - let alphabet = Array("abcdefghijklmnopqrstuvwxyz0123456789") - password = String((0..<10).map { _ in alphabet[Int.random(in: alphabet.indices)] }) - } + func beginLANListeningEditing() -> LANListeningDraft { + let lan = committing.committedSettings.listen.lan + let alphabet = Array("abcdefghijklmnopqrstuvwxyz0123456789") + let password = + lan.password ?? String((0..<10).map { _ in alphabet[Int.random(in: alphabet.indices)] }) listenerOccupancy.reset() return LANListeningDraft( ports: SettingsPortDraft( @@ -49,65 +44,7 @@ extension SettingsWorkflow { proposed.lan.udpRelayEnabled = draft.udpRelayEnabled proposed.lan.authenticationEnabled = draft.authenticationEnabled proposed.lan.username = draft.username - return await saveLANCredentials( - proposed, password: draft.password, - oldReference: base.lan.passwordReference) - } - - private func saveLANCredentials( - _ settings: ProxyListeningConfiguration, password: String, - oldReference: String? - ) async -> SettingsCommandOutcome { - var proposed = settings - if let old = oldReference { - do { - if try credentials.secret(for: CredentialReference(rawValue: old)) == password { - return await persistListening(proposed, fieldGroup: .lan) - } - } catch { - return .commitFailed( - .store(.credentialFailure(detail: String(localized: "局域网身份验证密码不可读")))) - } - } - // 身份验证启用时,新写入 keychain 的秘密必须可直接用于运行时认证文件。 - if proposed.lan.authenticationEnabled, - !RuntimeListenerAuthentication.validPassword(password) - || !RuntimeListenerAuthentication.validUsername(proposed.lan.username) - { - return .commitFailed( - .store(.credentialFailure(detail: String(localized: "用户名或密码无效")))) - } - let reference = CredentialReference.fresh() - var journal = CredentialWriteJournal(credentials: credentials) - do { - try journal.save(password, for: reference) - proposed.lan.passwordReference = reference.rawValue - let outcome = await persistListening(proposed, fieldGroup: .lan) - if case .persisted = outcome { - if let old = oldReference { - try? credentials.delete(CredentialReference(rawValue: old)) - } - return outcome - } - if case .partial = journal.rollback() { - return .commitFailed(.store(.credentialFailure(detail: String(localized: "凭据回滚失败")))) - } - return outcome - } catch { - let rollback = journal.rollback() - return .commitFailed( - .store( - .credentialFailure( - detail: rollback.isPartial ? String(localized: "凭据回滚失败") : String(localized: "凭据保存失败"))) - ) - } - } - -} - -extension CredentialRollbackOutcome { - fileprivate var isPartial: Bool { - if case .partial = self { return true } - return false + proposed.lan.password = draft.password + return await persistListening(proposed, fieldGroup: .lan) } } diff --git a/ShadowsocksX-NG2/App/Application/SettingsWorkflow/SettingsWorkflow.swift b/ShadowsocksX-NG2/App/Application/SettingsWorkflow/SettingsWorkflow.swift index 204baa05..3503c573 100644 --- a/ShadowsocksX-NG2/App/Application/SettingsWorkflow/SettingsWorkflow.swift +++ b/ShadowsocksX-NG2/App/Application/SettingsWorkflow/SettingsWorkflow.swift @@ -20,17 +20,14 @@ final class SettingsWorkflow: ObservableObject { var connectivityURLDidChange: ((URL) -> Void)? let committing: SettingsCommitting - let credentials: CredentialStoring let connectivityDefaults: UserDefaults init( committing: SettingsCommitting, occupancyProbe: PortOccupancyProbing = SystemPortOccupancyProbe(), - credentials: CredentialStoring = KeychainCredentialStore(), connectivityDefaults: UserDefaults = .standard ) { self.committing = committing - self.credentials = credentials self.connectivityDefaults = connectivityDefaults listenerOccupancy = ListenerOccupancy(committing: committing, probe: occupancyProbe) occupancyChanges = listenerOccupancy.objectWillChange.sink { [weak self] in diff --git a/ShadowsocksX-NG2/App/Composition/MainApp.swift b/ShadowsocksX-NG2/App/Composition/MainApp.swift index 75de53ca..cf976b11 100644 --- a/ShadowsocksX-NG2/App/Composition/MainApp.swift +++ b/ShadowsocksX-NG2/App/Composition/MainApp.swift @@ -196,7 +196,7 @@ private struct AppComposition { // 设置工作流 module(Candidate 02):设置窗口的唯一 seam,组合根接线一次; // 写入侧经窄缝 SettingsCommitting,运行时控制器薄扩展即生产实现。 let settingsWorkflow = SettingsWorkflow( - committing: controller, credentials: controller.credentials) + committing: controller) let testRuntime = ConnectivityRuntime( executable: Bundle.main.bundleURL.appendingPathComponent("Contents/Helpers/sslocal")) let connectivity = ServerConnectivity( @@ -275,7 +275,6 @@ private struct AppComposition { catalogSnapshotReader: bootstrap.catalogSnapshotReader, activationFileStore: dependencies.activationFileStore, runtimeFileStore: dependencies.runtimeFileStore, - credentials: dependencies.credentials, plugins: plugins, refreshPluginSecurityFacts: { [weak plugins] in _ = plugins?.refreshSecurityFacts() }, listeningStore: dependencies.listeningStore, @@ -313,7 +312,6 @@ private struct AppComposition { /// 装配永远面向真实存储、系统服务与用户默认域。 @MainActor private struct ApplicationDependencies { - let credentials: CredentialStoring let catalogFileStore: CatalogFileStore let activationFileStore: ActivationStateFileStore let runtimeFileStore: RuntimeFileStore @@ -330,22 +328,21 @@ private struct ApplicationDependencies { let qrImageSaver: any QrImageSaver static func make() -> ApplicationDependencies { + let listeningStore = ProxyListeningFileStore() + let listening = ProxyListeningStartup.load( + store: listeningStore, presentReset: ProxyListeningResetAlert.present) // ADR 0017 初版文件形态的迁移(发布链从未包含,仅存量开发机):文件不存在 // 即空操作,随每次启动调用无害。 SilentLaunchStore.migrateLegacyFileIfPresent( at: SilentLaunchStore.legacyFileURL, into: .standard) - let credentials = KeychainCredentialStore() - let library = ServerLibraryStorage(keys: KeychainLibraryMasterKeyStore()) + let library = ServerLibraryStorage(keys: KeychainMasterKeyStore(namespace: "server-library")) let catalogFileStore = CatalogFileStore( fileURL: CatalogFileStore.defaultFileURL(), library: library) - let listeningStore = ProxyListeningFileStore() - let listening = ProxyListeningFileStore.restored(store: listeningStore) let restoredSettings = RestoredProxyRuntimeSettings( settings: ProxyRuntimeSettings( - listen: listening.configuration, preferences: ProxyControlPreferences()), - unreadableError: listening.unreadableError) + listen: listening, preferences: ProxyControlPreferences()), + unreadableError: nil) return ApplicationDependencies( - credentials: credentials, catalogFileStore: catalogFileStore, activationFileStore: ActivationStateFileStore( fileURL: ActivationStateFileStore.defaultFileURL()), diff --git a/ShadowsocksX-NG2/App/PlatformEffects/ProxyListeningResetAlert.swift b/ShadowsocksX-NG2/App/PlatformEffects/ProxyListeningResetAlert.swift new file mode 100644 index 00000000..67cfa9a6 --- /dev/null +++ b/ShadowsocksX-NG2/App/PlatformEffects/ProxyListeningResetAlert.swift @@ -0,0 +1,18 @@ +import AppKit + +/// 启动门禁尚无工作区窗口;使用应用级模态提示,静默启动也能完成确认。 +@MainActor +enum ProxyListeningResetAlert { + static func present(_ error: ProxyListeningStoreError) { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = String(localized: "无法读取监听配置") + alert.informativeText = + AppPresentation.message(for: error) + "\n\n" + + String( + localized: "重置会丢弃原有监听配置,包括端口和局域网认证设置,并关闭局域网监听。服务器库和其他偏好设置保持不变。重置成功后应用才能继续启动。") + alert.addButton(withTitle: String(localized: "重置监听配置")) + NSApplication.shared.activate() + alert.runModal() + } +} diff --git a/ShadowsocksX-NG2/App/Presentation/ErrorPresentation+Runtime.swift b/ShadowsocksX-NG2/App/Presentation/ErrorPresentation+Runtime.swift index 7ef3955e..f59f101d 100644 --- a/ShadowsocksX-NG2/App/Presentation/ErrorPresentation+Runtime.swift +++ b/ShadowsocksX-NG2/App/Presentation/ErrorPresentation+Runtime.swift @@ -45,7 +45,7 @@ extension AppPresentation { localized: "本地代理 \(endpoint.uppercased()) 端点 \(host):\(port.formatted(.number.grouping(.never))) 未就绪(\(endpointFailure(cause)))" ) - case .unreadableSettings: return String(localized: "本地代理配置无法读取,已停止代理以避免静默改用出厂端口") + case .unreadableSettings: return String(localized: "本地代理配置无法读取,未应用新的代理配置") } } diff --git a/ShadowsocksX-NG2/App/Presentation/ErrorPresentation.swift b/ShadowsocksX-NG2/App/Presentation/ErrorPresentation.swift index 56c7459a..09b32b07 100644 --- a/ShadowsocksX-NG2/App/Presentation/ErrorPresentation.swift +++ b/ShadowsocksX-NG2/App/Presentation/ErrorPresentation.swift @@ -26,7 +26,7 @@ extension AppPresentation { return serverForm(error) case let error as SsUriError: return ssURI(error) - case let error as CredentialStoreError: + case let error as MasterKeyStoreError: return credential(error) case let error as SubscriptionFormError: return subscriptionForm(error) @@ -191,10 +191,9 @@ extension AppPresentation { } } - private static func credential(_ error: CredentialStoreError) -> String { + private static func credential(_ error: MasterKeyStoreError) -> String { switch error { case .keychainStatus: return String(localized: "钥匙串操作失败") - case .secretNotUTF8: return String(localized: "钥匙串中的凭据不是文本") } } @@ -265,6 +264,7 @@ extension AppPresentation { case .invalid(let errors): return errors.map(port).joined(separator: ";") case .ioFailure: return String(localized: "偏好文件读写失败") case .credentialFailure(let detail): return String(localized: "凭据操作失败:\(detail)") + case .encryptedFile: return String(localized: "监听配置无法解密或格式不受支持") } } diff --git a/ShadowsocksX-NG2/App/Resources/Localizable.xcstrings b/ShadowsocksX-NG2/App/Resources/Localizable.xcstrings index 6537b463..fd3836fe 100644 --- a/ShadowsocksX-NG2/App/Resources/Localizable.xcstrings +++ b/ShadowsocksX-NG2/App/Resources/Localizable.xcstrings @@ -8920,6 +8920,111 @@ } } } + }, + "无法读取监听配置": { + "extractionState": "manual", + "comment": "Startup alert title when proxy listening configuration cannot be loaded.", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法读取监听配置" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Unable to Read Listening Configuration" + } + } + } + }, + "重置监听配置": { + "extractionState": "manual", + "comment": "The only startup recovery alert action. Confirms discarding proxy listening settings.", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重置监听配置" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset Listening Configuration" + } + } + } + }, + "重置会丢弃原有监听配置,包括端口和局域网认证设置,并关闭局域网监听。服务器库和其他偏好设置保持不变。重置成功后应用才能继续启动。": { + "extractionState": "manual", + "comment": "Explains the scope and consequences before the user confirms resetting listening configuration.", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重置会丢弃原有监听配置,包括端口和局域网认证设置,并关闭局域网监听。服务器库和其他偏好设置保持不变。重置成功后应用才能继续启动。" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Resetting discards the previous listening configuration, including ports and LAN authentication settings, and disables LAN listening. The server library and other preferences remain unchanged. The app can start only after the reset succeeds." + } + } + } + }, + "无法访问监听配置主密钥": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法访问监听配置主密钥" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Unable to access the listening configuration master key." + } + } + } + }, + "监听配置无法解密或格式不受支持": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "监听配置无法解密或格式不受支持" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The listening configuration cannot be decrypted or its format is unsupported." + } + } + } + }, + "本地代理配置无法读取,未应用新的代理配置": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The local proxy configuration cannot be read. No new proxy configuration was applied." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "本地代理配置无法读取,未应用新的代理配置" + } + } + } } }, "version": "1.0" diff --git a/ShadowsocksX-NG2/App/UI/Settings/SettingsView.swift b/ShadowsocksX-NG2/App/UI/Settings/SettingsView.swift index 5c0e2b68..6914916d 100644 --- a/ShadowsocksX-NG2/App/UI/Settings/SettingsView.swift +++ b/ShadowsocksX-NG2/App/UI/Settings/SettingsView.swift @@ -8,7 +8,6 @@ struct SettingsView: View { @ObservedObject var workflow: SettingsWorkflow @ObservedObject var loginController: LaunchAtLoginController @ObservedObject var silentLaunch: SilentLaunchController - @State private var listenerEditingError: String? @State private var presentedEditor: SettingsEditorSheet? var body: some View { @@ -88,14 +87,8 @@ struct SettingsView: View { portSettingsRow settingRow(String(localized: "局域网监听")) { Button { - do { - presentedEditor = .lan( - LANListeningEditorSession( - initialDraft: try workflow.beginLANListeningEditing())) - listenerEditingError = nil - } catch { - listenerEditingError = String(localized: "局域网身份验证密码不可读,请重试。") - } + presentedEditor = .lan( + LANListeningEditorSession(initialDraft: workflow.beginLANListeningEditing())) } label: { HStack(spacing: 8) { VStack(alignment: .trailing, spacing: 3) { @@ -115,9 +108,6 @@ struct SettingsView: View { .buttonStyle(.bordered) .accessibilityLabel("局域网监听") } - if let listenerEditingError { - Text(listenerEditingError).font(.caption).foregroundStyle(.red) - } } header: { sectionHeader(String(localized: "后台代理客户端")) } diff --git a/ShadowsocksX-NG2/Domain/Catalog/NodeIdentity.swift b/ShadowsocksX-NG2/Domain/Catalog/NodeIdentity.swift index 4e1be505..96add03a 100644 --- a/ShadowsocksX-NG2/Domain/Catalog/NodeIdentity.swift +++ b/ShadowsocksX-NG2/Domain/Catalog/NodeIdentity.swift @@ -26,23 +26,3 @@ struct NodeID: Hashable, Codable, Sendable { try container.encode(rawValue) } } - -/// 凭据引用:与秘密值之间的非秘密关联(GLOSSARY.md「Credential reference」)。 -/// 配置树只存引用;秘密值经 CredentialStoring 读写。 -struct CredentialReference: Hashable, Codable, Sendable { - let rawValue: String - - init(rawValue: String) { self.rawValue = rawValue } - - /// 新建凭据引用。 - static func fresh() -> CredentialReference { CredentialReference(rawValue: UUID().uuidString) } - - init(from decoder: Decoder) throws { - rawValue = try decoder.singleValueContainer().decode(String.self) - } - - func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - try container.encode(rawValue) - } -} diff --git a/ShadowsocksX-NG2/Domain/Catalog/ServerLibraryStorage.swift b/ShadowsocksX-NG2/Domain/Catalog/ServerLibraryStorage.swift index cf9f09cf..d9da8990 100644 --- a/ShadowsocksX-NG2/Domain/Catalog/ServerLibraryStorage.swift +++ b/ShadowsocksX-NG2/Domain/Catalog/ServerLibraryStorage.swift @@ -1,117 +1,27 @@ -import CryptoKit import Foundation -enum ServerLibraryError: Error, Equatable { - case malformedFile - case unsupportedVersion - case authenticationFailed - case keyUnavailable - case notLoaded -} +typealias ServerLibraryError = EncryptedFileError -/// 服务器库整体加密存储;成功写盘前不替换当前主密钥。 +/// 服务器库专用载荷与密钥空间;封装格式保持不变。 final class ServerLibraryStorage { - private let keys: LibraryMasterKeyStoring - private var currentKey: (id: UUID, data: Data)? - private var loaded = false + private let encrypted: EncryptedFileStorage - // 固定头:8 字节 magic + 1 字节版本 + 36 字节 UUID。整段原始头字节参与认证。 - private static let magic = Data("SSXNG2LB".utf8) - private static let headerSize = 45 - private static let maximumSize = 64 * 1024 * 1024 - - init(keys: LibraryMasterKeyStoring = KeychainLibraryMasterKeyStore()) { - self.keys = keys + init(keys: MasterKeyStoring = KeychainMasterKeyStore(namespace: "server-library")) { + encrypted = EncryptedFileStorage( + keys: keys, magic: "SSXNG2LB", version: 2, maximumSize: 64 * 1024 * 1024, + legacyFileName: "catalog.json", makeDefault: { CatalogDocument() }, + encode: CatalogFileStore.encode, decode: CatalogFileStore.decode) } func load(at url: URL, allowingInteraction: Bool) throws -> CatalogDocument { - if loaded, !FileManager.default.fileExists(atPath: url.path) { - throw ServerLibraryError.notLoaded - } - if FileManager.default.fileExists(atPath: url.path) { - let (document, key) = try read(at: url, allowingInteraction: allowingInteraction) - currentKey = key - loaded = true - return document - } - let legacyURL = url.deletingLastPathComponent().appendingPathComponent("catalog.json") - guard !FileManager.default.fileExists(atPath: legacyURL.path) else { - throw ServerLibraryError.unsupportedVersion - } - let document = CatalogDocument() - let key = try createKey(allowingInteraction: allowingInteraction) - try write(document, key: key, to: url) - currentKey = key - loaded = true - return document + try encrypted.load(at: url, allowingInteraction: allowingInteraction) } func save(_ document: CatalogDocument, at url: URL) throws { - guard loaded, let key = currentKey else { throw ServerLibraryError.notLoaded } - try write(document, key: key, to: url) + try encrypted.save(document, at: url) } - /// 调用方先取得用户的破坏性操作确认;成功写盘前不发布新状态。 func reset(at url: URL) throws -> CatalogDocument { - let key = try createKey(allowingInteraction: true) - let document = CatalogDocument() - try write(document, key: key, to: url) - currentKey = key - loaded = true - return document - } - - private func createKey(allowingInteraction: Bool) throws -> (id: UUID, data: Data) { - let id = UUID() - let data = SymmetricKey(size: .bits256).withUnsafeBytes { Data($0) } - try keys.create(data, id: id, allowingInteraction: allowingInteraction) - guard try keys.read(id, allowingInteraction: allowingInteraction) == data else { - throw ServerLibraryError.keyUnavailable - } - return (id, data) - } - - private func write( - _ document: CatalogDocument, - key: (id: UUID, data: Data), to url: URL - ) throws { - let payload = try CatalogFileStore.encode(document) - guard payload.count <= Self.maximumSize else { throw ServerLibraryError.malformedFile } - let header = Self.magic + Data([2]) + Data(key.id.uuidString.utf8) - let sealed = try AES.GCM.seal( - payload, using: SymmetricKey(data: key.data), authenticating: header) - guard let combined = sealed.combined else { throw ServerLibraryError.malformedFile } - try AtomicFileWriter.write(header + combined, to: url) - } - - private func read( - at url: URL, allowingInteraction: Bool - ) throws -> (CatalogDocument, (id: UUID, data: Data)) { - let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0 - guard size >= Self.headerSize + 28, size <= Self.maximumSize + Self.headerSize + 28 else { - throw ServerLibraryError.malformedFile - } - let data = try Data(contentsOf: url) - guard data.starts(with: Self.magic) else { throw ServerLibraryError.malformedFile } - guard data[8] == 2 else { throw ServerLibraryError.unsupportedVersion } - guard let idText = String(data: data[9.. String? - /// 删除不存在的条目不视为错误(幂等)。 - func delete(_ reference: CredentialReference) throws -} - -enum CredentialStoreError: Error, Equatable { - /// Keychain 返回预期之外的状态码。 - case keychainStatus(OSStatus) - /// 存储的秘密值不是合法 UTF-8 文本。 - case secretNotUTF8 -} - -/// 生产实现:登录钥匙串 Generic Password。`service` 隔离本应用命名空间, -/// `account` 即凭据引用;可访问性取「首次解锁后」,兼顾代理运行时读取。 -struct KeychainCredentialStore: CredentialStoring { - let service: String - - init(service: String = "com.qiuyuzhou.ShadowsocksX-NG2.credentials") { - self.service = service - } - - func save(_ secret: String, for reference: CredentialReference) throws { - let data = Data(secret.utf8) - let query = baseQuery(for: reference) - let update: [String: Any] = [ - kSecValueData as String: data, - kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, - ] - var status = SecItemUpdate(query as CFDictionary, update as CFDictionary) - if status == errSecItemNotFound { - var addItem = query - addItem[kSecValueData as String] = data - addItem[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock - status = SecItemAdd(addItem as CFDictionary, nil) - } - guard status == errSecSuccess else { throw CredentialStoreError.keychainStatus(status) } - } - - func secret(for reference: CredentialReference) throws -> String? { - var query = baseQuery(for: reference) - query[kSecReturnData as String] = kCFBooleanTrue as Any - query[kSecMatchLimit as String] = kSecMatchLimitOne - var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) - if status == errSecItemNotFound { return nil } - guard status == errSecSuccess, let data = result as? Data else { - throw CredentialStoreError.keychainStatus(status) - } - guard let secret = String(data: data, encoding: .utf8) else { - throw CredentialStoreError.secretNotUTF8 - } - return secret - } - - func delete(_ reference: CredentialReference) throws { - let status = SecItemDelete(baseQuery(for: reference) as CFDictionary) - guard status == errSecSuccess || status == errSecItemNotFound else { - throw CredentialStoreError.keychainStatus(status) - } - } - - private func baseQuery(for reference: CredentialReference) -> [String: Any] { - [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: reference.rawValue, - ] - } -} diff --git a/ShadowsocksX-NG2/Domain/Credentials/CredentialWriteJournal.swift b/ShadowsocksX-NG2/Domain/Credentials/CredentialWriteJournal.swift deleted file mode 100644 index 3b00e226..00000000 --- a/ShadowsocksX-NG2/Domain/Credentials/CredentialWriteJournal.swift +++ /dev/null @@ -1,84 +0,0 @@ -import Foundation - -/// 凭据回滚的结构化结果:回滚是尽力恢复,存储失败不二次上抛,残局经此 -/// 显式报出。纯数据、不含用户可见文案(文案归 presentation edge)。 -enum CredentialRollbackOutcome: Equatable, Sendable { - /// 本次未触碰任何凭据引用,无需恢复。 - case nothingToRestore - /// 全部触碰过的引用恢复成功(集合非空)。 - case restored(Set) - /// 尽力恢复后的残局:旧值继续生效的不变量被存储失败违例,未能恢复的 - /// 引用点名在 `failed`。 - case partial(restored: Set, failed: Set) -} - -/// 凭据写入日志:目录文件只有在全部凭据写入成功后才替换;如果任何凭据或 -/// 目录写入失败,则恢复改动前的每个引用值,避免旧秘密被覆盖或丢失。 -/// -/// 回滚是 best-effort:单个引用恢复失败不中断其余引用,残局经 -/// `CredentialRollbackOutcome` 报出。写入期 `delete` 同样尽力而为——删除 -/// 失败留下的孤儿秘密不属本 module 的回滚语义(known limitation)。 -struct CredentialWriteJournal { - private struct OriginalValue { - let secret: String? - } - - let credentials: CredentialStoring - private var originals: [CredentialReference: OriginalValue] = [:] - - init(credentials: CredentialStoring) { - self.credentials = credentials - } - - mutating func save(_ secret: String, for reference: CredentialReference) throws { - if originals[reference] == nil { - originals[reference] = OriginalValue(secret: try credentials.secret(for: reference)) - } - try credentials.save(secret, for: reference) - } - - /// 尽力删除(幂等);回滚时恢复改动前的原值或「不存在」。 - mutating func delete(_ reference: CredentialReference) { - try? deleteOrThrow(reference) - } - - /// 事务调用方需要知道删除是否成功;保留 `delete` 的既有尽力而为语义 - /// 给目录工作流使用。 - mutating func deleteOrThrow(_ reference: CredentialReference) throws { - if originals[reference] == nil { - originals[reference] = OriginalValue(secret: try credentials.secret(for: reference)) - } - try credentials.delete(reference) - } - - /// 恢复全部触碰过的引用到改动前的值(尽力),返回逐引用结果。 - func rollback() -> CredentialRollbackOutcome { - guard !originals.isEmpty else { return .nothingToRestore } - var restored: Set = [] - var failed: Set = [] - for (reference, original) in originals { - let didRestore: Bool - if let secret = original.secret { - do { - try credentials.save(secret, for: reference) - didRestore = true - } catch { - didRestore = false - } - } else { - do { - try credentials.delete(reference) - didRestore = true - } catch { - didRestore = false - } - } - if didRestore { - restored.insert(reference) - } else { - failed.insert(reference) - } - } - return failed.isEmpty ? .restored(restored) : .partial(restored: restored, failed: failed) - } -} diff --git a/ShadowsocksX-NG2/Domain/Credentials/EncryptedFileStorage.swift b/ShadowsocksX-NG2/Domain/Credentials/EncryptedFileStorage.swift new file mode 100644 index 00000000..5521a48a --- /dev/null +++ b/ShadowsocksX-NG2/Domain/Credentials/EncryptedFileStorage.swift @@ -0,0 +1,135 @@ +import CryptoKit +import Foundation + +enum EncryptedFileError: Error, Equatable { + case malformedFile + case unsupportedVersion + case authenticationFailed + case keyUnavailable + case notLoaded +} + +/// 加密文件的密钥、封装与原子提交;每个实例独立持有已加载密钥。 +final class EncryptedFileStorage { + private let keys: MasterKeyStoring + private var currentKey: (id: UUID, data: Data)? + private var loaded = false + + // 固定头:8 字节 magic + 1 字节版本 + 36 字节 UUID。整段原始头字节参与认证。 + private let magic: Data + private let version: UInt8 + private let maximumSize: Int + private let legacyFileName: String + private let makeDefault: () -> Value + private let encode: (Value) throws -> Data + private let decode: (Data) throws -> Value + private var headerSize: Int { 45 } + var isLoaded: Bool { loaded } + + init( + keys: MasterKeyStoring, magic: String, version: UInt8, maximumSize: Int, + legacyFileName: String, makeDefault: @escaping () -> Value, + encode: @escaping (Value) throws -> Data, decode: @escaping (Data) throws -> Value + ) { + precondition(magic.utf8.count == 8) + self.keys = keys + self.magic = Data(magic.utf8) + self.version = version + self.maximumSize = maximumSize + self.legacyFileName = legacyFileName + self.makeDefault = makeDefault + self.encode = encode + self.decode = decode + } + + func load(at url: URL, allowingInteraction: Bool) throws -> Value { + if loaded, !FileManager.default.fileExists(atPath: url.path) { + throw EncryptedFileError.notLoaded + } + if FileManager.default.fileExists(atPath: url.path) { + let (document, key) = try read(at: url, allowingInteraction: allowingInteraction) + currentKey = key + loaded = true + return document + } + let legacyURL = url.deletingLastPathComponent().appendingPathComponent(legacyFileName) + guard !FileManager.default.fileExists(atPath: legacyURL.path) else { + throw EncryptedFileError.unsupportedVersion + } + let document = makeDefault() + let key = try createKey(allowingInteraction: allowingInteraction) + try write(document, key: key, to: url) + currentKey = key + loaded = true + return document + } + + func save(_ document: Value, at url: URL) throws { + guard loaded, let key = currentKey else { throw EncryptedFileError.notLoaded } + try write(document, key: key, to: url) + } + + /// 调用方先取得用户的破坏性操作确认;成功写盘前不发布新状态。 + func reset(at url: URL) throws -> Value { + let key = try createKey(allowingInteraction: true) + let document = makeDefault() + try write(document, key: key, to: url) + currentKey = key + loaded = true + return document + } + + private func createKey(allowingInteraction: Bool) throws -> (id: UUID, data: Data) { + let id = UUID() + let data = SymmetricKey(size: .bits256).withUnsafeBytes { Data($0) } + try keys.create(data, id: id, allowingInteraction: allowingInteraction) + guard try keys.read(id, allowingInteraction: allowingInteraction) == data else { + throw EncryptedFileError.keyUnavailable + } + return (id, data) + } + + private func write( + _ document: Value, + key: (id: UUID, data: Data), to url: URL + ) throws { + let payload = try encode(document) + guard payload.count <= maximumSize else { throw EncryptedFileError.malformedFile } + let header = magic + Data([version]) + Data(key.id.uuidString.utf8) + let sealed = try AES.GCM.seal( + payload, using: SymmetricKey(data: key.data), authenticating: header) + guard let combined = sealed.combined else { throw EncryptedFileError.malformedFile } + try AtomicFileWriter.write(header + combined, to: url) + } + + private func read( + at url: URL, allowingInteraction: Bool + ) throws -> (Value, (id: UUID, data: Data)) { + let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0 + guard size >= headerSize + 28, size <= maximumSize + headerSize + 28 else { + throw EncryptedFileError.malformedFile + } + let data = try Data(contentsOf: url) + guard data.starts(with: magic) else { throw EncryptedFileError.malformedFile } + guard data[8] == version else { throw EncryptedFileError.unsupportedVersion } + guard let idText = String(data: data[9.. Data? func create(_ data: Data, id: UUID, allowingInteraction: Bool) throws } -struct KeychainLibraryMasterKeyStore: LibraryMasterKeyStoring { +struct KeychainMasterKeyStore: MasterKeyStoring { let service: String - init(service: String = "com.qiuyuzhou.ShadowsocksX-NG2.server-library") { - self.service = service + init(namespace: String) { + self.service = "com.qiuyuzhou.ShadowsocksX-NG2.\(namespace)" } func read(_ id: UUID, allowingInteraction: Bool) throws -> Data? { @@ -23,7 +27,7 @@ struct KeychainLibraryMasterKeyStore: LibraryMasterKeyStoring { let status = SecItemCopyMatching(query as CFDictionary, &result) if status == errSecItemNotFound { return nil } guard status == errSecSuccess, let data = result as? Data else { - throw CredentialStoreError.keychainStatus(status) + throw MasterKeyStoreError.keychainStatus(status) } return data } @@ -33,13 +37,13 @@ struct KeychainLibraryMasterKeyStore: LibraryMasterKeyStoring { try withClassicKeychainInteraction(allowingInteraction) { var application: SecTrustedApplication? try check(SecTrustedApplicationCreateFromPath(nil, &application)) - guard let application else { throw CredentialStoreError.keychainStatus(errSecParam) } + guard let application else { throw MasterKeyStoreError.keychainStatus(errSecParam) } var access: SecAccess? try check( SecAccessCreate( - "ShadowsocksX-NG2 server library" as CFString, + "ShadowsocksX-NG2 encrypted configuration" as CFString, [application] as CFArray, &access)) - guard let access else { throw CredentialStoreError.keychainStatus(errSecParam) } + guard let access else { throw MasterKeyStoreError.keychainStatus(errSecParam) } var item = query(id) item[kSecValueData as String] = data item[kSecAttrAccess as String] = access @@ -58,7 +62,7 @@ struct KeychainLibraryMasterKeyStore: LibraryMasterKeyStoring { } private func check(_ status: OSStatus) throws { - guard status == errSecSuccess else { throw CredentialStoreError.keychainStatus(status) } + guard status == errSecSuccess else { throw MasterKeyStoreError.keychainStatus(status) } } } @@ -68,9 +72,9 @@ private func withClassicKeychainInteraction(_ allowed: Bool, _ operation: () { var previous: DarwinBoolean = false let getStatus = SecKeychainGetUserInteractionAllowed(&previous) - guard getStatus == errSecSuccess else { throw CredentialStoreError.keychainStatus(getStatus) } + guard getStatus == errSecSuccess else { throw MasterKeyStoreError.keychainStatus(getStatus) } let setStatus = SecKeychainSetUserInteractionAllowed(allowed) - guard setStatus == errSecSuccess else { throw CredentialStoreError.keychainStatus(setStatus) } + guard setStatus == errSecSuccess else { throw MasterKeyStoreError.keychainStatus(setStatus) } defer { SecKeychainSetUserInteractionAllowed(previous.boolValue) } return try operation() } diff --git a/ShadowsocksX-NG2/Domain/Diagnostics/DiagnosticReport.swift b/ShadowsocksX-NG2/Domain/Diagnostics/DiagnosticReport.swift index 38eefadd..415f71c7 100644 --- a/ShadowsocksX-NG2/Domain/Diagnostics/DiagnosticReport.swift +++ b/ShadowsocksX-NG2/Domain/Diagnostics/DiagnosticReport.swift @@ -98,7 +98,7 @@ struct DiagnosticSnapshot: Sendable { var systemSummary: String? var proxyState = DiagnosticProxyState.off var hasActiveTarget = false - var listen: ProxyListeningConfiguration? + var listen: RuntimeListenFacts? /// `Redactor.documentSummary` 产出的契约脱敏摘要(数量与模式)。 var runtimeDocumentSummary: String? /// 自定义规则安全摘要:数量 + 内容版本,无原始域名。 @@ -266,7 +266,7 @@ enum DiagnosticReportBuilder { } /// 本地端点只以端口事实呈现。 - private static func listenDescription(_ listen: ProxyListeningConfiguration) -> String { + private static func listenDescription(_ listen: RuntimeListenFacts) -> String { var parts = ["SOCKS5 端口 \(listen.socksPort)"] parts.append("HTTP 端口 \(listen.httpPort)") return parts.joined(separator: ";") diff --git a/ShadowsocksX-NG2/Domain/Runtime/ProxyListeningConfiguration.swift b/ShadowsocksX-NG2/Domain/Runtime/ProxyListeningConfiguration.swift index a68967e2..bdbbb0d0 100644 --- a/ShadowsocksX-NG2/Domain/Runtime/ProxyListeningConfiguration.swift +++ b/ShadowsocksX-NG2/Domain/Runtime/ProxyListeningConfiguration.swift @@ -11,7 +11,7 @@ struct LocalListeningSettings: Codable, Equatable, Sendable { var httpPort = 11087 } -/// 网络共享入口与本机入口共存,凭据只携带 Keychain 引用。 +/// 网络共享入口与本机入口共存,凭据随配置整体加密保存。 struct LANListeningSettings: Codable, Equatable, Sendable { var isEnabled = false var mode: LANListenerMode = .allIPv4Interfaces @@ -20,11 +20,48 @@ struct LANListeningSettings: Codable, Equatable, Sendable { var udpRelayEnabled = false var authenticationEnabled = false var username = "user" - var passwordReference: String? + var password: String? var effectiveMode: ListenerMode { isEnabled ? mode.bindingMode : .disabled } } +/// 不含认证密码的监听事实,供运行元数据、占用检查和界面投影使用。 +struct LANListeningFacts: Codable, Equatable, Sendable { + var isEnabled = false + var mode: LANListenerMode = .allIPv4Interfaces + var socksPort = 11088 + var httpPort = 11089 + var udpRelayEnabled = false + var authenticationEnabled = false + var username = "user" + + init() {} + + init(_ settings: LANListeningSettings) { + isEnabled = settings.isEnabled + mode = settings.mode + socksPort = settings.socksPort + httpPort = settings.httpPort + udpRelayEnabled = settings.udpRelayEnabled + authenticationEnabled = settings.authenticationEnabled + username = settings.username + } + + var effectiveMode: ListenerMode { isEnabled ? mode.bindingMode : .disabled } + + var configuration: LANListeningSettings { + var settings = LANListeningSettings() + settings.isEnabled = isEnabled + settings.mode = mode + settings.socksPort = socksPort + settings.httpPort = httpPort + settings.udpRelayEnabled = udpRelayEnabled + settings.authenticationEnabled = authenticationEnabled + settings.username = username + return settings + } +} + /// 局域网地址族配置;关闭时仍保留所选方式。 enum LANListenerMode: String, Codable, CaseIterable, Equatable, Sendable { case allIPv4Interfaces = "all_ipv4_interfaces" @@ -44,7 +81,7 @@ enum LANListenerMode: String, Codable, CaseIterable, Equatable, Sendable { var displayName: String { bindingMode.displayName } } -struct ProxyListeningConfiguration: Equatable, Sendable { +struct ProxyListeningConfiguration: Codable, Equatable, Sendable { static let defaultSocksPort = 11086 static let defaultHTTPPort = 11087 @@ -107,14 +144,14 @@ struct RuntimeListenFacts: Equatable, Sendable { let listenerMode: ListenerMode let socksPort: Int let httpPort: Int - var lan = LANListeningSettings() + var lan = LANListeningFacts() init(listen: ProxyListeningConfiguration) { self.init( listenerMode: .disabled, socksPort: listen.socksPort, httpPort: listen.httpPort) - lan = listen.lan + lan = LANListeningFacts(listen.lan) } init( diff --git a/ShadowsocksX-NG2/Domain/Runtime/RuntimePaths.swift b/ShadowsocksX-NG2/Domain/Runtime/RuntimePaths.swift index 6c6205c3..43115b05 100644 --- a/ShadowsocksX-NG2/Domain/Runtime/RuntimePaths.swift +++ b/ShadowsocksX-NG2/Domain/Runtime/RuntimePaths.swift @@ -52,7 +52,7 @@ enum RuntimePaths { /// 本地与局域网代理监听配置(ProxyListeningFileStore)。 static func proxyListeningFileURL() -> URL { - runtimeDirectory().appendingPathComponent("proxy-listening.json") + runtimeDirectory().appendingPathComponent("proxy-listening.enc") } /// 自定义规则文档(CustomRuleStore)。 diff --git a/ShadowsocksX-NG2/Domain/Runtime/SslocalRuntimeDocument.swift b/ShadowsocksX-NG2/Domain/Runtime/SslocalRuntimeDocument.swift index 48ed58f7..b061906e 100644 --- a/ShadowsocksX-NG2/Domain/Runtime/SslocalRuntimeDocument.swift +++ b/ShadowsocksX-NG2/Domain/Runtime/SslocalRuntimeDocument.swift @@ -72,7 +72,7 @@ struct SslocalLocalDocument: Codable, Equatable, Sendable { struct RuntimeListenDocument: Codable, Equatable, Sendable { let listenerMode: ListenerMode let bindAddress: String - var lan = LANListeningSettings() + var lan = LANListeningFacts() var authentication: RuntimeListenerAuthentication? enum CodingKeys: String, CodingKey { @@ -110,7 +110,7 @@ struct SslocalRuntimeDocument: Codable, Equatable, Sendable { locals = listen.locals self.listen = RuntimeListenDocument( listenerMode: listen.listenerMode, - bindAddress: listen.bindAddress, lan: listen.lan) + bindAddress: listen.bindAddress, lan: LANListeningFacts(listen.lan)) ipv6Only = listen.listenerMode.ipv6Only aclFilePath = acl?.path aclRuntime = acl @@ -255,7 +255,7 @@ extension SslocalRuntimeDocument { var expected = ProxyListeningConfiguration() expected.local.socksPort = socksPort expected.local.httpPort = httpPort - expected.lan = listen.lan + expected.lan = listen.lan.configuration guard network.count == (listen.lan.isEnabled ? 2 : 0), expected.configuredPorts.allSatisfy({ ProxyPortRange.valid.contains($0) }), Set(expected.configuredPorts).count == 4 diff --git a/ShadowsocksX-NG2/Domain/Settings/ProxyListeningFileStore.swift b/ShadowsocksX-NG2/Domain/Settings/ProxyListeningFileStore.swift index 7c1874e1..852dd3f0 100644 --- a/ShadowsocksX-NG2/Domain/Settings/ProxyListeningFileStore.swift +++ b/ShadowsocksX-NG2/Domain/Settings/ProxyListeningFileStore.swift @@ -1,11 +1,11 @@ import Foundation extension ProxyListeningConfiguration { - /// 保存时联合校验四端口,身份验证开启时要求用户名与密码引用有效。 + /// 保存时联合校验四端口,身份验证开启时要求用户名与密码有效。 func enforcePersistenceConstraints() throws { if lan.authenticationEnabled && (!RuntimeListenerAuthentication.validUsername(lan.username) - || lan.passwordReference == nil) + || !RuntimeListenerAuthentication.validPassword(lan.password ?? "")) { throw ProxyListeningStoreError.credentialFailure(detail: String(localized: "局域网身份验证凭据无效")) } @@ -19,93 +19,67 @@ protocol ProxyListeningStoring { func save(_ configuration: ProxyListeningConfiguration) throws } -struct RestoredProxyListeningConfiguration { - let configuration: ProxyListeningConfiguration - let unreadableError: ProxyListeningStoreError? -} - enum ProxyListeningStoreError: Error, Equatable { case corrupt(detail: String) case invalid([PortSettingError]) case ioFailure(detail: String) case credentialFailure(detail: String) + case encryptedFile(EncryptedFileError) } -/// 只保存本地与局域网监听配置,不承载控制偏好或其他场景设置。 +/// 整体加密保存监听配置,不承载控制偏好或其他场景设置。 struct ProxyListeningFileStore: ProxyListeningStoring { let fileURL: URL + private let encrypted: EncryptedFileStorage - init(fileURL: URL = ProxyListeningFileStore.defaultFileURL()) { + init( + fileURL: URL = ProxyListeningFileStore.defaultFileURL(), + keys: MasterKeyStoring = KeychainMasterKeyStore(namespace: "proxy-listening") + ) { self.fileURL = fileURL + encrypted = EncryptedFileStorage( + keys: keys, magic: "SSXNG2LP", version: 1, maximumSize: 1024 * 1024, + legacyFileName: "proxy-listening.json", makeDefault: { ProxyListeningConfiguration() }, + encode: { configuration in + try configuration.enforcePersistenceConstraints() + return try JSONEncoder().encode(configuration) + }, + decode: { data in + let configuration: ProxyListeningConfiguration + do { + configuration = try JSONDecoder().decode(ProxyListeningConfiguration.self, from: data) + } catch { throw ProxyListeningStoreError.corrupt(detail: "invalid listening payload") } + try configuration.enforcePersistenceConstraints() + return configuration + }) } static func defaultFileURL() -> URL { RuntimePaths.proxyListeningFileURL() } func load() throws -> ProxyListeningConfiguration { - guard FileManager.default.fileExists(atPath: fileURL.path) else { - return ProxyListeningConfiguration() - } - let data: Data - do { data = try Data(contentsOf: fileURL) } catch { - throw ProxyListeningStoreError.ioFailure(detail: String(describing: error)) - } - let record: ProxyListeningRecord - do { record = try JSONDecoder().decode(ProxyListeningRecord.self, from: data) } catch { - throw ProxyListeningStoreError.corrupt(detail: String(describing: error)) - } - var configuration = ProxyListeningConfiguration() - configuration.local = record.localListen - configuration.lan = record.lanListen - try configuration.enforcePersistenceConstraints() - return configuration + try translatingErrors { try encrypted.load(at: fileURL, allowingInteraction: false) } } func save(_ configuration: ProxyListeningConfiguration) throws { try configuration.enforcePersistenceConstraints() - do { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let record = ProxyListeningRecord( - localListen: configuration.local, lanListen: configuration.lan) - try AtomicFileWriter.write(encoder.encode(record), to: fileURL) - } catch { - throw ProxyListeningStoreError.ioFailure(detail: String(describing: error)) + try translatingErrors { + if !encrypted.isLoaded { _ = try load() } + try encrypted.save(configuration, at: fileURL) } } - static func restored(store: ProxyListeningFileStore = ProxyListeningFileStore()) - -> RestoredProxyListeningConfiguration - { - do { - return RestoredProxyListeningConfiguration( - configuration: try store.load(), unreadableError: nil) - } catch { - guard let failure = error as? ProxyListeningStoreError else { - preconditionFailure("load() 只抛 ProxyListeningStoreError") - } - RuntimeLog.emit(.settingsUnreadable(detail: String(describing: failure))) - return RestoredProxyListeningConfiguration( - configuration: ProxyListeningConfiguration(), unreadableError: failure) - } - } -} - -private struct ProxyListeningRecord: Codable { - var localListen: LocalListeningSettings - var lanListen: LANListeningSettings - - init(localListen: LocalListeningSettings, lanListen: LANListeningSettings) { - self.localListen = localListen - self.lanListen = lanListen - } - - init(from decoder: Decoder) throws { - let values = try decoder.container(keyedBy: CodingKeys.self) - localListen = - try values.decodeIfPresent(LocalListeningSettings.self, forKey: .localListen) - ?? LocalListeningSettings() - lanListen = - try values.decodeIfPresent(LANListeningSettings.self, forKey: .lanListen) - ?? LANListeningSettings() + func reset() throws -> ProxyListeningConfiguration { + try translatingErrors { try encrypted.reset(at: fileURL) } } + + private func translatingErrors(_ operation: () throws -> T) throws -> T { + do { return try operation() } catch let error as ProxyListeningStoreError { + throw error + } catch let error as EncryptedFileError { + throw ProxyListeningStoreError.encryptedFile(error) + } catch is MasterKeyStoreError { + throw ProxyListeningStoreError.credentialFailure(detail: String(localized: "无法访问监听配置主密钥")) + } catch { throw ProxyListeningStoreError.ioFailure(detail: String(describing: error)) } + } + } diff --git a/ShadowsocksX-NG2/README.md b/ShadowsocksX-NG2/README.md index 50699bd5..e9563f92 100644 --- a/ShadowsocksX-NG2/README.md +++ b/ShadowsocksX-NG2/README.md @@ -29,7 +29,7 @@ ShadowsocksX-NG 的现代化重写版本。新工程的全部源码与构建配 - `Settings/` — 代理监听配置文件、独立代理控制偏好、运行计算的内存快照与静默启动偏好。 - `SystemProxy/` — 系统代理 typed 配置、property list 映射、计划器与特权 helper 的 XPC 契约/请求引擎。 - `Plugin/` — 托管插件、用户插件映射与插件安全校验。 - - `Credentials/`、`LegacyImport/`、`Diagnostics/` — 凭据存储与写入日志、旧版导入、诊断报告。 + - `Credentials/`、`LegacyImport/`、`Diagnostics/` — 主密钥存储与加密文件提交、旧版导入、诊断报告。 跨进程契约按**文件**编入其它 target:`Runtime/` 的 10 个文件同时编入 wrapper(`RuntimePaths`、`SslocalRuntimeDocument`、`RuntimeLog` 等),`SystemProxy/` 全部 6 个文件编入特权 helper。`project.yml` 里这 16 条是显式路径,在 `Domain/` 内移动文件必须同步,漏改会让 `xcodegen generate` 以缺文件直接失败。布局守卫(`Tests/Architecture/SkeletonTests.swift` 的 `DomainLayoutTests`)断言根下无平铺 `.swift`、树下只有 `.swift`(非源码文件会被 XcodeGen 收进 Resources 阶段并封进签名后的 bundle)。 - `Tests/` — 单元测试 target,随 `ShadowsocksX-NG2` scheme 运行。目录即主题,根下不留平铺测试文件: @@ -196,8 +196,8 @@ Python 是唯一的上游转换实现;Swift 只加载、校验并消费快照 后台代理客户端运行时,本地 SOCKS/HTTP 始终监听 `127.0.0.1`,默认端口为 `11086`/`11087`;本地 SOCKS 固定开启 UDP 中继。局域网入口默认关闭,使用独立端口 `11088`/`11089`,支持 IPv4、双栈和仅 IPv6 三种方式。四端口必须在 `1025...65535` 内且全部不同。 -局域网 SOCKS 的 UDP 中继和 SOCKS/HTTP 共用的身份验证独立设置。密码保存在 Keychain,运行时派生当前用户专用的认证文件;上游认证失败日志中的密码会隐藏。设置只检查 TCP 占用,不设置独立 UDP 端口。首页命令按选择地址使用本地或局域网端口,局域网身份验证开启时加入编码后的凭据;tooltip 显示完整命令。 +局域网 SOCKS 的 UDP 中继和 SOCKS/HTTP 共用的身份验证独立设置。用户名与密码随监听配置整体加密保存,Keychain 仅保存监听专用主密钥;运行时派生当前用户专用的认证文件;上游认证失败日志中的密码会隐藏。设置只检查 TCP 占用,不设置独立 UDP 端口。首页命令按选择地址使用本地或局域网端口,局域网身份验证开启时加入编码后的凭据;tooltip 显示完整命令。 -监听配置单独保存在 `proxy-listening.json`,只包含本地与局域网监听相关值。代理模式、规则默认动作、代理服务开关、系统代理开关和附加系统代理例外以 `proxyControl.` 前缀的独立键保存在 UserDefaults;运行计算的内存快照不代表统一持久化文档。监听仍采用联合校验与原子文件保存;UserDefaults 偏好不提供同步落盘失败的报错与回滚。 +监听配置单独加密保存在 `proxy-listening.enc`,只包含本地与局域网监听相关值。代理模式、规则默认动作、代理服务开关、系统代理开关和附加系统代理例外以 `proxyControl.` 前缀的独立键保存在 UserDefaults;运行计算的内存快照不代表统一持久化文档。监听仍采用联合校验与原子文件保存;UserDefaults 偏好不提供同步落盘失败的报错与回滚。 -不迁移旧 `settings.json`:新监听文件与新增偏好缺值时使用默认值,需要重新设置现有开发配置;旧文件不读取、不删除,遗留 Keychain 条目不访问、不清理。已有语言、静默启动、测试 URL 等独立偏好及服务器资料保持原状。极端监听文件损坏接受人工恢复,不建设应用内修复流程。设计见 [ADR-0034](../docs/adr/0034-separate-local-and-lan-listeners.md)、[ADR-0040](../docs/adr/0040-keep-settings-recovery-simple.md) 和 [ADR-0041](../docs/adr/0041-separate-preferences-from-proxy-listening-configuration.md)。 +不迁移旧 `settings.json`:新监听文件与新增偏好缺值时使用默认值,需要重新设置现有开发配置;旧文件不读取、不删除,遗留 Keychain 条目不访问、不清理。已有语言、静默启动、测试 URL 等独立偏好及服务器资料保持原状。监听读取失败会阻断正常启动,包括静默启动,并显示只含“重置监听配置”的说明性 alert。用户确认后创建新监听主密钥并原子保存默认配置,默认关闭局域网监听。重置成功前保留原文件及已有后台代理和系统代理;成功后按默认配置与原有开关意图收敛。不迁移旧 `proxy-listening.json`,不读取或清理旧逐项 Keychain 凭据;发现旧文件时同样提示确认重置。设计见 [ADR-0034](../docs/adr/0034-separate-local-and-lan-listeners.md)、[ADR-0040](../docs/adr/0040-keep-settings-recovery-simple.md) 、[ADR-0041](../docs/adr/0041-separate-preferences-from-proxy-listening-configuration.md) 和 [ADR-0042](../docs/adr/0042-encrypt-proxy-listening-configuration.md)。 diff --git a/ShadowsocksX-NG2/Tests/Catalog/CatalogExpansionStateTests.swift b/ShadowsocksX-NG2/Tests/Catalog/CatalogExpansionStateTests.swift index 1b40df81..2c893e14 100644 --- a/ShadowsocksX-NG2/Tests/Catalog/CatalogExpansionStateTests.swift +++ b/ShadowsocksX-NG2/Tests/Catalog/CatalogExpansionStateTests.swift @@ -87,7 +87,6 @@ struct CatalogCreationExpansionTests { func removedCreationParentRejectsWithoutFallingBackToRoot(createServer: Bool) async throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } - let credentials = InMemoryCredentialStore() let workflow = makeCatalogWorkflow( coordinator: CatalogCommitCoordinator( fileStore: makeTestCatalogStore(fileURL: directory.appendingPathComponent("catalog.json")), @@ -110,6 +109,5 @@ struct CatalogCreationExpansionTests { #expect(underlying as? CatalogError == .parentNotFound(parent)) } #expect(workflow.tree.isEmpty) - #expect(credentials.storageSnapshot.isEmpty) } } diff --git a/ShadowsocksX-NG2/Tests/Catalog/CatalogFileStoreTests.swift b/ShadowsocksX-NG2/Tests/Catalog/CatalogFileStoreTests.swift index 02abed98..bc4036b9 100644 --- a/ShadowsocksX-NG2/Tests/Catalog/CatalogFileStoreTests.swift +++ b/ShadowsocksX-NG2/Tests/Catalog/CatalogFileStoreTests.swift @@ -66,10 +66,7 @@ final class CatalogFileStoreTests: XCTestCase { } func testPersistedFileContainsNoPlaintextSecrets() throws { - let credentials = InMemoryCredentialStore() var catalog = ConfigurationCatalog() - let passwordRef = CredentialReference.fresh() - let optionsRef = CredentialReference.fresh() let fields = ServerFields( address: "203.0.113.7", port: 8388, @@ -80,8 +77,6 @@ final class CatalogFileStoreTests: XCTestCase { pluginOptions: "obfs-local;obfs=http" ) _ = try catalog.addServer(fields) - try credentials.save("TOPSECRET-密码", for: passwordRef) - try credentials.save("obfs-local;obfs=http", for: optionsRef) try store.save(CatalogDocument(catalog: catalog)) diff --git a/ShadowsocksX-NG2/Tests/Catalog/ConfigurationGroupExportTests.swift b/ShadowsocksX-NG2/Tests/Catalog/ConfigurationGroupExportTests.swift index c6f61e42..9f3f6757 100644 --- a/ShadowsocksX-NG2/Tests/Catalog/ConfigurationGroupExportTests.swift +++ b/ShadowsocksX-NG2/Tests/Catalog/ConfigurationGroupExportTests.swift @@ -8,7 +8,6 @@ import XCTest final class ConfigurationGroupExportTests: XCTestCase { private var workDir: URL! private var catalogFileURL: URL! - private var credentials: InMemoryCredentialStore! private var fetcher: FakeSubscriptionFetcher! private var workflow: CatalogWorkflow! @@ -18,7 +17,7 @@ final class ConfigurationGroupExportTests: XCTestCase { .appendingPathComponent("group-export-tests-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true) catalogFileURL = workDir.appendingPathComponent("catalog.json") - credentials = InMemoryCredentialStore() + fetcher = FakeSubscriptionFetcher(behavior: .success(Data())) workflow = makeWorkflow() } @@ -131,10 +130,6 @@ final class ConfigurationGroupExportTests: XCTestCase { try catalog.addGroup("空组", id: emptyRoot) try catalog.addGroup("空子组", id: emptyNested, to: emptyRoot) try catalog.addGroup("含无效服务器", id: exportRoot) - let passwordRef = CredentialReference.fresh() - let optionsRef = CredentialReference.fresh() - try credentials.save("password", for: passwordRef) - try credentials.save("obfs=http", for: optionsRef) try catalog.addServer( ServerFields( address: "203.0.113.8", port: 8388, encryptionMethod: "aes-256-gcm", @@ -161,10 +156,6 @@ final class ConfigurationGroupExportTests: XCTestCase { var catalog = ConfigurationCatalog() let rootID = NodeID.fresh() let serverID = NodeID.fresh() - let passwordRef = CredentialReference.fresh() - let optionsRef = CredentialReference.fresh() - try credentials.save("password", for: passwordRef) - try credentials.save("mode=websocket", for: optionsRef) try catalog.addGroup("不完整组", id: rootID) try catalog.addServer( ServerFields( @@ -327,11 +318,9 @@ struct ConfigurationGroupSharingTests { @MainActor private struct Fixture { let directory: URL - let credentials = InMemoryCredentialStore() let root = NodeID.fresh() let empty = NodeID.fresh() let nestedServer = NodeID.fresh() - let nestedPassword = CredentialReference.fresh() let workflow: CatalogWorkflow init() throws { @@ -341,13 +330,6 @@ struct ConfigurationGroupSharingTests { var catalog = ConfigurationCatalog() try catalog.addGroup("组/根", id: root) try catalog.addGroup("空组", id: empty) - let firstPassword = CredentialReference.fresh() - let lastPassword = CredentialReference.fresh() - let options = CredentialReference.fresh() - try credentials.save("first", for: firstPassword) - try credentials.save("nested", for: nestedPassword) - try credentials.save("last", for: lastPassword) - try credentials.save("mode=websocket;host=example.com", for: options) try catalog.addServer( ServerFields( address: "203.0.113.1", port: 8388, diff --git a/ShadowsocksX-NG2/Tests/Catalog/EncryptedServerLibraryTests.swift b/ShadowsocksX-NG2/Tests/Catalog/EncryptedServerLibraryTests.swift index 888ed678..e62edbd3 100644 --- a/ShadowsocksX-NG2/Tests/Catalog/EncryptedServerLibraryTests.swift +++ b/ShadowsocksX-NG2/Tests/Catalog/EncryptedServerLibraryTests.swift @@ -103,18 +103,18 @@ struct EncryptedServerLibraryTests { } } -final class TestLibraryKeys: LibraryMasterKeyStoring { +final class TestLibraryKeys: MasterKeyStoring { var values: [UUID: Data] = [:] var requiresInteraction = false var createFails = false func read(_ id: UUID, allowingInteraction: Bool) throws -> Data? { if requiresInteraction && !allowingInteraction { - throw CredentialStoreError.keychainStatus(-25308) + throw MasterKeyStoreError.keychainStatus(-25308) } return values[id] } func create(_ data: Data, id: UUID, allowingInteraction: Bool) throws { - if createFails { throw CredentialStoreError.keychainStatus(-25308) } + if createFails { throw MasterKeyStoreError.keychainStatus(-25308) } values[id] = data } } diff --git a/ShadowsocksX-NG2/Tests/Catalog/LegacyImportTests.swift b/ShadowsocksX-NG2/Tests/Catalog/LegacyImportTests.swift index df4303fe..83853378 100644 --- a/ShadowsocksX-NG2/Tests/Catalog/LegacyImportTests.swift +++ b/ShadowsocksX-NG2/Tests/Catalog/LegacyImportTests.swift @@ -10,7 +10,6 @@ final class LegacyImportTests: XCTestCase { private var catalogURL: URL! private var activationURL: URL! private var settingsURL: URL! - private var credentials: InMemoryCredentialStore! private var marker: TestLegacyImportMarker! private var provider: FixedLegacySnapshotProvider! @@ -22,7 +21,7 @@ final class LegacyImportTests: XCTestCase { catalogURL = directory.appendingPathComponent("catalog.json") activationURL = directory.appendingPathComponent("activation.json") settingsURL = directory.appendingPathComponent("proxy-listening.json") - credentials = InMemoryCredentialStore() + marker = TestLegacyImportMarker() provider = FixedLegacySnapshotProvider(snapshot: try Self.makeSnapshot()) } @@ -73,7 +72,7 @@ final class LegacyImportTests: XCTestCase { existingSettings.listen.socksPort = 2086 existingSettings.listen.httpPort = 2087 existingSettings.preferredMode = .global - let listeningStore = ProxyListeningFileStore( + let listeningStore = makeTestListeningStore( fileURL: settingsURL) try listeningStore.save(existingSettings.listen) diff --git a/ShadowsocksX-NG2/Tests/Catalog/Workflow/CatalogWorkflowCreateServerTests.swift b/ShadowsocksX-NG2/Tests/Catalog/Workflow/CatalogWorkflowCreateServerTests.swift index 01243050..d234591c 100644 --- a/ShadowsocksX-NG2/Tests/Catalog/Workflow/CatalogWorkflowCreateServerTests.swift +++ b/ShadowsocksX-NG2/Tests/Catalog/Workflow/CatalogWorkflowCreateServerTests.swift @@ -21,7 +21,6 @@ private struct InvalidCreateDraftCase { final class CatalogWorkflowCreateServerTests: XCTestCase { private var workDir: URL! private var fileURL: URL! - private var credentials: InMemoryCredentialStore! private var runtime: FakeCatalogRuntime! var workflow: CatalogWorkflow! @@ -32,7 +31,7 @@ final class CatalogWorkflowCreateServerTests: XCTestCase { .appendingPathComponent("catalog-create-server-tests-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true) fileURL = workDir.appendingPathComponent("catalog.json") - credentials = InMemoryCredentialStore() + runtime = FakeCatalogRuntime() runtime.hasActiveTarget = true workflow = makeWorkflow() diff --git a/ShadowsocksX-NG2/Tests/Catalog/Workflow/CatalogWorkflowFavoritesTests.swift b/ShadowsocksX-NG2/Tests/Catalog/Workflow/CatalogWorkflowFavoritesTests.swift index cacdf4de..0060bc54 100644 --- a/ShadowsocksX-NG2/Tests/Catalog/Workflow/CatalogWorkflowFavoritesTests.swift +++ b/ShadowsocksX-NG2/Tests/Catalog/Workflow/CatalogWorkflowFavoritesTests.swift @@ -226,7 +226,6 @@ private struct FavoritesFixture { let directory: URL let store: CatalogFileStore let runtime = FakeCatalogRuntime() - let credentials = InMemoryCredentialStore() let fetcher = FakeSubscriptionFetcher(behavior: .success(SubscriptionDocs.tree())) let workflow: CatalogWorkflow diff --git a/ShadowsocksX-NG2/Tests/Catalog/Workflow/CatalogWorkflowImportTests.swift b/ShadowsocksX-NG2/Tests/Catalog/Workflow/CatalogWorkflowImportTests.swift index d3d01d7b..675fc053 100644 --- a/ShadowsocksX-NG2/Tests/Catalog/Workflow/CatalogWorkflowImportTests.swift +++ b/ShadowsocksX-NG2/Tests/Catalog/Workflow/CatalogWorkflowImportTests.swift @@ -12,7 +12,7 @@ struct CatalogWorkflowImportTests { // MARK: - SIP-008 整树导入 @Test func sip008FileImportsTreeWithFreshIdentities() async throws { - try await withImportWorkflow { workflow, credentials in + try await withImportWorkflow { workflow in let outcome = await workflow.importServers( from: [.file(name: "机场.json", data: Data(SIP008DocumentFixture.tree.utf8))], into: nil) @@ -38,12 +38,11 @@ struct CatalogWorkflowImportTests { #expect(try workflow.serverEditForm(for: first.id)?.remark == "香港 01") let second = try #require(root.childNodes.last) #expect(try workflow.serverEditForm(for: second.id)?.password == "pw2") - #expect(credentials.storageCount == 0, "服务器秘密不写逐项凭据存储") } } @Test func sip008FlatFallbackNamesRootFromFileName() async throws { - try await withImportWorkflow { workflow, _ in + try await withImportWorkflow { workflow in let outcome = await workflow.importServers( from: [.file(name: "我的机场.json", data: Data(SIP008DocumentFixture.flat.utf8))], into: nil) @@ -54,7 +53,7 @@ struct CatalogWorkflowImportTests { } @Test func sip008NamelessFileFallsBackToGenericRootName() async throws { - try await withImportWorkflow { workflow, _ in + try await withImportWorkflow { workflow in let outcome = await workflow.importServers( from: [.file(name: "", data: Data(SIP008DocumentFixture.flat.utf8))], into: nil) @@ -64,7 +63,7 @@ struct CatalogWorkflowImportTests { } @Test func sip008DuplicateRootNameGetsSuffix() async throws { - try await withImportWorkflow { workflow, _ in + try await withImportWorkflow { workflow in _ = await workflow.importServers( from: [.file(name: "机场.json", data: Data(SIP008DocumentFixture.tree.utf8))], into: nil) _ = await workflow.importServers( @@ -77,7 +76,7 @@ struct CatalogWorkflowImportTests { } @Test func sip008PluginOptionsStoredWithoutProgram() async throws { - try await withImportWorkflow { workflow, credentials in + try await withImportWorkflow { workflow in _ = await workflow.importServers( from: [.file(name: "opts.json", data: Data(SIP008DocumentFixture.pluginOptions.utf8))], into: nil) @@ -96,7 +95,7 @@ struct CatalogWorkflowImportTests { // MARK: - SIP-008 整份拒绝 @Test func sip008InvalidRecordRejectsWholeSource() async throws { - try await withImportWorkflow { workflow, credentials in + try await withImportWorkflow { workflow in let document = """ {"version":1,"servers":[ {"server":"203.0.113.7","server_port":8388,"password":"pw","method":"aes-256-gcm"}, @@ -112,14 +111,13 @@ struct CatalogWorkflowImportTests { } #expect(index == 1, "点名无效记录") #expect(workflow.tree.roots.isEmpty, "整份拒绝不留部分子树") - #expect(credentials.storageCount == 0, "拒绝路径不写凭据") } } // MARK: - 二维码图片 @Test func qrImageImportsDetectedServers() async throws { - try await withImportWorkflow { workflow, _ in + try await withImportWorkflow { workflow in let uri = "ss://YWVzLTI1Ni1nY206cGFzc3dvcmQxMjM@203.0.113.7:8388#二维码" let png = try QrCodeCodec.generatePNG(for: uri) @@ -132,7 +130,7 @@ struct CatalogWorkflowImportTests { } @Test func qrImageWithoutSSPayloadFails() async throws { - try await withImportWorkflow { workflow, _ in + try await withImportWorkflow { workflow in let png = try QrCodeCodec.generatePNG(for: "plain text payload") let outcome = await workflow.importServers( @@ -150,7 +148,7 @@ struct CatalogWorkflowImportTests { // MARK: - 文本文件与剪贴板 @Test func textFileImportsLinesPartially() async throws { - try await withImportWorkflow { workflow, _ in + try await withImportWorkflow { workflow in let text = "ss://YWVzLTI1Ni1nY206cGFzc3dvcmQxMjM@203.0.113.7:8388#文件行\n垃圾行\n" let outcome = await workflow.importServers( from: [.file(name: "list.txt", data: Data(text.utf8))], into: nil) @@ -164,7 +162,7 @@ struct CatalogWorkflowImportTests { } @Test func blankLinesKeepOriginalFailureIndexes() async throws { - try await withImportWorkflow { workflow, _ in + try await withImportWorkflow { workflow in let text = "第一行垃圾\n\n\nss://YWVzLTI1Ni1nY206cGFzc3dvcmQxMjM@203.0.113.7:8388" let outcome = await workflow.importServers( from: [.clipboardText(text)], into: nil) @@ -177,7 +175,7 @@ struct CatalogWorkflowImportTests { } @Test func clipboardEmptyTextFailsWithoutCatalogChange() async throws { - try await withImportWorkflow { workflow, _ in + try await withImportWorkflow { workflow in let outcome = await workflow.importServers( from: [.clipboardText(" \n ")], into: nil) @@ -193,7 +191,7 @@ struct CatalogWorkflowImportTests { // MARK: - 来源独立性与提交失败回滚 @Test func sourcesImportIndependently() async throws { - try await withImportWorkflow { workflow, _ in + try await withImportWorkflow { workflow in let broken = """ {"version":1,"servers":[ {"server":"203.0.113.7","server_port":8388,"password":"","method":"aes-256-gcm"}]} @@ -212,7 +210,7 @@ struct CatalogWorkflowImportTests { } @Test func commitFailureDoesNotPublishImportedRecords() async throws { - try await withGateBrokenWorkflow { workflow, credentials in + try await withGateBrokenWorkflow { workflow in let text = "ss://YWVzLTI1Ni1nY206cGFzc3dvcmQxMjM@203.0.113.7:8388#提交失败" let outcome = await workflow.importServers(from: [.clipboardText(text)], into: nil) @@ -227,7 +225,7 @@ struct CatalogWorkflowImportTests { } @Test func selectionCandidatePrefersLastImportedGroup() async throws { - try await withImportWorkflow { workflow, _ in + try await withImportWorkflow { workflow in let text = "ss://YWVzLTI1Ni1nY206cGFzc3dvcmQxMjM@203.0.113.7:8388#根层" let outcome = await workflow.importServers( from: [ @@ -245,39 +243,37 @@ struct CatalogWorkflowImportTests { /// 临时目录 + 内存凭据与库密钥;UUID 后缀命名,进程私有资源(runner 并行安全)。 private func withImportWorkflow( - _ body: @MainActor (CatalogWorkflow, InMemoryCredentialStore) async throws -> Void + _ body: @MainActor (CatalogWorkflow) async throws -> Void ) async throws { let directory = FileManager.default.temporaryDirectory .appendingPathComponent("catalog-import-tests-\(UUID().uuidString)", isDirectory: true) defer { try? FileManager.default.removeItem(at: directory) } try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - let credentials = InMemoryCredentialStore() let coordinator = CatalogCommitCoordinator( fileStore: makeTestCatalogStore( fileURL: directory.appendingPathComponent("catalog.json")), runtime: FakeCatalogRuntime()) let workflow = makeCatalogWorkflow(coordinator: coordinator) - try await body(workflow, credentials) + try await body(workflow) } /// 目录文件置于 gate 子目录;把 gate 目录替换为同名文件后,下一次持久化 /// 必然失败(回滚测试的确定性失败注入,与 CatalogWorkflowRollbackTests 同法)。 private func withGateBrokenWorkflow( - _ body: @MainActor (CatalogWorkflow, InMemoryCredentialStore) async throws -> Void + _ body: @MainActor (CatalogWorkflow) async throws -> Void ) async throws { let directory = FileManager.default.temporaryDirectory .appendingPathComponent("catalog-import-gate-\(UUID().uuidString)", isDirectory: true) defer { try? FileManager.default.removeItem(at: directory) } let gateDir = directory.appendingPathComponent("gate") try FileManager.default.createDirectory(at: gateDir, withIntermediateDirectories: true) - let credentials = InMemoryCredentialStore() let coordinator = CatalogCommitCoordinator( fileStore: makeTestCatalogStore(fileURL: gateDir.appendingPathComponent("catalog.json")), runtime: FakeCatalogRuntime()) let workflow = makeCatalogWorkflow(coordinator: coordinator) try FileManager.default.removeItem(at: gateDir) try Data().write(to: gateDir) - try await body(workflow, credentials) + try await body(workflow) } } diff --git a/ShadowsocksX-NG2/Tests/Catalog/Workflow/CatalogWorkflowLibraryTests.swift b/ShadowsocksX-NG2/Tests/Catalog/Workflow/CatalogWorkflowLibraryTests.swift index 6d6b8f25..36b63321 100644 --- a/ShadowsocksX-NG2/Tests/Catalog/Workflow/CatalogWorkflowLibraryTests.swift +++ b/ShadowsocksX-NG2/Tests/Catalog/Workflow/CatalogWorkflowLibraryTests.swift @@ -115,7 +115,6 @@ struct CatalogWorkflowLibraryTests { controlPreferences: makeTestProxyControlPreferences(), catalogSnapshotReader: bootstrap.catalogSnapshotReader, activationFileStore: activation, runtimeFileStore: RuntimeFileStore(fileURL: runtimeURL), - credentials: InMemoryCredentialStore(), settingsRestore: RestoredProxyRuntimeSettings( settings: ProxyRuntimeSettings(agentEnabled: true), unreadableError: nil), agent: agent) diff --git a/ShadowsocksX-NG2/Tests/Catalog/Workflow/CatalogWorkflowPluginTests.swift b/ShadowsocksX-NG2/Tests/Catalog/Workflow/CatalogWorkflowPluginTests.swift index fe44610b..8d4401af 100644 --- a/ShadowsocksX-NG2/Tests/Catalog/Workflow/CatalogWorkflowPluginTests.swift +++ b/ShadowsocksX-NG2/Tests/Catalog/Workflow/CatalogWorkflowPluginTests.swift @@ -11,7 +11,6 @@ final class CatalogWorkflowPluginTests: XCTestCase { private var workDir: URL! private var bundleRoot: URL! private var fileURL: URL! - private var credentials: InMemoryCredentialStore! private var workflow: CatalogWorkflow! override func setUp() async throws { @@ -26,7 +25,7 @@ final class CatalogWorkflowPluginTests: XCTestCase { at: bundleRoot.appendingPathComponent("Contents/Helpers/Plugins"), withIntermediateDirectories: true) fileURL = workDir.appendingPathComponent("catalog.json") - credentials = InMemoryCredentialStore() + workflow = makeWorkflow() } diff --git a/ShadowsocksX-NG2/Tests/Catalog/Workflow/CatalogWorkflowPolicyTests.swift b/ShadowsocksX-NG2/Tests/Catalog/Workflow/CatalogWorkflowPolicyTests.swift index c0e7cf46..d04c3ae1 100644 --- a/ShadowsocksX-NG2/Tests/Catalog/Workflow/CatalogWorkflowPolicyTests.swift +++ b/ShadowsocksX-NG2/Tests/Catalog/Workflow/CatalogWorkflowPolicyTests.swift @@ -9,7 +9,6 @@ import XCTest final class CatalogWorkflowPolicyTests: XCTestCase { private var workDir: URL! private var fileURL: URL! - private var credentials: InMemoryCredentialStore! private var runtime: FakeCatalogRuntime! private var activator: FakeActivator! private var workflow: CatalogWorkflow! @@ -20,7 +19,7 @@ final class CatalogWorkflowPolicyTests: XCTestCase { .appendingPathComponent("catalog-policy-tests-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true) fileURL = workDir.appendingPathComponent("catalog.json") - credentials = InMemoryCredentialStore() + runtime = FakeCatalogRuntime() activator = FakeActivator() workflow = makeWorkflow() diff --git a/ShadowsocksX-NG2/Tests/Catalog/Workflow/CatalogWorkflowRollbackTests.swift b/ShadowsocksX-NG2/Tests/Catalog/Workflow/CatalogWorkflowRollbackTests.swift index 8f264ea1..0cf839b1 100644 --- a/ShadowsocksX-NG2/Tests/Catalog/Workflow/CatalogWorkflowRollbackTests.swift +++ b/ShadowsocksX-NG2/Tests/Catalog/Workflow/CatalogWorkflowRollbackTests.swift @@ -201,7 +201,7 @@ final class CatalogWorkflowRollbackTests: XCTestCase { existingSettings.listen.socksPort = 2086 existingSettings.listen.httpPort = 2087 existingSettings.preferredMode = .global - let listeningStore = ProxyListeningFileStore( + let listeningStore = makeTestListeningStore( fileURL: settingsURL) try listeningStore.save(existingSettings.listen) let existingTarget = NodeID(rawValue: "existing-target") diff --git a/ShadowsocksX-NG2/Tests/Catalog/Workflow/CatalogWorkflowSubscriptionTests.swift b/ShadowsocksX-NG2/Tests/Catalog/Workflow/CatalogWorkflowSubscriptionTests.swift index 3df63ebe..e3b52c19 100644 --- a/ShadowsocksX-NG2/Tests/Catalog/Workflow/CatalogWorkflowSubscriptionTests.swift +++ b/ShadowsocksX-NG2/Tests/Catalog/Workflow/CatalogWorkflowSubscriptionTests.swift @@ -11,7 +11,6 @@ import XCTest final class CatalogWorkflowSubscriptionTests: XCTestCase { private var workDir: URL! var fileURL: URL! - var credentials: InMemoryCredentialStore! var fetcher: FakeSubscriptionFetcher! var runtime: FakeCatalogRuntime! var workflow: CatalogWorkflow! @@ -22,7 +21,7 @@ final class CatalogWorkflowSubscriptionTests: XCTestCase { .appendingPathComponent("subscription-workflow-tests-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true) fileURL = workDir.appendingPathComponent("catalog.json") - credentials = InMemoryCredentialStore() + runtime = FakeCatalogRuntime() fetcher = FakeSubscriptionFetcher(behavior: .success(SubscriptionDocs.flat(serverCount: 1))) workflow = makeWorkflow() diff --git a/ShadowsocksX-NG2/Tests/Catalog/Workflow/CatalogWorkflowTests.swift b/ShadowsocksX-NG2/Tests/Catalog/Workflow/CatalogWorkflowTests.swift index 3822bbf7..ff175b41 100644 --- a/ShadowsocksX-NG2/Tests/Catalog/Workflow/CatalogWorkflowTests.swift +++ b/ShadowsocksX-NG2/Tests/Catalog/Workflow/CatalogWorkflowTests.swift @@ -11,7 +11,6 @@ import XCTest final class CatalogWorkflowTests: XCTestCase { private var workDir: URL! private var fileURL: URL! - private var credentials: InMemoryCredentialStore! private var runtime: FakeCatalogRuntime! var workflow: CatalogWorkflow! @@ -22,7 +21,7 @@ final class CatalogWorkflowTests: XCTestCase { .appendingPathComponent("catalog-workflow-tests-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true) fileURL = workDir.appendingPathComponent("catalog.json") - credentials = InMemoryCredentialStore() + runtime = FakeCatalogRuntime() workflow = makeWorkflow() } diff --git a/ShadowsocksX-NG2/Tests/Connectivity/ConnectivityFormalRuntimeTests.swift b/ShadowsocksX-NG2/Tests/Connectivity/ConnectivityFormalRuntimeTests.swift index f1ab9319..c26628cc 100644 --- a/ShadowsocksX-NG2/Tests/Connectivity/ConnectivityFormalRuntimeTests.swift +++ b/ShadowsocksX-NG2/Tests/Connectivity/ConnectivityFormalRuntimeTests.swift @@ -8,7 +8,6 @@ struct ConnectivityFormalRuntimeTests { @Test func changingTestURLDoesNotConvergeTheRunningFormalProxy() async throws { let runtime = ProxyRuntimeFixture.makeTemporaryRuntime() defer { try? FileManager.default.removeItem(at: runtime.directory) } - let credentials = InMemoryCredentialStore() let agent = ProxyRuntimeFixture.FakeLaunchAgent() let systemProxy = ProxyRuntimeFixture.FakeSystemProxy() let store = SettingsWorkflowTests.InMemorySettingsStore() @@ -24,7 +23,7 @@ struct ConnectivityFormalRuntimeTests { at: runtime.directory.appendingPathComponent("catalog.json")), activationFileStore: ActivationStateFileStore( fileURL: runtime.directory.appendingPathComponent("activation.json")), - runtimeFileStore: fileStore, credentials: credentials, plugins: ActivationFixture.plugins, + runtimeFileStore: fileStore, plugins: ActivationFixture.plugins, listeningStore: store, customRuleStore: CustomRuleStore( fileURL: runtime.directory.appendingPathComponent("rules.json")), @@ -46,7 +45,7 @@ struct ConnectivityFormalRuntimeTests { let defaults = try #require(UserDefaults(suiteName: suite)) defer { defaults.removePersistentDomain(forName: suite) } let workflow = SettingsWorkflow( - committing: controller, credentials: credentials, connectivityDefaults: defaults) + committing: controller, connectivityDefaults: defaults) #expect(await workflow.saveConnectivityURL("http://example.com/probe?token=private")) #expect(controller.state == state) #expect(controller.activeTargetID == target) diff --git a/ShadowsocksX-NG2/Tests/Connectivity/ConnectivitySettingsTests.swift b/ShadowsocksX-NG2/Tests/Connectivity/ConnectivitySettingsTests.swift index d1cb4f37..c56b0e2c 100644 --- a/ShadowsocksX-NG2/Tests/Connectivity/ConnectivitySettingsTests.swift +++ b/ShadowsocksX-NG2/Tests/Connectivity/ConnectivitySettingsTests.swift @@ -11,9 +11,8 @@ struct ConnectivitySettingsTests { defer { defaults.removePersistentDomain(forName: suite) } let committer = FakeSettingsCommitter() committer.updateError = ProxyListeningStoreError.ioFailure(detail: "must not commit") - let credentials = UnavailableConnectivityCredentials() let workflow = SettingsWorkflow( - committing: committer, credentials: credentials, connectivityDefaults: defaults) + committing: committer, connectivityDefaults: defaults) var published: [URL] = [] workflow.connectivityURLDidChange = { published.append($0) } let url = "http://example.com/probe?token=public" @@ -22,7 +21,7 @@ struct ConnectivitySettingsTests { #expect(committer.committedSettings == ProxyRuntimeSettings()) #expect(published.map(\.absoluteString) == [url]) let reopened = SettingsWorkflow( - committing: committer, credentials: credentials, connectivityDefaults: defaults) + committing: committer, connectivityDefaults: defaults) #expect(reopened.beginConnectivityURLEditing() == url) } @@ -31,7 +30,7 @@ struct ConnectivitySettingsTests { let defaults = try #require(UserDefaults(suiteName: suite)) defer { defaults.removePersistentDomain(forName: suite) } let workflow = SettingsWorkflow( - committing: FakeSettingsCommitter(), credentials: UnavailableConnectivityCredentials(), + committing: FakeSettingsCommitter(), connectivityDefaults: defaults) #expect(workflow.beginConnectivityURLEditing() == "http://www.gstatic.com/generate_204") for value: Any in [ @@ -48,7 +47,7 @@ struct ConnectivitySettingsTests { let defaults = try #require(UserDefaults(suiteName: suite)) defer { defaults.removePersistentDomain(forName: suite) } let workflow = SettingsWorkflow( - committing: FakeSettingsCommitter(), credentials: UnavailableConnectivityCredentials(), + committing: FakeSettingsCommitter(), connectivityDefaults: defaults) var published: [URL] = [] workflow.connectivityURLDidChange = { published.append($0) } @@ -61,44 +60,21 @@ struct ConnectivitySettingsTests { #expect(published.count == 2) } - @Test func oldCredentialReferenceIsIgnoredWithoutAccessingKeychain() throws { + @Test func testURLIgnoresHistoricalCredentialReference() throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: directory) } let file = directory.appendingPathComponent("proxy-listening.json") - try Data( - #"{"connectivityURLReference":{"obsolete":true},"preferredMode":"global"}"#.utf8 - ).write(to: file) - let store = ProxyListeningFileStore(fileURL: file) - let settings = try store.load() - #expect(settings == ProxyListeningConfiguration()) - try store.save(settings) - #expect(try !String(contentsOf: file, encoding: .utf8).contains("connectivityURLReference")) + let original = Data(#"{"connectivityURLReference":{"obsolete":true}}"#.utf8) + try original.write(to: file) let suite = "ConnectivitySettingsTests.\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suite)) defer { defaults.removePersistentDomain(forName: suite) } let committer = FakeSettingsCommitter() - committer.committedSettings.listen = settings let workflow = SettingsWorkflow( - committing: committer, credentials: UnavailableConnectivityCredentials(), + committing: committer, connectivityDefaults: defaults) #expect(workflow.beginConnectivityURLEditing() == "http://www.gstatic.com/generate_204") - } -} - -private struct UnavailableConnectivityCredentials: CredentialStoring { - func save(_ secret: String, for reference: CredentialReference) throws { - Issue.record("Test URL must not write credentials") - throw CredentialStoreError.secretNotUTF8 - } - - func secret(for reference: CredentialReference) throws -> String? { - Issue.record("Test URL must not read credentials") - throw CredentialStoreError.secretNotUTF8 - } - - func delete(_ reference: CredentialReference) throws { - Issue.record("Test URL must not delete credentials") - throw CredentialStoreError.secretNotUTF8 + #expect(try Data(contentsOf: file) == original) } } diff --git a/ShadowsocksX-NG2/Tests/Credentials/CredentialStoreTests.swift b/ShadowsocksX-NG2/Tests/Credentials/CredentialStoreTests.swift deleted file mode 100644 index e0462399..00000000 --- a/ShadowsocksX-NG2/Tests/Credentials/CredentialStoreTests.swift +++ /dev/null @@ -1,119 +0,0 @@ -import XCTest - -@testable import ShadowsocksX_NG2 - -/// 凭据存储缝:Keychain 写入/读出一致。 -final class KeychainCredentialStoreTests: XCTestCase { - private var store: KeychainCredentialStore! - - override func setUpWithError() throws { - try super.setUpWithError() - // 每个用例独立 service 命名空间:避免并行/重复运行互相污染 - let service = "test.ShadowsocksX-NG2.credentials.\(UUID().uuidString)" - store = KeychainCredentialStore(service: service) - addTeardownBlock { [service] in - Self.removeKeychainItems(for: service) - } - } - - private static func removeKeychainItems(for service: String) { - // 先枚举本用例 service 下的全部 account,再逐条删除。先解包为 String, - // 避免将 `Optional` 桥接进 Security query;逐条删除则不会遗漏同一 - // service 下多个凭据引用的记录。 - let lookup: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecReturnAttributes as String: kCFBooleanTrue as Any, - kSecMatchLimit as String: kSecMatchLimitAll, - ] - var result: AnyObject? - let lookupStatus = SecItemCopyMatching(lookup as CFDictionary, &result) - guard lookupStatus == errSecSuccess || lookupStatus == errSecItemNotFound else { - XCTFail("查找测试 Keychain 记录失败,status=\(lookupStatus)") - return - } - - let items = (result as? [[String: Any]]) ?? [] - for item in items { - guard let account = item[kSecAttrAccount as String] as? String else { - XCTFail("测试 Keychain 记录缺少 account") - continue - } - let deleteQuery: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - ] - let deleteStatus = SecItemDelete(deleteQuery as CFDictionary) - XCTAssertTrue( - deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound, - "清理测试 Keychain 记录失败,status=\(deleteStatus)") - } - - var remaining: AnyObject? - let remainingStatus = SecItemCopyMatching(lookup as CFDictionary, &remaining) - let remainingItems = (remaining as? [[String: Any]]) ?? [] - XCTAssertTrue( - remainingStatus == errSecItemNotFound || remainingItems.isEmpty, - "测试 Keychain 记录未完全清理,status=\(remainingStatus)") - } - - func testSaveThenReadIsConsistent() throws { - let reference = CredentialReference.fresh() - let secret = "s3cret-密码-🔑" - - try store.save(secret, for: reference) - - XCTAssertEqual(try store.secret(for: reference), secret) - } - - func testSaveOverwritesExistingSecret() throws { - let reference = CredentialReference.fresh() - - try store.save("旧密码", for: reference) - try store.save("新密码", for: reference) - - XCTAssertEqual(try store.secret(for: reference), "新密码") - } - - func testAbsentReferenceReadsAsNil() throws { - XCTAssertNil(try store.secret(for: CredentialReference.fresh())) - } - - func testDeleteRemovesSecretAndIsIdempotent() throws { - let reference = CredentialReference.fresh() - try store.save("s", for: reference) - - try store.delete(reference) - - XCTAssertNil(try store.secret(for: reference)) - XCTAssertNoThrow(try store.delete(reference), "删除不存在的条目不报错") - } - - func testDistinctReferencesDoNotCollide() throws { - let first = CredentialReference.fresh() - let second = CredentialReference.fresh() - - try store.save("A", for: first) - try store.save("B", for: second) - - XCTAssertEqual(try store.secret(for: first), "A") - XCTAssertEqual(try store.secret(for: second), "B") - } -} - -final class InMemoryCredentialStoreTests: XCTestCase { - func testContractMatchesKeychainSemantics() throws { - let store = InMemoryCredentialStore() - let reference = CredentialReference.fresh() - - XCTAssertNil(try store.secret(for: reference)) - try store.save("x", for: reference) - XCTAssertEqual(try store.secret(for: reference), "x") - try store.save("y", for: reference) - XCTAssertEqual(try store.secret(for: reference), "y") - try store.delete(reference) - XCTAssertNil(try store.secret(for: reference)) - try store.delete(reference) - } -} diff --git a/ShadowsocksX-NG2/Tests/Credentials/CredentialWriteJournalTests.swift b/ShadowsocksX-NG2/Tests/Credentials/CredentialWriteJournalTests.swift deleted file mode 100644 index 5fd85fd4..00000000 --- a/ShadowsocksX-NG2/Tests/Credentials/CredentialWriteJournalTests.swift +++ /dev/null @@ -1,127 +0,0 @@ -import Security -import XCTest - -@testable import ShadowsocksX_NG2 - -/// `CredentialWriteJournal` 自身 seam 的语义:首触快照、幂等 -/// 删除、尽力回滚的三种结果。回滚经 typed outcome 断言,不戳存储内部状态。 -final class CredentialWriteJournalTests: XCTestCase { - private let passwordRef = CredentialReference(rawValue: "ref-pw") - private let optionsRef = CredentialReference(rawValue: "ref-opts") - - // MARK: - 快照语义 - - func testSaveSnapshotsOriginalOnFirstTouchOnly() throws { - let store = InMemoryCredentialStore() - try store.save("旧密码", for: passwordRef) - var journal = CredentialWriteJournal(credentials: store) - - try journal.save("新密码", for: passwordRef) - try journal.save("更新密码", for: passwordRef) - - XCTAssertEqual(journal.rollback(), .restored([passwordRef])) - XCTAssertEqual(try store.secret(for: passwordRef), "旧密码", "回滚恢复首触快照而非中间值") - } - - func testRollbackWithoutTouchReportsNothingToRestore() { - let store = InMemoryCredentialStore() - let journal = CredentialWriteJournal(credentials: store) - - XCTAssertEqual(journal.rollback(), .nothingToRestore) - } - - func testRollbackDeletesReferenceThatWasOriginallyAbsent() throws { - let store = InMemoryCredentialStore() - var journal = CredentialWriteJournal(credentials: store) - - try journal.save("新秘密", for: passwordRef) - - XCTAssertEqual(journal.rollback(), .restored([passwordRef])) - XCTAssertNil(try store.secret(for: passwordRef), "原本不存在的引用回滚后归于不存在") - } - - // MARK: - delete 的回滚 = 复活旧秘密 - - func testRollbackRevivesSecretDeletedByJournal() throws { - let store = InMemoryCredentialStore() - try store.save("mode=websocket", for: optionsRef) - var journal = CredentialWriteJournal(credentials: store) - - journal.delete(optionsRef) - XCTAssertNil(try store.secret(for: optionsRef)) - - XCTAssertEqual(journal.rollback(), .restored([optionsRef])) - XCTAssertEqual(try store.secret(for: optionsRef), "mode=websocket") - } - - func testDeleteIsIdempotentAndKeepsFirstSnapshot() throws { - let store = InMemoryCredentialStore() - try store.save("mode=websocket", for: optionsRef) - var journal = CredentialWriteJournal(credentials: store) - - journal.delete(optionsRef) - try journal.save("obfs=http", for: optionsRef) - journal.delete(optionsRef) - - XCTAssertEqual(journal.rollback(), .restored([optionsRef])) - XCTAssertEqual(try store.secret(for: optionsRef), "mode=websocket", "二次触碰不覆盖首触快照") - } - - // MARK: - 残局可观测(旧值继续生效的不变量被存储失败违例时点名) - - func testPartialRollbackNamesReferencesThatCouldNotBeRestored() throws { - let store = SelectivelyFailingCredentialStore() - try store.save("旧密码", for: passwordRef) - try store.save("mode=websocket", for: optionsRef) - store.failSave[passwordRef] = "旧密码" - var journal = CredentialWriteJournal(credentials: store) - - try journal.save("新密码", for: passwordRef) - journal.delete(optionsRef) - - XCTAssertEqual( - journal.rollback(), - .partial(restored: [optionsRef], failed: [passwordRef]), - "单个恢复失败不中断其余引用的恢复") - } - - func testPartialRollbackReportsFailedDeletionOfOriginallyAbsentReference() throws { - let store = SelectivelyFailingCredentialStore() - store.failDelete = [passwordRef] - var journal = CredentialWriteJournal(credentials: store) - - try journal.save("新秘密", for: passwordRef) - - XCTAssertEqual(journal.rollback(), .partial(restored: [], failed: [passwordRef])) - XCTAssertEqual(try store.secret(for: passwordRef), "新秘密", "删除失败即原值未生效,点名在 failed") - } -} - -/// 可注入「写回原值失败」的凭据存储:按 (引用, 秘密值) 对与引用命中抛错, -/// 用于制造 journal 回滚的 `.partial` 残局。命中之外的读写与 InMemory 契约 -/// 一致。 -private final class SelectivelyFailingCredentialStore: CredentialStoring { - private var storage: [String: String] = [:] - /// 命中 (引用, 秘密值) 的 save 抛错(回滚写回原值即此形态)。 - var failSave: [CredentialReference: String] = [:] - /// 命中引用的 delete 抛错。 - var failDelete: Set = [] - - func save(_ secret: String, for reference: CredentialReference) throws { - if failSave[reference] == secret { - throw CredentialStoreError.keychainStatus(errSecInternalError) - } - storage[reference.rawValue] = secret - } - - func secret(for reference: CredentialReference) throws -> String? { - storage[reference.rawValue] - } - - func delete(_ reference: CredentialReference) throws { - if failDelete.contains(reference) { - throw CredentialStoreError.keychainStatus(errSecInternalError) - } - storage.removeValue(forKey: reference.rawValue) - } -} diff --git a/ShadowsocksX-NG2/Tests/Diagnostics/DiagnosticReportTests.swift b/ShadowsocksX-NG2/Tests/Diagnostics/DiagnosticReportTests.swift index 5170b434..87f168e1 100644 --- a/ShadowsocksX-NG2/Tests/Diagnostics/DiagnosticReportTests.swift +++ b/ShadowsocksX-NG2/Tests/Diagnostics/DiagnosticReportTests.swift @@ -57,7 +57,7 @@ final class DiagnosticReportTests: XCTestCase { node: NodeID(rawValue: "leaf-1"), reason: .pluginNotProvided(program: "simple-obfs")))) snapshot.hasActiveTarget = true - snapshot.listen = ProxyListeningConfiguration() + snapshot.listen = RuntimeListenFacts(listen: ProxyListeningConfiguration()) snapshot.runtimeDocumentSummary = Redactor.documentSummary( ProxyRuntimeFixture.makeDocument( serverAddress: address, password: password, pluginOpts: pluginOpts)) @@ -143,7 +143,8 @@ final class DiagnosticReportTests: XCTestCase { /// 监听方式与实际绑定地址进入报告;设置不再收集单独的 LAN 地址。 func testAllIPv4ListenerModeReportsItsBindingAddress() { var snapshot = DiagnosticSnapshot() - snapshot.listen = ProxyListeningConfiguration(listenerMode: .allIPv4Interfaces) + snapshot.listen = RuntimeListenFacts( + listen: ProxyListeningConfiguration(listenerMode: .allIPv4Interfaces)) snapshot.homePathForRedaction = nil let report = DiagnosticReportBuilder.markdown(from: snapshot) diff --git a/ShadowsocksX-NG2/Tests/Plugin/BundleManagedPluginProviderTests.swift b/ShadowsocksX-NG2/Tests/Plugin/BundleManagedPluginProviderTests.swift index 30cc621e..dad26c5d 100644 --- a/ShadowsocksX-NG2/Tests/Plugin/BundleManagedPluginProviderTests.swift +++ b/ShadowsocksX-NG2/Tests/Plugin/BundleManagedPluginProviderTests.swift @@ -7,7 +7,6 @@ import XCTest /// 拒绝)。路径只从静态事实表派生,绝不拼接引用原文。 final class BundleManagedPluginProviderTests: XCTestCase { private var bundleRoot: URL! - private var credentials: InMemoryCredentialStore! override func setUpWithError() throws { try super.setUpWithError() @@ -16,7 +15,7 @@ final class BundleManagedPluginProviderTests: XCTestCase { try FileManager.default.createDirectory( at: bundleRoot.appendingPathComponent("Contents/Helpers/Plugins"), withIntermediateDirectories: true) - credentials = InMemoryCredentialStore() + } override func tearDownWithError() throws { diff --git a/ShadowsocksX-NG2/Tests/Plugin/PluginCatalogTests.swift b/ShadowsocksX-NG2/Tests/Plugin/PluginCatalogTests.swift index d8ddbad3..3a7ea460 100644 --- a/ShadowsocksX-NG2/Tests/Plugin/PluginCatalogTests.swift +++ b/ShadowsocksX-NG2/Tests/Plugin/PluginCatalogTests.swift @@ -207,7 +207,6 @@ struct PluginCatalogTests { fileURL: runtime.directory.appendingPathComponent("plugins.json")), inspector: QuietPluginInspector()) try plugins.addPlugin(program: "tool", path: first.path) - let credentials = InMemoryCredentialStore() var catalog = ConfigurationCatalog() let id = try ActivationFixture.addPlainServer("Plugin", in: &catalog) var fields = try #require(catalog.entry(for: id)).serverFieldsForPluginTest @@ -228,7 +227,7 @@ struct PluginCatalogTests { catalogSnapshotReader: ProxyRuntimeFixture.catalogSnapshotReader(at: catalogFile), activationFileStore: ActivationStateFileStore( fileURL: runtime.directory.appendingPathComponent("activation.json")), - runtimeFileStore: files, credentials: credentials, plugins: plugins, + runtimeFileStore: files, plugins: plugins, listeningStore: InMemoryProxyListeningStore(), customRuleStore: CustomRuleStore( fileURL: runtime.directory.appendingPathComponent("rules.json")), diff --git a/ShadowsocksX-NG2/Tests/Plugin/PluginMappingCommitTests.swift b/ShadowsocksX-NG2/Tests/Plugin/PluginMappingCommitTests.swift index f0f9d38c..e2917f92 100644 --- a/ShadowsocksX-NG2/Tests/Plugin/PluginMappingCommitTests.swift +++ b/ShadowsocksX-NG2/Tests/Plugin/PluginMappingCommitTests.swift @@ -188,7 +188,6 @@ private final class PluginMappingCommitFixture { fileURL: directory.appendingPathComponent("mappings/plugins.json")) plugins = PluginCatalog(store: store, inspector: CommitPluginInspector()) try plugins.addPlugin(program: "tool", path: first.path) - let credentials = InMemoryCredentialStore() var catalog = ConfigurationCatalog() group = try catalog.addGroup("Group") pluginServer = try ActivationFixture.addPlainServer( @@ -219,7 +218,7 @@ private final class PluginMappingCommitFixture { catalogSnapshotReader: bootstrap.catalogSnapshotReader, activationFileStore: ActivationStateFileStore( fileURL: directory.appendingPathComponent("activation.json")), - runtimeFileStore: files, credentials: credentials, plugins: plugins, + runtimeFileStore: files, plugins: plugins, listeningStore: InMemoryProxyListeningStore(), customRuleStore: CustomRuleStore(fileURL: directory.appendingPathComponent("rules.json")), appBundle: AppArtifact.bundle, diff --git a/ShadowsocksX-NG2/Tests/ProxyControl/ProxyControlWorkflowIntegrationTests.swift b/ShadowsocksX-NG2/Tests/ProxyControl/ProxyControlWorkflowIntegrationTests.swift index 7418ab3d..6a76416a 100644 --- a/ShadowsocksX-NG2/Tests/ProxyControl/ProxyControlWorkflowIntegrationTests.swift +++ b/ShadowsocksX-NG2/Tests/ProxyControl/ProxyControlWorkflowIntegrationTests.swift @@ -12,7 +12,6 @@ final class ProxyControlWorkflowIntegrationTests: XCTestCase { private var runtime: ProxyRuntimeFixture.TemporaryRuntime! private var catalogFileURL: URL! private var activationFileURL: URL! - private var credentials: InMemoryCredentialStore! var agent: ProxyRuntimeFixture.FakeLaunchAgent! var systemProxy: ProxyRuntimeFixture.FakeSystemProxy! var networkMonitor = ProxyRuntimeFixture.FakeSystemProxyNetworkChangeMonitor() @@ -24,7 +23,7 @@ final class ProxyControlWorkflowIntegrationTests: XCTestCase { runtime = ProxyRuntimeFixture.makeTemporaryRuntime() catalogFileURL = runtime.directory.appendingPathComponent("catalog.json") activationFileURL = runtime.directory.appendingPathComponent("activation.json") - credentials = InMemoryCredentialStore() + agent = ProxyRuntimeFixture.FakeLaunchAgent() systemProxy = ProxyRuntimeFixture.FakeSystemProxy() systemProxyHelper = ProxyRuntimeFixture.FakeSystemProxyHelperService() @@ -70,7 +69,6 @@ final class ProxyControlWorkflowIntegrationTests: XCTestCase { catalogSnapshotReader: ProxyRuntimeFixture.catalogSnapshotReader(at: catalogFileURL), activationFileStore: ActivationStateFileStore(fileURL: activationFileURL), runtimeFileStore: runtimeFileStore, - credentials: credentials, plugins: ActivationFixture.plugins, listeningStore: listeningStore ?? InMemoryProxyListeningStore(), appBundle: AppArtifact.bundle, diff --git a/ShadowsocksX-NG2/Tests/ProxyRuntime/ProxyRuntimeControllerModeTests.swift b/ShadowsocksX-NG2/Tests/ProxyRuntime/ProxyRuntimeControllerModeTests.swift index 69749e09..9e915985 100644 --- a/ShadowsocksX-NG2/Tests/ProxyRuntime/ProxyRuntimeControllerModeTests.swift +++ b/ShadowsocksX-NG2/Tests/ProxyRuntime/ProxyRuntimeControllerModeTests.swift @@ -166,7 +166,6 @@ final class CatalogRuntimeSnapshotIntegrationTests: XCTestCase { private var runtime: ProxyRuntimeFixture.TemporaryRuntime! private var catalogFileURL: URL! private var activationFileURL: URL! - private var credentials: InMemoryCredentialStore! private var agent: ProxyRuntimeFixture.FakeLaunchAgent! private var systemProxy: ProxyRuntimeFixture.FakeSystemProxy! @@ -175,7 +174,7 @@ final class CatalogRuntimeSnapshotIntegrationTests: XCTestCase { runtime = ProxyRuntimeFixture.makeTemporaryRuntime() catalogFileURL = runtime.directory.appendingPathComponent("catalog.json") activationFileURL = runtime.directory.appendingPathComponent("activation.json") - credentials = InMemoryCredentialStore() + agent = ProxyRuntimeFixture.FakeLaunchAgent() systemProxy = ProxyRuntimeFixture.FakeSystemProxy() } @@ -254,7 +253,6 @@ final class CatalogRuntimeSnapshotIntegrationTests: XCTestCase { catalogSnapshotReader: catalogSnapshotReader, activationFileStore: ActivationStateFileStore(fileURL: activationFileURL), runtimeFileStore: runtimeFileStore, - credentials: credentials, plugins: ActivationFixture.plugins, listeningStore: InMemoryProxyListeningStore(), appBundle: AppArtifact.bundle, diff --git a/ShadowsocksX-NG2/Tests/ProxyRuntime/ProxyRuntimeControllerTests.swift b/ShadowsocksX-NG2/Tests/ProxyRuntime/ProxyRuntimeControllerTests.swift index 86948071..8d870ba7 100644 --- a/ShadowsocksX-NG2/Tests/ProxyRuntime/ProxyRuntimeControllerTests.swift +++ b/ShadowsocksX-NG2/Tests/ProxyRuntime/ProxyRuntimeControllerTests.swift @@ -12,7 +12,6 @@ class ProxyRuntimeControllerTests: XCTestCase { var runtime: ProxyRuntimeFixture.TemporaryRuntime! var catalogFileURL: URL! var activationFileURL: URL! - var credentials: InMemoryCredentialStore! var agent: ProxyRuntimeFixture.FakeLaunchAgent! var systemProxy: ProxyRuntimeFixture.FakeSystemProxy! var systemProxyHelper: ProxyRuntimeFixture.FakeSystemProxyHelperService! @@ -80,7 +79,7 @@ class ProxyRuntimeControllerTests: XCTestCase { runtime = ProxyRuntimeFixture.makeTemporaryRuntime() catalogFileURL = runtime.directory.appendingPathComponent("catalog.json") activationFileURL = runtime.directory.appendingPathComponent("activation.json") - credentials = InMemoryCredentialStore() + agent = ProxyRuntimeFixture.FakeLaunchAgent() systemProxy = ProxyRuntimeFixture.FakeSystemProxy() systemProxyHelper = ProxyRuntimeFixture.FakeSystemProxyHelperService() @@ -166,7 +165,6 @@ class ProxyRuntimeControllerTests: XCTestCase { catalogSnapshotReader: ProxyRuntimeFixture.catalogSnapshotReader(at: catalogFileURL), activationFileStore: ActivationStateFileStore(fileURL: activationFileURL), runtimeFileStore: runtimeFileStore, - credentials: credentials, plugins: ActivationFixture.plugins, listeningStore: listeningStore ?? InMemoryProxyListeningStore(), customRuleStore: customRuleStore diff --git a/ShadowsocksX-NG2/Tests/ProxyRuntime/RuntimeConvergenceTests.swift b/ShadowsocksX-NG2/Tests/ProxyRuntime/RuntimeConvergenceTests.swift index 438d277c..84a32243 100644 --- a/ShadowsocksX-NG2/Tests/ProxyRuntime/RuntimeConvergenceTests.swift +++ b/ShadowsocksX-NG2/Tests/ProxyRuntime/RuntimeConvergenceTests.swift @@ -130,7 +130,6 @@ struct RuntimeConvergenceTests { snapshots: BuiltinRuleSnapshots = BuiltinRuleSnapshots( loader: ProxyRuntimeFixture.controlFlowRuleSnapshot) ) throws { - let credentials = InMemoryCredentialStore() var catalog = ConfigurationCatalog() server = try ActivationFixture.addPlainServer("收敛测试", in: &catalog) let catalogURL = runtime.directory.appendingPathComponent("catalog.json") @@ -155,7 +154,7 @@ struct RuntimeConvergenceTests { catalogSnapshotReader: ProxyRuntimeFixture.catalogSnapshotReader(at: catalogURL), activationFileStore: ActivationStateFileStore( fileURL: runtime.directory.appendingPathComponent("activation.json")), - runtimeFileStore: runtimeStore, credentials: credentials, + runtimeFileStore: runtimeStore, plugins: ActivationFixture.plugins, listeningStore: settings, customRuleStore: CustomRuleStore( fileURL: runtime.directory.appendingPathComponent("rules.json")), @@ -181,7 +180,7 @@ struct RuntimeConvergenceTests { controlPreferences: makeTestProxyControlPreferences(), catalogSnapshotReader: controller.catalogSnapshotReader, activationFileStore: controller.activationFileStore, - runtimeFileStore: runtimeStore, credentials: InMemoryCredentialStore(), + runtimeFileStore: runtimeStore, plugins: ActivationFixture.plugins, listeningStore: settings, customRuleStore: CustomRuleStore( fileURL: runtime.directory.appendingPathComponent("rules.json")), diff --git a/ShadowsocksX-NG2/Tests/Rules/BuiltinRuleCatalogTests.swift b/ShadowsocksX-NG2/Tests/Rules/BuiltinRuleCatalogTests.swift index e2767384..98a8f6e3 100644 --- a/ShadowsocksX-NG2/Tests/Rules/BuiltinRuleCatalogTests.swift +++ b/ShadowsocksX-NG2/Tests/Rules/BuiltinRuleCatalogTests.swift @@ -183,23 +183,6 @@ final class BuiltinRuleCatalogTests: XCTestCase { } -/// 测试用内存凭据存储:不触 Keychain。 -final class EphemeralCredentialStore: CredentialStoring { - private var storage: [String: String] = [:] - - func secret(for reference: CredentialReference) throws -> String? { - storage[reference.rawValue] - } - - func save(_ secret: String, for reference: CredentialReference) throws { - storage[reference.rawValue] = secret - } - - func delete(_ reference: CredentialReference) throws { - storage[reference.rawValue] = nil - } -} - private final class SharedRuleSourceLoader: @unchecked Sendable { private let lock = NSLock() private var counts: [RulesSource: Int] = [:] diff --git a/ShadowsocksX-NG2/Tests/Runtime/RuntimePublicationTests.swift b/ShadowsocksX-NG2/Tests/Runtime/RuntimePublicationTests.swift index ccf44c15..a2445617 100644 --- a/ShadowsocksX-NG2/Tests/Runtime/RuntimePublicationTests.swift +++ b/ShadowsocksX-NG2/Tests/Runtime/RuntimePublicationTests.swift @@ -25,7 +25,7 @@ extension RuntimePublicationTests { var listen = ProxyListeningConfiguration(listenerMode: .allIPv4Interfaces) listen.lan.authenticationEnabled = true listen.lan.username = "sample-lan-user" - listen.lan.passwordReference = "sample-keychain-reference" + listen.lan.password = "sample-keychain-reference" let auth = RuntimeListenerAuthentication( username: listen.lan.username, password: "sample-lan-password", directory: runtime.directory.path) diff --git a/ShadowsocksX-NG2/Tests/Runtime/SeparateListenerTests.swift b/ShadowsocksX-NG2/Tests/Runtime/SeparateListenerTests.swift index 118c1608..f8132dad 100644 --- a/ShadowsocksX-NG2/Tests/Runtime/SeparateListenerTests.swift +++ b/ShadowsocksX-NG2/Tests/Runtime/SeparateListenerTests.swift @@ -4,6 +4,13 @@ import Testing @testable import ShadowsocksX_NG2 struct SeparateListenerTests { + @Test func disabledAuthenticationDoesNotPublishRetainedPassword() throws { + var listen = ProxyListeningConfiguration() + listen.lan.password = "PRIVATE-RETAINED-PASSWORD" + let document = SslocalRuntimeDocument(servers: [], listen: listen) + #expect(try document.jsonData().range(of: Data("PRIVATE-RETAINED-PASSWORD".utf8)) == nil) + } + @Test func terminalCommandsTreatPasswordAsUsableSecretWithoutReshaping() { var settings = ProxyListeningConfiguration(listenerMode: .allIPv4Interfaces) settings.lan.authenticationEnabled = true @@ -74,17 +81,17 @@ struct SeparateListenerTests { @Test func disabledIPv6ConfigurationRoundTripsWithoutAffectingEffectiveRuntime() throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } - let store = ProxyListeningFileStore( + let store = makeTestListeningStore( fileURL: directory.appendingPathComponent("proxy-listening.json")) var listen = ProxyListeningConfiguration() listen.lan.mode = .allIPv6Interfaces listen.lan.authenticationEnabled = true - listen.lan.passwordReference = "saved-reference" + listen.lan.password = "saved-reference" try store.save(listen) let restored = try store.load() #expect(!restored.lan.isEnabled) #expect(restored.lan.mode == .allIPv6Interfaces) - #expect(restored.lan.passwordReference == "saved-reference") + #expect(restored.lan.password == "saved-reference") let document = SslocalRuntimeDocument(servers: [], listen: restored) #expect(document.locals.count == 2) #expect(document.ipv6Only == nil) @@ -113,7 +120,7 @@ struct SeparateListenerTests { try AtomicFileWriter.write(oldData, to: oldURL) let newURL = directory.appendingPathComponent( ProxyListeningFileStore.defaultFileURL().lastPathComponent) - let store = ProxyListeningFileStore(fileURL: newURL) + let store = makeTestListeningStore(fileURL: newURL) #expect(try store.load() == ProxyListeningConfiguration()) try store.save(ProxyListeningConfiguration()) #expect(try Data(contentsOf: oldURL) == oldData) diff --git a/ShadowsocksX-NG2/Tests/Servers/ServerFormLoadingTests.swift b/ShadowsocksX-NG2/Tests/Servers/ServerFormLoadingTests.swift index cc6bd1ff..bad77f46 100644 --- a/ShadowsocksX-NG2/Tests/Servers/ServerFormLoadingTests.swift +++ b/ShadowsocksX-NG2/Tests/Servers/ServerFormLoadingTests.swift @@ -112,7 +112,7 @@ struct ServerFormLoadingTests { } @Test func presentationOmitsSecretsAndEditFormReadsCompleteRecord() async throws { - try await Self.withWorkflow(credentials: InMemoryCredentialStore()) { workflow in + try await Self.withWorkflow { workflow in let program = ManagedPluginCatalog.plugins[0].program let id = try await workflow.createServer( ServerEditDraft( @@ -132,8 +132,7 @@ struct ServerFormLoadingTests { @Test func successfulRefreshReloadsSecretChangesAndFailureDoesNotReload() async throws { let fetcher = FakeSubscriptionFetcher(behavior: .success(SubscriptionDocs.flat(serverCount: 1))) - let credentials = InMemoryCredentialStore() - try await Self.withWorkflow(credentials: credentials, fetcher: fetcher) { workflow in + try await Self.withWorkflow(fetcher: fetcher) { workflow in let summary = try await workflow.createSubscription(urlString: "https://example.com/sub.json") let server = try #require(workflow.tree.node(withID: summary.groupID)?.childNodes.first) let fields = ServerFormFields() @@ -170,7 +169,6 @@ struct ServerFormLoadingTests { } private static func withWorkflow( - credentials: CredentialStoring, fetcher: SubscriptionFetching = FakeSubscriptionFetcher(behavior: .success(Data())), _ body: (CatalogWorkflow) async throws -> Void ) async throws { @@ -207,7 +205,7 @@ extension ServerFormLoadingTests { } @Test func detailFactsOmitSecretsAndExplicitParameterReadPreservesRawValue() async throws { - try await Self.withWorkflow(credentials: InMemoryCredentialStore()) { workflow in + try await Self.withWorkflow { workflow in let program = ManagedPluginCatalog.plugins[0].program let id = try await workflow.createServer( ServerEditDraft( @@ -228,9 +226,6 @@ extension ServerFormLoadingTests { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let store = makeTestCatalogStore(fileURL: directory.appendingPathComponent("catalog.json")) - let credentials = FormCredentialStore() - let optionsRef = CredentialReference.fresh() - try credentials.save("opaque=one;opaque=two", for: optionsRef) var catalog = ConfigurationCatalog() let id = try catalog.addServer( ServerFields( @@ -240,13 +235,10 @@ extension ServerFormLoadingTests { try store.save(CatalogDocument(catalog: catalog)) let workflow = makeCatalogWorkflow( coordinator: CatalogCommitCoordinator(fileStore: store, runtime: FakeCatalogRuntime())) - credentials.reads = 0 #expect( workflow.serverDetailPresentation(for: id)?.plugin.selection == .unknown(program: "unknown-test-plugin")) - #expect(credentials.reads == 0) #expect(try workflow.serverDetailPluginOptions(for: id) == "opaque=one;opaque=two") - #expect(credentials.reads == 0) } @Test @@ -316,21 +308,3 @@ extension ServerFormLoadingTests { } } - -private final class FormCredentialStore: CredentialStoring { - let storage = InMemoryCredentialStore() - var reads = 0 - var failReads = false - - func secret(for reference: CredentialReference) throws -> String? { - reads += 1 - if failReads { throw CredentialStoreError.secretNotUTF8 } - return try storage.secret(for: reference) - } - - func save(_ secret: String, for reference: CredentialReference) throws { - try storage.save(secret, for: reference) - } - - func delete(_ reference: CredentialReference) throws { try storage.delete(reference) } -} diff --git a/ShadowsocksX-NG2/Tests/Servers/ServerNameWorkflowTests.swift b/ShadowsocksX-NG2/Tests/Servers/ServerNameWorkflowTests.swift index 36225e3f..0106e622 100644 --- a/ShadowsocksX-NG2/Tests/Servers/ServerNameWorkflowTests.swift +++ b/ShadowsocksX-NG2/Tests/Servers/ServerNameWorkflowTests.swift @@ -23,10 +23,9 @@ private struct ServerNameImportCase: Sendable { struct ServerNameWorkflowTests { @Test(arguments: ["", " \t\n ", "\u{3000}"]) func manualCreateAndEditRejectBlankNamesWithoutWrites(_ name: String) async throws { - try await withWorkflow { workflow, store, credentials in + try await withWorkflow { workflow, store in let id = try await workflow.createServer(Self.draft(name: "原名"), into: nil) let before = try store.load() - let secretsBefore = credentials.storageSnapshot let treeBefore = workflow.tree do { _ = try await workflow.createServer(Self.draft(name: name), into: nil) @@ -41,14 +40,13 @@ struct ServerNameWorkflowTests { #expect(error as? ServerFormError == .emptyName) } #expect(try store.load() == before) - #expect(credentials.storageSnapshot == secretsBefore) #expect(workflow.tree == treeBefore) } } @Test func manualNamesAreTrimmedAndGeneratedNamesSurviveEndpointEdits() async throws { - try await withWorkflow { workflow, store, _ in + try await withWorkflow { workflow, store in let created = try await workflow.createServer(Self.draft(name: " \t新名称\n "), into: nil) #expect(try workflow.serverEditForm(for: created)?.remark == "新名称") try await workflow.updateServer(created, draft: Self.draft(name: "\n编辑名称\t")) @@ -71,7 +69,7 @@ struct ServerNameWorkflowTests { @Test(arguments: ServerNameImportCase.all) fileprivate func uriImportsSaveNames(_ sample: ServerNameImportCase) async throws { - try await withWorkflow { workflow, store, _ in + try await withWorkflow { workflow, store in let uri = SsUri( method: "aes-256-gcm", password: "p", host: sample.host, port: sample.port, remark: sample.supplied) @@ -86,7 +84,7 @@ struct ServerNameWorkflowTests { @Test(arguments: ServerNameImportCase.all) fileprivate func legacyImportsSaveNames(_ sample: ServerNameImportCase) async throws { - try await withWorkflow { _, store, credentials in + try await withWorkflow { _, store in var profile: [String: Any] = [ "ServerHost": sample.host, "ServerPort": sample.port, "Method": "aes-256-gcm", "Password": "p", @@ -118,7 +116,7 @@ struct ServerNameWorkflowTests { @Test(arguments: ServerNameImportCase.all) fileprivate func subscriptionImportsSaveNames(_ sample: ServerNameImportCase) async throws { let fetcher = FakeSubscriptionFetcher(behavior: .success(try Self.document(sample))) - try await withWorkflow(fetcher: fetcher) { workflow, store, _ in + try await withWorkflow(fetcher: fetcher) { workflow, store in let subscription = try await workflow.createSubscription( urlString: "https://provider.example/s") let group = try #require(workflow.tree.node(withID: subscription.groupID)) @@ -133,7 +131,7 @@ struct ServerNameWorkflowTests { func subscriptionRenamesKeepIdentityAndRefreshesRegenerateMissingNames() async throws { let initial = ServerNameImportCase.all[0] let fetcher = FakeSubscriptionFetcher(behavior: .success(try Self.document(initial))) - try await withWorkflow(fetcher: fetcher) { workflow, _, _ in + try await withWorkflow(fetcher: fetcher) { workflow, _ in let subscription = try await workflow.createSubscription( urlString: "https://provider.example/s") let id = try #require(workflow.tree.node(withID: subscription.groupID)?.childNodes.first?.id) @@ -165,7 +163,7 @@ struct ServerNameWorkflowTests { behavior: .success( try Self.document( sample, plugin: "v2ray-plugin", pluginOptions: "mode=websocket"))) - try await withWorkflow(fetcher: fetcher) { workflow, _, credentials in + try await withWorkflow(fetcher: fetcher) { workflow, _ in let subscription = try await workflow.createSubscription( urlString: "https://provider.example/s") let id = try #require(workflow.tree.node(withID: subscription.groupID)?.childNodes.first?.id) @@ -191,12 +189,11 @@ struct ServerNameWorkflowTests { func duplicateEndpointsRejectRefreshAndPreserveSavedSnapshot() async throws { let sample = ServerNameImportCase.all[0] let fetcher = FakeSubscriptionFetcher(behavior: .success(try Self.document(sample))) - try await withWorkflow(fetcher: fetcher) { workflow, store, credentials in + try await withWorkflow(fetcher: fetcher) { workflow, store in let subscription = try await workflow.createSubscription( urlString: "https://provider.example/s") let treeBefore = workflow.tree let catalogBefore = try store.load().catalog - let secretsBefore = credentials.storageSnapshot let duplicate = Data( """ {"version":1,"servers":[ @@ -208,7 +205,6 @@ struct ServerNameWorkflowTests { await workflow.refreshSubscription(subscription.id) #expect(workflow.tree == treeBefore) #expect(try store.load().catalog == catalogBefore) - #expect(credentials.storageSnapshot == secretsBefore) guard case .failed(_, let failure) = workflow.subscriptions.first?.status else { Issue.record("重复端点应拒绝整个刷新") return @@ -287,16 +283,15 @@ extension ServerNameWorkflowTests { private func withWorkflow( fetcher: SubscriptionFetching = FakeSubscriptionFetcher(behavior: .success(Data())), _ body: - @MainActor (CatalogWorkflow, CatalogFileStore, InMemoryCredentialStore) async throws -> Void + @MainActor (CatalogWorkflow, CatalogFileStore) async throws -> Void ) async throws { let directory = FileManager.default.temporaryDirectory .appendingPathComponent("server-name-tests-\(UUID().uuidString)", isDirectory: true) defer { try? FileManager.default.removeItem(at: directory) } let store = makeTestCatalogStore(fileURL: directory.appendingPathComponent("catalog.json")) - let credentials = InMemoryCredentialStore() let coordinator = CatalogCommitCoordinator(fileStore: store, runtime: FakeCatalogRuntime()) let workflow = makeCatalogWorkflow( coordinator: coordinator, subscriptionFetcher: fetcher) - try await body(workflow, store, credentials) + try await body(workflow, store) } } diff --git a/ShadowsocksX-NG2/Tests/Settings/LANListeningWorkflowTests.swift b/ShadowsocksX-NG2/Tests/Settings/LANListeningWorkflowTests.swift index aa9b47fb..5aa91292 100644 --- a/ShadowsocksX-NG2/Tests/Settings/LANListeningWorkflowTests.swift +++ b/ShadowsocksX-NG2/Tests/Settings/LANListeningWorkflowTests.swift @@ -9,9 +9,8 @@ struct LANListeningWorkflowTests { @Test func disabledAuthenticationDoesNotGateLANSaveOnCredentialShape() async throws { let committing = FakeSettingsCommitter() let workflow = SettingsWorkflow( - committing: committing, occupancyProbe: FakeOccupancyProbe(), - credentials: InMemoryCredentialStore()) - var draft = try workflow.beginLANListeningEditing() + committing: committing, occupancyProbe: FakeOccupancyProbe()) + var draft = workflow.beginLANListeningEditing() draft.isEnabled = true draft.authenticationEnabled = false draft.username = "" @@ -24,9 +23,8 @@ struct LANListeningWorkflowTests { @Test func enabledAuthenticationGatesLANSaveOnCredentialShape() async throws { let workflow = SettingsWorkflow( - committing: FakeSettingsCommitter(), occupancyProbe: FakeOccupancyProbe(), - credentials: InMemoryCredentialStore()) - var draft = try workflow.beginLANListeningEditing() + committing: FakeSettingsCommitter(), occupancyProbe: FakeOccupancyProbe()) + var draft = workflow.beginLANListeningEditing() draft.isEnabled = true draft.authenticationEnabled = true draft.username = "user:name" @@ -40,10 +38,9 @@ struct LANListeningWorkflowTests { let committing = FakeSettingsCommitter() committing.committedSettings.listen.lan.authenticationEnabled = false committing.committedSettings.listen.lan.username = "" - committing.committedSettings.listen.lan.passwordReference = "kept" + committing.committedSettings.listen.lan.password = "kept" let workflow = SettingsWorkflow( - committing: committing, occupancyProbe: FakeOccupancyProbe(), - credentials: InMemoryCredentialStore()) + committing: committing, occupancyProbe: FakeOccupancyProbe()) var draft = workflow.beginPortSettingsEditing() draft.socksPort = 13001 workflow.refreshPortEditorOccupancy(for: draft) @@ -56,9 +53,8 @@ struct LANListeningWorkflowTests { @Test func togglingLANInvalidatesOccupancyWithoutChangingModeOrPorts() async throws { let probe = FakeOccupancyProbe(occupiedPorts: [11088]) let workflow = SettingsWorkflow( - committing: FakeSettingsCommitter(), occupancyProbe: probe, - credentials: InMemoryCredentialStore()) - var draft = try workflow.beginLANListeningEditing() + committing: FakeSettingsCommitter(), occupancyProbe: probe) + var draft = workflow.beginLANListeningEditing() draft.isEnabled = true workflow.refreshPortEditorOccupancy(for: draft.ports) await waitForPortEditorOccupancy(workflow, draft: draft.ports, port: .socks) @@ -83,9 +79,8 @@ struct LANListeningWorkflowTests { let committing = FakeSettingsCommitter() let probe = FakeOccupancyProbe() let workflow = SettingsWorkflow( - committing: committing, occupancyProbe: probe, - credentials: InMemoryCredentialStore()) - var draft = try workflow.beginLANListeningEditing() + committing: committing, occupancyProbe: probe) + var draft = workflow.beginLANListeningEditing() #expect(!draft.isEnabled) #expect(draft.mode == .allIPv4Interfaces) draft.isEnabled = true @@ -101,7 +96,7 @@ struct LANListeningWorkflowTests { #expect(await workflow.saveLANListening(draft) == .persisted) #expect(probe.callCount == probeCount) #expect(committing.committedSettings.listen.locals.count == 2) - var reopened = try workflow.beginLANListeningEditing() + var reopened = workflow.beginLANListeningEditing() #expect(!reopened.isEnabled) #expect(reopened.mode == .allIPv6Interfaces) #expect(reopened.ports.socksPort == 13001) @@ -118,16 +113,14 @@ struct LANListeningWorkflowTests { let committing = FakeSettingsCommitter() committing.committedSettings.listen.socksPort = 13001 let probe = FakeOccupancyProbe(occupiedPorts: [11088, 11089]) - let credentials = InMemoryCredentialStore() let workflow = SettingsWorkflow( - committing: committing, occupancyProbe: probe, - credentials: credentials) - let draft = try workflow.beginLANListeningEditing() + committing: committing, occupancyProbe: probe) + let draft = workflow.beginLANListeningEditing() #expect(draft.password.count == 10) #expect(await workflow.saveLANListening(draft) == .persisted) #expect(committing.committedSettings.listen.socksPort == 13001) #expect(probe.callCount == 0) - let reopened = try workflow.beginLANListeningEditing() + let reopened = workflow.beginLANListeningEditing() #expect(reopened.password == draft.password) } @@ -145,33 +138,29 @@ struct LANListeningWorkflowTests { #expect(committing.committedSettings.listen.socksPort == 13001) } - @Test func persistenceFailurePreservesPasswordAndDeletesTheUncommittedSecret() async throws { + @Test func persistenceFailurePreservesCompleteCommittedListeningConfiguration() async throws { let committing = FakeSettingsCommitter() - let credentials = InMemoryCredentialStore() let workflow = SettingsWorkflow( - committing: committing, occupancyProbe: FakeOccupancyProbe(), - credentials: credentials) - let original = try workflow.beginLANListeningEditing() + committing: committing, occupancyProbe: FakeOccupancyProbe()) + let original = workflow.beginLANListeningEditing() #expect(await workflow.saveLANListening(original) == .persisted) let saved = committing.committedSettings - var edited = try workflow.beginLANListeningEditing() + var edited = workflow.beginLANListeningEditing() edited.password = "replacement" committing.updateError = ProxyListeningStoreError.ioFailure(detail: "disk full") #expect( await workflow.saveLANListening(edited) == .commitFailed(.store(.ioFailure(detail: "disk full")))) #expect(committing.committedSettings == saved) - #expect(try workflow.beginLANListeningEditing().password == original.password) - #expect(credentials.storageCount == 1) + #expect(workflow.beginLANListeningEditing().password == original.password) } @Test func savingLANLocksTheEditorBeforeTCPProbing() async throws { let committing = FakeSettingsCommitter() let probe = GatedOccupancyProbe(gatedAnswer: .free, passThroughAnswer: .free) let workflow = SettingsWorkflow( - committing: committing, occupancyProbe: probe, - credentials: InMemoryCredentialStore()) - var draft = try workflow.beginLANListeningEditing() + committing: committing, occupancyProbe: probe) + var draft = workflow.beginLANListeningEditing() draft.isEnabled = true draft.mode = .allIPv4Interfaces let submission = Task { await workflow.saveLANListening(draft) } @@ -195,8 +184,8 @@ struct LANListeningWorkflowTests { let committing = FakeSettingsCommitter() let probe = FakeOccupancyProbe() let workflow = SettingsWorkflow( - committing: committing, occupancyProbe: probe, credentials: InMemoryCredentialStore()) - var draft = try workflow.beginLANListeningEditing() + committing: committing, occupancyProbe: probe) + var draft = workflow.beginLANListeningEditing() draft.isEnabled = true draft.mode = mode draft.ports.socksPort = 13001 @@ -226,8 +215,8 @@ struct LANListeningWorkflowTests { committing.committedSettings.listen.httpPort = 32769 let probe = FakeOccupancyProbe(occupiedPorts: [11088]) let workflow = SettingsWorkflow( - committing: committing, occupancyProbe: probe, credentials: InMemoryCredentialStore()) - var draft = try workflow.beginLANListeningEditing() + committing: committing, occupancyProbe: probe) + var draft = workflow.beginLANListeningEditing() draft.isEnabled = true draft.mode = .allIPv6Interfaces workflow.refreshPortEditorOccupancy(for: draft.ports) @@ -271,8 +260,8 @@ struct LANListeningWorkflowTests { let probe = FakeOccupancyProbe( occupiedPorts: [11088], occupierIPv6ProcessIDs: [123], occupiedFamilies: [.ipv4]) let workflow = SettingsWorkflow( - committing: committing, occupancyProbe: probe, credentials: InMemoryCredentialStore()) - var draft = try workflow.beginLANListeningEditing() + committing: committing, occupancyProbe: probe) + var draft = workflow.beginLANListeningEditing() draft.isEnabled = true draft.mode = .allIPv4Interfaces workflow.refreshPortEditorOccupancy(for: draft.ports) @@ -289,8 +278,8 @@ struct LANListeningWorkflowTests { committing.runtimeListenerProcessID = 123 let probe = FakeOccupancyProbe(occupiedPorts: [13001], occupierProcessIDs: [123]) let workflow = SettingsWorkflow( - committing: committing, occupancyProbe: probe, credentials: InMemoryCredentialStore()) - var draft = try workflow.beginLANListeningEditing() + committing: committing, occupancyProbe: probe) + var draft = workflow.beginLANListeningEditing() draft.ports.socksPort = 13001 workflow.refreshPortEditorOccupancy(for: draft.ports) await waitForPortEditorOccupancy(workflow, draft: draft.ports, port: .socks) diff --git a/ShadowsocksX-NG2/Tests/Settings/ListenerModeSettingsWorkflowTests.swift b/ShadowsocksX-NG2/Tests/Settings/ListenerModeSettingsWorkflowTests.swift index a8ccfca6..f1277854 100644 --- a/ShadowsocksX-NG2/Tests/Settings/ListenerModeSettingsWorkflowTests.swift +++ b/ShadowsocksX-NG2/Tests/Settings/ListenerModeSettingsWorkflowTests.swift @@ -8,7 +8,7 @@ extension SettingsWorkflowInterfaceTests { -> SettingsCommandOutcome { do { - var draft = try workflow.beginLANListeningEditing() + var draft = workflow.beginLANListeningEditing() draft.isEnabled = mode != nil if let mode { draft.mode = mode } return await workflow.saveLANListening(draft) @@ -109,8 +109,8 @@ extension SettingsWorkflowInterfaceTests { var expected = original expected.listen.listenerMode = .allIPv4AndIPv6Interfaces - expected.listen.lan.passwordReference = - committing.committedSettings.listen.lan.passwordReference + expected.listen.lan.password = + committing.committedSettings.listen.lan.password XCTAssertEqual(outcome, .persisted) XCTAssertEqual(committing.updateCalls, [expected]) XCTAssertEqual(committing.committedSettings, expected) diff --git a/ShadowsocksX-NG2/Tests/Settings/ListenerOccupancyLifecycleTests.swift b/ShadowsocksX-NG2/Tests/Settings/ListenerOccupancyLifecycleTests.swift index 11ca0aff..0bf8eb3f 100644 --- a/ShadowsocksX-NG2/Tests/Settings/ListenerOccupancyLifecycleTests.swift +++ b/ShadowsocksX-NG2/Tests/Settings/ListenerOccupancyLifecycleTests.swift @@ -101,10 +101,9 @@ struct ListenerOccupancyLifecycleTests { { let committing = FakeSettingsCommitter() let probe = GatedOccupancyProbe(gatedAnswer: .free, passThroughAnswer: .free) - let credentials = InMemoryCredentialStore() let workflow = SettingsWorkflow( - committing: committing, occupancyProbe: probe, credentials: credentials) - var draft = try workflow.beginLANListeningEditing() + committing: committing, occupancyProbe: probe) + var draft = workflow.beginLANListeningEditing() draft.isEnabled = true let submission = Task { await workflow.saveLANListening(draft) } await waitUntil(probe.entered > 0) @@ -122,14 +121,12 @@ struct ListenerOccupancyLifecycleTests { #expect(committing.committedSettings.proxyExceptions == "example.com") #expect(committing.committedSettings.listen.lan.isEnabled) #expect(committing.updateCalls.count == 1) - #expect(credentials.storageCount == 1) return } #expect(await submission.value == .rejected(.superseded)) #expect(!workflow.isCommitting) #expect(committing.updateCalls.isEmpty) - #expect(credentials.storageCount == 0) await waitUntil(workflow.canSaveLANListening(draft)) #expect(committing.updateCalls.isEmpty) } diff --git a/ShadowsocksX-NG2/Tests/Settings/ProxyListeningFileStoreTests.swift b/ShadowsocksX-NG2/Tests/Settings/ProxyListeningFileStoreTests.swift index 25e5a476..5b4e9be2 100644 --- a/ShadowsocksX-NG2/Tests/Settings/ProxyListeningFileStoreTests.swift +++ b/ShadowsocksX-NG2/Tests/Settings/ProxyListeningFileStoreTests.swift @@ -12,8 +12,8 @@ final class ProxyListeningFileStoreTests: XCTestCase { try super.setUpWithError() directory = FileManager.default.temporaryDirectory .appendingPathComponent("ssxng-settings-tests-\(UUID().uuidString)", isDirectory: true) - store = ProxyListeningFileStore( - fileURL: directory.appendingPathComponent("proxy-listening.json")) + store = makeTestListeningStore( + fileURL: directory.appendingPathComponent("proxy-listening.enc")) } override func tearDownWithError() throws { @@ -21,17 +21,12 @@ final class ProxyListeningFileStoreTests: XCTestCase { try super.tearDownWithError() } - private func writeRaw(_ text: String) throws { - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - try Data(text.utf8).write(to: store.fileURL) - } - private func makeWriteFailingStore() throws -> ProxyListeningFileStore { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) let blockedParent = directory.appendingPathComponent("blocked") try Data("not a directory".utf8).write(to: blockedParent) - return ProxyListeningFileStore( - fileURL: blockedParent.appendingPathComponent("proxy-listening.json")) + return makeTestListeningStore( + fileURL: blockedParent.appendingPathComponent("proxy-listening.enc")) } func testRoundTripKeepsListeningConfiguration() throws { @@ -43,22 +38,12 @@ final class ProxyListeningFileStoreTests: XCTestCase { try store.save(settings) XCTAssertEqual(try store.load(), settings) - let raw = try String(contentsOf: store.fileURL, encoding: .utf8) - XCTAssertTrue(raw.contains("udpRelayEnabled")) - XCTAssertFalse(raw.contains("timeoutSeconds")) - XCTAssertFalse(raw.contains("verboseLogging")) - XCTAssertFalse(raw.contains("advertisedAddress")) - XCTAssertTrue(raw.contains("all_ipv6_interfaces")) XCTAssertEqual(try store.load().mode, "tcp_and_udp") } - func testListeningFileDoesNotOwnIndependentPreferences() throws { - try store.save(ProxyListeningConfiguration()) - let data = try Data(contentsOf: store.fileURL) - let record = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) - XCTAssertEqual(Set(record.keys), ["localListen", "lanListen"]) + func testEncryptedListeningHasItsOwnFile() { XCTAssertEqual( - ProxyListeningFileStore.defaultFileURL().lastPathComponent, "proxy-listening.json") + ProxyListeningFileStore.defaultFileURL().lastPathComponent, "proxy-listening.enc") } func testEveryListenerModeRoundTripsWithoutAnAdvertisedAddress() throws { @@ -66,8 +51,6 @@ final class ProxyListeningFileStoreTests: XCTestCase { try store.save(ProxyListeningConfiguration(listenerMode: mode)) XCTAssertEqual(try store.load().listenerMode, mode) - let raw = try String(contentsOf: store.fileURL, encoding: .utf8) - XCTAssertFalse(raw.contains("advertisedAddress")) } } @@ -83,12 +66,6 @@ final class ProxyListeningFileStoreTests: XCTestCase { XCTAssertEqual(loaded.httpPort, 1087) } - func testMissingPortFieldsUseNewFactoryDefaults() throws { - try writeRaw("{}") - - XCTAssertEqual(try store.load(), ProxyListeningConfiguration()) - } - func testInvalidSaveDoesNotTouchExistingSettings() throws { try store.save(ProxyListeningConfiguration()) var invalid = ProxyListeningConfiguration() diff --git a/ShadowsocksX-NG2/Tests/Settings/ProxyListeningStartupTests.swift b/ShadowsocksX-NG2/Tests/Settings/ProxyListeningStartupTests.swift new file mode 100644 index 00000000..74dba566 --- /dev/null +++ b/ShadowsocksX-NG2/Tests/Settings/ProxyListeningStartupTests.swift @@ -0,0 +1,52 @@ +import Foundation +import Testing + +@testable import ShadowsocksX_NG2 + +@MainActor +struct ProxyListeningStartupTests { + @Test func unsupportedListeningFileBlocksStartupUntilResetIsConfirmed() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let oldURL = directory.appendingPathComponent("proxy-listening.json") + let old = Data("old-listening-configuration".utf8) + try old.write(to: oldURL) + let keys = TestLibraryKeys() + let store = ProxyListeningFileStore( + fileURL: directory.appendingPathComponent("proxy-listening.enc"), keys: keys) + var prompts = 0 + let configuration = ProxyListeningStartup.load(store: store) { error in + prompts += 1 + #expect(error == .encryptedFile(.unsupportedVersion)) + #expect(keys.values.isEmpty) + #expect(!FileManager.default.fileExists(atPath: store.fileURL.path)) + } + #expect(prompts == 1) + #expect(configuration == ProxyListeningConfiguration()) + #expect(!configuration.lan.isEnabled) + #expect(try store.load() == configuration) + #expect(try Data(contentsOf: oldURL) == old) + } + @Test func failedResetKeepsOriginalFileAndRequestsConfirmationAgain() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let keys = TestLibraryKeys() + let file = directory.appendingPathComponent("proxy-listening.enc") + let store = ProxyListeningFileStore(fileURL: file, keys: keys) + _ = try store.load() + let original = try Data(contentsOf: file) + keys.values.removeAll() + keys.createFails = true + var prompts = 0 + let restarted = ProxyListeningFileStore(fileURL: file, keys: keys) + let configuration = ProxyListeningStartup.load(store: restarted) { _ in + prompts += 1 + #expect((try? Data(contentsOf: file)) == original) + if prompts >= 2 { keys.createFails = false } + } + #expect(prompts == 2) + #expect(configuration == ProxyListeningConfiguration()) + #expect(try ProxyListeningFileStore(fileURL: file, keys: keys).load() == configuration) + } +} diff --git a/ShadowsocksX-NG2/Tests/Settings/ProxyListeningStoreRecoveryTests.swift b/ShadowsocksX-NG2/Tests/Settings/ProxyListeningStoreRecoveryTests.swift index df1e2ca1..60736747 100644 --- a/ShadowsocksX-NG2/Tests/Settings/ProxyListeningStoreRecoveryTests.swift +++ b/ShadowsocksX-NG2/Tests/Settings/ProxyListeningStoreRecoveryTests.swift @@ -3,101 +3,76 @@ import Testing @testable import ShadowsocksX_NG2 -/// 偏好持久化读侧失败分类:损坏、I/O 失败与无效端口不得静默回落出厂端口, -/// `restored()` 必须携带点名错误(「任何路径不静默改端口」)。 -struct ProxyRuntimeSettingsStoreRecoveryTests { - private func withStore( - _ body: (_ store: ProxyListeningFileStore, _ directory: URL) throws -> Void - ) throws { - let directory = FileManager.default.temporaryDirectory - .appendingPathComponent("ssxng-settings-recovery-\(UUID().uuidString)", isDirectory: true) +struct ProxyListeningStoreRecoveryTests { + @Test func completeListeningIdentitySurvivesRestartWithoutPlaintextOnDisk() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } + let file = directory.appendingPathComponent("proxy-listening.enc") + let keys = TestLibraryKeys() + let store = ProxyListeningFileStore(fileURL: file, keys: keys) + var configuration = try store.load() + configuration.lan.username = "PRIVATE-LAN-USERNAME" + configuration.lan.password = "PRIVATE-LAN-PASSWORD" + try store.save(configuration) + let data = try Data(contentsOf: file) + for secret in ["PRIVATE-LAN-USERNAME", "PRIVATE-LAN-PASSWORD"] { + #expect(data.range(of: Data(secret.utf8)) == nil) + } + #expect(try ProxyListeningFileStore(fileURL: file, keys: keys).load() == configuration) + } + + @Test func unavailableKeyBlocksOrdinarySaveAndResetCreatesIndependentDefaults() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let keys = TestLibraryKeys() + let file = directory.appendingPathComponent("proxy-listening.enc") + let store = ProxyListeningFileStore(fileURL: file, keys: keys) + var configuration = try store.load() + configuration.lan.isEnabled = true + configuration.lan.authenticationEnabled = true + configuration.lan.password = "secret" + try store.save(configuration) + let original = try Data(contentsOf: file) + keys.values.removeAll() + let restarted = ProxyListeningFileStore(fileURL: file, keys: keys) + #expect(throws: ProxyListeningStoreError.encryptedFile(.keyUnavailable)) { + try restarted.load() + } + #expect(throws: ProxyListeningStoreError.encryptedFile(.keyUnavailable)) { + try restarted.save(ProxyListeningConfiguration()) + } + #expect(try Data(contentsOf: file) == original) + keys.createFails = true + #expect(throws: (any Error).self) { try restarted.reset() } + #expect(try Data(contentsOf: file) == original) + keys.createFails = false + #expect(try restarted.reset() == ProxyListeningConfiguration()) + #expect( + try ProxyListeningFileStore(fileURL: file, keys: keys).load() == ProxyListeningConfiguration() + ) + } + + @Test func resettingOneStoreDoesNotInvalidateTheOtherStore() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let keys = TestLibraryKeys() + let library = CatalogFileStore( + fileURL: directory.appendingPathComponent("catalog.enc"), + library: ServerLibraryStorage(keys: keys)) let store = ProxyListeningFileStore( - fileURL: directory.appendingPathComponent("proxy-listening.json")) - try body(store, directory) - } - - @Test - func loadReportsCorruptFileInsteadOfSilentlyRewritingPorts() throws { - try withStore { store, directory in - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - try Data("not json".utf8).write(to: store.fileURL) - - do { - _ = try store.load() - Issue.record("损坏文件应抛出 corrupt") - } catch { - guard case .corrupt = error as? ProxyListeningStoreError else { - Issue.record("应为 corrupt,实际 \(error)") - return - } - } - } - } - - @Test - func loadDistinguishesReadFailureFromCorruption() throws { - try withStore { store, directory in - // proxy-listening.json 本身是一个目录:存在性检查通过,读取失败 → ioFailure。 - try FileManager.default.createDirectory( - at: store.fileURL, withIntermediateDirectories: true) - - do { - _ = try store.load() - Issue.record("读取失败应抛出 ioFailure") - } catch { - guard case .ioFailure = error as? ProxyListeningStoreError else { - Issue.record("应为 ioFailure,实际 \(error)") - return - } - } - } - } - - @Test - func loadRejectsPersistedDuplicatePorts() throws { - try withStore { store, directory in - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - try Data( - """ - {"localListen":{"socksPort":1086,"httpPort":1086}} - """.utf8 - ).write(to: store.fileURL) - - do { - _ = try store.load() - Issue.record("重复端口应抛出 invalid") - } catch { - #expect( - error as? ProxyListeningStoreError - == .invalid([.duplicatePort(endpoint: .socks, otherEndpoint: .http, port: 1086)])) - } - } - } - - @Test - func restoredCarriesNamedErrorAndFactoryDefaults() throws { - try withStore { store, directory in - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - try Data("not json".utf8).write(to: store.fileURL) - - let restored = ProxyListeningFileStore.restored(store: store) - - #expect(restored.configuration == ProxyListeningConfiguration()) - guard case .corrupt = restored.unreadableError else { - Issue.record("restored 必须携带 corrupt 错误,实际 \(String(describing: restored.unreadableError))") - return - } - } - } - - @Test - func restoredOnMissingFileIsFactoryDefaultsWithoutError() throws { - try withStore { store, _ in - let restored = ProxyListeningFileStore.restored(store: store) - - #expect(restored.configuration == ProxyListeningConfiguration()) - #expect(restored.unreadableError == nil) - } + fileURL: directory.appendingPathComponent("proxy-listening.enc"), keys: keys) + _ = try library.load() + var configuration = try store.load() + configuration.local.socksPort = 12000 + configuration.lan.password = "retained-password" + try store.save(configuration) + _ = try library.reset() + #expect(try ProxyListeningFileStore(fileURL: store.fileURL, keys: keys).load() == configuration) + let libraryBytes = try Data(contentsOf: library.fileURL) + _ = try store.reset() + #expect(try Data(contentsOf: library.fileURL) == libraryBytes) + #expect( + try CatalogFileStore(fileURL: library.fileURL, library: ServerLibraryStorage(keys: keys)) + .load().catalog.isEmpty) } } diff --git a/ShadowsocksX-NG2/Tests/Settings/SettingsWorkflowInterfaceTests.swift b/ShadowsocksX-NG2/Tests/Settings/SettingsWorkflowInterfaceTests.swift index c7eabdc5..00c231f2 100644 --- a/ShadowsocksX-NG2/Tests/Settings/SettingsWorkflowInterfaceTests.swift +++ b/ShadowsocksX-NG2/Tests/Settings/SettingsWorkflowInterfaceTests.swift @@ -17,7 +17,7 @@ final class SettingsWorkflowInterfaceTests: XCTestCase { func makeWorkflow() -> SettingsWorkflow { SettingsWorkflow( - committing: committing, occupancyProbe: probe, credentials: InMemoryCredentialStore()) + committing: committing, occupancyProbe: probe) } func testPortEditorMapsValidationIssuesToTheAffectedFields() { diff --git a/ShadowsocksX-NG2/Tests/Settings/SettingsWorkflowTests.swift b/ShadowsocksX-NG2/Tests/Settings/SettingsWorkflowTests.swift index 8e01028b..35531e07 100644 --- a/ShadowsocksX-NG2/Tests/Settings/SettingsWorkflowTests.swift +++ b/ShadowsocksX-NG2/Tests/Settings/SettingsWorkflowTests.swift @@ -8,7 +8,6 @@ final class SettingsWorkflowTests: XCTestCase { private var runtime: ProxyRuntimeFixture.TemporaryRuntime! private var catalogFileURL: URL! private var activationFileURL: URL! - private var credentials: InMemoryCredentialStore! private var agent: ProxyRuntimeFixture.FakeLaunchAgent! private var systemProxy: ProxyRuntimeFixture.FakeSystemProxy! private var listeningStore: InMemorySettingsStore! @@ -18,7 +17,7 @@ final class SettingsWorkflowTests: XCTestCase { runtime = ProxyRuntimeFixture.makeTemporaryRuntime() catalogFileURL = runtime.directory.appendingPathComponent("catalog.json") activationFileURL = runtime.directory.appendingPathComponent("activation.json") - credentials = InMemoryCredentialStore() + agent = ProxyRuntimeFixture.FakeLaunchAgent() systemProxy = ProxyRuntimeFixture.FakeSystemProxy() listeningStore = InMemorySettingsStore() @@ -32,7 +31,7 @@ final class SettingsWorkflowTests: XCTestCase { func testListenerModeSavePersistsWithoutStartingAnOffAgent() async throws { let pair = makePair() - var draft = try pair.workflow.beginLANListeningEditing() + var draft = pair.workflow.beginLANListeningEditing() draft.isEnabled = true draft.mode = .allIPv4AndIPv6Interfaces let outcome = await pair.workflow.saveLANListening(draft) @@ -58,7 +57,6 @@ final class SettingsWorkflowTests: XCTestCase { catalogSnapshotReader: catalogSnapshotReader, activationFileStore: ActivationStateFileStore(fileURL: activationFileURL), runtimeFileStore: runtimeFileStore, - credentials: credentials, plugins: ActivationFixture.plugins, listeningStore: listeningStore, appBundle: AppArtifact.bundle, @@ -74,7 +72,7 @@ final class SettingsWorkflowTests: XCTestCase { sendSignal: { _, _ in 0 }, processIsAlive: { $0 == 42 }) let workflow = SettingsWorkflow( - committing: controller, occupancyProbe: FakeOccupancyProbe(), credentials: credentials) + committing: controller, occupancyProbe: FakeOccupancyProbe()) return (controller, workflow) } diff --git a/ShadowsocksX-NG2/Tests/Settings/UnreadableSettingsTests.swift b/ShadowsocksX-NG2/Tests/Settings/UnreadableSettingsTests.swift index 4b00fc22..6790ac1d 100644 --- a/ShadowsocksX-NG2/Tests/Settings/UnreadableSettingsTests.swift +++ b/ShadowsocksX-NG2/Tests/Settings/UnreadableSettingsTests.swift @@ -4,7 +4,7 @@ import Testing @testable import ShadowsocksX_NG2 /// 偏好设置不可读时的控制器行为(「任何路径不静默改端口」): -/// 占位出厂端口不得部署——停止运行时并点名呈现启动失败。 +/// 占位出厂端口不得部署,已有后台代理与系统代理保持原状。 @MainActor struct UnreadableSettingsTests { private struct Fixture { @@ -22,7 +22,6 @@ struct UnreadableSettingsTests { let runtime = ProxyRuntimeFixture.makeTemporaryRuntime() let catalogFileURL = runtime.directory.appendingPathComponent("catalog.json") let activationFileURL = runtime.directory.appendingPathComponent("activation.json") - let credentials = InMemoryCredentialStore() let agent = ProxyRuntimeFixture.FakeLaunchAgent() let systemProxy = ProxyRuntimeFixture.FakeSystemProxy() agent.setStatus(agentStatus) @@ -40,7 +39,6 @@ struct UnreadableSettingsTests { catalogSnapshotReader: catalogSnapshotReader, activationFileStore: ActivationStateFileStore(fileURL: activationFileURL), runtimeFileStore: RuntimeFileStore(fileURL: runtime.contract), - credentials: credentials, plugins: ActivationFixture.plugins, appBundle: AppArtifact.bundle, settingsRestore: settingsRestore, @@ -82,7 +80,7 @@ struct UnreadableSettingsTests { } @Test - func resyncWithUnreadableSettingsStopsRunningAgentInsteadOfRedeploying() async throws { + func resyncWithUnreadableSettingsPreservesRunningAgentAndContract() async throws { let fixture = try makeFixture( agentStatus: .registered, settingsRestore: RestoredProxyRuntimeSettings( @@ -106,8 +104,8 @@ struct UnreadableSettingsTests { AppPresentation.message(for: RuntimeFailureFacts.launch(.unreadableSettings)) .contains("配置无法读取")) #expect( - fixture.agent.unregisterCount == 1, "存续的上一会话 agent 必须停下,不得以占位端口重部署") - #expect(!FileManager.default.fileExists(atPath: fixture.runtime.contract.path)) + fixture.agent.unregisterCount == 0, "读取失败不得停止已有后台代理") + #expect(RuntimeFileStore(fileURL: fixture.runtime.contract).loadDocument() != nil) #expect(fixture.systemProxy.applied.isEmpty) #expect(fixture.controller.effectiveRuntimeListenFacts == nil) } diff --git a/ShadowsocksX-NG2/Tests/Subscription/SubscriptionInformationWorkflowTests.swift b/ShadowsocksX-NG2/Tests/Subscription/SubscriptionInformationWorkflowTests.swift index 04e04075..0b675f05 100644 --- a/ShadowsocksX-NG2/Tests/Subscription/SubscriptionInformationWorkflowTests.swift +++ b/ShadowsocksX-NG2/Tests/Subscription/SubscriptionInformationWorkflowTests.swift @@ -7,7 +7,7 @@ import Testing @MainActor struct SubscriptionInformationWorkflowTests { @Test func successfulInformationSurvivesFailureAndReload() async throws { - try await withWorkflow { workflow, store, credentials, fetcher in + try await withWorkflow { workflow, store, fetcher in let summary = try await workflow.createSubscription(urlString: "https://p.example.com/sub") #expect(summary.information?.bytesUsed == 25) #expect(summary.information?.bytesRemaining == 75) @@ -30,7 +30,7 @@ struct SubscriptionInformationWorkflowTests { } @Test func successfulRefreshReplacesMissingAndInvalidFields() async throws { - try await withWorkflow { workflow, _, _, fetcher in + try await withWorkflow { workflow, _, fetcher in let first = try await workflow.createSubscription(urlString: "https://p.example.com/sub") fetcher.setBehavior( .success( @@ -57,7 +57,7 @@ struct SubscriptionInformationWorkflowTests { } @Test func cancelledRefreshCannotCommitEvenIfFetcherReturnsData() async throws { - try await withWorkflow { workflow, store, credentials, _ in + try await withWorkflow { workflow, store, _ in let first = try await workflow.createSubscription(urlString: "https://p.example.com/sub") let bytes = try Data(contentsOf: store.fileURL) let fetcher = InformationGateFetcher() @@ -75,7 +75,7 @@ struct SubscriptionInformationWorkflowTests { } @Test func persistenceFailureDoesNotPublishNewInformation() async throws { - try await withWorkflow { workflow, store, _, fetcher in + try await withWorkflow { workflow, store, fetcher in let first = try await workflow.createSubscription(urlString: "https://p.example.com/sub") let tree = workflow.tree // 只破坏本夹具的文件路径,让原子替换失败,不影响其他 runner。 @@ -91,7 +91,7 @@ struct SubscriptionInformationWorkflowTests { } @Test func encryptedDocumentHidesSecretsAndRetainsOnlyAllowlistedInformation() async throws { - try await withWorkflow { workflow, store, credentials, fetcher in + try await withWorkflow { workflow, store, fetcher in fetcher.setBehavior( .success( Data( @@ -123,7 +123,7 @@ struct SubscriptionInformationWorkflowTests { } @Test func oldDocumentWithoutInformationOrSuccessfulTimeLoadsWithoutGuessing() async throws { - try await withWorkflow { workflow, store, credentials, _ in + try await withWorkflow { workflow, store, _ in _ = try await workflow.createSubscription(urlString: "https://p.example.com/sub") var document = try store.load() document.subscriptions[0].information = nil @@ -140,21 +140,20 @@ struct SubscriptionInformationWorkflowTests { private func withWorkflow( _ body: @MainActor ( - CatalogWorkflow, CatalogFileStore, InMemoryCredentialStore, FakeSubscriptionFetcher + CatalogWorkflow, CatalogFileStore, FakeSubscriptionFetcher ) async throws -> Void ) async throws { let directory = FileManager.default.temporaryDirectory .appendingPathComponent("subscription-information-\(UUID().uuidString)", isDirectory: true) defer { try? FileManager.default.removeItem(at: directory) } let store = makeTestCatalogStore(fileURL: directory.appendingPathComponent("catalog.json")) - let credentials = InMemoryCredentialStore() let fetcher = FakeSubscriptionFetcher( behavior: .success( Data(#"{"version":1,"servers":[],"bytes_used":25,"bytes_remaining":75}"#.utf8))) let workflow = makeCatalogWorkflow( coordinator: CatalogCommitCoordinator(fileStore: store, runtime: FakeCatalogRuntime()), subscriptionFetcher: fetcher) - try await body(workflow, store, credentials, fetcher) + try await body(workflow, store, fetcher) } } diff --git a/ShadowsocksX-NG2/Tests/Subscription/SubscriptionRefreshFeedbackTests.swift b/ShadowsocksX-NG2/Tests/Subscription/SubscriptionRefreshFeedbackTests.swift index 2e6b6f8d..81c6e021 100644 --- a/ShadowsocksX-NG2/Tests/Subscription/SubscriptionRefreshFeedbackTests.swift +++ b/ShadowsocksX-NG2/Tests/Subscription/SubscriptionRefreshFeedbackTests.swift @@ -80,7 +80,6 @@ struct SubscriptionRefreshFeedbackTests { @MainActor private final class Fixture { let directory: URL - let credentials = InMemoryCredentialStore() let fetcher = FakeSubscriptionFetcher(behavior: .success(SubscriptionDocs.flat(serverCount: 1))) let workflow: CatalogWorkflow diff --git a/ShadowsocksX-NG2/Tests/Support/CatalogFixtures.swift b/ShadowsocksX-NG2/Tests/Support/CatalogFixtures.swift index 9e8f9578..274d35e4 100644 --- a/ShadowsocksX-NG2/Tests/Support/CatalogFixtures.swift +++ b/ShadowsocksX-NG2/Tests/Support/CatalogFixtures.swift @@ -3,28 +3,6 @@ import XCTest @testable import ShadowsocksX_NG2 -/// 测试用内存凭据存储;契约与 Keychain 实现一致。 -final class InMemoryCredentialStore: CredentialStoring { - private var storage: [String: String] = [:] - - var storageCount: Int { storage.count } - - /// 全量键值快照(凭据生命周期观察:删除/回滚后应有值消失或恢复)。 - var storageSnapshot: [String: String] { storage } - - func save(_ secret: String, for reference: CredentialReference) throws { - storage[reference.rawValue] = secret - } - - func secret(for reference: CredentialReference) throws -> String? { - storage[reference.rawValue] - } - - func delete(_ reference: CredentialReference) throws { - storage.removeValue(forKey: reference.rawValue) - } -} - /// 订阅子树夹具:固定分组 + 嵌套分组 + 两个服务器叶子。 struct SubscriptionFixture { let catalog: ConfigurationCatalog @@ -206,7 +184,7 @@ func makeTestCatalogStore( return store } -private final class FixtureLibraryKeys: LibraryMasterKeyStoring { +private final class FixtureLibraryKeys: MasterKeyStoring { private static let lock = NSLock() nonisolated(unsafe) private static var values: [UUID: Data] = [:] @@ -217,3 +195,7 @@ private final class FixtureLibraryKeys: LibraryMasterKeyStoring { Self.lock.withLock { Self.values[id] = data } } } + +func makeTestListeningStore(fileURL: URL) -> ProxyListeningFileStore { + ProxyListeningFileStore(fileURL: fileURL, keys: FixtureLibraryKeys()) +} diff --git a/ShadowsocksX-NG2/Tests/Support/DiagnosticsWorkflowFixture.swift b/ShadowsocksX-NG2/Tests/Support/DiagnosticsWorkflowFixture.swift index fc5d21a2..33847c4a 100644 --- a/ShadowsocksX-NG2/Tests/Support/DiagnosticsWorkflowFixture.swift +++ b/ShadowsocksX-NG2/Tests/Support/DiagnosticsWorkflowFixture.swift @@ -161,7 +161,7 @@ class DiagnosticsWorkflowTestCase: XCTestCase { final class FakeRuntimeFacts: ProxyRuntimeDiagnosticFacts { var proxyState: DiagnosticProxyState = .off var hasActiveTarget = false - var listen = ProxyListeningConfiguration() + var listen = RuntimeListenFacts(listen: ProxyListeningConfiguration()) var contractSummary: String? var customRuleSummary: CustomRuleSummary? } diff --git a/docs/adr/0034-separate-local-and-lan-listeners.md b/docs/adr/0034-separate-local-and-lan-listeners.md index 7220a8e2..8d55fd85 100644 --- a/docs/adr/0034-separate-local-and-lan-listeners.md +++ b/docs/adr/0034-separate-local-and-lan-listeners.md @@ -1,5 +1,7 @@ # 分离本地监听与局域网监听 +后续修订:监听认证的持久化与读取失败重置方向见 [ADR-0042](0042-encrypt-proxy-listening-configuration.md);该修订已实施。 + **状态**:已接受。 2026-10-13 修订:局域网启用状态与监听模式独立。 diff --git a/docs/adr/0035-encrypt-persistent-server-library.md b/docs/adr/0035-encrypt-persistent-server-library.md index a6c1ce16..3c656df2 100644 --- a/docs/adr/0035-encrypt-persistent-server-library.md +++ b/docs/adr/0035-encrypt-persistent-server-library.md @@ -1,5 +1,7 @@ # 持久服务器库整体加密与主密钥恢复边界 +后续修订:范围外的监听配置采用独立主密钥整体加密,见 [ADR-0042](0042-encrypt-proxy-listening-configuration.md);该修订已实施,服务器库自身的恢复规则保持不变。 + Status: Accepted(2026-10-18)。 实施状态:整体加密、记录直接携带秘密及移除 NG2 历史格式兼容均已实施。当前实现以代码为准,验证结果见对应交付记录。 diff --git a/docs/adr/0041-separate-preferences-from-proxy-listening-configuration.md b/docs/adr/0041-separate-preferences-from-proxy-listening-configuration.md index bebc39d0..a4f622b4 100644 --- a/docs/adr/0041-separate-preferences-from-proxy-listening-configuration.md +++ b/docs/adr/0041-separate-preferences-from-proxy-listening-configuration.md @@ -1,5 +1,7 @@ # 分离独立偏好,将 JSON 限定为代理监听配置 +后续修订:监听配置整体加密与确认重置方向见 [ADR-0042](0042-encrypt-proxy-listening-configuration.md);该修订已实施,本 ADR 的存储范围分离继续有效。 + **状态**:已接受并实施(2026-10-19)。 当前 `settings.json` 同时保存监听配置和独立偏好,模式与开关等修改需要重写整份快照。将独立偏好直接分离,以缩小文件存储 module 的职责;不另建通用设置仓库,也不为原整快照保存路径专门建设集中管理 module。 diff --git a/docs/adr/0042-encrypt-proxy-listening-configuration.md b/docs/adr/0042-encrypt-proxy-listening-configuration.md new file mode 100644 index 00000000..a6545b42 --- /dev/null +++ b/docs/adr/0042-encrypt-proxy-listening-configuration.md @@ -0,0 +1,31 @@ +# 代理监听配置整体加密与确认重置 + +**状态**:已接受并实施(2026-10-19)。 + +代理监听配置改用与服务器库相同的主密钥加密方法,但保留独立文件和独立主密钥。复用 AES-256-GCM 加密与经典 Keychain 主密钥存储的实现,不共用服务器库当前的实际密钥。服务器库重置不改变监听配置及其密钥,监听配置重置也不改变服务器库。 + +用户名和密码随所属监听配置整体加密保存。移除逐项凭据存储、密码引用及跨存储提交补偿;保留完整配置校验、原子保存、保存失败不发布,以及保存成功后再执行运行状态收敛的契约。运行配置准备、编辑与终端命令从已解密内存读取所需值;状态投影、日志与诊断不得因此携带密码。 + +## 读取失败与重置 + +读取失败时保留原文件并阻断正常启动,不进入可操作的工作区,也不允许普通监听保存覆盖原配置。显示说明性错误 alert,只提供“重置监听配置”选项,不提供取消或退出选项。提示重置将放弃原监听配置,包括端口和局域网认证资料,并关闭局域网监听。不要求判断密钥是否永久丢失,不提供找回密钥或人工删除文件的恢复分支。 + +静默启动遇到相同错误时也直接显示该 alert,不等待用户打开主窗口。正常启动的静默行为保持不变。 + +只有用户明确确认重置后,才创建独立的新主密钥并原子保存默认监听配置。保存成功后发布默认值并解除监听配置读取失败的运行阻断。保存失败则保留原文件,不发布默认值,继续报告错误。默认配置关闭局域网监听,避免保留原共享入口却移除身份验证。 + +读取失败、等待确认及重置失败期间,保留已有后台代理和系统代理,不执行停止、重部署或系统代理修改。GUI 无法读取监听配置,不代表后台代理已经使用的独立运行配置失效;正常启动的运行状态收敛必须等待监听配置可用。重置成功后,再按默认监听配置、现有活动目标和已保存的代理服务与系统代理开关意图执行正常收敛,不重置这些独立意图。 + +不新增恢复页面、恢复向导或复杂的错误恢复状态机。相较于要求用户定位并删除配置文件,错误 alert 中的确认重置能以有限交互成本降低恢复成本。这是对 ADR-0040 人工恢复取舍的局部修订。 + +启动组合根在装配其他应用 module 前完成监听配置读取或确认重置。读取失败不会进入工作区或启动正常运行状态收敛;普通保存也不能绕过读取失败门禁。 + +## 取舍与适用范围 + +独立密钥使两个存储 module 保持各自的生命周期与恢复规则。共用实际密钥虽然减少一条 Keychain 记录,却要求协调两份密文的首次创建、读取和重置,收益不足。 + +不迁移旧监听 JSON,也不读取或清理旧逐项 Keychain 凭据。检测到旧监听文件时报告格式不支持,由用户明确确认后重置,不静默忽略旧文件并使用默认值。 + +本决定修订 ADR-0034、ADR-0035、ADR-0041 对监听密码继续独立保存于 Keychain 的规定。ADR-0041 对监听配置与独立偏好的存储范围分离继续有效。 + +本 ADR 记录产品行为,不授权在设计讨论期间重置本机实际配置或 Keychain 项。 diff --git a/docs/server-library-encryption-format.md b/docs/server-library-encryption-format.md index e5437b2e..37a73412 100644 --- a/docs/server-library-encryption-format.md +++ b/docs/server-library-encryption-format.md @@ -168,6 +168,6 @@ The user-facing import from the old ShadowsocksX-NG application remains availabl - [ServerLibraryStorage.swift](../ShadowsocksX-NG2/Domain/Catalog/ServerLibraryStorage.swift): header, encryption, decryption, payload size limits, and historical-file handling. - [CatalogFileStore.swift](../ShadowsocksX-NG2/Domain/Catalog/CatalogFileStore.swift): direct catalog JSON encoding, schema v7, and structure validation. -- [LibraryMasterKeyStore.swift](../ShadowsocksX-NG2/Domain/Credentials/LibraryMasterKeyStore.swift): master key attributes and keychain access. +- [MasterKeyStore.swift](../ShadowsocksX-NG2/Domain/Credentials/MasterKeyStore.swift): master key attributes and keychain access. - [CatalogModels.swift](../ShadowsocksX-NG2/Domain/Catalog/CatalogModels.swift) and [SubscriptionRecord.swift](../ShadowsocksX-NG2/Domain/Subscription/SubscriptionRecord.swift): Codable models for catalog records and inline secrets. - [AtomicFileWriter.swift](../ShadowsocksX-NG2/Domain/Runtime/AtomicFileWriter.swift): temporary files and permission baseline. diff --git a/docs/server-library-encryption-format.zh-CN.md b/docs/server-library-encryption-format.zh-CN.md index 15ac49ba..a0ea3128 100644 --- a/docs/server-library-encryption-format.zh-CN.md +++ b/docs/server-library-encryption-format.zh-CN.md @@ -168,6 +168,6 @@ NG2 尚未发版,读取器不迁移其历史数据: - [ServerLibraryStorage.swift](../ShadowsocksX-NG2/Domain/Catalog/ServerLibraryStorage.swift):文件头、加解密、payload 大小限制与历史文件处理。 - [CatalogFileStore.swift](../ShadowsocksX-NG2/Domain/Catalog/CatalogFileStore.swift):直接目录 JSON 编码、schema v7 和结构校验。 -- [LibraryMasterKeyStore.swift](../ShadowsocksX-NG2/Domain/Credentials/LibraryMasterKeyStore.swift):主密钥的 Keychain 属性与访问方式。 +- [MasterKeyStore.swift](../ShadowsocksX-NG2/Domain/Credentials/MasterKeyStore.swift):主密钥的 Keychain 属性与访问方式。 - [CatalogModels.swift](../ShadowsocksX-NG2/Domain/Catalog/CatalogModels.swift)、[SubscriptionRecord.swift](../ShadowsocksX-NG2/Domain/Subscription/SubscriptionRecord.swift):目录记录及内联秘密的 Codable 模型。 - [AtomicFileWriter.swift](../ShadowsocksX-NG2/Domain/Runtime/AtomicFileWriter.swift):临时文件与权限基线。