From f19d6082269141f7b36d0fe5da0f463db0a8f5d2 Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Tue, 22 Sep 2026 10:14:00 -0700 Subject: [PATCH 1/4] Add DNS-over-HTTPS TXT and well-known handle-resolution components Ports DNSWireFormat, DNSTXTFetcher, and DoHTXTFetcher (RFC 1035 / RFC 8484), and adds two new public resolvers alongside DidWebResolver and DidPlcResolver: WellKnownHandleResolver (HTTPS well-known method, over the injected HTTPFetcher seam) and DnsHandleResolver (DNS TXT method, defaulting to DoHTXTFetcher). Both refuse redirects and validate their result through Atproto.DID.init(string:), matching the existing resolvers' security shape. Co-Authored-By: Claude Sonnet 5 --- .changeset/dns-wellknown-handle-resolvers.md | 5 + Sources/AtprotoClient/DNS/DNSTXTFetcher.swift | 47 +++++ Sources/AtprotoClient/DNS/DNSWireFormat.swift | 182 ++++++++++++++++++ .../AtprotoClient/DNS/DnsHandleResolver.swift | 55 ++++++ Sources/AtprotoClient/DNS/DoHTXTFetcher.swift | 110 +++++++++++ .../DNS/WellKnownHandleResolver.swift | 119 ++++++++++++ .../AtprotoClientTests/DNS/DNSFixtures.swift | 51 +++++ .../DNS/DNSWireFormatTests.swift | 181 +++++++++++++++++ .../DNS/DnsHandleResolverTests.swift | 109 +++++++++++ .../DNS/DoHTXTFetcherTests.swift | 176 +++++++++++++++++ .../DNS/WellKnownHandleResolverTests.swift | 131 +++++++++++++ 11 files changed, 1166 insertions(+) create mode 100644 .changeset/dns-wellknown-handle-resolvers.md create mode 100644 Sources/AtprotoClient/DNS/DNSTXTFetcher.swift create mode 100644 Sources/AtprotoClient/DNS/DNSWireFormat.swift create mode 100644 Sources/AtprotoClient/DNS/DnsHandleResolver.swift create mode 100644 Sources/AtprotoClient/DNS/DoHTXTFetcher.swift create mode 100644 Sources/AtprotoClient/DNS/WellKnownHandleResolver.swift create mode 100644 Tests/AtprotoClientTests/DNS/DNSFixtures.swift create mode 100644 Tests/AtprotoClientTests/DNS/DNSWireFormatTests.swift create mode 100644 Tests/AtprotoClientTests/DNS/DnsHandleResolverTests.swift create mode 100644 Tests/AtprotoClientTests/DNS/DoHTXTFetcherTests.swift create mode 100644 Tests/AtprotoClientTests/DNS/WellKnownHandleResolverTests.swift diff --git a/.changeset/dns-wellknown-handle-resolvers.md b/.changeset/dns-wellknown-handle-resolvers.md new file mode 100644 index 0000000..7cfafc4 --- /dev/null +++ b/.changeset/dns-wellknown-handle-resolvers.md @@ -0,0 +1,5 @@ +--- +"@germ-network/atprotoclient": minor +--- + +Add DNS-over-HTTPS TXT and well-known atproto-did handle-resolution components: `Atproto.WellKnownHandleResolver` and `Atproto.DnsHandleResolver`, plus the underlying `DNSWireFormat` codec, `DNSTXTFetcher` protocol, and `DoHTXTFetcher` conformer they're built on. Together these implement the two handle-resolution methods from the atproto handle spec (https://atproto.com/specs/handle) - the DNS TXT method and the HTTPS well-known method - as standalone, portable, `Sendable` components matching `DidWebResolver`/`DidPlcResolver`'s conventions: redirect refusal, response-size bounds, and DID validation via `Atproto.DID.init(string:)`. diff --git a/Sources/AtprotoClient/DNS/DNSTXTFetcher.swift b/Sources/AtprotoClient/DNS/DNSTXTFetcher.swift new file mode 100644 index 0000000..a9082a2 --- /dev/null +++ b/Sources/AtprotoClient/DNS/DNSTXTFetcher.swift @@ -0,0 +1,47 @@ +// +// DNSTXTFetcher.swift +// AtprotoClient +// + +import Foundation + +/// Resolves TXT records for a fully-qualified name. +/// +/// Mirrors `HTTPFetcher`'s shape: a narrow protocol so the caller can inject a +/// platform conformer, a mock, or a platform's own system resolver. +public protocol DNSTXTFetcher: Sendable { + /// The strings of every TXT record found for `name`, one entry per record. + /// A record's own length-prefixed character-strings are already joined. + /// Empty when the name resolves but carries no TXT record (NXDOMAIN or NODATA). + func txtRecords(name: String) async throws -> [String] +} + +public enum DNSTXTFetcherError: LocalizedError, Equatable, Sendable { + /// The name does not exist. + case nameError + /// The response's RCODE was neither success (0) nor NXDOMAIN (3). + case serverError(rcode: UInt8) + /// Ran past the caller's `timeout` without an answer. + case timedOut + /// Malformed DNS wire-format response - see `DNSWireFormat.DecodeError`. + case malformedResponse(DNSWireFormat.DecodeError) + /// A platform system resolver reported failure with its own error code - + /// not a DNS RCODE, so kept distinct from `serverError`. Unused by any + /// conformer in this package today; kept so a platform-specific conformer + /// added later doesn't need a breaking change to this enum. + case resolverFailure(code: Int32) + /// `DoHTXTFetcher` was constructed with no providers to try - a caller + /// misconfiguration, not a network failure, so kept distinct from `timedOut`. + case noProvidersConfigured + + public var errorDescription: String? { + switch self { + case .nameError: "DNS name does not exist" + case .serverError(let rcode): "DNS server returned RCODE \(rcode)" + case .timedOut: "DNS query timed out" + case .malformedResponse(let error): "Malformed DNS response: \(error)" + case .resolverFailure(let code): "Platform resolver failed with code \(code)" + case .noProvidersConfigured: "No DoH providers configured" + } + } +} diff --git a/Sources/AtprotoClient/DNS/DNSWireFormat.swift b/Sources/AtprotoClient/DNS/DNSWireFormat.swift new file mode 100644 index 0000000..20d9347 --- /dev/null +++ b/Sources/AtprotoClient/DNS/DNSWireFormat.swift @@ -0,0 +1,182 @@ +// +// DNSWireFormat.swift +// AtprotoClient +// + +import Foundation + +/// A minimal RFC 1035 wire-format codec, scoped to exactly what a TXT lookup +/// needs: encode a single-question TXT query, decode the answer section of a +/// response into the joined character-strings of every TXT record found. +/// +/// Deliberately not a general DNS message library - no support for other query +/// types, no EDNS0, no writing responses. Pure byte-in/string-out; no platform +/// dependency, so it is tested directly against captured wire bytes rather than +/// through a live resolver. +public enum DNSWireFormat { + private static let typeTXT: UInt16 = 16 + private static let classIN: UInt16 = 1 + + public enum EncodeError: Error, Equatable, Sendable { + case emptyLabel + case labelTooLong(String) + case nameTooLong + } + + public enum DecodeError: Error, Equatable, Sendable, CustomStringConvertible { + case truncated + /// RCODE 3 - the name does not exist. Distinct from `serverFailure` so a + /// caller can treat "no record" and "this server misbehaved" differently. + case nameError + case serverFailure(rcode: UInt8) + case compressionPointerLoop + case malformedLabel + + public var description: String { + switch self { + case .truncated: "truncated DNS message" + case .nameError: "NXDOMAIN" + case .serverFailure(let rcode): "server failure, RCODE \(rcode)" + case .compressionPointerLoop: "DNS name compression pointer loop" + case .malformedLabel: "malformed DNS label" + } + } + } + + /// Encodes a single-question TXT query for `name`. + /// + /// ID is fixed at 0 (RFC 8484 4.1: DoH responses are not associated with a + /// query by ID, so a fixed ID improves cache hit rates for intermediaries). + public static func encodeTXTQuery(name: String) throws -> Data { + var qname = Data() + for label in name.split(separator: ".", omittingEmptySubsequences: false) { + let bytes = Array(label.utf8) + guard !bytes.isEmpty else { throw EncodeError.emptyLabel } + guard bytes.count <= 63 else { + throw EncodeError.labelTooLong(String(label)) + } + qname.append(UInt8(bytes.count)) + qname.append(contentsOf: bytes) + } + qname.append(0) + guard qname.count <= 255 else { throw EncodeError.nameTooLong } + + var message = Data(capacity: 12 + qname.count + 4) + message.append(contentsOf: [0x00, 0x00]) // ID = 0 + message.append(contentsOf: [0x01, 0x00]) // flags: RD=1, everything else 0 + message.append(contentsOf: [0x00, 0x01]) // QDCOUNT = 1 + // ANCOUNT / NSCOUNT / ARCOUNT = 0 + message.append(contentsOf: [0x00, 0x00, 0x00, 0x00, 0x00, 0x00]) + message.append(qname) + message.append(contentsOf: beBytes(typeTXT)) + message.append(contentsOf: beBytes(classIN)) + return message + } + + /// The strings of every TXT record in the answer section, one entry per + /// record, that record's own character-strings already joined. + /// + /// Throws `.nameError` for NXDOMAIN - callers that treat "no record" as a + /// normal outcome (not every consumer does) should catch that case + /// specifically rather than translating every throw into "try the next + /// server". + public static func decodeTXTRecords(_ data: Data) throws -> [String] { + let bytes = [UInt8](data) + guard bytes.count >= 12 else { throw DecodeError.truncated } + + let flags = beUInt16(bytes, at: 2) + let rcode = UInt8(flags & 0x000F) + guard rcode != 3 else { throw DecodeError.nameError } + guard rcode == 0 else { throw DecodeError.serverFailure(rcode: rcode) } + + let qdcount = Int(beUInt16(bytes, at: 4)) + let ancount = Int(beUInt16(bytes, at: 6)) + + var offset = 12 + for _ in 0.. Int { + var offset = start + var next: Int? + var jumps = 0 + + while true { + guard offset < bytes.count else { throw DecodeError.truncated } + let length = bytes[offset] + + if length == 0 { + if next == nil { next = offset + 1 } + return next! + } else if length & 0xC0 == 0xC0 { + guard offset + 1 < bytes.count else { throw DecodeError.truncated } + let pointer = (Int(length & 0x3F) << 8) | Int(bytes[offset + 1]) + if next == nil { next = offset + 2 } + jumps += 1 + // a pointer must target strictly earlier in the message - this + // alone guarantees termination; the jump cap is defense in depth. + guard pointer < offset, jumps <= 128 else { + throw DecodeError.compressionPointerLoop + } + offset = pointer + } else if length & 0xC0 != 0 { + throw DecodeError.malformedLabel + } else { + offset += 1 + Int(length) + guard offset <= bytes.count else { throw DecodeError.truncated } + } + } + } + + /// TXT RDATA is one or more length-prefixed character-strings; join them, + /// matching what a resolver client library's own `TXTRecord` accessor does + /// for the common single-string case. + static func joinedCharacterStrings(_ rdata: ArraySlice) -> String { + var strings: [String] = [] + var i = rdata.startIndex + while i < rdata.endIndex { + let length = Int(rdata[i]) + i += 1 + let end = min(i + length, rdata.endIndex) + strings.append(String(decoding: rdata[i.. [UInt8] { + [UInt8(value >> 8), UInt8(value & 0xFF)] + } + + private static func beUInt16(_ bytes: [UInt8], at offset: Int) -> UInt16 { + (UInt16(bytes[offset]) << 8) | UInt16(bytes[offset + 1]) + } +} diff --git a/Sources/AtprotoClient/DNS/DnsHandleResolver.swift b/Sources/AtprotoClient/DNS/DnsHandleResolver.swift new file mode 100644 index 0000000..e62d4e9 --- /dev/null +++ b/Sources/AtprotoClient/DNS/DnsHandleResolver.swift @@ -0,0 +1,55 @@ +// +// DnsHandleResolver.swift +// AtprotoClient +// + +import AtprotoTypes +import Foundation +import GermConvenience +import GermConvenienceHTTP + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +extension Atproto { + /// Resolves an atproto handle via its `_atproto` DNS TXT record, as + /// specified at https://atproto.com/specs/handle#dns-txt-method. + /// + /// Sibling to `WellKnownHandleResolver`: the two methods are independent + /// and a caller resolving a handle should try both, per the spec. + public struct DnsHandleResolver: Sendable { + static let namePrefix = "_atproto." + static let recordPrefix = "did=" + + let txtFetcher: any DNSTXTFetcher + + /// Defaults to `DoHTXTFetcher`, the only conformer this package ships - + /// portable everywhere, unlike a platform system resolver. Inject a + /// platform-specific `DNSTXTFetcher` to use one instead. + public init( + txtFetcher: any DNSTXTFetcher = DoHTXTFetcher( + fetcher: URLSession.manualRedirect()) + ) { + self.txtFetcher = txtFetcher + } + + /// `nil` when the name resolves but carries no `did=`-prefixed TXT + /// record; throws for a fetcher failure or a malformed DID value. + public func resolve(handle: Atproto.Handle) async throws -> Atproto.DID? { + let name = Self.namePrefix + handle.rawValue + let records = try await txtFetcher.txtRecords(name: name) + + guard + let didRecord = records.first(where: { + $0.hasPrefix(Self.recordPrefix) + }) + else { + return nil + } + + let didString = didRecord.dropFirst(Self.recordPrefix.count) + return try Atproto.DID(string: String(didString)) + } + } +} diff --git a/Sources/AtprotoClient/DNS/DoHTXTFetcher.swift b/Sources/AtprotoClient/DNS/DoHTXTFetcher.swift new file mode 100644 index 0000000..624592f --- /dev/null +++ b/Sources/AtprotoClient/DNS/DoHTXTFetcher.swift @@ -0,0 +1,110 @@ +// +// DoHTXTFetcher.swift +// AtprotoClient +// + +import Foundation +import GermConvenience +import GermConvenienceHTTP + +/// RFC 8484 DNS-over-HTTPS TXT lookups over the existing `HTTPFetcher` seam. +/// +/// Portable - no platform dependency, so this is usable on every platform +/// this package supports, including ones with no per-app system resolver +/// policy to inherit from a platform-specific conformer. +public struct DoHTXTFetcher: DNSTXTFetcher { + public static let cloudflare = URL(string: "https://cloudflare-dns.com/dns-query")! + public static let google = URL(string: "https://dns.google/dns-query")! + /// Cloudflare first, Google as fallback if the former is blocked or down. + public static let defaultProviders = [cloudflare, google] + + let fetcher: any HTTPFetcher + let serverURLs: [URL] + let timeout: Duration + + /// `serverURLs` is a plain, ordered list of providers to try in turn - not + /// hardcoded to `defaultProviders` - so a caller can point this at a + /// self-hosted or region-specific DoH resolver instead. + public init( + fetcher: any HTTPFetcher, + serverURLs: [URL] = DoHTXTFetcher.defaultProviders, + timeout: Duration = .seconds(5) + ) { + self.fetcher = fetcher + self.serverURLs = serverURLs + self.timeout = timeout + } + + /// Tries each provider in order. Only advances on failure - a network error, + /// a non-2xx, a malformed response, or that provider's own timeout. A clean + /// NXDOMAIN is a real answer (no record), not a reason to try the next host. + public func txtRecords(name: String) async throws -> [String] { + guard !serverURLs.isEmpty else { + throw DNSTXTFetcherError.noProvidersConfigured + } + + let query = try DNSWireFormat.encodeTXTQuery(name: name) + var lastError: any Error = DNSTXTFetcherError.timedOut + + for serverURL in serverURLs { + //a cancelled caller is not a failing provider: without this the + //loop would burn through every remaining server, each cancelled + //in turn, before reporting the cancellation. + try Task.checkCancellation() + do { + return try await queryOneServer(url: serverURL, query: query) + } catch DNSTXTFetcherError.nameError { + return [] + } catch is CancellationError { + throw CancellationError() + } catch { + lastError = error + } + } + throw lastError + } + + /// Races the network request against `timeout`; cancels whichever loses. + private func queryOneServer(url: URL, query: Data) async throws -> [String] { + try await withThrowingTaskGroup(of: [String].self) { group in + defer { group.cancelAll() } + + group.addTask { try await self.performQuery(url: url, query: query) } + group.addTask { + try await Task.sleep(for: self.timeout) + throw DNSTXTFetcherError.timedOut + } + + guard let result = try await group.next() else { + throw DNSTXTFetcherError.timedOut + } + return result + } + } + + private func performQuery(url: URL, query: Data) async throws -> [String] { + let request = try BundledHTTPRequest( + method: .post, + url: url, + headerFields: [ + .contentType: "application/dns-message", + .accept: "application/dns-message", + ], + body: query + ) + let data = try await fetcher.data(for: request).expectSuccess() + + do { + return try DNSWireFormat.decodeTXTRecords(data) + } catch let decodeError as DNSWireFormat.DecodeError { + switch decodeError { + case .nameError: + throw DNSTXTFetcherError.nameError + case .serverFailure(let rcode): + throw DNSTXTFetcherError.serverError(rcode: rcode) + case .truncated, .compressionPointerLoop, .malformedLabel: + throw DNSTXTFetcherError.malformedResponse(decodeError) + } + } + } +} diff --git a/Sources/AtprotoClient/DNS/WellKnownHandleResolver.swift b/Sources/AtprotoClient/DNS/WellKnownHandleResolver.swift new file mode 100644 index 0000000..1b12c90 --- /dev/null +++ b/Sources/AtprotoClient/DNS/WellKnownHandleResolver.swift @@ -0,0 +1,119 @@ +// +// WellKnownHandleResolver.swift +// AtprotoClient +// + +import AtprotoTypes +import Foundation +import GermConvenience +import GermConvenienceHTTP +import HTTPTypes + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +extension Atproto { + /// Resolves an atproto handle by fetching + /// `https:///.well-known/atproto-did`, as specified at + /// https://atproto.com/specs/handle#https-well-known-method. + /// + /// Sibling to `DidWebResolver`/`DidPlcResolver`: redirects are refused, + /// and the parsed value is only ever handed back through + /// `Atproto.DID.init(string:)`, which validates it. + public struct WellKnownHandleResolver: Sendable { + static let wellKnownPath = "/.well-known/atproto-did" + static let acceptHeader = "text/plain;charset=UTF-8" + /// A generous bound on a body that should be one short line - a DID is + /// at most 2KB (https://atproto.com/specs/did) - so nothing this size + /// is ever a legitimate answer. Checked before the body is parsed, so + /// an oversized response is rejected rather than processed. + static let maxBodySize = 8192 + + let fetcher: any HTTPFetcher + + /// Defaults to a redirect-refusing session, for the same reason + /// `DidWebResolver` refuses one - the handle is already validated, but + /// a followed redirect would fetch a second, unvalidated host. + public init(fetcher: any HTTPFetcher = URLSession.manualRedirect()) { + self.fetcher = fetcher + } + + /// `nil` for a well-formed request the endpoint declined (a non-2xx + /// status, or a 2xx with an empty body); throws for anything that + /// means this resolver or the response can't be trusted. + public func resolve(handle: Atproto.Handle) async throws -> Atproto.DID? { + let url = try Self.wellKnownURL(for: handle) + + let request = try BundledHTTPRequest( + url: url, + headerFields: [.accept: Self.acceptHeader] + ) + let response = try await fetcher.data(for: request) + + if response.response.status.kind == .redirection { + // A refused redirect surfaces as its own response rather than + // being followed - distinct from an ordinary 4xx/5xx. + throw Errors.redirectRefused + } + guard response.response.status.kind == .successful else { + return nil + } + guard response.data.count <= Self.maxBodySize else { + throw Errors.responseTooLarge + } + + let body = String(decoding: response.data, as: UTF8.self) + guard + let firstLine = body.split( + separator: "\n", maxSplits: 1, + omittingEmptySubsequences: false + ).first + else { + return nil + } + let trimmed = firstLine.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return nil + } + + return try Atproto.DID(string: trimmed) + } + + /// Pure and synchronous - every security-relevant decision here is + /// testable without a network call or a mock. + static func wellKnownURL(for handle: Atproto.Handle) throws -> URL { + var components = URLComponents() + components.scheme = "https" + components.host = handle.rawValue + components.path = Self.wellKnownPath + + guard let url = components.url, let host = url.host, !host.isEmpty else { + // Not reachable through a `Handle` that already passed its own + // grammar check - kept as defense in depth rather than a + // force-unwrap, since the handle drives a network fetch. + throw Errors.invalidHandle + } + return url + } + } +} + +extension Atproto.WellKnownHandleResolver { + public enum Errors: Error, Equatable, Sendable { + case invalidHandle + case redirectRefused + case responseTooLarge + } +} + +extension Atproto.WellKnownHandleResolver.Errors: LocalizedError { + public var errorDescription: String? { + switch self { + case .invalidHandle: "handle could not be resolved to a well-known URL" + case .redirectRefused: + "the well-known endpoint attempted a redirect, which is refused" + case .responseTooLarge: "the well-known response body exceeded the size bound" + } + } +} diff --git a/Tests/AtprotoClientTests/DNS/DNSFixtures.swift b/Tests/AtprotoClientTests/DNS/DNSFixtures.swift new file mode 100644 index 0000000..af09f1d --- /dev/null +++ b/Tests/AtprotoClientTests/DNS/DNSFixtures.swift @@ -0,0 +1,51 @@ +// +// DNSFixtures.swift +// AtprotoClientTests +// + +import Foundation + +/// Minimal RFC 1035 response builders for tests that need a full DoH response +/// body, not just the question `DNSWireFormat.encodeTXTQuery` produces. Kept +/// separate from `DNSWireFormatTests`' own handcrafted cases so +/// `DoHTXTFetcherTests` and future conformer tests share one builder rather +/// than three copies of the same byte-packing. +enum DNSFixtures { + static func name(_ dotted: String) -> [UInt8] { + var out: [UInt8] = [] + for label in dotted.split(separator: ".") { + out.append(UInt8(label.utf8.count)) + out.append(contentsOf: Array(label.utf8)) + } + out.append(0) + return out + } + + private static func beU16(_ value: UInt16) -> [UInt8] { + [UInt8(value >> 8), UInt8(value & 0xFF)] + } + + /// A response with RCODE=0 and one TXT answer containing `text` as a single + /// character-string. + static func txtResponse(question: String, text: String) -> Data { + var bytes: [UInt8] = [0, 0, 0x81, 0x80, 0, 1, 0, 1, 0, 0, 0, 0] + bytes += name(question) + bytes += [0, 16, 0, 1] + bytes += [0xC0, 0x0C] // owner name: pointer back to the question + bytes += [0, 16, 0, 1, 0, 0, 0, 60] + let stringBytes = Array(text.utf8) + let rdata: [UInt8] = [UInt8(stringBytes.count)] + stringBytes + bytes += beU16(UInt16(rdata.count)) + bytes += rdata + return Data(bytes) + } + + /// A response with RCODE=3 (NXDOMAIN), no answers - a real "no record" + /// outcome, as opposed to a transport/server failure. + static func nxdomainResponse(question: String) -> Data { + var bytes: [UInt8] = [0, 0, 0x81, 0x83, 0, 1, 0, 0, 0, 0, 0, 0] + bytes += name(question) + bytes += [0, 16, 0, 1] + return Data(bytes) + } +} diff --git a/Tests/AtprotoClientTests/DNS/DNSWireFormatTests.swift b/Tests/AtprotoClientTests/DNS/DNSWireFormatTests.swift new file mode 100644 index 0000000..64e9e53 --- /dev/null +++ b/Tests/AtprotoClientTests/DNS/DNSWireFormatTests.swift @@ -0,0 +1,181 @@ +// +// DNSWireFormatTests.swift +// AtprotoClientTests +// + +import Foundation +import Testing + +@testable import AtprotoClient + +struct DNSWireFormatTests { + + // MARK: - encode + + @Test func encodesTheExpectedQuestionSection() throws { + let query = try DNSWireFormat.encodeTXTQuery(name: "_atproto.pfrazee.com") + + // captured with `dig`: the byte-for-byte query Cloudflare's DoH endpoint + // answered to produce `realTXTResponse` below. + let expected = Data( + base64Encoded: "AAABAAABAAAAAAAACF9hdHByb3RvB3BmcmF6ZWUDY29tAAAQAAE=")! + #expect(query == expected) + } + + @Test func rejectsAnEmptyLabel() { + #expect(throws: DNSWireFormat.EncodeError.emptyLabel) { + try DNSWireFormat.encodeTXTQuery(name: "_atproto..example.com") + } + } + + @Test func rejectsALabelOver63Bytes() { + let label = String(repeating: "a", count: 64) + #expect(throws: DNSWireFormat.EncodeError.labelTooLong(label)) { + try DNSWireFormat.encodeTXTQuery(name: "\(label).example.com") + } + } + + // MARK: - decode: real captured responses + + // `dig +noedns _atproto.pfrazee.com TXT`, captured as the raw RFC 8484 + // response body from Cloudflare's DoH endpoint - a single TXT answer whose + // owner name is a compression pointer back into the question section. + static let realTXTResponse = Data( + base64Encoded: + "AACBgAABAAEAAAAACF9hdHByb3RvB3BmcmF6ZWUDY29tAAAQAAHADAAQAAEAAAAhACUkZGlkPWRp" + + "ZDpwbGM6cmFndGpzbTJqMnZrbndrejN6cDRveHJk" + )! + + @Test func decodesARealSingleAnswerRecordWithACompressionPointer() throws { + let records = try DNSWireFormat.decodeTXTRecords(Self.realTXTResponse) + #expect(records == ["did=did:plc:ragtjsm2j2vknwkz3zp4oxrd"]) + } + + // A real NXDOMAIN response: ANCOUNT=0, NSCOUNT=1 carrying a root-server SOA + // record in the authority section - the decoder must not mistake that + // authority record for an answer, and must throw `.nameError`, not silently + // return an empty array (the fetcher layer is the one that turns that into + // "no record", not the codec). + static let realNXDOMAINResponse = Data( + base64Encoded: + "AACBgwABAAAAAQAACF9hdHByb3RvKXRoaXMtZGVmaW5pdGVseS1kb2VzLW5vdC1leGlzdC1nZXJt" + + "LXByb2JlB2V4YW1wbGUAABAAAQAABgABAAFRgABAAWEMcm9vdC1zZXJ2ZXJzA25ldAAFbnN0bGQM" + + "dmVyaXNpZ24tZ3JzA2NvbQB4w4rpAAAHCAAAA4QACTqAAAFRgA==" + )! + + @Test func nxdomainThrowsNameError() { + #expect(throws: DNSWireFormat.DecodeError.nameError) { + try DNSWireFormat.decodeTXTRecords(Self.realNXDOMAINResponse) + } + } + + // gmail.com TXT via Cloudflare: 4 real answers, each owner name a + // compression pointer to the same offset - the multi-answer walk case. + static let realMultiAnswerResponse = Data( + base64Encoded: + "AACBgAABAAQAAAAABWdtYWlsA2NvbQAAEAABwAwAEAABAAAA4gBEQ3lhaG9vLXZlcmlmaWNhdGlv" + + "bi1rZXk9K2Vad0lQU2dSeGtBVXpEdHFUOFFoaytqNEExSkY2Vi93dEdveUdUa0VMWT3ADAAQAAEA" + + "AADiACAfdj1zcGYxIHJlZGlyZWN0PV9zcGYuZ29vZ2xlLmNvbcAMABAAAQAAAOIAQUBnbG9iYWxz" + + "aWduLXNtaW1lLWR2PUNEWVgrWEZIVXcyd21sNi9HYjgrNTlCc0gzMUt6VXI2YzFsMkJQdnFLWDg9" + + "wAwAEAABAAAA4gBEQ3lhaG9vLXZlcmlmaWNhdGlvbi1rZXk9ZEtZd2ZWYmF4YXRtY1hpWHk2TERB" + + "eE1SaXJxcE9xNXRqOThpSnY5cVdWaz0=" + )! + + @Test func decodesFourAnswersEachAsItsOwnEntry() throws { + let records = try DNSWireFormat.decodeTXTRecords(Self.realMultiAnswerResponse) + #expect(records.count == 4) + #expect(records[1] == "v=spf1 redirect=_spf.google.com") + } + + // MARK: - decode: handcrafted edge cases + + @Test func joinsMultipleCharacterStringsWithinOneRecord() throws { + // header (12) + owner "example.com" (13) + TYPE/CLASS/TTL/RDLENGTH (10) + // + two character-strings totaling 6 bytes of RDATA + var bytes: [UInt8] = [ + 0, 0, 0x81, 0x80, 0, 1, 0, 1, 0, 0, 0, 0, + ] + bytes += name("example.com") + bytes += [0, 16, 0, 1] // QTYPE=TXT, QCLASS=IN + bytes += [0xC0, 0x0C] // answer owner: compression pointer to offset 12 + bytes += [0, 16, 0, 1, 0, 0, 0, 60] // TYPE, CLASS, TTL + let rdata: [UInt8] = [3, 0x66, 0x6F, 0x6F, 2, 0x62, 0x61] // "foo" + "ba" + bytes += beU16(UInt16(rdata.count)) + bytes += rdata + + let records = try DNSWireFormat.decodeTXTRecords(Data(bytes)) + #expect(records == ["fooba"]) + } + + @Test func skipsAnAnswerThatIsNotTXT() throws { + var bytes: [UInt8] = [0, 0, 0x81, 0x80, 0, 1, 0, 2, 0, 0, 0, 0] + bytes += name("example.com") + bytes += [0, 16, 0, 1] + + // answer 1: an A record, must be skipped + bytes += [0xC0, 0x0C] + bytes += [0, 1, 0, 1, 0, 0, 0, 60] + bytes += beU16(4) + bytes += [127, 0, 0, 1] + + // answer 2: the real TXT record + bytes += [0xC0, 0x0C] + bytes += [0, 16, 0, 1, 0, 0, 0, 60] + let rdata: [UInt8] = [4] + Array("real".utf8) + bytes += beU16(UInt16(rdata.count)) + bytes += rdata + + let records = try DNSWireFormat.decodeTXTRecords(Data(bytes)) + #expect(records == ["real"]) + } + + @Test func rejectsAForwardPointingCompressionPointer() { + // a pointer that targets a later offset than itself must not be + // followed - it can never terminate the way a well-formed message + // (where pointers only ever point backward) does. + var bytes: [UInt8] = [0, 0, 0x81, 0x80, 0, 1, 0, 0, 0, 0, 0, 0] + let pointerAt = bytes.count + bytes += [0xC0, UInt8(pointerAt + 4)] // points past itself + bytes += [0, 16, 0, 1] + + #expect(throws: DNSWireFormat.DecodeError.compressionPointerLoop) { + try DNSWireFormat.decodeTXTRecords(Data(bytes)) + } + } + + @Test func truncatedMessageThrowsRatherThanCrashing() { + // a header claiming one answer, with nothing after the question + var bytes: [UInt8] = [0, 0, 0x81, 0x80, 0, 1, 0, 1, 0, 0, 0, 0] + bytes += name("example.com") + bytes += [0, 16, 0, 1] + // no answer section at all, despite ANCOUNT=1 + + #expect(throws: DNSWireFormat.DecodeError.truncated) { + try DNSWireFormat.decodeTXTRecords(Data(bytes)) + } + } + + @Test func serverFailureRcodeIsDistinctFromNameError() { + // RCODE=2, SERVFAIL + let bytes: [UInt8] = [0, 0, 0x81, 0x82, 0, 0, 0, 0, 0, 0, 0, 0] + #expect(throws: DNSWireFormat.DecodeError.serverFailure(rcode: 2)) { + try DNSWireFormat.decodeTXTRecords(Data(bytes)) + } + } + + // MARK: - helpers + + private func name(_ dotted: String) -> [UInt8] { + var out: [UInt8] = [] + for label in dotted.split(separator: ".") { + out.append(UInt8(label.utf8.count)) + out.append(contentsOf: Array(label.utf8)) + } + out.append(0) + return out + } + + private func beU16(_ value: UInt16) -> [UInt8] { + [UInt8(value >> 8), UInt8(value & 0xFF)] + } +} diff --git a/Tests/AtprotoClientTests/DNS/DnsHandleResolverTests.swift b/Tests/AtprotoClientTests/DNS/DnsHandleResolverTests.swift new file mode 100644 index 0000000..7880094 --- /dev/null +++ b/Tests/AtprotoClientTests/DNS/DnsHandleResolverTests.swift @@ -0,0 +1,109 @@ +// +// DnsHandleResolverTests.swift +// AtprotoClientTests +// +// Exercises DnsHandleResolver's did= filtering against an injected +// DNSTXTFetcher, so these tests don't touch the network or DoHTXTFetcher - +// that conformer is covered directly in DoHTXTFetcherTests. +// + +import AtprotoTypes +import Foundation +import Testing + +@testable import AtprotoClient + +private final class MockTXTFetcher: DNSTXTFetcher, @unchecked Sendable { + let records: [String] + private(set) var requestedName: String? + + init(records: [String]) { + self.records = records + } + + func txtRecords(name: String) async throws -> [String] { + requestedName = name + return records + } +} + +struct DnsHandleResolverTests { + @Test func queriesTheUnderscoreAtprotoPrefixedName() async throws { + let mock = MockTXTFetcher(records: []) + let resolver = Atproto.DnsHandleResolver(txtFetcher: mock) + + _ = try await resolver.resolve( + handle: try Atproto.Handle(string: "alice.example.com")) + + #expect(mock.requestedName == "_atproto.alice.example.com") + } + + @Test func parsesTheDidFromAMatchingRecord() async throws { + let mock = MockTXTFetcher(records: ["did=did:plc:abc123"]) + let resolver = Atproto.DnsHandleResolver(txtFetcher: mock) + + let did = try await resolver.resolve( + handle: try Atproto.Handle(string: "alice.example.com")) + + #expect(did == Atproto.DID(method: .plc, identifier: "abc123")) + } + + @Test func parsesEverythingAfterTheFirstEqualsSignAsTheDid() async throws { + // a naive split(separator: "=").last would truncate this at the + // second "=" instead of taking everything after "did=" + let mock = MockTXTFetcher(records: ["did=did:web:a=b"]) + let resolver = Atproto.DnsHandleResolver(txtFetcher: mock) + + let did = try await resolver.resolve( + handle: try Atproto.Handle(string: "alice.example.com")) + + #expect(did == Atproto.DID(method: .web, identifier: "a=b")) + } + + @Test func picksTheFirstMatchingRecordAmongSeveral() async throws { + let mock = MockTXTFetcher(records: [ + "v=spf1 -all", + "did=did:plc:first", + "did=did:plc:second", + ]) + let resolver = Atproto.DnsHandleResolver(txtFetcher: mock) + + let did = try await resolver.resolve( + handle: try Atproto.Handle(string: "alice.example.com")) + + #expect(did == Atproto.DID(method: .plc, identifier: "first")) + } + + @Test func returnsNilWhenNoRecordHasTheDidPrefix() async throws { + let mock = MockTXTFetcher(records: ["v=spf1 -all", "some other txt record"]) + let resolver = Atproto.DnsHandleResolver(txtFetcher: mock) + + let did = try await resolver.resolve( + handle: try Atproto.Handle(string: "alice.example.com")) + + #expect(did == nil) + } + + @Test func returnsNilForAnEmptyRecordSet() async throws { + let mock = MockTXTFetcher(records: []) + let resolver = Atproto.DnsHandleResolver(txtFetcher: mock) + + let did = try await resolver.resolve( + handle: try Atproto.Handle(string: "alice.example.com")) + + #expect(did == nil) + } + + @Test func propagatesAThrowFromTheFetcher() async throws { + struct Boom: Error {} + struct ThrowingFetcher: DNSTXTFetcher { + func txtRecords(name: String) async throws -> [String] { throw Boom() } + } + let resolver = Atproto.DnsHandleResolver(txtFetcher: ThrowingFetcher()) + + await #expect(throws: Boom.self) { + try await resolver.resolve( + handle: try Atproto.Handle(string: "alice.example.com")) + } + } +} diff --git a/Tests/AtprotoClientTests/DNS/DoHTXTFetcherTests.swift b/Tests/AtprotoClientTests/DNS/DoHTXTFetcherTests.swift new file mode 100644 index 0000000..e2f9358 --- /dev/null +++ b/Tests/AtprotoClientTests/DNS/DoHTXTFetcherTests.swift @@ -0,0 +1,176 @@ +// +// DoHTXTFetcherTests.swift +// AtprotoClientTests +// + +import Foundation +import GermConvenience +import GermConvenienceHTTP +import Testing + +@testable import AtprotoClient + +private final class ScriptedFetcher: HTTPFetcher, @unchecked Sendable { + enum Behavior { + case response(HTTPDataResponse) + /// Never returns until its Task is cancelled - for the cancellation test. + case hang + } + + private let lock = NSLock() + private var behaviors: [Behavior] + private var _requestedRequests: [BundledHTTPRequest] = [] + + /// Read from the test body while a hung request may still be in flight, so + /// it takes the same lock the fetcher writes under. + var requestedRequests: [BundledHTTPRequest] { lock.withLock { _requestedRequests } } + var requestedURLs: [URL] { requestedRequests.map { $0.request.url! } } + + init(_ behaviors: [Behavior]) { + self.behaviors = behaviors + } + + func data(for request: BundledHTTPRequest) async throws -> HTTPDataResponse { + let behavior = lock.withLock { + let index = _requestedRequests.count + _requestedRequests.append(request) + return index < behaviors.count + ? behaviors[index] : behaviors[behaviors.count - 1] + } + + switch behavior { + case .response(let response): + return response + case .hang: + try await Task.sleep(for: .seconds(3600)) + throw CancellationError() + } + } +} + +private let cloudflare = DoHTXTFetcher.cloudflare +private let google = DoHTXTFetcher.google + +struct DoHTXTFetcherTests { + + @Test func requestShapeIsRFC8484POST() async throws { + let responseData = DNSFixtures.txtResponse( + question: "_atproto.example.com", text: "did=did:plc:abc") + let fetcher = ScriptedFetcher([ + .response(.init(data: responseData, response: .init(status: .ok))) + ]) + let txtFetcher = DoHTXTFetcher(fetcher: fetcher, serverURLs: [cloudflare]) + + _ = try await txtFetcher.txtRecords(name: "_atproto.example.com") + + let sent = try #require(fetcher.requestedRequests.first) + #expect(sent.request.method == .post) + #expect(sent.request.headerFields[.contentType] == "application/dns-message") + #expect(sent.request.headerFields[.accept] == "application/dns-message") + #expect( + sent.body + == (try DNSWireFormat.encodeTXTQuery(name: "_atproto.example.com"))) + + #expect(fetcher.requestedURLs == [cloudflare]) + } + + @Test func parsesTheDidFromASuccessfulResponse() async throws { + let responseData = DNSFixtures.txtResponse( + question: "_atproto.example.com", text: "did=did:plc:abc123") + let fetcher = ScriptedFetcher([ + .response(.init(data: responseData, response: .init(status: .ok))) + ]) + let txtFetcher = DoHTXTFetcher(fetcher: fetcher, serverURLs: [cloudflare]) + + let records = try await txtFetcher.txtRecords(name: "_atproto.example.com") + + #expect(records == ["did=did:plc:abc123"]) + } + + // MARK: - fallback order + + @Test func advancesToTheNextProviderOnFailure() async throws { + let responseData = DNSFixtures.txtResponse( + question: "_atproto.example.com", text: "did=did:plc:fromgoogle") + let fetcher = ScriptedFetcher([ + .response( + .init(data: Data(), response: .init(status: .internalServerError))), + .response(.init(data: responseData, response: .init(status: .ok))), + ]) + let txtFetcher = DoHTXTFetcher(fetcher: fetcher, serverURLs: [cloudflare, google]) + + let records = try await txtFetcher.txtRecords(name: "_atproto.example.com") + + #expect(records == ["did=did:plc:fromgoogle"]) + #expect(fetcher.requestedURLs == [cloudflare, google]) + } + + @Test func aCleanNXDOMAINIsTheAnswerNotAReasonToTryTheNextProvider() async throws { + let nxdomain = DNSFixtures.nxdomainResponse(question: "_atproto.example.com") + let fetcher = ScriptedFetcher([ + .response(.init(data: nxdomain, response: .init(status: .ok))) + ]) + let txtFetcher = DoHTXTFetcher(fetcher: fetcher, serverURLs: [cloudflare, google]) + + let records = try await txtFetcher.txtRecords(name: "_atproto.example.com") + + #expect(records.isEmpty) + #expect(fetcher.requestedURLs == [cloudflare]) + } + + @Test func throwsAfterExhaustingEveryProvider() async { + let fetcher = ScriptedFetcher([ + .response( + .init(data: Data(), response: .init(status: .internalServerError))), + .response( + .init(data: Data(), response: .init(status: .internalServerError))), + ]) + let txtFetcher = DoHTXTFetcher(fetcher: fetcher, serverURLs: [cloudflare, google]) + + await #expect(throws: (any Error).self) { + try await txtFetcher.txtRecords(name: "_atproto.example.com") + } + #expect(fetcher.requestedURLs == [cloudflare, google]) + } + + // MARK: - timeout and cancellation + + @Test func aSlowProviderTimesOutAndFallsThroughToTheNext() async throws { + let responseData = DNSFixtures.txtResponse( + question: "_atproto.example.com", text: "did=did:plc:secondserver") + let fetcher = ScriptedFetcher([ + .hang, + .response(.init(data: responseData, response: .init(status: .ok))), + ]) + let txtFetcher = DoHTXTFetcher( + fetcher: fetcher, serverURLs: [cloudflare, google], + timeout: .milliseconds(100)) + + let records = try await txtFetcher.txtRecords(name: "_atproto.example.com") + + #expect(records == ["did=did:plc:secondserver"]) + } + + // Cancel before the task's body has any chance to run: `Task.checkCancellation()` + // at the top of the provider loop and the dedicated `catch is CancellationError` + // in `txtRecords` both exist so a cancelled caller reports cancellation + // immediately rather than burning through the remaining providers first, or + // having the `.hang` fetcher's own `CancellationError` misread as "this + // provider failed, try the next one." + @Test func taskCancellationInterruptsPromptly() async throws { + let fetcher = ScriptedFetcher([.hang]) + let txtFetcher = DoHTXTFetcher( + fetcher: fetcher, serverURLs: [cloudflare, google], timeout: .seconds(30)) + + let task = Task { + try await txtFetcher.txtRecords(name: "_atproto.example.com") + } + task.cancel() + + let result = await task.result + #expect(throws: CancellationError.self) { try result.get() } + // the cancelled provider must not be treated as merely failed - a + // regression here would try `google` next instead of stopping. + #expect(fetcher.requestedURLs.count <= 1) + } +} diff --git a/Tests/AtprotoClientTests/DNS/WellKnownHandleResolverTests.swift b/Tests/AtprotoClientTests/DNS/WellKnownHandleResolverTests.swift new file mode 100644 index 0000000..3485499 --- /dev/null +++ b/Tests/AtprotoClientTests/DNS/WellKnownHandleResolverTests.swift @@ -0,0 +1,131 @@ +// +// WellKnownHandleResolverTests.swift +// AtprotoClientTests +// + +import AtprotoTypes +import Foundation +import GermConvenience +import GermConvenienceHTTP +import HTTPTypes +import Testing + +import struct AtprotoClientMocks.StubHTTPFetcher + +@testable import AtprotoClient + +@Suite struct WellKnownHandleResolverURLConstructionTests { + @Test func buildsTheWellKnownAtprotoDidURL() throws { + let handle = try Atproto.Handle(string: "alice.example.com") + let url = try Atproto.WellKnownHandleResolver.wellKnownURL(for: handle) + #expect(url.absoluteString == "https://alice.example.com/.well-known/atproto-did") + } + + @Test func usesHTTPS() throws { + let handle = try Atproto.Handle(string: "alice.example.com") + let url = try Atproto.WellKnownHandleResolver.wellKnownURL(for: handle) + #expect(url.scheme == "https") + } +} + +@Suite struct WellKnownHandleResolverFetchTests { + private func envelope(status: HTTPResponse.Status, body: Data) -> HTTPDataResponse { + .init(data: body, response: .init(status: status)) + } + + @Test func sendsThePlainTextAcceptHeader() async throws { + let handle = try Atproto.Handle(string: "alice.example.com") + let fetcher = StubHTTPFetcher { request in + #expect(request.request.headerFields[.accept] == "text/plain;charset=UTF-8") + return .init( + data: Data("did:plc:abc123456789012345678ab".utf8), + response: .init(status: .ok) + ) + } + _ = try await Atproto.WellKnownHandleResolver(fetcher: fetcher).resolve( + handle: handle) + } + + @Test func aSuccessfulPlainDidBodyResolves() async throws { + let handle = try Atproto.Handle(string: "alice.example.com") + let fetcher = StubHTTPFetcher( + envelope(status: .ok, body: Data("did:plc:abc123456789012345678ab".utf8)) + ) + let did = try await Atproto.WellKnownHandleResolver(fetcher: fetcher) + .resolve(handle: handle) + #expect(did == Atproto.DID(method: .plc, identifier: "abc123456789012345678ab")) + } + + @Test func trailingWhitespaceAndNewlinesAreTrimmed() async throws { + let handle = try Atproto.Handle(string: "alice.example.com") + let fetcher = StubHTTPFetcher( + envelope(status: .ok, body: Data("did:plc:abc123456789012345678ab\n".utf8)) + ) + let did = try await Atproto.WellKnownHandleResolver(fetcher: fetcher) + .resolve(handle: handle) + #expect(did == Atproto.DID(method: .plc, identifier: "abc123456789012345678ab")) + } + + @Test func aNotFoundResponseResolvesToNil() async throws { + let handle = try Atproto.Handle(string: "alice.example.com") + let fetcher = StubHTTPFetcher(envelope(status: .notFound, body: Data())) + let did = try await Atproto.WellKnownHandleResolver(fetcher: fetcher) + .resolve(handle: handle) + #expect(did == nil) + } + + @Test func aSuccessfulButEmptyBodyResolvesToNil() async throws { + let handle = try Atproto.Handle(string: "alice.example.com") + let fetcher = StubHTTPFetcher(envelope(status: .ok, body: Data())) + let did = try await Atproto.WellKnownHandleResolver(fetcher: fetcher) + .resolve(handle: handle) + #expect(did == nil) + } + + @Test(arguments: [HTTPResponse.Status.movedPermanently, .found, .temporaryRedirect]) + func aRedirectThrowsRatherThanResolvingToNilOrFollowing( + _ status: HTTPResponse.Status + ) async throws { + let handle = try Atproto.Handle(string: "alice.example.com") + let fetcher = StubHTTPFetcher(envelope(status: status, body: Data())) + await #expect(throws: Atproto.WellKnownHandleResolver.Errors.redirectRefused) { + try await Atproto.WellKnownHandleResolver(fetcher: fetcher).resolve( + handle: handle) + } + } + + @Test func anOversizedBodyIsRejectedBeforeParsing() async throws { + let handle = try Atproto.Handle(string: "alice.example.com") + let oversized = Data(repeating: UInt8(ascii: "a"), count: 9000) + let fetcher = StubHTTPFetcher(envelope(status: .ok, body: oversized)) + await #expect(throws: Atproto.WellKnownHandleResolver.Errors.responseTooLarge) { + try await Atproto.WellKnownHandleResolver(fetcher: fetcher).resolve( + handle: handle) + } + } + + @Test func aMalformedDidBodyThrows() async throws { + let handle = try Atproto.Handle(string: "alice.example.com") + let fetcher = StubHTTPFetcher( + envelope(status: .ok, body: Data("not-a-did".utf8)) + ) + await #expect(throws: (any Error).self) { + try await Atproto.WellKnownHandleResolver(fetcher: fetcher).resolve( + handle: handle) + } + } + + @Test func onlyTheFirstLineOfTheBodyIsParsed() async throws { + let handle = try Atproto.Handle(string: "alice.example.com") + let fetcher = StubHTTPFetcher( + envelope( + status: .ok, + body: Data( + "did:plc:abc123456789012345678ab\nunexpected trailer".utf8) + ) + ) + let did = try await Atproto.WellKnownHandleResolver(fetcher: fetcher) + .resolve(handle: handle) + #expect(did == Atproto.DID(method: .plc, identifier: "abc123456789012345678ab")) + } +} From 16edf57c4f053edf54e9a57d2eaf9913fb8b3724 Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Tue, 22 Sep 2026 10:36:04 -0700 Subject: [PATCH 2/4] Address review: reserved-TLD screen, OOB test coverage, nits - WellKnownHandleResolver now rejects reserved TLDs before building the well-known URL, matching DidWebResolver's screen (reuses its reservedTLDs set as the shared source of truth). - Add tests exercising the two untested parser bounds in decodeTXTRecords - a short answer header and an RDLENGTH exceeding the buffer - both verified to fail (crash) when their guard is neutralized. - Reword the maxBodySize comment: the bound rejects a body before parsing, not before it's buffered. - Reword the changeset to not oversell DID validation. - Make joinedCharacterStrings private - nothing outside the type uses it. Co-Authored-By: Claude Sonnet 5 --- .changeset/dns-wellknown-handle-resolvers.md | 2 +- Sources/AtprotoClient/DNS/DNSWireFormat.swift | 2 +- .../DNS/WellKnownHandleResolver.swift | 20 ++++++++++-- .../DNS/DNSWireFormatTests.swift | 31 +++++++++++++++++++ .../DNS/WellKnownHandleResolverTests.swift | 14 +++++++++ 5 files changed, 65 insertions(+), 4 deletions(-) diff --git a/.changeset/dns-wellknown-handle-resolvers.md b/.changeset/dns-wellknown-handle-resolvers.md index 7cfafc4..b7a3c55 100644 --- a/.changeset/dns-wellknown-handle-resolvers.md +++ b/.changeset/dns-wellknown-handle-resolvers.md @@ -2,4 +2,4 @@ "@germ-network/atprotoclient": minor --- -Add DNS-over-HTTPS TXT and well-known atproto-did handle-resolution components: `Atproto.WellKnownHandleResolver` and `Atproto.DnsHandleResolver`, plus the underlying `DNSWireFormat` codec, `DNSTXTFetcher` protocol, and `DoHTXTFetcher` conformer they're built on. Together these implement the two handle-resolution methods from the atproto handle spec (https://atproto.com/specs/handle) - the DNS TXT method and the HTTPS well-known method - as standalone, portable, `Sendable` components matching `DidWebResolver`/`DidPlcResolver`'s conventions: redirect refusal, response-size bounds, and DID validation via `Atproto.DID.init(string:)`. +Add DNS-over-HTTPS TXT and well-known atproto-did handle-resolution components: `Atproto.WellKnownHandleResolver` and `Atproto.DnsHandleResolver`, plus the underlying `DNSWireFormat` codec, `DNSTXTFetcher` protocol, and `DoHTXTFetcher` conformer they're built on. Together these implement the two handle-resolution methods from the atproto handle spec (https://atproto.com/specs/handle) - the DNS TXT method and the HTTPS well-known method - as standalone, portable, `Sendable` components matching `DidWebResolver`/`DidPlcResolver`'s conventions: redirect refusal, response-size bounds, and the resolved value parsed as an `Atproto.DID` (method form checked via `Atproto.DID.init(string:)`). diff --git a/Sources/AtprotoClient/DNS/DNSWireFormat.swift b/Sources/AtprotoClient/DNS/DNSWireFormat.swift index 20d9347..a140a2e 100644 --- a/Sources/AtprotoClient/DNS/DNSWireFormat.swift +++ b/Sources/AtprotoClient/DNS/DNSWireFormat.swift @@ -159,7 +159,7 @@ public enum DNSWireFormat { /// TXT RDATA is one or more length-prefixed character-strings; join them, /// matching what a resolver client library's own `TXTRecord` accessor does /// for the common single-string case. - static func joinedCharacterStrings(_ rdata: ArraySlice) -> String { + private static func joinedCharacterStrings(_ rdata: ArraySlice) -> String { var strings: [String] = [] var i = rdata.startIndex while i < rdata.endIndex { diff --git a/Sources/AtprotoClient/DNS/WellKnownHandleResolver.swift b/Sources/AtprotoClient/DNS/WellKnownHandleResolver.swift index 1b12c90..9821869 100644 --- a/Sources/AtprotoClient/DNS/WellKnownHandleResolver.swift +++ b/Sources/AtprotoClient/DNS/WellKnownHandleResolver.swift @@ -19,6 +19,7 @@ extension Atproto { /// https://atproto.com/specs/handle#https-well-known-method. /// /// Sibling to `DidWebResolver`/`DidPlcResolver`: redirects are refused, + /// reserved TLDs are rejected the same way `DidWebResolver` rejects them, /// and the parsed value is only ever handed back through /// `Atproto.DID.init(string:)`, which validates it. public struct WellKnownHandleResolver: Sendable { @@ -26,8 +27,10 @@ extension Atproto { static let acceptHeader = "text/plain;charset=UTF-8" /// A generous bound on a body that should be one short line - a DID is /// at most 2KB (https://atproto.com/specs/did) - so nothing this size - /// is ever a legitimate answer. Checked before the body is parsed, so - /// an oversized response is rejected rather than processed. + /// is ever a legitimate answer. `fetcher.data(for:)` already buffers + /// the full body before this is checked, so the bound rejects an + /// oversized body before it's parsed - it does not limit how much is + /// buffered beforehand. static let maxBodySize = 8192 let fetcher: any HTTPFetcher @@ -83,6 +86,16 @@ extension Atproto { /// Pure and synchronous - every security-relevant decision here is /// testable without a network call or a mock. static func wellKnownURL(for handle: Atproto.Handle) throws -> URL { + // IP-literals are already excluded by `Atproto.Handle`'s own + // grammar - only the reserved-TLD screen needs repeating here, + // reusing `DidWebResolver`'s set as the one source of truth. + guard + let tld = handle.rawValue.split(separator: ".").last, + !Atproto.DidWebResolver.reservedTLDs.contains(tld) + else { + throw Errors.reservedTLD + } + var components = URLComponents() components.scheme = "https" components.host = handle.rawValue @@ -102,6 +115,7 @@ extension Atproto { extension Atproto.WellKnownHandleResolver { public enum Errors: Error, Equatable, Sendable { case invalidHandle + case reservedTLD case redirectRefused case responseTooLarge } @@ -111,6 +125,8 @@ extension Atproto.WellKnownHandleResolver.Errors: LocalizedError { public var errorDescription: String? { switch self { case .invalidHandle: "handle could not be resolved to a well-known URL" + case .reservedTLD: + "handle's TLD is an IANA special-use domain, never a legitimate public identity" case .redirectRefused: "the well-known endpoint attempted a redirect, which is refused" case .responseTooLarge: "the well-known response body exceeded the size bound" diff --git a/Tests/AtprotoClientTests/DNS/DNSWireFormatTests.swift b/Tests/AtprotoClientTests/DNS/DNSWireFormatTests.swift index 64e9e53..5fea423 100644 --- a/Tests/AtprotoClientTests/DNS/DNSWireFormatTests.swift +++ b/Tests/AtprotoClientTests/DNS/DNSWireFormatTests.swift @@ -155,6 +155,37 @@ struct DNSWireFormatTests { } } + @Test func answerHeaderShorterThanTenBytesThrowsTruncated() { + // the owner name resolves fine, but only TYPE+CLASS (4 bytes) follow + // it - TTL and RDLENGTH are missing, so the per-answer header bound + // (guards the TYPE/RDLENGTH `beUInt16` reads) must catch this. + var bytes: [UInt8] = [0, 0, 0x81, 0x80, 0, 1, 0, 1, 0, 0, 0, 0] + bytes += name("example.com") + bytes += [0, 16, 0, 1] + bytes += [0xC0, 0x0C] // answer owner: pointer to offset 12 + bytes += [0, 16, 0, 1] // TYPE + CLASS only - TTL/RDLENGTH missing + + #expect(throws: DNSWireFormat.DecodeError.truncated) { + try DNSWireFormat.decodeTXTRecords(Data(bytes)) + } + } + + @Test func rdlengthExceedingRemainingBytesThrowsTruncated() { + // a complete answer header, but its RDLENGTH claims far more bytes + // than actually remain - the RDLENGTH bound (guards the + // `bytes[offset.. Date: Tue, 22 Sep 2026 10:44:33 -0700 Subject: [PATCH 3/4] Namespace DNS TXT-fetcher types under Atproto DoHTXTFetcher, DNSWireFormat, DNSTXTFetcher, and DNSTXTFetcherError move into extension Atproto, matching DnsHandleResolver and WellKnownHandleResolver. Names and casing unchanged. Co-Authored-By: Claude Opus 4.8 --- .changeset/dns-wellknown-handle-resolvers.md | 2 +- Sources/AtprotoClient/DNS/DNSTXTFetcher.swift | 74 ++-- Sources/AtprotoClient/DNS/DNSWireFormat.swift | 323 +++++++++--------- Sources/AtprotoClient/DNS/DoHTXTFetcher.swift | 172 +++++----- .../DNS/DNSWireFormatTests.swift | 44 +-- .../DNS/DnsHandleResolverTests.swift | 4 +- .../DNS/DoHTXTFetcherTests.swift | 25 +- 7 files changed, 337 insertions(+), 307 deletions(-) diff --git a/.changeset/dns-wellknown-handle-resolvers.md b/.changeset/dns-wellknown-handle-resolvers.md index b7a3c55..b01657c 100644 --- a/.changeset/dns-wellknown-handle-resolvers.md +++ b/.changeset/dns-wellknown-handle-resolvers.md @@ -2,4 +2,4 @@ "@germ-network/atprotoclient": minor --- -Add DNS-over-HTTPS TXT and well-known atproto-did handle-resolution components: `Atproto.WellKnownHandleResolver` and `Atproto.DnsHandleResolver`, plus the underlying `DNSWireFormat` codec, `DNSTXTFetcher` protocol, and `DoHTXTFetcher` conformer they're built on. Together these implement the two handle-resolution methods from the atproto handle spec (https://atproto.com/specs/handle) - the DNS TXT method and the HTTPS well-known method - as standalone, portable, `Sendable` components matching `DidWebResolver`/`DidPlcResolver`'s conventions: redirect refusal, response-size bounds, and the resolved value parsed as an `Atproto.DID` (method form checked via `Atproto.DID.init(string:)`). +Add DNS-over-HTTPS TXT and well-known atproto-did handle-resolution components: `Atproto.WellKnownHandleResolver` and `Atproto.DnsHandleResolver`, plus the underlying `Atproto.DNSWireFormat` codec, `Atproto.DNSTXTFetcher` protocol, and `Atproto.DoHTXTFetcher` conformer they're built on. Together these implement the two handle-resolution methods from the atproto handle spec (https://atproto.com/specs/handle) - the DNS TXT method and the HTTPS well-known method - as standalone, portable, `Sendable` components matching `DidWebResolver`/`DidPlcResolver`'s conventions: redirect refusal, response-size bounds, and the resolved value parsed as an `Atproto.DID` (method form checked via `Atproto.DID.init(string:)`). diff --git a/Sources/AtprotoClient/DNS/DNSTXTFetcher.swift b/Sources/AtprotoClient/DNS/DNSTXTFetcher.swift index a9082a2..5f6432f 100644 --- a/Sources/AtprotoClient/DNS/DNSTXTFetcher.swift +++ b/Sources/AtprotoClient/DNS/DNSTXTFetcher.swift @@ -3,45 +3,49 @@ // AtprotoClient // +import AtprotoTypes import Foundation -/// Resolves TXT records for a fully-qualified name. -/// -/// Mirrors `HTTPFetcher`'s shape: a narrow protocol so the caller can inject a -/// platform conformer, a mock, or a platform's own system resolver. -public protocol DNSTXTFetcher: Sendable { - /// The strings of every TXT record found for `name`, one entry per record. - /// A record's own length-prefixed character-strings are already joined. - /// Empty when the name resolves but carries no TXT record (NXDOMAIN or NODATA). - func txtRecords(name: String) async throws -> [String] -} +extension Atproto { + /// Resolves TXT records for a fully-qualified name. + /// + /// Mirrors `HTTPFetcher`'s shape: a narrow protocol so the caller can inject a + /// platform conformer, a mock, or a platform's own system resolver. + public protocol DNSTXTFetcher: Sendable { + /// The strings of every TXT record found for `name`, one entry per record. + /// A record's own length-prefixed character-strings are already joined. + /// Empty when the name resolves but carries no TXT record (NXDOMAIN or NODATA). + func txtRecords(name: String) async throws -> [String] + } -public enum DNSTXTFetcherError: LocalizedError, Equatable, Sendable { - /// The name does not exist. - case nameError - /// The response's RCODE was neither success (0) nor NXDOMAIN (3). - case serverError(rcode: UInt8) - /// Ran past the caller's `timeout` without an answer. - case timedOut - /// Malformed DNS wire-format response - see `DNSWireFormat.DecodeError`. - case malformedResponse(DNSWireFormat.DecodeError) - /// A platform system resolver reported failure with its own error code - - /// not a DNS RCODE, so kept distinct from `serverError`. Unused by any - /// conformer in this package today; kept so a platform-specific conformer - /// added later doesn't need a breaking change to this enum. - case resolverFailure(code: Int32) - /// `DoHTXTFetcher` was constructed with no providers to try - a caller - /// misconfiguration, not a network failure, so kept distinct from `timedOut`. - case noProvidersConfigured + public enum DNSTXTFetcherError: LocalizedError, Equatable, Sendable { + /// The name does not exist. + case nameError + /// The response's RCODE was neither success (0) nor NXDOMAIN (3). + case serverError(rcode: UInt8) + /// Ran past the caller's `timeout` without an answer. + case timedOut + /// Malformed DNS wire-format response - see `DNSWireFormat.DecodeError`. + case malformedResponse(DNSWireFormat.DecodeError) + /// A platform system resolver reported failure with its own error code - + /// not a DNS RCODE, so kept distinct from `serverError`. Unused by any + /// conformer in this package today; kept so a platform-specific conformer + /// added later doesn't need a breaking change to this enum. + case resolverFailure(code: Int32) + /// `DoHTXTFetcher` was constructed with no providers to try - a caller + /// misconfiguration, not a network failure, so kept distinct from `timedOut`. + case noProvidersConfigured - public var errorDescription: String? { - switch self { - case .nameError: "DNS name does not exist" - case .serverError(let rcode): "DNS server returned RCODE \(rcode)" - case .timedOut: "DNS query timed out" - case .malformedResponse(let error): "Malformed DNS response: \(error)" - case .resolverFailure(let code): "Platform resolver failed with code \(code)" - case .noProvidersConfigured: "No DoH providers configured" + public var errorDescription: String? { + switch self { + case .nameError: "DNS name does not exist" + case .serverError(let rcode): "DNS server returned RCODE \(rcode)" + case .timedOut: "DNS query timed out" + case .malformedResponse(let error): "Malformed DNS response: \(error)" + case .resolverFailure(let code): + "Platform resolver failed with code \(code)" + case .noProvidersConfigured: "No DoH providers configured" + } } } } diff --git a/Sources/AtprotoClient/DNS/DNSWireFormat.swift b/Sources/AtprotoClient/DNS/DNSWireFormat.swift index a140a2e..c59ef58 100644 --- a/Sources/AtprotoClient/DNS/DNSWireFormat.swift +++ b/Sources/AtprotoClient/DNS/DNSWireFormat.swift @@ -3,180 +3,193 @@ // AtprotoClient // +import AtprotoTypes import Foundation -/// A minimal RFC 1035 wire-format codec, scoped to exactly what a TXT lookup -/// needs: encode a single-question TXT query, decode the answer section of a -/// response into the joined character-strings of every TXT record found. -/// -/// Deliberately not a general DNS message library - no support for other query -/// types, no EDNS0, no writing responses. Pure byte-in/string-out; no platform -/// dependency, so it is tested directly against captured wire bytes rather than -/// through a live resolver. -public enum DNSWireFormat { - private static let typeTXT: UInt16 = 16 - private static let classIN: UInt16 = 1 - - public enum EncodeError: Error, Equatable, Sendable { - case emptyLabel - case labelTooLong(String) - case nameTooLong - } +extension Atproto { + /// A minimal RFC 1035 wire-format codec, scoped to exactly what a TXT lookup + /// needs: encode a single-question TXT query, decode the answer section of a + /// response into the joined character-strings of every TXT record found. + /// + /// Deliberately not a general DNS message library - no support for other query + /// types, no EDNS0, no writing responses. Pure byte-in/string-out; no platform + /// dependency, so it is tested directly against captured wire bytes rather than + /// through a live resolver. + public enum DNSWireFormat { + private static let typeTXT: UInt16 = 16 + private static let classIN: UInt16 = 1 + + public enum EncodeError: Error, Equatable, Sendable { + case emptyLabel + case labelTooLong(String) + case nameTooLong + } - public enum DecodeError: Error, Equatable, Sendable, CustomStringConvertible { - case truncated - /// RCODE 3 - the name does not exist. Distinct from `serverFailure` so a - /// caller can treat "no record" and "this server misbehaved" differently. - case nameError - case serverFailure(rcode: UInt8) - case compressionPointerLoop - case malformedLabel - - public var description: String { - switch self { - case .truncated: "truncated DNS message" - case .nameError: "NXDOMAIN" - case .serverFailure(let rcode): "server failure, RCODE \(rcode)" - case .compressionPointerLoop: "DNS name compression pointer loop" - case .malformedLabel: "malformed DNS label" + public enum DecodeError: Error, Equatable, Sendable, CustomStringConvertible { + case truncated + /// RCODE 3 - the name does not exist. Distinct from `serverFailure` so a + /// caller can treat "no record" and "this server misbehaved" differently. + case nameError + case serverFailure(rcode: UInt8) + case compressionPointerLoop + case malformedLabel + + public var description: String { + switch self { + case .truncated: "truncated DNS message" + case .nameError: "NXDOMAIN" + case .serverFailure(let rcode): "server failure, RCODE \(rcode)" + case .compressionPointerLoop: "DNS name compression pointer loop" + case .malformedLabel: "malformed DNS label" + } } } - } - /// Encodes a single-question TXT query for `name`. - /// - /// ID is fixed at 0 (RFC 8484 4.1: DoH responses are not associated with a - /// query by ID, so a fixed ID improves cache hit rates for intermediaries). - public static func encodeTXTQuery(name: String) throws -> Data { - var qname = Data() - for label in name.split(separator: ".", omittingEmptySubsequences: false) { - let bytes = Array(label.utf8) - guard !bytes.isEmpty else { throw EncodeError.emptyLabel } - guard bytes.count <= 63 else { - throw EncodeError.labelTooLong(String(label)) + /// Encodes a single-question TXT query for `name`. + /// + /// ID is fixed at 0 (RFC 8484 4.1: DoH responses are not associated with a + /// query by ID, so a fixed ID improves cache hit rates for intermediaries). + public static func encodeTXTQuery(name: String) throws -> Data { + var qname = Data() + for label in name.split(separator: ".", omittingEmptySubsequences: false) { + let bytes = Array(label.utf8) + guard !bytes.isEmpty else { throw EncodeError.emptyLabel } + guard bytes.count <= 63 else { + throw EncodeError.labelTooLong(String(label)) + } + qname.append(UInt8(bytes.count)) + qname.append(contentsOf: bytes) } - qname.append(UInt8(bytes.count)) - qname.append(contentsOf: bytes) + qname.append(0) + guard qname.count <= 255 else { throw EncodeError.nameTooLong } + + var message = Data(capacity: 12 + qname.count + 4) + message.append(contentsOf: [0x00, 0x00]) // ID = 0 + message.append(contentsOf: [0x01, 0x00]) // flags: RD=1, everything else 0 + message.append(contentsOf: [0x00, 0x01]) // QDCOUNT = 1 + // ANCOUNT / NSCOUNT / ARCOUNT = 0 + message.append(contentsOf: [0x00, 0x00, 0x00, 0x00, 0x00, 0x00]) + message.append(qname) + message.append(contentsOf: beBytes(typeTXT)) + message.append(contentsOf: beBytes(classIN)) + return message } - qname.append(0) - guard qname.count <= 255 else { throw EncodeError.nameTooLong } - - var message = Data(capacity: 12 + qname.count + 4) - message.append(contentsOf: [0x00, 0x00]) // ID = 0 - message.append(contentsOf: [0x01, 0x00]) // flags: RD=1, everything else 0 - message.append(contentsOf: [0x00, 0x01]) // QDCOUNT = 1 - // ANCOUNT / NSCOUNT / ARCOUNT = 0 - message.append(contentsOf: [0x00, 0x00, 0x00, 0x00, 0x00, 0x00]) - message.append(qname) - message.append(contentsOf: beBytes(typeTXT)) - message.append(contentsOf: beBytes(classIN)) - return message - } - /// The strings of every TXT record in the answer section, one entry per - /// record, that record's own character-strings already joined. - /// - /// Throws `.nameError` for NXDOMAIN - callers that treat "no record" as a - /// normal outcome (not every consumer does) should catch that case - /// specifically rather than translating every throw into "try the next - /// server". - public static func decodeTXTRecords(_ data: Data) throws -> [String] { - let bytes = [UInt8](data) - guard bytes.count >= 12 else { throw DecodeError.truncated } - - let flags = beUInt16(bytes, at: 2) - let rcode = UInt8(flags & 0x000F) - guard rcode != 3 else { throw DecodeError.nameError } - guard rcode == 0 else { throw DecodeError.serverFailure(rcode: rcode) } - - let qdcount = Int(beUInt16(bytes, at: 4)) - let ancount = Int(beUInt16(bytes, at: 6)) - - var offset = 12 - for _ in 0.. [String] { + let bytes = [UInt8](data) + guard bytes.count >= 12 else { throw DecodeError.truncated } + + let flags = beUInt16(bytes, at: 2) + let rcode = UInt8(flags & 0x000F) + guard rcode != 3 else { throw DecodeError.nameError } + guard rcode == 0 else { throw DecodeError.serverFailure(rcode: rcode) } + + let qdcount = Int(beUInt16(bytes, at: 4)) + let ancount = Int(beUInt16(bytes, at: 6)) + + var offset = 12 + for _ in 0.. Int { - var offset = start - var next: Int? - var jumps = 0 - - while true { - guard offset < bytes.count else { throw DecodeError.truncated } - let length = bytes[offset] - - if length == 0 { - if next == nil { next = offset + 1 } - return next! - } else if length & 0xC0 == 0xC0 { - guard offset + 1 < bytes.count else { throw DecodeError.truncated } - let pointer = (Int(length & 0x3F) << 8) | Int(bytes[offset + 1]) - if next == nil { next = offset + 2 } - jumps += 1 - // a pointer must target strictly earlier in the message - this - // alone guarantees termination; the jump cap is defense in depth. - guard pointer < offset, jumps <= 128 else { - throw DecodeError.compressionPointerLoop + /// Advances past a (possibly compressed) name, returning the offset just + /// past it - past the pointer if compressed, since a pointer always + /// terminates the name in the message being read. + private static func skipName(_ bytes: [UInt8], at start: Int) throws -> Int { + var offset = start + var next: Int? + var jumps = 0 + + while true { + guard offset < bytes.count else { throw DecodeError.truncated } + let length = bytes[offset] + + if length == 0 { + if next == nil { next = offset + 1 } + return next! + } else if length & 0xC0 == 0xC0 { + guard offset + 1 < bytes.count else { + throw DecodeError.truncated + } + let pointer = + (Int(length & 0x3F) << 8) | Int(bytes[offset + 1]) + if next == nil { next = offset + 2 } + jumps += 1 + // a pointer must target strictly earlier in the message - this + // alone guarantees termination; the jump cap is defense in depth. + guard pointer < offset, jumps <= 128 else { + throw DecodeError.compressionPointerLoop + } + offset = pointer + } else if length & 0xC0 != 0 { + throw DecodeError.malformedLabel + } else { + offset += 1 + Int(length) + guard offset <= bytes.count else { + throw DecodeError.truncated + } } - offset = pointer - } else if length & 0xC0 != 0 { - throw DecodeError.malformedLabel - } else { - offset += 1 + Int(length) - guard offset <= bytes.count else { throw DecodeError.truncated } } } - } - /// TXT RDATA is one or more length-prefixed character-strings; join them, - /// matching what a resolver client library's own `TXTRecord` accessor does - /// for the common single-string case. - private static func joinedCharacterStrings(_ rdata: ArraySlice) -> String { - var strings: [String] = [] - var i = rdata.startIndex - while i < rdata.endIndex { - let length = Int(rdata[i]) - i += 1 - let end = min(i + length, rdata.endIndex) - strings.append(String(decoding: rdata[i..) -> String { + var strings: [String] = [] + var i = rdata.startIndex + while i < rdata.endIndex { + let length = Int(rdata[i]) + i += 1 + let end = min(i + length, rdata.endIndex) + strings.append(String(decoding: rdata[i.. [UInt8] { - [UInt8(value >> 8), UInt8(value & 0xFF)] - } + private static func beBytes(_ value: UInt16) -> [UInt8] { + [UInt8(value >> 8), UInt8(value & 0xFF)] + } - private static func beUInt16(_ bytes: [UInt8], at offset: Int) -> UInt16 { - (UInt16(bytes[offset]) << 8) | UInt16(bytes[offset + 1]) + private static func beUInt16(_ bytes: [UInt8], at offset: Int) -> UInt16 { + (UInt16(bytes[offset]) << 8) | UInt16(bytes[offset + 1]) + } } } diff --git a/Sources/AtprotoClient/DNS/DoHTXTFetcher.swift b/Sources/AtprotoClient/DNS/DoHTXTFetcher.swift index 624592f..e274d99 100644 --- a/Sources/AtprotoClient/DNS/DoHTXTFetcher.swift +++ b/Sources/AtprotoClient/DNS/DoHTXTFetcher.swift @@ -3,107 +3,113 @@ // AtprotoClient // +import AtprotoTypes import Foundation import GermConvenience import GermConvenienceHTTP -/// RFC 8484 DNS-over-HTTPS TXT lookups over the existing `HTTPFetcher` seam. -/// -/// Portable - no platform dependency, so this is usable on every platform -/// this package supports, including ones with no per-app system resolver -/// policy to inherit from a platform-specific conformer. -public struct DoHTXTFetcher: DNSTXTFetcher { - public static let cloudflare = URL(string: "https://cloudflare-dns.com/dns-query")! - public static let google = URL(string: "https://dns.google/dns-query")! - /// Cloudflare first, Google as fallback if the former is blocked or down. - public static let defaultProviders = [cloudflare, google] +extension Atproto { + /// RFC 8484 DNS-over-HTTPS TXT lookups over the existing `HTTPFetcher` seam. + /// + /// Portable - no platform dependency, so this is usable on every platform + /// this package supports, including ones with no per-app system resolver + /// policy to inherit from a platform-specific conformer. + public struct DoHTXTFetcher: DNSTXTFetcher { + public static let cloudflare = URL(string: "https://cloudflare-dns.com/dns-query")! + public static let google = URL(string: "https://dns.google/dns-query")! + /// Cloudflare first, Google as fallback if the former is blocked or down. + public static let defaultProviders = [cloudflare, google] - let fetcher: any HTTPFetcher - let serverURLs: [URL] - let timeout: Duration + let fetcher: any HTTPFetcher + let serverURLs: [URL] + let timeout: Duration - /// `serverURLs` is a plain, ordered list of providers to try in turn - not - /// hardcoded to `defaultProviders` - so a caller can point this at a - /// self-hosted or region-specific DoH resolver instead. - public init( - fetcher: any HTTPFetcher, - serverURLs: [URL] = DoHTXTFetcher.defaultProviders, - timeout: Duration = .seconds(5) - ) { - self.fetcher = fetcher - self.serverURLs = serverURLs - self.timeout = timeout - } - - /// Tries each provider in order. Only advances on failure - a network error, - /// a non-2xx, a malformed response, or that provider's own timeout. A clean - /// NXDOMAIN is a real answer (no record), not a reason to try the next host. - public func txtRecords(name: String) async throws -> [String] { - guard !serverURLs.isEmpty else { - throw DNSTXTFetcherError.noProvidersConfigured + /// `serverURLs` is a plain, ordered list of providers to try in turn - not + /// hardcoded to `defaultProviders` - so a caller can point this at a + /// self-hosted or region-specific DoH resolver instead. + public init( + fetcher: any HTTPFetcher, + serverURLs: [URL] = DoHTXTFetcher.defaultProviders, + timeout: Duration = .seconds(5) + ) { + self.fetcher = fetcher + self.serverURLs = serverURLs + self.timeout = timeout } - let query = try DNSWireFormat.encodeTXTQuery(name: name) - var lastError: any Error = DNSTXTFetcherError.timedOut + /// Tries each provider in order. Only advances on failure - a network error, + /// a non-2xx, a malformed response, or that provider's own timeout. A clean + /// NXDOMAIN is a real answer (no record), not a reason to try the next host. + public func txtRecords(name: String) async throws -> [String] { + guard !serverURLs.isEmpty else { + throw DNSTXTFetcherError.noProvidersConfigured + } - for serverURL in serverURLs { - //a cancelled caller is not a failing provider: without this the - //loop would burn through every remaining server, each cancelled - //in turn, before reporting the cancellation. - try Task.checkCancellation() - do { - return try await queryOneServer(url: serverURL, query: query) - } catch DNSTXTFetcherError.nameError { - return [] - } catch is CancellationError { - throw CancellationError() - } catch { - lastError = error + let query = try DNSWireFormat.encodeTXTQuery(name: name) + var lastError: any Error = DNSTXTFetcherError.timedOut + + for serverURL in serverURLs { + //a cancelled caller is not a failing provider: without this the + //loop would burn through every remaining server, each cancelled + //in turn, before reporting the cancellation. + try Task.checkCancellation() + do { + return try await queryOneServer( + url: serverURL, query: query) + } catch DNSTXTFetcherError.nameError { + return [] + } catch is CancellationError { + throw CancellationError() + } catch { + lastError = error + } } + throw lastError } - throw lastError - } - /// Races the network request against `timeout`; cancels whichever loses. - private func queryOneServer(url: URL, query: Data) async throws -> [String] { - try await withThrowingTaskGroup(of: [String].self) { group in - defer { group.cancelAll() } + /// Races the network request against `timeout`; cancels whichever loses. + private func queryOneServer(url: URL, query: Data) async throws -> [String] { + try await withThrowingTaskGroup(of: [String].self) { group in + defer { group.cancelAll() } - group.addTask { try await self.performQuery(url: url, query: query) } - group.addTask { - try await Task.sleep(for: self.timeout) - throw DNSTXTFetcherError.timedOut - } + group.addTask { + try await self.performQuery(url: url, query: query) + } + group.addTask { + try await Task.sleep(for: self.timeout) + throw DNSTXTFetcherError.timedOut + } - guard let result = try await group.next() else { - throw DNSTXTFetcherError.timedOut + guard let result = try await group.next() else { + throw DNSTXTFetcherError.timedOut + } + return result } - return result } - } - private func performQuery(url: URL, query: Data) async throws -> [String] { - let request = try BundledHTTPRequest( - method: .post, - url: url, - headerFields: [ - .contentType: "application/dns-message", - .accept: "application/dns-message", - ], - body: query - ) - let data = try await fetcher.data(for: request).expectSuccess() + private func performQuery(url: URL, query: Data) async throws -> [String] { + let request = try BundledHTTPRequest( + method: .post, + url: url, + headerFields: [ + .contentType: "application/dns-message", + .accept: "application/dns-message", + ], + body: query + ) + let data = try await fetcher.data(for: request).expectSuccess() - do { - return try DNSWireFormat.decodeTXTRecords(data) - } catch let decodeError as DNSWireFormat.DecodeError { - switch decodeError { - case .nameError: - throw DNSTXTFetcherError.nameError - case .serverFailure(let rcode): - throw DNSTXTFetcherError.serverError(rcode: rcode) - case .truncated, .compressionPointerLoop, .malformedLabel: - throw DNSTXTFetcherError.malformedResponse(decodeError) + do { + return try DNSWireFormat.decodeTXTRecords(data) + } catch let decodeError as DNSWireFormat.DecodeError { + switch decodeError { + case .nameError: + throw DNSTXTFetcherError.nameError + case .serverFailure(let rcode): + throw DNSTXTFetcherError.serverError(rcode: rcode) + case .truncated, .compressionPointerLoop, .malformedLabel: + throw DNSTXTFetcherError.malformedResponse(decodeError) + } } } } diff --git a/Tests/AtprotoClientTests/DNS/DNSWireFormatTests.swift b/Tests/AtprotoClientTests/DNS/DNSWireFormatTests.swift index 5fea423..857fd62 100644 --- a/Tests/AtprotoClientTests/DNS/DNSWireFormatTests.swift +++ b/Tests/AtprotoClientTests/DNS/DNSWireFormatTests.swift @@ -3,6 +3,7 @@ // AtprotoClientTests // +import AtprotoTypes import Foundation import Testing @@ -13,7 +14,7 @@ struct DNSWireFormatTests { // MARK: - encode @Test func encodesTheExpectedQuestionSection() throws { - let query = try DNSWireFormat.encodeTXTQuery(name: "_atproto.pfrazee.com") + let query = try Atproto.DNSWireFormat.encodeTXTQuery(name: "_atproto.pfrazee.com") // captured with `dig`: the byte-for-byte query Cloudflare's DoH endpoint // answered to produce `realTXTResponse` below. @@ -23,15 +24,15 @@ struct DNSWireFormatTests { } @Test func rejectsAnEmptyLabel() { - #expect(throws: DNSWireFormat.EncodeError.emptyLabel) { - try DNSWireFormat.encodeTXTQuery(name: "_atproto..example.com") + #expect(throws: Atproto.DNSWireFormat.EncodeError.emptyLabel) { + try Atproto.DNSWireFormat.encodeTXTQuery(name: "_atproto..example.com") } } @Test func rejectsALabelOver63Bytes() { let label = String(repeating: "a", count: 64) - #expect(throws: DNSWireFormat.EncodeError.labelTooLong(label)) { - try DNSWireFormat.encodeTXTQuery(name: "\(label).example.com") + #expect(throws: Atproto.DNSWireFormat.EncodeError.labelTooLong(label)) { + try Atproto.DNSWireFormat.encodeTXTQuery(name: "\(label).example.com") } } @@ -47,7 +48,7 @@ struct DNSWireFormatTests { )! @Test func decodesARealSingleAnswerRecordWithACompressionPointer() throws { - let records = try DNSWireFormat.decodeTXTRecords(Self.realTXTResponse) + let records = try Atproto.DNSWireFormat.decodeTXTRecords(Self.realTXTResponse) #expect(records == ["did=did:plc:ragtjsm2j2vknwkz3zp4oxrd"]) } @@ -64,8 +65,8 @@ struct DNSWireFormatTests { )! @Test func nxdomainThrowsNameError() { - #expect(throws: DNSWireFormat.DecodeError.nameError) { - try DNSWireFormat.decodeTXTRecords(Self.realNXDOMAINResponse) + #expect(throws: Atproto.DNSWireFormat.DecodeError.nameError) { + try Atproto.DNSWireFormat.decodeTXTRecords(Self.realNXDOMAINResponse) } } @@ -82,7 +83,8 @@ struct DNSWireFormatTests { )! @Test func decodesFourAnswersEachAsItsOwnEntry() throws { - let records = try DNSWireFormat.decodeTXTRecords(Self.realMultiAnswerResponse) + let records = try Atproto.DNSWireFormat.decodeTXTRecords( + Self.realMultiAnswerResponse) #expect(records.count == 4) #expect(records[1] == "v=spf1 redirect=_spf.google.com") } @@ -103,7 +105,7 @@ struct DNSWireFormatTests { bytes += beU16(UInt16(rdata.count)) bytes += rdata - let records = try DNSWireFormat.decodeTXTRecords(Data(bytes)) + let records = try Atproto.DNSWireFormat.decodeTXTRecords(Data(bytes)) #expect(records == ["fooba"]) } @@ -125,7 +127,7 @@ struct DNSWireFormatTests { bytes += beU16(UInt16(rdata.count)) bytes += rdata - let records = try DNSWireFormat.decodeTXTRecords(Data(bytes)) + let records = try Atproto.DNSWireFormat.decodeTXTRecords(Data(bytes)) #expect(records == ["real"]) } @@ -138,8 +140,8 @@ struct DNSWireFormatTests { bytes += [0xC0, UInt8(pointerAt + 4)] // points past itself bytes += [0, 16, 0, 1] - #expect(throws: DNSWireFormat.DecodeError.compressionPointerLoop) { - try DNSWireFormat.decodeTXTRecords(Data(bytes)) + #expect(throws: Atproto.DNSWireFormat.DecodeError.compressionPointerLoop) { + try Atproto.DNSWireFormat.decodeTXTRecords(Data(bytes)) } } @@ -150,8 +152,8 @@ struct DNSWireFormatTests { bytes += [0, 16, 0, 1] // no answer section at all, despite ANCOUNT=1 - #expect(throws: DNSWireFormat.DecodeError.truncated) { - try DNSWireFormat.decodeTXTRecords(Data(bytes)) + #expect(throws: Atproto.DNSWireFormat.DecodeError.truncated) { + try Atproto.DNSWireFormat.decodeTXTRecords(Data(bytes)) } } @@ -165,8 +167,8 @@ struct DNSWireFormatTests { bytes += [0xC0, 0x0C] // answer owner: pointer to offset 12 bytes += [0, 16, 0, 1] // TYPE + CLASS only - TTL/RDLENGTH missing - #expect(throws: DNSWireFormat.DecodeError.truncated) { - try DNSWireFormat.decodeTXTRecords(Data(bytes)) + #expect(throws: Atproto.DNSWireFormat.DecodeError.truncated) { + try Atproto.DNSWireFormat.decodeTXTRecords(Data(bytes)) } } @@ -181,16 +183,16 @@ struct DNSWireFormatTests { bytes += [0, 16, 0, 1, 0, 0, 0, 60] // TYPE, CLASS, TTL bytes += beU16(200) // RDLENGTH claims 200 bytes; none follow - #expect(throws: DNSWireFormat.DecodeError.truncated) { - try DNSWireFormat.decodeTXTRecords(Data(bytes)) + #expect(throws: Atproto.DNSWireFormat.DecodeError.truncated) { + try Atproto.DNSWireFormat.decodeTXTRecords(Data(bytes)) } } @Test func serverFailureRcodeIsDistinctFromNameError() { // RCODE=2, SERVFAIL let bytes: [UInt8] = [0, 0, 0x81, 0x82, 0, 0, 0, 0, 0, 0, 0, 0] - #expect(throws: DNSWireFormat.DecodeError.serverFailure(rcode: 2)) { - try DNSWireFormat.decodeTXTRecords(Data(bytes)) + #expect(throws: Atproto.DNSWireFormat.DecodeError.serverFailure(rcode: 2)) { + try Atproto.DNSWireFormat.decodeTXTRecords(Data(bytes)) } } diff --git a/Tests/AtprotoClientTests/DNS/DnsHandleResolverTests.swift b/Tests/AtprotoClientTests/DNS/DnsHandleResolverTests.swift index 7880094..fe69271 100644 --- a/Tests/AtprotoClientTests/DNS/DnsHandleResolverTests.swift +++ b/Tests/AtprotoClientTests/DNS/DnsHandleResolverTests.swift @@ -13,7 +13,7 @@ import Testing @testable import AtprotoClient -private final class MockTXTFetcher: DNSTXTFetcher, @unchecked Sendable { +private final class MockTXTFetcher: Atproto.DNSTXTFetcher, @unchecked Sendable { let records: [String] private(set) var requestedName: String? @@ -96,7 +96,7 @@ struct DnsHandleResolverTests { @Test func propagatesAThrowFromTheFetcher() async throws { struct Boom: Error {} - struct ThrowingFetcher: DNSTXTFetcher { + struct ThrowingFetcher: Atproto.DNSTXTFetcher { func txtRecords(name: String) async throws -> [String] { throw Boom() } } let resolver = Atproto.DnsHandleResolver(txtFetcher: ThrowingFetcher()) diff --git a/Tests/AtprotoClientTests/DNS/DoHTXTFetcherTests.swift b/Tests/AtprotoClientTests/DNS/DoHTXTFetcherTests.swift index e2f9358..eae9dc6 100644 --- a/Tests/AtprotoClientTests/DNS/DoHTXTFetcherTests.swift +++ b/Tests/AtprotoClientTests/DNS/DoHTXTFetcherTests.swift @@ -3,6 +3,7 @@ // AtprotoClientTests // +import AtprotoTypes import Foundation import GermConvenience import GermConvenienceHTTP @@ -48,8 +49,8 @@ private final class ScriptedFetcher: HTTPFetcher, @unchecked Sendable { } } -private let cloudflare = DoHTXTFetcher.cloudflare -private let google = DoHTXTFetcher.google +private let cloudflare = Atproto.DoHTXTFetcher.cloudflare +private let google = Atproto.DoHTXTFetcher.google struct DoHTXTFetcherTests { @@ -59,7 +60,7 @@ struct DoHTXTFetcherTests { let fetcher = ScriptedFetcher([ .response(.init(data: responseData, response: .init(status: .ok))) ]) - let txtFetcher = DoHTXTFetcher(fetcher: fetcher, serverURLs: [cloudflare]) + let txtFetcher = Atproto.DoHTXTFetcher(fetcher: fetcher, serverURLs: [cloudflare]) _ = try await txtFetcher.txtRecords(name: "_atproto.example.com") @@ -69,7 +70,8 @@ struct DoHTXTFetcherTests { #expect(sent.request.headerFields[.accept] == "application/dns-message") #expect( sent.body - == (try DNSWireFormat.encodeTXTQuery(name: "_atproto.example.com"))) + == (try Atproto.DNSWireFormat.encodeTXTQuery( + name: "_atproto.example.com"))) #expect(fetcher.requestedURLs == [cloudflare]) } @@ -80,7 +82,7 @@ struct DoHTXTFetcherTests { let fetcher = ScriptedFetcher([ .response(.init(data: responseData, response: .init(status: .ok))) ]) - let txtFetcher = DoHTXTFetcher(fetcher: fetcher, serverURLs: [cloudflare]) + let txtFetcher = Atproto.DoHTXTFetcher(fetcher: fetcher, serverURLs: [cloudflare]) let records = try await txtFetcher.txtRecords(name: "_atproto.example.com") @@ -97,7 +99,8 @@ struct DoHTXTFetcherTests { .init(data: Data(), response: .init(status: .internalServerError))), .response(.init(data: responseData, response: .init(status: .ok))), ]) - let txtFetcher = DoHTXTFetcher(fetcher: fetcher, serverURLs: [cloudflare, google]) + let txtFetcher = Atproto.DoHTXTFetcher( + fetcher: fetcher, serverURLs: [cloudflare, google]) let records = try await txtFetcher.txtRecords(name: "_atproto.example.com") @@ -110,7 +113,8 @@ struct DoHTXTFetcherTests { let fetcher = ScriptedFetcher([ .response(.init(data: nxdomain, response: .init(status: .ok))) ]) - let txtFetcher = DoHTXTFetcher(fetcher: fetcher, serverURLs: [cloudflare, google]) + let txtFetcher = Atproto.DoHTXTFetcher( + fetcher: fetcher, serverURLs: [cloudflare, google]) let records = try await txtFetcher.txtRecords(name: "_atproto.example.com") @@ -125,7 +129,8 @@ struct DoHTXTFetcherTests { .response( .init(data: Data(), response: .init(status: .internalServerError))), ]) - let txtFetcher = DoHTXTFetcher(fetcher: fetcher, serverURLs: [cloudflare, google]) + let txtFetcher = Atproto.DoHTXTFetcher( + fetcher: fetcher, serverURLs: [cloudflare, google]) await #expect(throws: (any Error).self) { try await txtFetcher.txtRecords(name: "_atproto.example.com") @@ -142,7 +147,7 @@ struct DoHTXTFetcherTests { .hang, .response(.init(data: responseData, response: .init(status: .ok))), ]) - let txtFetcher = DoHTXTFetcher( + let txtFetcher = Atproto.DoHTXTFetcher( fetcher: fetcher, serverURLs: [cloudflare, google], timeout: .milliseconds(100)) @@ -159,7 +164,7 @@ struct DoHTXTFetcherTests { // provider failed, try the next one." @Test func taskCancellationInterruptsPromptly() async throws { let fetcher = ScriptedFetcher([.hang]) - let txtFetcher = DoHTXTFetcher( + let txtFetcher = Atproto.DoHTXTFetcher( fetcher: fetcher, serverURLs: [cloudflare, google], timeout: .seconds(30)) let task = Task { From 18c070d62897a63f70e74a082365abfbc95dd906 Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Tue, 22 Sep 2026 16:08:23 -0700 Subject: [PATCH 4/4] Add relationshipLookup, bskyProfileIfExists, and BskyCDN read helpers Three additive helpers on Atproto.XRPC.BskyAppCallable: relationshipLookup(actor:others:) dedupes/chunks app.bsky.graph.getRelationships at the lexicon's 30-subject limit and reports not-found subjects rather than dropping them (not an account-existence check - the AppView returns a relationship entry for any well-formed DID); bskyProfileIfExists(actor:) maps getProfile's undeclared not-found shape (400 InvalidRequest, "Profile not found") to nil; and Atproto.BskyCDN.imageURL(_:did:blob:host:) builds CDN image URLs from a blob reference with explicit RFC 3986 path-segment encoding so a crafted DID/CID can't escape its segment. Also fixes GetRelationships.Parameters.init to accept exactly 30 others (the lexicon's own maxLength, previously rejected) and makes GetRelationships.Errors public, since it's already thrown by public API. Co-Authored-By: Claude Sonnet 5 --- .changeset/bsky-read-helpers.md | 7 + .../Agent/Agents/BskyAppAgent.swift | 89 +++++ Sources/AtprotoClient/BskyCDN.swift | 111 ++++++ .../Lexicon/AppBsky/Actor/GetProfile.swift | 5 +- .../AppBsky/Graph/GetRelationships.swift | 50 ++- Tests/AtprotoClientTests/BskyCDNTests.swift | 112 ++++++ .../BskyProfileIfExistsTests.swift | 104 ++++++ .../GetRelationshipsTests.swift | 339 ++++++++++++++++++ 8 files changed, 808 insertions(+), 9 deletions(-) create mode 100644 .changeset/bsky-read-helpers.md create mode 100644 Sources/AtprotoClient/BskyCDN.swift create mode 100644 Tests/AtprotoClientTests/BskyCDNTests.swift create mode 100644 Tests/AtprotoClientTests/BskyProfileIfExistsTests.swift create mode 100644 Tests/AtprotoClientTests/GetRelationshipsTests.swift diff --git a/.changeset/bsky-read-helpers.md b/.changeset/bsky-read-helpers.md new file mode 100644 index 0000000..f3a13ea --- /dev/null +++ b/.changeset/bsky-read-helpers.md @@ -0,0 +1,7 @@ +--- +"@germ-network/atprotoclient": minor +--- + +Add three Bluesky read helpers on `Atproto.XRPC.BskyAppCallable`: `relationshipLookup(actor:others:)`, which reports which `app.bsky.graph.getRelationships` subjects the AppView returned a relationship for and which it didn't (unlike `getRelationships(actor:subjects:)`, which drops every not-found subject and loses which ones they were) - note that this isn't an account-existence check, since the AppView returns a relationship entry for any well-formed DID whether or not the account exists; and `bskyProfileIfExists(actor:)`, which maps `app.bsky.actor.getProfile`'s undeclared not-found shape (a 400 `InvalidRequest` whose message is "Profile not found") to `nil` rather than throwing. Also add `Atproto.BskyCDN.imageURL(_:did:blob:host:)` to build Bluesky CDN image URLs from a blob reference. + +Also fix `Lexicon.App.Bsky.Graph.GetRelationships.Parameters.init` to accept exactly 30 `others`, matching the lexicon's `others.maxLength: 30` - it previously rejected exactly 30, and make `Lexicon.App.Bsky.Graph.GetRelationships.Errors` (`tooManyOthersInput`, `actorMismatch`) public - it's already thrown by public API. diff --git a/Sources/AtprotoClient/Agent/Agents/BskyAppAgent.swift b/Sources/AtprotoClient/Agent/Agents/BskyAppAgent.swift index 1a1b833..5d7e81d 100644 --- a/Sources/AtprotoClient/Agent/Agents/BskyAppAgent.swift +++ b/Sources/AtprotoClient/Agent/Agents/BskyAppAgent.swift @@ -64,9 +64,33 @@ extension Atproto.XRPC.BskyAppCallable { parameters: .init(actor: actor) ) } + + /// `getProfile` declares no dedicated not-found error - a missing actor + /// arrives as a 400 `InvalidRequest` whose message happens to be "Profile + /// not found". Maps exactly that shape to `nil`; every other error + /// (including other `InvalidRequest` messages, and a deactivated or + /// suspended account's `AccountDeactivated`/`AccountTakedown`) rethrows. + public func bskyProfileIfExists( + actor: LexiconString.AtIdentifier + ) async throws -> Lexicon.App.Bsky.Actor.Defs.ProfileViewDetailed? { + do { + return try await bskyProfile(actor: actor) + } catch Atproto.XRPC.ParseError.xrpcError( + status: .badRequest, + error: let error + ) + where error.error == "InvalidRequest" + && error.message == "Profile not found" + { + return nil + } + } } extension Atproto.XRPC.BskyAppCallable { + /// Drops every not-found subject and loses which ones they were - use + /// ``relationshipLookup(actor:others:)`` to keep that information. Still + /// throws `GetRelationships.Errors.tooManyOthersInput` above 30 subjects. public func getRelationships( actor: Atproto.DID, subjects: [Atproto.DID] @@ -79,6 +103,71 @@ extension Atproto.XRPC.BskyAppCallable { return results.relationships .compactMap { $0.asRelationships } } + + /// `others` deduped (preserving order) and chunked into requests of at + /// most 30, the lexicon's `others.maxLength`, merging every chunk's + /// result. Only `.relationship` + /// entries for a requested DID count as found - an unrequested DID the + /// server threw in is dropped, and `.notFoundActor` is never used to + /// populate `found` (its `actor` can arrive in handle form, which can't + /// be mapped back to the DID that was actually requested). Whatever + /// wasn't found - whether the server said so explicitly (by DID or by + /// handle) or simply omitted it - lands in `notFound`. See + /// `GetRelationships.Lookup`'s doc for why this isn't an + /// account-existence check. + public func relationshipLookup( + actor: Atproto.DID, + others: [Atproto.DID] + ) async throws -> Lexicon.App.Bsky.Graph.GetRelationships.Lookup { + let deduped = Self.dedupedPreservingOrder(others) + guard !deduped.isEmpty else { + return .init() + } + let requested = Set(deduped) + + var found: [Atproto.DID: Lexicon.App.Bsky.Graph.Relationships] = [:] + for chunk in Self.chunked( + deduped, + intoSizeAtMost: Lexicon.App.Bsky.Graph.GetRelationships.Parameters.maxOthers + ) { + let parameters = try Lexicon.App.Bsky.Graph.GetRelationships.Parameters( + actor: .did(actor), + others: chunk.map { .did($0) } + ) + let output = try await call( + Lexicon.App.Bsky.Graph.GetRelationships.self, + parameters: parameters + ) + guard output.actor == actor else { + throw Lexicon.App.Bsky.Graph.GetRelationships.Errors.actorMismatch( + requested: actor, + returned: output.actor + ) + } + for entry in output.relationships { + guard case .relationship(let relationship) = entry, + requested.contains(relationship.did) + else { continue } + found[relationship.did] = relationship + } + } + let notFound = deduped.filter { found[$0] == nil } + return .init(found: found, notFound: notFound) + } + + private static func dedupedPreservingOrder(_ dids: [Atproto.DID]) -> [Atproto.DID] { + var seen = Set() + return dids.filter { seen.insert($0).inserted } + } + + private static func chunked( + _ dids: [Atproto.DID], + intoSizeAtMost size: Int + ) -> [[Atproto.DID]] { + stride(from: 0, to: dids.count, by: size).map { + Array(dids[$0.. URL { + guard + let components = URLComponents( + url: host, resolvingAgainstBaseURL: false), + let scheme = components.scheme?.lowercased(), + scheme == "https" || scheme == "http", + let componentHost = components.host, !componentHost.isEmpty, + components.path.isEmpty || components.path == "/", + components.query == nil, + components.fragment == nil, + components.user == nil, + components.password == nil + else { + throw Errors.invalidHost + } + var built = components + // Normalize the scheme's case so "HTTPS://cdn.bsky.app" and + // "https://cdn.bsky.app/" both produce the same canonical URL as + // "https://cdn.bsky.app". + built.scheme = scheme + // `did.rawValue` is attacker-influenced (a DID's identifier is + // otherwise unvalidated) and only percent-encoding "/" out of it + // stops it from smuggling extra path segments (e.g. + // "did:plc:../../evil") into the CDN request - `URLComponents.path` + // leaves "/" alone since it's a legal path character, so this is + // set through `percentEncodedPath` instead, on an already-escaped + // string. + built.percentEncodedPath = + "/img/\(kind.rawValue)/plain/\(try Self.pathSegment(did.rawValue))/\(try Self.pathSegment(blob.ref.link.string))@jpeg" + guard let url = built.url else { + throw Errors.invalidHost + } + return url + } + + // Built explicitly from RFC 3986 (unreserved + sub-delims + ":", + // excluding "/" and "@") rather than derived from + // `CharacterSet.urlPathAllowed` - Darwin's built-in set and a + // modified copy of it don't necessarily encode `:` the same way, + // which would make exact-URL tests diverge on Linux. Excluding "@" + // keeps the only literal "@" in the path the fixed "@jpeg" suffix. + private static let pathSegmentAllowed: CharacterSet = { + var allowed = CharacterSet() + allowed.insert( + charactersIn: + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + ) + allowed.insert(charactersIn: "!$&'()*+,;=") + allowed.insert(charactersIn: ":") + return allowed + }() + + private static func pathSegment(_ value: String) throws -> String { + guard + let encoded = value.addingPercentEncoding( + withAllowedCharacters: Self.pathSegmentAllowed) + else { + throw Errors.unencodablePathSegment + } + return encoded + } + } +} + +extension Atproto.BskyCDN { + public enum Errors: Error, Equatable, Sendable { + /// `host` isn't a bare `http`/`https` origin (no path, query, + /// fragment, or userinfo). + case invalidHost + /// A DID or CID couldn't be percent-encoded into a path segment. + case unencodablePathSegment + } +} + +extension Atproto.BskyCDN.Errors: LocalizedError { + public var errorDescription: String? { + switch self { + case .invalidHost: + "host must be a bare http(s) origin (no path, query, fragment, or userinfo)" + case .unencodablePathSegment: + "a DID or CID could not be percent-encoded into a CDN URL path segment" + } + } +} diff --git a/Sources/AtprotoClient/Lexicon/AppBsky/Actor/GetProfile.swift b/Sources/AtprotoClient/Lexicon/AppBsky/Actor/GetProfile.swift index daee0a2..83e83b4 100644 --- a/Sources/AtprotoClient/Lexicon/AppBsky/Actor/GetProfile.swift +++ b/Sources/AtprotoClient/Lexicon/AppBsky/Actor/GetProfile.swift @@ -41,5 +41,8 @@ extension Lexicon.App.Bsky.Actor { } extension Lexicon.App.Bsky.Actor.GetProfile: Atproto.XRPC.ResponseParsing { - public static var badRequestErrors: Set { defaultErrors } + /// Explicit about `InvalidRequest` rather than relying on it already + /// being in `defaultErrors` - `bskyProfileIfExists(actor:)` depends on + /// this error being parsed rather than falling through to `.unrecognized`. + public static var badRequestErrors: Set { defaultErrors.union(["InvalidRequest"]) } } diff --git a/Sources/AtprotoClient/Lexicon/AppBsky/Graph/GetRelationships.swift b/Sources/AtprotoClient/Lexicon/AppBsky/Graph/GetRelationships.swift index 32c39dc..7366c81 100644 --- a/Sources/AtprotoClient/Lexicon/AppBsky/Graph/GetRelationships.swift +++ b/Sources/AtprotoClient/Lexicon/AppBsky/Graph/GetRelationships.swift @@ -33,7 +33,9 @@ extension Lexicon.App.Bsky.Graph { others: [LexiconString.AtIdentifier]? ) throws { if let others { - guard others.count < Self.maxOthers else { + // The lexicon declares others.maxLength: 30, so exactly 30 + // is valid - only 31+ is too many. + guard others.count <= Self.maxOthers else { throw Errors.tooManyOthersInput } } @@ -104,16 +106,48 @@ extension Lexicon.App.Bsky.Graph { } } - enum Errors: LocalizedError { - case tooManyOthersInput + /// Found/not-found subjects from ``Atproto.XRPC.BskyAppCallable/relationshipLookup(actor:others:)``, + /// which - unlike ``Atproto.XRPC.BskyAppCallable/getRelationships(actor:subjects:)`` - + /// keeps track of which requested subjects weren't found. + /// + /// `found`/`notFound` isn't an account-existence check: the AppView + /// returns a `#relationship` entry for any well-formed DID, whether or + /// not the account exists, so a nonexistent `did:plc` still lands in + /// `found`. `notFound` only covers what the server explicitly reported + /// via `notFoundActor` (in practice, for a handle that doesn't + /// resolve) or omitted outright. Callers that need to know whether an + /// account actually exists should use + /// ``Atproto.XRPC.BskyAppCallable/bskyProfileIfExists(actor:)``. + public struct Lookup: Sendable { + public var found: [Atproto.DID: Relationships] + public var notFound: [Atproto.DID] - var errorDescription: String? { - switch self { - case .tooManyOthersInput: - "Too many others input" - } + public init( + found: [Atproto.DID: Relationships] = [:], + notFound: [Atproto.DID] = [] + ) { + self.found = found + self.notFound = notFound } } + + public enum Errors: Error, Equatable, Sendable { + case tooManyOthersInput + /// A chunked request's response named a different `actor` than the + /// one requested. + case actorMismatch(requested: Atproto.DID, returned: Atproto.DID) + } + } +} + +extension Lexicon.App.Bsky.Graph.GetRelationships.Errors: LocalizedError { + public var errorDescription: String? { + switch self { + case .tooManyOthersInput: + "Too many others input" + case .actorMismatch(let requested, let returned): + "getRelationships returned actor \(returned.rawValue), requested \(requested.rawValue)" + } } } diff --git a/Tests/AtprotoClientTests/BskyCDNTests.swift b/Tests/AtprotoClientTests/BskyCDNTests.swift new file mode 100644 index 0000000..d3212c8 --- /dev/null +++ b/Tests/AtprotoClientTests/BskyCDNTests.swift @@ -0,0 +1,112 @@ +// +// BskyCDNTests.swift +// AtprotoClientTests +// + +import AtprotoTypes +import Foundation +import Testing + +@testable import AtprotoClient + +@Suite struct BskyCDNTests { + private func blob(cid: Atproto.CID) -> Atproto.Primitive.Blob { + .init(ref: .init(link: cid), mimeType: "image/jpeg", size: 100) + } + + @Test("imageURL builds the exact avatar CDN URL") + func avatarURLExactString() throws { + let did = Atproto.DID(method: .plc, identifier: "aaaaaaaaaaaaaaaaaaaaaaaa") + let cid = try Atproto.CID(string: "baaaaaaaa") + + let url = try Atproto.BskyCDN.imageURL(.avatar, did: did, blob: blob(cid: cid)) + + #expect( + url.absoluteString + == "https://cdn.bsky.app/img/avatar/plain/\(did.rawValue)/\(cid.string)@jpeg" + ) + } + + @Test("imageURL builds the exact banner CDN URL against a custom host") + func bannerURLExactString() throws { + let did = Atproto.DID(method: .web, identifier: "example.com") + let cid = try Atproto.CID(string: "baaaaaaaa") + + let url = try Atproto.BskyCDN.imageURL( + .banner, did: did, blob: blob(cid: cid), + host: URL(string: "https://cdn.example.net")!) + + #expect( + url.absoluteString + == "https://cdn.example.net/img/banner/plain/\(did.rawValue)/\(cid.string)@jpeg" + ) + } + + @Test("A crafted DID with path-traversal segments can't escape the CDN path") + func pathTraversalDIDStaysOneSegment() throws { + let did = try Atproto.DID(string: "did:plc:../../../../evil/x?y#z") + let cid = try Atproto.CID(string: "baaaaaaaa") + + let url = try Atproto.BskyCDN.imageURL(.avatar, did: did, blob: blob(cid: cid)) + + #expect(url.host == "cdn.bsky.app") + #expect(url.absoluteString.hasPrefix("https://cdn.bsky.app/img/avatar/plain/")) + // The DID's own "/" characters must be encoded, not literal path + // separators - otherwise the ".." segments could traverse out of + // /img/avatar/plain/. + #expect(!url.absoluteString.contains("/..")) + #expect(url.absoluteString.contains("%2F")) + } + + @Test( + "A trailing slash and an uppercase scheme are both accepted, producing the canonical URL", + arguments: ["https://cdn.bsky.app/", "HTTPS://cdn.bsky.app"] + ) + func acceptsTrailingSlashAndUppercaseScheme(_ hostString: String) throws { + let did = Atproto.DID(method: .plc, identifier: "aaaaaaaaaaaaaaaaaaaaaaaa") + let cid = try Atproto.CID(string: "baaaaaaaa") + + let url = try Atproto.BskyCDN.imageURL( + .avatar, did: did, blob: blob(cid: cid), + host: URL(string: hostString)!) + + #expect( + url.absoluteString + == "https://cdn.bsky.app/img/avatar/plain/\(did.rawValue)/\(cid.string)@jpeg" + ) + } + + @Test("An '@' in a DID is percent-encoded, leaving only the '@jpeg' suffix literal") + func atSignInDIDIsEncoded() throws { + let did = Atproto.DID(method: .plc, identifier: "evil@example.com") + let cid = try Atproto.CID(string: "baaaaaaaa") + + let url = try Atproto.BskyCDN.imageURL(.avatar, did: did, blob: blob(cid: cid)) + + #expect(did.rawValue.contains("@")) + let pathAfterDID = url.absoluteString.components(separatedBy: "/").last ?? "" + #expect(pathAfterDID == "\(cid.string)@jpeg") + #expect(url.absoluteString.contains("%40")) + } + + @Test( + "imageURL rejects a host that isn't a bare http(s) origin", + arguments: [ + "https://cdn.bsky.app/extra", + "https://cdn.bsky.app?x=1", + "https://cdn.bsky.app#frag", + "https://user@cdn.bsky.app", + "ftp://cdn.bsky.app", + ] + ) + func rejectsNonBareHost(_ hostString: String) throws { + let did = Atproto.DID(method: .plc, identifier: "aaaaaaaaaaaaaaaaaaaaaaaa") + let cid = try Atproto.CID(string: "baaaaaaaa") + + #expect(throws: Atproto.BskyCDN.Errors.invalidHost) { + try Atproto.BskyCDN.imageURL( + .avatar, did: did, blob: blob(cid: cid), + host: URL(string: hostString)!) + } + } +} diff --git a/Tests/AtprotoClientTests/BskyProfileIfExistsTests.swift b/Tests/AtprotoClientTests/BskyProfileIfExistsTests.swift new file mode 100644 index 0000000..0381114 --- /dev/null +++ b/Tests/AtprotoClientTests/BskyProfileIfExistsTests.swift @@ -0,0 +1,104 @@ +// +// BskyProfileIfExistsTests.swift +// AtprotoClientTests +// + +import AtprotoTypes +import Foundation +import GermConvenience +import GermConvenienceHTTP +import HTTPTypes +import Testing + +import struct AtprotoClientMocks.StubHTTPFetcher + +@testable import AtprotoClient + +@Suite struct BskyProfileIfExistsTests { + private let appViewURL = URL(string: "https://appview.example.com")! + private let actor: LexiconString.AtIdentifier + + init() throws { + actor = .handle(try Atproto.Handle(string: "nobody.example.com")) + } + + private func agent(fetcher: HTTPFetcher) throws -> BskyAppViewAgent { + try BskyAppViewAgent(serviceUrl: appViewURL, resourceFetcher: fetcher) + } + + /// `getProfile` declares no errors of its own - confirms the live + /// AppView's actual miss shape parses as a typed `.xrpcError` (rather + /// than falling through to `.unrecognized`) with the message intact, + /// which is what `bskyProfileIfExists` pattern-matches against. + @Test( + "GetProfile.badRequestErrors recognizes InvalidRequest, so the real parser produces .xrpcError" + ) + func realParserProducesXrpcErrorWithMessageIntact() async throws { + #expect( + Lexicon.App.Bsky.Actor.GetProfile.badRequestErrors.contains( + "InvalidRequest")) + + let fetcher = StubHTTPFetcher( + .init( + data: Data( + "{\"error\":\"InvalidRequest\",\"message\":\"Profile not found\"}" + .utf8), + response: .init(status: .badRequest))) + + let thrown = await #expect(throws: Atproto.XRPC.ParseError.self) { + try await self.agent(fetcher: fetcher).bskyProfile(actor: actor) + } + guard case .xrpcError(let status, let error) = thrown else { + Issue.record("expected .xrpcError, got \(String(describing: thrown))") + return + } + #expect(status == .badRequest) + #expect(error.error == "InvalidRequest") + #expect(error.message == "Profile not found") + } + + @Test("The exact not-found shape (400 InvalidRequest 'Profile not found') maps to nil") + func exactNotFoundShapeMapsToNil() async throws { + let fetcher = StubHTTPFetcher( + .init( + data: Data( + "{\"error\":\"InvalidRequest\",\"message\":\"Profile not found\"}" + .utf8), + response: .init(status: .badRequest))) + + let profile = try await agent(fetcher: fetcher).bskyProfileIfExists(actor: actor) + + #expect(profile == nil) + } + + @Test("A 400 InvalidRequest with a different message rethrows rather than mapping to nil") + func differentMessageRethrows() async throws { + let fetcher = StubHTTPFetcher( + .init( + data: Data( + "{\"error\":\"InvalidRequest\",\"message\":\"Something else\"}" + .utf8), + response: .init(status: .badRequest))) + + await #expect(throws: Atproto.XRPC.ParseError.self) { + try await self.agent(fetcher: fetcher).bskyProfileIfExists( + actor: self.actor) + } + } + + @Test("A 200 response returns the profile") + func successReturnsProfile() async throws { + let fetcher = StubHTTPFetcher( + .init( + data: Data( + """ + {"did":"did:plc:aaaaaaaaaaaaaaaaaaaaaaaa","handle":"alice.example.com","displayName":"Alice"} + """.utf8), + response: .init(status: .ok))) + + let profile = try await agent(fetcher: fetcher).bskyProfileIfExists(actor: actor) + + #expect(profile?.handle.rawValue == "alice.example.com") + #expect(profile?.displayName == "Alice") + } +} diff --git a/Tests/AtprotoClientTests/GetRelationshipsTests.swift b/Tests/AtprotoClientTests/GetRelationshipsTests.swift new file mode 100644 index 0000000..c128989 --- /dev/null +++ b/Tests/AtprotoClientTests/GetRelationshipsTests.swift @@ -0,0 +1,339 @@ +// +// GetRelationshipsTests.swift +// AtprotoClientTests +// + +import AtprotoTypes +import Foundation +import GermConvenience +import GermConvenienceHTTP +import HTTPTypes +import Testing + +import struct AtprotoClientMocks.StubHTTPFetcher + +@testable import AtprotoClient + +/// Thread-safe call counter - `StubHTTPFetcher`'s handler is synchronous but +/// invoked from concurrent async call sites. +private final class CallCounter: @unchecked Sendable { + private let lock = NSLock() + private var value = 0 + + @discardableResult + func increment() -> Int { + lock.lock() + defer { lock.unlock() } + value += 1 + return value + } + + var count: Int { + lock.lock() + defer { lock.unlock() } + return value + } +} + +private func xrpcURL( + _ serviceUrl: URL, + nsid: String, + queryItems: [URLQueryItem] +) -> URL { + var components = URLComponents(url: serviceUrl, resolvingAgainstBaseURL: false)! + components.path = "/xrpc/\(nsid)" + components.queryItems = queryItems + return components.url! +} + +@Suite struct GetRelationshipsParametersTests { + private static let maxOthers = Lexicon.App.Bsky.Graph.GetRelationships.Parameters.maxOthers + + @Test("Exactly maxOthers (30) is accepted - the lexicon's others.maxLength") + func exactlyMaxOthersIsAccepted() throws { + let others = (0.. BskyAppViewAgent { + try BskyAppViewAgent(serviceUrl: appViewURL, resourceFetcher: fetcher) + } + + @Test("relationshipLookup splits found vs. not-found subjects") + func splitsFoundAndNotFound() async throws { + let actor = Atproto.DID(method: .plc, identifier: "aaaaaaaaaaaaaaaaaaaaaaaa") + let foundDID = Atproto.DID(method: .plc, identifier: "bbbbbbbbbbbbbbbbbbbbbbbb") + let notFoundDID = Atproto.DID(method: .plc, identifier: "cccccccccccccccccccccccc") + let requestURL = xrpcURL( + appViewURL, nsid: "app.bsky.graph.getRelationships", + queryItems: [ + .init(name: "actor", value: actor.rawValue), + .init(name: "others", value: foundDID.rawValue), + .init(name: "others", value: notFoundDID.rawValue), + ]) + let counter = CallCounter() + let fetcher = StubHTTPFetcher { request in + counter.increment() + #expect(request.request.url == requestURL) + return .init( + data: Data( + """ + {"actor":"\(actor.rawValue)","relationships":[ + {"$type":"app.bsky.graph.defs#relationship","did":"\(foundDID.rawValue)"}, + {"$type":"app.bsky.graph.defs#notFoundActor","actor":"\(notFoundDID.rawValue)","notFound":true} + ]} + """.utf8), + response: .init(status: .ok)) + } + + let result = try await agent(fetcher: fetcher).relationshipLookup( + actor: actor, others: [foundDID, notFoundDID]) + + #expect(result.found[foundDID]?.did == foundDID) + #expect(result.notFound == [notFoundDID]) + #expect(counter.count == 1) + } + + @Test( + "An unrequested subject is dropped, a handle-form not-found and an omitted subject both land in notFound" + ) + func dropsUnrequestedAndReportsHandleFormAndOmitted() async throws { + let actor = Atproto.DID(method: .plc, identifier: "aaaaaaaaaaaaaaaaaaaaaaaa") + let a = Atproto.DID(method: .plc, identifier: "bbbbbbbbbbbbbbbbbbbbbbbb") + let b = Atproto.DID(method: .plc, identifier: "cccccccccccccccccccccccc") + let c = Atproto.DID(method: .plc, identifier: "dddddddddddddddddddddddd") + let requestURL = xrpcURL( + appViewURL, nsid: "app.bsky.graph.getRelationships", + queryItems: [ + .init(name: "actor", value: actor.rawValue), + .init(name: "others", value: a.rawValue), + .init(name: "others", value: b.rawValue), + .init(name: "others", value: c.rawValue), + ]) + // b reported not-found by handle; c omitted entirely; an unrequested + // DID is returned alongside the legitimately-found a. + let fetcher = StubHTTPFetcher { request in + #expect(request.request.url == requestURL) + return .init( + data: Data( + """ + {"actor":"\(actor.rawValue)","relationships":[ + {"$type":"app.bsky.graph.defs#relationship","did":"\(a.rawValue)"}, + {"$type":"app.bsky.graph.defs#notFoundActor","actor":"bob.example.com","notFound":true}, + {"$type":"app.bsky.graph.defs#relationship","did":"did:plc:zzzzzzzzzzzzzzzzzzzzzzzz"} + ]} + """.utf8), + response: .init(status: .ok)) + } + + let result = try await agent(fetcher: fetcher) + .relationshipLookup(actor: actor, others: [a, b, c]) + + #expect(Set(result.found.keys) == [a]) + #expect(Set(result.notFound) == [b, c]) + } + + @Test("Duplicate subjects are sent once and reported once, whether found or not") + func dedupesSubjects() async throws { + let actor = Atproto.DID(method: .plc, identifier: "aaaaaaaaaaaaaaaaaaaaaaaa") + let foundDID = Atproto.DID(method: .plc, identifier: "bbbbbbbbbbbbbbbbbbbbbbbb") + let notFoundDID = Atproto.DID(method: .plc, identifier: "cccccccccccccccccccccccc") + let requestURL = xrpcURL( + appViewURL, nsid: "app.bsky.graph.getRelationships", + queryItems: [ + .init(name: "actor", value: actor.rawValue), + .init(name: "others", value: foundDID.rawValue), + .init(name: "others", value: notFoundDID.rawValue), + ]) + let counter = CallCounter() + let fetcher = StubHTTPFetcher { request in + counter.increment() + // Exactly one `others` query item per subject - a request built + // from undeduped input would carry each of them repeatedly and + // fail to match. + #expect(request.request.url == requestURL) + // notFoundDID is omitted entirely - it's requested three times + // below but must be reported as not-found exactly once, not once + // per duplicate. + return .init( + data: Data( + """ + {"actor":"\(actor.rawValue)","relationships":[ + {"$type":"app.bsky.graph.defs#relationship","did":"\(foundDID.rawValue)"} + ]} + """.utf8), + response: .init(status: .ok)) + } + + let result = try await agent(fetcher: fetcher) + .relationshipLookup( + actor: actor, + others: [ + foundDID, foundDID, notFoundDID, notFoundDID, notFoundDID, + ]) + + #expect(counter.count == 1) + #expect(result.found.count == 1) + #expect(result.notFound == [notFoundDID]) + } + + @Test("A mismatched actor in the response throws actorMismatch") + func mismatchedActorThrows() async throws { + let actor = Atproto.DID(method: .plc, identifier: "aaaaaaaaaaaaaaaaaaaaaaaa") + let wrongActor = Atproto.DID(method: .plc, identifier: "ffffffffffffffffffffffff") + let other = Atproto.DID(method: .plc, identifier: "bbbbbbbbbbbbbbbbbbbbbbbb") + let fetcher = StubHTTPFetcher( + .init( + data: Data( + "{\"actor\":\"\(wrongActor.rawValue)\",\"relationships\":[]}" + .utf8), + response: .init(status: .ok))) + + await #expect( + throws: Lexicon.App.Bsky.Graph.GetRelationships.Errors.actorMismatch( + requested: actor, returned: wrongActor) + ) { + try await agent(fetcher: fetcher) + .relationshipLookup(actor: actor, others: [other]) + } + } + + @Test("A mismatched actor on a later chunk (not just the first) throws actorMismatch") + func mismatchedActorOnLaterChunkThrows() async throws { + let actor = Atproto.DID(method: .plc, identifier: "aaaaaaaaaaaaaaaaaaaaaaaa") + let wrongActor = Atproto.DID(method: .plc, identifier: "ffffffffffffffffffffffff") + // 31 subjects -> 2 chunks; the first chunk's response names the + // correct actor, so a check that only looks at the first chunk would + // miss the mismatch on the second. + let subjects = (0..<31).map { + Atproto.DID(method: .plc, identifier: "subject\($0)") + } + let counter = CallCounter() + let fetcher = StubHTTPFetcher { request in + let requestNumber = counter.increment() + let respondingActor = requestNumber == 1 ? actor : wrongActor + return .init( + data: Data( + "{\"actor\":\"\(respondingActor.rawValue)\",\"relationships\":[]}" + .utf8), + response: .init(status: .ok)) + } + + await #expect( + throws: Lexicon.App.Bsky.Graph.GetRelationships.Errors.actorMismatch( + requested: actor, returned: wrongActor) + ) { + try await agent(fetcher: fetcher) + .relationshipLookup(actor: actor, others: subjects) + } + #expect(counter.count == 2) + } + + @Test("Empty others returns an empty result without any request") + func emptyOthersMakesNoRequest() async throws { + let actor = Atproto.DID(method: .plc, identifier: "aaaaaaaaaaaaaaaaaaaaaaaa") + let counter = CallCounter() + let fetcher = StubHTTPFetcher { request in + counter.increment() + Issue.record("expected no request for empty others") + return .init(data: Data(), response: .init(status: .ok)) + } + + let result = try await agent(fetcher: fetcher) + .relationshipLookup(actor: actor, others: []) + + #expect(result.found.isEmpty) + #expect(result.notFound.isEmpty) + #expect(counter.count == 0) + } + + /// Thread-safe collector for each request's `others` DIDs, in call order. + private final class ChunkCollector: @unchecked Sendable { + private let lock = NSLock() + private var chunks: [[String]] = [] + + func append(_ chunk: [String]) { + lock.lock() + defer { lock.unlock() } + chunks.append(chunk) + } + + var all: [[String]] { + lock.lock() + defer { lock.unlock() } + return chunks + } + } + + @Test( + "others are chunked at maxOthers (30 -> 1 request, 31 -> 2, 60 -> 2, 61 -> 3), each request carries at most 30, and the chunks concatenate back to the original order", + arguments: [(30, 1), (31, 2), (60, 2), (61, 3)] + ) + func chunksAtMaxOthers(_ subjectsAndRequests: (subjects: Int, requests: Int)) async throws { + let actor = Atproto.DID(method: .plc, identifier: "aaaaaaaaaaaaaaaaaaaaaaaa") + let subjects = (0..