Files
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

157 lines
5.7 KiB
Swift

import Darwin
import Foundation
struct PluginSecurityFacts: Equatable, Sendable {
enum Quarantine: Sendable { case present, absent, unknown }
enum Signature: Sendable { case valid, unsigned, invalid, notApplicable, unknown }
enum Policy: Sendable { case accepted, rejected, notApplicable, unknown }
let quarantine: Quarantine
let signature: Signature
let policy: Policy
}
protocol PluginInspecting: Sendable {
func inspect(_ executable: URL) async -> PluginSecurityFacts
}
struct PluginInspectionCommandResult: Sendable {
let status: Int32
let output: String
}
/// Read-only evidence, not a prediction that exec will succeed or a security guarantee.
struct MacOSPluginInspection: PluginInspecting {
typealias CommandRunner = @Sendable (URL, [String]) -> PluginInspectionCommandResult?
private let runCommand: CommandRunner
init(
runCommand: @escaping CommandRunner = { tool, arguments in Self.run(tool, arguments: arguments)
}
) {
self.runCommand = runCommand
}
func inspect(_ executable: URL) async -> PluginSecurityFacts {
await Task.detached(priority: .utility) { inspectSynchronously(executable) }.value
}
private func inspectSynchronously(_ executable: URL) -> PluginSecurityFacts {
let attributeSize = getxattr(executable.path, "com.apple.quarantine", nil, 0, 0, 0)
let quarantine: PluginSecurityFacts.Quarantine =
attributeSize >= 0 ? .present : (errno == ENOATTR ? .absent : .unknown)
let signature: PluginSecurityFacts.Signature
let header: Data? = {
guard let handle = try? FileHandle(forReadingFrom: executable) else { return nil }
defer { try? handle.close() }
return try? handle.read(upToCount: 4)
}()
if header?.starts(with: [0x23, 0x21]) == true {
signature = .notApplicable
} else if let result = runCommand(
URL(fileURLWithPath: "/usr/bin/codesign"),
["--verify", "--strict", "--verbose=2", executable.path])
{
if result.status == 0 {
signature = .valid
} else if result.output.contains("not signed at all") {
signature = .unsigned
} else if result.output.contains("invalid signature")
|| result.output.contains("code or signature have been modified")
{
signature = .invalid
} else {
signature = .unknown
}
} else {
signature = .unknown
}
let policy: PluginSecurityFacts.Policy
if let result = runCommand(
URL(fileURLWithPath: "/usr/sbin/spctl"),
["--assess", "--type", "execute", "--verbose=2", executable.path])
{
if result.status == 0 {
policy = .accepted
} else if result.output.contains("does not seem to be an app")
|| result.output.contains("Insufficient Context")
{
policy = .notApplicable
} else if result.output.contains(": rejected") {
policy = .rejected
} else {
policy = .unknown
}
} else {
policy = .unknown
}
return PluginSecurityFacts(quarantine: quarantine, signature: signature, policy: policy)
}
/// Use a private output file to avoid pipe deadlocks; only read a bounded prefix.
/// Only fixed Apple tools run here. The plugin itself is never executed.
private static func run(_ tool: URL, arguments: [String]) -> PluginInspectionCommandResult? {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("plugin-inspection-\(UUID().uuidString)")
guard
(try? FileManager.default.createDirectory(
at: directory, withIntermediateDirectories: false,
attributes: [.posixPermissions: 0o700])) != nil
else { return nil }
defer { try? FileManager.default.removeItem(at: directory) }
let outputURL = directory.appendingPathComponent("output")
guard
FileManager.default.createFile(
atPath: outputURL.path, contents: nil, attributes: [.posixPermissions: 0o600]),
let output = try? FileHandle(forWritingTo: outputURL)
else { return nil }
defer { try? output.close() }
let process = Process()
process.executableURL = tool
process.arguments = arguments
process.standardOutput = output
process.standardError = output
let finished = DispatchSemaphore(value: 0)
process.terminationHandler = { _ in finished.signal() }
do { try process.run() } catch { return nil }
guard finished.wait(timeout: .now() + 3) == .success else {
// A timed-out assessment is unknown, never rejected.
kill(process.processIdentifier, SIGKILL)
_ = finished.wait(timeout: .now() + 1)
return nil
}
guard let input = try? FileHandle(forReadingFrom: outputURL) else { return nil }
defer { try? input.close() }
guard let data = try? input.read(upToCount: 16_384) else { return nil }
return PluginInspectionCommandResult(
status: process.terminationStatus, output: String(bytes: data, encoding: .utf8) ?? "")
}
}
/// Reject evidence for another file, including a changed symlink target.
struct PluginFileIdentity: Equatable, Sendable {
let resolvedPath: String
let inode: UInt64
let device: Int32
let size: Int64
let modifiedSeconds: Int
let modifiedNanoseconds: Int
let changedSeconds: Int
let changedNanoseconds: Int
let permissions: UInt16
init?(path: String) {
let resolved = URL(fileURLWithPath: path).resolvingSymlinksInPath()
var facts = stat()
guard stat(resolved.path, &facts) == 0 else { return nil }
self.resolvedPath = resolved.path
self.inode = facts.st_ino
self.device = facts.st_dev
self.size = facts.st_size
self.modifiedSeconds = facts.st_mtimespec.tv_sec
self.modifiedNanoseconds = facts.st_mtimespec.tv_nsec
self.changedSeconds = facts.st_ctimespec.tv_sec
self.changedNanoseconds = facts.st_ctimespec.tv_nsec
self.permissions = facts.st_mode
}
}