diff --git a/Package.swift b/Package.swift index ceecd45e2..be0e8cb11 100644 --- a/Package.swift +++ b/Package.swift @@ -46,6 +46,11 @@ var package = Package( name: "BluetoothHCI", type: libraryType, targets: ["BluetoothHCI"] + ), + .library( + name: "BluetoothSDP", + type: libraryType, + targets: ["BluetoothSDP"] ) ], targets: [ @@ -83,6 +88,12 @@ var package = Package( "BluetoothGAP" ] ), + .target( + name: "BluetoothSDP", + dependencies: [ + "Bluetooth" + ] + ), .testTarget( name: "BluetoothTests", dependencies: [ @@ -102,6 +113,10 @@ var package = Package( .target( name: "BluetoothHCI", condition: .when(platforms: [.macOS, .linux, .macCatalyst, .windows, .android]) + ), + .target( + name: "BluetoothSDP", + condition: .when(platforms: [.macOS, .linux, .macCatalyst, .windows, .android]) ) ] ) @@ -126,7 +141,7 @@ if embeddedFoundation { name: "FoundationEmbedded", package: "swift-embedded-foundation" ) - for name in ["Bluetooth", "BluetoothGAP", "BluetoothGATT", "BluetoothHCI"] { + for name in ["Bluetooth", "BluetoothGAP", "BluetoothGATT", "BluetoothHCI", "BluetoothSDP"] { guard let index = package.targets.firstIndex(where: { $0.name == name }) else { fatalError("Missing target \(name)") } diff --git a/Sources/BluetoothSDP/SDPAttributeID.swift b/Sources/BluetoothSDP/SDPAttributeID.swift new file mode 100644 index 000000000..7d047b43e --- /dev/null +++ b/Sources/BluetoothSDP/SDPAttributeID.swift @@ -0,0 +1,100 @@ +// +// SDPAttributeID.swift +// Bluetooth +// +// The universal attribute IDs every SDP service record shares (SDP +// Protocol, Bluetooth Core Spec Vol 3, Part B, §5.1), plus the +// per-record language-base offset scheme the string attributes +// (name/description/provider) are defined relative to. +// + +/// An SDP service record attribute ID. +public struct SDPAttributeID: RawRepresentable, Hashable, Sendable, Codable { + + public let rawValue: UInt16 + + public init(rawValue: UInt16) { + self.rawValue = rawValue + } +} + +public extension SDPAttributeID { + + /// A 32-bit number that uniquely identifies each service record. + static var serviceRecordHandle: SDPAttributeID { SDPAttributeID(rawValue: 0x0000) } + + /// A list of service class UUIDs, most specific first. + static var serviceClassIDList: SDPAttributeID { SDPAttributeID(rawValue: 0x0001) } + + static var serviceRecordState: SDPAttributeID { SDPAttributeID(rawValue: 0x0002) } + + /// A UUID identifying this particular service instance. + static var serviceID: SDPAttributeID { SDPAttributeID(rawValue: 0x0003) } + + /// One entry per protocol in the protocol stack used to gain + /// access to the service, outermost protocol first — the entry + /// after the outermost typically carries the L2CAP PSM or RFCOMM + /// channel number the service listens on. + static var protocolDescriptorList: SDPAttributeID { SDPAttributeID(rawValue: 0x0004) } + + static var browseGroupList: SDPAttributeID { SDPAttributeID(rawValue: 0x0005) } + + /// A sequence of (language, encoding, attribute ID base) triples; + /// the base offset into which the string attributes below are + /// added to get the actual attribute ID used for a given language. + static var languageBaseAttributeIDList: SDPAttributeID { SDPAttributeID(rawValue: 0x0006) } + + static var serviceInfoTimeToLive: SDPAttributeID { SDPAttributeID(rawValue: 0x0007) } + + static var serviceAvailability: SDPAttributeID { SDPAttributeID(rawValue: 0x0008) } + + /// One entry per Bluetooth profile the service conforms to: a + /// (profile UUID, version) pair. + static var bluetoothProfileDescriptorList: SDPAttributeID { SDPAttributeID(rawValue: 0x0009) } + + static var documentationURL: SDPAttributeID { SDPAttributeID(rawValue: 0x000A) } + + static var clientExecutableURL: SDPAttributeID { SDPAttributeID(rawValue: 0x000B) } + + static var iconURL: SDPAttributeID { SDPAttributeID(rawValue: 0x000C) } + + static var additionalProtocolDescriptorLists: SDPAttributeID { SDPAttributeID(rawValue: 0x000D) } +} + +public extension SDPAttributeID { + + /// The offsets added to a language base (from + /// ``languageBaseAttributeIDList``) to get the actual attribute ID + /// of a language-specific string attribute. + enum PrimaryLanguageOffset: UInt16, Sendable { + case serviceName = 0x0000 + case serviceDescription = 0x0001 + case providerName = 0x0002 + } + + /// The default primary-language base offset (0x0100), used when a + /// record doesn't have its own ``languageBaseAttributeIDList``. + static var primaryLanguageBase: UInt16 { 0x0100 } + + /// The attribute ID for `offset` under `base` (the primary + /// language's base, from ``languageBaseAttributeIDList``, or + /// ``primaryLanguageBase`` for the default language). + static func string(_ offset: PrimaryLanguageOffset, base: UInt16 = primaryLanguageBase) -> SDPAttributeID { + SDPAttributeID(rawValue: base + offset.rawValue) + } +} + +extension SDPAttributeID: ExpressibleByIntegerLiteral { + + public init(integerLiteral value: UInt16) { + self.init(rawValue: value) + } +} + +extension SDPAttributeID: CustomStringConvertible { + + public var description: String { + let hex = String(rawValue, radix: 16, uppercase: true) + return "0x" + String(repeating: "0", count: max(0, 4 - hex.count)) + hex + } +} diff --git a/Sources/BluetoothSDP/SDPBigEndian.swift b/Sources/BluetoothSDP/SDPBigEndian.swift new file mode 100644 index 000000000..736585a53 --- /dev/null +++ b/Sources/BluetoothSDP/SDPBigEndian.swift @@ -0,0 +1,60 @@ +// +// SDPBigEndian.swift +// Bluetooth +// +// SDP PDUs are big-endian (network byte order), unlike most Bluetooth +// wire formats in this package, which are little-endian. Rather than +// reuse the little-endian-oriented `DataConvertible` conformances +// elsewhere in this module (`UInt16.init(bytes:)` and friends encode +// raw native/little-endian memory), every multi-byte integer in this +// module is read and written explicitly, most-significant-byte-first, +// through these two small generic helpers. +// +// UInt128 gets its own non-generic overloads below rather than using +// these: this package's `UInt128` only conforms to `FixedWidthInteger` +// under `@available(macOS 15, ...)` (on Apple platforms before the +// native stdlib type, it's a hand-rolled struct), so a generic +// `FixedWidthInteger`-constrained call fails to compile for it on +// earlier deployment targets. The concrete overloads instead go +// through `ByteValue`'s tuple accessor, which has no such gate. +// + +import Bluetooth + +internal extension FixedWidthInteger { + + /// Parses `self` from `bytes`, most-significant byte first. + init(bigEndianBytes bytes: Bytes) where Bytes.Element == UInt8 { + self = bytes.reduce(into: 0 as Self) { result, byte in + result = (result << 8) | Self(truncatingIfNeeded: byte) + } + } + + /// The big-endian (network byte order) bytes of `self`, most + /// significant byte first. + var bigEndianBytes: [UInt8] { + let byteCount = Self.bitWidth / 8 + return (0 ..< byteCount).reversed().map { shift in + UInt8(truncatingIfNeeded: self >> (shift * 8)) + } + } +} + +internal extension UInt128 { + + /// Parses `self` from 16 big-endian (network byte order) bytes. + init(bigEndianBytes bytes: Bytes) where Bytes.Element == UInt8 { + let b = Array(bytes) + self = UInt128(bytes: ( + b[15], b[14], b[13], b[12], b[11], b[10], b[9], b[8], + b[7], b[6], b[5], b[4], b[3], b[2], b[1], b[0] + )) + } + + /// The big-endian (network byte order) bytes of `self`, most + /// significant byte first. + var bigEndianBytes: [UInt8] { + let b = bytes + return [b.15, b.14, b.13, b.12, b.11, b.10, b.9, b.8, b.7, b.6, b.5, b.4, b.3, b.2, b.1, b.0] + } +} diff --git a/Sources/BluetoothSDP/SDPDataElement.swift b/Sources/BluetoothSDP/SDPDataElement.swift new file mode 100644 index 000000000..81a8019c4 --- /dev/null +++ b/Sources/BluetoothSDP/SDPDataElement.swift @@ -0,0 +1,342 @@ +// +// SDPDataElement.swift +// Bluetooth +// +// The SDP data element: every value inside an SDP PDU or service +// record — attribute values, search patterns, attribute ID lists — +// is one of these, self-describing via a one-byte type/size-descriptor +// header (SDP Protocol, Bluetooth Core Spec Vol 3, Part B, §3.1). +// + +import Bluetooth + +/// An SDP (Service Discovery Protocol) data element. +/// +/// Every element starts with a header byte: the upper 5 bits are the +/// type, the lower 3 bits are a size descriptor — either the value's +/// width directly (fixed-width types) or which of a 1/2/4-byte length +/// field follows (variable-width types). +@frozen +public enum SDPDataElement: Hashable, Sendable { + + /// The nil value — no associated data. + case null + + case unsignedInt8(UInt8) + case unsignedInt16(UInt16) + case unsignedInt32(UInt32) + case unsignedInt64(UInt64) + case unsignedInt128(UInt128) + + case signedInt8(Int8) + case signedInt16(Int16) + case signedInt32(Int32) + case signedInt64(Int64) + + /// A 128-bit signed integer, stored as its raw two's-complement bit + /// pattern — SDP's own reference implementation has no dedicated + /// 128-bit signed integer storage either, and reuses the unsigned + /// 128-bit representation for it. + case signedInt128(UInt128) + + case uuid(BluetoothUUID) + + /// A UTF-8 text string. + case text(String) + + case boolean(Bool) + + /// An ordered sequence of data elements — SDP's equivalent of an + /// array or tuple; used for attribute values (service class ID + /// lists, protocol descriptor lists) and for search patterns. + case sequence([SDPDataElement]) + + /// A set of alternative representations of the same value, of + /// which a consumer picks one. Rare in practice; included for + /// completeness. + case alternative([SDPDataElement]) + + case url(String) +} + +// MARK: - Type / Size Descriptor + +internal extension SDPDataElement { + + /// The upper 5 bits of the header byte. + enum ElementType: UInt8 { + case null = 0 + case unsignedInteger = 1 + case signedInteger = 2 + case uuid = 3 + case text = 4 + case boolean = 5 + case sequence = 6 + case alternative = 7 + case url = 8 + } + + /// Smallest size descriptor (5, 6, or 7) whose 1/2/4-byte explicit + /// length field can hold `byteCount`. + static func lengthSizeDescriptor(for byteCount: Int) -> UInt8 { + if byteCount <= Int(UInt8.max) { return 5 } + if byteCount <= Int(UInt16.max) { return 6 } + return 7 + } + + static func header(_ type: ElementType, _ sizeDescriptor: UInt8) -> UInt8 { + (type.rawValue << 3) | sizeDescriptor + } +} + +// MARK: - DataConvertible + +extension SDPDataElement: DataConvertible { + + public init?(data: Data) { + var consumed = 0 + guard let element = SDPDataElement(parsing: Array(data), consumed: &consumed), + consumed == data.count + else { return nil } + self = element + } + + public func append(to data: inout Data) { + data += encodedBytes + } + + /// The number of bytes encoding this element produces, without + /// actually encoding — used to size a containing sequence's length + /// field without an O(n²) double-encode. + public var dataLength: Int { + switch self { + case .null: + return 1 + case .unsignedInt8, .signedInt8, .boolean: + return 2 + case .unsignedInt16, .signedInt16: + return 3 + case .unsignedInt32, .signedInt32: + return 5 + case .unsignedInt64, .signedInt64: + return 9 + case .unsignedInt128, .signedInt128: + return 17 + case let .uuid(value): + switch value { + case .bit16: return 3 + case .bit32: return 5 + case .bit128: return 17 + } + case let .text(value): + return Self.variableLengthTotal(Array(value.utf8).count) + case let .url(value): + return Self.variableLengthTotal(Array(value.utf8).count) + case let .sequence(elements): + return Self.variableLengthTotal(elements.reduce(0) { $0 + $1.dataLength }) + case let .alternative(elements): + return Self.variableLengthTotal(elements.reduce(0) { $0 + $1.dataLength }) + } + } +} + +// MARK: - Encoding + +private extension SDPDataElement { + + /// This element encoded as SDP wire bytes. + var encodedBytes: [UInt8] { + switch self { + case .null: + return [Self.header(.null, 0)] + case let .unsignedInt8(value): + return [Self.header(.unsignedInteger, 0), value] + case let .unsignedInt16(value): + return [Self.header(.unsignedInteger, 1)] + value.bigEndianBytes + case let .unsignedInt32(value): + return [Self.header(.unsignedInteger, 2)] + value.bigEndianBytes + case let .unsignedInt64(value): + return [Self.header(.unsignedInteger, 3)] + value.bigEndianBytes + case let .unsignedInt128(value): + return [Self.header(.unsignedInteger, 4)] + value.bigEndianBytes + case let .signedInt8(value): + return [Self.header(.signedInteger, 0), UInt8(bitPattern: value)] + case let .signedInt16(value): + return [Self.header(.signedInteger, 1)] + UInt16(bitPattern: value).bigEndianBytes + case let .signedInt32(value): + return [Self.header(.signedInteger, 2)] + UInt32(bitPattern: value).bigEndianBytes + case let .signedInt64(value): + return [Self.header(.signedInteger, 3)] + UInt64(bitPattern: value).bigEndianBytes + case let .signedInt128(value): + return [Self.header(.signedInteger, 4)] + value.bigEndianBytes + case let .uuid(value): + return Self.encodeUUID(value) + case let .text(value): + return Self.encodeVariableLength(.text, Array(value.utf8)) + case let .boolean(value): + return [Self.header(.boolean, 0), value ? 1 : 0] + case let .sequence(elements): + return Self.encodeVariableLength(.sequence, elements.flatMap(\.encodedBytes)) + case let .alternative(elements): + return Self.encodeVariableLength(.alternative, elements.flatMap(\.encodedBytes)) + case let .url(value): + return Self.encodeVariableLength(.url, Array(value.utf8)) + } + } + + static func encodeUUID(_ uuid: BluetoothUUID) -> [UInt8] { + switch uuid { + case let .bit16(value): + return [header(.uuid, 1)] + value.bigEndianBytes + case let .bit32(value): + return [header(.uuid, 2)] + value.bigEndianBytes + case let .bit128(value): + return [header(.uuid, 4)] + value.bigEndianBytes + } + } + + static func encodeVariableLength(_ type: ElementType, _ bytes: [UInt8]) -> [UInt8] { + let sizeDescriptor = lengthSizeDescriptor(for: bytes.count) + var result = [header(type, sizeDescriptor)] + switch sizeDescriptor { + case 5: result.append(UInt8(bytes.count)) + case 6: result.append(contentsOf: UInt16(bytes.count).bigEndianBytes) + default: result.append(contentsOf: UInt32(bytes.count).bigEndianBytes) + } + result.append(contentsOf: bytes) + return result + } + + static func variableLengthTotal(_ byteCount: Int) -> Int { + let lengthFieldSize: Int + switch lengthSizeDescriptor(for: byteCount) { + case 5: lengthFieldSize = 1 + case 6: lengthFieldSize = 2 + default: lengthFieldSize = 4 + } + return 1 + lengthFieldSize + byteCount + } +} + +// MARK: - Decoding + +internal extension SDPDataElement { + + /// Parses a single data element from the start of `bytes`, which + /// may contain trailing data after it (as it does when parsing one + /// element out of a sequence). `consumed` is set to the number of + /// bytes the element occupied. + init?(parsing bytes: some Collection, consumed: inout Int) { + let bytes = Array(bytes) + guard let first = bytes.first, + let type = ElementType(rawValue: first >> 3) + else { return nil } + let sizeDescriptor = first & 0x07 + + func fixedWidthValue(_ width: Int) -> [UInt8]? { + guard bytes.count >= 1 + width else { return nil } + return Array(bytes[1 ..< 1 + width]) + } + + switch type { + case .null: + self = .null + consumed = 1 + + case .unsignedInteger, .signedInteger: + let width: Int + switch sizeDescriptor { + case 0: width = 1 + case 1: width = 2 + case 2: width = 4 + case 3: width = 8 + case 4: width = 16 + default: return nil + } + guard let valueBytes = fixedWidthValue(width) else { return nil } + consumed = 1 + width + if type == .unsignedInteger { + switch width { + case 1: self = .unsignedInt8(valueBytes[0]) + case 2: self = .unsignedInt16(UInt16(bigEndianBytes: valueBytes)) + case 4: self = .unsignedInt32(UInt32(bigEndianBytes: valueBytes)) + case 8: self = .unsignedInt64(UInt64(bigEndianBytes: valueBytes)) + default: self = .unsignedInt128(UInt128(bigEndianBytes: valueBytes)) + } + } else { + switch width { + case 1: self = .signedInt8(Int8(bitPattern: valueBytes[0])) + case 2: self = .signedInt16(Int16(bitPattern: UInt16(bigEndianBytes: valueBytes))) + case 4: self = .signedInt32(Int32(bitPattern: UInt32(bigEndianBytes: valueBytes))) + case 8: self = .signedInt64(Int64(bitPattern: UInt64(bigEndianBytes: valueBytes))) + default: self = .signedInt128(UInt128(bigEndianBytes: valueBytes)) + } + } + + case .uuid: + let width: Int + switch sizeDescriptor { + case 1: width = 2 + case 2: width = 4 + case 4: width = 16 + default: return nil + } + guard let valueBytes = fixedWidthValue(width) else { return nil } + consumed = 1 + width + switch width { + case 2: self = .uuid(.bit16(UInt16(bigEndianBytes: valueBytes))) + case 4: self = .uuid(.bit32(UInt32(bigEndianBytes: valueBytes))) + default: self = .uuid(.bit128(UInt128(bigEndianBytes: valueBytes))) + } + + case .boolean: + guard let valueBytes = fixedWidthValue(1) else { return nil } + consumed = 2 + self = .boolean(valueBytes[0] != 0) + + case .text, .url, .sequence, .alternative: + guard let (length, headerSize) = Self.variableLength(sizeDescriptor, bytes) else { return nil } + let valueStart = 1 + headerSize + guard bytes.count >= valueStart + length else { return nil } + let valueBytes = Array(bytes[valueStart ..< valueStart + length]) + consumed = valueStart + length + + switch type { + case .text: + self = .text(String(decoding: valueBytes, as: UTF8.self)) + case .url: + self = .url(String(decoding: valueBytes, as: UTF8.self)) + case .sequence, .alternative: + var elements: [SDPDataElement] = [] + var offset = 0 + while offset < valueBytes.count { + var elementConsumed = 0 + guard let element = SDPDataElement(parsing: valueBytes[offset...], consumed: &elementConsumed), + elementConsumed > 0 + else { return nil } + elements.append(element) + offset += elementConsumed + } + self = type == .sequence ? .sequence(elements) : .alternative(elements) + default: + return nil + } + } + } + + private static func variableLength(_ sizeDescriptor: UInt8, _ bytes: [UInt8]) -> (length: Int, headerSize: Int)? { + switch sizeDescriptor { + case 5: + guard bytes.count >= 2 else { return nil } + return (Int(bytes[1]), 1) + case 6: + guard bytes.count >= 3 else { return nil } + return (Int(UInt16(bigEndianBytes: bytes[1 ..< 3])), 2) + case 7: + guard bytes.count >= 5 else { return nil } + return (Int(UInt32(bigEndianBytes: bytes[1 ..< 5])), 4) + default: + return nil + } + } +} diff --git a/Sources/BluetoothSDP/SDPError.swift b/Sources/BluetoothSDP/SDPError.swift new file mode 100644 index 000000000..6ed8a70b9 --- /dev/null +++ b/Sources/BluetoothSDP/SDPError.swift @@ -0,0 +1,42 @@ +// +// SDPError.swift +// Bluetooth +// +// The error codes an SDP server returns in an ErrorResponse PDU (SDP +// Protocol, Bluetooth Core Spec Vol 3, Part B, §4.4.1). +// + +/// An SDP `ErrorResponse` error code. +public struct SDPError: RawRepresentable, Hashable, Sendable, Error, Codable { + + public let rawValue: UInt16 + + public init(rawValue: UInt16) { + self.rawValue = rawValue + } +} + +public extension SDPError { + + static var invalidVersion: SDPError { SDPError(rawValue: 0x0001) } + static var invalidServiceRecordHandle: SDPError { SDPError(rawValue: 0x0002) } + static var invalidRequestSyntax: SDPError { SDPError(rawValue: 0x0003) } + static var invalidPDUSize: SDPError { SDPError(rawValue: 0x0004) } + static var invalidContinuationState: SDPError { SDPError(rawValue: 0x0005) } + static var insufficientResources: SDPError { SDPError(rawValue: 0x0006) } +} + +extension SDPError: CustomStringConvertible { + + public var description: String { + switch self { + case .invalidVersion: return "Invalid/unsupported SDP version" + case .invalidServiceRecordHandle: return "Invalid service record handle" + case .invalidRequestSyntax: return "Invalid request syntax" + case .invalidPDUSize: return "Invalid PDU size" + case .invalidContinuationState: return "Invalid continuation state" + case .insufficientResources: return "Insufficient resources" + default: return "SDP error 0x" + String(rawValue, radix: 16, uppercase: true) + } + } +} diff --git a/Sources/BluetoothSDP/SDPPDU.swift b/Sources/BluetoothSDP/SDPPDU.swift new file mode 100644 index 000000000..1b04a4d27 --- /dev/null +++ b/Sources/BluetoothSDP/SDPPDU.swift @@ -0,0 +1,94 @@ +// +// SDPPDU.swift +// Bluetooth +// +// The fixed 5-byte header every SDP PDU starts with (SDP Protocol, +// Bluetooth Core Spec Vol 3, Part B, §4.2) — PDU ID, transaction ID, +// and the byte length of everything that follows. +// +// This covers PDU framing and the data model/record codec +// (`SDPDataElement`, `SDPServiceRecord`); it does not define the +// individual request/response parameter layouts (ServiceSearchRequest +// and friends) or continuation-state fragmentation/reassembly, which +// belong with a socket-based session layer, not this pure data model. +// + +import Bluetooth + +/// The PDU ID identifying an SDP PDU's kind (SDP Protocol, Bluetooth +/// Core Spec Vol 3, Part B, §4.2). +public struct SDPPDUID: RawRepresentable, Hashable, Sendable, Codable { + + public let rawValue: UInt8 + + public init(rawValue: UInt8) { + self.rawValue = rawValue + } +} + +public extension SDPPDUID { + + static var errorResponse: SDPPDUID { SDPPDUID(rawValue: 0x01) } + static var serviceSearchRequest: SDPPDUID { SDPPDUID(rawValue: 0x02) } + static var serviceSearchResponse: SDPPDUID { SDPPDUID(rawValue: 0x03) } + static var serviceAttributeRequest: SDPPDUID { SDPPDUID(rawValue: 0x04) } + static var serviceAttributeResponse: SDPPDUID { SDPPDUID(rawValue: 0x05) } + static var serviceSearchAttributeRequest: SDPPDUID { SDPPDUID(rawValue: 0x06) } + static var serviceSearchAttributeResponse: SDPPDUID { SDPPDUID(rawValue: 0x07) } +} + +extension SDPPDUID: ExpressibleByIntegerLiteral { + + public init(integerLiteral value: UInt8) { + self.init(rawValue: value) + } +} + +/// The fixed 5-byte header every SDP PDU starts with. +@frozen +public struct SDPPDUHeader: Equatable, Hashable, Sendable { + + /// The kind of PDU this is. + public var pduID: SDPPDUID + + /// Identifies a request/response pair; the response echoes the + /// request's transaction ID unchanged. + public var transactionID: UInt16 + + /// The byte length of everything following this header. + public var parameterLength: UInt16 + + public init(pduID: SDPPDUID, transactionID: UInt16, parameterLength: UInt16) { + self.pduID = pduID + self.transactionID = transactionID + self.parameterLength = parameterLength + } +} + +public extension SDPPDUHeader { + + static var length: Int { 5 } +} + +// MARK: - DataConvertible + +extension SDPPDUHeader: DataConvertible { + + public init?(data: Data) { + guard data.count == Self.length else { return nil } + let bytes = Array(data) + self.init( + pduID: SDPPDUID(rawValue: bytes[0]), + transactionID: UInt16(bigEndianBytes: bytes[1 ..< 3]), + parameterLength: UInt16(bigEndianBytes: bytes[3 ..< 5]) + ) + } + + public func append(to data: inout Data) { + data += pduID.rawValue + data += transactionID.bigEndianBytes + data += parameterLength.bigEndianBytes + } + + public var dataLength: Int { Self.length } +} diff --git a/Sources/BluetoothSDP/SDPServiceRecord.swift b/Sources/BluetoothSDP/SDPServiceRecord.swift new file mode 100644 index 000000000..00820309a --- /dev/null +++ b/Sources/BluetoothSDP/SDPServiceRecord.swift @@ -0,0 +1,153 @@ +// +// SDPServiceRecord.swift +// Bluetooth +// +// An SDP service record: the set of attributes describing one +// service, as returned by a ServiceAttributeResponse / +// ServiceSearchAttributeResponse, or built up to register one. +// +// On the wire, a record is a data element sequence containing, for +// each attribute, its ID (encoded as a UINT16 data element) directly +// followed by its value — not further nested into pairs. +// + +import Bluetooth + +/// An SDP service record: a service's attributes, keyed by attribute ID. +public struct SDPServiceRecord: Hashable, Sendable { + + public var attributes: [SDPAttributeID: SDPDataElement] + + public init(attributes: [SDPAttributeID: SDPDataElement] = [:]) { + self.attributes = attributes + } + + public subscript(attributeID: SDPAttributeID) -> SDPDataElement? { + get { attributes[attributeID] } + set { attributes[attributeID] = newValue } + } +} + +// MARK: - DataConvertible + +extension SDPServiceRecord: DataConvertible { + + public init?(data: Data) { + guard let sequence = SDPDataElement(data: data), + case let .sequence(elements) = sequence, + elements.count % 2 == 0 + else { return nil } + + var attributes: [SDPAttributeID: SDPDataElement] = [:] + attributes.reserveCapacity(elements.count / 2) + var index = elements.startIndex + while index < elements.endIndex { + guard case let .unsignedInt16(rawAttributeID) = elements[index] else { return nil } + let value = elements[index + 1] + attributes[SDPAttributeID(rawValue: rawAttributeID)] = value + index += 2 + } + self.init(attributes: attributes) + } + + public func append(to data: inout Data) { + sequenceElement.append(to: &data) + } + + public var dataLength: Int { + sequenceElement.dataLength + } + + /// This record's attributes, sorted by ID, as the flat data element + /// sequence the wire format expects. + private var sequenceElement: SDPDataElement { + var elements: [SDPDataElement] = [] + elements.reserveCapacity(attributes.count * 2) + for (attributeID, value) in attributes.sorted(by: { $0.key.rawValue < $1.key.rawValue }) { + elements.append(.unsignedInt16(attributeID.rawValue)) + elements.append(value) + } + return .sequence(elements) + } +} + +// MARK: - Well-known attribute accessors + +public extension SDPServiceRecord { + + /// The 32-bit handle a server assigns this record — usually only + /// meaningful on records read back from a server, not ones being + /// built for registration. + var handle: UInt32? { + get { + guard case let .unsignedInt32(value)? = self[.serviceRecordHandle] else { return nil } + return value + } + set { + self[.serviceRecordHandle] = newValue.map { .unsignedInt32($0) } + } + } + + /// The service class UUIDs, most specific first. + var serviceClassIDList: [BluetoothUUID]? { + get { uuidList(for: .serviceClassIDList) } + set { self[.serviceClassIDList] = newValue.map { .sequence($0.map(SDPDataElement.uuid)) } } + } + + /// The Bluetooth profile UUIDs (with version) this record conforms + /// to. + var bluetoothProfileDescriptorList: [(uuid: BluetoothUUID, version: UInt16)]? { + guard case let .sequence(profiles)? = self[.bluetoothProfileDescriptorList] else { return nil } + return profiles.compactMap { profile in + guard case let .sequence(pair) = profile, + pair.count == 2, + case let .uuid(uuid) = pair[0], + case let .unsignedInt16(version) = pair[1] + else { return nil } + return (uuid, version) + } + } + + /// This record's primary language's base offset for the + /// string attributes below (``SDPAttributeID/primaryLanguageBase`` + /// if the record has no `languageBaseAttributeIDList`). + private var primaryLanguageBase: UInt16 { + guard case let .sequence(triples)? = self[.languageBaseAttributeIDList], + triples.count >= 3, + case let .unsignedInt16(base) = triples[2] + else { return SDPAttributeID.primaryLanguageBase } + return base + } + + var serviceName: String? { + get { string(.serviceName) } + set { setString(.serviceName, newValue) } + } + + var serviceDescription: String? { + get { string(.serviceDescription) } + set { setString(.serviceDescription, newValue) } + } + + var providerName: String? { + get { string(.providerName) } + set { setString(.providerName, newValue) } + } + + private func string(_ offset: SDPAttributeID.PrimaryLanguageOffset) -> String? { + guard case let .text(value)? = self[.string(offset, base: primaryLanguageBase)] else { return nil } + return value + } + + private mutating func setString(_ offset: SDPAttributeID.PrimaryLanguageOffset, _ newValue: String?) { + self[.string(offset, base: primaryLanguageBase)] = newValue.map { .text($0) } + } + + private func uuidList(for attributeID: SDPAttributeID) -> [BluetoothUUID]? { + guard case let .sequence(elements)? = self[attributeID] else { return nil } + return elements.compactMap { element in + guard case let .uuid(uuid) = element else { return nil } + return uuid + } + } +} diff --git a/Tests/BluetoothTests/SDPTests.swift b/Tests/BluetoothTests/SDPTests.swift new file mode 100644 index 000000000..26c3fc907 --- /dev/null +++ b/Tests/BluetoothTests/SDPTests.swift @@ -0,0 +1,172 @@ +// +// SDPTests.swift +// BluetoothTests +// + +#if canImport(BluetoothSDP) +import Testing +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif +import Bluetooth +@testable import BluetoothSDP + +@Suite struct SDPTests { + + @Test func uint128BigEndianBytes() { + + // Checked against a value built independently via the + // always-available `ByteValue` tuple initializer (not an + // integer literal — UInt128's ExpressibleByIntegerLiteral and + // BinaryInteger conformances are macOS-15+-only on this + // package's older deployment target), so this catches actual + // byte-order mistakes rather than only self-consistency (a + // reversed-but-consistent implementation would pass a pure + // round-trip check too). + let oneBytes = [UInt8](repeating: 0, count: 15) + [0x01] + let one = UInt128(bytes: (1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)) + #expect(UInt128(bigEndianBytes: oneBytes) == one) + #expect(one.bigEndianBytes == oneBytes) + + let value = UInt128(bigEndianBytes: Array(0 ..< 16)) + #expect(value.bigEndianBytes == Array(0 ..< 16)) + let expected = UInt128(bytes: (15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0)) + #expect(value == expected) + } + + @Test func dataElementRoundTrip() { + + let elements: [SDPDataElement] = [ + .null, + .unsignedInt8(0xFF), + .unsignedInt16(0x1234), + .unsignedInt32(0xDEADBEEF), + .unsignedInt64(0x0102030405060708), + .unsignedInt128(UInt128(bigEndianBytes: Array(0 ..< 16))), + .signedInt8(-1), + .signedInt16(-1234), + .signedInt32(-1), + .signedInt64(-1), + .uuid(.bit16(0x1101)), + .uuid(.bit32(0x00001101)), + .uuid(.bit128(UInt128(bigEndianBytes: Array(0 ..< 16)))), + .text("Serial Port"), + .text(""), + .boolean(true), + .boolean(false), + .url("https://www.bluetooth.com"), + .sequence([.uuid(.bit16(0x1101)), .unsignedInt16(1)]), + .alternative([.unsignedInt8(1), .unsignedInt16(2)]) + ] + + for element in elements { + let data = Data(element) + #expect(data.count == element.dataLength) + let decoded = SDPDataElement(data: data) + #expect(decoded == element) + } + } + + @Test func nestedSequence() { + + let element = SDPDataElement.sequence([ + .sequence([.uuid(.bit16(0x0100))]), + .sequence([.uuid(.bit16(0x0003)), .unsignedInt8(9)]) + ]) + + let data = Data(element) + #expect(SDPDataElement(data: data) == element) + } + + @Test func largeTextUsesWiderLengthField() { + + // 300 bytes doesn't fit an 8-bit length field (max 255), so + // this must pick the 16-bit size descriptor, not truncate. + let text = String(repeating: "a", count: 300) + let element = SDPDataElement.text(text) + let data = Data(element) + + // header (1) + 16-bit length field (2) + payload + #expect(data.count == 1 + 2 + 300) + #expect(SDPDataElement(data: data) == element) + } + + @Test func malformedDataFailsToDecode() { + + #expect(SDPDataElement(data: Data()) == nil) + // UUID tag claiming 16 bytes but only 2 provided + #expect(SDPDataElement(data: Data([0x1C, 0x11, 0x01])) == nil) + // trailing byte after a complete element + #expect(SDPDataElement(data: Data([0x00, 0x00])) == nil) + } + + @Test func serviceRecordRoundTrip() { + + var record = SDPServiceRecord() + record.handle = 0x00010001 + record.serviceClassIDList = [.bit16(0x1101)] + record.serviceName = "Serial Port" + record.serviceDescription = "COM Port" + record.providerName = "PureSwift" + record[.protocolDescriptorList] = .sequence([ + .sequence([.uuid(.bit16(0x0100))]), + .sequence([.uuid(.bit16(0x0003)), .unsignedInt8(1)]) + ]) + + let data = Data(record) + let decoded = SDPServiceRecord(data: data) + + #expect(decoded?.handle == record.handle) + #expect(decoded?.serviceClassIDList == record.serviceClassIDList) + #expect(decoded?.serviceName == record.serviceName) + #expect(decoded?.serviceDescription == record.serviceDescription) + #expect(decoded?.providerName == record.providerName) + #expect(decoded == record) + } + + @Test func serviceRecordAttributesSortedOnEncode() { + + var record = SDPServiceRecord() + record[.serviceInfoTimeToLive] = .unsignedInt32(30) + record[.serviceRecordHandle] = .unsignedInt32(1) + record[.serviceClassIDList] = .sequence([.uuid(.bit16(0x1101))]) + + let data = Data(record) + #expect(SDPServiceRecord(data: data) == record) + + // first attribute ID element, right after the outer sequence's + // own header bytes (SEQ8 tag + 1-byte length = 2 bytes), must + // be the smallest one, 0x0000 — encoded as a UINT16 element: + // tag 0x09, then the two value bytes. + let bytes = [UInt8](data) + #expect(Array(bytes[2 ..< 5]) == [0x09, 0x00, 0x00]) + } + + @Test func attributeID() { + + #expect(SDPAttributeID.serviceClassIDList.rawValue == 0x0001) + #expect(SDPAttributeID.protocolDescriptorList.rawValue == 0x0004) + #expect(SDPAttributeID.string(.serviceName).rawValue == 0x0100) + #expect(SDPAttributeID.string(.serviceName, base: 0x0200).rawValue == 0x0200) + #expect(SDPAttributeID.serviceClassIDList.description == "0x0001") + } + + @Test func pduHeaderRoundTrip() { + + let header = SDPPDUHeader(pduID: .serviceSearchRequest, transactionID: 0x0102, parameterLength: 0x0304) + let data = Data(header) + + #expect(data.count == SDPPDUHeader.length) + #expect([UInt8](data) == [0x02, 0x01, 0x02, 0x03, 0x04]) + #expect(SDPPDUHeader(data: data) == header) + } + + @Test func error() { + + #expect(SDPError.invalidPDUSize.rawValue == 0x0004) + #expect(SDPError.invalidPDUSize.description == "Invalid PDU size") + } +} +#endif