From e345d772ebf5c24a26522834a3db9db7b0752930 Mon Sep 17 00:00:00 2001 From: Qiu Yuzhou Date: Sat, 3 Oct 2026 00:09:42 +0800 Subject: [PATCH] fix(runtime): make ACL deployment transactional --- CONTEXT.md | 2 + .../App/ProxyRuntimeController+Commands.swift | 50 +++-- .../ProxyRuntimeController+CustomRules.swift | 13 +- .../App/ProxyRuntimeController+Health.swift | 9 +- .../App/ProxyRuntimeController+Mode.swift | 33 ++- .../ProxyRuntimeController+SettingsSync.swift | 11 +- .../Domain/ProxyRuntimePlan.swift | 9 +- .../Domain/RuntimeFileStore.swift | 204 +++++++++++------- .../Domain/SslocalRuntimeDocument.swift | 14 ++ .../Tests/ProxyRuntimePlanTests.swift | 20 +- .../RuntimeFileStoreTransactionTests.swift | 161 ++++++++++++++ 11 files changed, 415 insertions(+), 111 deletions(-) create mode 100644 ShadowsocksX-NG2/Tests/RuntimeFileStoreTransactionTests.swift diff --git a/CONTEXT.md b/CONTEXT.md index 961a7c20..49bbae7e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -51,6 +51,7 @@ - **Legacy configuration**: The server list and preferences persisted by the frozen implementation in `Legacy/`. - **Legacy import**: The user-confirmed, read-only translation of a Legacy configuration's server records into a separate manual group in 2.0. It migrates no preferences, activates no target, and neither starts proxying nor alters the Legacy source. - **Runtime configuration file**: The derived JSON document used by the external tunnel service for the active target. It carries only connection-effective values (endpoints, credentials, encryption, plugin, listeners, ACL); user-visible display names are catalog metadata and never enter the file. Fields NG2 omits are left to the bundled `sslocal` defaults; the file is not the user-managed server configuration or subscription document. +- **Prepared runtime contract**: A deployment-local value holding one runtime document, its encoded JSON bytes, and the digest of those bytes. Planning, persistence, and that deployment's health confirmation share the value; it is not a cache of disk state. - **Sensitive information**: Server passwords, plugin options (sensitive in their entirety), and nonempty user-provided remote URLs, including subscription URLs. - **Credential exposure boundary**: This product reduces accidental disclosure and exposure to other user accounts, but does not guarantee protection against a compromised same-user process, root access, or APFS snapshots and backups; it does not promise secure erasure. - **Credential reference**: A non-secret association from a server configuration, subscription, or remote-URL setting to its durable credential; the credential value is kept outside the configuration tree and resolved only when needed. @@ -111,6 +112,7 @@ - Preferences have no single restore-all-factory-defaults action. Where a reset exists, it is scoped to one configuration item, never to the whole preference set at once. - The proxy mode selector's choice and the rule default action persist with the settings snapshot and survive GUI restarts; a persistence failure keeps the previous mode and sub-option in force and names the reason. Rule, global, and direct mode are always available. - The GUI is the user-facing manager; the Shadowsocks tunnel service is an external runtime boundary rather than part of the GUI's domain model. +- Runtime file deployment prepares contract bytes once and still reads the actual disk contract before choosing actions. ACL digest-manifest hits avoid reading the variant body; a miss reads the existing variant once and reuses those bytes if rollback is needed. An unchanged active-link target is preserved. The synchronous transaction records only completed mutations and attempts every recorded restoration on failure, reporting any restoration failure explicitly. A decoded contract with no ACL body can restore only an existing regular variant file; a missing or unsafe variant fails before publishing its link or contract. Digest metadata is updated only after contract persistence succeeds. - The Proxy Rules destination provides browsing through `RulesWorkflow`: it prepares a versioned collection off the UI actor from fixed local policy, all three built-in snapshots (including preserved expressible absorbed candidates), and the custom-rule document. Equivalent identities retain all source memberships and custom UUIDs. Search, source, and action filters intersect; sorting affects presentation only; hidden selections are cleared and navigation preserves session query/selection. Source metadata and original conversion reports remain distinct from current coverage explanations. Missing/corrupt inputs explicitly mark the collection incomplete while retaining available sources. The page also tests domains, IP literals, and URL hosts offline against that complete saved collection, independently of browsing filters, runtime state, and the unmatched default action. Results retain all deciding identities and source memberships plus other target-specific hits; domain tests explicitly leave IP rules untested without DNS. Invalid input and incomplete collections are operation errors. Editing the target or retrying clears prior results, and generation/version checks reject late tasks. The page supports individual and batch enablement through the Workflow. A schema-2 user document atomically stores custom entries and source-independent disabled identities; schema 1 reads with no disabled records. Disabled identities survive source disappearance and are visible under all sources with the disabled filter, where enabling removes orphan rows. Filtering excludes disabled identities before current coverage analysis and offline matching; runtime analysis separately uses the existing default-action source subset and actual ACL skeleton, restoring preserved expressible absorbed candidates and GFWList exceptions when their blockers are disabled. Commits return the final saved document after deployment or rollback and use one fixed toolbar status through persistence, deployment, and in-memory collection preparation. They reject repeats while keeping browsing available, preserve the runtime switch intent, and deploy only if ACL identity changes. A changed final document rebuilds browsing analysis from the retained built-in inputs off the UI actor; an unchanged document retains the collection. There is no ordinary toolbar reload or post-commit file reread. Initial loading shares bundled source facts with runtime preparation; only failure actions explicitly retry failed sources. Success feedback counts actual changes, distinguishes applied, unchanged runtime, and deferred application, and expires after three seconds; failures remain dismissible with details and contextual retry or runtime-inspection actions. A failed preparation retains cached rows and blocks edits until an explicit successful retry. Runtime projection and ACL compilation use immutable captured inputs off the UI actor, reject superseded intent before deployment, and reuse the compiled document for convergence checks and deployment. Deployment failures restore the entire prior document and runtime; recovery failures remain explicit errors. The page has no add, edit, or delete actions yet. - The app presents in the adaptive app form: menu-bar-only after the last workspace or independent rule-report window closes, and with a Dock icon and app-switcher presence while either is open; hiding the app is not closing it and does not change the form; the GUI process keeps running with the status menu available regardless of the workspace window. - At launch the GUI presents the workspace window unless silent launch is on; after the workspace window is closed, the status menu is the only way to reopen it. Silent launch changes only window presentation: proxy runtime restoration happens at every launch regardless of it. diff --git a/ShadowsocksX-NG2/App/ProxyRuntimeController+Commands.swift b/ShadowsocksX-NG2/App/ProxyRuntimeController+Commands.swift index e522244d..172eb78a 100644 --- a/ShadowsocksX-NG2/App/ProxyRuntimeController+Commands.swift +++ b/ShadowsocksX-NG2/App/ProxyRuntimeController+Commands.swift @@ -210,9 +210,9 @@ extension ProxyRuntimeController { await refuseDeployForUnreadableListenSettings() return false } - let document: SslocalRuntimeDocument + let contract: PreparedRuntimeContract do { - document = try await runtimeDocument(sourceDocument, for: proxyMode) + contract = try PreparedRuntimeContract(await runtimeDocument(sourceDocument, for: proxyMode)) } catch RulePreparationError.superseded { return false } catch { @@ -221,17 +221,20 @@ extension ProxyRuntimeController { state = .serviceFailed(.runtimeFile) return false } - return await deployPrepared(document, preparation: runtimePreparationGeneration) + return await deployPrepared(contract, preparation: runtimePreparationGeneration) } - func deployPrepared(_ document: SslocalRuntimeDocument, preparation: Int) async -> Bool { + func deployPrepared(_ contract: PreparedRuntimeContract, preparation: Int) async -> Bool { guard preparation == runtimePreparationGeneration else { return false } + let document = contract.document lastDocument = document - guard await execute(.run(document), document: document) else { return false } + guard await execute(.run(document), document: document, preparedContract: contract) else { + return false + } guard preparation == runtimePreparationGeneration else { return false } state = .starting return await presentLaunchHealth( - document, requiresReceipt: document.aclRuntime != nil) + document, requiresReceipt: document.aclRuntime != nil, preparedContract: contract) } /// D8「任何路径不静默改端口」:监听设置不可读时以占位出厂端口部署等于 @@ -275,32 +278,49 @@ extension ProxyRuntimeController { extension ProxyRuntimeController { /// 按计划顺序执行动作;返回 false 表示中途失败、状态已呈现(后续动作与 /// 健康探测都不应继续)。 - func execute(_ intent: RuntimeIntent, document: SslocalRuntimeDocument?) async -> Bool { + func execute( + _ intent: RuntimeIntent, document: SslocalRuntimeDocument?, + preparedContract: PreparedRuntimeContract? = nil + ) async -> Bool { cancelFirewallObservation() flowGeneration += 1 let generation = flowGeneration let preparation = runtimePreparationGeneration + let contract: PreparedRuntimeContract? + if case .run(let desired) = intent { + guard let prepared = preparedContract ?? (try? PreparedRuntimeContract(desired)), + prepared.document == desired, document == desired + else { + state = .serviceFailed(document == nil ? .missingDocument : .runtimeFile) + return false + } + contract = prepared + } else { + contract = nil + } let actions = ProxyRuntimePlan.actions( intent: intent, agentStatus: agent.status, wrapper: wrapperState(), - contractOnDisk: runtimeFileStore.readData()) + contractOnDisk: runtimeFileStore.readData(), preparedContract: contract) if case .run = intent, actions.isEmpty { RuntimeLog.emit(.contractUnchanged) } for action in actions { guard generation == flowGeneration, preparation == runtimePreparationGeneration, - await perform(action, document: document) + await perform(action, preparedContract: contract) else { return false } } return generation == flowGeneration && preparation == runtimePreparationGeneration } /// 执行单个动作;返回 false 表示应终止后续动作(状态已呈现)。 - private func perform(_ action: RuntimeAction, document: SslocalRuntimeDocument?) async -> Bool { + private func perform( + _ action: RuntimeAction, preparedContract: PreparedRuntimeContract? + ) async -> Bool { switch action { case .writeContract: - return performWriteContract(document) + return performWriteContract(preparedContract) case .registerAgent: return performAgentRegistration() case .unregisterAgent: @@ -315,14 +335,14 @@ extension ProxyRuntimeController { } /// 原子写契约文件;缺文档或写失败即终止。 - private func performWriteContract(_ document: SslocalRuntimeDocument?) -> Bool { - guard let document else { + private func performWriteContract(_ contract: PreparedRuntimeContract?) -> Bool { + guard let contract else { state = .serviceFailed(.missingDocument) return false } do { - try runtimeFileStore.write(document) - RuntimeLog.emit(.contractWritten(serverCount: document.servers.count)) + try runtimeFileStore.write(contract) + RuntimeLog.emit(.contractWritten(serverCount: contract.document.servers.count)) return true } catch { state = .serviceFailed(.runtimeFile) diff --git a/ShadowsocksX-NG2/App/ProxyRuntimeController+CustomRules.swift b/ShadowsocksX-NG2/App/ProxyRuntimeController+CustomRules.swift index 1edbb4f8..d7b168df 100644 --- a/ShadowsocksX-NG2/App/ProxyRuntimeController+CustomRules.swift +++ b/ShadowsocksX-NG2/App/ProxyRuntimeController+CustomRules.swift @@ -104,9 +104,11 @@ extension ProxyRuntimeController { modeChangeGeneration += 1 let generation = modeChangeGeneration let runtimeGeneration = flowGeneration + 1 + let contract = try? PreparedRuntimeContract(nextDocument) lastDocument = nextDocument state = .starting - guard await execute(.run(nextDocument), document: nextDocument) else { + guard await execute(.run(nextDocument), document: nextDocument, preparedContract: contract) + else { guard ruleDeploymentIsCurrent(preparation, generation, runtimeGeneration) else { return .runtimeChanged(rulesRestored: false) @@ -122,7 +124,7 @@ extension ProxyRuntimeController { nextDocument, requiresReceipt: true, convergeProxyOnSuccess: false, - preserveProxyOnFailure: true) + preserveProxyOnFailure: true, preparedContract: contract) guard ruleDeploymentIsCurrent(preparation, generation, runtimeGeneration) else { return .runtimeChanged(rulesRestored: false) @@ -196,9 +198,11 @@ extension ProxyRuntimeController { let modeGeneration = modeChangeGeneration let preparation = runtimePreparationGeneration let runtimeGeneration = flowGeneration + 1 + let contract = try? PreparedRuntimeContract(previousDocument) lastDocument = previousDocument state = .starting - let executed = await execute(.run(previousDocument), document: previousDocument) + let executed = await execute( + .run(previousDocument), document: previousDocument, preparedContract: contract) guard ruleDeploymentIsCurrent(preparation, modeGeneration, runtimeGeneration) else { return .runtimeChanged(rulesRestored: rulesRestored) @@ -209,7 +213,8 @@ extension ProxyRuntimeController { return .recoveryFailed(detail: failures.joined(separator: "; "), rulesRestored: rulesRestored) } let healthy = await presentLaunchHealth( - previousDocument, requiresReceipt: true, convergeProxyOnSuccess: false) + previousDocument, requiresReceipt: true, convergeProxyOnSuccess: false, + preparedContract: contract) guard ruleDeploymentIsCurrent(preparation, modeGeneration, runtimeGeneration) else { return .runtimeChanged(rulesRestored: rulesRestored) diff --git a/ShadowsocksX-NG2/App/ProxyRuntimeController+Health.swift b/ShadowsocksX-NG2/App/ProxyRuntimeController+Health.swift index d995303e..dceaa16f 100644 --- a/ShadowsocksX-NG2/App/ProxyRuntimeController+Health.swift +++ b/ShadowsocksX-NG2/App/ProxyRuntimeController+Health.swift @@ -28,11 +28,16 @@ extension ProxyRuntimeController { _ document: SslocalRuntimeDocument, requiresReceipt: Bool = false, convergeProxyOnSuccess: Bool = true, - preserveProxyOnFailure: Bool = false + preserveProxyOnFailure: Bool = false, + preparedContract: PreparedRuntimeContract? = nil ) async -> Bool { + guard preparedContract == nil || preparedContract?.document == document else { + return await reportRuntimeFileFailure(preserveProxyOnFailure: preserveProxyOnFailure) + } let generation = flowGeneration let preparation = runtimePreparationGeneration - let expectedDigest = requiresReceipt ? document.deploymentSHA256 : nil + let expectedDigest = + requiresReceipt ? (preparedContract?.sha256 ?? document.deploymentSHA256) : nil guard !requiresReceipt || expectedDigest != nil else { return await reportRuntimeFileFailure(preserveProxyOnFailure: preserveProxyOnFailure) } diff --git a/ShadowsocksX-NG2/App/ProxyRuntimeController+Mode.swift b/ShadowsocksX-NG2/App/ProxyRuntimeController+Mode.swift index 1edf053b..ab1d9710 100644 --- a/ShadowsocksX-NG2/App/ProxyRuntimeController+Mode.swift +++ b/ShadowsocksX-NG2/App/ProxyRuntimeController+Mode.swift @@ -172,9 +172,10 @@ extension ProxyRuntimeController { guard generation == modeChangeGeneration, preparation == runtimePreparationGeneration else { return } + let contract = try? PreparedRuntimeContract(document) lastDocument = document state = .starting - guard await execute(.run(document), document: document) else { + guard await execute(.run(document), document: document, preparedContract: contract) else { guard generation == modeChangeGeneration, preparation == runtimePreparationGeneration else { return } @@ -189,7 +190,7 @@ extension ProxyRuntimeController { document, requiresReceipt: true, convergeProxyOnSuccess: false, - preserveProxyOnFailure: true) + preserveProxyOnFailure: true, preparedContract: contract) guard generation == modeChangeGeneration, preparation == runtimePreparationGeneration else { return } @@ -224,7 +225,10 @@ extension ProxyRuntimeController { proxyMode = snapshot.mode lastDocument = previousDocument - guard await restoreRuntimeDocument(previousDocument, generation: generation) + let contract = try? PreparedRuntimeContract(previousDocument) + guard + await restoreRuntimeDocument( + previousDocument, generation: generation, preparedContract: contract) else { return } guard generation == modeChangeGeneration, preparation == runtimePreparationGeneration else { @@ -234,7 +238,7 @@ extension ProxyRuntimeController { let restored = await presentLaunchHealth( previousDocument, requiresReceipt: true, - convergeProxyOnSuccess: false) + convergeProxyOnSuccess: false, preparedContract: contract) guard generation == modeChangeGeneration, preparation == runtimePreparationGeneration else { return } @@ -263,9 +267,9 @@ extension ProxyRuntimeController { /// 代理并返回 false(健康检查由调用方继续)。 private func restoreRuntimeDocument( _ previousDocument: SslocalRuntimeDocument, - generation: Int + generation: Int, preparedContract: PreparedRuntimeContract? ) async -> Bool { - let expectedDigest = previousDocument.deploymentSHA256 + let expectedDigest = preparedContract?.sha256 ?? previousDocument.deploymentSHA256 let currentDocument = runtimeFileStore.loadDocument() let receipt = runtimeFileStore.readRuntimeReceipt() let wrapper = wrapperState() @@ -280,7 +284,11 @@ extension ProxyRuntimeController { if currentDocument != previousDocument || !previousInstanceIsRunning { if currentDocument != previousDocument { do { - try runtimeFileStore.write(previousDocument) + if let preparedContract { + try runtimeFileStore.write(preparedContract) + } else { + try runtimeFileStore.write(previousDocument) + } } catch { state = .serviceFailed(.runtimeFile) await holdSystemProxyIntent() @@ -290,7 +298,8 @@ extension ProxyRuntimeController { if !previousInstanceIsRunning { guard await relaunchPreviousInstance( - wrapper: wrapper, previousDocument: previousDocument, generation: generation) + wrapper: wrapper, previousDocument: previousDocument, generation: generation, + preparedContract: preparedContract) else { return false } } } @@ -301,7 +310,7 @@ extension ProxyRuntimeController { private func relaunchPreviousInstance( wrapper: WrapperProcessState, previousDocument: SslocalRuntimeDocument, - generation: Int + generation: Int, preparedContract: PreparedRuntimeContract? ) async -> Bool { let preparation = runtimePreparationGeneration switch wrapper { @@ -312,7 +321,11 @@ extension ProxyRuntimeController { return false } case .notRunning: - guard await execute(.run(previousDocument), document: previousDocument) else { + guard + await execute( + .run(previousDocument), document: previousDocument, + preparedContract: preparedContract) + else { guard generation == modeChangeGeneration, preparation == runtimePreparationGeneration else { return false } diff --git a/ShadowsocksX-NG2/App/ProxyRuntimeController+SettingsSync.swift b/ShadowsocksX-NG2/App/ProxyRuntimeController+SettingsSync.swift index b2d3415b..4362b1af 100644 --- a/ShadowsocksX-NG2/App/ProxyRuntimeController+SettingsSync.swift +++ b/ShadowsocksX-NG2/App/ProxyRuntimeController+SettingsSync.swift @@ -16,10 +16,11 @@ extension ProxyRuntimeController { do { let document = try await runtimeDocument(configuration.document, for: proxyMode) let preparation = runtimePreparationGeneration - if runtimeAlreadyConverged(with: document) { + let contract = try PreparedRuntimeContract(document) + if runtimeAlreadyConverged(with: contract) { RuntimeLog.emit(.contractUnchanged) } else { - _ = await deployPrepared(document, preparation: preparation) + _ = await deployPrepared(contract, preparation: preparation) } } catch RulePreparationError.superseded { return .revalidated @@ -56,7 +57,7 @@ extension ProxyRuntimeController { /// (`ProxyRuntimePlan.actions` 返回空 = 幂等跳过):磁盘漂移或 wrapper 失踪 /// 时动作序列自然非空,走完整 deploy 自愈。目录重校验(跳过名单、目标失效 /// 清除)已在 `reexpand` 完成,不受此门影响。 - private func runtimeAlreadyConverged(with document: SslocalRuntimeDocument) -> Bool { + private func runtimeAlreadyConverged(with contract: PreparedRuntimeContract) -> Bool { switch state { case .running, .firewallBlocked: break @@ -64,10 +65,10 @@ extension ProxyRuntimeController { return false } let actions = ProxyRuntimePlan.actions( - intent: .run(document), + intent: .run(contract.document), agentStatus: agent.status, wrapper: wrapperState(), - contractOnDisk: runtimeFileStore.readData()) + contractOnDisk: runtimeFileStore.readData(), preparedContract: contract) return actions.isEmpty } diff --git a/ShadowsocksX-NG2/Domain/ProxyRuntimePlan.swift b/ShadowsocksX-NG2/Domain/ProxyRuntimePlan.swift index f71eed5e..777400f1 100644 --- a/ShadowsocksX-NG2/Domain/ProxyRuntimePlan.swift +++ b/ShadowsocksX-NG2/Domain/ProxyRuntimePlan.swift @@ -49,10 +49,14 @@ enum ProxyRuntimePlan { intent: RuntimeIntent, agentStatus: LaunchAgentStatus, wrapper: WrapperProcessState, - contractOnDisk: Data? + contractOnDisk: Data?, + preparedContract: PreparedRuntimeContract? = nil ) -> [RuntimeAction] { switch intent { case .run(let document): + let preparedData = preparedContract.flatMap { contract in + contract.document == document ? contract.data : nil + } switch agentStatus { case .notRegistered, .notFound, .requiresApproval: // 首次启用或从未注册:写盘后注册,launchd 拉起的 wrapper 直接读新档。 @@ -62,7 +66,8 @@ enum ProxyRuntimePlan { case .running(let pid): // 相同内容跳过写入(幂等优化);内容比较本身失败时宁可重写—— // 写入侧会如实呈现错误,不静默跳过部署。 - if let onDisk = contractOnDisk, let desiredData = try? document.jsonData(), + if let onDisk = contractOnDisk, + let desiredData = preparedData ?? (try? document.jsonData()), desiredData == onDisk { return [] diff --git a/ShadowsocksX-NG2/Domain/RuntimeFileStore.swift b/ShadowsocksX-NG2/Domain/RuntimeFileStore.swift index 8debcfe0..a610e169 100644 --- a/ShadowsocksX-NG2/Domain/RuntimeFileStore.swift +++ b/ShadowsocksX-NG2/Domain/RuntimeFileStore.swift @@ -10,7 +10,7 @@ struct RuntimeFileStore { enum PersistenceError: Error, Equatable { /// 写入时的文件系统错误。 case ioFailure(detail: String) - /// Runtime JSON 写失败后无法恢复此前的 ACL 变体或链接。 + /// 部署失败后无法恢复已修改的 ACL 变体或链接。 case rollbackFailed(detail: String) } @@ -54,64 +54,90 @@ struct RuntimeFileStore { directoryURL.appendingPathComponent("agent-runtime-status.json") } - /// 原子写盘;任一步失败保留原文件。 + /// 变体、链接、契约按序原子替换;失败时恢复已完成的变更。 func write(_ document: SslocalRuntimeDocument) throws { - let data: Data + let contract: PreparedRuntimeContract do { - data = try document.jsonData() + contract = try PreparedRuntimeContract(document) } catch { throw PersistenceError.ioFailure(detail: String(describing: error)) } + try write(contract) + } - var previousVariantData: Data? - var previousLinkTarget: String? - var wroteVariant = false - if let acl = document.aclRuntime { - guard - acl.path == aclFileURL.standardizedFileURL.path, - acl.isWellFormed - else { - throw PersistenceError.ioFailure(detail: "ACL sidecar path or digest is invalid") - } - let variantURL = aclVariantFileURL(summary: acl.summary) - previousLinkTarget = try? FileManager.default.destinationOfSymbolicLink( - atPath: aclFileURL.path) - do { - if !acl.content.isEmpty { - previousVariantData = try? Data(contentsOf: variantURL) - if try shouldWriteVariant(summary: acl.summary, sha256: acl.sha256, at: variantURL) { - try fileWriter(Data(acl.content.utf8), variantURL) - wroteVariant = true - } - } - try repointActiveLink(to: variantURL) - } catch { - try? restoreVariant(previousVariantData, at: variantURL) - try? restoreLink(previousLinkTarget) - throw PersistenceError.ioFailure(detail: String(describing: error)) - } - } - + /// 单次部署准备好的契约字节由比较与写入共用。 + func write(_ contract: PreparedRuntimeContract) throws { + let document = contract.document + var mutations: [Mutation] = [] + var verifiedDigest: DigestEntry? do { - try fileWriter(data, fileURL) - } catch { if let acl = document.aclRuntime { - do { - try restoreVariant(previousVariantData, at: aclVariantFileURL(summary: acl.summary)) - try restoreLink(previousLinkTarget) - } catch { - throw PersistenceError.rollbackFailed(detail: String(describing: error)) + guard acl.path == aclFileURL.standardizedFileURL.path, acl.isWellFormed else { + throw PersistenceError.ioFailure(detail: "ACL sidecar path or digest is invalid") + } + let variant = aclVariantFileURL(summary: acl.summary) + if acl.content.isEmpty { + guard isRegularVariant(variant) else { + throw PersistenceError.ioFailure(detail: "Existing ACL variant is unavailable") + } + } else { + let change = try prepareVariant(acl, at: variant) + if change.needsWrite { + try fileWriter(Data(acl.content.utf8), variant) + mutations.append(.variant(variant, change.previous)) + } + verifiedDigest = DigestEntry(sha256: acl.sha256, size: acl.content.utf8.count) + } + let previousLink = try activeLinkState() + if previousLink != .link(variant.lastPathComponent) { + try repointActiveLink(to: variant) + mutations.append(.link(previousLink)) } } - throw PersistenceError.ioFailure(detail: String(describing: error)) + try fileWriter(contract.data, fileURL) + } catch { + let original = String(describing: error) + let failures = rollback(mutations) + if !failures.isEmpty { + throw PersistenceError.rollbackFailed( + detail: original + "; rollback: " + failures.joined(separator: "; ")) + } + throw PersistenceError.ioFailure(detail: original) } - // digest 清单只是跳写缓存:契约写成功后才登记,失败回滚不会留下 - // 「清单说新内容、盘上是旧内容」的脱节(下轮退化为全量哈希仍正确)。 - if wroteVariant, let acl = document.aclRuntime { - recordDigest(summary: acl.summary, sha256: acl.sha256, size: acl.content.utf8.count) + // 清单只在契约成功后登记;它不参与部署事务的正确性。 + if let verifiedDigest, let acl = document.aclRuntime { + recordDigest(summary: acl.summary, entry: verifiedDigest) } } + private enum LinkState: Equatable { + case absent + case link(String) + case file(Data) + } + + private enum Mutation { + case variant(URL, Data?) + case link(LinkState) + } + + private func rollback(_ mutations: [Mutation]) -> [String] { + var failures: [String] = [] + for mutation in mutations.reversed() { + do { + switch mutation { + case .variant(let url, let data): + try restoreVariant(data, at: url) + case .link(let state): + try restoreLink(state) + } + } catch { + failures.append(String(describing: error)) + } + } + return failures + } + /// 读取侧判定:结构有效 + 链接解析后仍在运行目录内。不读 ACL 内容、 /// 不核摘要(ADR-0011 / Q12)。 func loadDocument() -> SslocalRuntimeDocument? { @@ -172,25 +198,57 @@ struct RuntimeFileStore { } } +} + +extension RuntimeFileStore { // MARK: - ACL 变体与链接 - /// digest 清单命中(摘要 + 字节数一致)且文件在盘则跳过写;清单失效时 - /// 退化为对文件做一次全量哈希确认(ADR-0011)。 - private func shouldWriteVariant(summary: String, sha256: String, at variantURL: URL) throws - -> Bool + /// 命中清单不读正文;未命中只读一次,同时保留需要写入时的回滚字节。 + private func prepareVariant(_ acl: ProxyACLDocument, at url: URL) throws + -> (needsWrite: Bool, previous: Data?) { - let fileManager = FileManager.default - if let recorded = loadDigestManifest()[summary], recorded.sha256 == sha256 { - let attributes = try? fileManager.attributesOfItem(atPath: variantURL.path) - let size = (attributes?[.size] as? NSNumber)?.intValue - if size == recorded.size { return false } + let attributes = try attributesIfPresent(url) + if let attributes { + guard attributes[.type] as? FileAttributeType == .typeRegular else { + throw PersistenceError.ioFailure(detail: "ACL variant is not a regular file") + } + if let recorded = loadDigestManifest()[acl.summary], recorded.sha256 == acl.sha256, + recorded.size == acl.content.utf8.count, + (attributes[.size] as? NSNumber)?.intValue == recorded.size + { + return (false, nil) + } + let previous = try Data(contentsOf: url) + return (ProxyACLDocument.digest(previous) != acl.sha256, previous) } - if let existing = try? Data(contentsOf: variantURL), ProxyACLDocument.digest(existing) == sha256 + return (true, nil) + } + + private func attributesIfPresent(_ url: URL) throws -> [FileAttributeKey: Any]? { + do { + return try FileManager.default.attributesOfItem(atPath: url.path) + } catch let error as NSError + where error.domain == NSCocoaErrorDomain + && error.code == NSFileReadNoSuchFileError { - recordDigest(summary: summary, sha256: sha256, size: existing.count) - return false + return nil + } + } + + private func isRegularVariant(_ url: URL) -> Bool { + (try? attributesIfPresent(url))?[.type] as? FileAttributeType == .typeRegular + } + + private func activeLinkState() throws -> LinkState { + guard let attributes = try attributesIfPresent(aclFileURL) else { return .absent } + switch attributes[.type] as? FileAttributeType { + case .typeSymbolicLink: + return .link(try FileManager.default.destinationOfSymbolicLink(atPath: aclFileURL.path)) + case .typeRegular: + return .file(try Data(contentsOf: aclFileURL)) + default: + throw PersistenceError.ioFailure(detail: "Active ACL path is not a file or link") } - return true } private struct DigestEntry: Codable, Equatable { @@ -203,9 +261,10 @@ struct RuntimeFileStore { return (try? JSONDecoder().decode([String: DigestEntry].self, from: data)) ?? [:] } - private func recordDigest(summary: String, sha256: String, size: Int) { + private func recordDigest(summary: String, entry: DigestEntry) { var manifest = loadDigestManifest() - manifest[summary] = DigestEntry(sha256: sha256, size: size) + guard manifest[summary] != entry else { return } + manifest[summary] = entry guard let data = try? JSONEncoder().encode(manifest) else { return } try? fileWriter(data, aclDigestManifestURL) } @@ -235,24 +294,25 @@ struct RuntimeFileStore { if let previousData { try fileWriter(previousData, variantURL) } else { - try? FileManager.default.removeItem(at: variantURL) + try FileManager.default.removeItem(at: variantURL) } } - private func restoreLink(_ previousTarget: String?) throws { - let fileManager = FileManager.default - if let previousTarget { - let temporaryLink = directoryURL.appendingPathComponent( + private func restoreLink(_ state: LinkState) throws { + switch state { + case .link(let target): + let temporary = directoryURL.appendingPathComponent( ".\(aclFileURL.lastPathComponent).tmp-\(UUID().uuidString)") - try? fileManager.removeItem(at: temporaryLink) - try fileManager.createSymbolicLink( - atPath: temporaryLink.path, withDestinationPath: previousTarget) - guard rename(temporaryLink.path, aclFileURL.path) == 0 else { - try? fileManager.removeItem(at: temporaryLink) + defer { try? FileManager.default.removeItem(at: temporary) } + try FileManager.default.createSymbolicLink( + atPath: temporary.path, withDestinationPath: target) + guard rename(temporary.path, aclFileURL.path) == 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) } - } else { - try? fileManager.removeItem(at: aclFileURL) + case .file(let data): + try fileWriter(data, aclFileURL) + case .absent: + try FileManager.default.removeItem(at: aclFileURL) } } diff --git a/ShadowsocksX-NG2/Domain/SslocalRuntimeDocument.swift b/ShadowsocksX-NG2/Domain/SslocalRuntimeDocument.swift index 8739b76d..c3c91bda 100644 --- a/ShadowsocksX-NG2/Domain/SslocalRuntimeDocument.swift +++ b/ShadowsocksX-NG2/Domain/SslocalRuntimeDocument.swift @@ -275,3 +275,17 @@ extension SslocalRuntimeDocument { } } } + +/// A single deployment's encoded contract and receipt identity. Keep this value +/// local to preparation/deployment so on-disk state is still checked each time. +struct PreparedRuntimeContract: Sendable { + let document: SslocalRuntimeDocument + let data: Data + let sha256: String + + init(_ document: SslocalRuntimeDocument) throws { + self.document = document + data = try document.jsonData() + sha256 = ProxyACLDocument.digest(data) + } +} diff --git a/ShadowsocksX-NG2/Tests/ProxyRuntimePlanTests.swift b/ShadowsocksX-NG2/Tests/ProxyRuntimePlanTests.swift index 1fd1cc29..c3f6cee8 100644 --- a/ShadowsocksX-NG2/Tests/ProxyRuntimePlanTests.swift +++ b/ShadowsocksX-NG2/Tests/ProxyRuntimePlanTests.swift @@ -18,11 +18,17 @@ final class ProxyRuntimePlanTests: XCTestCase { disk: Data?? = nil ) throws -> [RuntimeAction] { let fallback = try contractData() + let prepared: PreparedRuntimeContract? + if case .run(let desired) = intent { + prepared = try PreparedRuntimeContract(desired) + } else { + prepared = nil + } return ProxyRuntimePlan.actions( intent: intent, agentStatus: agent, wrapper: wrapper, - contractOnDisk: disk ?? fallback) + contractOnDisk: disk ?? fallback, preparedContract: prepared) } // MARK: 开启代理 @@ -56,6 +62,18 @@ final class ProxyRuntimePlanTests: XCTestCase { "相同内容跳过写入与信号(激活状态机幂等注记)") } + func testPreparedContractForAnotherDocumentDoesNotSkipDeployment() throws { + let other = ProxyRuntimeFixture.makeDocument(serverAddress: "198.51.100.9") + let prepared = try PreparedRuntimeContract(other) + + XCTAssertEqual( + ProxyRuntimePlan.actions( + intent: .run(document), agentStatus: .registered, wrapper: .running(pid: 42), + contractOnDisk: prepared.data, preparedContract: prepared), + [.writeContract, .signalReload(pid: 42)], + "Another document's prepared bytes cannot make this intent appear converged") + } + func testRunningWithStaleContractWritesThenSignalsReload() throws { let stale = try ProxyRuntimeFixture.makeDocument( serverAddress: "198.51.100.9", localPort: 1086 diff --git a/ShadowsocksX-NG2/Tests/RuntimeFileStoreTransactionTests.swift b/ShadowsocksX-NG2/Tests/RuntimeFileStoreTransactionTests.swift new file mode 100644 index 00000000..fdccaf3b --- /dev/null +++ b/ShadowsocksX-NG2/Tests/RuntimeFileStoreTransactionTests.swift @@ -0,0 +1,161 @@ +import XCTest + +@testable import ShadowsocksX_NG2 + +/// ACL 部署事务通过临时目录与原子写入故障 seam 验证。 +final class RuntimeFileStoreTransactionTests: XCTestCase { + private var runtime: ProxyRuntimeFixture.TemporaryRuntime! + private var store: RuntimeFileStore! + + override func setUpWithError() throws { + runtime = ProxyRuntimeFixture.makeTemporaryRuntime() + store = RuntimeFileStore(fileURL: runtime.contract) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: runtime.directory) + } + + func testDecodedDocumentContractFailurePreservesExistingVariant() throws { + let original = SslocalRuntimeDocument( + servers: [], listen: SslocalListenSettings(), acl: .direct(at: store.aclFileURL)) + try store.write(original) + let decoded = try XCTUnwrap(store.loadDocument()) + let variant = store.aclVariantFileURL(summary: "direct") + let bytes = try Data(contentsOf: variant) + let failing = RuntimeFileStore(fileURL: runtime.contract) { _, _ in + throw NSError(domain: "contract", code: 1) + } + XCTAssertThrowsError(try failing.write(decoded)) + XCTAssertEqual(try Data(contentsOf: variant), bytes) + XCTAssertEqual(store.loadDocument(), original) + } + + func testUnchangedACLFailureDoesNotRewriteVariantOrLink() throws { + let document = SslocalRuntimeDocument( + servers: [], listen: SslocalListenSettings(), acl: .direct(at: store.aclFileURL)) + try store.write(document) + let variant = store.aclVariantFileURL(summary: "direct") + let bytes = try Data(contentsOf: variant) + let linkNumber = + try FileManager.default.attributesOfItem(atPath: store.aclFileURL.path)[.systemFileNumber] + as? NSNumber + var touched: [URL] = [] + let failing = RuntimeFileStore(fileURL: runtime.contract) { _, url in + touched.append(url) + throw NSError(domain: "contract", code: 1) + } + XCTAssertThrowsError(try failing.write(document)) + XCTAssertEqual(touched, [runtime.contract]) + XCTAssertEqual(try Data(contentsOf: variant), bytes) + XCTAssertEqual( + try FileManager.default.attributesOfItem(atPath: store.aclFileURL.path)[.systemFileNumber] + as? NSNumber, linkNumber) + } + + func testLinkRestoreFailureStillRestoresVariant() throws { + let old = SslocalRuntimeDocument( + servers: [], listen: SslocalListenSettings(), acl: .global(at: store.aclFileURL)) + try store.write(old) + let variant = store.aclVariantFileURL(summary: "global") + let oldBytes = try Data(contentsOf: variant) + try store.write(old.replacingACL(.direct(at: store.aclFileURL))) + let failing = RuntimeFileStore(fileURL: runtime.contract) { data, url in + if url == self.runtime.contract { + try FileManager.default.removeItem(at: self.store.aclFileURL) + try FileManager.default.createDirectory( + at: self.store.aclFileURL, withIntermediateDirectories: false) + throw NSError(domain: "contract", code: 1) + } + try AtomicFileWriter.write(data, to: url) + } + let changed = old.replacingACL( + ProxyACLDocument( + path: store.aclFileURL.path, summary: "global", + content: "[proxy_all]\n[bypass_list]\nexample.com\n")) + XCTAssertThrowsError(try failing.write(changed)) { error in + guard case RuntimeFileStore.PersistenceError.rollbackFailed = error else { + return XCTFail("Expected explicit link rollback failure: \(error)") + } + } + XCTAssertEqual(try Data(contentsOf: variant), oldBytes) + } + + func testDecodedDocumentRejectsMissingOrSymlinkVariant() throws { + let document = SslocalRuntimeDocument( + servers: [], listen: SslocalListenSettings(), acl: .direct(at: store.aclFileURL)) + try store.write(document) + let decoded = try XCTUnwrap(store.loadDocument()) + let before = try Data(contentsOf: runtime.contract) + let variant = store.aclVariantFileURL(summary: "direct") + try FileManager.default.removeItem(at: variant) + XCTAssertThrowsError(try store.write(decoded)) + try FileManager.default.createSymbolicLink( + atPath: variant.path, withDestinationPath: runtime.contract.path) + XCTAssertThrowsError(try store.write(decoded)) + XCTAssertEqual(try Data(contentsOf: runtime.contract), before) + } + + func testRollbackFailureStillRestoresOtherChangedResources() throws { + let old = SslocalRuntimeDocument( + servers: [], listen: SslocalListenSettings(), acl: .direct(at: store.aclFileURL)) + try store.write(old) + let global = SslocalRuntimeDocument( + servers: [], listen: SslocalListenSettings(), acl: .global(at: store.aclFileURL)) + try store.write(global) + try store.write(old) + let variant = store.aclVariantFileURL(summary: "global") + var writes = 0 + let failing = RuntimeFileStore(fileURL: runtime.contract) { data, url in + if url == self.runtime.contract { throw NSError(domain: "contract", code: 1) } + if url == variant { + writes += 1 + if writes == 2 { throw NSError(domain: "restore", code: 2) } + } + try AtomicFileWriter.write(data, to: url) + } + let changed = SslocalRuntimeDocument( + servers: [], listen: SslocalListenSettings(), + acl: ProxyACLDocument( + path: store.aclFileURL.path, summary: "global", + content: "[proxy_all]\n[bypass_list]\n127.0.0.1\n")) + XCTAssertThrowsError(try failing.write(changed)) { error in + guard case RuntimeFileStore.PersistenceError.rollbackFailed = error else { + return XCTFail("Expected explicit rollback failure: \(error)") + } + } + XCTAssertEqual( + try FileManager.default.destinationOfSymbolicLink(atPath: store.aclFileURL.path), + "acl-direct.ini") + XCTAssertEqual(try Data(contentsOf: runtime.contract), try old.jsonData()) + } + + func testPreparationFailureRestoresWrittenVariantAndReportsRestoreFailure() throws { + let document = SslocalRuntimeDocument( + servers: [], listen: SslocalListenSettings(), acl: .direct(at: store.aclFileURL)) + try store.write(document) + let variant = store.aclVariantFileURL(summary: "direct") + try FileManager.default.removeItem(at: store.aclFileURL) + try FileManager.default.createDirectory( + at: store.aclFileURL, withIntermediateDirectories: false) + var writes = 0 + let failing = RuntimeFileStore(fileURL: runtime.contract) { data, url in + if url == variant { + writes += 1 + if writes == 2 { throw NSError(domain: "restore", code: 1) } + } + try AtomicFileWriter.write(data, to: url) + } + let changed = document.replacingACL( + ProxyACLDocument( + path: store.aclFileURL.path, summary: "direct", + content: "[bypass_all]\n[proxy_list]\nexample.com\n")) + XCTAssertThrowsError(try failing.write(changed)) { error in + guard case RuntimeFileStore.PersistenceError.rollbackFailed = error else { + return XCTFail("Expected explicit preparation rollback failure: \(error)") + } + } + XCTAssertEqual(writes, 2) + } + +}