RadrootsNativeRedirectServer.swift (3775B)
1 #if os(iOS) && targetEnvironment(simulator) 2 import Foundation 3 import Network 4 @testable import RadrootsKit 5 6 /// Isolated loopback fixture. No external DNS, TLS trust changes, or credentials. 7 final class NativeRedirectServer: @unchecked Sendable { 8 private let listener: NWListener 9 private let queue = DispatchQueue(label: "org.radroots.tests.redirect-http") 10 private let lock = NSLock() 11 private var connections: [NWConnection] = [] 12 private var received = 0 13 private var location: String? 14 private var wireResponse: Data? 15 16 init() throws { 17 let parameters = NWParameters.tcp 18 parameters.requiredLocalEndpoint = .hostPort(host: "127.0.0.1", port: .any) 19 listener = try NWListener(using: parameters) 20 } 21 22 var requestCount: Int { 23 lock.withLock { received } 24 } 25 26 func redirect(to location: String) { 27 lock.withLock { self.location = location } 28 } 29 30 func respond(with bytes: Data) { 31 lock.withLock { wireResponse = bytes } 32 } 33 34 func start() async throws -> UInt16 { 35 listener.newConnectionHandler = { [weak self] connection in 36 guard let self else { connection.cancel(); return } 37 connections.append(connection) 38 connection.start(queue: queue) 39 read(connection, accumulated: Data()) 40 } 41 listener.start(queue: queue) 42 for _ in 0 ..< 500 { 43 if let port = listener.port, port.rawValue > 0 { 44 return port.rawValue 45 } 46 try await Task.sleep(for: .milliseconds(10)) 47 } 48 throw RadrootsBackgroundTransferError.unavailable 49 } 50 51 func stop() { 52 listener.cancel() 53 queue.async { for connection in self.connections { 54 connection.cancel() 55 } } 56 } 57 58 private func read(_ connection: NWConnection, accumulated: Data) { 59 connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, _, complete, error in 60 var bytes = accumulated 61 if let data { 62 bytes.append(data) 63 } 64 guard error == nil, bytes.count <= 131_072 else { connection.cancel(); return } 65 if self.hasBody(bytes) { 66 let destination = self.lock.withLock { self.received += 1; return self.location } 67 let status = destination.map { "307 Temporary Redirect\r\nLocation: \($0)" } ?? "200 OK" 68 let response = self.lock.withLock { self.wireResponse } 69 ?? Data("HTTP/1.1 \(status)\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".utf8) 70 connection.send(content: response, completion: .contentProcessed { _ in connection.cancel() }) 71 } else if complete { 72 connection.cancel() 73 } else { 74 self.read(connection, accumulated: bytes) 75 } 76 } 77 } 78 79 private func hasBody(_ bytes: Data) -> Bool { 80 guard let separator = bytes.range(of: Data("\r\n\r\n".utf8)), 81 let header = String(data: bytes[..<separator.lowerBound], encoding: .utf8) 82 else { return false } 83 let length = header.components(separatedBy: "\r\n").first { 84 $0.lowercased().hasPrefix("content-length:") 85 }.flatMap { Int($0.dropFirst("content-length:".count).trimmingCharacters(in: .whitespaces)) } ?? 0 86 return (0 ... 65536).contains(length) && bytes.count - separator.upperBound == length 87 } 88 } 89 #endif