import Foundation private typealias RuntimeFileCreator = (Data, String, String) throws -> String private struct PreparedAuthentication { let document: SslocalRuntimeDocument let id: String? let files: [String] } /// GUI 独占配置准备与监管发布;Agent 独占稳定链接切换和退休文件回收。 struct RuntimeFileStore { enum PersistenceError: Error, Equatable { case ioFailure(detail: String) case rollbackFailed(detail: String) } private let paths: RuntimePaths var fileURL: URL { paths.fileURL } private let fileWriter: (Data, URL) throws -> Void init( fileURL: URL = ApplicationSupportPaths.current.runtime.fileURL, fileWriter: @escaping (Data, URL) throws -> Void = { try AtomicFileWriter.write($0, to: $1) } ) { paths = RuntimePaths(fileURL: fileURL) self.fileWriter = fileWriter } var directoryURL: URL { paths.directoryURL } var supervisionFileURL: URL { paths.supervisionFileURL } var failureFileURL: URL { paths.failureFileURL } var pidFileURL: URL { paths.pidFileURL } var aclFileURL: URL { paths.aclFileURL } var runtimeStatusFileURL: URL { paths.runtimeStatusFileURL } func write(_ document: SslocalRuntimeDocument) throws { try write(PreparedRuntimeContract(document)) } func write(_ contract: PreparedRuntimeContract) throws { try RuntimePublication.withLock(in: directoryURL) { try publishLocked(contract) } } private func publishLocked(_ contract: PreparedRuntimeContract) throws { var created: [URL] = [] var publicationID: String? do { guard contract.document.isWellFormed else { throw POSIXError(.EINVAL) } let old = RuntimePublication.load(at: supervisionFileURL) let generation = try reserveGeneration() let prefix = String(format: "%020llu", generation) func create(_ data: Data, kind: String, suffix: String) throws -> String { let name = "\(kind)-\(prefix)-\(suffix)" let url = directoryURL.appendingPathComponent(name) guard !FileManager.default.fileExists(atPath: url.path) else { throw POSIXError(.EEXIST) } // 先登记:注入的写入器即使在写后抛错,也由 GUI 清理本次未发布文件。 created.append(url) try fileWriter(data, url) guard RuntimePublication.isRegularFile(url), try Data(contentsOf: url) == data else { throw POSIXError(.EIO) } return name } let snapshot = try makeSnapshot( contract, old: old, generation: generation, create: { try create($0, kind: $1, suffix: $2) }) // 唯一发布点。所有可引用资源已落盘且校验完成;不提前触碰任何活动链接。 publicationID = snapshot.deploymentID try fileWriter(JSONEncoder().encode(snapshot), supervisionFileURL) } catch { try handlePublicationFailure(error, publicationID: publicationID, created: created) } } private func makeSnapshot( _ contract: PreparedRuntimeContract, old: RuntimeSupervision?, generation: UInt64, create: RuntimeFileCreator ) throws -> RuntimeSupervision { var variants = old?.aclVariants ?? [:] var selectedACL: RuntimeSupervision.ACL? if let acl = contract.document.aclRuntime { guard acl.path == aclFileURL.standardizedFileURL.path, acl.isWellFormed else { throw POSIXError(.EINVAL) } selectedACL = try selectACL(acl, variants: &variants, create: create) } let authentication = try prepareAuthentication(contract.document, old: old, create: create) let configurationData = try authentication.document.jsonData() let configurationID = reusableConfigurationID( for: configurationData, previous: old) ?? UUID().uuidString let configuration = try create(configurationData, "config", "sslocal.json") let intent = PublishedRuntimeIntent( sha256: contract.sha256, configurationSHA256: ProxyACLDocument.digest(configurationData), summary: Redactor.documentSummary(contract.document)) let intentMetadata = try create(JSONEncoder().encode(intent), "config", "intent.json") let snapshot = RuntimeSupervision( version: 2, generation: generation, deploymentID: UUID().uuidString, configuration: configuration, configurationID: configurationID, intentMetadata: intentMetadata, locals: authentication.document.locals.map { .init( inboundProtocol: $0.inboundProtocol, localAddress: $0.localAddress, localPort: $0.localPort, mode: $0.mode) }, ipv6Only: authentication.document.ipv6Only, authenticationID: authentication.id, authenticationFiles: authentication.files, acl: selectedACL, aclVariants: variants) guard snapshot.validated(in: directoryURL) else { throw POSIXError(.EINVAL) } return snapshot } private func selectACL( _ acl: ProxyACLDocument, variants: inout [String: RuntimeSupervision.ACL], create: RuntimeFileCreator ) throws -> RuntimeSupervision.ACL { if let cached = variants[acl.summary], cached.sha256 == acl.sha256 { return cached } guard !acl.content.isEmpty else { throw POSIXError(.ENOENT) } let data = Data(acl.content.utf8) let name = try create(data, "acl", "\(acl.summary).ini") let selected = RuntimeSupervision.ACL(file: name, sha256: acl.sha256, size: data.count) variants[acl.summary] = selected return selected } private func reusableConfigurationID(for data: Data, previous: RuntimeSupervision?) -> String? { guard let previous, let identity = previous.configurationID, publishedIntent(previous)?.configurationSHA256 == ProxyACLDocument.digest(data), (try? Data(contentsOf: directoryURL.appendingPathComponent(previous.configuration))) == data else { return nil } return identity } private func handlePublicationFailure( _ error: Error, publicationID: String?, created: [URL] ) throws { // 写入器在原子替换后报错时,发布已经发生;不得删除读者可见资源。 if let publicationID, RuntimePublication.load(at: supervisionFileURL)?.deploymentID == publicationID { throw PersistenceError.ioFailure(detail: "Publication completed but writer reported failure") } var failures: [String] = [] for url in created { do { try FileManager.default.removeItem(at: url) } catch { if FileManager.default.fileExists(atPath: url.path) { failures.append(url.lastPathComponent) } } } if !failures.isEmpty { throw PersistenceError.rollbackFailed( detail: "Unpublished cleanup: " + failures.joined(separator: ",")) } throw PersistenceError.ioFailure(detail: String(describing: error)) } private func prepareAuthentication( _ document: SslocalRuntimeDocument, old: RuntimeSupervision?, create: RuntimeFileCreator ) throws -> PreparedAuthentication { var authenticationID: String? var authenticationFiles: [String] = [] var encoded = document if let auth = encoded.listen.authentication { guard auth.directory == directoryURL.standardizedFileURL.path else { throw POSIXError(.EINVAL) } let contents = try auth.fileContents() let previousFiles = old?.authenticationFiles ?? [] let unchanged = previousFiles.count == contents.count && zip(previousFiles, contents).allSatisfy { name, content in (try? Data(contentsOf: directoryURL.appendingPathComponent(name))) == content.1 } if unchanged, let oldID = old?.authenticationID { authenticationID = oldID authenticationFiles = old?.authenticationFiles ?? [] } else { authenticationID = UUID().uuidString for (kind, data) in contents { authenticationFiles.append(try create(data, "auth", "\(kind).json")) } } for index in encoded.locals.indices where encoded.locals[index].localAddress != "127.0.0.1" { if encoded.locals[index].inboundProtocol == "socks" { encoded.locals[index].socks5AuthConfigPath = directoryURL .appendingPathComponent(authenticationFiles[0]).path } else { encoded.locals[index].httpAuthConfigPath = directoryURL .appendingPathComponent(authenticationFiles[1]).path } } } return PreparedAuthentication( document: encoded, id: authenticationID, files: authenticationFiles) } /// 代次高水位独立于部署文件持久保存。预留失败可留空号,溢出拒绝部署。 private func reserveGeneration() throws -> UInt64 { let url = paths.generationFileURL let current: UInt64 if FileManager.default.fileExists(atPath: url.path) { guard let value = UInt64(try String(contentsOf: url, encoding: .utf8)) else { throw POSIXError(.EINVAL) } current = value } else { current = 0 } guard current < UInt64.max else { throw POSIXError(.EOVERFLOW) } try AtomicFileWriter.write(Data(String(current + 1).utf8), to: url) return current + 1 } } extension RuntimeFileStore { /// 意图比较不解析输出正文;最终字节摘要保留原有磁盘漂移检查。 func contains(_ contract: PreparedRuntimeContract) -> Bool { (try? RuntimePublication.withLock(in: directoryURL) { guard let snapshot = RuntimePublication.load(at: supervisionFileURL), let intent = publishedIntent(snapshot), intent.sha256 == contract.sha256, let data = try? Data( contentsOf: directoryURL.appendingPathComponent(snapshot.configuration)) else { return false } return ProxyACLDocument.digest(data) == intent.configurationSHA256 }) ?? false } func publishedSummary() -> String? { try? RuntimePublication.withLock(in: directoryURL) { guard let snapshot = RuntimePublication.load(at: supervisionFileURL) else { return nil } return publishedIntent(snapshot)?.summary } } private func publishedIntent(_ snapshot: RuntimeSupervision) -> PublishedRuntimeIntent? { guard let data = try? Data(contentsOf: directoryURL.appendingPathComponent(snapshot.intentMetadata)) else { return nil } return try? JSONDecoder().decode(PublishedRuntimeIntent.self, from: data) } func receiptIdentity(for document: SslocalRuntimeDocument) -> String? { receiptIdentity(for: document.intentSHA256) } func receiptIdentity(for intentSHA256: String?) -> String? { try? RuntimePublication.withLock(in: directoryURL) { let snapshot = RuntimePublication.metadata(at: supervisionFileURL) guard let snapshot, let identity = publishedIntent(snapshot)?.sha256, identity == intentSHA256 else { return nil } return snapshot.deploymentID } } func blocksDeployment(_ document: SslocalRuntimeDocument) -> Bool { blocksDeployment(document.intentSHA256) } func blocksDeployment(_ intentSHA256: String?) -> Bool { guard let failure = readFailure() else { return false } guard let snapshot = RuntimePublication.metadata(at: supervisionFileURL) else { return true } guard failure.deploymentID == snapshot.deploymentID else { return false } // GUI 私有身份本身丢失时也不自动重试,用户可显式重试修复派生资源。 guard let identity = publishedIntent(snapshot)?.sha256, identity.count == 64 else { return true } return identity == intentSHA256 } func readRuntimeReceipt() -> RuntimeDeploymentReceipt? { guard let data = try? Data(contentsOf: runtimeStatusFileURL) else { return nil } return try? JSONDecoder().decode(RuntimeDeploymentReceipt.self, from: data) } func readFailure() -> RuntimeDeploymentFailure? { guard let data = try? Data(contentsOf: failureFileURL) else { return nil } return try? JSONDecoder().decode(RuntimeDeploymentFailure.self, from: data) } func recordFailure(reason: String) throws { try RuntimePublication.withLock(in: directoryURL) { guard let snapshot = RuntimePublication.metadata(at: supervisionFileURL) else { throw POSIXError(.EINVAL) } try AtomicFileWriter.write( JSONEncoder().encode( RuntimeDeploymentFailure( deploymentID: snapshot.deploymentID, reason: reason)), to: failureFileURL) } } /// 只清除旧部署失败。Agent 对本代新确认的失败不得被 GUI 健康任务擦除。 func settleSuccessfulDeployment() throws { try RuntimePublication.withLock(in: directoryURL) { guard let snapshot = RuntimePublication.metadata(at: supervisionFileURL) else { throw POSIXError(.EINVAL) } if let failure = readFailure(), failure.deploymentID == snapshot.deploymentID { throw POSIXError(.EIO) } try clearFailure() } } func clearFailure() throws { if FileManager.default.fileExists(atPath: failureFileURL.path) { try FileManager.default.removeItem(at: failureFileURL) } } var hasUnconfirmedChild: Bool { let url = paths.childPIDFileURL guard FileManager.default.fileExists(atPath: url.path) else { return false } guard let data = try? String(contentsOf: url, encoding: .utf8), let pid = Int32(data), pid > 0 else { return true } return kill(pid, 0) == 0 || errno == EPERM } /// 仅在确认 wrapper 及其子进程退出后调用;高水位与锁跨停止保留。 func deleteRuntimeFiles() throws { try RuntimePublication.withLock(in: directoryURL) { guard !hasUnconfirmedChild else { throw POSIXError(.EBUSY) } let manager = FileManager.default let removableNames = paths.removableFileNames for name in try manager.contentsOfDirectory(atPath: directoryURL.path) { if RuntimePublication.generation(of: name) != nil || removableNames.contains(name) || name.hasPrefix("lan-auth-") || name == "acl-digests.json" || (name.hasPrefix("acl-") && name.hasSuffix(".ini")) || AtomicFileWriter.isResidueName(name) { try manager.removeItem(at: directoryURL.appendingPathComponent(name)) } } } } } /// GUI 私有发布事实。意图摘要不进入 Agent 监管资料、回执或诊断。 struct PublishedRuntimeIntent: Codable { let sha256: String let configurationSHA256: String let summary: String }