diff --git a/.changeset/great-moths-travel.md b/.changeset/great-moths-travel.md new file mode 100644 index 0000000..9cbb01e --- /dev/null +++ b/.changeset/great-moths-travel.md @@ -0,0 +1,9 @@ +--- +"@germ-network/atprototypes": patch +--- + +Screen PDS endpoint hosts without Network.framework, so the package builds on +Linux and Android. Readings are unchanged on Apple platforms: dotted quads go +through a reimplementation of `IPv4Address`'s grammar pinned by tests, every +other v4 shape defers to the platform's `inet_aton` exactly as `IPv4Address` +did, and IPv6 moves to `inet_pton`. diff --git a/Sources/AtprotoTypes/Atproto/IPLiteral.swift b/Sources/AtprotoTypes/Atproto/IPLiteral.swift new file mode 100644 index 0000000..2c2f5a3 --- /dev/null +++ b/Sources/AtprotoTypes/Atproto/IPLiteral.swift @@ -0,0 +1,114 @@ +// +// IPLiteral.swift +// AtprotoTypes +// +// Created by Mark @ Germ on 8/17/26. +// + +#if canImport(Darwin) + import Darwin +#elseif canImport(Android) + import Android +#elseif canImport(Bionic) + import Bionic +#elseif canImport(Musl) + import Musl +#elseif canImport(Glibc) + import Glibc +#endif + +/// The address readings ``Atproto/DIDDocument/Service/validate(endpoint:policy:)`` +/// screens a host against. Network.framework is Apple-only, so these reproduce +/// what its `IPv4Address` / `IPv6Address` returned, on every platform the +/// package builds for. +/// +/// Two IPv4 readings, deliberately: one string can name different addresses +/// depending on who parses it, and the caller rejects on any disagreement. +package enum IPLiteral { + /// The permissive grammar `IPv4Address` accepts. In a dotted quad each part + /// names one byte, `0x` honored as hex and a leading zero read as + /// **decimal** (`0177.0.0.1` is 177.0.0.1). Every other shape defers to + /// ``legacyV4(_:)``, because that is what `IPv4Address` does: `16843009` is + /// 1.1.1.1, `127.1` is 127.0.0.1, and `010` is 0.0.0.8 — octal, so a + /// dotless spelling with an 8 or 9 after a leading zero has no reading and + /// falls to the single-label rule. + /// + /// The one probed divergence from `IPv4Address` is a quad part of `0x` with + /// no digits (`1.0x.2.3`), which read as zero there and has no reading + /// here. Safe: a host with no reading loses the literal exemption and is + /// screened as a name, and where the platform's `inet_aton` reads the + /// spelling, ``legacyV4(_:)`` still screens that reading. + package static func v4(_ host: String) -> [UInt8]? { + let parts = host.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count == 4 else { + return legacyV4(host) + } + + let values = parts.compactMap(partValue) + guard values.count == 4, values.allSatisfy({ $0 <= 0xff }) else { + return nil + } + + return values.map { UInt8($0) } + } + + /// `inet_aton`'s reading of the same string, where a leading zero is octal + /// (`0177.0.0.1` is 127.0.0.1 here and 177.0.0.1 to ``v4(_:)``). Left to the + /// platform's own libc rather than reimplemented, since this is the reading + /// `getaddrinfo` will give the connection that follows. + package static func legacyV4(_ host: String) -> [UInt8]? { + var address = in_addr() + guard host.withCString({ inet_aton($0, &address) }) == 1 else { + return nil + } + return withUnsafeBytes(of: address.s_addr) { [UInt8]($0) } + } + + /// The 16 address bytes, ignoring any `%zone` suffix as `IPv6Address` does. + package static func v6(_ host: String) -> [UInt8]? { + let address = host.prefix { $0 != "%" } + var bytes = [UInt8](repeating: 0, count: 16) + guard String(address).withCString({ inet_pton(AF_INET6, $0, &bytes) }) == 1 + else { + return nil + } + return bytes + } + + /// The IPv4 address embedded in a v4-mapped (`::ffff:a.b.c.d`) or the + /// deprecated v4-compatible (`::a.b.c.d`) form, matching + /// `IPv6Address.asIPv4`. `::` and `::1` are the unspecified and loopback + /// v6 addresses, not v4 spellings, so they read as nil and the caller + /// screens them on the v6 path. + package static func embeddedV4(in bytes: [UInt8]) -> [UInt8]? { + guard bytes.count == 16, bytes.prefix(10).allSatisfy({ $0 == 0 }) else { + return nil + } + + let tail = Array(bytes.suffix(4)) + switch (bytes[10], bytes[11]) { + case (0xff, 0xff): return tail + case (0, 0): + return tail.dropLast().allSatisfy({ $0 == 0 }) && tail[3] <= 1 + ? nil : tail + default: return nil + } + } + + private static func partValue(_ part: Substring) -> UInt32? { + guard !part.isEmpty else { return nil } + + guard part.hasPrefix("0x") || part.hasPrefix("0X") else { + //UInt32(_:radix:) would otherwise accept a leading + or - + guard part.allSatisfy({ $0.isASCII && $0.isNumber }) else { return nil } + return UInt32(part, radix: 10) + } + + let digits = part.dropFirst(2) + guard !digits.isEmpty, digits.allSatisfy({ $0.isASCII && $0.isHexDigit }) + else { + return nil + } + return UInt32(digits, radix: 16) + } +} diff --git a/Sources/AtprotoTypes/Atproto/ServiceEndpoint.swift b/Sources/AtprotoTypes/Atproto/ServiceEndpoint.swift index 92cad0f..8807502 100644 --- a/Sources/AtprotoTypes/Atproto/ServiceEndpoint.swift +++ b/Sources/AtprotoTypes/Atproto/ServiceEndpoint.swift @@ -5,9 +5,7 @@ // Created by Mark @ Germ on 7/25/26. // -import Darwin import Foundation -import Network extension Atproto.DIDDocument { /// What ``Service/validate(endpoint:policy:)`` will accept. Strict unless @@ -73,18 +71,17 @@ extension Atproto.DIDDocument.Service { //same discipline as `permitted(host:)`: every parser that can read this //has to agree, or we don't know where the connection actually lands - let v4Readings = [IPv4Address(host), inetAtonAddress(host)].compactMap { $0 } + let v4Readings = [IPLiteral.v4(host), IPLiteral.legacyV4(host)] + .compactMap { $0 } if !v4Readings.isEmpty { - return v4Readings.allSatisfy { [UInt8]($0.rawValue).first == 127 } + return v4Readings.allSatisfy { $0.first == 127 } } - guard let v6 = IPv6Address(host) else { return false } - if let mapped = v6.asIPv4 { - return [UInt8](mapped.rawValue).first == 127 + guard let v6 = IPLiteral.v6(host) else { return false } + if let mapped = IPLiteral.embeddedV4(in: v6) { + return mapped.first == 127 } - let bytes = [UInt8](v6.rawValue) - return bytes.count == 16 && bytes.dropLast().allSatisfy { $0 == 0 } - && bytes[15] == 1 + return v6.dropLast().allSatisfy { $0 == 0 } && v6[15] == 1 } private static func normalized(_ rawHost: String) -> String { @@ -105,16 +102,25 @@ extension Atproto.DIDDocument.Service { guard !host.isEmpty else { return false } + let v4 = IPLiteral.v4(host) + let v6 = IPLiteral.v6(host) + //One string can name different addresses depending on who parses it: - //`0177.0.0.1` is 177.0.0.1 to IPv4Address and 127.0.0.1 to inet_aton, + //`0177.0.0.1` is 177.0.0.1 to IPLiteral.v4 and 127.0.0.1 to inet_aton, //and `010.0.0.1` splits the other way. We don't control which parser the //connection ultimately uses, so every reading has to be acceptable. - if let v6 = IPv6Address(host), !permitted(v6) { return false } - if let v4 = IPv4Address(host), !permitted(v4) { return false } - if let legacy = inetAtonAddress(host), !permitted(legacy) { return false } + if let v6, !permitted(v6: v6) { return false } + if let v4, !permitted(v4: v4) { return false } + if let legacy = IPLiteral.legacyV4(host), !permitted(v4: legacy) { + return false + } - //an address literal that survived every parser's screening - if IPv4Address(host) != nil || IPv6Address(host) != nil { return true } + //An address literal that survived every parser's screening. Deliberately + //v4/v6 only: a spelling only inet_aton reads earns no exemption — its + //blocked readings were rejected above, and the rest fall through to the + //name rules, which is how the resolver will treat the string wherever + //its own inet_aton agrees it is not an address. + if v4 != nil || v6 != nil { return true } //single-label names resolve through local search domains, never a public PDS guard let lastDot = host.lastIndex(of: ".") else { return false } @@ -128,8 +134,7 @@ extension Atproto.DIDDocument.Service { "localhost", "local", "internal", "test", "invalid", "example", ] - private static func permitted(_ address: IPv4Address) -> Bool { - let bytes = [UInt8](address.rawValue) + private static func permitted(v4 bytes: [UInt8]) -> Bool { guard bytes.count == 4 else { return false } switch bytes[0] { @@ -145,13 +150,12 @@ extension Atproto.DIDDocument.Service { } } - private static func permitted(_ address: IPv6Address) -> Bool { + private static func permitted(v6 bytes: [UInt8]) -> Bool { //::ffff:127.0.0.1 and friends are the v4 ranges wearing a v6 hat - if let mapped = address.asIPv4 { - return permitted(mapped) + if let mapped = IPLiteral.embeddedV4(in: bytes) { + return permitted(v4: mapped) } - let bytes = [UInt8](address.rawValue) guard bytes.count == 16 else { return false } //:: unspecified and ::1 loopback @@ -166,12 +170,4 @@ extension Atproto.DIDDocument.Service { return true } - ///the legacy decimal/octal/hex forms `getaddrinfo` still honors - private static func inetAtonAddress(_ host: String) -> IPv4Address? { - var address = in_addr() - guard host.withCString({ inet_aton($0, &address) }) == 1 else { - return nil - } - return IPv4Address(withUnsafeBytes(of: address.s_addr) { Data($0) }) - } } diff --git a/Tests/AtprotoTypesTests/IPLiteralTests.swift b/Tests/AtprotoTypesTests/IPLiteralTests.swift new file mode 100644 index 0000000..67959c4 --- /dev/null +++ b/Tests/AtprotoTypesTests/IPLiteralTests.swift @@ -0,0 +1,116 @@ +// +// IPLiteralTests.swift +// AtprotoTypes +// +// Created by Mark @ Germ on 8/17/26. +// + +import AtprotoTypes +import Testing + +//`IPLiteral.v4` stands in for Network.framework's `IPv4Address`, which the +//package can no longer import on Linux or Android. These readings were taken +//from `IPv4Address` on macOS, so a drift in the reimplementation shows up here +//rather than as a hole in the endpoint screening. +struct IPLiteralTests { + @Test( + arguments: [ + ("127.0.0.1", [127, 0, 0, 1]), + ("1.1.1.1", [1, 1, 1, 1]), + ("0.0.0.0", [0, 0, 0, 0]), + ("255.255.255.255", [255, 255, 255, 255]), + //in a quad, a leading zero is decimal here and octal to inet_aton + ("0177.0.0.1", [177, 0, 0, 1]), + ("010.0.0.1", [10, 0, 0, 1]), + ("01.02.03.04", [1, 2, 3, 4]), + ("00000000000000000177.0.0.1", [177, 0, 0, 1]), + //anything shorter defers to inet_aton: last part widens, and a + //leading zero is octal + ("16843009", [1, 1, 1, 1]), + ("2130706433", [127, 0, 0, 1]), + ("1", [0, 0, 0, 1]), + ("0.1", [0, 0, 0, 1]), + ("1.2", [1, 0, 0, 2]), + ("127.1", [127, 0, 0, 1]), + ("1.2.3", [1, 2, 0, 3]), + ("010", [0, 0, 0, 8]), + ("0177", [0, 0, 0, 127]), + ("010.1", [8, 0, 0, 1]), + ("017700000001", [127, 0, 0, 1]), + //0x is hex in any position + ("0x7f000001", [127, 0, 0, 1]), + ("0xffffffff", [255, 255, 255, 255]), + ("0x7f.0.0.1", [127, 0, 0, 1]), + ("1.0xff", [1, 0, 0, 255]), + ] as [(String, [UInt8])] + ) + func v4ReadsTheSpellingsIPv4AddressAccepted( + _ host: String, + _ expected: [UInt8] + ) { + #expect(IPLiteral.v4(host) == expected) + } + + @Test( + arguments: [ + "", "localhost", "pds", "::1", + //a trailing dot is a name in FQDN form + "2130706433.", "192.168.1.1.", + //empty parts, too many parts, a part that overflows its width + ".1.2.3", "1..2", "1.2.3.4.5", "300.1.2.3", "1.2.3.0x100", + //UInt32(_:radix:) would take these; inet_aton and IPv4Address don't + "+1.2.3.4", "-1", "0x", + //octal context (leading zero) with a digit past 7: no parser reads + //these, so dotless ones stay subject to the single-label rule + "018015111", "08", "09", + ] + ) + func v4RejectsWhatIsNotAnAddress(_ host: String) { + #expect(IPLiteral.v4(host) == nil) + } + + //the two IPv4 readings disagreeing is the whole point of consulting both + @Test( + arguments: [ + ("0177.0.0.1", [177, 0, 0, 1], [127, 0, 0, 1]), + ("010.0.0.1", [10, 0, 0, 1], [8, 0, 0, 1]), + ("0100.0.0.1", [100, 0, 0, 1], [64, 0, 0, 1]), + ] as [(String, [UInt8], [UInt8])] + ) + func leadingZeroSplitsTheReadings( + _ host: String, + _ decimal: [UInt8], + _ octal: [UInt8] + ) { + #expect(IPLiteral.v4(host) == decimal) + #expect(IPLiteral.legacyV4(host) == octal) + } + + @Test func v6ParsesAndIgnoresTheZoneId() { + #expect(IPLiteral.v6("::1") == [UInt8](repeating: 0, count: 15) + [1]) + #expect(IPLiteral.v6("fe80::1%en0") == IPLiteral.v6("fe80::1")) + #expect(IPLiteral.v6("2606:4700:4700::1111")?.first == 0x26) + #expect(IPLiteral.v6("localhost") == nil) + #expect(IPLiteral.v6("127.0.0.1") == nil) + } + + //`::` and `::1` are v6 addresses in their own right, not v4 spellings, and + //the caller's v6 path is what rejects them + @Test( + arguments: [ + ("::ffff:127.0.0.1", [127, 0, 0, 1] as [UInt8]?), + ("::ffff:10.0.0.5", [10, 0, 0, 5]), + ("::127.0.0.1", [127, 0, 0, 1]), + ("::1.1.1.1", [1, 1, 1, 1]), + ("::1", nil), + ("::", nil), + ("fd00::1", nil), + ("2606:4700:4700::1111", nil), + ] as [(String, [UInt8]?)] + ) + func embeddedV4MatchesAsIPv4(_ host: String, _ expected: [UInt8]?) throws { + let bytes = try #require(IPLiteral.v6(host)) + + #expect(IPLiteral.embeddedV4(in: bytes) == expected) + } +} diff --git a/Tests/AtprotoTypesTests/PDSEndpointTests.swift b/Tests/AtprotoTypesTests/PDSEndpointTests.swift index 9407ecd..4a43702 100644 --- a/Tests/AtprotoTypesTests/PDSEndpointTests.swift +++ b/Tests/AtprotoTypesTests/PDSEndpointTests.swift @@ -105,6 +105,8 @@ struct PDSEndpointTests { //single-label hosts resolve via local search domains "https://pds", "https://intranet", + //octal-invalid, so no address parser vouches for it either + "https://018015111", //special-use TLDs (RFC 6761/6762, ICANN .internal) "https://foo.local", "https://foo.internal",