From f23666456176ff2ddc7a50dad36a9a78b1398948 Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Wed, 30 Sep 2026 23:39:08 -0700 Subject: [PATCH 1/2] Require an explicit fetcher on the four URL-based resolvers Remove the URLSession.manualRedirect() defaults from DidWebResolver, DidPlcResolver, WellKnownHandleResolver and DnsHandleResolver, drop the unused FoundationNetworking imports, and require GermConvenience 0.13.0. The Android CI leg now scans the portable targets' objects for a FoundationNetworking autolink entry, with a positive control. Co-Authored-By: Claude Sonnet 5.5 --- .changeset/required-resolver-fetchers.md | 7 +++ .github/workflows/ci-android.yml | 18 +++++++ Package.resolved | 6 +-- Package.swift | 8 ++-- Scripts/scan-foundation-networking.sh | 48 +++++++++++++++++++ .../AtprotoClient/DNS/DnsHandleResolver.swift | 16 ++----- .../DNS/WellKnownHandleResolver.swift | 12 ++--- Sources/AtprotoClient/DidPlcResolver.swift | 12 ++--- Sources/AtprotoClient/DidWebResolver.swift | 14 ++---- Tests/AtprotoClientTests/OnlineTests.swift | 1 + 10 files changed, 100 insertions(+), 42 deletions(-) create mode 100644 .changeset/required-resolver-fetchers.md create mode 100755 Scripts/scan-foundation-networking.sh diff --git a/.changeset/required-resolver-fetchers.md b/.changeset/required-resolver-fetchers.md new file mode 100644 index 0000000..0f2bb47 --- /dev/null +++ b/.changeset/required-resolver-fetchers.md @@ -0,0 +1,7 @@ +--- +"@germ-network/atprotoclient": minor +--- + +Remove the `URLSession` defaults so non-Apple platforms can depend on the fetcher-based API without reaching `URLSession`. + +Breaking: `DidWebResolver.init(fetcher:)`, `DidPlcResolver.init(directory:fetcher:)`, `WellKnownHandleResolver.init(fetcher:)` and `DnsHandleResolver.init(txtFetcher:)` no longer default to a `URLSession.manualRedirect()`-backed fetcher; pass an explicit redirect-refusing `HTTPFetcher` (or `DNSTXTFetcher`). Apple callers can pass `URLSession.manualRedirect()` from `GermConvenienceURLSession` (GermConvenience 0.13.0, now required). Also drops the unused `FoundationNetworking` imports from the four resolver files. diff --git a/.github/workflows/ci-android.yml b/.github/workflows/ci-android.yml index 74bc155..dda583e 100644 --- a/.github/workflows/ci-android.yml +++ b/.github/workflows/ci-android.yml @@ -25,6 +25,24 @@ jobs: steps: - uses: actions/checkout@v6 - name: "Test Swift Package on Android" + id: android uses: skiptools/swift-android-action@v2 with: swift-version: '6.3' + # The portable products must not link FoundationNetworking. Built into a + # scratch directory of their own so the objects scanned are exactly their + # closure. `--target`, not `--product`: SwiftPM 6.3 rejects `--product` + # for a library product and silently builds every target instead. + - name: "Portable products do not link FoundationNetworking" + run: | + for target in AtprotoClient AtprotoClientMocks; do + ${{ steps.android.outputs.swift-build }} --scratch-path .build-portable --target "$target" + done + Scripts/scan-foundation-networking.sh .build-portable + # Control: the scanner must flag a target that does use URLSession (the + # test target), or a pass above could just mean it is not seeing the + # autolink entry. + - name: "Scanner detects FoundationNetworking in the test target" + run: | + ${{ steps.android.outputs.swift-build }} --scratch-path .build-control --target AtprotoClientTests + Scripts/scan-foundation-networking.sh --expect .build-control diff --git a/Package.resolved b/Package.resolved index 967b2a2..7842738 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "2fca09e3769f98028df31234a79b72b2b41680fae0c9aec00a17f52a76b203c7", + "originHash" : "d8e185491cc210c8876990e4829f342cc5b4592c9bad282ba4674879af5be226", "pins" : [ { "identity" : "atprototypes", @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/germ-network/GermConvenience.git", "state" : { - "revision" : "2d894a11b313954aaf772ae694b41744da6c2a06", - "version" : "0.11.0" + "revision" : "a5277580086ab49d56c0a07800ba5df6e039892e", + "version" : "0.13.0" } }, { diff --git a/Package.swift b/Package.swift index f49acc6..5bc719d 100644 --- a/Package.swift +++ b/Package.swift @@ -24,9 +24,10 @@ let package = Package( ), .package( url: "https://github.com/germ-network/GermConvenience.git", - // 0.11.0: manualRedirect() also refuses redirects on Linux/Android, which the - // DID/handle resolvers' one-time host screening depends on. - from: "0.11.0" + // 0.13.0: the URLSession conformers moved out of GermConvenienceHTTP into + // GermConvenienceURLSession, so the portable targets here no longer + // reach URLSession. + from: "0.13.0" ), .package( url: "https://github.com/apple/swift-crypto.git", @@ -64,6 +65,7 @@ let package = Package( dependencies: [ "AtprotoClient", "AtprotoClientMocks", .product(name: "GermConvenienceHTTP", package: "GermConvenience"), + .product(name: "GermConvenienceURLSession", package: "GermConvenience"), ] ), ] diff --git a/Scripts/scan-foundation-networking.sh b/Scripts/scan-foundation-networking.sh new file mode 100755 index 0000000..5590d01 --- /dev/null +++ b/Scripts/scan-foundation-networking.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Scans the object files of a SwiftPM scratch directory for the +# `-lFoundationNetworking` autolink entry Swift records for any module that +# imports FoundationNetworking, and exits 1 if one is found (or 0 if --expect +# is given and one is). A module that carries the entry puts +# libFoundationNetworking.so in the NEEDED list of whatever links it. +# +# usage: scan-foundation-networking.sh [--expect] +set -euo pipefail + +expect=false +if [[ "${1:-}" == "--expect" ]]; then + expect=true + shift +fi +scratch="${1:?usage: $0 [--expect] }" + +objects=() +while IFS= read -r -d '' object; do + objects+=("$object") +done < <(find "$scratch" -name '*.o' -path '*android*' -print0) + +if [[ ${#objects[@]} -eq 0 ]]; then + echo "error: no Android object files under $scratch; nothing was scanned" >&2 + exit 2 +fi + +hits=() +for object in "${objects[@]}"; do + if strings "$object" | grep -- '-lFoundationNetworking' >/dev/null; then + hits+=("$object") + fi +done + +if $expect; then + if [[ ${#hits[@]} -eq 0 ]]; then + echo "error: expected a FoundationNetworking autolink entry but found none (scanner is not detecting it)" >&2 + exit 1 + fi + echo "ok: scanner detects FoundationNetworking in ${#hits[@]} of ${#objects[@]} objects" +else + if [[ ${#hits[@]} -gt 0 ]]; then + echo "error: FoundationNetworking is linked by:" >&2 + printf ' %s\n' "${hits[@]}" >&2 + exit 1 + fi + echo "ok: none of ${#objects[@]} objects link FoundationNetworking" +fi diff --git a/Sources/AtprotoClient/DNS/DnsHandleResolver.swift b/Sources/AtprotoClient/DNS/DnsHandleResolver.swift index e62d4e9..673f10b 100644 --- a/Sources/AtprotoClient/DNS/DnsHandleResolver.swift +++ b/Sources/AtprotoClient/DNS/DnsHandleResolver.swift @@ -8,10 +8,6 @@ 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. @@ -24,13 +20,11 @@ extension Atproto { 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()) - ) { + /// `DoHTXTFetcher`, over a redirect-refusing `HTTPFetcher`, is the + /// conformer this package ships - portable everywhere, unlike a + /// platform system resolver. Pass a platform-specific `DNSTXTFetcher` + /// to use one instead. + public init(txtFetcher: any DNSTXTFetcher) { self.txtFetcher = txtFetcher } diff --git a/Sources/AtprotoClient/DNS/WellKnownHandleResolver.swift b/Sources/AtprotoClient/DNS/WellKnownHandleResolver.swift index 9821869..b582993 100644 --- a/Sources/AtprotoClient/DNS/WellKnownHandleResolver.swift +++ b/Sources/AtprotoClient/DNS/WellKnownHandleResolver.swift @@ -9,10 +9,6 @@ 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 @@ -35,10 +31,10 @@ extension Atproto { 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()) { + /// `fetcher` must refuse redirects, for the same reason `DidWebResolver` + /// does - the handle is already validated, but a followed redirect would + /// fetch a second, unvalidated host. + public init(fetcher: any HTTPFetcher) { self.fetcher = fetcher } diff --git a/Sources/AtprotoClient/DidPlcResolver.swift b/Sources/AtprotoClient/DidPlcResolver.swift index 57f8839..54c07e5 100644 --- a/Sources/AtprotoClient/DidPlcResolver.swift +++ b/Sources/AtprotoClient/DidPlcResolver.swift @@ -9,10 +9,6 @@ import GermConvenience import GermConvenienceHTTP import HTTPTypes -#if canImport(FoundationNetworking) - import FoundationNetworking -#endif - extension Atproto { /// Resolves a did:plc identifier by fetching `https://plc.directory/`. /// @@ -29,12 +25,12 @@ extension Atproto { /// fragment, or userinfo. Anything else (a trailing slash, most /// easily) silently breaks URL construction and turns every /// resolution into a `nil`, so it's rejected here instead. `fetcher` - /// defaults to a redirect-refusing session, for the same reason - /// `DidWebResolver` refuses one — a followed redirect would bypass - /// identifier validation, which only runs once, before the request. + /// must refuse redirects, for the same reason `DidWebResolver` does — a + /// followed redirect would bypass identifier validation, which only runs + /// once, before the request. public init( directory: URL = Self.defaultDirectory, - fetcher: any HTTPFetcher = URLSession.manualRedirect() + fetcher: any HTTPFetcher ) throws { guard let components = URLComponents( diff --git a/Sources/AtprotoClient/DidWebResolver.swift b/Sources/AtprotoClient/DidWebResolver.swift index d9e4737..79411b4 100644 --- a/Sources/AtprotoClient/DidWebResolver.swift +++ b/Sources/AtprotoClient/DidWebResolver.swift @@ -9,10 +9,6 @@ import GermConvenience import GermConvenienceHTTP import HTTPTypes -#if canImport(FoundationNetworking) - import FoundationNetworking -#endif - extension Atproto { /// Resolves a did:web identifier by fetching /// `https://{host}/.well-known/did.json`. @@ -28,11 +24,11 @@ extension Atproto { let fetcher: any HTTPFetcher - /// Defaults to a redirect-refusing session — do not override with a - /// redirect-following fetcher in production. Host screening runs once, - /// before the request; a followed redirect bypasses it, and nothing - /// downstream can detect that after the fact. - public init(fetcher: any HTTPFetcher = URLSession.manualRedirect()) { + /// `fetcher` must refuse redirects — do not pass a redirect-following + /// fetcher in production. Host screening runs once, before the request; + /// a followed redirect bypasses it, and nothing downstream can detect + /// that after the fact. + public init(fetcher: any HTTPFetcher) { self.fetcher = fetcher } diff --git a/Tests/AtprotoClientTests/OnlineTests.swift b/Tests/AtprotoClientTests/OnlineTests.swift index 5244946..15aa26f 100644 --- a/Tests/AtprotoClientTests/OnlineTests.swift +++ b/Tests/AtprotoClientTests/OnlineTests.swift @@ -8,6 +8,7 @@ import AtprotoClient import AtprotoTypes import Foundation +import GermConvenienceURLSession import Testing #if canImport(FoundationNetworking) From 86db5057edf302603e02a5ab2da2c63c205aabfe Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Thu, 1 Oct 2026 00:11:47 -0700 Subject: [PATCH 2/2] Require a RedirectRefusingHTTPFetcher on the host-screening resolvers DidWebResolver, DidPlcResolver and WellKnownHandleResolver now take an any RedirectRefusingHTTPFetcher (GermConvenience 0.14.0), so a redirect-following fetcher no longer compiles. StubHTTPFetcher conforms. Co-Authored-By: Claude Sonnet 5.5 --- .changeset/required-resolver-fetchers.md | 2 +- Package.resolved | 6 +++--- Package.swift | 9 +++++---- .../AtprotoClient/DNS/WellKnownHandleResolver.swift | 10 +++++----- Sources/AtprotoClient/DidPlcResolver.swift | 10 +++++----- Sources/AtprotoClient/DidWebResolver.swift | 11 +++++------ Sources/AtprotoClientMocks/StubHTTPFetcher.swift | 5 +++-- 7 files changed, 27 insertions(+), 26 deletions(-) diff --git a/.changeset/required-resolver-fetchers.md b/.changeset/required-resolver-fetchers.md index 0f2bb47..88ef2c1 100644 --- a/.changeset/required-resolver-fetchers.md +++ b/.changeset/required-resolver-fetchers.md @@ -4,4 +4,4 @@ Remove the `URLSession` defaults so non-Apple platforms can depend on the fetcher-based API without reaching `URLSession`. -Breaking: `DidWebResolver.init(fetcher:)`, `DidPlcResolver.init(directory:fetcher:)`, `WellKnownHandleResolver.init(fetcher:)` and `DnsHandleResolver.init(txtFetcher:)` no longer default to a `URLSession.manualRedirect()`-backed fetcher; pass an explicit redirect-refusing `HTTPFetcher` (or `DNSTXTFetcher`). Apple callers can pass `URLSession.manualRedirect()` from `GermConvenienceURLSession` (GermConvenience 0.13.0, now required). Also drops the unused `FoundationNetworking` imports from the four resolver files. +Breaking: `DidWebResolver.init(fetcher:)`, `DidPlcResolver.init(directory:fetcher:)` and `WellKnownHandleResolver.init(fetcher:)` no longer default to a `URLSession.manualRedirect()`-backed fetcher, and now take an `any RedirectRefusingHTTPFetcher` (from GermConvenience 0.14.0, now required), so a redirect-following fetcher no longer compiles. Pass `ManualRedirectFetcher()` from `GermConvenienceURLSession`, or any conformer of your own. `DnsHandleResolver.init(txtFetcher:)` no longer defaults either; pass a `DNSTXTFetcher`, e.g. `DoHTXTFetcher(fetcher:)`. `StubHTTPFetcher` now conforms to `RedirectRefusingHTTPFetcher`. Also drops the unused `FoundationNetworking` imports from the four resolver files. diff --git a/Package.resolved b/Package.resolved index 7842738..94fb874 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "d8e185491cc210c8876990e4829f342cc5b4592c9bad282ba4674879af5be226", + "originHash" : "f042904a973ccd6c2ddd356d9781437690d76280b2cc2ae56c6ba1e5dff0cf5b", "pins" : [ { "identity" : "atprototypes", @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/germ-network/GermConvenience.git", "state" : { - "revision" : "a5277580086ab49d56c0a07800ba5df6e039892e", - "version" : "0.13.0" + "revision" : "93816f6efe28ecf5dfb6141dfa3e770449777de2", + "version" : "0.14.0" } }, { diff --git a/Package.swift b/Package.swift index 5bc719d..664844e 100644 --- a/Package.swift +++ b/Package.swift @@ -24,10 +24,11 @@ let package = Package( ), .package( url: "https://github.com/germ-network/GermConvenience.git", - // 0.13.0: the URLSession conformers moved out of GermConvenienceHTTP into - // GermConvenienceURLSession, so the portable targets here no longer - // reach URLSession. - from: "0.13.0" + // 0.14.0: RedirectRefusingHTTPFetcher, required by the resolvers that + // screen a host once before fetching. 0.13.0 moved the URLSession + // conformers into GermConvenienceURLSession, so the portable targets + // here no longer reach URLSession. + from: "0.14.0" ), .package( url: "https://github.com/apple/swift-crypto.git", diff --git a/Sources/AtprotoClient/DNS/WellKnownHandleResolver.swift b/Sources/AtprotoClient/DNS/WellKnownHandleResolver.swift index b582993..a6729ee 100644 --- a/Sources/AtprotoClient/DNS/WellKnownHandleResolver.swift +++ b/Sources/AtprotoClient/DNS/WellKnownHandleResolver.swift @@ -29,12 +29,12 @@ extension Atproto { /// buffered beforehand. static let maxBodySize = 8192 - let fetcher: any HTTPFetcher + let fetcher: any RedirectRefusingHTTPFetcher - /// `fetcher` must refuse redirects, for the same reason `DidWebResolver` - /// does - the handle is already validated, but a followed redirect would - /// fetch a second, unvalidated host. - public init(fetcher: any HTTPFetcher) { + /// `fetcher` must refuse redirects, which the type requires, for the same + /// reason `DidWebResolver` does - the handle is already validated, but a + /// followed redirect would fetch a second, unvalidated host. + public init(fetcher: any RedirectRefusingHTTPFetcher) { self.fetcher = fetcher } diff --git a/Sources/AtprotoClient/DidPlcResolver.swift b/Sources/AtprotoClient/DidPlcResolver.swift index 54c07e5..47e7695 100644 --- a/Sources/AtprotoClient/DidPlcResolver.swift +++ b/Sources/AtprotoClient/DidPlcResolver.swift @@ -19,18 +19,18 @@ extension Atproto { static let acceptHeader = "application/did+ld+json,application/json" let directory: URL - let fetcher: any HTTPFetcher + let fetcher: any RedirectRefusingHTTPFetcher /// `directory` must be a bare `https` origin — no path, query, /// fragment, or userinfo. Anything else (a trailing slash, most /// easily) silently breaks URL construction and turns every /// resolution into a `nil`, so it's rejected here instead. `fetcher` - /// must refuse redirects, for the same reason `DidWebResolver` does — a - /// followed redirect would bypass identifier validation, which only runs - /// once, before the request. + /// must refuse redirects, which the type requires, for the same reason + /// `DidWebResolver` does — a followed redirect would bypass identifier + /// validation, which only runs once, before the request. public init( directory: URL = Self.defaultDirectory, - fetcher: any HTTPFetcher + fetcher: any RedirectRefusingHTTPFetcher ) throws { guard let components = URLComponents( diff --git a/Sources/AtprotoClient/DidWebResolver.swift b/Sources/AtprotoClient/DidWebResolver.swift index 79411b4..0d1dec0 100644 --- a/Sources/AtprotoClient/DidWebResolver.swift +++ b/Sources/AtprotoClient/DidWebResolver.swift @@ -22,13 +22,12 @@ extension Atproto { static let wellKnownPath = "/.well-known/did.json" static let acceptHeader = "application/did+ld+json,application/json" - let fetcher: any HTTPFetcher + let fetcher: any RedirectRefusingHTTPFetcher - /// `fetcher` must refuse redirects — do not pass a redirect-following - /// fetcher in production. Host screening runs once, before the request; - /// a followed redirect bypasses it, and nothing downstream can detect - /// that after the fact. - public init(fetcher: any HTTPFetcher) { + /// `fetcher` must refuse redirects, which the type requires. Host + /// screening runs once, before the request; a followed redirect bypasses + /// it, and nothing downstream can detect that after the fact. + public init(fetcher: any RedirectRefusingHTTPFetcher) { self.fetcher = fetcher } diff --git a/Sources/AtprotoClientMocks/StubHTTPFetcher.swift b/Sources/AtprotoClientMocks/StubHTTPFetcher.swift index 585bce5..33b3a32 100644 --- a/Sources/AtprotoClientMocks/StubHTTPFetcher.swift +++ b/Sources/AtprotoClientMocks/StubHTTPFetcher.swift @@ -7,8 +7,9 @@ import Foundation import GermConvenience import GermConvenienceHTTP -/// A canned-response `HTTPFetcher`, for anything built on the seam. -public struct StubHTTPFetcher: HTTPFetcher, Sendable { +/// A canned-response `HTTPFetcher`, for anything built on the seam. It only +/// returns what it's given, so it never follows a redirect. +public struct StubHTTPFetcher: RedirectRefusingHTTPFetcher, Sendable { private let handler: @Sendable (BundledHTTPRequest) throws -> HTTPDataResponse public init(