mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-08 11:04: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 文件时两个用例均 失败并点名违规文件,清理后复验为绿。
157 lines
5.7 KiB
Swift
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
|
|
}
|
|
}
|