Requirements for IP address and port APIs

Small updates about swift-endpoint.

  • The library now stores big-endian bytes in the underlying storage of IPv[4/6]Address types.
    • The storages are still just integer types, but we act like they are each a contiguous sequence of bytes in big-endian.
    • There was pretty much no performance impact after adapting the code. Perhaps a minor improvement even.
  • Since the underlying value is now in big-endian, and thanks to some underscored attributes, we now have var bytes: Span<UInt8> accessors for the IPv[4/6]Address types.
    • Required a few experimental features enabled:
      • Lifetimes for declaring lifetime dependency of the Span<UInt8>.
      • AddressableTypes for the @_addressableForDependencies attribute which is assigned to each IP type.
      • BuiltinModule since we need to use Builtin.addressOfBorrow (and Builtin.unprotectedAddressOfBorrow).
      • The code is similar to InlineArray.span.

These are some of the new IPv[4/6]Address APIs:

  • bytes: Span<UInt8>
  • _unprotectedBytes: Span<UInt8>
  • asUInt[32/128]() (little-endian)
  • asUInt[32/128](byteOrder: .native/.bigEndian/.littleEndian)

As I planned and mentioned in the meetings, the underlying storage of the IP types are no longer publicly accessible. However you can still practically just read the raw storage via asUInt[32/128](byteOrder: .bigEndian).

The code:

import func Builtin.addressOfBorrow
import func Builtin.unprotectedAddressOfBorrow

@_addressableForDependencies
public struct IPv4Address: Sendable, Hashable {
    public static var size: Int { 4 }

    /// The underlying 4 bytes representing this IPv4 address, in big-endian byte order.
    var _storage: UInt32

    public init(_ address: UInt32, byteOrder: ByteOrder = .native) {
        self._storage = byteOrder == .bigEndian ? address : address.byteSwapped
    }
}

extension IPv4Address {
    /// The underlying 32 bits (4 bytes) representing this IPv4 address, as a `UInt32`.
    /// For example `IPv4Address("127.0.0.1")!.asUInt32()` is `0x7F00_0001`.
    /// Or `IPv4Address("127.0.0.1")!.asUInt32(byteOrder: .bigEndian)` is `0x0100_007F`.
    public func asUInt32(byteOrder: ByteOrder = .native) -> UInt32 {
        byteOrder == .bigEndian ? self._storage : self._storage.byteSwapped
    }

    /// A view of exactly 4 (IPv4Address.size) bytes representing this IPv4 address, in network byte order.
    /// For example `IPv4Address("127.0.0.1")!.bytes` contains `[127, 0, 0, 1]`.
    public var bytes: Span<UInt8> {
        @_lifetime(borrow self)
        get {
            let pointer = UnsafeRawPointer(Builtin.addressOfBorrow(self))
            let span = unsafe Span<UInt8>(_unsafeStart: pointer, byteCount: Self.size)
            return unsafe _overrideLifetime(span, borrowing: self)
        }
    }

    /// A stack-unprotected view of exactly 4 (IPv4Address.size) bytes representing this IPv4 address, in network byte order.
    /// For example `IPv4Address("127.0.0.1")!._unprotectedBytes` contains `[127, 0, 0, 1]`.
    ///
    /// This unprotected accessor means there is no stack canary to protect the stack frame where this
    /// accessor will land. An out-of-bounds write from any other code (e.g. when using unsafe-pointers)
    /// in that same function will go undetected instead of trapping on the stack canary.
    /// Therefore prefer `bytes` unless you trust the calling frame.
    public var _unprotectedBytes: Span<UInt8> {
        @_lifetime(borrow self)
        get {
            let pointer = UnsafeRawPointer(Builtin.unprotectedAddressOfBorrow(self))
            let span = unsafe Span<UInt8>(_unsafeStart: pointer, byteCount: Self.size)
            return unsafe _overrideLifetime(span, borrowing: self)
        }
    }
}

What is IPv[4/6]Address._unprotectedBytes for?
  • This is similar to IPv[4/6]Address.bytes with only difference being usage of unprotectedAddressOfBorrow instead of addressOfBorrow.
  • This is to ensure the compiler doesn't emit a stack canary to possibly stop memory corruptions.
  • This can be useful in certain contexts for example when interoperating with C, but also costs 15+ instructions when all you would otherwise need might be like 2.
  • It'll insert a "stack canary", meaning 8 bytes, after the address of the bytes of the ip address. If those addresses are modified at the end of the stack frame, then something must have gone wrong, and it'll trap on that.
  • To be clear this is not a rare behavior related to Spans or such. e.g. withUnsafeBytes(of:) also comes with this stack canary.

The compiler appears to be weak around eliminating the stack canary when it's not actually needed at all, so I've put up this PR to hopefully fix some of those:


What is @_addressableForDependencies?

This is an underscored attribute that is not even documented in swiftlang/swift/docs/ReferenceGuides/UnderscoredAttributes.md.
Not until this PR is merged at least: Add `@_addressableForDependencies` to "Underscored Attributes Reference" by MahdiBM · Pull Request #92211 · swiftlang/swift · GitHub

  • It ensures the ip-value is addressable in the stack-frame of the caller of bytes, by passing it to bytes as an address instead of as an address-less value in a register, so bytes can point at that storage that is guaranteed to outlive bytes getter's stack-frame.
  • This effect requires both @_lifetime(borrow self) on bytes and @_addressableForDependencies on the type.
  • This way the Span's underlying pointer is guaranteed to remain valid for the caller to read through.
2 Likes