// // TokenPolicyDriftTests.swift — F3(2):三份令牌策略 + 服务端正则的漂移守卫 // // 问题:同一条访问令牌规则(cookie 名 `webterm_auth`,字符集 `[A-Za-z0-9._~+/=-]`, // 长度 16–512)现在有 **四份** 独立实现: // 1. `SessionCore/AuthCookie.swift` —— Swift `Regex` 字面量(WS upgrade 头) // 2. `HostRegistry/AccessToken.swift` —— `Set` + count(Keychain 边界) // 3. `APIClient/Endpoints.swift`(AccessToken.swift) —— `Set` + count(HTTP 头) // 4. `src/config.ts:178` `WEBTERM_TOKEN_RE` —— 服务端启动校验(唯一真源) // 安全复核(774 条对抗样本)确认它们**今天**一致,但没有任何东西**维持**这一致。 // // 为什么守卫必须落在这里:orchestrator 裁定 `WireProtocol` 冻结(共享密钥助手不 // 属于跨语言线协议契约),因此不能把三份合并成一份。IntegrationTests 是**唯一** // 能同时看见三个包的目标(见 Package.swift 注释),所以"守卫 = 测试"。 // // 判据(任意两者不一致即红): // - 三份 Swift 判定 + 服务端正则,在同一份对抗语料上**逐条同判**; // - 服务端正则不是硬编码的复制品:它在运行时从 `src/config.ts` **原文抽取**, // 所以服务端改规则 → 本用例立刻红(而不是三份 Swift 自洽地一起错)。 // - cookie 名同样三处对钉,并对钉 `src/http/auth.ts` 的 `AUTH_COOKIE_NAME`。 // // 密级纪律:语料里的"合法"样本是形状合法的假串,不是任何真令牌;失败信息只打印 // 判定结果与样本**标签**,绝不打印真实令牌(本文件也不接触真实令牌)。 import Foundation import HostRegistry import Testing @testable import APIClient @testable import SessionCore // ─── 服务端真源:运行时从 src/ 抽取(不复制粘贴,否则守卫会跟着一起漂) ──────── enum ServerTokenPolicy { /// `src/config.ts` 里 `const WEBTERM_TOKEN_RE = /…/` 的正则字面量原文。 static func extractedTokenPatternSource() throws -> String { let source = try readRepoFile("src/config.ts") guard let line = source.split(separator: "\n").first(where: { $0.contains("const WEBTERM_TOKEN_RE") }) else { throw HarnessError.setup("src/config.ts 里找不到 WEBTERM_TOKEN_RE 定义") } guard let open = line.firstIndex(of: "/"), let close = line.lastIndex(of: "/"), open < close else { throw HarnessError.setup("WEBTERM_TOKEN_RE 那一行不是 /…/ 正则字面量: \(line)") } return String(line[line.index(after: open).. String { let source = try readRepoFile("src/http/auth.ts") guard let line = source.split(separator: "\n").first(where: { $0.contains("export const AUTH_COOKIE_NAME") }) else { throw HarnessError.setup("src/http/auth.ts 里找不到 AUTH_COOKIE_NAME 定义") } guard let open = line.firstIndex(of: "'"), let close = line.lastIndex(of: "'"), open < close else { throw HarnessError.setup("AUTH_COOKIE_NAME 那一行没有单引号字面量: \(line)") } return String(line[line.index(after: open).. @Sendable (String) -> Bool { let jsSource = try extractedTokenPatternSource() var icuSource = jsSource guard icuSource.hasPrefix("^"), icuSource.hasSuffix("$") else { throw HarnessError.setup("WEBTERM_TOKEN_RE 不再是 ^…$ 整串锚定形状: \(jsSource)") } icuSource.removeFirst() icuSource.removeLast() let regex = try NSRegularExpression(pattern: "\\A\(icuSource)\\z") return { candidate in let range = NSRange(candidate.startIndex..., in: candidate) return regex.firstMatch(in: candidate, options: [], range: range) != nil } } private static func readRepoFile(_ relativePath: String) throws -> String { let url = try locateRepoRoot().appending(path: relativePath) guard let text = try? String(contentsOf: url, encoding: .utf8) else { throw HarnessError.setup("读不到 \(url.path)(服务端真源缺失)") } return text } } // ─── 对抗语料(标签 + 样本;失败时只打标签,不打样本内容) ──────────────────── struct TokenProbe: Sendable { let label: String let candidate: String } enum TokenCorpus { /// 16 字符的合法基底(形状合法的假串,不是任何真令牌)。 static let validBase = "AbCdEfGh01234567" static func repeated(_ unit: String, count: Int) -> String { String(repeating: unit, count: count) } /// 全部对抗样本。覆盖任务要求的每一类:全角形、组合记号、零宽、emoji、 /// U+2013 与 '-'、长度边界 15/16/512/513。 static let probes: [TokenProbe] = { var out: [TokenProbe] = [] func add(_ label: String, _ candidate: String) { out.append(TokenProbe(label: label, candidate: candidate)) } // ── 长度边界(ASCII,四份实现必须逐条同判) ── add("len15(ASCII)", String(repeated("a", count: 15))) add("len16(ASCII)", String(repeated("a", count: 16))) add("len512(ASCII)", String(repeated("a", count: 512))) add("len513(ASCII)", String(repeated("a", count: 513))) add("len0(empty)", "") // ── 字符集:合法集合逐字符 ── add("charset-all-legal", "AZaz09._~+/=-" + "abc") add("charset-dot-only", String(repeated(".", count: 16))) add("charset-slash-only", String(repeated("/", count: 16))) add("charset-equals-only", String(repeated("=", count: 16))) add("charset-tilde-only", String(repeated("~", count: 16))) add("charset-plus-only", String(repeated("+", count: 16))) add("charset-hyphen-only", String(repeated("-", count: 16))) // ── 字符集:合法但易被误判为非法的 ── // `_` **在**集合内(`[A-Za-z0-9._~+/=-]` 的 `.` 之后就是 `_`)。写死这条, // 是因为它极易被人肉读错成"不在集合内"(本任务作者就读错过一次,靠下面的 // 变异用例才发现)。 add("underscore(在集合内)", "AbCdEfGh0123456_") // ── 字符集:非法但"看起来像"的 ── add("colon", "AbCdEfGh0123456:") add("semicolon(cookie 分隔符)", "AbCdEfGh0123456;") add("space-interior", "AbCdEfGh 123456789") add("percent", "AbCdEfGh0123456%") add("backslash", "AbCdEfGh0123456\\") add("comma", "AbCdEfGh0123456,") add("quote-double", "AbCdEfGh0123456\"") // ── 头注入形状(CR/LF 必须被四份实现一致拒绝) ── add("CR-suffix", validBase + "\r") add("LF-suffix", validBase + "\n") add("CRLF-injection", validBase + "\r\nX-Evil: 1") add("LF-interior", "AbCdEfGh\n123456789") add("NUL-suffix", validBase + "\u{0}") add("TAB-suffix", validBase + "\t") // ── 首尾空白(HostRegistry 有意 trim —— 见 divergence 用例) ── add("leading-space", " " + validBase) add("trailing-space", validBase + " ") add("leading-newline", "\n" + validBase) // ── U+2013 EN DASH vs ASCII '-'(同形不同码) ── add("ascii-hyphen", "AbCdEfGh-1234567") add("en-dash-U+2013", "AbCdEfGh\u{2013}1234567") add("minus-sign-U+2212", "AbCdEfGh\u{2212}1234567") add("non-breaking-hyphen-U+2011", "AbCdEfGh\u{2011}1234567") // ── 全角形(Halfwidth and Fullwidth Forms) ── add("fullwidth-A(U+FF21)", "\u{FF21}bCdEfGh01234567") add("fullwidth-digit0(U+FF10)", "\u{FF10}bCdEfGh01234567") add("fullwidth-fullstop(U+FF0E)", "AbCdEfGh0123456\u{FF0E}") add("fullwidth-solidus(U+FF0F)", "AbCdEfGh0123456\u{FF0F}") add("fullwidth-all", String(repeated("\u{FF21}", count: 16))) // ── 组合记号(Swift 数 Character=1,JS 数码元=2 —— 若字符集放宽会立刻分叉) ── add("combining-acute", "A\u{0301}bCdEfGh01234567") add("combining-only", String(repeated("a\u{0301}", count: 16))) // Swift 数 Character = 256(在 16…512 窗口内),JS 数 UTF-16 码元 = 512 // (也在窗口内)—— 两种计数法都"长度合法",唯一拦住它的是字符集。 add("combining-256-graphemes-512-units", String(repeated("a\u{0301}", count: 256))) add("precomposed-e-acute", "\u{00E9}bCdEfGh01234567") // ── 零宽 / 不可见 ── add("zero-width-space(U+200B)", "AbCdEfGh\u{200B}1234567") add("zero-width-joiner(U+200D)", "AbCdEfGh\u{200D}1234567") add("zero-width-nbsp(U+FEFF)", "\u{FEFF}" + validBase) add("soft-hyphen(U+00AD)", "AbCdEfGh\u{00AD}1234567") add("nbsp(U+00A0)", "AbCdEfGh\u{00A0}1234567") add("LTR-override(U+202E)", "AbCdEfGh\u{202E}1234567") // ── emoji / 星际平面(Swift 1 Character、JS 2 码元) ── add("emoji-single", "AbCdEfGh\u{1F600}1234567") add("emoji-zwj-family", "AbCdEfG\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}1234567") add("emoji-flag", "AbCdEfGh\u{1F1E8}\u{1F1F3}123456") add("emoji-only-16-graphemes", String(repeated("\u{1F600}", count: 16))) add("emoji-fill-to-512-units", String(repeated("\u{1F600}", count: 256))) // ── 同形西里尔 / 希腊(homoglyph) ── add("cyrillic-a(U+0430)", "\u{0430}bCdEfGh01234567") add("greek-omicron(U+03BF)", "\u{03BF}bCdEfGh01234567") // ── 超长边界的非 ASCII 组合 ── add("len513-with-fullwidth-tail", String(repeated("a", count: 512)) + "\u{FF21}") add("len511-plus-emoji", String(repeated("a", count: 511)) + "\u{1F600}") return out }() } // ─── 四份实现的统一判定入口 ────────────────────────────────────────────────── struct TokenVerdicts: Equatable { let sessionCore: Bool let apiClient: Bool let hostRegistry: Bool let server: Bool /// SessionCore / APIClient / 服务端三者必须**完全**同判。 /// HostRegistry 单列:它在验证前 trim 首尾空白(有意为之,见其类型注释), /// 因此只在"无首尾空白"的样本上参与全等断言(padded 分歧另有专门用例钉住)。 var coreThreeAgree: Bool { sessionCore == apiClient && apiClient == server } var allFourAgree: Bool { coreThreeAgree && hostRegistry == server } var summary: String { "SessionCore=\(sessionCore) APIClient=\(apiClient) " + "HostRegistry=\(hostRegistry) server=\(server)" } } /// 四个判定器的集合。生产判定固定为下面的 `live(server:)`; /// `disagreementLabels` 之所以接受任意四元组,是为了能**用一个刻意漂移的替身** /// 证明比较逻辑真的会红(守卫自身的变异测试,且不需要动 Owns 之外的任何文件)。 struct TokenPolicySet { let sessionCore: @Sendable (String) -> Bool let apiClient: @Sendable (String) -> Bool let hostRegistry: @Sendable (String) -> Bool let server: @Sendable (String) -> Bool /// 生产四元组:三份 Swift 实现 + 从 src/ 抽取的服务端正则。 static func live(server: @escaping @Sendable (String) -> Bool) -> TokenPolicySet { TokenPolicySet( // 模块内部符号:两个包各有一个 internal `AuthCookie`;SessionCore 侧可以 // 限定模块名,APIClient 侧因模块名与类型名撞车,走 APIClientTokenPolicy 窗口。 sessionCore: { SessionCore.AuthCookie.isValidToken($0) }, apiClient: { APIClientTokenPolicy.isWellFormed($0) }, hostRegistry: { AccessToken(rawValue: $0) != nil }, server: server ) } func verdicts(for candidate: String) -> TokenVerdicts { TokenVerdicts( sessionCore: sessionCore(candidate), apiClient: apiClient(candidate), hostRegistry: hostRegistry(candidate), server: server(candidate) ) } /// 语料上所有分歧样本的**标签**(绝不含样本内容 —— 样本可能是形似令牌的串)。 func disagreementLabels(over probes: [TokenProbe]) -> [String] { probes.compactMap { probe in let verdicts = self.verdicts(for: probe.candidate) // 无首尾空白 ⇒ HostRegistry 的 trim 不改变输入,四份直接全等比较; // 带空白的样本只比另外三份(trim 分歧另有专门用例钉住)。 let agrees = hasSurroundingWhitespace(probe.candidate) ? verdicts.coreThreeAgree : verdicts.allFourAgree return agrees ? nil : "[\(probe.label)] \(verdicts.summary)" } } } func evaluateTokenPolicies( _ candidate: String, server: @escaping @Sendable (String) -> Bool ) -> TokenVerdicts { TokenPolicySet.live(server: server).verdicts(for: candidate) } /// 无首尾空白 ⇒ HostRegistry 的 trim 不会改变输入,四份可直接全等比较。 func hasSurroundingWhitespace(_ candidate: String) -> Bool { candidate != candidate.trimmingCharacters(in: .whitespacesAndNewlines) } // ─── 用例 ──────────────────────────────────────────────────────────────────── @Suite("F3 令牌策略漂移守卫(三份 Swift + 服务端正则)") struct TokenPolicyDriftTests { @Test("对抗语料逐条同判:任意两份实现分歧即红") func allImplementationsAgreeOverAdversarialCorpus() throws { let serverPredicate = try ServerTokenPolicy.makeEvaluator() let disagreements = TokenPolicySet.live(server: serverPredicate) .disagreementLabels(over: TokenCorpus.probes) #expect( disagreements.isEmpty, """ 令牌策略已漂移(\(disagreements.count)/\(TokenCorpus.probes.count) 条样本分歧)。 四份实现必须同判:SessionCore/AuthCookie.swift、HostRegistry/AccessToken.swift、 APIClient/AccessToken.swift、src/config.ts:WEBTERM_TOKEN_RE。 分歧样本(只打标签,不打令牌): \(disagreements.joined(separator: "\n")) """ ) // 语料本身不许被悄悄掏空(否则"零分歧"是假绿)。 #expect(TokenCorpus.probes.count >= 50, "对抗语料被削减到 \(TokenCorpus.probes.count) 条 —— 守卫失去意义") } /// 守卫自身的变异测试:把任一份实现换成"刻意漂移一点点"的替身,比较逻辑必须 /// 立刻报出分歧。没有这条,上面那条用例即使写成恒真也没人发现(假绿)。 /// 用替身而不是真去改 SessionCore/HostRegistry/APIClient —— 那些文件不在本任务 /// 的 Owns 里,且变异测试本来就不该留下副作用。 @Test("守卫自身是响的:任一份实现哪怕只放宽一个字符,比较逻辑也必须报分歧") func guardItselfDetectsEachSingleDrift() throws { let serverPredicate = try ServerTokenPolicy.makeEvaluator() let live = TokenPolicySet.live(server: serverPredicate) // 漂移替身:多接受一个 `:`(**不**在服务端的 `[A-Za-z0-9._~+/=-]` 里; // 注意 `_` 是在集合内的,用它当替身等于什么都没变 —— 第一版就踩了这个坑, // 正是本用例把它抓了出来)。 let drifted: @Sendable (String) -> Bool = { candidate in let relaxed = candidate.replacingOccurrences(of: ":", with: ".") return APIClientTokenPolicy.isWellFormed(relaxed) } let mutants: [(String, TokenPolicySet)] = [ ("SessionCore", TokenPolicySet( sessionCore: drifted, apiClient: live.apiClient, hostRegistry: live.hostRegistry, server: live.server)), ("APIClient", TokenPolicySet( sessionCore: live.sessionCore, apiClient: drifted, hostRegistry: live.hostRegistry, server: live.server)), ("HostRegistry", TokenPolicySet( sessionCore: live.sessionCore, apiClient: live.apiClient, hostRegistry: drifted, server: live.server)), ("server", TokenPolicySet( sessionCore: live.sessionCore, apiClient: live.apiClient, hostRegistry: live.hostRegistry, server: drifted)), ] for (name, mutant) in mutants { let found = mutant.disagreementLabels(over: TokenCorpus.probes) #expect(!found.isEmpty, "\(name) 漂移未被守卫抓到 —— 守卫失效(假绿)") } // 对照组:未变异时必须干净(否则上面的"抓到了"可能只是本来就红)。 #expect(live.disagreementLabels(over: TokenCorpus.probes).isEmpty) } @Test("语料确实两侧都覆盖:既有被四份一致接受的,也有被四份一致拒绝的") func corpusExercisesBothVerdicts() throws { let serverPredicate = try ServerTokenPolicy.makeEvaluator() let verdicts = TokenCorpus.probes.map { evaluateTokenPolicies($0.candidate, server: serverPredicate).server } // 若某天字符集被改成"什么都拒绝",上一条用例会全绿(全 false 也叫一致), // 这条防的就是那种假绿。 #expect(verdicts.contains(true), "语料里没有任何被接受的样本 —— 上一条用例会假绿") #expect(verdicts.contains(false), "语料里没有任何被拒绝的样本 —— 上一条用例会假绿") } @Test("服务端正则原文未变:字符集/长度窗口逐字节对钉") func serverPatternIsStillTheFrozenShape() throws { let pattern = try ServerTokenPolicy.extractedTokenPatternSource() // 契约 §1.1 冻结值。改服务端规则 ⇒ 这里红 ⇒ 必须同步改三份 Swift。 #expect(pattern == "^[A-Za-z0-9._~+/=-]{16,512}$", "src/config.ts 的 WEBTERM_TOKEN_RE 变了(实测 \(pattern));三份 Swift 实现必须同步更新") } @Test("长度窗口 15/16/512/513:四份实现同判且落在 16…512") func lengthBoundariesAreIdenticalEverywhere() throws { let serverPredicate = try ServerTokenPolicy.makeEvaluator() let cases: [(length: Int, expected: Bool)] = [ (15, false), (16, true), (511, true), (512, true), (513, false), ] for (length, expected) in cases { let candidate = String(repeating: "a", count: length) let verdicts = evaluateTokenPolicies(candidate, server: serverPredicate) #expect(verdicts.allFourAgree, "长度 \(length): \(verdicts.summary)") #expect(verdicts.server == expected, "长度 \(length) 应为 \(expected),实测 \(verdicts.summary)") } } @Test("cookie 名三处 + 服务端 AUTH_COOKIE_NAME 全等") func cookieNameIsPinnedEverywhere() throws { let serverName = try ServerTokenPolicy.extractedCookieName() #expect(serverName == "webterm_auth", "src/http/auth.ts 的 AUTH_COOKIE_NAME 变了(实测 \(serverName))") #expect(SessionCore.AuthCookie.name == serverName, "SessionCore.AuthCookie.name=\(SessionCore.AuthCookie.name) ≠ 服务端 \(serverName)") #expect(APIClientTokenPolicy.cookieName == serverName, "APIClient.AuthCookie.name=\(APIClientTokenPolicy.cookieName) ≠ 服务端 \(serverName)") } @Test("cookie 头拼装形状三处一致:name=,无分号无空格") func cookieHeaderAssemblyIsIdentical() throws { let token = TokenCorpus.validBase let fromSessionCore = SessionCore.AuthCookie.headerValue(token: token) let fromAPIClient = APIClientTokenPolicy.headerValue(for: token) #expect(fromSessionCore == "webterm_auth=\(token)") #expect(fromAPIClient == fromSessionCore, "两份 cookie 头拼装分歧:APIClient=\(fromAPIClient) SessionCore=\(fromSessionCore ?? "nil")") // 拼装结果绝不能带分隔符 —— 那是头注入/cookie 拆分的形状。 #expect(fromAPIClient.contains(";") == false) #expect(fromAPIClient.contains("\r") == false && fromAPIClient.contains("\n") == false) } @Test("已知且有意的分歧:HostRegistry 先 trim 首尾空白,另两份原样拒绝") func hostRegistryTrimDivergenceIsIntentionalAndBounded() throws { let serverPredicate = try ServerTokenPolicy.makeEvaluator() let padded = " \(TokenCorpus.validBase) " let verdicts = evaluateTokenPolicies(padded, server: serverPredicate) // 这不是漂移,是 AccessToken 类型注释里写明的入口归一化(粘贴场景)。 // 钉住它,是为了让"未来某天 trim 被去掉/被扩散到另外两份"也变成红。 #expect(verdicts.hostRegistry, "HostRegistry 应接受首尾空白并归一化") #expect(verdicts.sessionCore == false, "SessionCore 应原样拒绝带空白的串") #expect(verdicts.apiClient == false, "APIClient 应原样拒绝带空白的串") #expect(verdicts.server == false, "服务端正则应原样拒绝带空白的串") // 归一化的产物必须是四份都接受的 —— 否则 trim 会造出一个"存得下、发不出"的令牌。 let normalized = try #require(AccessToken(rawValue: padded)).rawValue let afterTrim = evaluateTokenPolicies(normalized, server: serverPredicate) #expect(afterTrim.allFourAgree && afterTrim.server, "trim 后的令牌必须四份都接受,实测 \(afterTrim.summary)") } }