mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-28 21:23:44 +00:00
refactor(connectivity): store test URL in user defaults
This commit is contained in:
+4
-1
@@ -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 访问或快照与备份,也不承诺安全擦除。
|
||||
|
||||
+1
-6
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+10
-29
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,组合根接线一次;进程期
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 "<redacted-url>"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 相关脱敏规则与诊断输出,使其符合非敏感分类,同时保持其他凭据的保护。
|
||||
Reference in New Issue
Block a user