Files
ShadowsocksX-NG/ShadowsocksX-NG2/Domain/Runtime/RuntimeFileStore.swift
T
Qiu Yuzhou 856d6b4272 refactor(domain): 按主题重组 Domain/ 源码目录
Domain/ 根目录原有 62 个平铺源文件,无法从目录读出主题划分。

按「目录即主题」重组为 11 个目录(Rules/Custom 为嵌套子目录):Catalog /
Subscription / Rules / Runtime / Activation / Settings / SystemProxy /
Plugin / Credentials / LegacyImport / Diagnostics。纯移动:类型名、模块名
与 import 均未改动,Domain/ 根下不再有散落文件。目录口径取自
GLOSSARY.md 的章节划分(配置与订阅、代理规则、代理控制与网络、设置、
凭据与诊断),而非新造分类。

配套改动:

- project.yml:wrapper 与 helper 两个 target 按文件引用的 15 条显式路径
  随目录改名,并补注释固定这一约定——这 15 条指向不存在的文件时,
  xcodegen generate 以 "missing source directory" 直接失败(实测,不静默)。
- SkeletonTests 新增 DomainLayoutTests:断言 Domain/ 根下无平铺 .swift、
  树下只有 .swift。后者防的是与 App/ 同源的坑——Domain/ 整目录是 app 与
  Core 的 source path,任何非源码文件都会被 XcodeGen 收进 Resources 阶段
  并封进签名后的 bundle。
- README 工程结构说明同步;docs/design 与 docs/research 属历史文档不改。

验证:xcodegen generate 后 app 164 / Core 163 源文件数与整理前一致;Agent
恰好 9 个、Helper 恰好 6 个 Domain 文件(无缺失无多出);app 与 Core 各覆盖
全部 62 个。clean build 0 error;task test 1024 用例全通过(整理前 1022,
+2 为新增守卫);task format 未改写任何被移动文件(证明纯移动);task lint
与整理前同为 21 条内容型 warning;packaging-gate.sh ALL CHECKS PASSED。

布局守卫已做变异验证:临时投放松散 .swift 与非 .swift 文件时两个用例均
失败并点名违规文件,清理后复验为绿。
2026-10-05 20:13:35 +08:00

