diff --git a/.changeset/required-resolver-fetchers.md b/.changeset/required-resolver-fetchers.md new file mode 100644 index 0000000..88ef2c1 --- /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:)` 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/.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..94fb874 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "2fca09e3769f98028df31234a79b72b2b41680fae0c9aec00a17f52a76b203c7", + "originHash" : "f042904a973ccd6c2ddd356d9781437690d76280b2cc2ae56c6ba1e5dff0cf5b", "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" : "93816f6efe28ecf5dfb6141dfa3e770449777de2", + "version" : "0.14.0" } }, { diff --git a/Package.swift b/Package.swift index f49acc6..664844e 100644 --- a/Package.swift +++ b/Package.swift @@ -24,9 +24,11 @@ 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.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", @@ -64,6 +66,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..a6729ee 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 @@ -33,12 +29,12 @@ extension Atproto { /// buffered beforehand. static let maxBodySize = 8192 - let fetcher: any HTTPFetcher + let fetcher: any RedirectRefusingHTTPFetcher - /// 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, 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 57f8839..47e7695 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/`. /// @@ -23,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` - /// 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, 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 = URLSession.manualRedirect() + fetcher: any RedirectRefusingHTTPFetcher ) throws { guard let components = URLComponents( diff --git a/Sources/AtprotoClient/DidWebResolver.swift b/Sources/AtprotoClient/DidWebResolver.swift index d9e4737..0d1dec0 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`. @@ -26,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 - /// 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, 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( 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)