diff --git a/GLOSSARY.md b/GLOSSARY.md index af997a44..885bfd8a 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -77,6 +77,9 @@ _避免用词_:可移除节点(Removable node)。 **服务器连通性测试(Server connectivity test)**: 用户手动请求,经指定服务器配置向测试 URL 发起应用 TCP 请求并检查响应的操作。测试经过该配置的实际传输路径,包括使用 UDP 传输的插件隧道;它不验证应用 UDP 中继。 +**测试 URL(Connectivity test URL)**: +服务器连通性测试请求访问的目标地址,是用户可修改的非敏感偏好信息,在展示、日志与诊断中无需脱敏。 + **代理响应延迟(Proxy response latency)**: 完成目标地址选择与预热后,经指定服务器配置访问测试 URL 的 HTTP 响应头耗时,以成功样本的中位数表示。它与测试目标及测试条件有关,不表示服务器 IP 的纯网络往返时间或下载速度。 @@ -274,7 +277,7 @@ _避免用词_:菜单栏形态(Menu bar form,应用形态并非恒定) ### 凭据与诊断 **敏感信息(Sensitive information)**: -服务器密码、完整插件选项,以及用户提供的非空远程 URL,包括订阅 URL。 +服务器密码、完整插件选项,以及用户提供的非空远程 URL,包括订阅 URL,但不包括测试 URL。 **凭据暴露边界(Credential exposure boundary)**: 产品降低意外泄露及向其他用户账户暴露凭据的保护范围。该范围不包含同一用户进程被攻破、root 访问或快照与备份,也不承诺安全擦除。 diff --git a/ShadowsocksX-NG2/App/Application/ProxyRuntime/ProxyRuntimeController+SettingsSync.swift b/ShadowsocksX-NG2/App/Application/ProxyRuntime/ProxyRuntimeController+SettingsSync.swift index 92328fcc..cce4f0ae 100644 --- a/ShadowsocksX-NG2/App/Application/ProxyRuntime/ProxyRuntimeController+SettingsSync.swift +++ b/ShadowsocksX-NG2/App/Application/ProxyRuntime/ProxyRuntimeController+SettingsSync.swift @@ -73,15 +73,10 @@ extension ProxyRuntimeController { /// status remains observable through the controller's status stream. func updateSettings(_ proposed: ProxySettings) async throws { let catalog = catalogSnapshotReader.catalogSnapshot - var connectionIndependent = settings - connectionIndependent.connectivityURLReference = proposed.connectivityURLReference - let onlyTestURLChanged = - settings.connectivityURLReference != proposed.connectivityURLReference - && connectionIndependent == proposed try settingsStore.save(proposed) settings = proposed settingsUnreadable = false - guard state != .off, !onlyTestURLChanged else { return } + guard state != .off else { return } await convergeToReexpanded(catalog) } diff --git a/ShadowsocksX-NG2/App/Application/SettingsWorkflow/SettingsWorkflow+Connectivity.swift b/ShadowsocksX-NG2/App/Application/SettingsWorkflow/SettingsWorkflow+Connectivity.swift index 8ac69d4d..57fe8331 100644 --- a/ShadowsocksX-NG2/App/Application/SettingsWorkflow/SettingsWorkflow+Connectivity.swift +++ b/ShadowsocksX-NG2/App/Application/SettingsWorkflow/SettingsWorkflow+Connectivity.swift @@ -1,39 +1,20 @@ import Foundation extension SettingsWorkflow { - func beginConnectivityURLEditing() throws -> String { - guard let reference = committing.committedSettings.connectivityURLReference else { - return ServerConnectivity.defaultURL.absoluteString - } - // Previously supported URLs remain editable after the accepted scheme changes. - guard let secret = try credentials.secret(for: reference), !secret.isEmpty - else { - throw ServerFormLoadError.credentialsUnavailable - } - return secret + private static let connectivityURLKey = "connectivityTestURL" + + func beginConnectivityURLEditing() -> String { + let url = + (connectivityDefaults.object(forKey: Self.connectivityURLKey) as? String) + .flatMap(ServerConnectivity.validURL) ?? ServerConnectivity.defaultURL + return url.absoluteString } - /// Each custom URL gets a fresh secret; failed preference saves roll it back. func saveConnectivityURL(_ raw: String) async -> Bool { guard let url = ServerConnectivity.validURL(raw), startCommit() else { return false } defer { finishCommit() } - let previous = committing.committedSettings.connectivityURLReference - let reference: CredentialReference? = url == ServerConnectivity.defaultURL ? nil : .fresh() - do { - if let reference { try credentials.save(url.absoluteString, for: reference) } - var proposed = committing.committedSettings - proposed.connectivityURLReference = reference - let outcome = await persistSettings(proposed) - guard outcome == .persisted else { - if let reference { try? credentials.delete(reference) } - return false - } - connectivityURLDidChange?(url) - if let previous { try? credentials.delete(previous) } - return true - } catch { - if let reference { try? credentials.delete(reference) } - return false - } + connectivityDefaults.set(url.absoluteString, forKey: Self.connectivityURLKey) + connectivityURLDidChange?(url) + return true } } diff --git a/ShadowsocksX-NG2/App/Application/SettingsWorkflow/SettingsWorkflow.swift b/ShadowsocksX-NG2/App/Application/SettingsWorkflow/SettingsWorkflow.swift index 01912085..e1dc9f84 100644 --- a/ShadowsocksX-NG2/App/Application/SettingsWorkflow/SettingsWorkflow.swift +++ b/ShadowsocksX-NG2/App/Application/SettingsWorkflow/SettingsWorkflow.swift @@ -5,8 +5,8 @@ import Foundation /// The module exposes validation, occupancy facts, shared commit state, and typed /// commands; editor-local drafts and presentation state remain in the UI. /// -/// Writes depend on the narrow `SettingsCommitting` seam. The module does not -/// depend on SwiftUI, persistence formats, or runtime convergence details. +/// Proxy setting writes use `SettingsCommitting`; the independent test URL +/// preference uses UserDefaults. Runtime convergence stays behind the commit seam. @MainActor final class SettingsWorkflow: ObservableObject { let listenerOccupancy: ListenerOccupancy @@ -21,14 +21,17 @@ final class SettingsWorkflow: ObservableObject { let committing: SettingsCommitting let credentials: CredentialStoring + let connectivityDefaults: UserDefaults init( committing: SettingsCommitting, occupancyProbe: PortOccupancyProbing = SystemPortOccupancyProbe(), - credentials: CredentialStoring = KeychainCredentialStore() + 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 self?.objectWillChange.send() diff --git a/ShadowsocksX-NG2/App/Composition/MainApp.swift b/ShadowsocksX-NG2/App/Composition/MainApp.swift index afc015a3..93d6af9d 100644 --- a/ShadowsocksX-NG2/App/Composition/MainApp.swift +++ b/ShadowsocksX-NG2/App/Composition/MainApp.swift @@ -210,7 +210,7 @@ private struct AppComposition { measure: testRuntime.measure) catalogWorkflow.connectivity = connectivity connectivity.setURL( - (try? settingsWorkflow.beginConnectivityURLEditing()).flatMap(ServerConnectivity.validURL)) + ServerConnectivity.validURL(settingsWorkflow.beginConnectivityURLEditing())) settingsWorkflow.connectivityURLDidChange = { [weak connectivity] in connectivity?.setURL($0) } let connectivityLifecycle = ConnectivityLifecycle(module: connectivity) // 诊断工作流 module:诊断区唯一 seam,组合根接线一次;进程期 diff --git a/ShadowsocksX-NG2/App/Resources/Connectivity.xcstrings b/ShadowsocksX-NG2/App/Resources/Connectivity.xcstrings index b6e3abb1..cc2c35ee 100644 --- a/ShadowsocksX-NG2/App/Resources/Connectivity.xcstrings +++ b/ShadowsocksX-NG2/App/Resources/Connectivity.xcstrings @@ -256,19 +256,19 @@ } } }, - "无法读取或保存测试 URL": { + "无法保存测试 URL": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Unable to read or save the test URL" + "value": "Unable to save the test URL" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "无法读取或保存测试 URL" + "value": "无法保存测试 URL" } } } diff --git a/ShadowsocksX-NG2/App/UI/Settings/ConnectivityURLSettings.swift b/ShadowsocksX-NG2/App/UI/Settings/ConnectivityURLSettings.swift index 59673812..30f95be3 100644 --- a/ShadowsocksX-NG2/App/UI/Settings/ConnectivityURLSettings.swift +++ b/ShadowsocksX-NG2/App/UI/Settings/ConnectivityURLSettings.swift @@ -33,7 +33,7 @@ struct ConnectivityURLSettings: View { } if failure { - Text(ConnectivityCopy.text("无法读取或保存测试 URL")) + Text(ConnectivityCopy.text("无法保存测试 URL")) .font(.caption) .foregroundStyle(.red) } @@ -47,7 +47,7 @@ struct ConnectivityURLSettings: View { TextField("HTTP", text: $value).textFieldStyle(.roundedBorder) Text(ConnectivityCopy.text("仅支持 HTTP。预热后采样 3 次,至少成功 2 次;显示成功样本的中位数。")) .font(.caption).foregroundStyle(.secondary) - if failure { Text(ConnectivityCopy.text("无法读取或保存测试 URL")).foregroundStyle(.red) } + if failure { Text(ConnectivityCopy.text("无法保存测试 URL")).foregroundStyle(.red) } HStack { Button(ConnectivityCopy.text("恢复默认")) { value = ServerConnectivity.defaultURL.absoluteString @@ -75,24 +75,14 @@ struct ConnectivityURLSettings: View { } private func refreshDisplayedURL() { - do { - displayedURL = try workflow.beginConnectivityURLEditing() - failure = false - } catch { - displayedURL = "" - failure = true - } + displayedURL = workflow.beginConnectivityURLEditing() + failure = false } private func beginEditing() { - do { - value = try workflow.beginConnectivityURLEditing() - displayedURL = value - failure = false - editing = true - } catch { - displayedURL = "" - failure = true - } + value = workflow.beginConnectivityURLEditing() + displayedURL = value + failure = false + editing = true } } diff --git a/ShadowsocksX-NG2/Domain/Diagnostics/DiagnosticReport.swift b/ShadowsocksX-NG2/Domain/Diagnostics/DiagnosticReport.swift index 7fd56a85..7b7374de 100644 --- a/ShadowsocksX-NG2/Domain/Diagnostics/DiagnosticReport.swift +++ b/ShadowsocksX-NG2/Domain/Diagnostics/DiagnosticReport.swift @@ -126,8 +126,8 @@ struct DiagnosticPluginFacts: Equatable, Sendable { /// 诊断报告构建器:只输出状态、存在性、权限、 /// 大小/时间、数量与脱敏元数据。测试夹具以投毒数据验证导出不含任何敏感 -/// 类目(密码、插件参数、Keychain 值、完整订阅 URL、URL token、运行时 -/// JSON 内容、服务器地址与备注)。 +/// 类目(密码、插件参数、Keychain 值、完整订阅 URL 及其 token、运行时 +/// JSON 内容、服务器地址与备注)。测试 URL 为非敏感信息,无需脱敏。 enum DiagnosticReportBuilder { /// 目录数量元数据(只含计数,不含名称/地址/备注)。 struct CatalogCounts: Equatable, Sendable { @@ -168,7 +168,7 @@ enum DiagnosticReportBuilder { lines.append("- 系统:\(snapshot.systemSummary ?? "未提供")") lines.append("") lines.append("本报告仅含状态、存在性、权限、大小/时间、数量与脱敏元数据;不包含") - lines.append("密码、插件参数、钥匙串值、完整订阅 URL、URL token、运行时 JSON") + lines.append("密码、插件参数、钥匙串值、完整订阅 URL 及其 token、运行时 JSON") lines.append("内容、服务器地址与备注。") lines.append("") lines.append(contentsOf: statusLines(snapshot)) diff --git a/ShadowsocksX-NG2/Domain/Runtime/RuntimeLog.swift b/ShadowsocksX-NG2/Domain/Runtime/RuntimeLog.swift index a5c08bf1..6af47e57 100644 --- a/ShadowsocksX-NG2/Domain/Runtime/RuntimeLog.swift +++ b/ShadowsocksX-NG2/Domain/Runtime/RuntimeLog.swift @@ -1,10 +1,10 @@ import Foundation /// 运行时日志基线:事件是封闭枚举,脱敏由构造保证——永不 -/// 携带密码、插件参数、Keychain 值、完整订阅 URL、URL token 或运行时 JSON -/// 内容;服务器地址与备注默认不进普通日志(端点探测失败点名的 host:port -/// 是本机监听端点,属于需要呈现的事实字段,不受此限)。日志查看与诊断 -/// 日志查看与诊断导出均以本事件流为数据源。 +/// 携带密码、插件参数、Keychain 值、完整订阅 URL 及其 token 或运行时 JSON +/// 内容;测试 URL 属于非敏感信息,可完整记录。服务器地址与备注默认不进 +/// 普通日志(端点探测失败点名的 host:port 是本机监听端点,属于需要呈现的 +/// 事实字段,不受此限)。日志查看与诊断导出均以本事件流为数据源。 enum RuntimeLogEvent: Equatable, CustomStringConvertible, Sendable { /// 契约文件已原子写入(只携带数量元数据,非内容)。 case contractWritten(serverCount: Int) @@ -123,8 +123,9 @@ protocol RuntimeEventSink: Sendable { /// 敏感信息脱敏工具(「敏感信息」定义的读取面)。 enum Redactor { - /// 非空远程 URL 只保留 scheme+host:路径、查询与片段全部略去,完整订阅 + /// 敏感远程 URL 只保留 scheme+host:路径、查询与片段全部略去,完整订阅 /// URL 与 URL token 永不入日志。解析失败一律归并为占位符。 + /// 测试 URL 无需脱敏,不使用此方法。 static func remoteURL(_ url: String) -> String { guard let parsed = URL(string: url), let host = parsed.host(), !host.isEmpty else { return "" diff --git a/ShadowsocksX-NG2/Domain/Settings/ProxySettings.swift b/ShadowsocksX-NG2/Domain/Settings/ProxySettings.swift index fc99c63f..f0abb5b0 100644 --- a/ShadowsocksX-NG2/Domain/Settings/ProxySettings.swift +++ b/ShadowsocksX-NG2/Domain/Settings/ProxySettings.swift @@ -5,7 +5,6 @@ import Foundation struct ProxySettings: Equatable, Sendable { static let defaultProxyExceptions = "" - var connectivityURLReference: CredentialReference? var listen: SslocalListenSettings /// User-added system-proxy exceptions; these entries do not become ACL rules. var proxyExceptions: String @@ -23,14 +22,12 @@ struct ProxySettings: Equatable, Sendable { init( listen: SslocalListenSettings = SslocalListenSettings(), - connectivityURLReference: CredentialReference? = nil, proxyExceptions: String = ProxySettings.defaultProxyExceptions, preferredMode: ProxyModeKind = .rule, ruleDefaultAction: RuleDefaultAction = .proxyWhenUnmatched, agentEnabled: Bool = false, systemProxyEnabled: Bool = false ) { - self.connectivityURLReference = connectivityURLReference self.listen = listen self.proxyExceptions = proxyExceptions self.preferredMode = preferredMode diff --git a/ShadowsocksX-NG2/Domain/Settings/ProxySettingsFileStore.swift b/ShadowsocksX-NG2/Domain/Settings/ProxySettingsFileStore.swift index 0c769263..7d6ffbd6 100644 --- a/ShadowsocksX-NG2/Domain/Settings/ProxySettingsFileStore.swift +++ b/ShadowsocksX-NG2/Domain/Settings/ProxySettingsFileStore.swift @@ -73,7 +73,6 @@ struct ProxySettingsFileStore: ProxySettingsStoring { return try validated( ProxySettings( listen: listen, - connectivityURLReference: record.connectivityURLReference, proxyExceptions: record.proxyExceptions, preferredMode: record.preferredMode, ruleDefaultAction: record.ruleDefaultAction, @@ -83,7 +82,6 @@ struct ProxySettingsFileStore: ProxySettingsStoring { private func record(from settings: ProxySettings) -> ProxySettingsRecord { var record = ProxySettingsRecord() - record.connectivityURLReference = settings.connectivityURLReference record.localListen = settings.listen.local record.lanListen = settings.listen.lan record.proxyExceptions = settings.proxyExceptions @@ -102,7 +100,6 @@ struct ProxySettingsFileStore: ProxySettingsStoring { } private struct ProxySettingsRecord: Codable, Equatable, Sendable { - var connectivityURLReference: CredentialReference? var localListen = LocalListeningSettings() var lanListen = LANListeningSettings() var proxyExceptions: String = ProxySettings.defaultProxyExceptions @@ -115,8 +112,6 @@ private struct ProxySettingsRecord: Codable, Equatable, Sendable { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - connectivityURLReference = try container.decodeIfPresent( - CredentialReference.self, forKey: .connectivityURLReference) // 未正式发版:忽略旧的单组监听字段,不迁移独立旧监听文件。 localListen = try container.decodeIfPresent(LocalListeningSettings.self, forKey: .localListen) @@ -149,7 +144,6 @@ private struct ProxySettingsRecord: Codable, Equatable, Sendable { extension ProxySettingsRecord { fileprivate enum CodingKeys: String, CodingKey { - case connectivityURLReference case localListen, lanListen case proxyExceptions case preferredMode diff --git a/ShadowsocksX-NG2/Tests/Connectivity/ConnectivityFormalRuntimeTests.swift b/ShadowsocksX-NG2/Tests/Connectivity/ConnectivityFormalRuntimeTests.swift index d665cdfa..c95577ab 100644 --- a/ShadowsocksX-NG2/Tests/Connectivity/ConnectivityFormalRuntimeTests.swift +++ b/ShadowsocksX-NG2/Tests/Connectivity/ConnectivityFormalRuntimeTests.swift @@ -41,7 +41,11 @@ struct ConnectivityFormalRuntimeTests { let calls = agent.registerCount let target = controller.activeTargetID let document = try Data(contentsOf: runtime.contract) - let workflow = SettingsWorkflow(committing: controller, credentials: credentials) + let suite = "ConnectivityFormalRuntimeTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let workflow = SettingsWorkflow( + committing: controller, credentials: credentials, 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 0802d5d6..1a2deefa 100644 --- a/ShadowsocksX-NG2/Tests/Connectivity/ConnectivitySettingsTests.swift +++ b/ShadowsocksX-NG2/Tests/Connectivity/ConnectivitySettingsTests.swift @@ -5,49 +5,100 @@ import Testing @MainActor struct ConnectivitySettingsTests { - @Test func customURLIsSecretAndFailureDoesNotPublishOrChangeSavedURL() async throws { + @Test func customURLPersistsWithoutCredentialsOrProxySettingsCommit() async throws { + let suite = "ConnectivitySettingsTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } let committer = FakeSettingsCommitter() - let credentials = InMemoryCredentialStore() - let workflow = SettingsWorkflow(committing: committer, credentials: credentials) - var published = 0 - workflow.connectivityURLDidChange = { _ in published += 1 } - let secretURL = "http://example.com/probe?token=private" - #expect(await workflow.saveConnectivityURL(secretURL)) - #expect(try workflow.beginConnectivityURLEditing() == secretURL) - #expect(credentials.storageCount == 1) - let saved = committer.committedSettings - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - let file = directory.appendingPathComponent("settings.json") - defer { try? FileManager.default.removeItem(at: directory) } - let store = ProxySettingsFileStore(fileURL: file) - try store.save(saved) - #expect(try store.load() == saved) - let text = try String(contentsOf: file, encoding: .utf8) - #expect(!text.contains("private")) - #expect(!text.contains("example.com")) - committer.updateError = ProxySettingsStoreError.ioFailure(detail: "fixture") - #expect(await !workflow.saveConnectivityURL("http://example.com/new")) - #expect(committer.committedSettings == saved) - #expect(credentials.storageCount == 1) - #expect(published == 1) - #expect(try workflow.beginConnectivityURLEditing() == secretURL) - committer.updateError = nil - #expect(await workflow.saveConnectivityURL(ServerConnectivity.defaultURL.absoluteString)) - #expect(committer.committedSettings.connectivityURLReference == nil) - #expect(credentials.storageCount == 0) - } - @Test func previouslySavedHTTPSURLCanBeOpenedAndReplacedWithHTTP() async throws { - let committer = FakeSettingsCommitter() - let credentials = InMemoryCredentialStore() - let reference = CredentialReference.fresh() - try credentials.save("https://example.com/old?token=private", for: reference) - committer.committedSettings.connectivityURLReference = reference - let workflow = SettingsWorkflow(committing: committer, credentials: credentials) - #expect(try workflow.beginConnectivityURLEditing() == "https://example.com/old?token=private") - #expect(await !workflow.saveConnectivityURL("https://example.com/new")) - #expect(await workflow.saveConnectivityURL("http://example.com/new")) - #expect(try workflow.beginConnectivityURLEditing() == "http://example.com/new") - #expect(try credentials.secret(for: reference) == nil) + committer.updateError = ProxySettingsStoreError.ioFailure(detail: "must not commit") + let credentials = UnavailableConnectivityCredentials() + let workflow = SettingsWorkflow( + committing: committer, credentials: credentials, connectivityDefaults: defaults) + var published: [URL] = [] + workflow.connectivityURLDidChange = { published.append($0) } + let url = "http://example.com/probe?token=public" + #expect(await workflow.saveConnectivityURL(url)) + #expect(workflow.beginConnectivityURLEditing() == url) + #expect(committer.committedSettings == ProxySettings()) + #expect(published.map(\.absoluteString) == [url]) + let reopened = SettingsWorkflow( + committing: committer, credentials: credentials, connectivityDefaults: defaults) + #expect(reopened.beginConnectivityURLEditing() == url) } + @Test func missingOrInvalidPreferencesUseDefaultURL() throws { + let suite = "ConnectivitySettingsTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let workflow = SettingsWorkflow( + committing: FakeSettingsCommitter(), credentials: UnavailableConnectivityCredentials(), + connectivityDefaults: defaults) + #expect(workflow.beginConnectivityURLEditing() == "http://www.gstatic.com/generate_204") + for value: Any in [ + 42, "", "https://example.com", "http://user:pass@example.com", + "http://example.com/#fragment", "http://example.com:0", + ] { + defaults.set(value, forKey: "connectivityTestURL") + #expect(workflow.beginConnectivityURLEditing() == "http://www.gstatic.com/generate_204") + } + } + + @Test func invalidSaveKeepsPreviousURLAndDefaultCanBeRestored() async throws { + let suite = "ConnectivitySettingsTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let workflow = SettingsWorkflow( + committing: FakeSettingsCommitter(), credentials: UnavailableConnectivityCredentials(), + connectivityDefaults: defaults) + var published: [URL] = [] + workflow.connectivityURLDidChange = { published.append($0) } + #expect(await workflow.saveConnectivityURL(" http://example.com/probe?token=public \n")) + #expect(await !workflow.saveConnectivityURL("https://example.com")) + #expect(workflow.beginConnectivityURLEditing() == "http://example.com/probe?token=public") + #expect(published.count == 1) + #expect(await workflow.saveConnectivityURL("http://www.gstatic.com/generate_204")) + #expect(workflow.beginConnectivityURLEditing() == "http://www.gstatic.com/generate_204") + #expect(published.count == 2) + } + + @Test func oldCredentialReferenceIsIgnoredWithoutLosingOtherSettings() 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("settings.json") + try Data( + #"{"connectivityURLReference":{"obsolete":true},"preferredMode":"global"}"#.utf8 + ).write(to: file) + let store = ProxySettingsFileStore(fileURL: file) + let settings = try store.load() + #expect(settings.preferredMode == .global) + try store.save(settings) + #expect(try !String(contentsOf: file, encoding: .utf8).contains("connectivityURLReference")) + let suite = "ConnectivitySettingsTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let committer = FakeSettingsCommitter() + committer.committedSettings = settings + let workflow = SettingsWorkflow( + committing: committer, credentials: UnavailableConnectivityCredentials(), + 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 + } } diff --git a/docs/adr/0036-store-connectivity-test-url-in-user-defaults.md b/docs/adr/0036-store-connectivity-test-url-in-user-defaults.md new file mode 100644 index 00000000..a3501b8e --- /dev/null +++ b/docs/adr/0036-store-connectivity-test-url-in-user-defaults.md @@ -0,0 +1,13 @@ +# 测试 URL 作为非敏感偏好存入 UserDefaults + +Status: Accepted(2026-10-18,用户已通过 implement 指令确认实施)。 + +测试 URL 是非敏感偏好,以明文存入 UserDefaults,不再通过 Keychain 和代理设置中的凭据引用保存。展示、日志、诊断报告及其他路径均不要求对测试 URL 脱敏,包括其路径与查询参数;这修订 ADR-0006 对 URL 的一概排除规则,仅限测试 URL。订阅 URL、服务器密码及其他凭据的保护边界保持不变。ADR-0035 对测试 URL 的范围外凭据描述由本决定替代。 + +不迁移已有自定义值,不访问或清理对应旧 Keychain 项,移除旧测试 URL 引用的使用。没有新偏好时使用默认值 `http://www.gstatic.com/generate_204`;旧 Keychain 条目可能残留,不再影响测试功能。 + +UserDefaults 键缺失、类型错误或保存的字符串不符合现有 URL 校验时,统一使用默认 URL。保存时保留现有校验:仅允许 HTTP,要求非空主机,禁止用户名、密码与 fragment,显式端口须在 1…65535 范围内,允许路径与查询参数。合法值按项目现有 UserDefaults 方式保存并立即生效,不承诺原文件保存路径的落盘错误报告与回滚保证。 + +URL 变化后沿用现有行为:清空历史测试结果并取消正在执行的测试,不重启代理。界面布局、测试协议、测量算法及其他设置的存储不在本次调整范围内。 + +实施验收覆盖独立 UserDefaults 测试域中的自定义值往返、默认值与异常值处理、无效输入拒绝、旧引用被忽略且不访问 Keychain,以及 URL 变化的发布行为。核查测试 URL 相关脱敏规则与诊断输出,使其符合非敏感分类,同时保持其他凭据的保护。