mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-09 02:44:51 +00:00
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 文件时两个用例均 失败并点名违规文件,清理后复验为绿。
310 lines
12 KiB
Swift
310 lines
12 KiB
Swift
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)
|
||
}
|
||
}
|