feat(ios): 中继通道 P4/P5——端点抽象、信封、传输、配对与分层错误
- InstanceEndpoint 取代 Instance.origin(旧数据 init(from:) 读成 .direct, 写盘只输出 endpoint;dedupKey 直通用 origin、中继用 relay:<nodeID>) - RelayWire:Swift 侧重算 contracts/test-vectors.json(CanonicalJSON 手写解析器, 因为 Foundation 的解码器会抹掉'拼写',而 intent 摘要定义的正是拼写) - NodeTransport 抽象 + DirectTransport / RelayTransport / NodeTransportRouter; DirectTransport 是薄适配,直通路径行为不变 - RelayTransport:信封构造、签名、加密、验签、解密;响应验签失败即丢弃, 四类以上失败各有独立 UpstreamError 分支与用户文案 - DeviceIdentity(沙盒文件 0600,配对时由节点分配 deviceID)、RelayPairing、配对 sheet - 卡片通道徽标 + freshness 副标题;危险写 intent 在发送瞬间生成 - scripts/export-app-routes.swift 生成 vendored 路由表,AppRoutesContractTests 反向守卫 - 测试 78→92:向量互算、迁移(字面量旧 JSON)、配对四类失败、路由器、 传输往返与各类拒绝路径
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
import Foundation
|
||||
|
||||
/// The canonical JSON form this protocol hashes.
|
||||
///
|
||||
/// This is a hand-written parser rather than `JSONSerialization`, and the reason is not preference:
|
||||
/// the canonical form is defined over the *spelling* of the input, and every Foundation decoder
|
||||
/// erases spelling before the caller sees anything. `1.0` and `1` both arrive as `NSNumber(1)`;
|
||||
/// a lone surrogate is either rejected outright or silently repaired depending on the framework
|
||||
/// version. Trying to recover the original text from a decoded tree is how two languages end up
|
||||
/// hashing different bytes for the same body, which surfaces as an intent mismatch the phone
|
||||
/// reports as "confirmation failed" with no way to tell which side was wrong.
|
||||
///
|
||||
/// The rules, which the Go agent and the Node reference implementation implement identically, are
|
||||
/// documented in `agent/internal/wire/json.go`. The short version: keys sorted by UTF-8 bytes,
|
||||
/// no insignificant whitespace, integers only in canonical spelling, strings escaped as `\" \\ \n
|
||||
/// \r \t \b \f`, `\u00XX` for other control characters, literal UTF-8 for everything else, and a
|
||||
/// lone surrogate escape is an error rather than a replacement character.
|
||||
public enum CanonicalJSON {
|
||||
public enum Failure: Error, Equatable {
|
||||
case invalidJSON
|
||||
case trailingData
|
||||
case nonCanonicalNumber(String)
|
||||
case loneSurrogate(UInt32)
|
||||
case unsupportedValue
|
||||
}
|
||||
|
||||
/// Canonicalises a JSON document. An empty or whitespace-only body canonicalises to no bytes at
|
||||
/// all, not to the four characters `null`, so "no body" and "empty body" cannot be confused.
|
||||
public static func canonicalize(_ text: String) throws -> Data {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return Data() }
|
||||
var parser = Parser(Array(trimmed.unicodeScalars))
|
||||
let value = try parser.parseValue()
|
||||
parser.skipWhitespace()
|
||||
guard parser.isAtEnd else { throw Failure.trailingData }
|
||||
var out = Data()
|
||||
try serialize(value, into: &out)
|
||||
return out
|
||||
}
|
||||
|
||||
/// Canonicalises raw body bytes, which is what the intent digest needs.
|
||||
public static func canonicalize(_ data: Data) throws -> Data {
|
||||
guard let text = String(data: data, encoding: .utf8) else { throw Failure.invalidJSON }
|
||||
return try canonicalize(text)
|
||||
}
|
||||
|
||||
// MARK: - Value tree
|
||||
|
||||
indirect enum Value {
|
||||
case null
|
||||
case bool(Bool)
|
||||
case integer(String)
|
||||
case string(String)
|
||||
case array([Value])
|
||||
case object([(key: String, value: Value)])
|
||||
}
|
||||
|
||||
// MARK: - Serialisation
|
||||
|
||||
static func serialize(_ value: Value, into out: inout Data) throws {
|
||||
switch value {
|
||||
case .null:
|
||||
out.append(contentsOf: Array("null".utf8))
|
||||
case .bool(let flag):
|
||||
out.append(contentsOf: Array((flag ? "true" : "false").utf8))
|
||||
case .integer(let literal):
|
||||
out.append(contentsOf: Array(literal.utf8))
|
||||
case .string(let text):
|
||||
escape(text, into: &out)
|
||||
case .array(let items):
|
||||
out.append(UInt8(ascii: "["))
|
||||
for (index, item) in items.enumerated() {
|
||||
if index > 0 { out.append(UInt8(ascii: ",")) }
|
||||
try serialize(item, into: &out)
|
||||
}
|
||||
out.append(UInt8(ascii: "]"))
|
||||
case .object(let members):
|
||||
// UTF-8 byte order, which is what the Go agent's sort.Strings produces. Two shortcuts
|
||||
// are wrong here and both look right on ASCII: Swift's default String `<` uses Unicode
|
||||
// collation (so "é" sorts before "z"), and Foundation's `.sortedKeys` does the same.
|
||||
let sorted = members.sorted { Array($0.key.utf8).lexicographicallyPrecedes(Array($1.key.utf8)) }
|
||||
out.append(UInt8(ascii: "{"))
|
||||
for (index, member) in sorted.enumerated() {
|
||||
if index > 0 { out.append(UInt8(ascii: ",")) }
|
||||
escape(member.key, into: &out)
|
||||
out.append(UInt8(ascii: ":"))
|
||||
try serialize(member.value, into: &out)
|
||||
}
|
||||
out.append(UInt8(ascii: "}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes a JSON string literal in the canonical spelling.
|
||||
///
|
||||
/// The two rules worth stating: control characters below 0x20 use the short forms JSON names
|
||||
/// where they exist and `\u00XX` otherwise (matching Go's encoder with HTML escaping off), and
|
||||
/// everything else — including U+2028, U+2029, DEL and astral characters — is emitted as
|
||||
/// literal UTF-8. Go's encoder escapes U+2028/U+2029 even with HTML escaping disabled, which is
|
||||
/// why the Go side had to grow its own writer too; the vectors pin the literal form.
|
||||
static func escape(_ text: String, into out: inout Data) {
|
||||
out.append(UInt8(ascii: "\""))
|
||||
for scalar in text.unicodeScalars {
|
||||
switch scalar {
|
||||
case "\"":
|
||||
out.append(contentsOf: Array("\\\"".utf8))
|
||||
case "\\":
|
||||
out.append(contentsOf: Array("\\\\".utf8))
|
||||
case "\n":
|
||||
out.append(contentsOf: Array("\\n".utf8))
|
||||
case "\r":
|
||||
out.append(contentsOf: Array("\\r".utf8))
|
||||
case "\t":
|
||||
out.append(contentsOf: Array("\\t".utf8))
|
||||
case Unicode.Scalar(0x08):
|
||||
out.append(contentsOf: Array("\\b".utf8))
|
||||
case Unicode.Scalar(0x0C):
|
||||
out.append(contentsOf: Array("\\f".utf8))
|
||||
default:
|
||||
if scalar.value < 0x20 {
|
||||
let hex = String(format: "%04x", scalar.value)
|
||||
out.append(contentsOf: Array("\\u\(hex)".utf8))
|
||||
} else {
|
||||
out.append(contentsOf: Array(String(scalar).utf8))
|
||||
}
|
||||
}
|
||||
}
|
||||
out.append(UInt8(ascii: "\""))
|
||||
}
|
||||
|
||||
// MARK: - Parser
|
||||
|
||||
struct Parser {
|
||||
private let scalars: [Unicode.Scalar]
|
||||
private var index = 0
|
||||
|
||||
init(_ scalars: [Unicode.Scalar]) {
|
||||
self.scalars = scalars
|
||||
}
|
||||
|
||||
var isAtEnd: Bool { index >= scalars.count }
|
||||
|
||||
mutating func skipWhitespace() {
|
||||
while index < scalars.count, isSpace(scalars[index]) { index += 1 }
|
||||
}
|
||||
|
||||
private func isSpace(_ scalar: Unicode.Scalar) -> Bool {
|
||||
scalar == " " || scalar == "\t" || scalar == "\n" || scalar == "\r"
|
||||
}
|
||||
|
||||
private func peek() -> Unicode.Scalar? {
|
||||
index < scalars.count ? scalars[index] : nil
|
||||
}
|
||||
|
||||
private mutating func expect(_ scalar: Unicode.Scalar) throws {
|
||||
guard peek() == scalar else { throw Failure.invalidJSON }
|
||||
index += 1
|
||||
}
|
||||
|
||||
mutating func parseValue() throws -> Value {
|
||||
skipWhitespace()
|
||||
guard let scalar = peek() else { throw Failure.invalidJSON }
|
||||
switch scalar {
|
||||
case "{": return try parseObject()
|
||||
case "[": return try parseArray()
|
||||
case "\"": return .string(try parseString())
|
||||
case "t":
|
||||
try expectWord("true")
|
||||
return .bool(true)
|
||||
case "f":
|
||||
try expectWord("false")
|
||||
return .bool(false)
|
||||
case "n":
|
||||
try expectWord("null")
|
||||
return .null
|
||||
default:
|
||||
return .integer(try parseNumber())
|
||||
}
|
||||
}
|
||||
|
||||
private mutating func expectWord(_ word: String) throws {
|
||||
for scalar in word.unicodeScalars {
|
||||
try expect(scalar)
|
||||
}
|
||||
}
|
||||
|
||||
private mutating func parseObject() throws -> Value {
|
||||
try expect("{")
|
||||
var members: [(key: String, value: Value)] = []
|
||||
skipWhitespace()
|
||||
if peek() == "}" {
|
||||
index += 1
|
||||
return .object(members)
|
||||
}
|
||||
while true {
|
||||
skipWhitespace()
|
||||
let key = try parseString()
|
||||
skipWhitespace()
|
||||
try expect(":")
|
||||
let value = try parseValue()
|
||||
// A repeated key takes the last occurrence, which is what Go's decoder and
|
||||
// JSON.parse both do. Refusing here would make the phone stricter than the agent
|
||||
// that recomputes the intent digest, and the two disagreeing about a body is the
|
||||
// failure this whole file exists to prevent.
|
||||
if let existing = members.firstIndex(where: { $0.key == key }) {
|
||||
members[existing] = (key, value)
|
||||
} else {
|
||||
members.append((key, value))
|
||||
}
|
||||
skipWhitespace()
|
||||
if peek() == "," {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
try expect("}")
|
||||
return .object(members)
|
||||
}
|
||||
}
|
||||
|
||||
private mutating func parseArray() throws -> Value {
|
||||
try expect("[")
|
||||
var items: [Value] = []
|
||||
skipWhitespace()
|
||||
if peek() == "]" {
|
||||
index += 1
|
||||
return .array(items)
|
||||
}
|
||||
while true {
|
||||
items.append(try parseValue())
|
||||
skipWhitespace()
|
||||
if peek() == "," {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
try expect("]")
|
||||
return .array(items)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a string, resolving escapes.
|
||||
///
|
||||
/// A `\uXXXX` escape in the surrogate range must be correctly paired. A lone surrogate is
|
||||
/// an error here for the same reason it is in Go and Node: the three frameworks disagree
|
||||
/// about what to do with one, so the only rule all three can share is to accept none.
|
||||
private mutating func parseString() throws -> String {
|
||||
try expect("\"")
|
||||
var output = String.UnicodeScalarView()
|
||||
while true {
|
||||
guard let scalar = peek() else { throw Failure.invalidJSON }
|
||||
index += 1
|
||||
switch scalar {
|
||||
case "\"":
|
||||
return String(output)
|
||||
case "\\":
|
||||
output.append(try parseEscape())
|
||||
default:
|
||||
if scalar.value < 0x20 { throw Failure.invalidJSON }
|
||||
output.append(scalar)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private mutating func parseEscape() throws -> Unicode.Scalar {
|
||||
guard let scalar = peek() else { throw Failure.invalidJSON }
|
||||
index += 1
|
||||
switch scalar {
|
||||
case "\"": return "\""
|
||||
case "\\": return "\\"
|
||||
case "/": return "/"
|
||||
case "b": return Unicode.Scalar(0x08)
|
||||
case "f": return Unicode.Scalar(0x0C)
|
||||
case "n": return "\n"
|
||||
case "r": return "\r"
|
||||
case "t": return "\t"
|
||||
case "u":
|
||||
let high = try parseHex4()
|
||||
if high >= 0xD800, high <= 0xDBFF {
|
||||
guard peek() == "\\" else { throw Failure.loneSurrogate(high) }
|
||||
index += 1
|
||||
guard peek() == "u" else { throw Failure.loneSurrogate(high) }
|
||||
index += 1
|
||||
let low = try parseHex4()
|
||||
guard low >= 0xDC00, low <= 0xDFFF else { throw Failure.loneSurrogate(high) }
|
||||
let combined = 0x10000 + ((high - 0xD800) << 10) + (low - 0xDC00)
|
||||
guard let scalar = Unicode.Scalar(combined) else { throw Failure.loneSurrogate(high) }
|
||||
return scalar
|
||||
}
|
||||
guard high < 0xDC00 || high > 0xDFFF else { throw Failure.loneSurrogate(high) }
|
||||
guard let scalar = Unicode.Scalar(high) else { throw Failure.loneSurrogate(high) }
|
||||
return scalar
|
||||
default:
|
||||
throw Failure.invalidJSON
|
||||
}
|
||||
}
|
||||
|
||||
private mutating func parseHex4() throws -> UInt32 {
|
||||
var value: UInt32 = 0
|
||||
for _ in 0..<4 {
|
||||
guard let scalar = peek(), let digit = hexDigit(scalar) else { throw Failure.invalidJSON }
|
||||
index += 1
|
||||
value = value << 4 | digit
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private func hexDigit(_ scalar: Unicode.Scalar) -> UInt32? {
|
||||
switch scalar {
|
||||
case "0"..."9": return scalar.value - 0x30
|
||||
case "a"..."f": return scalar.value - 0x61 + 10
|
||||
case "A"..."F": return scalar.value - 0x41 + 10
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses an integer in the one spelling this protocol hashes.
|
||||
///
|
||||
/// The literal is kept as text rather than converted to a number, so what gets hashed is
|
||||
/// what the phone sent. `1.0`, `1e3`, `+1`, `.5`, `01` and `-0` are all rejected: each of
|
||||
/// them is the same number as some accepted spelling, and "which spelling did the phone
|
||||
/// hash" is not a question this protocol leaves open.
|
||||
private mutating func parseNumber() throws -> String {
|
||||
var literal = ""
|
||||
if peek() == "-" {
|
||||
literal.append("-")
|
||||
index += 1
|
||||
}
|
||||
guard let first = peek(), first >= "0", first <= "9" else {
|
||||
throw Failure.invalidJSON
|
||||
}
|
||||
if first == "0" {
|
||||
literal.append("0")
|
||||
index += 1
|
||||
if let next = peek(), next >= "0", next <= "9" {
|
||||
throw Failure.nonCanonicalNumber(literal + String(next))
|
||||
}
|
||||
} else {
|
||||
while let next = peek(), next >= "0", next <= "9" {
|
||||
literal.append(Character(next))
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
if let next = peek(), next == "." || next == "e" || next == "E" || next == "+" {
|
||||
throw Failure.nonCanonicalNumber(literal + String(next))
|
||||
}
|
||||
if literal == "-0" { throw Failure.nonCanonicalNumber(literal) }
|
||||
return literal
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user