Files
ShadowsocksX-NG/ShadowsocksX-NG2/Domain/Subscription/SubscriptionFetch.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

125 lines
4.7 KiB
Swift

import Foundation
/// 订阅获取缝(spec #21 D4):HTTPS 强制、TLS/证书校验不放松、Content-Type
/// 与状态码按 SIP-008 契约检查。测试注入夹具,生产走 URLSession 实现。
protocol SubscriptionFetching: Sendable {
func fetch(_ url: URL) async throws -> Data
}
/// 获取失败点名原因。任何细节都不携带订阅 URL(D5:完整 URL 与 token 永不
/// 进入错误文案、日志与诊断)。
enum SubscriptionFetchError: Error, Equatable, Sendable {
/// URL 无法构造或没有主机。
case invalidURL
/// 非 HTTPS 地址(宽松模式永不提供,issue #35 Out of Scope)。
case unsupportedScheme
/// 重定向目标不是 HTTPS(降级一律拒绝)。
case insecureRedirect
/// 连接/TLS 握手失败等传输层错误(默认证书校验失败也在此呈现)。
case transport(detail: String)
/// HTTP 状态码非 200。
case httpStatus(code: Int)
/// Content-Type 不是 `application/json; charset=utf-8`(大小写与空白不敏感)。
case contentType(received: String?)
}
/// URLSession 实现:重定向守卫只放行 https→https;TLS 校验用系统默认行为
/// (从不实现证书忽略)。响应契约校验抽为纯函数便于夹具测试。
struct HTTPSSubscriptionFetcher: SubscriptionFetching {
/// 单次请求超时(秒)。
var timeoutInterval: TimeInterval = 15
private static let requiredContentType = "application/json;charset=utf-8"
func fetch(_ url: URL) async throws -> Data {
guard url.scheme?.lowercased() == "https" else {
throw SubscriptionFetchError.unsupportedScheme
}
guard url.host != nil else { throw SubscriptionFetchError.invalidURL }
var request = URLRequest(url: url)
request.timeoutInterval = timeoutInterval
// 守卫是任务级 delegate:只拦截非 HTTPS 降级重定向,不触碰 TLS 校验链路。
let redirectGuard = RedirectGuard()
let data: Data
let response: URLResponse
do {
(data, response) = try await Self.session.data(for: request, delegate: redirectGuard)
} catch is CancellationError {
throw CancellationError()
} catch {
throw SubscriptionFetchError.transport(detail: Self.redactedTransportDetail(error))
}
if redirectGuard.didBlockInsecureRedirect {
throw SubscriptionFetchError.insecureRedirect
}
guard let http = response as? HTTPURLResponse else {
throw SubscriptionFetchError.transport(detail: "nonHTTPResponse")
}
try Self.validate(
statusCode: http.statusCode,
contentType: http.value(forHTTPHeaderField: "Content-Type"))
return data
}
/// 响应契约(纯函数):200 + `application/json; charset=utf-8`。
static func validate(statusCode: Int, contentType: String?) throws {
guard statusCode == 200 else { throw SubscriptionFetchError.httpStatus(code: statusCode) }
let normalized = (contentType ?? "").lowercased().filter { !$0.isWhitespace }
guard normalized == requiredContentType else {
throw SubscriptionFetchError.contentType(received: contentType)
}
}
/// 重定向放行策略(纯函数):仅 https → https。
static func allowsRedirect(to scheme: String?) -> Bool {
scheme?.lowercased() == "https"
}
/// 传输错误脱敏描述:URLError 只取错误码符号名;其余只取类型名。两者都
/// 不携带 URL 或主机(D5)。
static func redactedTransportDetail(_ error: Error) -> String {
if let urlError = error as? URLError {
return "URLError.\(String(describing: urlError.code))"
}
return String(describing: type(of: error))
}
/// 共享会话(无会话级 delegate)。
private static let session: URLSession = {
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = 15
return URLSession(configuration: configuration)
}()
}
/// 重定向守卫:跟随 https→https;降级到非 HTTPS 时拒绝并标记,请求随后以
/// 3xx 原样响应或取消收场,由调用方按标记点名 `insecureRedirect`。
private final class RedirectGuard: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
private let lock = NSLock()
private var blocked = false
func urlSession(
_ session: URLSession,
task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse,
newRequest request: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void
) {
guard HTTPSSubscriptionFetcher.allowsRedirect(to: request.url?.scheme) else {
lock.lock()
blocked = true
lock.unlock()
completionHandler(nil)
return
}
completionHandler(request)
}
var didBlockInsecureRedirect: Bool {
lock.lock()
defer { lock.unlock() }
return blocked
}
}