310 lines
12 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
/// 运行时文件存取(spec #21 D5,issue #27;ACL 布局见 ADR-0011):
/// `sslocal-active.json` 是 GUI 写、wrapper 读的跨进程交接契约。ACL 侧为
/// 每个变体一个 `acl-<summary>.ini` 实文件,外加稳定的 `acl-active.ini` 链接;
/// 契约只携带链接路径与身份摘要。写入复用 `AtomicFileWriter` 的物理安全基线
/// (目录 0700 含自愈、临时文件创建即 0600、写全校验后原子替换);显式停止
/// 后的清理只做普通 unlink,不承诺安全擦除(D5)。
struct RuntimeFileStore {
enum PersistenceError: Error, Equatable {
/// 写入时的文件系统错误。
case ioFailure(detail: String)
/// 部署失败后无法恢复已修改的 ACL 变体或链接。
case rollbackFailed(detail: String)
}
let fileURL: URL
private let fileWriter: (Data, URL) throws -> Void
init(
fileURL: URL = RuntimePaths.runtimeFileURL(),
fileWriter: @escaping (Data, URL) throws -> Void = {
try AtomicFileWriter.write($0, to: $1)
}
) {
self.fileURL = fileURL
self.fileWriter = fileWriter
}
private var directoryURL: URL { fileURL.deletingLastPathComponent() }
/// wrapper pid 文件与契约同目录:GUI 判活与 SIGUSR1 投递依据
/// (SMAppService 不暴露运行中 agent 的 pid)。
var pidFileURL: URL {
directoryURL.appendingPathComponent("agent.pid")
}
/// 稳定 ACL 链接(ADR-0011):契约 `acl` 字段恒指向它,换模式只改链接。
var aclFileURL: URL {
directoryURL.appendingPathComponent("acl-active.ini")
}
/// 某个 ACL 变体的实文件。
func aclVariantFileURL(summary: String) -> URL {
directoryURL.appendingPathComponent("acl-\(summary).ini")
}
/// GUI 私有 digest 清单:未变内容免读免写。
var aclDigestManifestURL: URL {
directoryURL.appendingPathComponent("acl-digests.json")
}
var runtimeStatusFileURL: URL {
directoryURL.appendingPathComponent("agent-runtime-status.json")
}
/// 变体、链接、契约按序原子替换;失败时恢复已完成的变更。
func write(_ document: SslocalRuntimeDocument) throws {
let contract: PreparedRuntimeContract
do {
contract = try PreparedRuntimeContract(document)
} catch {
throw PersistenceError.ioFailure(detail: String(describing: error))
}
try write(contract)
}
/// 单次部署准备好的契约字节由比较与写入共用。
func write(_ contract: PreparedRuntimeContract) throws {
let document = contract.document
var mutations: [Mutation] = []
var verifiedDigest: DigestEntry?
do {
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 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))
}
}
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)
}
// 清单只在契约成功后登记;它不参与部署事务的正确性。
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? {
guard let data = try? Data(contentsOf: fileURL) else { return nil }
guard let document = SslocalRuntimeDocument.decodeValidated(data) else { return nil }
guard let acl = document.aclRuntime else { return document }
guard
acl.path == aclFileURL.standardizedFileURL.path,
activeLinkResolvesInsideDirectory()
else { return nil }
return document
}
/// 磁盘原始字节,供与派生文档比较以跳过相同内容的写入(幂等)。
func readData() -> Data? {
try? Data(contentsOf: fileURL)
}
/// 部署收据只读:唯一生产写者是 Agent wrapper(AgentContract.writeRuntimeReceipt),
/// GUI 在健康检查中核对 pid 与契约摘要,从不写。
func readRuntimeReceipt() -> RuntimeDeploymentReceipt? {
guard let data = try? Data(contentsOf: runtimeStatusFileURL) else { return nil }
return try? JSONDecoder().decode(RuntimeDeploymentReceipt.self, from: data)
}
/// 显式停止清理(D2 停止协议末端):运行时契约、wrapper pid、ACL 链接与
/// 变体、digest 清单及原子写残留。异常崩溃路径不调用本方法(文件保留供
/// KeepAlive 重放,D5)。文件缺失时调用无害。
func deleteRuntimeFiles() {
let fileManager = FileManager.default
try? fileManager.removeItem(at: fileURL)
try? fileManager.removeItem(at: pidFileURL)
try? fileManager.removeItem(at: aclFileURL)
try? fileManager.removeItem(at: aclDigestManifestURL)
try? fileManager.removeItem(at: runtimeStatusFileURL)
try? fileManager.removeItem(at: directoryURL.appendingPathComponent("sslocal-active.acl"))
if let names = try? fileManager.contentsOfDirectory(atPath: directoryURL.path) {
for name in names {
// 变体实文件,或 AtomicFileWriter 的暂存残留。
if name.hasPrefix("acl-") && name.hasSuffix(".ini") {
try? fileManager.removeItem(at: directoryURL.appendingPathComponent(name))
} else if AtomicFileWriter.isResidueName(name) {
try? fileManager.removeItem(at: directoryURL.appendingPathComponent(name))
}
}
}
}
}
extension RuntimeFileStore {
// MARK: - ACL 变体与链接
/// 命中清单不读正文;未命中只读一次,同时保留需要写入时的回滚字节。
private func prepareVariant(_ acl: ProxyACLDocument, at url: URL) throws
-> (needsWrite: Bool, previous: Data?)
{
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)
}
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
{
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")
}
}
private struct DigestEntry: Codable, Equatable {
let sha256: String
let size: Int
}
private func loadDigestManifest() -> [String: DigestEntry] {
guard let data = try? Data(contentsOf: aclDigestManifestURL) else { return [:] }
return (try? JSONDecoder().decode([String: DigestEntry].self, from: data)) ?? [:]
}
private func recordDigest(summary: String, entry: DigestEntry) {
var manifest = loadDigestManifest()
guard manifest[summary] != entry else { return }
manifest[summary] = entry
guard let data = try? JSONEncoder().encode(manifest) else { return }
try? fileWriter(data, aclDigestManifestURL)
}
private func repointActiveLink(to variantURL: URL) throws {
let fileManager = FileManager.default
let temporaryLink = AtomicFileWriter.temporaryURL(for: aclFileURL)
try? fileManager.removeItem(at: temporaryLink)
try fileManager.createSymbolicLink(
atPath: temporaryLink.path, withDestinationPath: variantURL.lastPathComponent)
if fileManager.fileExists(atPath: aclFileURL.path)
|| (try? fileManager
.destinationOfSymbolicLink(atPath: aclFileURL.path)) != nil
{
// rename 原子替换既有链接/文件。
guard rename(temporaryLink.path, aclFileURL.path) == 0 else {
try? fileManager.removeItem(at: temporaryLink)
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
}
} else {
try fileManager.moveItem(at: temporaryLink, to: aclFileURL)
}
}
private func restoreVariant(_ previousData: Data?, at variantURL: URL) throws {
if let previousData {
try fileWriter(previousData, variantURL)
} else {
try FileManager.default.removeItem(at: variantURL)
}
}
private func restoreLink(_ state: LinkState) throws {
switch state {
case .link(let target):
let temporary = AtomicFileWriter.temporaryURL(for: aclFileURL)
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)
}
case .file(let data):
try fileWriter(data, aclFileURL)
case .absent:
try FileManager.default.removeItem(at: aclFileURL)
}
}
/// 链接(或实文件)解析后必须仍在运行目录内——防链接逃逸(ADR-0011)。
private func activeLinkResolvesInsideDirectory() -> Bool {
ACLActiveLinkPolicy.resolvesToRegularFile(inside: directoryURL, linkURL: aclFileURL)
}
}