Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/great-moths-travel.md
Original file line number Diff line number Diff line change
@@ -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`.
114 changes: 114 additions & 0 deletions Sources/AtprotoTypes/Atproto/IPLiteral.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
56 changes: 26 additions & 30 deletions Sources/AtprotoTypes/Atproto/ServiceEndpoint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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 }
Expand All @@ -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] {
Expand All @@ -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
Expand All @@ -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) })
}
}
116 changes: 116 additions & 0 deletions Tests/AtprotoTypesTests/IPLiteralTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 2 additions & 0 deletions Tests/AtprotoTypesTests/PDSEndpointTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading