diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml new file mode 100644 index 0000000..679ceb2 --- /dev/null +++ b/.github/workflows/swift.yml @@ -0,0 +1,69 @@ +name: Swift +on: [push] +jobs: + + # macOS is a compile target only, on both architectures: macos-15 is Apple + # Silicon and macos-15-intel is the x86_64 image that replaced the retired + # macos-13. The test suite needs a session bus and D-Bus is not part of macOS, + # so the suite is run on Linux, where a real bus is available. The tests are + # still *built* here, so they cannot rot out of compiling on Darwin. + macos: + name: macOS (${{ matrix.arch }}, ${{ matrix.config }}) + strategy: + # One failing cell should not hide the others: knowing which + # architecture and configuration combinations fail is the whole + # point of the matrix when diagnosing platform-specific behavior. + fail-fast: false + matrix: + arch: ["arm64", "x86_64"] + config: ["debug", "release"] + include: + - arch: arm64 + runner: macos-15 + - arch: x86_64 + runner: macos-15-intel + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Swift Version + run: swift --version + - name: Build + run: swift build -c ${{ matrix.config }} + - name: Build tests + # `-enable-testing` is on by default only for debug. The tests use `@testable import`, + # and `swift test` passes the flag itself, but a plain build in release does not. + run: swift build -c ${{ matrix.config }} --build-tests -Xswiftc -enable-testing + + # x86_64 on the standard hosted runner, arm64 on ubuntu-24.04-arm, which is a + # real ARM64 machine rather than a cross-compilation target, so the suite runs + # there too. Docker pulls the matching architecture of the same Swift images. + linux: + name: Linux (${{ matrix.arch }}, ${{ matrix.container }}, ${{ matrix.config }}, ${{ matrix.options || 'static' }}) + strategy: + fail-fast: false + matrix: + arch: ["x86_64", "arm64"] + container: ["swift:6.2.3", "swift:6.3.3"] + config: ["debug", "release"] + options: ["", "SWIFT_BUILD_DYNAMIC_LIBRARY=1"] + include: + - arch: x86_64 + runner: ubuntu-latest + - arch: arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + container: ${{ matrix.container }}-jammy + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Swift Version + run: swift --version + - name: Install D-Bus + run: apt-get update -qq && apt-get install -y --no-install-recommends dbus + - name: Build + run: ${{ matrix.options }} swift build -c ${{ matrix.config }} + - name: Test + run: dbus-run-session -- env ${{ matrix.options }} swift test -c ${{ matrix.config }} --no-parallel diff --git a/Package.resolved b/Package.resolved index e11846f..9d14dfc 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,16 +1,24 @@ { - "object": { - "pins": [ - { - "package": "CDBus", - "repositoryURL": "https://github.com/PureSwift/CDBus.git", - "state": { - "branch": "master", - "revision": "ccffce2e8469e89589d24234eed31c067104722a", - "version": null - } + "originHash" : "4450dc9d58c314eae579e6834118bfe9a1e0a76ca1c166800d4b802afacbc6c9", + "pins" : [ + { + "identity" : "socket", + "kind" : "remoteSourceControl", + "location" : "https://github.com/PureSwift/Socket.git", + "state" : { + "branch" : "fix/stale-readiness", + "revision" : "37381d5dc881da2acb13ef38f23d645bad87b8c6" } - ] - }, - "version": 1 + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system", + "state" : { + "revision" : "50688cacbd41d547e9eb9f7a213542340b7c442b", + "version" : "1.7.5" + } + } + ], + "version" : 3 } diff --git a/Package.swift b/Package.swift index f69414b..099035b 100644 --- a/Package.swift +++ b/Package.swift @@ -1,27 +1,34 @@ -// swift-tools-version:4.1 +// swift-tools-version:6.0 import PackageDescription +import class Foundation.ProcessInfo + +// force building as dynamic library +let dynamicLibrary = ProcessInfo.processInfo.environment["SWIFT_BUILD_DYNAMIC_LIBRARY"] != nil +let libraryType: PackageDescription.Product.Library.LibraryType? = dynamicLibrary ? .dynamic : nil let package = Package( name: "DBus", + platforms: [ + .macOS(.v13), + ], products: [ .library( name: "DBus", - targets: [ - "DBus" - ] + type: libraryType, + targets: ["DBus"] ) ], dependencies: [ .package( - url: "https://github.com/PureSwift/CDBus.git", - .branch("master") + url: "https://github.com/PureSwift/Socket.git", + branch: "fix/stale-readiness" ) ], targets: [ .target( name: "DBus", dependencies: [ - //"CDBus" + "Socket" ] ), .testTarget( @@ -30,6 +37,5 @@ let package = Package( "DBus" ] ) - ], - swiftLanguageVersions: [5] + ] ) diff --git a/README.md b/README.md index 147393c..c914eee 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,152 @@ # DBus -Swift library for D-Bus + +Pure Swift library for D-Bus. No `libdbus`, no C implementation — the wire format, the SASL +handshake and the transport are all implemented in Swift. + +## Requirements + +- Swift 6.0+ +- Linux (or any platform with a D-Bus daemon reachable over a Unix socket) + +Depends on [PureSwift/Socket](https://github.com/PureSwift/Socket) for async sockets. + +> The manifest currently points at a local `../Socket` checkout, which carries three changes not +> yet upstream: `SCM_RIGHTS` ancillary data support that file-descriptor passing is built on, and +> two socket-monitor fixes — deferred poll results being applied to a reused descriptor, and a +> never-connected socket being torn down because an unconnected socket polls as `POLLHUP`. + +## Usage + +```swift +import DBus + +let connection = try await DBusConnection.connect(to: .session) + +let reply = try await connection.send( + DBusMessage(methodCall: DBusMessage.MethodCall( + destination: DBusBusName(rawValue: "org.freedesktop.DBus")!, + path: DBusObjectPath(rawValue: "/org/freedesktop/DBus")!, + interface: DBusInterface(rawValue: "org.freedesktop.DBus")!, + method: DBusMember(rawValue: "ListNames")! + )) +) + +if case let .array(names)? = reply.arguments.first { + for name in names.compactMap({ $0.stringValue }) { + print(name) + } +} + +await connection.close() +``` + +An error reply is thrown as a `DBusError` carrying its `org.freedesktop.DBus.Error.*` name; +framing and marshalling failures are thrown as `DBusProtocolError`. + +### File descriptors + +A `UNIX_FD` argument carries a real descriptor. On the wire it is marshalled as an index into +the descriptors sent out of band, so the value you pass is the descriptor itself: + +```swift +try await connection.callMethod( + destination: name, + path: path, + interface: interface, + method: DBusMember(rawValue: "Accept")!, + arguments: [.fileDescriptor(.init(rawValue: myFileDescriptor))] +) +``` + +The peer receives its own descriptor referring to the same open file, and **owns it**: close it +when finished. Sending requires the peer to have agreed to `NEGOTIATE_UNIX_FD`, which +`unixFileDescriptorsSupported` reports. + +### Signals + +A connection receives no broadcast signals until it installs a match rule. `signals(matching:)` +installs one and yields matching messages until the stream is dropped, which removes it again. + +```swift +let signals = try await connection.signals(matching: .nameOwnerChanged()) + +for await signal in signals { + print(signal.arguments) +} +``` + +### Exporting an object + +```swift +let counter = DBusInterfaceImplementation( + name: DBusInterface(rawValue: "com.example.Counter")!, + methods: [ + .init(name: DBusMember(rawValue: "Increment")!, + outputSignature: DBusSignature(rawValue: "u")!, + handler: { _ in [.uint32(await state.increment())] }) + ], + properties: [ + .init(name: "Total", type: .uint32, access: .read, + get: { .uint32(await state.total) }) + ] +) + +await connection.export(DBusExportedObject([counter]), + at: DBusObjectPath(rawValue: "/com/example/Counter")!) + +try await connection.requestName(DBusBusName(rawValue: "com.example.Counter")!) +``` + +`org.freedesktop.DBus.Peer`, `.Introspectable` and `.Properties` are answered automatically: +introspection XML is generated from the declared methods, properties and signals. + +## Design + +`DBusMessage` and every value type are `Sendable` structs. `DBusConnection` is an actor that owns +the socket, runs the read loop and matches replies to calls by serial. Nothing wraps a C pointer, +so there is no reference counting to get wrong. + +Values are modelled by `DBusMessageArgument`, which covers every D-Bus type including `variant` +and `dict`. Array element types are stored explicitly rather than inferred, so an empty array +still marshals with the right signature. + +Names are validated on construction by hand-written parsers: `DBusObjectPath`, `DBusInterface`, +`DBusMember`, `DBusBusName` and `DBusSignature`. + +## Status + +Implemented: + +- Message marshalling and unmarshalling, both byte orders +- Bus address parsing (`unix:path=`, `unix:abstract=`, `unix:runtime=yes`, percent escaping) +- Unix socket transport, including the Linux abstract namespace +- SASL `EXTERNAL` and `ANONYMOUS`, plus `NEGOTIATE_UNIX_FD` +- Method calls and replies, with timeouts +- Bus daemon API: name registration and queries, `AddMatch` / `RemoveMatch` +- Match rules, with spec-correct encoding and local matching +- Signal subscriptions as `AsyncStream` +- Server side: object export, method dispatch, signal emission +- `org.freedesktop.DBus.Peer`, `.Introspectable` and `.Properties` +- SASL `DBUS_COOKIE_SHA1`, with a pure-Swift SHA-1 +- `tcp:` and `nonce-tcp:` transports, IPv4 and IPv6 +- Unix file descriptor passing, via `SCM_RIGHTS` + +- Parsing introspection XML into a typed node model + +Not yet implemented: + +- Code generation from introspection XML + +## Tests + +```sh +swift test +``` + +Written with [Swift Testing](https://github.com/swiftlang/swift-testing). The suites that need a +live bus are marked `.enabled(if: hasSessionBus)`, so they report as skipped rather than passing +when no bus socket is present. + +## License + +MIT. See [LICENSE](LICENSE). diff --git a/Sources/DBus/Auth/DBusKeyring.swift b/Sources/DBus/Auth/DBusKeyring.swift new file mode 100644 index 0000000..81a2090 --- /dev/null +++ b/Sources/DBus/Auth/DBusKeyring.swift @@ -0,0 +1,170 @@ +// +// DBusKeyring.swift +// DBus +// + +import SystemPackage + +/// The shared secrets used by `DBUS_COOKIE_SHA1`. +/// +/// Cookies live in `~/.dbus-keyrings/`, one per line, as +/// ` ` where the cookie is hexadecimal. +/// +/// Reference: https://dbus.freedesktop.org/doc/dbus-specification.html#auth-mechanisms-sha +internal struct DBusKeyring { + + /// A single cookie. + struct Cookie: Equatable { + + /// Identifier, unique within the context. + let identifier: String + + /// Creation time, as seconds since the Unix epoch. + let creationTime: UInt64 + + /// The secret, as a hexadecimal string. + let value: String + } + + /// The cookies in the context, in file order. + let cookies: [Cookie] + + init(cookies: [Cookie]) { + + self.cookies = cookies + } + + /// The cookie with the given identifier. + func cookie(for identifier: String) -> Cookie? { + + return cookies.first { $0.identifier == identifier } + } +} + +internal extension DBusKeyring { + + /// The directory keyrings live in, relative to the home directory. + static let directoryName = ".dbus-keyrings" + + /// Load the keyring for a context. + /// + /// - Parameter context: The cookie context named by the server. Validated, because it + /// arrives from the peer and is used to build a path. + static func load(context: String, + homeDirectory: String? = ProcessEnvironment.homeDirectory) throws -> DBusKeyring { + + guard isValidContext(context) + else { throw DBusProtocolError.authenticationFailed("Invalid cookie context '\(context)'") } + + guard let home = homeDirectory + else { throw DBusProtocolError.authenticationFailed("No home directory for the keyring") } + + let path = "\(home)/\(directoryName)/\(context)" + + guard let contents = readFile(path) + else { throw DBusProtocolError.authenticationFailed("Could not read the keyring at \(path)") } + + return DBusKeyring(cookies: parse(contents)) + } + + /// Whether a cookie context is safe to use as a path component. + /// + /// The specification restricts contexts to `[A-Za-z0-9_-]`, which also keeps a hostile + /// server from escaping the keyring directory with `../`. + static func isValidContext(_ context: String) -> Bool { + + guard context.isEmpty == false, context.utf8.count <= 255 + else { return false } + + return context.utf8.allSatisfy { $0.isBusNameElementByte } + } + + static func parse(_ contents: String) -> [Cookie] { + + var cookies = [Cookie]() + + for line in contents.split(separator: "\n", omittingEmptySubsequences: true) { + + let fields = line.split(separator: " ", omittingEmptySubsequences: true) + + guard fields.count >= 3, + let creationTime = UInt64(fields[1]) + else { continue } // skip malformed lines rather than failing the whole keyring + + cookies.append(Cookie(identifier: String(fields[0]), + creationTime: creationTime, + value: String(fields[2]))) + } + + return cookies + } + + private static func readFile(_ path: String) -> String? { + + guard let descriptor = try? FileDescriptor.open(FilePath(path), .readOnly) + else { return nil } + + defer { try? descriptor.close() } + + var contents = [UInt8]() + var buffer = [UInt8](repeating: 0, count: 4096) + + while true { + + guard let count = try? buffer.withUnsafeMutableBytes({ try descriptor.read(into: $0) }) + else { return nil } + + guard count > 0 else { break } + + contents.append(contentsOf: buffer[0 ..< count]) + + // A keyring is small; refuse to read an unbounded file. + guard contents.count <= 64 * 1024 + else { return nil } + } + + return String(validatingUTF8: contents) + } +} + +// MARK: - Challenge + +internal enum DBusCookieChallenge { + + /// Parse the server's challenge, which is ` `. + static func parse(_ decoded: String) throws -> (context: String, identifier: String, challenge: String) { + + let fields = decoded.split(separator: " ", omittingEmptySubsequences: true) + + guard fields.count == 3 + else { throw DBusProtocolError.authenticationFailed("Malformed cookie challenge '\(decoded)'") } + + return (String(fields[0]), String(fields[1]), String(fields[2])) + } + + /// A fresh client challenge, as a hexadecimal string. + static func clientChallenge(byteCount: Int = 16) -> String { + + var generator = SystemRandomNumberGenerator() + + var bytes = [UInt8]() + bytes.reserveCapacity(byteCount) + + for _ in 0 ..< byteCount { + bytes.append(UInt8.random(in: .min ... .max, using: &generator)) + } + + return bytes.hexEncoded + } + + /// The response digest: `SHA1(serverChallenge:clientChallenge:cookie)`, as lowercase hex. + /// + /// - Note: The three components are their *textual* hexadecimal forms, joined with colons, + /// not the bytes they decode to. + static func digest(serverChallenge: String, + clientChallenge: String, + cookie: String) -> String { + + return SHA1.hexDigest("\(serverChallenge):\(clientChallenge):\(cookie)") + } +} diff --git a/Sources/DBus/Auth/SASL.swift b/Sources/DBus/Auth/SASL.swift new file mode 100644 index 0000000..6a1f810 --- /dev/null +++ b/Sources/DBus/Auth/SASL.swift @@ -0,0 +1,366 @@ +// +// SASL.swift +// DBus +// + +/// A SASL authentication mechanism. +/// +/// Reference: https://dbus.freedesktop.org/doc/dbus-specification.html#auth-mechanisms +public enum DBusAuthenticationMechanism: String, Sendable, CaseIterable { + + /// Authenticate using out-of-band credentials, i.e. the peer's uid as reported by the + /// kernel. This is what local Unix socket connections use. + case external = "EXTERNAL" + + /// No authentication. Accepted only by servers configured to allow it. + case anonymous = "ANONYMOUS" + + /// Prove knowledge of a shared secret from the user's keyring, without transmitting it. + /// + /// Used where the peer's credentials cannot be obtained out of band, such as over TCP. + case cookieSHA1 = "DBUS_COOKIE_SHA1" +} + +// MARK: - Commands + +/// A line of the SASL handshake. +/// +/// The handshake is a line protocol: ASCII commands terminated by `\r\n`, exchanged before any +/// D-Bus messages are sent. +internal enum DBusSASLResponse: Equatable { + + /// Authentication succeeded; the argument is the server's GUID. + case ok(String) + + /// Authentication failed; the argument lists the mechanisms the server supports. + case rejected([String]) + + /// The server sent challenge data. + case data(String) + + /// The server encountered an error. + case error(String) + + /// The server agreed to Unix file descriptor passing. + case agreeUnixFD + + init(line: String) throws { + + let parts = line.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true) + + guard let command = parts.first + else { throw DBusProtocolError.authenticationFailed("Empty response") } + + let argument = parts.count > 1 ? String(parts[1]) : "" + + switch command { + case "OK": + self = .ok(argument) + case "REJECTED": + self = .rejected(argument.split(separator: " ").map(String.init)) + case "DATA": + self = .data(argument) + case "ERROR": + self = .error(argument) + case "AGREE_UNIX_FD": + self = .agreeUnixFD + default: + throw DBusProtocolError.authenticationFailed("Unknown response '\(command)'") + } + } +} + +// MARK: - Client + +/// The client side of the SASL handshake, as a state machine. +/// +/// Kept free of I/O so it can be driven by tests without a socket. +internal struct DBusSASLClient { + + enum State: Equatable { + + /// Waiting for the reply to an `AUTH` command. + case authenticating(DBusAuthenticationMechanism) + + /// Waiting for the reply to `NEGOTIATE_UNIX_FD`. + case negotiatingUnixFileDescriptors + + /// `BEGIN` has been sent; the connection carries messages from here on. + case ready + + /// The handshake failed. + case failed + } + + /// Mechanisms to try, in preference order. + private var remaining: [DBusAuthenticationMechanism] + + /// The user ID offered for `EXTERNAL`. + let userID: UInt32 + + /// Whether to ask the server for file descriptor passing. + let negotiateUnixFileDescriptors: Bool + + private(set) var state: State + + /// The server's GUID, once authenticated. + private(set) var serverGUID: String? + + /// Whether the server agreed to file descriptor passing. + private(set) var unixFileDescriptorsSupported = false + + /// The login name offered for `DBUS_COOKIE_SHA1`. + let userName: String + + /// Where to load keyrings from. Injected so tests need not touch the real home directory. + let keyringLoader: @Sendable (String) throws -> DBusKeyring + + /// The client challenge generated for the current cookie exchange, kept for tests. + private(set) var clientChallenge: String? + + init(mechanisms: [DBusAuthenticationMechanism] = [.external, .cookieSHA1, .anonymous], + userID: UInt32, + userName: String = ProcessEnvironment.userName, + negotiateUnixFileDescriptors: Bool = true, + keyringLoader: @escaping @Sendable (String) throws -> DBusKeyring = { try DBusKeyring.load(context: $0) }) { + + precondition(mechanisms.isEmpty == false, "At least one mechanism is required") + + self.remaining = mechanisms + self.userID = userID + self.userName = userName + self.negotiateUnixFileDescriptors = negotiateUnixFileDescriptors + self.keyringLoader = keyringLoader + self.state = .authenticating(mechanisms[0]) + } +} + +internal extension DBusSASLClient { + + /// The bytes to send before anything else. + /// + /// - Note: The leading NUL byte is required by the specification. It is not part of the + /// SASL protocol; historically it carried credentials via `SCM_CREDS`, and on Linux the + /// kernel supplies them through `SO_PEERCRED` instead, but the byte must still be sent. + mutating func start() -> [UInt8] { + + guard case let .authenticating(mechanism) = state + else { preconditionFailure("Already started") } + + return [0x00] + command(auth: mechanism) + } + + /// Advance the state machine with a response from the server. + /// + /// - Returns: The bytes to send in reply, or `nil` if there is nothing to send. + mutating func handle(_ response: DBusSASLResponse) throws -> [UInt8]? { + + switch (state, response) { + + // MARK: Authenticating + + case let (.authenticating, .ok(guid)): + + serverGUID = guid + + if negotiateUnixFileDescriptors { + state = .negotiatingUnixFileDescriptors + return line("NEGOTIATE_UNIX_FD") + } else { + state = .ready + return line("BEGIN") + } + + case let (.authenticating(mechanism), .rejected(offered)): + + // Drop the mechanism that just failed, then keep only those the server offers. + remaining.removeAll { $0 == mechanism } + let supported = remaining.filter { offered.contains($0.rawValue) } + + guard let next = supported.first else { + state = .failed + throw DBusProtocolError.authenticationRejected( + "No supported mechanism; server offered \(offered.joined(separator: ", "))") + } + + state = .authenticating(next) + return command(auth: next) + + case let (.authenticating, .error(message)): + + state = .failed + throw DBusProtocolError.authenticationFailed(message.isEmpty ? "Server error" : message) + + case let (.authenticating(mechanism), .data(hex)): + + // Only DBUS_COOKIE_SHA1 uses a challenge/response exchange. + guard mechanism == .cookieSHA1 else { + state = .failed + return line("CANCEL") + } + + do { + return try cookieResponse(challenge: hex) + } + catch { + // Cancel rather than drop the connection, so the server can offer another + // mechanism; the REJECTED that follows drives the fallback. + state = .authenticating(mechanism) + return line("CANCEL") + } + + // MARK: Negotiating file descriptors + + case (.negotiatingUnixFileDescriptors, .agreeUnixFD): + + unixFileDescriptorsSupported = true + state = .ready + return line("BEGIN") + + case (.negotiatingUnixFileDescriptors, .error): + + // A server that does not support fd passing answers ERROR. That is not fatal. + unixFileDescriptorsSupported = false + state = .ready + return line("BEGIN") + + // MARK: Anything else + + default: + state = .failed + throw DBusProtocolError.authenticationFailed("Unexpected response \(response) in state \(state)") + } + } + + /// Whether the handshake has completed and messages may now be exchanged. + var isReady: Bool { + + return state == .ready + } +} + +private extension DBusSASLClient { + + func command(auth mechanism: DBusAuthenticationMechanism) -> [UInt8] { + + switch mechanism { + + case .external: + // The credential is the uid written as ASCII decimal, then hex encoded: + // uid 1000 is "1000", which is sent as "31303030". + return line("AUTH EXTERNAL \(String(userID).hexEncodedASCII)") + + case .anonymous: + // The trace string is optional and purely informational. + return line("AUTH ANONYMOUS \("DBus".hexEncodedASCII)") + + case .cookieSHA1: + // The server uses the login name to find the keyring holding the shared secret. + return line("AUTH DBUS_COOKIE_SHA1 \(userName.hexEncodedASCII)") + } + } + + /// Answer a `DBUS_COOKIE_SHA1` challenge. + /// + /// The server sends ` `, hex encoded. The reply is + /// ` `, also hex encoded, where the digest proves knowledge of + /// the cookie without sending it. + private mutating func cookieResponse(challenge hex: String) throws -> [UInt8] { + + guard let decoded = hex.hexDecodedString + else { throw DBusProtocolError.authenticationFailed("Cookie challenge is not valid hex") } + + let (context, identifier, serverChallenge) = try DBusCookieChallenge.parse(decoded) + + let keyring = try keyringLoader(context) + + guard let cookie = keyring.cookie(for: identifier) + else { throw DBusProtocolError.authenticationFailed("No cookie \(identifier) in context \(context)") } + + let clientChallenge = DBusCookieChallenge.clientChallenge() + self.clientChallenge = clientChallenge + + let digest = DBusCookieChallenge.digest(serverChallenge: serverChallenge, + clientChallenge: clientChallenge, + cookie: cookie.value) + + return line("DATA \("\(clientChallenge) \(digest)".hexEncodedASCII)") + } + + func line(_ string: String) -> [UInt8] { + + return Swift.Array(string.utf8) + [0x0D, 0x0A] // CR LF + } +} + +// MARK: - Hex + +internal extension String { + + /// The UTF-8 bytes of the string, hex encoded as uppercase ASCII. + /// + /// SASL sends binary data as hexadecimal text. + var hexEncodedASCII: String { + + let digits = Swift.Array("0123456789abcdef".utf8) + + var bytes = [UInt8]() + bytes.reserveCapacity(utf8.count * 2) + + for byte in utf8 { + bytes.append(digits[Int(byte >> 4)]) + bytes.append(digits[Int(byte & 0x0F)]) + } + + return String(decoding: bytes, as: UTF8.self) + } +} + +// MARK: - Line Buffer + +/// Splits a stream of bytes into `\r\n` terminated SASL lines. +internal struct DBusSASLLineBuffer { + + private var bytes: [UInt8] = [] + + /// The maximum length of a single line, to bound memory use against a hostile peer. + static let maximumLineLength = 8192 + + mutating func append(_ newBytes: S) where S.Element == UInt8 { + + bytes.append(contentsOf: newBytes) + } + + /// Remove and return the next complete line, if one is available. + mutating func next() throws -> String? { + + // Find CR LF. + var index = 0 + while index + 1 < bytes.count { + + if bytes[index] == 0x0D, bytes[index + 1] == 0x0A { + + let lineBytes = Swift.Array(bytes[0 ..< index]) + bytes.removeFirst(index + 2) + + guard let line = String(validatingUTF8: lineBytes) + else { throw DBusProtocolError.authenticationFailed("Response is not valid UTF-8") } + + return line + } + + index += 1 + } + + guard bytes.count <= Self.maximumLineLength + else { throw DBusProtocolError.authenticationFailed("Response line is too long") } + + return nil + } + + /// Bytes received after the last complete line, which belong to the message stream. + var remainder: [UInt8] { + + return bytes + } +} diff --git a/Sources/DBus/Auth/SHA1.swift b/Sources/DBus/Auth/SHA1.swift new file mode 100644 index 0000000..779f6f9 --- /dev/null +++ b/Sources/DBus/Auth/SHA1.swift @@ -0,0 +1,216 @@ +// +// SHA1.swift +// DBus +// + +/// SHA-1, as required by the `DBUS_COOKIE_SHA1` authentication mechanism. +/// +/// - Warning: SHA-1 is not collision resistant and must not be used for anything else. It is +/// here only because the D-Bus specification mandates it for this one mechanism. +/// +/// Reference: RFC 3174. +internal struct SHA1 { + + private var state: (UInt32, UInt32, UInt32, UInt32, UInt32) = ( + 0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0 + ) + + /// Bytes not yet consumed by a full 64 byte block. + private var buffer = [UInt8]() + + /// Total message length in bytes, which is appended as a bit count at the end. + private var length = 0 + + init() { + + buffer.reserveCapacity(64) + } + + /// The digest of a byte sequence. + static func hash(_ bytes: S) -> [UInt8] where S.Element == UInt8 { + + var sha1 = SHA1() + sha1.update(bytes) + return sha1.finalize() + } + + /// The digest of a string's UTF-8 bytes, as lowercase hexadecimal. + static func hexDigest(_ string: String) -> String { + + return hash(Array(string.utf8)).hexEncoded + } + + mutating func update(_ bytes: S) where S.Element == UInt8 { + + for byte in bytes { + + buffer.append(byte) + length += 1 + + if buffer.count == 64 { + process(buffer) + buffer.removeAll(keepingCapacity: true) + } + } + } + + mutating func finalize() -> [UInt8] { + + // Append 0x80, then zeros, until 8 bytes short of a block boundary, then the bit count. + let bitLength = UInt64(length) * 8 + + update([0x80]) + + while buffer.count != 56 { + update([0x00]) + } + + // `update` counts these toward `length`, which no longer matters: the bit count was + // captured before padding began. + var lengthBytes = [UInt8]() + for shift in stride(from: 56, through: 0, by: -8) { + lengthBytes.append(UInt8(truncatingIfNeeded: bitLength >> UInt64(shift))) + } + update(lengthBytes) + + var digest = [UInt8]() + digest.reserveCapacity(20) + + for word in [state.0, state.1, state.2, state.3, state.4] { + digest.append(UInt8(truncatingIfNeeded: word >> 24)) + digest.append(UInt8(truncatingIfNeeded: word >> 16)) + digest.append(UInt8(truncatingIfNeeded: word >> 8)) + digest.append(UInt8(truncatingIfNeeded: word)) + } + + return digest + } + + private mutating func process(_ block: [UInt8]) { + + assert(block.count == 64) + + var w = [UInt32](repeating: 0, count: 80) + + for index in 0 ..< 16 { + let offset = index * 4 + w[index] = UInt32(block[offset]) << 24 + | UInt32(block[offset + 1]) << 16 + | UInt32(block[offset + 2]) << 8 + | UInt32(block[offset + 3]) + } + + for index in 16 ..< 80 { + w[index] = rotateLeft(w[index - 3] ^ w[index - 8] ^ w[index - 14] ^ w[index - 16], 1) + } + + var (a, b, c, d, e) = state + + for index in 0 ..< 80 { + + let f: UInt32 + let k: UInt32 + + switch index { + case 0 ..< 20: + f = (b & c) | (~b & d) + k = 0x5A827999 + case 20 ..< 40: + f = b ^ c ^ d + k = 0x6ED9EBA1 + case 40 ..< 60: + f = (b & c) | (b & d) | (c & d) + k = 0x8F1BBCDC + default: + f = b ^ c ^ d + k = 0xCA62C1D6 + } + + let temp = rotateLeft(a, 5) &+ f &+ e &+ k &+ w[index] + e = d + d = c + c = rotateLeft(b, 30) + b = a + a = temp + } + + state = (state.0 &+ a, state.1 &+ b, state.2 &+ c, state.3 &+ d, state.4 &+ e) + } + + private func rotateLeft(_ value: UInt32, _ amount: UInt32) -> UInt32 { + + return (value << amount) | (value >> (32 - amount)) + } +} + +// MARK: - Hex + +internal extension Collection where Element == UInt8 { + + /// The bytes as lowercase hexadecimal. + var hexEncoded: String { + + let digits = Array("0123456789abcdef".utf8) + + var output = [UInt8]() + output.reserveCapacity(count * 2) + + for byte in self { + output.append(digits[Int(byte >> 4)]) + output.append(digits[Int(byte & 0x0F)]) + } + + return String(decoding: output, as: UTF8.self) + } +} + +internal extension String { + + /// Decode a hexadecimal string into bytes, or `nil` if it is malformed. + var hexDecoded: [UInt8]? { + + let characters = Array(utf8) + + guard characters.count % 2 == 0 + else { return nil } + + var bytes = [UInt8]() + bytes.reserveCapacity(characters.count / 2) + + var index = 0 + while index < characters.count { + + guard let high = characters[index].hexDigitValue, + let low = characters[index + 1].hexDigitValue + else { return nil } + + bytes.append(high << 4 | low) + index += 2 + } + + return bytes + } + + /// Decode a hexadecimal string into a UTF-8 string. + var hexDecodedString: String? { + + guard let bytes = hexDecoded + else { return nil } + + return String(validatingUTF8: bytes) + } +} + +internal extension UInt8 { + + /// The numeric value of an ASCII hexadecimal digit. + var hexDigitValue: UInt8? { + + switch self { + case 0x30 ... 0x39: return self - 0x30 // 0-9 + case 0x41 ... 0x46: return self - 0x41 + 10 // A-F + case 0x61 ... 0x66: return self - 0x61 + 10 // a-f + default: return nil + } + } +} diff --git a/Sources/DBus/Boolean.swift b/Sources/DBus/Boolean.swift deleted file mode 100644 index c9eb7cd..0000000 --- a/Sources/DBus/Boolean.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// Boolean.swift -// DBus -// -// Created by Alsey Coleman Miller on 2/25/16. -// Copyright © 2016 PureSwift. All rights reserved. -// - -import CDBus - -public extension Bool { - - init(_ boolValue: dbus_bool_t) { - - self = boolValue != 0 - } -} - -public extension dbus_bool_t { - - init(_ boolValue: Bool) { - - self = boolValue ? 1 : 0 - } -} - -extension dbus_bool_t: ExpressibleByBooleanLiteral { - - public init(booleanLiteral value: Bool) { - - self.init(value) - } -} diff --git a/Sources/DBus/Bus/BusAPI.swift b/Sources/DBus/Bus/BusAPI.swift new file mode 100644 index 0000000..b3f62f5 --- /dev/null +++ b/Sources/DBus/Bus/BusAPI.swift @@ -0,0 +1,351 @@ +// +// BusAPI.swift +// DBus +// + +/// Well known names, paths and interfaces of the message bus itself. +public enum DBusWellKnown { + + /// The bus daemon's well known name, `org.freedesktop.DBus`. + public static let busName = DBusBusName("org.freedesktop.DBus") + + /// The bus daemon's object path, `/org/freedesktop/DBus`. + public static let busPath = DBusObjectPath("/org/freedesktop/DBus") + + /// The bus daemon's interface, `org.freedesktop.DBus`. + public static let busInterface = DBusInterface(rawValue: "org.freedesktop.DBus")! + + /// `org.freedesktop.DBus.Peer` + public static let peerInterface = DBusInterface(rawValue: "org.freedesktop.DBus.Peer")! + + /// `org.freedesktop.DBus.Introspectable` + public static let introspectableInterface = DBusInterface(rawValue: "org.freedesktop.DBus.Introspectable")! + + /// `org.freedesktop.DBus.Properties` + public static let propertiesInterface = DBusInterface(rawValue: "org.freedesktop.DBus.Properties")! +} + +// MARK: - Calling + +public extension DBusConnection { + + /// Invoke a method and return the reply's arguments. + /// + /// - Throws: The `DBusError` carried by an error reply. + @discardableResult + func callMethod(destination: DBusBusName?, + path: DBusObjectPath, + interface: DBusInterface?, + method: DBusMember, + arguments: [DBusMessageArgument] = [], + timeout: Duration? = DBusConnection.defaultTimeout) async throws -> [DBusMessageArgument] { + + let call = DBusMessage.MethodCall(destination: destination, + path: path, + interface: interface, + method: method) + + let reply = try await send(DBusMessage(methodCall: call, arguments: arguments), timeout: timeout) + + return reply.arguments + } + + /// Invoke a method on the bus daemon itself. + @discardableResult + internal func callBus(_ method: String, + arguments: [DBusMessageArgument] = []) async throws -> [DBusMessageArgument] { + + return try await callMethod(destination: DBusWellKnown.busName, + path: DBusWellKnown.busPath, + interface: DBusWellKnown.busInterface, + method: DBusMember(method), + arguments: arguments) + } +} + +// MARK: - Name Registration + +public extension DBusConnection { + + /// Flags controlling how a name is requested. + struct RequestNameFlags: OptionSet, Equatable, Hashable, Sendable { + + public var rawValue: UInt32 + + public init(rawValue: UInt32) { + + self.rawValue = rawValue + } + + /// If another connection already owns the name and has set `allowReplacement`, + /// take ownership from it. + public static let allowReplacement = RequestNameFlags(rawValue: 0x01) + + /// Allow another connection that sets `allowReplacement` to take the name from us. + public static let replaceExisting = RequestNameFlags(rawValue: 0x02) + + /// Do not place the request in the queue if the name is already owned; fail instead. + public static let doNotQueue = RequestNameFlags(rawValue: 0x04) + } + + /// The outcome of requesting a name. + enum RequestNameResult: UInt32, Sendable { + + /// The caller is now the primary owner of the name. + case primaryOwner = 1 + + /// The name is already owned and the caller has been placed in the queue. + case inQueue = 2 + + /// The name is already owned and `doNotQueue` was set. + case exists = 3 + + /// The caller already owns the name. + case alreadyOwner = 4 + } + + /// The outcome of releasing a name. + enum ReleaseNameResult: UInt32, Sendable { + + /// The name was released. + case released = 1 + + /// No such name exists on the bus. + case nonExistent = 2 + + /// The name exists but the caller was neither its owner nor in its queue. + case notOwner = 3 + } + + /// Ask the bus to assign the given well known name to this connection. + @discardableResult + func requestName(_ name: DBusBusName, + flags: RequestNameFlags = [.doNotQueue]) async throws -> RequestNameResult { + + let reply = try await callBus("RequestName", + arguments: [.string(name.rawValue), .uint32(flags.rawValue)]) + + guard case let .uint32(rawValue)? = reply.first, + let result = RequestNameResult(rawValue: rawValue) + else { throw DBusProtocolError.invalidValue("Unexpected RequestName reply \(reply)") } + + return result + } + + /// Give up a well known name. + @discardableResult + func releaseName(_ name: DBusBusName) async throws -> ReleaseNameResult { + + let reply = try await callBus("ReleaseName", arguments: [.string(name.rawValue)]) + + guard case let .uint32(rawValue)? = reply.first, + let result = ReleaseNameResult(rawValue: rawValue) + else { throw DBusProtocolError.invalidValue("Unexpected ReleaseName reply \(reply)") } + + return result + } +} + +// MARK: - Name Queries + +public extension DBusConnection { + + /// Every name currently visible on the bus, unique and well known alike. + func listNames() async throws -> [String] { + + return try stringArray(from: await callBus("ListNames"), method: "ListNames") + } + + /// Names that can be activated on demand, whether or not they are currently owned. + func listActivatableNames() async throws -> [String] { + + return try stringArray(from: await callBus("ListActivatableNames"), + method: "ListActivatableNames") + } + + /// Whether the given name currently has an owner. + func nameHasOwner(_ name: DBusBusName) async throws -> Bool { + + let reply = try await callBus("NameHasOwner", arguments: [.string(name.rawValue)]) + + guard case let .boolean(value)? = reply.first + else { throw DBusProtocolError.invalidValue("Unexpected NameHasOwner reply \(reply)") } + + return value + } + + /// The unique name of the connection that owns the given name. + /// + /// - Throws: `org.freedesktop.DBus.Error.NameHasNoOwner` if the name is unowned. + func getNameOwner(_ name: DBusBusName) async throws -> DBusBusName { + + let reply = try await callBus("GetNameOwner", arguments: [.string(name.rawValue)]) + + guard case let .string(value)? = reply.first, + let busName = DBusBusName(rawValue: value) + else { throw DBusProtocolError.invalidValue("Unexpected GetNameOwner reply \(reply)") } + + return busName + } + + /// The well known names owned by the given connection. + func listQueuedOwners(_ name: DBusBusName) async throws -> [String] { + + return try stringArray(from: await callBus("ListQueuedOwners", + arguments: [.string(name.rawValue)]), + method: "ListQueuedOwners") + } + + /// The Unix user ID of the connection owning the given name. + func getConnectionUnixUser(_ name: DBusBusName) async throws -> UInt32 { + + let reply = try await callBus("GetConnectionUnixUser", arguments: [.string(name.rawValue)]) + + guard case let .uint32(value)? = reply.first + else { throw DBusProtocolError.invalidValue("Unexpected GetConnectionUnixUser reply \(reply)") } + + return value + } + + /// The process ID of the connection owning the given name. + func getConnectionUnixProcessID(_ name: DBusBusName) async throws -> UInt32 { + + let reply = try await callBus("GetConnectionUnixProcessID", arguments: [.string(name.rawValue)]) + + guard case let .uint32(value)? = reply.first + else { throw DBusProtocolError.invalidValue("Unexpected GetConnectionUnixProcessID reply \(reply)") } + + return value + } + + /// The unique ID of the bus. + func getBusID() async throws -> String { + + let reply = try await callBus("GetId") + + guard case let .string(value)? = reply.first + else { throw DBusProtocolError.invalidValue("Unexpected GetId reply \(reply)") } + + return value + } + + /// Start a service by name, if the bus is configured to activate it. + @discardableResult + func startServiceByName(_ name: DBusBusName, flags: UInt32 = 0) async throws -> UInt32 { + + let reply = try await callBus("StartServiceByName", + arguments: [.string(name.rawValue), .uint32(flags)]) + + guard case let .uint32(value)? = reply.first + else { throw DBusProtocolError.invalidValue("Unexpected StartServiceByName reply \(reply)") } + + return value + } + + private func stringArray(from reply: [DBusMessageArgument], + method: String) throws -> [String] { + + guard case let .array(array)? = reply.first + else { throw DBusProtocolError.invalidValue("Unexpected \(method) reply \(reply)") } + + return try array.map { + guard case let .string(value) = $0 + else { throw DBusProtocolError.invalidValue("\(method) returned a non-string element") } + return value + } + } +} + +// MARK: - Standard Interfaces + +public extension DBusConnection { + + /// Call `org.freedesktop.DBus.Peer.Ping` on a remote object. + func ping(destination: DBusBusName, + path: DBusObjectPath = DBusObjectPath()) async throws { + + try await callMethod(destination: destination, + path: path, + interface: DBusWellKnown.peerInterface, + method: DBusMember("Ping")) + } + + /// Call `org.freedesktop.DBus.Introspectable.Introspect` and return the XML. + func introspect(destination: DBusBusName, + path: DBusObjectPath) async throws -> String { + + let reply = try await callMethod(destination: destination, + path: path, + interface: DBusWellKnown.introspectableInterface, + method: DBusMember("Introspect")) + + guard case let .string(xml)? = reply.first + else { throw DBusProtocolError.invalidValue("Introspect did not return a string") } + + return xml + } + + /// Call `org.freedesktop.DBus.Properties.Get`, returning the value inside the variant. + func getProperty(destination: DBusBusName, + path: DBusObjectPath, + interface: DBusInterface, + name: String) async throws -> DBusMessageArgument { + + let reply = try await callMethod(destination: destination, + path: path, + interface: DBusWellKnown.propertiesInterface, + method: DBusMember("Get"), + arguments: [.string(interface.rawValue), .string(name)]) + + guard let value = reply.first?.variantValue + else { throw DBusProtocolError.invalidValue("Get did not return a variant") } + + return value + } + + /// Call `org.freedesktop.DBus.Properties.Set`. + func setProperty(destination: DBusBusName, + path: DBusObjectPath, + interface: DBusInterface, + name: String, + value: DBusMessageArgument) async throws { + + try await callMethod(destination: destination, + path: path, + interface: DBusWellKnown.propertiesInterface, + method: DBusMember("Set"), + arguments: [ + .string(interface.rawValue), + .string(name), + .variant(DBusMessageArgument.Variant(value)) + ]) + } + + /// Call `org.freedesktop.DBus.Properties.GetAll`, unwrapping each variant. + func getAllProperties(destination: DBusBusName, + path: DBusObjectPath, + interface: DBusInterface) async throws -> [String: DBusMessageArgument] { + + let reply = try await callMethod(destination: destination, + path: path, + interface: DBusWellKnown.propertiesInterface, + method: DBusMember("GetAll"), + arguments: [.string(interface.rawValue)]) + + guard case let .dictionary(dictionary)? = reply.first + else { throw DBusProtocolError.invalidValue("GetAll did not return a dictionary") } + + var properties = [String: DBusMessageArgument]() + + for entry in dictionary { + + guard case let .string(key) = entry.key, + let value = entry.value.variantValue + else { throw DBusProtocolError.invalidValue("GetAll returned an unexpected entry") } + + properties[key] = value + } + + return properties + } +} diff --git a/Sources/DBus/Bus/DBusMatchRule.swift b/Sources/DBus/Bus/DBusMatchRule.swift new file mode 100644 index 0000000..a76f44e --- /dev/null +++ b/Sources/DBus/Bus/DBusMatchRule.swift @@ -0,0 +1,368 @@ +// +// DBusMatchRule.swift +// DBus +// + +/// A rule describing which messages a connection wants the bus to deliver to it. +/// +/// A connection receives no broadcast signals until it installs at least one match rule with +/// `AddMatch`. Every non-nil field must match for a message to be delivered; fields left `nil` +/// match anything. +/// +/// Reference: https://dbus.freedesktop.org/doc/dbus-specification.html#message-bus-routing-match-rules +public struct DBusMatchRule: Equatable, Hashable, Sendable { + + /// The type of message to match. + public var type: DBusMessageType? + + /// The name of the connection that sent the message. + /// + /// - Note: The bus rewrites the `sender` header field to the unique name of the sender, so a + /// rule naming a well-known name is resolved by the bus at match time. Local matching + /// therefore cannot reproduce it; see `matches(_:)`. + public var sender: DBusBusName? + + /// The interface of the message. + public var interface: DBusInterface? + + /// The member (method or signal name) of the message. + public var member: DBusMember? + + /// The object path of the message. + public var path: DBusObjectPath? + + /// Matches messages whose path is this path or a child of it. + /// + /// - Note: Mutually exclusive with `path`. + public var pathNamespace: DBusObjectPath? + + /// The destination the message is addressed to. + public var destination: DBusBusName? + + /// String arguments to match, by position. + /// + /// Only the first 64 arguments can be matched, and only string-like arguments. + public var arguments: [Int: String] + + /// Matches when argument 0 is this namespace or a name within it. + public var argument0Namespace: String? + + /// Matches when argument 0 is an object path equal to, or a child of, this path. + public var argument0Path: String? + + /// Whether to receive messages not addressed to this connection. + /// + /// - Note: Requires the bus to be configured to permit eavesdropping, and is refused on a + /// stock system bus. + public var eavesdrop: Bool? + + public init(type: DBusMessageType? = nil, + sender: DBusBusName? = nil, + interface: DBusInterface? = nil, + member: DBusMember? = nil, + path: DBusObjectPath? = nil, + pathNamespace: DBusObjectPath? = nil, + destination: DBusBusName? = nil, + arguments: [Int: String] = [:], + argument0Namespace: String? = nil, + argument0Path: String? = nil, + eavesdrop: Bool? = nil) { + + self.type = type + self.sender = sender + self.interface = interface + self.member = member + self.path = path + self.pathNamespace = pathNamespace + self.destination = destination + self.arguments = arguments + self.argument0Namespace = argument0Namespace + self.argument0Path = argument0Path + self.eavesdrop = eavesdrop + } +} + +// MARK: - Constants + +public extension DBusMatchRule { + + /// The highest argument index a match rule can reference. + static let maximumArgumentIndex = 63 +} + +// MARK: - Convenience + +public extension DBusMatchRule { + + /// A rule matching signals, optionally narrowed by interface, member and path. + static func signal(interface: DBusInterface? = nil, + member: DBusMember? = nil, + path: DBusObjectPath? = nil, + sender: DBusBusName? = nil) -> DBusMatchRule { + + return DBusMatchRule(type: .signal, + sender: sender, + interface: interface, + member: member, + path: path) + } + + /// A rule matching `org.freedesktop.DBus.NameOwnerChanged` for a particular name. + /// + /// Argument 0 of that signal is the name whose ownership changed. + static func nameOwnerChanged(name: DBusBusName? = nil) -> DBusMatchRule { + + var rule = DBusMatchRule.signal( + interface: DBusInterface(rawValue: "org.freedesktop.DBus")!, + member: DBusMember(rawValue: "NameOwnerChanged")!, + path: DBusObjectPath(rawValue: "/org/freedesktop/DBus")!, + sender: DBusBusName(rawValue: "org.freedesktop.DBus")! + ) + + if let name = name { + rule.arguments[0] = name.rawValue + } + + return rule + } + + /// A rule matching `org.freedesktop.DBus.Properties.PropertiesChanged`. + static func propertiesChanged(interface: DBusInterface? = nil, + path: DBusObjectPath? = nil, + sender: DBusBusName? = nil) -> DBusMatchRule { + + var rule = DBusMatchRule.signal( + interface: DBusInterface(rawValue: "org.freedesktop.DBus.Properties")!, + member: DBusMember(rawValue: "PropertiesChanged")!, + path: path, + sender: sender + ) + + // Argument 0 of PropertiesChanged is the interface whose properties changed. + if let interface = interface { + rule.arguments[0] = interface.rawValue + } + + return rule + } +} + +// MARK: - String Encoding + +extension DBusMatchRule: RawRepresentable { + + /// The rule in the comma-separated `key='value'` form `AddMatch` expects. + public var rawValue: String { + + var components = [String]() + + if let type = type { + components.append("type=\(DBusMatchRule.escape(type.matchRuleName))") + } + + if let sender = sender { + components.append("sender=\(DBusMatchRule.escape(sender.rawValue))") + } + + if let interface = interface { + components.append("interface=\(DBusMatchRule.escape(interface.rawValue))") + } + + if let member = member { + components.append("member=\(DBusMatchRule.escape(member.rawValue))") + } + + if let path = path { + components.append("path=\(DBusMatchRule.escape(path.rawValue))") + } + + if let pathNamespace = pathNamespace { + components.append("path_namespace=\(DBusMatchRule.escape(pathNamespace.rawValue))") + } + + if let destination = destination { + components.append("destination=\(DBusMatchRule.escape(destination.rawValue))") + } + + // Sorted so that the encoding is deterministic, which matters because the rule string + // is the key used to reference count `AddMatch` and `RemoveMatch`. + for index in arguments.keys.sorted() { + components.append("arg\(index)=\(DBusMatchRule.escape(arguments[index]!))") + } + + if let argument0Namespace = argument0Namespace { + components.append("arg0namespace=\(DBusMatchRule.escape(argument0Namespace))") + } + + if let argument0Path = argument0Path { + components.append("arg0path=\(DBusMatchRule.escape(argument0Path))") + } + + if let eavesdrop = eavesdrop { + components.append("eavesdrop=\(DBusMatchRule.escape(eavesdrop ? "true" : "false"))") + } + + return components.joined(separator: ",") + } + + /// - Note: Parsing a rule back from its string form is not implemented; rules are built + /// from their fields. This initializer exists only to satisfy `RawRepresentable` and + /// always returns `nil`. + public init?(rawValue: String) { + + return nil + } +} + +internal extension DBusMatchRule { + + /// Quote a value for inclusion in a match rule. + /// + /// Values are single quoted. A literal apostrophe cannot appear inside single quotes, so it + /// is written by closing the quote, emitting an escaped apostrophe, and reopening: + /// `it's` becomes `'it'\''s'`. + static func escape(_ value: String) -> String { + + var result = "'" + + for character in value { + if character == "'" { + result += "'\\''" + } else { + result.append(character) + } + } + + result += "'" + + return result + } +} + +internal extension DBusMessageType { + + /// The name used for this type in a match rule. + var matchRuleName: String { + + switch self { + case .methodCall: return "method_call" + case .methodReturn: return "method_return" + case .error: return "error" + case .signal: return "signal" + } + } +} + +// MARK: - Local Matching + +public extension DBusMatchRule { + + /// Whether a message satisfies this rule. + /// + /// The bus delivers the union of every rule a connection has installed, so a connection + /// with several subscriptions must decide locally which one each message belongs to. + /// + /// - Note: `sender` is compared literally. The bus resolves a well-known name in a rule to + /// the unique name that owns it, but a received message carries only the unique name, so a + /// rule written with a well-known sender will not match here. Match on `interface` and + /// `member` instead when routing locally. + func matches(_ message: DBusMessage) -> Bool { + + if let type = type, message.type != type { + return false + } + + if let sender = sender, message.sender != sender { + return false + } + + if let interface = interface, message.interface != interface { + return false + } + + if let member = member, message.member != member { + return false + } + + if let path = path, message.path != path { + return false + } + + if let pathNamespace = pathNamespace { + + guard let messagePath = message.path, + messagePath.isEqualToOrDescendant(of: pathNamespace) + else { return false } + } + + if let destination = destination, message.destination != destination { + return false + } + + for (index, value) in arguments { + + guard index < message.arguments.count, + let argument = message.arguments[index].matchableString, + argument == value + else { return false } + } + + if let namespace = argument0Namespace { + + guard let argument = message.arguments.first?.matchableString, + argument == namespace || argument.hasPrefix(namespace + ".") + else { return false } + } + + if let value = argument0Path { + + // Matches when the argument equals the value, or when either is a path prefix of + // the other and the shorter of the two ends in '/'. + guard let argument = message.arguments.first?.matchableString, + argument == value + || (value.hasSuffix("/") && argument.hasPrefix(value)) + || (argument.hasSuffix("/") && value.hasPrefix(argument)) + else { return false } + } + + return true + } +} + +internal extension DBusMessageArgument { + + /// The value as a string, for argument matching. + /// + /// Only `STRING`, `OBJECT_PATH` and `SIGNATURE` arguments can be matched. + var matchableString: String? { + + switch self { + case let .string(value): return value + case let .objectPath(value): return value.rawValue + case let .signature(value): return value.rawValue + default: return nil + } + } +} + +internal extension DBusObjectPath { + + /// Whether this path is `other`, or nested beneath it. + func isEqualToOrDescendant(of other: DBusObjectPath) -> Bool { + + guard count >= other.count + else { return false } + + return zip(self, other).allSatisfy { $0 == $1 } + } +} + +// MARK: - Description + +extension DBusMatchRule: CustomStringConvertible { + + public var description: String { + + return rawValue + } +} diff --git a/Sources/DBus/Bus/Signals.swift b/Sources/DBus/Bus/Signals.swift new file mode 100644 index 0000000..c408fa3 --- /dev/null +++ b/Sources/DBus/Bus/Signals.swift @@ -0,0 +1,128 @@ +// +// Signals.swift +// DBus +// + +// MARK: - Match Rules + +public extension DBusConnection { + + /// Ask the bus to start delivering messages that satisfy the rule. + /// + /// - Note: The bus reference counts match rules per connection, so adding the same rule + /// twice requires removing it twice. + func addMatch(_ rule: DBusMatchRule) async throws { + + try await callBus("AddMatch", arguments: [.string(rule.rawValue)]) + } + + /// Ask the bus to stop delivering messages that satisfy the rule. + func removeMatch(_ rule: DBusMatchRule) async throws { + + try await callBus("RemoveMatch", arguments: [.string(rule.rawValue)]) + } +} + +// MARK: - Subscriptions + +public extension DBusConnection { + + /// Subscribe to signals matching a rule. + /// + /// Installs the match rule with the bus, then yields every matching message until the + /// stream is cancelled or the connection closes. Cancelling the stream, or simply dropping + /// it, removes the match rule. + /// + /// ```swift + /// let signals = try await connection.signals(matching: .nameOwnerChanged()) + /// for await signal in signals { + /// print(signal.arguments) + /// } + /// ``` + /// + /// - Parameter bufferingPolicy: How many messages to hold if the consumer falls behind. + /// Unbounded by default, so no signal is silently dropped; bound it when subscribing to a + /// high volume rule. + func signals(matching rule: DBusMatchRule, + bufferingPolicy: AsyncStream.Continuation.BufferingPolicy = .unbounded) + async throws -> AsyncStream { + + // Install the rule with the bus first, so a failure surfaces here rather than as a + // stream that silently never yields. + try await addMatchIfNeeded(rule) + + let identifier = nextSubscriptionID() + + let (stream, continuation) = AsyncStream.makeStream(bufferingPolicy: bufferingPolicy) + + continuation.onTermination = { [weak self] _ in + // Runs on whichever task terminated the stream, so hop back onto the actor. + Task { await self?.endSubscription(identifier) } + } + + subscriptions[identifier] = Subscription(rule: rule, continuation: continuation) + + return stream + } + + /// Subscribe to a signal by interface and member. + func signals(interface: DBusInterface, + member: DBusMember? = nil, + path: DBusObjectPath? = nil, + sender: DBusBusName? = nil) async throws -> AsyncStream { + + return try await signals(matching: .signal(interface: interface, + member: member, + path: path, + sender: sender)) + } +} + +// MARK: - Internal + +internal extension DBusConnection { + + /// Install the rule with the bus unless another subscription already did. + func addMatchIfNeeded(_ rule: DBusMatchRule) async throws { + + let key = rule.rawValue + let count = matchRuleCounts[key] ?? 0 + + if count == 0 { + try await addMatch(rule) + } + + matchRuleCounts[key] = count + 1 + } + + /// Tear down a subscription and, if it was the last one using its rule, remove the match. + func endSubscription(_ identifier: UInt64) async { + + guard let subscription = subscriptions.removeValue(forKey: identifier) + else { return } + + let key = subscription.rule.rawValue + let count = matchRuleCounts[key] ?? 0 + + guard count > 1 else { + + matchRuleCounts[key] = nil + + // Best effort: the connection may already be closing, and there is nowhere to + // report a failure to at this point. + if isConnected { + try? await removeMatch(subscription.rule) + } + + return + } + + matchRuleCounts[key] = count - 1 + } + + /// The number of active subscriptions, for tests. + var subscriptionCount: Int { + + return subscriptions.count + } +} diff --git a/Sources/DBus/BusName.swift b/Sources/DBus/BusName.swift index 5fdbb5a..d08b62a 100644 --- a/Sources/DBus/BusName.swift +++ b/Sources/DBus/BusName.swift @@ -5,72 +5,125 @@ // Created by Alsey Coleman Miller on 11/3/18. // -import Foundation -import CDBus - /** Bus names Connections have one or more bus names associated with them. A connection has exactly one bus name that is a unique connection name. The unique connection name remains with the connection for its entire lifetime. A bus name is of type STRING, meaning that it must be valid UTF-8. However, there are also some additional restrictions that apply to bus names specifically: - + * Bus names that start with a colon (':') character are unique connection names. Other bus names are called well-known bus names. - + * Bus names are composed of 1 or more elements separated by a period ('.') character. All elements must contain at least one character. - + * Each element must only contain the ASCII characters "[A-Z][a-z][0-9]_-", with "-" discouraged in new bus names. Only elements that are part of a unique connection name may begin with a digit, elements in other bus names must not begin with a digit. - + * Bus names must contain at least one '.' (period) character (and thus at least two elements). - + * Bus names must not begin with a '.' (period) character. - + * Bus names must not exceed the maximum name length. - + Note that the hyphen ('-') character is allowed in bus names but not in interface names. It is also problematic or not allowed in various specifications and APIs that refer to D-Bus, such as Flatpak application IDs, the DBusActivatable interface in the Desktop Entry Specification, and the convention that an application's "main" interface and object path resemble its bus name. To avoid situations that require special-case handling, it is recommended that new D-Bus names consistently replace hyphens with underscores. - + Like interface names, well-known bus names should start with the reversed DNS domain name of the author of the interface (in lower-case), and it is conventional for the rest of the well-known bus name to consist of words run together, with initial capital letters. As with interface names, including a version number in well-known bus names is a good idea; it's possible to have the well-known bus name for more than one version simultaneously if backwards compatibility is required. - + As with interface names, if the author's DNS domain name contains hyphen/minus characters they should be replaced by underscores, and if it contains leading digits they should be escaped by prepending an underscore. For example, if the owner of 7-zip.org used a D-Bus name for an archiving application, it might be named `org._7_zip.Archiver`. - - If a well-known bus name implies the presence of a "main" interface, that "main" interface is often given the same name as the well-known bus name, and situated at the corresponding object path. For instance, if the owner of example.com is developing a D-Bus API for a music player, they might define that any application that takes the well-known name `com.example.MusicPlayer1` should have an object at the object path `/com/example/MusicPlayer1` which implements the interface `com.example.MusicPlayer1`. */ -public struct DBusBusName: RawRepresentable, Equatable, Hashable { - +public struct DBusBusName: RawRepresentable, Equatable, Hashable, Sendable { + public let rawValue: String - + public init?(rawValue: String) { - + do { try DBusBusName.validate(rawValue) } catch { return nil } - + self.rawValue = rawValue } } -internal extension DBusBusName { - - init(_ unsafe: String) { - - precondition(DBusBusName(rawValue: unsafe) != nil, "Invalid bus name \(unsafe)") - - self.rawValue = unsafe +public extension DBusBusName { + + /// Bus names that start with a colon (':') character are unique connection names. + var isUnique: Bool { + + return rawValue.utf8.first == DBusBusName.uniquePrefix } } internal extension DBusBusName { - + + static let length = (min: 1, max: 255) + + static let separator = ".".first! + + /// ASCII ':' + static let uniquePrefix: UInt8 = 0x3A + + init(_ unsafe: String) { + + guard let value = DBusBusName(rawValue: unsafe) + else { fatalError("Invalid bus name \(unsafe)") } + + self = value + } + static func validate(_ string: String) throws { - - let error = DBusError() - guard Bool(dbus_validate_bus_name(string, &error.internalValue)) - else { throw error } + + guard string.utf8.count >= length.min, + string.utf8.count <= length.max + else { throw DBusError.invalidBusName(string) } + + // Bus names that start with a colon are unique connection names. Only in those may an + // element begin with a digit. + let isUnique = string.utf8.first == uniquePrefix + let body = isUnique ? string.dropFirst() : Substring(string) + + guard body.first != separator, // must not begin with '.' + body.last != separator, // no trailing '.' + body.contains(separator) // at least two elements + else { throw DBusError.invalidBusName(string) } + + let elements = body.split(separator: separator, + maxSplits: .max, + omittingEmptySubsequences: false) + + guard elements.count > 1 + else { throw DBusError.invalidBusName(string) } + + for element in elements { + + let bytes = element.utf8 + + guard let first = bytes.first, + isUnique || first.isASCIIDigit == false, + bytes.allSatisfy({ $0.isBusNameElementByte }) + else { throw DBusError.invalidBusName(string) } + } + } +} + +internal extension UInt8 { + + /// Whether the byte is one of the ASCII characters "[A-Z][a-z][0-9]_-" + var isBusNameElementByte: Bool { + + return isObjectPathElementByte || self == 0x2D // '-' + } +} + +private extension DBusError { + + static func invalidBusName(_ string: String) -> DBusError { + + return DBusError(name: .invalidArguments, message: "Bus name was not valid: '\(string)'") } } // MARK: - CustomStringConvertible extension DBusBusName: CustomStringConvertible { - + public var description: String { - + return rawValue } } diff --git a/Sources/DBus/Connection.swift b/Sources/DBus/Connection.swift index 5ed250f..c5965bb 100644 --- a/Sources/DBus/Connection.swift +++ b/Sources/DBus/Connection.swift @@ -6,349 +6,655 @@ // Copyright © 2016 PureSwift. All rights reserved. // -import CDBus +import Foundation +import Socket -/// Type representing a connection to a remote application and associated incoming/outgoing message queues. +/// A connection to a D-Bus peer, usually a message bus daemon. /// -/// Several methods use the following terms: +/// The connection owns a socket, performs the SASL handshake, and runs a read loop that frames +/// incoming bytes into messages and matches replies to the calls that are waiting for them. /// -/// - **read** means to fill the incoming message queue by reading from the socket. -/// - **write** means to drain the outgoing queue by writing to the socket. -/// - **dispatch** means to drain the incoming queue by invoking application-provided message handlers. -/// -/// The method `readWriteDispatch()` for example does all three of these things, offering a simple alternative to a main loop. -/// -/// In an application with a main loop, the read/write/dispatch operations are usually separate. -public final class DBusConnection { - +/// - Note: An actor rather than a class. All mutable state — the serial counter, the table of +/// outstanding calls, the read buffer — is isolated, so the connection can be shared freely. +public actor DBusConnection { + // MARK: - Properties - - public let shared: Bool - + + /// The unique name the bus assigned to this connection, once `Hello` has completed. + public private(set) var uniqueName: DBusBusName? + + /// The server's GUID, from the SASL handshake. + public private(set) var serverGUID: String? + + /// Whether the server agreed to Unix file descriptor passing. + /// + /// Sending a message containing a `UNIX_FD` argument requires this to be true. + public private(set) var unixFileDescriptorsSupported = false + + /// Whether the connection is still usable. + public private(set) var isConnected = false + // MARK: - Internal Properties - - internal let internalPointer: OpaquePointer - + + private let socket: Socket + + /// The serial to assign to the next outgoing message. + private var lastSerial: UInt32 = 0 + + /// Calls waiting for a reply, keyed by the serial of the call. + private var pendingReplies: [UInt32: CheckedContinuation] = [:] + + /// Serials that have been sent but not yet awaited. + private var outstanding: Set = [] + + /// Replies that arrived before the caller began waiting. + private var earlyReplies: [UInt32: Result] = [:] + + /// Bytes received but not yet framed into a complete message. + private var readBuffer: [UInt8] = [] + + /// Descriptors received but not yet claimed by a framed message. + /// + /// They arrive attached to whichever read delivered their message's bytes, so they are + /// queued in order and handed to messages as those are framed. + private var receivedDescriptors: [Int32] = [] + + /// The task draining the socket. + private var readTask: Task? + + /// Set when the read loop has exited and is no longer touching the socket. + private var isReadLoopFinished = true + + /// Invoked for messages that are not replies and that nothing else consumed. + internal var messageHandler: (@Sendable (DBusMessage) -> Void)? + + /// Active signal subscriptions, keyed by an identifier private to this connection. + internal var subscriptions: [UInt64: Subscription] = [:] + + /// How many subscriptions reference each match rule string. + /// + /// `AddMatch` and `RemoveMatch` are reference counted by the bus per connection, but + /// counting here keeps the number of round trips down and makes removal exact. + internal var matchRuleCounts: [String: Int] = [:] + + /// The identifier to assign to the next subscription. + private var lastSubscriptionID: UInt64 = 0 + + /// Objects exported by this connection, keyed by object path. + internal var exportedObjects: [DBusObjectPath: DBusExportedObject] = [:] + + /// How many bytes to request per read. + private static let readChunkSize = 16 * 1024 + + /// How many 10ms polls `close()` will wait for the read loop to exit. + private static let readLoopShutdownPolls = 50 + + /// The default time to wait for a reply, matching the reference implementation. + public static let defaultTimeout: Duration = .seconds(25) + // MARK: - Initialization - + + private init(socket: Socket) { + + self.socket = socket + } + deinit { - - // Connections created with dbus_connection_open_private() or dbus_bus_get_private() are not kept track of - /// or referenced by libdbus. The creator of these connections is responsible for calling dbus_connection_close() - /// prior to releasing the last reference, if the connection is not already disconnected. - if shared == false { - - self.close() - } - - dbus_connection_unref(internalPointer) + + readTask?.cancel() + } + + /// A live signal subscription. + internal struct Subscription { + + let rule: DBusMatchRule + + let continuation: AsyncStream.Continuation + } + + /// Allocate an identifier for a new subscription. + internal func nextSubscriptionID() -> UInt64 { + + lastSubscriptionID += 1 + return lastSubscriptionID + } +} + +// MARK: - Connecting + +public extension DBusConnection { + + /// Connect to a well known bus. + static func connect(to busType: DBusBusType, + mechanisms: [DBusAuthenticationMechanism] = [.external, .cookieSHA1, .anonymous]) async throws -> DBusConnection { + + return try await connect(to: try DBusAddress.addresses(for: busType), mechanisms: mechanisms) + } + + /// Parse an address string and connect to the first alternative that works. + static func connect(to addressString: String, + mechanisms: [DBusAuthenticationMechanism] = [.external, .cookieSHA1, .anonymous]) async throws -> DBusConnection { + + return try await connect(to: try DBusAddress.parse(addressString), mechanisms: mechanisms) + } + + /// Connect to a bus at a specific Unix socket. + static func connect(to address: DBusUnixSocketAddress, + mechanisms: [DBusAuthenticationMechanism] = [.external, .cookieSHA1, .anonymous]) async throws -> DBusConnection { + + return try await connect(to: .unix(address), mechanisms: mechanisms) } - - /// Gets a connection to a remote address. + + /// Try each address in turn, and within an address each endpoint it resolves to. /// - /// - Parameter address: The address to connect to. - /// - Parameter shared: Whether the connection will be shared by subsequent callers, - /// or a new dedicated connection should be created. - public init(address: String, shared: Bool = true) throws { - - self.shared = shared - - let error = DBusError() - - if shared { - - self.internalPointer = dbus_connection_open(address, &error.internalValue) - - } else { - - self.internalPointer = dbus_connection_open_private(address, &error.internalValue) + /// An address string lists alternatives, and a TCP host can resolve to several addresses; + /// both are tried before giving up, and the last failure is reported. + internal static func connect(to addresses: [DBusAddress], + mechanisms: [DBusAuthenticationMechanism]) async throws -> DBusConnection { + + var lastError: Error = DBusProtocolError.invalidAddress("No addresses") + + for address in addresses { + + let endpoints: [DBusTransportEndpoint] + + do { endpoints = try address.endpoints() } + catch { + lastError = error + continue + } + + for endpoint in endpoints { + + do { return try await connect(to: endpoint, mechanisms: mechanisms) } + catch { lastError = error } + } } - - // check for error - if error.isSet { - + + throw lastError + } + + internal static func connect(to endpoint: DBusTransportEndpoint, + mechanisms: [DBusAuthenticationMechanism]) async throws -> DBusConnection { + + let socket: Socket + let nonce: [UInt8]? + + switch endpoint { + + case let .unix(address): + socket = try await Socket(DBusUnixProtocol.stream) + nonce = nil + do { try await socket.connect(to: address) } + catch { + await socket.close() + throw error + } + + case let .tcp(.ipv4(address), tcpNonce): + socket = try await Socket(IPv4Protocol.tcp) + nonce = tcpNonce + do { try await socket.connect(to: address) } + catch { + await socket.close() + throw error + } + + case let .tcp(.ipv6(address), tcpNonce): + socket = try await Socket(IPv6Protocol.tcp) + nonce = tcpNonce + do { try await socket.connect(to: address) } + catch { + await socket.close() + throw error + } + } + + let connection = DBusConnection(socket: socket) + + do { + try await connection.handshake(mechanisms: mechanisms, nonce: nonce) + try await connection.hello() + } + catch { + await connection.close() throw error } + + return connection } - - /// Connects to a bus daemon and registers the client with it. +} + +// MARK: - Handshake + +internal extension DBusConnection { + + /// Run the SASL handshake, then start the read loop. /// - /// - Parameter busType: Bus type. - /// - Parameter shared: Whether the connection will be shared by subsequent callers, - /// or a new dedicated connection should be created. - public init(busType: DBusBusType, shared: Bool = true) throws { - - self.shared = shared - - let error = DBusError() - - let internalBusType = CDBus.DBusBusType(rawValue: busType.rawValue) - - if shared { - - self.internalPointer = dbus_bus_get(internalBusType, &error.internalValue) - - } else { - - self.internalPointer = dbus_bus_get_private(internalBusType, &error.internalValue) + /// - Parameter nonce: For the `nonce-tcp` transport, the bytes read from the server's nonce + /// file. They are sent before anything else, including the SASL NUL byte. + func handshake(mechanisms: [DBusAuthenticationMechanism], + nonce: [UInt8]? = nil) async throws { + + if let nonce = nonce { + try await writeAll(nonce) } - - // check for error - if error.isSet { - - throw error + + var client = DBusSASLClient(mechanisms: mechanisms, userID: ProcessEnvironment.userID) + var buffer = DBusSASLLineBuffer() + + try await writeAll(client.start()) + + while client.isReady == false { + + // Drain any lines already buffered before reading more. + if let line = try buffer.next() { + + let response = try DBusSASLResponse(line: line) + + if let reply = try client.handle(response) { + try await writeAll(reply) + } + + continue + } + + let data = try await socket.read(DBusConnection.readChunkSize) + + guard data.isEmpty == false + else { throw DBusProtocolError.endOfStream } + + buffer.append(data) } + + serverGUID = client.serverGUID + unixFileDescriptorsSupported = client.unixFileDescriptorsSupported + + // Bytes after the final CRLF are the first bytes of the message stream. + readBuffer = buffer.remainder + isConnected = true + + startReading() } - - // MARK: - Class Methods - - /// This method sets a global flag for whether `dbus_connection_new()` will set `SIGPIPE` behavior to `SIG_IGN`. - static func setChangeSIGPIPE(change: Bool) { - - dbus_connection_set_change_sigpipe(dbus_bool_t(change)) + + /// Send `Hello` to obtain a unique name, which every connection must do first. + func hello() async throws { + + let call = DBusMessage.MethodCall( + destination: DBusBusName(rawValue: "org.freedesktop.DBus")!, + path: DBusObjectPath(rawValue: "/org/freedesktop/DBus")!, + interface: DBusInterface(rawValue: "org.freedesktop.DBus")!, + method: DBusMember(rawValue: "Hello")! + ) + + let reply = try await send(DBusMessage(methodCall: call)) + + guard case let .string(name)? = reply.arguments.first, + let busName = DBusBusName(rawValue: name) + else { throw DBusProtocolError.invalidValue("Hello did not return a unique name") } + + uniqueName = busName } - - // MARK: - Methods - - /// As long as the connection is open, this function will block until it can read or write, - /// then read or write, then return `true`. - /// - /// If the connection is closed, the function returns `false`. +} + +// MARK: - Sending + +public extension DBusConnection { + + /// Send a method call and wait for its reply. /// - /// - Note: Even after disconnection, messages may remain in the incoming queue that need to be processed. - public func readWrite(timeout: Int = Int(DBUS_TIMEOUT_USE_DEFAULT)) -> Bool { - - return Bool(dbus_connection_read_write(internalPointer, CInt(timeout))) + /// - Throws: The `DBusError` carried by an error reply, or `DBusProtocolError` if the + /// connection fails or the reply does not arrive within `timeout`. + func send(_ message: DBusMessage, + timeout: Duration? = DBusConnection.defaultTimeout) async throws -> DBusMessage { + + let serial = try await transmit(message) + + return try await awaitReply(serial: serial, timeout: timeout) } - - /// If there are messages to dispatch, this method will call `DBusConnection.dispatch()` once, and return. - /// If there are no messages to dispatch, this function will block until it can read or write, then read or write, then return. - /// - /// The way to think of this function is that it either makes some sort of progress, or it blocks. - /// Note that, while it is blocked on I/O, it cannot be interrupted (even by other threads), - /// which makes this function unsuitable for applications that do more than just react to received messages. + + /// Send a message without waiting for a reply. /// - /// - Returns: The return value indicates whether the disconnect message has been processed, - /// NOT whether the connection is connected. - /// This is important because even after disconnecting, you want to process any messages you received prior to the disconnect. - public func readWriteDispatch(timeout: Int = Int(DBUS_TIMEOUT_USE_DEFAULT)) -> Bool { - - return Bool(dbus_connection_read_write_dispatch(internalPointer, CInt(timeout))) + /// - Returns: The serial assigned to the message. + @discardableResult + func send(oneWay message: DBusMessage) async throws -> UInt32 { + + var message = message + message.flags.insert(.noReplyExpected) + + return try await transmit(message, expectsReply: false) } - - /// Processes any incoming data. + + /// Set the handler invoked for messages that are not replies to outstanding calls. /// - /// If there's incoming raw data that has not yet been parsed, it is parsed, - /// which may or may not result in adding messages to the incoming queue. - public func dispatch() -> DBusDispatchStatus { - - let rawValue = dbus_connection_dispatch(internalPointer).rawValue - - return DBusDispatchStatus(rawValue: rawValue)! + /// - Note: Signals are only delivered once a matching rule has been added with `AddMatch`, + /// which this branch does not yet wrap. + func setMessageHandler(_ handler: (@Sendable (DBusMessage) -> Void)?) { + + self.messageHandler = handler } - - /// Closes a private connection, so no further data can be sent or received. + + /// Close the connection and fail every outstanding call. /// - //// This disconnects the transport (such as a socket) underlying the connection. - public func close() { - - dbus_connection_close(internalPointer) - } - - /// Tests whether a certain type can be sent via the connection. - public func canSend(type: DBusType) -> Bool { - - return Bool(dbus_connection_can_send_type(internalPointer, Int32(type.integerValue))) - } - - /// Adds a message to the outgoing message queue. + /// - Note: Waits, briefly, for the read loop to stop touching the socket. /// - /// Does not block to write the message to the network; that happens asynchronously. - /// To force the message to be written, call `flush()`. + /// Closing frees the file descriptor number immediately while the pending read is aborted + /// asynchronously, so a loop still suspended on the old number can wake after a new + /// connection has been handed the same number. Clearing `isConnected` first stops it + /// issuing another read; this wait then covers the window where it is still inside one. /// - /// - Parameter message: The message to write. - public func send(message: DBusMessage) throws -> dbus_uint32_t { - - var serialNumber: dbus_uint32_t = 0 - - guard Bool(dbus_connection_send(internalPointer, message.internalPointer, &serialNumber)) - else { throw try DBusError(name: DBusError.Name.failed, message: "") } - - return serialNumber - } - - /// Queues a message to send, as with `DBusConnection.send()`, - /// but also returns reply to the message. - public func sendWithReply(message: DBusMessage, timeout: Timeout = .default) throws -> DBusPendingCall? { - - let pendingCallDoublePointer = UnsafeMutablePointer.allocate(capacity: 1) - - // free double pointer - defer { - pendingCallDoublePointer.deinitialize(count: 1) - pendingCallDoublePointer.deallocate() + /// The wait is bounded rather than a plain `await readTask.value`, because `Socket.remove` + /// resumes pending operations from a separate task: a read that registers after that has + /// already run is never resumed, and awaiting it would hang forever. + func close() async { + + guard isConnected || readTask != nil + else { return } // already closed + + isConnected = false + + failAll(with: DBusError(name: .disconnected, message: "The connection was closed")) + + readTask?.cancel() + readTask = nil + + // Closing is what aborts the pending read and lets the loop finish. + await socket.close() + + for _ in 0 ..< DBusConnection.readLoopShutdownPolls { + + if isReadLoopFinished { return } + + try? await Task.sleep(for: .milliseconds(10)) } - - guard Bool(dbus_connection_send_with_reply(internalPointer, message.internalPointer, pendingCallDoublePointer, timeout.rawValue)) - else { throw try DBusError(name: DBusError.Name.failed, message: "No memory") } - - // if the connection is disconnected or you try to send Unix file descriptors on a connection that does not support them, - // the DBusPendingCall will be set to NULL - guard let pendingCallPointer = pendingCallDoublePointer.pointee - else { return nil } - - return DBusPendingCall(pendingCallPointer) - } - - /// Blocks until the outgoing message queue is empty. - public func flush() { - - dbus_connection_flush(internalPointer) - } - - /// Returns the first-received message from the incoming message queue, removing it from the queue. - /// - /// If the queue is empty, returns `nil`. - public func popMessage() -> DBusMessage? { - - guard let messageInternalPointer = dbus_connection_pop_message(internalPointer) - else { return nil } - - let message = DBusMessage(messageInternalPointer) - - return message } - - // MARK: - Dynamic Properties - - /// Returns the first-received message from the incoming message queue, leaving it in the queue. - /// - /// - Note: The message object is only valid for the duration of the block. - public func withFirstMessage(_ block: (DBusMessage?) throws -> Result) rethrows -> Result { - - guard let messageInternalPointer = dbus_connection_borrow_message(internalPointer) - else { return try block(nil) } - - let borrowedMessage = DBusMessage(messageInternalPointer) - - return try block(borrowedMessage) - } - - /// Returns a copy of the first message. - public var firstMessage: DBusMessage? { - - guard let messageInternalPointer = dbus_connection_borrow_message(internalPointer) - else { return nil } - - /// No one can get at the message while its borrowed, so return it as quickly as possible - /// and don't keep a reference to it after returning it. If you need to keep the message, make a copy of it. - let borrowedMessage = DBusMessage(messageInternalPointer) - let message = try? borrowedMessage.copy() - return message +} + +// MARK: - Internal + +private extension DBusConnection { + + /// Assign a serial and write the message. + @discardableResult + func transmit(_ message: DBusMessage, expectsReply: Bool = true) async throws -> UInt32 { + + guard isConnected + else { throw DBusError(name: .disconnected, message: "The connection is not open") } + + var message = message + message.serial = nextSerial() + + let (bytes, descriptors) = try message.encodeWithDescriptors() + + guard descriptors.isEmpty || unixFileDescriptorsSupported + else { throw DBusProtocolError.invalidValue("The peer did not agree to file descriptor passing") } + + // Register before writing: `writeAll` suspends, so the reply can arrive before this + // call resumes. + if expectsReply { + outstanding.insert(message.serial) + } + + do { + try await writeAll(bytes, fileDescriptors: descriptors) + } + catch { + outstanding.remove(message.serial) + earlyReplies[message.serial] = nil + throw error + } + + return message.serial } - - /// Whether the connection is currently open. - public var connected: Bool { - - return Bool(dbus_connection_get_is_connected(internalPointer)) + + /// The next serial. Serials wrap and never take the value zero, which means "unset". + func nextSerial() -> UInt32 { + + lastSerial = lastSerial &+ 1 + + if lastSerial == 0 { + lastSerial = 1 + } + + return lastSerial } - - /// Whether the connection was authenticated. - public var authenticated: Bool { - - return Bool(dbus_connection_get_is_authenticated(internalPointer)) + + func awaitReply(serial: UInt32, timeout: Duration?) async throws -> DBusMessage { + + // A timer that fails the call if no reply arrives. Both this and the read loop run on + // the actor, and each removes the entry before resuming, so the continuation is + // resumed exactly once. + let timeoutTask: Task? = timeout.map { duration in + Task { [weak self] in + try? await Task.sleep(for: duration) + await self?.timeoutReply(serial: serial) + } + } + + defer { timeoutTask?.cancel() } + + let reply: DBusMessage = try await withCheckedThrowingContinuation { continuation in + + if let early = earlyReplies.removeValue(forKey: serial) { + outstanding.remove(serial) + continuation.resume(with: early) + } else { + pendingReplies[serial] = continuation + } + } + + // An error reply is surfaced as a thrown `DBusError`. + if let error = DBusError(message: reply) { + throw error + } + + return reply } - - /// Whether the connection is not authenticated as a specific user. - public var anonymous: Bool { - - return Bool(dbus_connection_get_is_anonymous(internalPointer)) + + func timeoutReply(serial: UInt32) { + + guard let continuation = pendingReplies.removeValue(forKey: serial) + else { return } + + outstanding.remove(serial) + continuation.resume(throwing: DBusError(name: .noReply, + message: "Did not receive a reply within the timeout")) } - - /// Checks whether there are messages in the outgoing message queue. + + /// Write every byte, attaching any descriptors to the first chunk. /// - /// Use `DBusConnection.flush()` to block until all outgoing messages have been written to the underlying transport - /// (such as a socket). - public var hasMessages: Bool { - - return Bool(dbus_connection_has_messages_to_send(internalPointer)) + /// - Note: The descriptors go with the first write because ancillary data is delivered with + /// the byte it accompanies, and the receiver associates them with the message those bytes + /// begin. A message always has at least one byte, which `sendMessage` requires. + func writeAll(_ bytes: [UInt8], fileDescriptors: [Int32] = []) async throws { + + var offset = 0 + var pending = fileDescriptors + + while offset < bytes.count { + + let chunk = Swift.Array(bytes[offset...]) + let written: Int + + if pending.isEmpty { + written = try await socket.write(Data(chunk)) + } else { + let descriptors = pending.map { SocketDescriptor(rawValue: $0) } + written = try await socket.sendMessage(chunk, fileDescriptors: descriptors) + pending = [] + } + + guard written > 0 + else { throw DBusProtocolError.endOfStream } + + offset += written + } } - - /// Gets the ID of the server address we are authenticated to, - /// if this connection is on the client side, - /// or `nil` if the connection is on the server side. - public var serverIdentifier: String? { - - guard let cString = dbus_connection_get_server_id(internalPointer) - else { return nil } - - return String(cString: cString) + + // MARK: Reading + + func startReading() { + + isReadLoopFinished = false + + readTask = Task { [weak self] in + + // Every exit falls through to the mark below, so `close()` can tell when the loop + // has stopped touching the socket. + while let self = self, await self.isConnected, Task.isCancelled == false { + + do { + let message = try await self.readChunk() + + guard message.data.isEmpty == false else { + await self.disconnected(DBusProtocolError.endOfStream) + break + } + + await self.received(Array(message.data), + fileDescriptors: message.fileDescriptors.map { $0.rawValue }) + } + catch { + await self.disconnected(error) + break + } + } + + await self?.markReadLoopFinished() + } } - - /// The approximate size in bytes of all messages in the outgoing message queue. - /// - /// The size is approximate in that you shouldn't use it to decide how many bytes to read off the network - /// or anything of that nature, as optimizations may choose to tell small white lies to avoid performance overhead. - public var outgoingSize: Int { - - return dbus_connection_get_outgoing_size(internalPointer) + + func markReadLoopFinished() { + + isReadLoopFinished = true } - - /// The approximate number of file descriptors of all messages in the outgoing message queue. - public var outgoingFileDescriptors: Int { - - return dbus_connection_get_outgoing_unix_fds(internalPointer) + + nonisolated func readChunk() async throws -> SocketMessage { + + // Always received as a message, so `SCM_RIGHTS` ancillary data is never dropped. On a + // stream socket this behaves like a plain read when no descriptors are attached. + return try await socket.receiveMessage( + DBusConnection.readChunkSize, + maximumDescriptors: SocketDescriptor.maximumAncillaryDescriptors + ) } - - /// Specifies the maximum size message this connection is allowed to receive. - /// - /// Larger messages will result in disconnecting the connection. - public var maximumSize: Int { - - get { return dbus_connection_get_max_message_size(internalPointer) } - - set { dbus_connection_set_max_message_size(internalPointer, newValue) } + + /// Append received bytes and dispatch every complete message they contain. + func received(_ bytes: [UInt8], fileDescriptors: [Int32] = []) { + + readBuffer.append(contentsOf: bytes) + receivedDescriptors.append(contentsOf: fileDescriptors) + + while true { + + let length: Int? + + do { length = try DBusMessage.length(from: readBuffer) } + catch { + // The stream is unframeable from here on; there is no way to resynchronise. + disconnected(error) + return + } + + guard let messageLength = length, readBuffer.count >= messageLength + else { return } + + let messageBytes = Array(readBuffer[0 ..< messageLength]) + readBuffer.removeFirst(messageLength) + + do { + // Descriptor indices are per message and start at zero, so decoding against the + // whole queue resolves this message's correctly. Its header then says how many + // belonged to it, and those are consumed. + let (message, _) = try DBusMessage.decode(messageBytes, + fileDescriptors: receivedDescriptors) + + let claimed = min(Int(message.unixFileDescriptorCount ?? 0), receivedDescriptors.count) + receivedDescriptors.removeFirst(claimed) + + dispatch(message) + } + catch { + // A single malformed message is not fatal to the framing, because its length + // was read from the header; skip it and carry on. + continue + } + } } - - /// Specifies the maximum number of file descriptors a message on this connection is allowed to receive. - /// - /// Messages with more file descriptors will result in disconnecting the connection. - public var maximumFileDescriptors: Int { - - get { return dbus_connection_get_max_message_unix_fds(internalPointer) } - - set { dbus_connection_set_max_message_unix_fds(internalPointer, newValue) } + + func dispatch(_ message: DBusMessage) { + + // Replies are matched by the serial of the call they answer. + if message.type == .methodReturn || message.type == .error, + let replySerial = message.replySerial, + outstanding.contains(replySerial) { + + if let continuation = pendingReplies.removeValue(forKey: replySerial) { + outstanding.remove(replySerial) + continuation.resume(returning: message) + } else { + // The caller has not started waiting yet. + earlyReplies[replySerial] = .success(message) + } + + return + } + + switch message.type { + + case .signal: + // The bus delivers the union of every rule installed, so each subscription decides + // for itself whether this message is one it asked for. + var delivered = false + + for subscription in subscriptions.values where subscription.rule.matches(message) { + subscription.continuation.yield(message) + delivered = true + } + + if delivered == false { + messageHandler?(message) + } + + case .methodCall: + handleMethodCall(message) + + default: + // A reply whose call is no longer outstanding, e.g. one that already timed out. + messageHandler?(message) + } } - - /// Sets the maximum total number of bytes that can be used for all messages received on this connection. - /// - /// Messages count toward the maximum until they are finalized. - /// When the maximum is reached, the connection will not read more data until some messages are finalized. - /// - /// The semantics of the maximum are: if outstanding messages are already above the maximum, - /// additional messages will not be read. - /// The semantics are not: if the next message would cause us to exceed the maximum, we don't read it. - /// The reason is that we don't know the size of a message until after we read it. - /// - /// Thus, the max live messages size can actually be exceeded by up to the maximum size of a single message. - public var maximumRecievedSize: Int { - - get { return dbus_connection_get_max_received_size(internalPointer) } - - set { dbus_connection_set_max_received_size(internalPointer, newValue) } + + func disconnected(_ error: Error) { + + guard isConnected else { return } + + isConnected = false + failAll(with: error) } - - /// Sets the maximum total number of unix fds that can be used for all messages received on this connection. - /// - /// Messages count toward the maximum until they are finalized. - /// When the maximum is reached, the connection will not read more data until some messages are finalized. - /// - /// The semantics are analogous to those of `maximumRecievedSize`. - public var maximumRecievedFileDescriptors: Int { - - get { return dbus_connection_get_max_received_unix_fds(internalPointer) } - - set { dbus_connection_set_max_received_unix_fds(internalPointer, newValue) } + + func failAll(with error: Error) { + + let pending = pendingReplies + pendingReplies.removeAll() + + for serial in outstanding where pending[serial] == nil { + earlyReplies[serial] = .failure(error) + } + + for (_, continuation) in pending { + continuation.resume(throwing: error) + } + + outstanding.removeAll() + + // Signal subscribers see the stream end rather than hang forever. + let closing = subscriptions + subscriptions.removeAll() + matchRuleCounts.removeAll() + + for subscription in closing.values { + subscription.continuation.finish() + } } - } diff --git a/Sources/DBus/DispatchStatus.swift b/Sources/DBus/DispatchStatus.swift deleted file mode 100644 index ffaeca9..0000000 --- a/Sources/DBus/DispatchStatus.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// DispatchStatus.swift -// DBus -// -// Created by Alsey Coleman Miller on 2/27/16. -// Copyright © 2016 PureSwift. All rights reserved. -// - -/// Indicates the status of incoming data on a `DBusConnection`. -/// -/// This determines whether `DBusConnection.dispatch()` needs to be called. -public enum DBusDispatchStatus: UInt32 { - - /// There is more data to potentially convert to messages. - case dataRemains - - /// All currently available data has been processed. - case complete - - /// More memory is needed to continue. - case needMemory -} diff --git a/Sources/DBus/Error.swift b/Sources/DBus/Error.swift index 8d2fd71..6a381df 100644 --- a/Sources/DBus/Error.swift +++ b/Sources/DBus/Error.swift @@ -6,118 +6,282 @@ // Copyright © 2016 PureSwift. All rights reserved. // -import Foundation -import CDBus +/// DBus type representing an exception. +/// +/// This is a *bus-level* error: it carries an `org.freedesktop.DBus.Error.*` name and a +/// human-readable message, and corresponds to an error reply on the wire. Framing and +/// marshalling failures are reported as ``DBusProtocolError`` instead. +public struct DBusError: Error, Equatable, Hashable, Sendable { -// This error is for DBus (swift) runtime failures. -public enum RuntimeError: Error { - case generic(String) -} + /// Error name field + public let name: DBusError.Name -// Given a swift string, make a copy of the C string (const char *) and return a pointer to it. -// This will throw RuntimeError.generic if malloc() fails∫. -func swiftStringToConstCharStar(_ s: String) throws -> UnsafePointer { - return try s.withCString { (unsafePointer: UnsafePointer) -> UnsafePointer in - // We need to copy the string to save a copy. unsafePointer is only valid in this closure - // UnsafeMutableRawPointer - let bufferLen = strlen(unsafePointer) + 1 - guard let unsafeMutableRawPointer = malloc(bufferLen) else { - throw RuntimeError.generic("malloc() failed") - } - memcpy(unsafeMutableRawPointer, unsafePointer, bufferLen) - // UnsafeMutablePointer - let unsafeMutablePointer = unsafeMutableRawPointer.assumingMemoryBound(to: Int8.self) - // UnsafePointer - return UnsafePointer(unsafeMutablePointer) + /// Error message field + public let message: String + + public init(name: DBusError.Name, message: String) { + + self.name = name + self.message = message } } -// This is a wrapper of the libdbus DBusError type. -public class DBusError: Error, Equatable, CustomStringConvertible { - internal var internalValue = CDBus.DBusError() +// MARK: - CustomStringConvertible + +extension DBusError: CustomStringConvertible { - init() { - dbus_error_init(&internalValue); + public var description: String { + + return "\(name): \(message)" } +} - // This will throw RuntimeError.generic if the name passed in is not a valid DBus error name, - // or if malloc() fails when we are copying the strings to live in C land. - convenience init(name: String, message: String = "") throws { - self.init() +// MARK: Error Name - let validationError = DBusError() - let isValid = dbus_validate_error_name(name, &validationError.internalValue) - if isValid == false { - throw RuntimeError.generic("\(name) is not a valid DBus Error name.") - } +public extension DBusError { - let cName = try swiftStringToConstCharStar(name) - let cMessage = try swiftStringToConstCharStar(message) - dbus_set_error_const(&internalValue, cName, cMessage) - } + /// A D-Bus error name. + /// + /// Error names follow the same syntax rules as interface names. + struct Name: Equatable, Hashable, Sendable { - deinit { - dbus_error_free(&internalValue) - } + public let rawValue: String + + public init?(rawValue: String) { + + do { try DBusInterface.validate(rawValue) } + catch { return nil } - public var isSet: Bool { - let dbusBool = dbus_error_is_set(&internalValue) - return Bool(dbusBool) + self.rawValue = rawValue + } } +} + +internal extension DBusError.Name { + + /// Initialize with a string known at compile time to be valid. + init(_ unsafe: String) { - public var name: String { - return String(cString: internalValue.name) + guard let value = DBusError.Name(rawValue: unsafe) + else { fatalError("Invalid error name \(unsafe)") } + + self = value } +} + +public extension DBusError.Name { - public var message: String { - return String(cString: internalValue.message) + init(_ interface: DBusInterface) { + + // should be valid + self.rawValue = interface.rawValue } +} - public static func == (lhs: DBusError, rhs: DBusError) -> Bool { - let lhsName = String(cString: lhs.internalValue.name) - let rhsName = String(cString: rhs.internalValue.name) - let lhsMessage = String(cString: lhs.internalValue.message) - let rhsMessage = String(cString: rhs.internalValue.message) - return (lhsName == rhsName && - lhsMessage == rhsMessage) +public extension DBusInterface { + + init(_ error: DBusError.Name) { + + self.init(rawValue: error.rawValue)! } +} + +public extension DBusError.Name { + + /// A generic error; "something went wrong" - see the error message for more. + /// + /// `org.freedesktop.DBus.Error.Failed` + static let failed = DBusError.Name("org.freedesktop.DBus.Error.Failed") + + /// No Memory + /// + /// `org.freedesktop.DBus.Error.NoMemory` + static let noMemory = DBusError.Name("org.freedesktop.DBus.Error.NoMemory") + + /// Existing file and the operation you're using does not silently overwrite. + /// + /// `org.freedesktop.DBus.Error.FileExists` + static let fileExists = DBusError.Name("org.freedesktop.DBus.Error.FileExists") + + /// Missing file. + /// + /// `org.freedesktop.DBus.Error.FileNotFound` + static let fileNotFound = DBusError.Name("org.freedesktop.DBus.Error.FileNotFound") + + /// Invalid arguments + /// + /// `org.freedesktop.DBus.Error.InvalidArgs` + static let invalidArguments = DBusError.Name("org.freedesktop.DBus.Error.InvalidArgs") + + /// Invalid signature + /// + /// `org.freedesktop.DBus.Error.InvalidSignature` + static let invalidSignature = DBusError.Name("org.freedesktop.DBus.Error.InvalidSignature") + + /// The bus doesn't know how to launch a service to supply the bus name you wanted. + /// + /// `org.freedesktop.DBus.Error.ServiceUnknown` + static let serviceUnknown = DBusError.Name("org.freedesktop.DBus.Error.ServiceUnknown") + + /// The bus name you referenced doesn't exist (i.e. no application owns it). + /// + /// `org.freedesktop.DBus.Error.NameHasNoOwner` + static let nameHasNoOwner = DBusError.Name("org.freedesktop.DBus.Error.NameHasNoOwner") + + /// Method not found on the object. + /// + /// `org.freedesktop.DBus.Error.UnknownMethod` + static let unknownMethod = DBusError.Name("org.freedesktop.DBus.Error.UnknownMethod") + + /// Object does not exist at the given path. + /// + /// `org.freedesktop.DBus.Error.UnknownObject` + static let unknownObject = DBusError.Name("org.freedesktop.DBus.Error.UnknownObject") + + /// Interface not implemented by the object. + /// + /// `org.freedesktop.DBus.Error.UnknownInterface` + static let unknownInterface = DBusError.Name("org.freedesktop.DBus.Error.UnknownInterface") + + /// Property does not exist on the interface. + /// + /// `org.freedesktop.DBus.Error.UnknownProperty` + static let unknownProperty = DBusError.Name("org.freedesktop.DBus.Error.UnknownProperty") + + /// Property is read-only. + /// + /// `org.freedesktop.DBus.Error.PropertyReadOnly` + static let propertyReadOnly = DBusError.Name("org.freedesktop.DBus.Error.PropertyReadOnly") + + /// Permission denied. + /// + /// `org.freedesktop.DBus.Error.AccessDenied` + static let accessDenied = DBusError.Name("org.freedesktop.DBus.Error.AccessDenied") + + /// The operation is not supported. + /// + /// `org.freedesktop.DBus.Error.NotSupported` + static let notSupported = DBusError.Name("org.freedesktop.DBus.Error.NotSupported") + + /// The call timed out. + /// + /// `org.freedesktop.DBus.Error.NoReply` + static let noReply = DBusError.Name("org.freedesktop.DBus.Error.NoReply") + + /// The connection is disconnected and you're trying to use it. + /// + /// `org.freedesktop.DBus.Error.Disconnected` + static let disconnected = DBusError.Name("org.freedesktop.DBus.Error.Disconnected") + + /// The address given was not valid. + /// + /// `org.freedesktop.DBus.Error.BadAddress` + static let badAddress = DBusError.Name("org.freedesktop.DBus.Error.BadAddress") + + /// A limit was exceeded. + /// + /// `org.freedesktop.DBus.Error.LimitsExceeded` + static let limitsExceeded = DBusError.Name("org.freedesktop.DBus.Error.LimitsExceeded") +} + +extension DBusError.Name: CustomStringConvertible { public var description: String { - return "DBusError(name: '\(name)', message: '\(message)') " + + return rawValue } } -public extension DBusError { +extension DBusError.Name: RawRepresentable { } + +// MARK: - Protocol Error + +/// An error in the D-Bus wire protocol: framing, marshalling, transport or authentication. +/// +/// Distinct from ``DBusError``, which models an error *reply* sent by a peer. +public enum DBusProtocolError: Error, Equatable, Hashable, Sendable { + + /// The stream ended before a complete message could be read. + case endOfStream + + /// The endianness byte was neither `l` nor `B`. + case invalidByteOrder(UInt8) - public struct Name { - /// A generic error; "something went wrong" - see the error message for more. - /// - /// `org.freedesktop.DBus.Error.Failed` - public static let failed = String(DBUS_ERROR_FAILED) - - /// No Memory - /// - /// `org.freedesktop.DBus.Error.NoMemory` - public static let noMemory = String(DBUS_ERROR_NO_MEMORY) - - /// Existing file and the operation you're using does not silently overwrite. - /// - /// `org.freedesktop.DBus.Error.FileExists` - public static let fileExists = String(DBUS_ERROR_FILE_EXISTS) - - /// Missing file. - /// - /// `org.freedesktop.DBus.Error.FileNotFound` - public static let fileNotFound = String(DBUS_ERROR_FILE_NOT_FOUND) - - /// Invalid arguments - /// - /// `org.freedesktop.DBus.Error.InvalidArgs` - public static let invalidArguments = String(DBUS_ERROR_INVALID_ARGS) - - /// Invalid signature - /// - /// `org.freedesktop.DBus.Error.InvalidSignature` - public static let invalidSignature = String(DBUS_ERROR_INVALID_SIGNATURE) + /// The protocol version was not `1`. + case invalidProtocolVersion(UInt8) + + /// The message type code was not recognised. + case invalidMessageType(UInt8) + + /// A header field could not be decoded. + case invalidHeaderField(UInt8) + + /// A required header field was absent for this message type. + case missingHeaderField(String) + + /// The declared length exceeds the maximum message size. + case messageTooLarge(UInt32) + + /// A type code in the wire data was not a valid signature. + case invalidSignature(String) + + /// A marshalled value did not match its declared type. + case typeMismatch(expected: String, actual: String) + + /// A string field was not valid UTF-8, or was not NUL-terminated. + case invalidString + + /// A padding byte was non-zero, which the specification forbids. + case invalidPadding + + /// The value could not be represented, e.g. an array whose elements are not homogeneous. + case invalidValue(String) + + /// The bus address string could not be parsed. + case invalidAddress(String) + + /// No supported authentication mechanism was offered by the peer. + case authenticationFailed(String) + + /// The peer rejected the connection during the SASL handshake. + case authenticationRejected(String) +} + +extension DBusProtocolError: CustomStringConvertible { + + public var description: String { + + switch self { + case .endOfStream: + return "The stream ended before a complete message could be read" + case let .invalidByteOrder(byte): + return "Invalid byte order marker: \(byte)" + case let .invalidProtocolVersion(version): + return "Unsupported protocol version: \(version)" + case let .invalidMessageType(type): + return "Invalid message type: \(type)" + case let .invalidHeaderField(code): + return "Invalid header field code: \(code)" + case let .missingHeaderField(name): + return "Missing required header field: \(name)" + case let .messageTooLarge(length): + return "Message length \(length) exceeds the maximum message size" + case let .invalidSignature(string): + return "Invalid signature: '\(string)'" + case let .typeMismatch(expected, actual): + return "Type mismatch: expected \(expected), found \(actual)" + case .invalidString: + return "Invalid string value" + case .invalidPadding: + return "Non-zero padding byte" + case let .invalidValue(reason): + return "Invalid value: \(reason)" + case let .invalidAddress(string): + return "Invalid bus address: '\(string)'" + case let .authenticationFailed(reason): + return "Authentication failed: \(reason)" + case let .authenticationRejected(reason): + return "Authentication rejected: \(reason)" + } } } diff --git a/Sources/DBus/HandlerResult.swift b/Sources/DBus/HandlerResult.swift deleted file mode 100644 index de7e657..0000000 --- a/Sources/DBus/HandlerResult.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// HandlerResult.swift -// DBus -// -// Created by Alsey Coleman Miller on 2/27/16. -// Copyright © 2016 PureSwift. All rights reserved. -// - -/// Results that a message handler can return. -public enum DBusHandlerResult: UInt32 { - - /// Message has had its effect - no need to run more handlers. - case handled - - /// Message has not had any effect - see if other handlers want it. - case notYetHandled - - /// Please try again later with more memory. - case needMemory -} diff --git a/Sources/DBus/Interface.swift b/Sources/DBus/Interface.swift index 83eab0b..0502c28 100644 --- a/Sources/DBus/Interface.swift +++ b/Sources/DBus/Interface.swift @@ -5,41 +5,38 @@ // Created by Alsey Coleman Miller on 10/24/18. // -import Foundation -import CDBus - /** DBus Interface Name (e.g "`com.example.MusicPlayer1.readValue`") - + The various names in D-Bus messages have some restrictions. - + There is a maximum name length of 255 which applies to bus names, interfaces, and members. - + Interfaces have names with type STRING, meaning that they must be valid UTF-8. However, there are also some additional restrictions that apply to interface names specifically: - + Interface names are composed of 1 or more elements separated by a period ('.') character. All elements must contain at least one character. - + Each element must only contain the ASCII characters "[A-Z][a-z][0-9]_" and must not begin with a digit. - + Interface names must contain at least one '.' (period) character (and thus at least two elements). - + Interface names must not begin with a '.' (period) character. - + Interface names must not exceed the maximum name length. - + Interface names should start with the reversed DNS domain name of the author of the interface (in lower-case), like interface names in Java. It is conventional for the rest of the interface name to consist of words run together, with initial capital letters on all words ("CamelCase"). Several levels of hierarchy can be used. It is also a good idea to include the major version of the interface in the name, and increment it if incompatible changes are made; this way, a single object can implement several versions of an interface in parallel, if necessary. - + For instance, if the owner of example.com is developing a D-Bus API for a music player, they might define interfaces called com.example.MusicPlayer1, com.example.MusicPlayer1.Track and com.example.MusicPlayer1.Seekable. - + If the author's DNS domain name contains hyphen/minus characters ('-'), which are not allowed in D-Bus interface names, they should be replaced by underscores. If the DNS domain name contains a digit immediately following a period ('.'), which is also not allowed in interface names), the interface name should add an underscore before that digit. For example, if the owner of 7-zip.org defined an interface for out-of-process plugins, it might be named org._7_zip.Plugin. - + D-Bus does not distinguish between the concepts that would be called classes and interfaces in Java: either can be identified on D-Bus by an interface name. */ -public struct DBusInterface { - +public struct DBusInterface: Sendable { + @usableFromInline internal private(set) var elements: [Element] - + /// Cached string. /// This will be the original string the object path was created from. /// @@ -48,70 +45,83 @@ public struct DBusInterface { /// but for values created from either a string or an array of elements, this value is cached. @usableFromInline internal private(set) var string: String? - + /// Initialize with an array of elements. public init?(_ elements: [Element]) { - + // Must have at least one period, so at least 2 elements guard elements.count > 1 else { return nil } - + self.elements = elements self.string = String(elements) } } internal extension DBusInterface { - + + /// Minimum is "a.b", maximum applies to bus names, interfaces and members alike. static let length = (min: 3, max: 255) - + static let separator = ".".first! - + static func parse(_ string: String) -> [Element]? { - - guard string.count >= length.min, - string.count <= length.max, - string.first != separator, - string.last != separator, - string.contains(separator) - else { return nil } - + + return try? parseThrowing(string) + } + + static func validate(_ string: String) throws { + + _ = try parseThrowing(string) + } + + static func parseThrowing(_ string: String) throws -> [Element] { + + // Length is measured in bytes, and only ASCII is legal in an interface name. + guard string.utf8.count >= length.min, + string.utf8.count <= length.max, + string.first != separator, // must not begin with '.' + string.last != separator, // must not end with '.' + string.contains(separator) // must contain at least one '.' + else { throw DBusError.invalidInterface(string) } + let pathStrings = string.split(separator: separator, maxSplits: .max, omittingEmptySubsequences: false) - + var elements = [Element]() elements.reserveCapacity(pathStrings.count) // pre-allocate buffer - + for substring in pathStrings { - + guard let element = Element(substring: substring) - else { return nil } - + else { throw DBusError.invalidInterface(string) } + elements.append(element) } - + // Must have at least one period, so at least 2 elements guard elements.count > 1 - else { return nil } - + else { throw DBusError.invalidInterface(string) } + return elements } - - static func validate(_ string: String) throws { - - let error = DBusError() - guard Bool(dbus_validate_interface(string, &error.internalValue)) - else { throw error } +} + +private extension DBusError { + + static func invalidInterface(_ string: String) -> DBusError { + + return DBusError(name: .invalidArguments, message: "Interface name was not valid: '\(string)'") } } internal extension String { - + init(_ interface: [DBusInterface.Element]) { - + assert(interface.count > 1, "Must have at least 2 elements") - + let separator = String(DBusInterface.separator) self = interface.enumerated().reduce("", { $0 + $1.element.rawValue + (($1.offset + 1 < interface.count) ? separator : "") @@ -120,50 +130,50 @@ internal extension String { } extension DBusInterface: RawRepresentable { - + public init?(rawValue: String) { - - guard let elements = DBusInterface.parse(rawValue) + + guard let elements = try? DBusInterface.parseThrowing(rawValue) else { return nil } - + self.elements = elements self.string = rawValue } - + public var rawValue: String { - + return string ?? String(elements) } } extension DBusInterface: Equatable { - + public static func == (lhs: DBusInterface, rhs: DBusInterface) -> Bool { - + // fast path if let lhsString = lhs.string, let rhsString = rhs.string { - + return lhsString == rhsString } - + // slower comparison return lhs.elements == rhs.elements } } extension DBusInterface: Hashable { - - public var hashValue: Int { - - return rawValue.hashValue + + public func hash(into hasher: inout Hasher) { + + hasher.combine(rawValue) } } extension DBusInterface: CustomStringConvertible { - + public var description: String { - + return rawValue } } @@ -171,48 +181,48 @@ extension DBusInterface: CustomStringConvertible { // MARK: Collection extension DBusInterface: MutableCollection { - + public typealias Index = Int - + public subscript (index: Index) -> Element { - + get { return elements[index] } - + mutating set { string = nil elements[index] = newValue } } - + public var count: Int { - + return elements.count } - + /// The start `Index`. public var startIndex: Index { return 0 } - + /// The end `Index`. /// /// This is the "one-past-the-end" position, and will always be equal to the `count`. public var endIndex: Index { return count } - + public func index(before i: Index) -> Index { return i - 1 } - + public func index(after i: Index) -> Index { return i + 1 } - + public func makeIterator() -> IndexingIterator { return IndexingIterator(_elements: self) } - + public mutating func append(_ element: Element) { string = nil elements.append(element) @@ -224,23 +234,26 @@ extension DBusInterface: RandomAccessCollection { } // MARK: - Element public extension DBusInterface { - - /// An element in the object path - struct Element { - + + /// An element in the interface name + struct Element: Sendable { + /// Don't copy buffer of individual elements, because these elements will always be created /// from a bigger string, which we should just internally reference. internal let substring: Substring - + /// Designated initializer. internal init?(substring: Substring) { - - // validate string - guard substring.isEmpty == false, // No element may be an empty string. - substring.contains(DBusInterface.separator) == false, // Multiple '.' characters cannot occur in sequence. - substring.rangeOfCharacter(from: Element.invalidCharacters) == nil // check for invalid characters + + let bytes = substring.utf8 + + // No element may be an empty string, must only contain "[A-Z][a-z][0-9]_", + // and must not begin with a digit. + guard let first = bytes.first, + first.isASCIIDigit == false, + bytes.allSatisfy({ $0.isObjectPathElementByte }) else { return nil } - + // store validated string self.substring = substring } @@ -248,46 +261,40 @@ public extension DBusInterface { } extension DBusInterface.Element: RawRepresentable { - + public init?(rawValue: String) { - + // This API will rarely be used let substring = Substring(rawValue) self.init(substring: substring) } - + public var rawValue: String { - + return String(substring) } } -private extension DBusInterface.Element { - - /// only ASCII characters "[A-Z][a-z][0-9]_" - static let invalidCharacters = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLKMNOPQRSTUVWXYZ0123456789_").inverted -} - extension DBusInterface.Element: Equatable { - + public static func == (lhs: DBusInterface.Element, rhs: DBusInterface.Element) -> Bool { - + return lhs.substring == rhs.substring } } extension DBusInterface.Element: CustomStringConvertible { - + public var description: String { - + return rawValue } } extension DBusInterface.Element: Hashable { - - public var hashValue: Int { - - return substring.hashValue + + public func hash(into hasher: inout Hasher) { + + hasher.combine(substring) } } diff --git a/Sources/DBus/Marshal/Endianness.swift b/Sources/DBus/Marshal/Endianness.swift new file mode 100644 index 0000000..3a98183 --- /dev/null +++ b/Sources/DBus/Marshal/Endianness.swift @@ -0,0 +1,95 @@ +// +// Endianness.swift +// DBus +// + +/// The byte order of a marshalled D-Bus message. +/// +/// Messages are marshalled in the sender's native byte order and converted by the receiver; +/// there is no canonical network order. +/// +/// Reference: https://dbus.freedesktop.org/doc/dbus-specification.html#message-protocol-marshaling +public enum DBusEndianness: UInt8, Sendable, CaseIterable { + + /// Little endian, ASCII 'l'. + case little = 0x6C + + /// Big endian, ASCII 'B'. + case big = 0x42 +} + +public extension DBusEndianness { + + /// The byte order of the current machine. + static var host: DBusEndianness { + + return _isLittleEndian ? .little : .big + } +} + +/// Determined once, from a value whose byte pattern differs between orders. +internal let _isLittleEndian: Bool = { + + return UInt16(1).littleEndian == 1 +}() + +internal extension FixedWidthInteger { + + /// The value converted to the given byte order. + func byteSwapped(to endianness: DBusEndianness) -> Self { + + switch endianness { + case .little: return self.littleEndian + case .big: return self.bigEndian + } + } + + /// The value interpreted as having been stored in the given byte order. + init(_ value: Self, from endianness: DBusEndianness) { + + switch endianness { + case .little: self.init(littleEndian: value) + case .big: self.init(bigEndian: value) + } + } +} + +// MARK: - Alignment + +internal extension DBusSignature.ValueType { + + /// The alignment requirement of the type, in bytes. + /// + /// Every value is preceded by however many zero bytes are needed to bring the current + /// position to a multiple of this number. + /// + /// Reference: https://dbus.freedesktop.org/doc/dbus-specification.html#message-protocol-marshaling-alignment + var alignment: Int { + + switch self { + case .byte: return 1 + case .boolean: return 4 // marshalled as UInt32 + case .int16, .uint16: return 2 + case .int32, .uint32: return 4 + case .int64, .uint64: return 8 + case .double: return 8 + case .fileDescriptor: return 4 // an index into the fd array, marshalled as UInt32 + case .string, .objectPath: return 4 // UInt32 length prefix + case .signature: return 1 // single byte length prefix + case .variant: return 1 // the contained signature is a signature, so alignment 1 + case .array, .dictionary: return 4 // UInt32 length prefix; a dictionary is an array of entries + case .struct: return 8 + } + } +} + +/// Alignment of a `DICT_ENTRY`, which is a struct on the wire and so aligns to 8. +internal let dictionaryEntryAlignment = 8 + +/// The maximum length of a message, header plus body, in bytes (2^27). +/// +/// Reference: https://dbus.freedesktop.org/doc/dbus-specification.html#message-protocol-messages +public let maximumMessageLength = 134_217_728 + +/// The maximum length of the header field array, in bytes (2^26). +public let maximumArrayLength = 67_108_864 diff --git a/Sources/DBus/Marshal/Marshaller.swift b/Sources/DBus/Marshal/Marshaller.swift new file mode 100644 index 0000000..377b75f --- /dev/null +++ b/Sources/DBus/Marshal/Marshaller.swift @@ -0,0 +1,259 @@ +// +// Marshaller.swift +// DBus +// + +/// Encodes D-Bus values into the wire format. +/// +/// Reference: https://dbus.freedesktop.org/doc/dbus-specification.html#message-protocol-marshaling +internal struct DBusMarshaller { + + /// The byte order values are written in. + let endianness: DBusEndianness + + /// The encoded bytes. + private(set) var bytes: [UInt8] + + /// Descriptors encountered while marshalling, in the order their indices were assigned. + /// + /// A `UNIX_FD` is marshalled as an index into the array of descriptors sent out of band + /// with the message, not as the descriptor number itself. + private(set) var fileDescriptors: [Int32] = [] + + /// The position the alignment is measured from. + /// + /// Alignment in D-Bus is relative to the start of the *message*, not the start of the + /// buffer being written. When marshalling a message body separately from its header, the + /// body's alignment origin is the (already 8-aligned) end of the header, so an origin of + /// zero is correct there too. + private let origin: Int + + init(endianness: DBusEndianness = .host, bytes: [UInt8] = [], origin: Int = 0) { + + self.endianness = endianness + self.bytes = bytes + self.origin = origin + } +} + +// MARK: - Primitives + +internal extension DBusMarshaller { + + /// The current offset, relative to the alignment origin. + var offset: Int { + + return origin + bytes.count + } + + /// Insert zero bytes until the offset is a multiple of `alignment`. + mutating func pad(to alignment: Int) { + + precondition(alignment > 0) + + let remainder = offset % alignment + + guard remainder != 0 else { return } + + bytes.append(contentsOf: repeatElement(0, count: alignment - remainder)) + } + + mutating func append(_ value: UInt8) { + + bytes.append(value) + } + + mutating func append(_ value: T) { + + pad(to: MemoryLayout.size) + appendUnaligned(value) + } + + /// Append an integer without first padding, for use where alignment is already guaranteed. + mutating func appendUnaligned(_ value: T) { + + withUnsafeBytes(of: value.byteSwapped(to: endianness)) { + bytes.append(contentsOf: $0) + } + } + + mutating func append(_ value: Double) { + + append(value.bitPattern) + } + + mutating func append(_ value: Bool) { + + append(UInt32(value ? 1 : 0)) + } + + /// Append a `STRING` or `OBJECT_PATH`: a `UInt32` byte count, the UTF-8 bytes, then a NUL. + /// + /// - Note: The length excludes the terminating NUL. + mutating func appendString(_ value: String) { + + let utf8 = Swift.Array(value.utf8) + append(UInt32(utf8.count)) + bytes.append(contentsOf: utf8) + bytes.append(0) + } + + /// Append a `SIGNATURE`: a single byte count, the ASCII bytes, then a NUL. + /// + /// - Note: A signature is at most 255 bytes, which is why a single byte suffices. + mutating func appendSignature(_ value: String) { + + let utf8 = Swift.Array(value.utf8) + assert(utf8.count <= 255, "Signature exceeds 255 bytes") + bytes.append(UInt8(truncatingIfNeeded: utf8.count)) + bytes.append(contentsOf: utf8) + bytes.append(0) + } +} + +// MARK: - Values + +internal extension DBusMarshaller { + + /// Append a sequence of complete values, as in a message body. + mutating func append(contentsOf arguments: S) throws where S.Element == DBusMessageArgument { + + for argument in arguments { + try append(argument) + } + } + + mutating func append(_ argument: DBusMessageArgument) throws { + + switch argument { + + case let .byte(value): + append(value) + case let .boolean(value): + append(value) + case let .int16(value): + append(value) + case let .uint16(value): + append(value) + case let .int32(value): + append(value) + case let .uint32(value): + append(value) + case let .int64(value): + append(value) + case let .uint64(value): + append(value) + case let .double(value): + append(value) + case let .fileDescriptor(value): + // Marshalled as an index into the out-of-band file descriptor array, so the + // descriptor itself is recorded here and only its position goes on the wire. + let index = fileDescriptors.count + fileDescriptors.append(value.rawValue) + append(UInt32(index)) + + case let .string(value): + appendString(value) + case let .objectPath(value): + appendString(value.rawValue) + case let .signature(value): + appendSignature(value.rawValue) + + case let .array(array): + try appendArray(elementAlignment: array.type.alignment) { marshaller in + for element in array { + try marshaller.append(element) + } + } + + case let .dictionary(dictionary): + // A dictionary is an array of DICT_ENTRY, which align to 8 like a struct. + try appendArray(elementAlignment: dictionaryEntryAlignment) { marshaller in + for entry in dictionary { + marshaller.pad(to: dictionaryEntryAlignment) + try marshaller.append(entry.key) + try marshaller.append(entry.value) + } + } + + case let .struct(structure): + pad(to: 8) + for element in structure { + try append(element) + } + + case let .variant(variant): + let element = variant.element + appendSignature(String(element.type)) + try append(element) + } + } + + /// Append an `ARRAY`: a `UInt32` byte count, then the elements. + /// + /// - Note: The length counts only the element data. Padding inserted between the length + /// and the first element to satisfy the element alignment is *not* included, and must be + /// written even when the array is empty. + private mutating func appendArray(elementAlignment: Int, + _ body: (inout DBusMarshaller) throws -> ()) throws { + + // The length itself is a UInt32 and so aligns to 4. + pad(to: 4) + + // Reserve space for the length, to be backfilled once the elements are written. + let lengthIndex = bytes.count + appendUnaligned(UInt32(0)) + + // Padding to the element alignment is written even for an empty array, and is not + // counted in the length. + pad(to: elementAlignment) + + let start = bytes.count + try body(&self) + let length = bytes.count - start + + guard length <= maximumArrayLength + else { throw DBusProtocolError.invalidValue("Array length \(length) exceeds the maximum") } + + // Backfill the length. + withUnsafeBytes(of: UInt32(length).byteSwapped(to: endianness)) { lengthBytes in + for (index, byte) in lengthBytes.enumerated() { + bytes[lengthIndex + index] = byte + } + } + } +} + +// MARK: - Convenience + +internal extension DBusMarshaller { + + /// Marshal a complete list of values and return the resulting bytes. + static func marshal(_ arguments: [DBusMessageArgument], + endianness: DBusEndianness = .host, + origin: Int = 0) throws -> [UInt8] { + + return try marshalWithDescriptors(arguments, endianness: endianness, origin: origin).bytes + } + + /// Marshal a complete list of values, returning the bytes and any descriptors they refer to. + static func marshalWithDescriptors(_ arguments: [DBusMessageArgument], + endianness: DBusEndianness = .host, + origin: Int = 0) throws -> (bytes: [UInt8], fileDescriptors: [Int32]) { + + var marshaller = DBusMarshaller(endianness: endianness, origin: origin) + try marshaller.append(contentsOf: arguments) + return (marshaller.bytes, marshaller.fileDescriptors) + } +} + +// MARK: - Signature + +internal extension Sequence where Element == DBusMessageArgument { + + /// The concatenated signature of the values. + var signature: DBusSignature { + + return DBusSignature(map { $0.type }) + } +} diff --git a/Sources/DBus/Marshal/MessageCodec.swift b/Sources/DBus/Marshal/MessageCodec.swift new file mode 100644 index 0000000..f15aabd --- /dev/null +++ b/Sources/DBus/Marshal/MessageCodec.swift @@ -0,0 +1,369 @@ +// +// MessageCodec.swift +// DBus +// + +// MARK: - Header Field Code + +/// The code identifying a header field. +/// +/// Reference: https://dbus.freedesktop.org/doc/dbus-specification.html#message-protocol-header-fields +internal enum DBusHeaderFieldCode: UInt8, CaseIterable { + + /// The object to send a call to, or the object a signal is emitted from. `OBJECT_PATH`. + case path = 1 + + /// The interface to invoke a method call on, or that a signal is emitted from. `STRING`. + case interface = 2 + + /// The member, either the method name or signal name. `STRING`. + case member = 3 + + /// The name of the error that occurred, for errors. `STRING`. + case errorName = 4 + + /// The serial number of the message this message is a reply to. `UINT32`. + case replySerial = 5 + + /// The name of the connection this message is intended for. `STRING`. + case destination = 6 + + /// Unique name of the sending connection. `STRING`. + case sender = 7 + + /// The signature of the message body. `SIGNATURE`. + case signature = 8 + + /// The number of Unix file descriptors that accompany the message. `UINT32`. + case unixFileDescriptors = 9 +} + +internal extension DBusHeaderFieldCode { + + /// The type the field's variant must contain. + var valueType: DBusSignature.ValueType { + + switch self { + case .path: return .objectPath + case .interface, .member, .errorName, .destination, .sender: return .string + case .replySerial, .unixFileDescriptors: return .uint32 + case .signature: return .signature + } + } +} + +// MARK: - Constants + +internal extension DBusMessage { + + /// The only protocol version this implementation speaks. + static let protocolVersion: UInt8 = 1 + + /// Size of the fixed portion of the header, before the header field array. + /// + /// endianness + type + flags + version + body length + serial + static let fixedHeaderLength = 12 + + /// Offset of the header field array's length prefix. + static let headerFieldsLengthOffset = 12 + + /// The number of bytes that must be read before the total message length is known. + static let minimumHeaderLength = 16 +} + +// MARK: - Encoding + +public extension DBusMessage { + + /// Marshal the message into its wire representation. + /// + /// - Parameter endianness: The byte order to encode in. Defaults to the host's, which is + /// what the specification recommends: senders write native order and receivers convert. + func encode(endianness: DBusEndianness = .host) throws -> [UInt8] { + + return try encodeWithDescriptors(endianness: endianness).bytes + } + + /// Marshal the message, returning the bytes and the descriptors that must accompany them. + /// + /// A `UNIX_FD` argument is written as an index into `fileDescriptors`; the descriptors + /// themselves travel out of band, as `SCM_RIGHTS` ancillary data. + func encodeWithDescriptors(endianness: DBusEndianness = .host) throws -> (bytes: [UInt8], fileDescriptors: [Int32]) { + + // The body is marshalled first, because its length appears in the fixed header, and + // because marshalling is what assigns the descriptor indices. + // + // Alignment inside the body is relative to the start of the message, but the header is + // always padded to 8 and 8 is the largest alignment, so an origin of zero is equivalent. + let (body, descriptors) = try DBusMarshaller.marshalWithDescriptors(arguments, endianness: endianness) + + guard body.count <= maximumMessageLength + else { throw DBusProtocolError.messageTooLarge(UInt32(truncatingIfNeeded: body.count)) } + + var marshaller = DBusMarshaller(endianness: endianness) + + marshaller.append(endianness.rawValue) + marshaller.append(type.rawValue) + marshaller.append(flags.rawValue) + marshaller.append(DBusMessage.protocolVersion) + marshaller.appendUnaligned(UInt32(body.count)) + marshaller.appendUnaligned(serial) + + try marshaller.append(.array(headerFieldsArgument(unixFileDescriptorCount: UInt32(descriptors.count)))) + + // The header is padded to an 8 byte boundary before the body begins. + marshaller.pad(to: 8) + + var bytes = marshaller.bytes + bytes.append(contentsOf: body) + + guard bytes.count <= maximumMessageLength + else { throw DBusProtocolError.messageTooLarge(UInt32(truncatingIfNeeded: bytes.count)) } + + return (bytes, descriptors) + } + + /// The header fields, as the `a(yv)` value they are marshalled as. + /// + /// - Parameter unixFileDescriptorCount: How many descriptors accompany the message. Taken + /// from what marshalling actually produced rather than from the stored property, so the + /// field can never disagree with the body. + internal func headerFieldsArgument(unixFileDescriptorCount: UInt32? = nil) -> DBusMessageArgument.Array { + + var fields = [DBusMessageArgument]() + + func append(_ code: DBusHeaderFieldCode, _ value: DBusMessageArgument) { + + guard let structure = DBusMessageArgument.Structure([ + .byte(code.rawValue), + .variant(DBusMessageArgument.Variant(value)) + ]) else { fatalError("Header field structure is never empty") } + + fields.append(.struct(structure)) + } + + if let path = self.path { + append(.path, .objectPath(path)) + } + + if let interface = self.interface { + append(.interface, .string(interface.rawValue)) + } + + if let member = self.member { + append(.member, .string(member.rawValue)) + } + + if let errorName = self.errorName { + append(.errorName, .string(errorName.rawValue)) + } + + if let replySerial = self.replySerial { + append(.replySerial, .uint32(replySerial)) + } + + if let destination = self.destination { + append(.destination, .string(destination.rawValue)) + } + + if let sender = self.sender { + append(.sender, .string(sender.rawValue)) + } + + // The signature field is omitted when the body is empty. + if arguments.isEmpty == false { + append(.signature, .signature(signature)) + } + + if let count = unixFileDescriptorCount ?? self.unixFileDescriptorCount, count > 0 { + append(.unixFileDescriptors, .uint32(count)) + } + + let elementType = DBusSignature.ValueType.struct([.byte, .variant]) + + guard let array = DBusMessageArgument.Array(type: elementType, fields) + else { fatalError("Header fields are all (yv) structs") } + + return array + } +} + +// MARK: - Decoding + +public extension DBusMessage { + + /// The total length of the message beginning at the start of `bytes`, or `nil` if not + /// enough bytes are available to determine it yet. + /// + /// Used by the read loop to frame the stream: read 16 bytes, learn the length, read the rest. + static func length(from bytes: [UInt8]) throws -> Int? { + + guard bytes.count >= minimumHeaderLength + else { return nil } + + guard let endianness = DBusEndianness(rawValue: bytes[0]) + else { throw DBusProtocolError.invalidByteOrder(bytes[0]) } + + var unmarshaller = DBusUnmarshaller(bytes: bytes, endianness: endianness, offset: 4) + let bodyLength = Int(try unmarshaller.readIntegerUnaligned(UInt32.self)) + _ = try unmarshaller.readIntegerUnaligned(UInt32.self) // serial + let fieldsLength = Int(try unmarshaller.readIntegerUnaligned(UInt32.self)) + + guard fieldsLength <= maximumArrayLength + else { throw DBusProtocolError.messageTooLarge(UInt32(truncatingIfNeeded: fieldsLength)) } + + // Header fields start at 16, then the header is padded to 8 before the body. + let headerEnd = minimumHeaderLength + fieldsLength + let paddedHeaderEnd = headerEnd.aligned(to: 8) + let total = paddedHeaderEnd + bodyLength + + guard total <= maximumMessageLength, total >= 0 + else { throw DBusProtocolError.messageTooLarge(UInt32(truncatingIfNeeded: total)) } + + return total + } + + /// Decode a message from its wire representation. + /// + /// - Parameters: + /// - bytes: A buffer beginning with a complete message. Trailing bytes are ignored. + /// - fileDescriptors: Descriptors received out of band with this message. Any `UNIX_FD` + /// argument indexes into these, and the decoded arguments carry the real descriptors. + /// - Returns: The decoded message and the number of bytes it occupied. + static func decode(_ bytes: [UInt8], + fileDescriptors: [Int32] = []) throws -> (message: DBusMessage, length: Int) { + + guard let total = try length(from: bytes) + else { throw DBusProtocolError.endOfStream } + + guard bytes.count >= total + else { throw DBusProtocolError.endOfStream } + + guard let endianness = DBusEndianness(rawValue: bytes[0]) + else { throw DBusProtocolError.invalidByteOrder(bytes[0]) } + + guard let type = DBusMessageType(rawValue: bytes[1]) + else { throw DBusProtocolError.invalidMessageType(bytes[1]) } + + let flags = Flags(rawValue: bytes[2]) + + guard bytes[3] == protocolVersion + else { throw DBusProtocolError.invalidProtocolVersion(bytes[3]) } + + var unmarshaller = DBusUnmarshaller(bytes: bytes, endianness: endianness, offset: 4) + let bodyLength = Int(try unmarshaller.readIntegerUnaligned(UInt32.self)) + let serial = try unmarshaller.readIntegerUnaligned(UInt32.self) + + // Header fields: a(yv) + let fieldsType = DBusSignature.ValueType.array(.struct([.byte, .variant])) + let fieldsArgument = try unmarshaller.read(fieldsType) + + guard case let .array(fieldsArray) = fieldsArgument + else { throw DBusProtocolError.invalidHeaderField(0) } + + var message = DBusMessage(type: type, flags: flags, serial: serial) + var bodySignature: DBusSignature? + + for field in fieldsArray { + + guard case let .struct(structure) = field, + structure.count == 2, + case let .byte(rawCode) = structure[0], + case let .variant(variant) = structure[1] + else { throw DBusProtocolError.invalidHeaderField(0) } + + // Unknown field codes must be ignored, not rejected, so that future + // specification additions do not break this implementation. + guard let code = DBusHeaderFieldCode(rawValue: rawCode) + else { continue } + + let value = variant.element + + guard value.type == code.valueType + else { throw DBusProtocolError.typeMismatch(expected: String(code.valueType), + actual: String(value.type)) } + + switch (code, value) { + + case let (.path, .objectPath(path)): + message.path = path + + case let (.interface, .string(string)): + guard let interface = DBusInterface(rawValue: string) + else { throw DBusProtocolError.invalidValue("Invalid interface '\(string)'") } + message.interface = interface + + case let (.member, .string(string)): + guard let member = DBusMember(rawValue: string) + else { throw DBusProtocolError.invalidValue("Invalid member '\(string)'") } + message.member = member + + case let (.errorName, .string(string)): + guard let name = DBusError.Name(rawValue: string) + else { throw DBusProtocolError.invalidValue("Invalid error name '\(string)'") } + message.errorName = name + + case let (.replySerial, .uint32(value)): + message.replySerial = value + + case let (.destination, .string(string)): + guard let busName = DBusBusName(rawValue: string) + else { throw DBusProtocolError.invalidValue("Invalid destination '\(string)'") } + message.destination = busName + + case let (.sender, .string(string)): + guard let busName = DBusBusName(rawValue: string) + else { throw DBusProtocolError.invalidValue("Invalid sender '\(string)'") } + message.sender = busName + + case let (.signature, .signature(signature)): + bodySignature = signature + + case let (.unixFileDescriptors, .uint32(value)): + message.unixFileDescriptorCount = value + + default: + throw DBusProtocolError.invalidHeaderField(rawCode) + } + } + + // The header is padded to 8 before the body. + let bodyStart = unmarshaller.offset.aligned(to: 8) + + guard bodyStart + bodyLength <= bytes.count + else { throw DBusProtocolError.endOfStream } + + if let bodySignature = bodySignature, bodySignature.isEmpty == false { + + let body = Swift.Array(bytes[bodyStart ..< bodyStart + bodyLength]) + var bodyUnmarshaller = DBusUnmarshaller(bytes: body, + endianness: endianness, + fileDescriptors: fileDescriptors) + message.arguments = try bodyUnmarshaller.read(signature: bodySignature) + + guard bodyUnmarshaller.isAtEnd + else { throw DBusProtocolError.invalidValue("Body has \(bodyUnmarshaller.remaining) trailing bytes") } + + } else { + + guard bodyLength == 0 + else { throw DBusProtocolError.missingHeaderField("signature") } + } + + return (message, total) + } +} + +// MARK: - Supporting + +internal extension Int { + + /// The value rounded up to the next multiple of `alignment`. + func aligned(to alignment: Int) -> Int { + + precondition(alignment > 0) + + let remainder = self % alignment + return remainder == 0 ? self : self + (alignment - remainder) + } +} diff --git a/Sources/DBus/Marshal/Unmarshaller.swift b/Sources/DBus/Marshal/Unmarshaller.swift new file mode 100644 index 0000000..b639ca3 --- /dev/null +++ b/Sources/DBus/Marshal/Unmarshaller.swift @@ -0,0 +1,323 @@ +// +// Unmarshaller.swift +// DBus +// + +/// Decodes D-Bus values from the wire format. +/// +/// Reference: https://dbus.freedesktop.org/doc/dbus-specification.html#message-protocol-marshaling +internal struct DBusUnmarshaller { + + /// The byte order the values were written in. + let endianness: DBusEndianness + + /// The bytes being decoded. + let bytes: [UInt8] + + /// The current read position within `bytes`. + private(set) var offset: Int + + /// The position alignment is measured from. See `DBusMarshaller.origin`. + private let origin: Int + + /// Descriptors received out of band with this message, which `UNIX_FD` values index into. + let fileDescriptors: [Int32] + + init(bytes: [UInt8], + endianness: DBusEndianness, + offset: Int = 0, + origin: Int = 0, + fileDescriptors: [Int32] = []) { + + self.bytes = bytes + self.endianness = endianness + self.offset = offset + self.origin = origin + self.fileDescriptors = fileDescriptors + } +} + +// MARK: - Primitives + +internal extension DBusUnmarshaller { + + /// Whether every byte has been consumed. + var isAtEnd: Bool { + + return offset >= bytes.count + } + + /// The number of bytes remaining. + var remaining: Int { + + return bytes.count - offset + } + + /// Skip padding until the position is a multiple of `alignment`. + /// + /// - Note: The specification requires padding bytes to be zero, and requires receivers to + /// reject messages where they are not. + mutating func align(to alignment: Int) throws { + + precondition(alignment > 0) + + let position = origin + offset + let remainder = position % alignment + + guard remainder != 0 else { return } + + let padding = alignment - remainder + + guard remaining >= padding + else { throw DBusProtocolError.endOfStream } + + for index in offset ..< (offset + padding) { + + guard bytes[index] == 0 + else { throw DBusProtocolError.invalidPadding } + } + + offset += padding + } + + mutating func readByte() throws -> UInt8 { + + guard remaining >= 1 + else { throw DBusProtocolError.endOfStream } + + defer { offset += 1 } + return bytes[offset] + } + + mutating func readBytes(_ count: Int) throws -> [UInt8] { + + guard count >= 0, remaining >= count + else { throw DBusProtocolError.endOfStream } + + defer { offset += count } + return Swift.Array(bytes[offset ..< offset + count]) + } + + mutating func readInteger(_ type: T.Type = T.self) throws -> T { + + try align(to: MemoryLayout.size) + return try readIntegerUnaligned(type) + } + + mutating func readIntegerUnaligned(_ type: T.Type = T.self) throws -> T { + + let size = MemoryLayout.size + + guard remaining >= size + else { throw DBusProtocolError.endOfStream } + + var value = T.zero + withUnsafeMutableBytes(of: &value) { destination in + for index in 0 ..< size { + destination[index] = bytes[offset + index] + } + } + offset += size + + return T(value, from: endianness) + } + + /// Read a `STRING` or `OBJECT_PATH`: a `UInt32` byte count, the UTF-8 bytes, then a NUL. + mutating func readString() throws -> String { + + let length = try readInteger(UInt32.self) + return try readString(length: Int(length)) + } + + /// Read a `SIGNATURE`: a single byte count, the ASCII bytes, then a NUL. + mutating func readSignatureString() throws -> String { + + let length = try readByte() + return try readString(length: Int(length)) + } + + private mutating func readString(length: Int) throws -> String { + + let utf8 = try readBytes(length) + + // The terminating NUL is not counted in the length but is always present. + guard try readByte() == 0 + else { throw DBusProtocolError.invalidString } + + // Strings on the bus must be valid UTF-8; reject rather than substitute replacement + // characters, which would silently corrupt object paths and interface names. + guard let string = String(validatingUTF8: utf8) + else { throw DBusProtocolError.invalidString } + + return string + } +} + +// MARK: - Values + +internal extension DBusUnmarshaller { + + /// Read the complete values described by a signature. + mutating func read(signature: DBusSignature) throws -> [DBusMessageArgument] { + + var arguments = [DBusMessageArgument]() + arguments.reserveCapacity(signature.count) + + for type in signature { + arguments.append(try read(type)) + } + + return arguments + } + + /// Read a single complete value of the given type. + mutating func read(_ type: DBusSignature.ValueType) throws -> DBusMessageArgument { + + switch type { + + case .byte: + return .byte(try readByte()) + + case .boolean: + let value = try readInteger(UInt32.self) + // Only 0 and 1 are legal; anything else is invalid per the specification. + switch value { + case 0: return .boolean(false) + case 1: return .boolean(true) + default: throw DBusProtocolError.invalidValue("Boolean value \(value) is not 0 or 1") + } + + case .int16: + return .int16(try readInteger(Int16.self)) + case .uint16: + return .uint16(try readInteger(UInt16.self)) + case .int32: + return .int32(try readInteger(Int32.self)) + case .uint32: + return .uint32(try readInteger(UInt32.self)) + case .int64: + return .int64(try readInteger(Int64.self)) + case .uint64: + return .uint64(try readInteger(UInt64.self)) + + case .double: + return .double(Double(bitPattern: try readInteger(UInt64.self))) + + case .fileDescriptor: + // The wire carries an index into the descriptors delivered out of band. + let index = Int(try readInteger(UInt32.self)) + + guard index < fileDescriptors.count + else { throw DBusProtocolError.invalidValue("File descriptor index \(index) is out of range; \(fileDescriptors.count) were received") } + + return .fileDescriptor(DBusMessageArgument.FileDescriptor(rawValue: fileDescriptors[index])) + + case .string: + return .string(try readString()) + + case .objectPath: + let string = try readString() + guard let objectPath = DBusObjectPath(rawValue: string) + else { throw DBusProtocolError.invalidValue("Invalid object path '\(string)'") } + return .objectPath(objectPath) + + case .signature: + let string = try readSignatureString() + guard let signature = DBusSignature(rawValue: string) + else { throw DBusProtocolError.invalidSignature(string) } + return .signature(signature) + + case let .array(elementType): + + var elements = [DBusMessageArgument]() + try readArray(elementAlignment: elementType.alignment) { unmarshaller in + elements.append(try unmarshaller.read(elementType)) + } + + guard let array = DBusMessageArgument.Array(type: elementType, elements) + else { throw DBusProtocolError.invalidValue("Array elements do not match the declared type") } + + return .array(array) + + case let .dictionary(dictionaryType): + + var entries = [DBusMessageArgument.Dictionary.Entry]() + try readArray(elementAlignment: dictionaryEntryAlignment) { unmarshaller in + try unmarshaller.align(to: dictionaryEntryAlignment) + let key = try unmarshaller.read(dictionaryType.key) + let value = try unmarshaller.read(dictionaryType.value) + entries.append(DBusMessageArgument.Dictionary.Entry(key: key, value: value)) + } + + guard let dictionary = DBusMessageArgument.Dictionary(keyType: dictionaryType.key, + valueType: dictionaryType.value, + entries) + else { throw DBusProtocolError.invalidValue("Dictionary entries do not match the declared type") } + + return .dictionary(dictionary) + + case let .struct(structureType): + + try align(to: 8) + + var elements = [DBusMessageArgument]() + elements.reserveCapacity(structureType.count) + + for elementType in structureType { + elements.append(try read(elementType)) + } + + guard let structure = DBusMessageArgument.Structure(elements) + else { throw DBusProtocolError.invalidValue("Empty struct") } + + return .struct(structure) + + case .variant: + + let signatureString = try readSignatureString() + + guard let signature = DBusSignature(rawValue: signatureString) + else { throw DBusProtocolError.invalidSignature(signatureString) } + + guard signature.count == 1 + else { throw DBusProtocolError.invalidValue("Variant must contain exactly one complete type, found \(signature.count)") } + + let element = try read(signature[0]) + + return .variant(DBusMessageArgument.Variant(element)) + } + } + + /// Read an `ARRAY` body, invoking `element` once per element. + /// + /// - Note: The length prefix counts only the element data. Padding between the length and + /// the first element is present even for an empty array and is not part of the length. + private mutating func readArray(elementAlignment: Int, + _ element: (inout DBusUnmarshaller) throws -> ()) throws { + + let length = Int(try readInteger(UInt32.self)) + + guard length <= maximumArrayLength + else { throw DBusProtocolError.invalidValue("Array length \(length) exceeds the maximum") } + + try align(to: elementAlignment) + + let start = offset + + guard remaining >= length + else { throw DBusProtocolError.endOfStream } + + while offset - start < length { + + let positionBefore = offset + try element(&self) + + // Defensive: a zero-width element would loop forever. + guard offset > positionBefore + else { throw DBusProtocolError.invalidValue("Array element consumed no bytes") } + } + + guard offset - start == length + else { throw DBusProtocolError.invalidValue("Array elements overran the declared length") } + } +} diff --git a/Sources/DBus/Member.swift b/Sources/DBus/Member.swift index 3b74875..eab0b47 100644 --- a/Sources/DBus/Member.swift +++ b/Sources/DBus/Member.swift @@ -5,48 +5,72 @@ // Created by Alsey Coleman Miller on 11/3/18. // -import CDBus - /** DBus Member Name - + Member (i.e. method or signal) names: * Must only contain the ASCII characters "[A-Z][a-z][0-9]_" and may not begin with a digit. * Must not contain the '.' (period) character. * Must not exceed the maximum name length. * Must be at least 1 byte in length. - + It is conventional for member names on D-Bus to consist of capitalized words with no punctuation ("camel-case"). Method names should usually be verbs, such as "`GetItems`", and signal names should usually be a description of an event, such as "`ItemsChanged`". */ -public struct DBusMember: RawRepresentable, Equatable, Hashable { - +public struct DBusMember: RawRepresentable, Equatable, Hashable, Sendable { + public let rawValue: String - + public init?(rawValue: String) { - + do { try DBusMember.validate(rawValue) } catch { return nil } - + self.rawValue = rawValue } } internal extension DBusMember { - + + static let length = (min: 1, max: 255) + + init(_ unsafe: String) { + + guard let value = DBusMember(rawValue: unsafe) + else { fatalError("Invalid member \(unsafe)") } + + self = value + } + static func validate(_ string: String) throws { - - let error = DBusError() - guard Bool(dbus_validate_member(string, &error.internalValue)) - else { throw error } + + let bytes = string.utf8 + + guard bytes.count >= length.min, + bytes.count <= length.max + else { throw DBusError.invalidMember(string) } + + // May not begin with a digit. The allowed character set excludes '.' by construction. + guard let first = bytes.first, + first.isASCIIDigit == false, + bytes.allSatisfy({ $0.isObjectPathElementByte }) + else { throw DBusError.invalidMember(string) } + } +} + +private extension DBusError { + + static func invalidMember(_ string: String) -> DBusError { + + return DBusError(name: .invalidArguments, message: "Member name was not valid: '\(string)'") } } // MARK: - CustomStringConvertible extension DBusMember: CustomStringConvertible { - + public var description: String { - + return rawValue } } diff --git a/Sources/DBus/Message.swift b/Sources/DBus/Message.swift index 9e4c3a9..0410ea6 100644 --- a/Sources/DBus/Message.swift +++ b/Sources/DBus/Message.swift @@ -6,8 +6,6 @@ // Copyright © 2016 PureSwift. All rights reserved. // -import CDBus - /// Message to be sent or received over a `DBusConnection`. /// /// A `DBusMessage` is the most basic unit of communication over a `DBusConnection`. @@ -16,459 +14,262 @@ import CDBus /// /// A message has header fields such as the sender, destination, method or signal name, and so forth. /// -public final class DBusMessage { - - // MARK: - Internal Properties - - internal let internalPointer: OpaquePointer - - // MARK: - Initialization - - deinit { - - dbus_message_unref(internalPointer) - } - - internal init(_ internalPointer: OpaquePointer) { - - self.internalPointer = internalPointer - } - - /// Constructs a new message of the given message type. - public init(type: DBusMessageType) throws { - - guard let internalPointer = dbus_message_new(type.rawValue) - else { throw RuntimeError.generic("dbus_message_new() failed") } - - self.internalPointer = internalPointer - } - - /// Creates a new message that is an error reply to another message. - /// - /// Error replies are most common in response to method calls, but can be returned in reply to any message. - /// The error name must be a valid error name according to the syntax given in the D-Bus specification. - /// If you don't want to make up an error name just use `org.freedesktop.DBus.Error.Failed`. - /// - /// - Parameter error: A tuple consisting of the message to reply to, the error name, and the error message. - public init(error: Error) throws { - - guard let internalPointer = dbus_message_new_error(error.replyTo.internalPointer, error.name, error.message) - else { throw RuntimeError.generic("dbus_message_new_error() failed") } - - self.internalPointer = internalPointer - } - - /// Constructs a new message to invoke a method on a remote object. - /// - /// - Note: Destination, path, interface, and method name can't contain any invalid characters (see the D-Bus specification). - public init(methodCall: MethodCall) throws { - - // Returns NULL if memory can't be allocated for the message. - guard let internalPointer = dbus_message_new_method_call(methodCall.destination?.rawValue, methodCall.path.rawValue, methodCall.interface?.rawValue, methodCall.method) else { - throw RuntimeError.generic("dbus_message_new_method_call() failed") - } - - self.internalPointer = internalPointer - } - - /// Constructs a message that is a reply to a method call. - public init(methodReturn: DBusMessage) throws { - - guard let internalPointer = dbus_message_new_method_return(methodReturn.internalPointer) - else { throw RuntimeError.generic("dbus_message_new_method_return() failed") } - - self.internalPointer = internalPointer - } - - /// Constructs a new message representing a signal emission. - /// - /// A signal is identified by its originating object path, interface, and the name of the signal. - /// - /// - Note: Path, interface, and signal name must all be valid. - public init(signal: Signal) throws { - - guard let internalPointer = dbus_message_new_signal(signal.path, signal.interface, signal.name) - else { throw RuntimeError.generic("dbus_message_new_signal() failed") } - - self.internalPointer = internalPointer - } - - // MARK: - Methods - - /// Append argument. - public func append(_ argument: DBusMessageArgument) throws { - - var iterator = DBusMessageIter(appending: self) - try iterator.append(argument: argument) - } - - /// Append contents of sequence. - public func append (contentsOf sequence: S) throws where S.Element == DBusMessageArgument { - - var iterator = DBusMessageIter(appending: self) - try sequence.forEach { try iterator.append(argument: $0) } - } - - // MARK: - Properties - +/// - Note: This is a value type. It owns no external resource and can be freely copied and sent +/// across concurrency domains. +public struct DBusMessage: Equatable, Hashable, Sendable { + /// The message type. - public lazy var type: DBusMessageType = { - - let rawValue = dbus_message_get_type(internalPointer) - - guard let type = DBusMessageType(rawValue: rawValue) - else { fatalError("Invalid DBus Message type: \(rawValue)") } - - return type - }() - - /// Checks whether a message contains Unix file descriptors. - public var containsFileDescriptors: Bool { - - return Bool(dbus_message_contains_unix_fds(internalPointer)) - } - - /// The serial of a message or `0` if none has been specified. + public var type: DBusMessageType + + /// Message flags. + public var flags: Flags + + /// The serial of a message, or `0` if none has been assigned. /// /// The message's serial number is provided by the application sending the message and /// is used to identify replies to this message. /// - /// - Note: All messages received on a connection will have a serial provided by the remote application. - /// - /// For messages you're sending, `DBusConnection.send()` will assign a serial and return it to you. - public var serial: UInt32 { - - get { return dbus_message_get_serial(internalPointer) } - - set { dbus_message_set_serial(internalPointer, newValue) } - } - - /// The reply serial of a message (the serial of the message this is a reply to). - public var replySerial: UInt32 { - - return dbus_message_get_reply_serial(internalPointer) - } - - /// Sets the reply serial of a message (the serial of the message this is a reply to). - public func setReplySerial(_ newValue: UInt32) throws { - - guard Bool(dbus_message_set_reply_serial(internalPointer, newValue)) - else { throw RuntimeError.generic("dbus_message_set_reply_serial() failed") } - } - - /// Flag indicating that the caller of the method is prepared to wait for interactive authorization to take place - /// (for instance via Polkit) before the actual method is processed. - /// - /// The flag is `false` by default; - /// that is, by default the other end is expected to make any authorization decisions non-interactively and promptly. - public var allowInteractiveAuthorization: Bool { - - get { return Bool(dbus_message_get_allow_interactive_authorization(internalPointer)) } - - set { dbus_message_set_allow_interactive_authorization(internalPointer, dbus_bool_t(newValue)) } - } - - /// Sets a flag indicating that an owner for the destination name will be automatically started before the message is delivered. - /// - /// When this flag is set, the message is held until a name owner finishes starting up, - /// or fails to start up. In case of failure, the reply will be an error. - /// - /// The flag is set to `true` by default, i.e. auto starting is the default. - public var autoStart: Bool { - - get { return Bool(dbus_message_get_auto_start(internalPointer)) } - - set { dbus_message_set_auto_start(internalPointer, dbus_bool_t(newValue)) } - } - - /// Flag indicating that the message does not want a reply; - /// if this flag is set, the other end of the connection may (but is not required to) - /// optimize by not sending method return or error replies. - /// - /// The flag is `false` by default, that is by default the other end is required to reply. - /// - /// - Note: If this flag is set, there is no way to know whether the message successfully arrived at the remote end. - /// Normally you know a message was received when you receive the reply to it. - public var noReply: Bool { - - get { return Bool(dbus_message_get_no_reply(internalPointer)) } - - set { dbus_message_set_no_reply(internalPointer, dbus_bool_t(newValue)) } - } - - /// The destination is the name of another connection on the bus - /// and may be either the unique name assigned by the bus to each connection, - /// or a well-known name specified in advance. - /// - /// The destination name must contain only valid characters as defined in the D-Bus specification. - public var destination: DBusBusName? { - - guard let string = getString(dbus_message_get_destination) - else { return nil } - - return DBusBusName(string) - } - - /// Sets the message's destination. - public func setDestination(_ newValue: DBusBusName?) throws { - - try setString(dbus_message_set_destination, newValue?.rawValue) - } - - /// The name of the error (for `Error` message type). - /// - /// The name is fully-qualified (namespaced). - /// The error name must contain only valid characters as defined in the D-Bus specification. - public var errorName: String? { - - guard let name = getString(dbus_message_get_error_name) - else { return nil } + /// - Note: All messages received on a connection will have a serial provided by the remote + /// application. For messages you send, `DBusConnection.send()` assigns a serial. + public var serial: UInt32 - return name - } - - /// Sets the name of the error (DBUS_MESSAGE_TYPE_ERROR). - /// The name is fully-qualified (namespaced). - /// The error name must contain only valid characters as defined in the D-Bus specification. - public func setErrorName(_ newValue: String) throws { - - try setString(dbus_message_set_error_name, newValue) - } - - /// The interface this message is being sent to (for method call type) - /// or the interface a signal is being emitted from (for signal call type). - /// - /// The interface name must contain only valid characters as defined in the D-Bus specification. - public var interface: DBusInterface? { - - guard let string = getString(dbus_message_get_interface) - else { return nil } - - guard let interface = DBusInterface(rawValue: string) - else { fatalError("Invalid interface \(string)") } - - return interface - } - - /// Sets the interface this message is being sent to (for `DBusMessageType.MethodCall`) - /// or the interface a signal is being emitted from (for `DBusMessageType.Signal`). - public func setInterface(_ newValue: DBusInterface?) throws { - - try setString(dbus_message_set_interface, newValue?.rawValue) - } - /// The object path this message is being sent to (for method call type) /// or the one a signal is being emitted from (for signal call type). - /// - /// The path must contain only valid characters as defined in the D-Bus specification. - public var path: DBusObjectPath? { - - guard let string = getString(dbus_message_get_path) - else { return nil } - - guard let objectPath = DBusObjectPath(rawValue: string) - else { fatalError("Invalid object path \(string)") } - - return objectPath - } - - /// Sets the object path this message is being sent to (for `DBusMessageType.MethodCall`) - /// or the one a signal is being emitted from (for `DBusMessageType.Signal`). - public func setPath(_ newValue: DBusObjectPath?) throws { - - try setString(dbus_message_set_path, newValue?.rawValue) - } - + public var path: DBusObjectPath? + + /// The interface this message is being sent to (for method call type) + /// or the interface a signal is being emitted from (for signal call type). + public var interface: DBusInterface? + /// The interface member being invoked (for method call type) or emitted (for signal type). - /// - /// The member name must contain only valid characters as defined in the D-Bus specification. - public var member: DBusMember? { - - guard let string = getString(dbus_message_get_member) - else { return nil } - - guard let member = DBusMember(rawValue: string) - else { fatalError("Invalid member \(string)") } - - return member - } - - /// Sets the interface member being invoked (`DBusMessageType.MethodCall`) - /// or emitted (`DBusMessageType.Signal`). - /// - /// The member name must contain only valid characters as defined in the D-Bus specification. - public func setMember(_ newValue: DBusMember?) throws { - - try setString(dbus_message_set_member, newValue?.rawValue) - } - + public var member: DBusMember? + + /// The name of the error (for `error` message type). + public var errorName: DBusError.Name? + + /// The serial of the message this is a reply to. + public var replySerial: UInt32? + + /// The name of another connection on the bus this message is addressed to. + public var destination: DBusBusName? + /// The message sender. /// - /// The sender must be a valid bus name as defined in the D-Bus specification. - /// - /// - Note: Usually you don't want to call this. - /// The message bus daemon will call it to set the origin of each message. - /// If you aren't implementing a message bus daemon you shouldn't need to set the sender. - public var sender: DBusBusName? { - - guard let string = getString(dbus_message_get_sender) - else { return nil } - - return DBusBusName(string) - } - - /// Sets the message sender. - /// - /// The sender must be a valid bus name as defined in the D-Bus specification. + /// - Note: Usually you don't set this. The message bus daemon sets the origin of each message. + public var sender: DBusBusName? + + /// The number of Unix file descriptors that accompany this message. /// - /// - Note: Usually you don't want to call this. - /// The message bus daemon will call it to set the origin of each message. - /// If you aren't implementing a message bus daemon you shouldn't need to set the sender. - public func setSender(_ newValue: DBusBusName?) throws { - - try setString(dbus_message_set_sender, newValue?.rawValue) - } - - // MARK: - Private Methods - - private func getString(_ function: (OpaquePointer?) -> (UnsafePointer?)) -> String? { - - // should not be free - guard let cString = function(internalPointer) - else { return nil } - - return String(cString: cString) - } - - private func setString(_ function: (OpaquePointer?, UnsafePointer?) -> (dbus_bool_t), _ newValue: String?) throws { - - if let newValue = newValue { - - guard Bool(newValue.withCString({ function(internalPointer, $0) })) - else { throw RuntimeError.generic("DBusMessage.setString() failed") } - - } else { - - guard Bool(function(internalPointer, nil)) - else { throw RuntimeError.generic("DBusMessage.setString() failed") } - } + /// - Note: File descriptor passing is not yet implemented; this field is decoded and + /// preserved so that a message round-trips, but the descriptors themselves are not + /// transferred. + public var unixFileDescriptorCount: UInt32? + + /// The message body. + public var arguments: [DBusMessageArgument] + + public init(type: DBusMessageType, + flags: Flags = [], + serial: UInt32 = 0, + path: DBusObjectPath? = nil, + interface: DBusInterface? = nil, + member: DBusMember? = nil, + errorName: DBusError.Name? = nil, + replySerial: UInt32? = nil, + destination: DBusBusName? = nil, + sender: DBusBusName? = nil, + unixFileDescriptorCount: UInt32? = nil, + arguments: [DBusMessageArgument] = []) { + + self.type = type + self.flags = flags + self.serial = serial + self.path = path + self.interface = interface + self.member = member + self.errorName = errorName + self.replySerial = replySerial + self.destination = destination + self.sender = sender + self.unixFileDescriptorCount = unixFileDescriptorCount + self.arguments = arguments } } -// MARK: - Copying +// MARK: - Computed Properties public extension DBusMessage { - - /** - Creates a new message that is an exact replica of the message specified, except that its refcount is set to 1, its message serial is reset to 0, and if the original message was "locked" (in the outgoing message queue and thus not modifiable) the new message will not be locked. - */ - func copy() throws -> DBusMessage { - - guard let copyPointer = dbus_message_copy(internalPointer) - else { throw try DBusError(name: DBusError.Name.noMemory, message: "Could not copy message") } - - let copyMessage = DBusMessage(copyPointer) - - return copyMessage + + /// The signature of the message body. + var signature: DBusSignature { + + return arguments.signature } -} -// MARK: - Sequence + /// Whether the message contains Unix file descriptors. + var containsFileDescriptors: Bool { -extension DBusMessage: Sequence { - - public typealias Element = DBusMessageArgument - - public func makeIterator() -> Iterator { - - return Iterator(self) + return (unixFileDescriptorCount ?? 0) > 0 } } +// MARK: - Flags + public extension DBusMessage { - - /// DBus Message Iterator - struct Iterator: IteratorProtocol { - - public typealias Element = DBusMessageArgument - - /// Internal libdbus iterator - internal private(set) var internalValue: DBusMessageIter - - /// Intialize for iterating the specifed message. - internal init(_ message: DBusMessage) { - - // Message doesnt need to be retained becuase this should only be called while the message is still >= 1 ARC. - self.internalValue = DBusMessageIter(iterating: message) - } - - public mutating func next() -> DBusMessageArgument? { - - return internalValue.next() + + /// Message header flags. + struct Flags: OptionSet, Equatable, Hashable, Sendable { + + public var rawValue: UInt8 + + public init(rawValue: UInt8) { + + self.rawValue = rawValue } + + /// This message does not expect method return messages or error messages, + /// even if it is of a type that can have a reply; the reply should be omitted. + /// + /// - Note: If this flag is set, there is no way to know whether the message successfully + /// arrived at the remote end. + public static let noReplyExpected = Flags(rawValue: 0x01) + + /// The bus must not launch an owner for the destination name in response to this message. + public static let noAutoStart = Flags(rawValue: 0x02) + + /// This message may prompt the user for interactive authorization + /// (for instance via Polkit) before the actual method is processed. + /// + /// The flag is unset by default; that is, by default the other end is expected to make + /// any authorization decisions non-interactively and promptly. + public static let allowInteractiveAuthorization = Flags(rawValue: 0x04) } } // MARK: - Supporting Types public extension DBusMessage { - - struct Error { - - public let replyTo: DBusMessage - public let name: String - public let message: String - - public init(replyTo: DBusMessage, name: String, message: String) { - - self.replyTo = replyTo - self.name = name - self.message = message - } - - public init(replyTo: DBusMessage, error: DBusError) { - - self.init(replyTo: replyTo, name: error.name, message: error.message) + + /// A method call to invoke on a remote object. + struct MethodCall: Equatable, Hashable, Sendable { + + /// The name of the connection the call is addressed to. + public var destination: DBusBusName? + + /// The object to invoke the method on. + public var path: DBusObjectPath + + /// The interface the method belongs to. + public var interface: DBusInterface? + + /// The method to invoke. + public var method: DBusMember + + public init(destination: DBusBusName? = nil, + path: DBusObjectPath, + interface: DBusInterface? = nil, + method: DBusMember) { + + self.destination = destination + self.path = path + self.interface = interface + self.method = method } } } public extension DBusMessage { - - struct MethodCall { - - public let destination: DBusBusName? - public let path: DBusObjectPath - public let interface: DBusInterface? - public let method: String + + /// A signal is identified by its originating object path, interface, and the name of the signal. + struct Signal: Equatable, Hashable, Sendable { + + /// The object the signal is emitted from. + public var path: DBusObjectPath + + /// The interface the signal belongs to. + public var interface: DBusInterface + + /// The name of the signal. + public var name: DBusMember + + public init(path: DBusObjectPath, + interface: DBusInterface, + name: DBusMember) { + + self.path = path + self.interface = interface + self.name = name + } } } +// MARK: - Initializers + public extension DBusMessage { - - /// A signal is identified by its originating object path, interface, and the name of the signal. - struct Signal { - - public let path: String - public let interface: String - public let name: String + + /// Constructs a new message to invoke a method on a remote object. + init(methodCall: MethodCall, arguments: [DBusMessageArgument] = []) { + + self.init(type: .methodCall, + path: methodCall.path, + interface: methodCall.interface, + member: methodCall.method, + destination: methodCall.destination, + arguments: arguments) + } + + /// Constructs a message that is a reply to a method call. + init(methodReturn replyTo: DBusMessage, arguments: [DBusMessageArgument] = []) { + + self.init(type: .methodReturn, + replySerial: replyTo.serial, + destination: replyTo.sender, + arguments: arguments) + } + + /// Constructs a new message representing a signal emission. + init(signal: Signal, arguments: [DBusMessageArgument] = []) { + + self.init(type: .signal, + path: signal.path, + interface: signal.interface, + member: signal.name, + arguments: arguments) + } + + /// Creates a new message that is an error reply to another message. + /// + /// Error replies are most common in response to method calls, but can be returned in reply + /// to any message. If you don't want to make up an error name just use + /// `org.freedesktop.DBus.Error.Failed`. + init(replyTo: DBusMessage, error: DBusError) { + + self.init(type: .error, + errorName: error.name, + replySerial: replyTo.serial, + destination: replyTo.sender, + arguments: [.string(error.message)]) } } +// MARK: - Error Extraction + public extension DBusError { - /** - Sets a DBusError based on the contents of the given message. - The error is only set if the message is an error message, as in `DBusMessageType.error`. The name of the error is set to the name of the message, and the error message is set to the first argument if the argument exists and is a string. - */ - convenience init?(message: DBusMessage) { - guard message.type == .error - else { return nil } + /// Extract the error carried by an error-reply message. + /// + /// The name of the error is taken from the message's `errorName` header field, and the + /// message text from the first argument if it exists and is a string. + /// + /// - Returns: `nil` if the message is not of type `error`. + init?(message: DBusMessage) { - self.init() - guard Bool(dbus_set_error_from_message(&self.internalValue, message.internalPointer)) + guard message.type == .error, + let name = message.errorName else { return nil } - assert(isSet == true) + let text = message.arguments.first?.stringValue ?? "" + + self.init(name: name, message: text) } } diff --git a/Sources/DBus/MessageArgument.swift b/Sources/DBus/MessageArgument.swift index f4588a0..e172a80 100644 --- a/Sources/DBus/MessageArgument.swift +++ b/Sources/DBus/MessageArgument.swift @@ -6,11 +6,9 @@ // Copyright © 2016 Pure All rights reserved. // -import CDBus - /// DBus Message argument value. -public enum DBusMessageArgument: Equatable { - +public enum DBusMessageArgument: Equatable, Hashable, Sendable { + case byte(UInt8) case boolean(Bool) case int16(Int16) @@ -21,22 +19,27 @@ public enum DBusMessageArgument: Equatable { case uint64(UInt64) case double(Double) case fileDescriptor(FileDescriptor) - + case string(String) case objectPath(DBusObjectPath) case signature(DBusSignature) - + case array(Array) case `struct`(Structure) - //case variant - //case dictionaryEntry + case dictionary(Dictionary) + + /// A value whose type is carried alongside the value itself. + /// + /// - Note: `indirect` because the payload contains a `DBusMessageArgument` directly rather + /// than through an array, which would otherwise make the enum recursively sized. + indirect case variant(Variant) } public extension DBusMessageArgument { - - /// Argument value type. + + /// Argument value type. var type: DBusSignature.ValueType { - + switch self { case .byte: return .byte case .boolean: return .boolean @@ -51,8 +54,10 @@ public extension DBusMessageArgument { case .string: return .string case .objectPath: return .objectPath case .signature: return .signature + case .variant: return .variant case let .array(array): return .array(array.type) case let .struct(structure): return .struct(structure.type) + case let .dictionary(dictionary): return .dictionary(dictionary.type) } } } @@ -60,47 +65,82 @@ public extension DBusMessageArgument { // MARK: - Supporting Types public extension DBusMessageArgument { - + /// File Descriptor - struct FileDescriptor: RawRepresentable, Equatable, Hashable { - - public var rawValue: CInt - - public init(rawValue: CInt) { - + struct FileDescriptor: RawRepresentable, Equatable, Hashable, Sendable { + + public var rawValue: Int32 + + public init(rawValue: Int32) { + self.rawValue = rawValue } } } +// MARK: - Variant + +public extension DBusMessageArgument { + + /// A variant contains a single complete value of any type, with the type marshalled + /// alongside the value. + struct Variant: Equatable, Hashable, Sendable { + + /// The contained value. + public let element: DBusMessageArgument + + public init(_ element: DBusMessageArgument) { + + self.element = element + } + } +} + +public extension DBusMessageArgument.Variant { + + /// The type of the contained value. + /// + /// - Note: This is the signature marshalled *inside* the variant, not the variant's own + /// type code. + var elementType: DBusSignature.ValueType { + + return element.type + } +} + +// MARK: - Structure + public extension DBusMessageArgument { - + /// Structure - struct Structure: Equatable { - + struct Structure: Equatable, Hashable, Sendable { + /// Structure elements. internal let elements: [DBusMessageArgument] - + /// Initializes a structure argument with the specified arguments. + /// + /// - Note: Returns `nil` for an empty array; the specification requires at least one + /// type code between the parentheses. public init?(_ elements: [DBusMessageArgument]) { - + guard elements.isEmpty == false else { return nil } - + self.elements = elements } } } public extension DBusMessageArgument.Structure { - + var type: DBusSignature.StructureType { - + let types = elements.map { $0.type } - + guard let structureType = DBusSignature.StructureType(types) else { fatalError("Invalid structure") } - + return structureType } } @@ -108,84 +148,89 @@ public extension DBusMessageArgument.Structure { // MARK: RandomAccessCollection extension DBusMessageArgument.Structure: RandomAccessCollection { - + public typealias Element = DBusMessageArgument - + public typealias Index = Int - + public subscript (index: Index) -> Element { return elements[index] } - + public var count: Int { return elements.count } - + /// The start `Index`. public var startIndex: Index { return 0 } - + /// The end `Index`. /// /// This is the "one-past-the-end" position, and will always be equal to the `count`. public var endIndex: Index { return count } - + public func index(before i: Index) -> Index { return i - 1 } - + public func index(after i: Index) -> Index { return i + 1 } - + public func makeIterator() -> IndexingIterator { return IndexingIterator(_elements: self) } } +// MARK: - Array + public extension DBusMessageArgument { - - struct Array: Equatable { - + + struct Array: Equatable, Hashable, Sendable { + /// Array elements. internal let elements: [DBusMessageArgument] - + /// Type of the elements. + /// + /// - Note: Stored explicitly rather than inferred from `elements`, so that an empty + /// array still marshals with the correct element signature. public let type: DBusSignature.ValueType - + /// Initialize with an empty array. public init(type: DBusSignature.ValueType) { - + self.elements = [] self.type = type } - + /// Initialize with an array of homogenous array elements and tries to infer the element value type. public init?(_ elements: [Element]) { - + // dynamically infer signature guard let element = elements.first else { return nil } // can't infer from empty array - + self.init(type: element.type, elements) } - + /// Initialize with an array of homogenous array elements. public init?(type: DBusSignature.ValueType, _ elements: [Element]) { - + // validate homogenous array if elements.isEmpty == false { - + for element in elements { - + guard element.type == type else { return nil } // all elements must have the same type } } - + self.elements = elements self.type = type } @@ -195,147 +240,197 @@ public extension DBusMessageArgument { // MARK: RandomAccessCollection extension DBusMessageArgument.Array: RandomAccessCollection { - + public typealias Element = DBusMessageArgument - + public typealias Index = Int - + public subscript (index: Index) -> Element { return elements[index] } - + public var count: Int { return elements.count } - + /// The start `Index`. public var startIndex: Index { return 0 } - + /// The end `Index`. /// /// This is the "one-past-the-end" position, and will always be equal to the `count`. public var endIndex: Index { return count } - + public func index(before i: Index) -> Index { return i - 1 } - + public func index(after i: Index) -> Index { return i + 1 } - + public func makeIterator() -> IndexingIterator { return IndexingIterator(_elements: self) } } -// MARK: - DBusMessageArgumentValue +// MARK: - Dictionary -internal protocol DBusMessageArgumentValue { - - //init?(argument: DBusMessageArgument) - - func toArgument() -> DBusMessageArgument -} +public extension DBusMessageArgument { -extension UInt8: DBusMessageArgumentValue { - - func toArgument() -> DBusMessageArgument { - return .byte(self) - } -} + /// A dictionary, marshalled as an array of key/value entries. + /// + /// - Note: Entries are stored in order. D-Bus dictionaries are ordered on the wire, and the + /// specification does not require unique keys, so this deliberately does not use + /// `Swift.Dictionary`. + struct Dictionary: Equatable, Hashable, Sendable { -extension Bool: DBusMessageArgumentValue { - - func toArgument() -> DBusMessageArgument { - return .boolean(self) - } -} + /// A single key/value pair. + public struct Entry: Equatable, Hashable, Sendable { -extension Int16: DBusMessageArgumentValue { - - func toArgument() -> DBusMessageArgument { - return .int16(self) - } -} + public let key: DBusMessageArgument + + public let value: DBusMessageArgument + + public init(key: DBusMessageArgument, value: DBusMessageArgument) { + + self.key = key + self.value = value + } + } + + /// Dictionary entries, in wire order. + internal let entries: [Entry] + + /// Type of the keys. + public let keyType: DBusSignature.ValueType + + /// Type of the values. + public let valueType: DBusSignature.ValueType + + /// Initialize an empty dictionary with the specified key and value types. + /// + /// - Note: Returns `nil` if `keyType` is not a basic type. + public init?(keyType: DBusSignature.ValueType, valueType: DBusSignature.ValueType) { + + guard keyType.isBasic + else { return nil } + + self.entries = [] + self.keyType = keyType + self.valueType = valueType + } + + /// Initialize with entries, validating that all keys and values share a type. + /// + /// - Note: Returns `nil` if `keyType` is not a basic type, or if any entry does not + /// match the declared types. + public init?(keyType: DBusSignature.ValueType, + valueType: DBusSignature.ValueType, + _ entries: [Entry]) { + + guard keyType.isBasic + else { return nil } + + for entry in entries { + + guard entry.key.type == keyType, + entry.value.type == valueType + else { return nil } + } -extension Int32: DBusMessageArgumentValue { - - func toArgument() -> DBusMessageArgument { - return .int32(self) + self.entries = entries + self.keyType = keyType + self.valueType = valueType + } + + /// Initialize with entries, inferring the key and value types from the first entry. + /// + /// - Note: Returns `nil` for an empty array, since the types cannot be inferred. + public init?(_ entries: [Entry]) { + + guard let first = entries.first + else { return nil } + + self.init(keyType: first.key.type, valueType: first.value.type, entries) + } } } -extension Int64: DBusMessageArgumentValue { - - func toArgument() -> DBusMessageArgument { - return .int64(self) +public extension DBusMessageArgument.Dictionary { + + var type: DBusSignature.DictionaryType { + + guard let dictionaryType = DBusSignature.DictionaryType(key: keyType, value: valueType) + else { fatalError("Invalid dictionary key type \(keyType)") } + + return dictionaryType } } -extension UInt16: DBusMessageArgumentValue { - - func toArgument() -> DBusMessageArgument { - return .uint16(self) +// MARK: RandomAccessCollection + +extension DBusMessageArgument.Dictionary: RandomAccessCollection { + + public typealias Element = Entry + + public typealias Index = Int + + public subscript (index: Index) -> Element { + return entries[index] } -} -extension UInt32: DBusMessageArgumentValue { - - func toArgument() -> DBusMessageArgument { - return .uint32(self) + public var count: Int { + return entries.count } -} -extension UInt64: DBusMessageArgumentValue { - - func toArgument() -> DBusMessageArgument { - return .uint64(self) + /// The start `Index`. + public var startIndex: Index { + return 0 } -} -extension Double: DBusMessageArgumentValue { - - func toArgument() -> DBusMessageArgument { - return .double(self) + /// The end `Index`. + /// + /// This is the "one-past-the-end" position, and will always be equal to the `count`. + public var endIndex: Index { + return count } -} -extension DBusMessageArgument.FileDescriptor: DBusMessageArgumentValue { - - func toArgument() -> DBusMessageArgument { - return .fileDescriptor(self) + public func index(before i: Index) -> Index { + return i - 1 } -} -extension String: DBusMessageArgumentValue { - - func toArgument() -> DBusMessageArgument { - return .string(self) + public func index(after i: Index) -> Index { + return i + 1 } -} -extension DBusObjectPath: DBusMessageArgumentValue { - - func toArgument() -> DBusMessageArgument { - return .objectPath(self) + public func makeIterator() -> IndexingIterator { + return IndexingIterator(_elements: self) } } -extension DBusSignature: DBusMessageArgumentValue { - - func toArgument() -> DBusMessageArgument { - return .signature(self) +// MARK: - Convenience Accessors + +public extension DBusMessageArgument { + + /// The contained value if this is a variant, otherwise `nil`. + var variantValue: DBusMessageArgument? { + + guard case let .variant(variant) = self else { return nil } + return variant.element } -} -extension DBusMessageArgument.Array: DBusMessageArgumentValue { - - func toArgument() -> DBusMessageArgument { - return .array(self) + /// The string value, unwrapping a single level of variant. + var stringValue: String? { + + switch self { + case let .string(value): return value + case let .variant(variant): return variant.element.stringValue + default: return nil + } } } diff --git a/Sources/DBus/MessageIterator.swift b/Sources/DBus/MessageIterator.swift deleted file mode 100644 index 8e83f79..0000000 --- a/Sources/DBus/MessageIterator.swift +++ /dev/null @@ -1,275 +0,0 @@ -// -// MessageIterator.swift -// DBus -// -// Created by Alsey Coleman Miller on 10/12/18. -// - -import CDBus - -// MARK: - Iterating - -extension DBusMessageIter { - - init(iterating message: DBusMessage) { - - self.init() - dbus_message_iter_init(message.internalPointer, &self) - } - - mutating func next() -> DBusMessageArgument? { - - // make sure there is a valid element - guard let argumentType = DBusType(Int(dbus_message_iter_get_arg_type(&self))) - else { return nil } - - let value: DBusMessageArgument - - switch argumentType { - - case .byte: - value = .byte(readBasic().byt) - case .boolean: - value = .boolean(Bool(readBasic().bool_val)) - case .int16: - value = .int16(readBasic().i16) - case .int32: - value = .int32(readBasic().i32) - case .int64: - value = .int64(Int64(readBasic().i64)) - case .uint16: - value = .uint16(readBasic().u16) - case .uint32: - value = .uint32(readBasic().u32) - case .uint64: - value = .uint64(UInt64(readBasic().u64)) - case .double: - value = .double(readBasic().dbl) - case .fileDescriptor: - let fileDescriptor = DBusMessageArgument.FileDescriptor(rawValue: readBasic().fd) - value = .fileDescriptor(fileDescriptor) - - case .string: - value = .string(readString()) - case .objectPath: - value = .objectPath(DBusObjectPath(readString())) - case .signature: - value = .signature(DBusSignature(readString())) - - case .array: - - guard let signature = try? self.signature(), - let arrayType = signature.first, - case let .array(valueType) = arrayType - else { fatalError("Invalid array signature \((try? self.signature())?.description ?? "")") } - - var elements = [DBusMessageArgument]() - recursiveIterate { elements.append($0) } - - guard let array = DBusMessageArgument.Array(type: valueType, elements) - else { fatalError("Invalid elements") } - - value = .array(array) - - case .struct: - - var elements = [DBusMessageArgument]() - recursiveIterate { elements.append($0) } - - guard let structure = DBusMessageArgument.Structure(elements) - else { fatalError("Invalid elements") } - - value = .struct(structure) - - default: - fatalError() - } - - // move iterator to next element in the sequence - dbus_message_iter_next(&self) - - // return value - return value - } - - /// Read a basic value into the provided pointer. - @inline(__always) - private mutating func readBasic() -> DBusBasicValue { - - var basicValue = DBusBasicValue() - withUnsafeMutablePointer(to: &basicValue) { - dbus_message_iter_get_basic(&self, UnsafeMutableRawPointer($0)) - } - return basicValue - } - - private mutating func readString() -> String { - - guard let cString = readBasic().str - else { fatalError("Nil string pointer") } - - return String(cString: cString) - } - - /// Recurses into a container value when reading values from a message. - private mutating func recursiveIterate(_ iterate: (DBusMessageArgument) throws -> ()) rethrows { - - /** - Recurses into a container value when reading values from a message, initializing a sub-iterator to use for traversing the child values of the container. - - Note that this recurses into a value, not a type, so you can only recurse if the value exists. The main implication of this is that if you have for example an empty array of array of int32, you can recurse into the outermost array, but it will have no values, so you won't be able to recurse further. There's no array of int32 to recurse into. - */ - - var subiterator = DBusMessageIter() - dbus_message_iter_recurse(&self, &subiterator) - - while let element = subiterator.next() { - try iterate(element) - } - } - - private mutating func signature() throws -> DBusSignature { - - guard let cString = dbus_message_iter_get_signature(&self) - else { throw try DBusError(name: DBusError.Name.noMemory, message: "Could not get signature") } - - let string = String(cString: cString) - - dbus_free(UnsafeMutableRawPointer(cString)) - - return DBusSignature(string) - } -} - -// MARK: - Appending - -/* - -internal extension DBusMessageIter { - - /// A message iterator for which `dbus_message_iter_abandon_container_if_open()` is the only valid operation. - static var closed: DBusMessageIter { - - var iter = DBusMessageIter() - dbus_message_iter_init_closed(&iter) - return iter - } - - /** - Abandons creation of a contained-typed value and frees resources created by dbus_message_iter_open_container(). - - Once this returns, the message is hosed and you have to start over building the whole message. - - Unlike dbus_message_iter_abandon_container(), it is valid to call this function on an iterator that was initialized with DBUS_MESSAGE_ITER_INIT_CLOSED, or an iterator that was already closed or abandoned. However, it is not valid to call this function on uninitialized memory. This is intended to be used in error cleanup code paths, similar to this pattern: - */ - @inline(__always) - mutating func abandonContainerIfOpen(_ subcontainer: inout DBusMessageIter) { - - dbus_message_iter_abandon_container_if_open(&self, &subcontainer) - } -} - -*/ - -internal extension DBusMessageIter { - - /// Initializes a DBusMessageIter for appending arguments to the end of a message. - init(appending message: DBusMessage) { - - self.init() - dbus_message_iter_init_append(message.internalPointer, &self) - } - - mutating func append(argument: DBusMessageArgument) throws { - - switch argument { - - case let .byte(value): - var basicValue = DBusBasicValue(byt: value) - try append(&basicValue, .byte) - case let .boolean(value): - var basicValue = DBusBasicValue(bool_val: dbus_bool_t(value)) - try append(&basicValue, .boolean) - case let .int16(value): - var basicValue = DBusBasicValue(i16: value) - try append(&basicValue, .int16) - case let .uint16(value): - var basicValue = DBusBasicValue(u16: value) - try append(&basicValue, .uint16) - case let .int32(value): - var basicValue = DBusBasicValue(i32: value) - try append(&basicValue, .int32) - case let .uint32(value): - var basicValue = DBusBasicValue(u32: value) - try append(&basicValue, .uint32) - case let .int64(value): - var basicValue = DBusBasicValue(i64: dbus_int64_t(value)) - try append(&basicValue, .int64) - case let .uint64(value): - var basicValue = DBusBasicValue(u64: dbus_uint64_t(value)) - try append(&basicValue, .uint64) - case let .double(value): - var basicValue = DBusBasicValue(dbl: value) - try append(&basicValue, .double) - case let .fileDescriptor(value): - var basicValue = DBusBasicValue(fd: value.rawValue) - try append(&basicValue, .fileDescriptor) - - case let .string(value): - try append(value) - case let .objectPath(value): - try append(value.rawValue, .objectPath) - case let .signature(value): - try append(value.rawValue, .signature) - - case let .array(array): - try appendContainer(type: .array, signature: DBusSignature([array.type])) { - for element in array { - try $0.append(argument: element) - } - } - - case let .struct(structure): - try appendContainer(type: .struct) { - for element in structure { - try $0.append(argument: element) - } - } - } - } - - private mutating func append(_ basicValue: inout DBusBasicValue, _ type: DBusType) throws { - - guard withUnsafePointer(to: &basicValue, { - Bool(dbus_message_iter_append_basic(&self, Int32(type.integerValue), UnsafeRawPointer($0))) - }) else { throw RuntimeError.generic("dbus_message_iter_append_basic() failed") } - } - - private mutating func append(_ string: String, _ type: DBusType = .string) throws { - - try string.withCString { - let cString = UnsafeMutablePointer(mutating: $0) - var basicValue = DBusBasicValue(str: cString) - try append(&basicValue, type) - } - } - - /** - Appends a container-typed value to the message. - */ - private mutating func appendContainer(type: DBusType, signature: DBusSignature? = nil, container: (inout DBusMessageIter) throws -> ()) throws { - - var subIterator = DBusMessageIter() - - /** - On success, you are required to append the contents of the container using the returned sub-iterator, and then call dbus_message_iter_close_container(). Container types are for example struct, variant, and array. For variants, the contained_signature should be the type of the single value inside the variant. For structs and dict entries, contained_signature should be NULL; it will be set to whatever types you write into the struct. For arrays, contained_signature should be the type of the array elements. - */ - - guard Bool(dbus_message_iter_open_container(&self, Int32(type.integerValue), signature?.rawValue, &subIterator)) - else { throw RuntimeError.generic("dbus_message_iter_open_container() failed") } - - defer { dbus_message_iter_close_container(&self, &subIterator) } - - try container(&subIterator) - } -} diff --git a/Sources/DBus/MessageType.swift b/Sources/DBus/MessageType.swift index 5cdfe8e..8324616 100644 --- a/Sources/DBus/MessageType.swift +++ b/Sources/DBus/MessageType.swift @@ -7,7 +7,7 @@ // /// The DBus Message type. -public enum DBusMessageType: CInt { +public enum DBusMessageType: UInt8, Sendable, CaseIterable { /// Message type of a method call message. /// diff --git a/Sources/DBus/ObjectPath.swift b/Sources/DBus/ObjectPath.swift index 050641b..38345d9 100644 --- a/Sources/DBus/ObjectPath.swift +++ b/Sources/DBus/ObjectPath.swift @@ -5,31 +5,28 @@ // Created by Alsey Coleman Miller on 10/20/18. // -import Foundation -import CDBus - /** DBus Object Path (e.g "`/com/example/MusicPlayer1`") - + The following rules define a valid object path. Implementations must not send or accept messages with invalid object paths. - + The path may be of any length. - + The path must begin with an ASCII '/' (integer 47) character, and must consist of elements separated by slash characters. - + Each element must only contain the ASCII characters "[A-Z][a-z][0-9]_" - + No element may be the empty string. - + Multiple '/' characters cannot occur in sequence. - + A trailing '/' character is not allowed unless the path is the root path (a single '/' character). */ -public struct DBusObjectPath { - +public struct DBusObjectPath: Sendable { + @usableFromInline internal private(set) var elements: [Element] - + /// Cached string. /// This will be the original string the object path was created from. /// @@ -38,22 +35,22 @@ public struct DBusObjectPath { /// but for values created from either a string or an array of elements, this value is cached. @usableFromInline internal private(set) var string: String? - + /// Initialize with an array of elements. public init(_ elements: [Element] = []) { - + self.elements = elements self.string = String(elements) } } internal extension DBusObjectPath { - + init(_ unsafe: String) { - + guard let value = DBusObjectPath(rawValue: unsafe) else { fatalError("Invalid object path \(unsafe)") } - + self = value } } @@ -61,49 +58,70 @@ internal extension DBusObjectPath { // MARK: - String Parsing internal extension DBusObjectPath { - + static let separator = "/".first! - + /// Parses the object path string and returns the parsed object path. static func parse(_ string: String) -> [Element]? { - + + return try? parseThrowing(string) + } + + static func validate(_ string: String) throws { + + _ = try parseThrowing(string) + } + + static func parseThrowing(_ string: String) throws -> [Element] { + // The path must begin with an ASCII '/' (integer 47) character, // and must consist of elements separated by slash characters. - guard let firstCharacter = string.first, // cant't be empty string - firstCharacter == separator, // must start with "/" - string.count == 1 || string.last != separator // last character - else { return nil } - - let pathStrings = string.split(separator: separator, - maxSplits: .max, - omittingEmptySubsequences: true) - + guard let firstCharacter = string.first // can't be an empty string + else { throw DBusError.invalidObjectPath(string) } + + guard firstCharacter == separator // must start with "/" + else { throw DBusError.invalidObjectPath(string) } + + // Drop the leading separator, then split on the remainder *without* omitting empty + // subsequences. An empty subsequence is then either a "//" in the middle or a trailing + // "/", both of which the specification forbids. + let remainder = string.dropFirst() + + // The root path is a single "/" and has no elements. + guard remainder.isEmpty == false + else { return [] } + + let pathStrings = remainder.split(separator: separator, + maxSplits: .max, + omittingEmptySubsequences: false) + var elements = [Element]() elements.reserveCapacity(pathStrings.count) // pre-allocate buffer - + for elementString in pathStrings { - + guard let element = Element(substring: elementString) - else { return nil } - + else { throw DBusError.invalidObjectPath(string) } + elements.append(element) } - + return elements } - - static func validate(_ string: String) throws { - - let error = DBusError() - guard Bool(dbus_validate_path(string, &error.internalValue)) - else { throw error } +} + +private extension DBusError { + + static func invalidObjectPath(_ string: String) -> DBusError { + + return DBusError(name: .invalidArguments, message: "Object path was not valid: '\(string)'") } } internal extension String { - + init(_ objectPath: [DBusObjectPath.Element]) { - + let separator = String(DBusObjectPath.separator) self = objectPath.isEmpty ? separator : objectPath.reduce("", { $0 + separator + $1.rawValue }) } @@ -112,18 +130,18 @@ internal extension String { // MARK: - RawRepresentable extension DBusObjectPath: RawRepresentable { - + public init?(rawValue: String) { - - guard let elements = DBusObjectPath.parse(rawValue) + + guard let elements = try? DBusObjectPath.parseThrowing(rawValue) else { return nil } - + self.elements = elements self.string = rawValue // store original string } - + public var rawValue: String { - + get { return string ?? String(elements) } } } @@ -131,16 +149,16 @@ extension DBusObjectPath: RawRepresentable { // MARK: - Equatable extension DBusObjectPath: Equatable { - + public static func == (lhs: DBusObjectPath, rhs: DBusObjectPath) -> Bool { - + // fast path if let lhsString = lhs.string, let rhsString = rhs.string { - + return lhsString == rhsString } - + // slower comparison return lhs.elements == rhs.elements } @@ -149,19 +167,19 @@ extension DBusObjectPath: Equatable { // MARK: - Hashable extension DBusObjectPath: Hashable { - - public var hashValue: Int { - - return rawValue.hashValue + + public func hash(into hasher: inout Hasher) { + + hasher.combine(rawValue) } } // MARK: - CustomStringConvertible extension DBusObjectPath: CustomStringConvertible { - + public var description: String { - + return rawValue } } @@ -169,9 +187,9 @@ extension DBusObjectPath: CustomStringConvertible { // MARK: - Array Literal extension DBusObjectPath: ExpressibleByArrayLiteral { - + public init(arrayLiteral elements: Element...) { - + self.init(elements) } } @@ -179,90 +197,90 @@ extension DBusObjectPath: ExpressibleByArrayLiteral { // MARK: - Collection extension DBusObjectPath: MutableCollection { - + public typealias Index = Int - + public subscript (index: Index) -> Element { - + get { return elements[index] } - + mutating set { string = nil elements[index] = newValue } } - + public var count: Int { - + return elements.count } - + /// The start `Index`. public var startIndex: Index { return 0 } - + /// The end `Index`. /// /// This is the "one-past-the-end" position, and will always be equal to the `count`. public var endIndex: Index { return count } - + public func index(before i: Index) -> Index { return i - 1 } - + public func index(after i: Index) -> Index { return i + 1 } - + public func makeIterator() -> IndexingIterator { return IndexingIterator(_elements: self) } - + /// Adds a new element at the end of the object path. /// /// Use this method to append a single element to the end of a mutable object path. public mutating func append(_ element: Element) { - + string = nil elements.append(element) } - + /// Removes and returns the first element of the object path. /// /// - Precondition: The object path must not be empty. @discardableResult public mutating func removeFirst() -> Element { - + string = nil return elements.removeFirst() } - + /// Removes and returns the last element of the object path. /// /// - Precondition: The object path must not be empty. @discardableResult public mutating func removeLast() -> Element { - + string = nil return elements.removeLast() } - + /// Removes and returns the element at the specified position. /// /// All the elements following the specified position are moved up to close the gap. @discardableResult public mutating func remove(at index: Int) -> Element { - + string = nil return elements.remove(at: index) } - + /// Removes all elements from the object path. public mutating func removeAll() { - + self = DBusObjectPath() } } @@ -272,69 +290,86 @@ extension DBusObjectPath: RandomAccessCollection { } // MARK: - Element public extension DBusObjectPath { - + /// An element in the object path - struct Element { - + struct Element: Sendable { + /// Don't copy buffer of individual elements, because these elements will always be created /// from a bigger string, which we should just internally reference. internal let substring: Substring - + /// Designated initializer. internal init?(substring: Substring) { - - // validate string - guard substring.isEmpty == false, // No element may be an empty string. - substring.contains(DBusObjectPath.separator) == false, // Multiple '/' characters cannot occur in sequence. - substring.rangeOfCharacter(from: Element.invalidCharacters) == nil // only ASCII characters "[A-Z][a-z][0-9]_" + + // No element may be an empty string. Because the parser splits without omitting empty + // subsequences, this also rejects "//" and a trailing "/". + guard substring.isEmpty == false, + substring.utf8.allSatisfy({ $0.isObjectPathElementByte }) else { return nil } - + self.substring = substring } } } extension DBusObjectPath.Element: RawRepresentable { - + public init?(rawValue: String) { - + // This API will rarely be used let substring = Substring(rawValue) self.init(substring: substring) } - + public var rawValue: String { - + return String(substring) } } -private extension DBusObjectPath.Element { - - /// only ASCII characters "[A-Z][a-z][0-9]_" - static let invalidCharacters = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLKMNOPQRSTUVWXYZ0123456789_").inverted +internal extension UInt8 { + + /// Whether the byte is one of the ASCII characters "[A-Z][a-z][0-9]_" + var isObjectPathElementByte: Bool { + + switch self { + case 0x41 ... 0x5A, // A-Z + 0x61 ... 0x7A, // a-z + 0x30 ... 0x39, // 0-9 + 0x5F: // _ + return true + default: + return false + } + } + + /// Whether the byte is an ASCII digit. + var isASCIIDigit: Bool { + + return (0x30 ... 0x39).contains(self) + } } extension DBusObjectPath.Element: Equatable { - + public static func == (lhs: DBusObjectPath.Element, rhs: DBusObjectPath.Element) -> Bool { - + return lhs.substring == rhs.substring } } extension DBusObjectPath.Element: CustomStringConvertible { - + public var description: String { - + return rawValue } } extension DBusObjectPath.Element: Hashable { - - public var hashValue: Int { - - return substring.hashValue + + public func hash(into hasher: inout Hasher) { + + hasher.combine(substring) } } diff --git a/Sources/DBus/PendingCall.swift b/Sources/DBus/PendingCall.swift deleted file mode 100644 index 09c9eed..0000000 --- a/Sources/DBus/PendingCall.swift +++ /dev/null @@ -1,97 +0,0 @@ -// -// PendingCall.swift -// DBus -// -// Created by Alsey Coleman Miller on 2/27/16. -// Copyright © 2016 PureSwift. All rights reserved. -// - -import CDBus - -/// Pending reply to a method call message. -public final class DBusPendingCall { - - // MARK: - Properties - - /// Notification closure to be called when the reply is received or the pending call times out - public var notification: (() -> ())? - - // MARK: - Internal Properties - - internal let internalPointer: OpaquePointer - - // MARK: - Private Properties - - private var replyMessageCache: DBusMessage? - - // MARK: - Initialization - - deinit { - - dbus_pending_call_unref(internalPointer) - } - - internal init(_ internalPointer: OpaquePointer) { - - self.internalPointer = internalPointer - - setNotification() - } - - // MARK: - Methods - - private func setNotification() { - - let objectPointer = Unmanaged.passRetained(self).toOpaque() - - dbus_pending_call_set_notify(internalPointer, { (internalPointer, objectPointer) in - - let object = Unmanaged.fromOpaque(objectPointer!).takeUnretainedValue() - - object.notification?() - - }, objectPointer, { (objectPointer) in - - // free object - Unmanaged.fromOpaque(objectPointer!).release() - }) - } - - /// Cancels the pending call, such that any reply or error received will just be ignored. - public func cancel() { - - dbus_pending_call_cancel(internalPointer) - } - - /// Block until the pending call is completed. - public func block() { - - dbus_pending_call_block(internalPointer) - } - - // MARK: - Dynamic Properties - - /// Gets the reply, or returns `nil` if none has been received yet. - public var replyMessage: DBusMessage? { - - // return cached message - guard replyMessageCache == nil else { return replyMessageCache } - - // attempt to get reply message - - guard let messageInternalPointer = dbus_pending_call_steal_reply(internalPointer) - else { return nil } - - let message = DBusMessage(messageInternalPointer) - - self.replyMessageCache = message - - return message - } - - /// Checks whether the pending call has received a reply yet, or not. - public var completed: Bool { - - return Bool(dbus_pending_call_get_completed(internalPointer)) - } -} diff --git a/Sources/DBus/Server/DBusExportedObject.swift b/Sources/DBus/Server/DBusExportedObject.swift new file mode 100644 index 0000000..e3f69b1 --- /dev/null +++ b/Sources/DBus/Server/DBusExportedObject.swift @@ -0,0 +1,224 @@ +// +// DBusExportedObject.swift +// DBus +// + +/// An object made available to other connections at an object path. +/// +/// Build one from ``DBusInterfaceImplementation`` values and register it with +/// ``DBusConnection/export(_:at:)``. The standard interfaces `Peer`, `Introspectable` and +/// `Properties` are supplied automatically. +public struct DBusExportedObject: Sendable { + + /// The interfaces this object implements, keyed by name. + public private(set) var interfaces: [DBusInterface: DBusInterfaceImplementation] + + public init(_ interfaces: [DBusInterfaceImplementation] = []) { + + self.interfaces = [:] + + for implementation in interfaces { + self.interfaces[implementation.name] = implementation + } + } + + /// Add or replace an interface. + public mutating func add(_ implementation: DBusInterfaceImplementation) { + + interfaces[implementation.name] = implementation + } +} + +// MARK: - Interface + +/// The implementation of a single interface on an exported object. +public struct DBusInterfaceImplementation: Sendable { + + /// The interface name. + public let name: DBusInterface + + /// The methods the interface exposes, keyed by member name. + public private(set) var methods: [DBusMember: Method] + + /// The properties the interface exposes, keyed by property name. + public private(set) var properties: [String: Property] + + /// The signals the interface may emit, for introspection only. + public private(set) var signals: [DBusMember: Signal] + + public init(name: DBusInterface, + methods: [Method] = [], + properties: [Property] = [], + signals: [Signal] = []) { + + self.name = name + self.methods = [:] + self.properties = [:] + self.signals = [:] + + for method in methods { + self.methods[method.name] = method + } + + for property in properties { + self.properties[property.name] = property + } + + for signal in signals { + self.signals[signal.name] = signal + } + } +} + +// MARK: - Method + +public extension DBusInterfaceImplementation { + + /// A method that can be invoked on the interface. + struct Method: Sendable { + + /// The method name. + public let name: DBusMember + + /// The signature of the arguments the method accepts. + public let inputSignature: DBusSignature + + /// The signature of the values the method returns. + public let outputSignature: DBusSignature + + /// Names for the input arguments, for introspection. May be shorter than the signature. + public let inputNames: [String] + + /// Names for the output arguments, for introspection. + public let outputNames: [String] + + /// The implementation. + /// + /// - Throws: A `DBusError` to send an error reply; anything else becomes + /// `org.freedesktop.DBus.Error.Failed`. + public let handler: @Sendable (DBusMethodCall) async throws -> [DBusMessageArgument] + + public init(name: DBusMember, + inputSignature: DBusSignature = DBusSignature(), + outputSignature: DBusSignature = DBusSignature(), + inputNames: [String] = [], + outputNames: [String] = [], + handler: @escaping @Sendable (DBusMethodCall) async throws -> [DBusMessageArgument]) { + + self.name = name + self.inputSignature = inputSignature + self.outputSignature = outputSignature + self.inputNames = inputNames + self.outputNames = outputNames + self.handler = handler + } + } +} + +// MARK: - Property + +public extension DBusInterfaceImplementation { + + /// A property exposed through `org.freedesktop.DBus.Properties`. + struct Property: Sendable { + + /// Whether a property may be read, written, or both. + public enum Access: String, Sendable { + + case read + case write + case readwrite + } + + /// The property name. + public let name: String + + /// The type of the property's value. + public let type: DBusSignature.ValueType + + /// Whether the property is readable, writable or both. + public let access: Access + + /// Reads the current value. Required unless the property is write-only. + public let get: (@Sendable () async throws -> DBusMessageArgument)? + + /// Writes a new value. Required unless the property is read-only. + public let set: (@Sendable (DBusMessageArgument) async throws -> Void)? + + public init(name: String, + type: DBusSignature.ValueType, + access: Access = .read, + get: (@Sendable () async throws -> DBusMessageArgument)? = nil, + set: (@Sendable (DBusMessageArgument) async throws -> Void)? = nil) { + + self.name = name + self.type = type + self.access = access + self.get = get + self.set = set + } + } +} + +// MARK: - Signal + +public extension DBusInterfaceImplementation { + + /// A signal the interface may emit. Declared for introspection; emitting is done with + /// ``DBusConnection/emit(_:arguments:)``. + struct Signal: Sendable { + + public let name: DBusMember + + public let signature: DBusSignature + + public let argumentNames: [String] + + public init(name: DBusMember, + signature: DBusSignature = DBusSignature(), + argumentNames: [String] = []) { + + self.name = name + self.signature = signature + self.argumentNames = argumentNames + } + } +} + +// MARK: - Method Call + +/// An incoming method call handed to a ``DBusInterfaceImplementation/Method`` handler. +public struct DBusMethodCall: Sendable { + + /// The full message, for handlers that need the header fields. + public let message: DBusMessage + + /// The object path the call was addressed to. + public let path: DBusObjectPath + + /// The interface the call named, if any. + public let interface: DBusInterface? + + /// The method being invoked. + public let member: DBusMember + + /// The call's arguments. + public var arguments: [DBusMessageArgument] { message.arguments } + + /// The unique name of the caller, as stamped by the bus. + public var sender: DBusBusName? { message.sender } + + /// Whether the caller wants a reply. + public var expectsReply: Bool { message.flags.contains(.noReplyExpected) == false } + + internal init(message: DBusMessage, + path: DBusObjectPath, + interface: DBusInterface?, + member: DBusMember) { + + self.message = message + self.path = path + self.interface = interface + self.member = member + } +} diff --git a/Sources/DBus/Server/IntrospectionNode.swift b/Sources/DBus/Server/IntrospectionNode.swift new file mode 100644 index 0000000..d721fa4 --- /dev/null +++ b/Sources/DBus/Server/IntrospectionNode.swift @@ -0,0 +1,325 @@ +// +// IntrospectionNode.swift +// DBus +// + +// MARK: - Model + +public extension DBusIntrospection { + + /// An object node described by an introspection document. + struct Node: Equatable, Hashable, Sendable { + + /// The node's name, when the document gives one. + /// + /// The root node of a reply usually has none, because the path is already known; child + /// nodes carry a relative name. + public var name: String? + + /// The interfaces the object implements. + public var interfaces: [Interface] + + /// Child nodes. Usually name-only stubs, to be introspected in turn. + public var children: [Node] + + public init(name: String? = nil, + interfaces: [Interface] = [], + children: [Node] = []) { + + self.name = name + self.interfaces = interfaces + self.children = children + } + } + + /// An interface described by an introspection document. + struct Interface: Equatable, Hashable, Sendable { + + public var name: DBusInterface + public var methods: [Method] + public var signals: [Signal] + public var properties: [Property] + + public init(name: DBusInterface, + methods: [Method] = [], + signals: [Signal] = [], + properties: [Property] = []) { + + self.name = name + self.methods = methods + self.signals = signals + self.properties = properties + } + } + + /// A method described by an introspection document. + struct Method: Equatable, Hashable, Sendable { + + public var name: DBusMember + public var arguments: [Argument] + + public init(name: DBusMember, arguments: [Argument] = []) { + + self.name = name + self.arguments = arguments + } + + /// The signature of the arguments the method accepts. + public var inputSignature: DBusSignature { + DBusSignature(arguments.filter { $0.direction == .in }.map { $0.type }) + } + + /// The signature of the values the method returns. + public var outputSignature: DBusSignature { + DBusSignature(arguments.filter { $0.direction == .out }.map { $0.type }) + } + } + + /// A signal described by an introspection document. + struct Signal: Equatable, Hashable, Sendable { + + public var name: DBusMember + public var arguments: [Argument] + + public init(name: DBusMember, arguments: [Argument] = []) { + + self.name = name + self.arguments = arguments + } + + /// The signature of the signal's arguments. + public var signature: DBusSignature { + DBusSignature(arguments.map { $0.type }) + } + } + + /// A property described by an introspection document. + struct Property: Equatable, Hashable, Sendable { + + public var name: String + public var type: DBusSignature.ValueType + public var access: DBusInterfaceImplementation.Property.Access + + public init(name: String, + type: DBusSignature.ValueType, + access: DBusInterfaceImplementation.Property.Access) { + + self.name = name + self.type = type + self.access = access + } + } + + /// An argument of a method or signal. + struct Argument: Equatable, Hashable, Sendable { + + /// Which way the argument travels. + public enum Direction: String, Sendable { + + case `in` + case out + } + + public var name: String? + public var type: DBusSignature.ValueType + public var direction: Direction + + public init(name: String? = nil, + type: DBusSignature.ValueType, + direction: Direction = .in) { + + self.name = name + self.type = type + self.direction = direction + } + } +} + +// MARK: - Parsing + +public extension DBusIntrospection { + + /// Parse an introspection document. + /// + /// - Throws: `DBusProtocolError.invalidValue` if the document is malformed, names a type + /// that is not a valid signature, or uses a name the specification forbids. + static func parse(_ xml: String) throws -> Node { + + let root = try XMLReader.parse(xml) + + guard root.name == "node" + else { throw DBusProtocolError.invalidValue("Root element is <\(root.name)>, expected ") } + + return try node(from: root) + } + + private static func node(from element: XMLElement) throws -> Node { + + var node = Node(name: element.attributes["name"]) + + for child in element.children { + + switch child.name { + + case "interface": + node.interfaces.append(try interface(from: child)) + + case "node": + node.children.append(try self.node(from: child)) + + case "annotation": + continue // annotations carry no information this model represents + + default: + throw DBusProtocolError.invalidValue("Unexpected <\(child.name)> inside ") + } + } + + return node + } + + private static func interface(from element: XMLElement) throws -> Interface { + + guard let rawName = element.attributes["name"] + else { throw DBusProtocolError.invalidValue(" has no name") } + + guard let name = DBusInterface(rawValue: rawName) + else { throw DBusProtocolError.invalidValue("Invalid interface name '\(rawName)'") } + + var interface = Interface(name: name) + + for child in element.children { + + switch child.name { + + case "method": + interface.methods.append(Method(name: try member(of: child), + arguments: try arguments(of: child, defaultDirection: .in))) + + case "signal": + // A signal's arguments are always outbound and carry no direction attribute. + interface.signals.append(Signal(name: try member(of: child), + arguments: try arguments(of: child, defaultDirection: .out))) + + case "property": + interface.properties.append(try property(from: child)) + + case "annotation": + continue + + default: + throw DBusProtocolError.invalidValue("Unexpected <\(child.name)> inside ") + } + } + + return interface + } + + private static func member(of element: XMLElement) throws -> DBusMember { + + guard let rawName = element.attributes["name"] + else { throw DBusProtocolError.invalidValue("<\(element.name)> has no name") } + + guard let member = DBusMember(rawValue: rawName) + else { throw DBusProtocolError.invalidValue("Invalid member name '\(rawName)'") } + + return member + } + + private static func arguments(of element: XMLElement, + defaultDirection: Argument.Direction) throws -> [Argument] { + + return try element.children(named: "arg").map { child in + + guard let rawType = child.attributes["type"] + else { throw DBusProtocolError.invalidValue(" has no type") } + + guard let signature = DBusSignature(rawValue: rawType), signature.count == 1 + else { throw DBusProtocolError.invalidValue(" type '\(rawType)' is not a single complete type") } + + let direction: Argument.Direction + + if let rawDirection = child.attributes["direction"] { + guard let parsed = Argument.Direction(rawValue: rawDirection) + else { throw DBusProtocolError.invalidValue("Invalid argument direction '\(rawDirection)'") } + direction = parsed + } else { + direction = defaultDirection + } + + return Argument(name: child.attributes["name"], type: signature[0], direction: direction) + } + } + + private static func property(from element: XMLElement) throws -> Property { + + guard let name = element.attributes["name"] + else { throw DBusProtocolError.invalidValue(" has no name") } + + guard let rawType = element.attributes["type"] + else { throw DBusProtocolError.invalidValue(" '\(name)' has no type") } + + guard let signature = DBusSignature(rawValue: rawType), signature.count == 1 + else { throw DBusProtocolError.invalidValue(" type '\(rawType)' is not a single complete type") } + + guard let rawAccess = element.attributes["access"] + else { throw DBusProtocolError.invalidValue(" '\(name)' has no access") } + + guard let access = DBusInterfaceImplementation.Property.Access(rawValue: rawAccess) + else { throw DBusProtocolError.invalidValue("Invalid property access '\(rawAccess)'") } + + return Property(name: name, type: signature[0], access: access) + } +} + +// MARK: - Lookup + +public extension DBusIntrospection.Node { + + /// The interface with the given name, if the object implements it. + func interface(named name: DBusInterface) -> DBusIntrospection.Interface? { + + return interfaces.first { $0.name == name } + } + + /// Whether the object implements the interface. + func implements(_ name: DBusInterface) -> Bool { + + return interface(named: name) != nil + } +} + +public extension DBusIntrospection.Interface { + + /// The method with the given name, if the interface declares it. + func method(named name: DBusMember) -> DBusIntrospection.Method? { + + return methods.first { $0.name == name } + } + + /// The signal with the given name, if the interface declares it. + func signal(named name: DBusMember) -> DBusIntrospection.Signal? { + + return signals.first { $0.name == name } + } + + /// The property with the given name, if the interface declares it. + func property(named name: String) -> DBusIntrospection.Property? { + + return properties.first { $0.name == name } + } +} + +// MARK: - Connection + +public extension DBusConnection { + + /// Call `Introspect` on a remote object and parse the result. + func introspectNode(destination: DBusBusName, + path: DBusObjectPath) async throws -> DBusIntrospection.Node { + + let xml = try await introspect(destination: destination, path: path) + + return try DBusIntrospection.parse(xml) + } +} diff --git a/Sources/DBus/Server/ObjectServer.swift b/Sources/DBus/Server/ObjectServer.swift new file mode 100644 index 0000000..943da4f --- /dev/null +++ b/Sources/DBus/Server/ObjectServer.swift @@ -0,0 +1,245 @@ +// +// ObjectServer.swift +// DBus +// + +// MARK: - Exporting + +public extension DBusConnection { + + /// Make an object available to other connections at the given path. + /// + /// The standard interfaces `org.freedesktop.DBus.Peer`, `.Introspectable` and `.Properties` + /// are answered automatically and do not need to be part of `object`. + func export(_ object: DBusExportedObject, at path: DBusObjectPath) { + + exportedObjects[path] = object + } + + /// Remove a previously exported object. + @discardableResult + func unexport(at path: DBusObjectPath) -> DBusExportedObject? { + + return exportedObjects.removeValue(forKey: path) + } + + /// The object exported at the given path, if any. + func exportedObject(at path: DBusObjectPath) -> DBusExportedObject? { + + return exportedObjects[path] + } + + /// Every path this connection has exported an object at. + var exportedPaths: [DBusObjectPath] { + + return Array(exportedObjects.keys) + } + + /// Emit a signal from an exported object. + func emit(_ signal: DBusMessage.Signal, + arguments: [DBusMessageArgument] = [], + destination: DBusBusName? = nil) async throws { + + var message = DBusMessage(signal: signal, arguments: arguments) + message.destination = destination + + try await send(oneWay: message) + } + + /// Emit `org.freedesktop.DBus.Properties.PropertiesChanged` for an object. + /// + /// - Parameters: + /// - changed: Property names and their new values. + /// - invalidated: Names of properties whose value changed but is not being broadcast. + func emitPropertiesChanged(at path: DBusObjectPath, + interface: DBusInterface, + changed: [String: DBusMessageArgument] = [:], + invalidated: [String] = []) async throws { + + let entries = changed.keys.sorted().map { key in + DBusMessageArgument.Dictionary.Entry( + key: .string(key), + value: .variant(DBusMessageArgument.Variant(changed[key]!)) + ) + } + + guard let dictionary = DBusMessageArgument.Dictionary(keyType: .string, + valueType: .variant, + entries) + else { throw DBusProtocolError.invalidValue("Could not build the changed properties dictionary") } + + guard let invalidatedArray = DBusMessageArgument.Array(type: .string, + invalidated.map { .string($0) }) + else { throw DBusProtocolError.invalidValue("Could not build the invalidated properties array") } + + let signal = DBusMessage.Signal(path: path, + interface: DBusWellKnown.propertiesInterface, + name: DBusMember("PropertiesChanged")) + + try await emit(signal, arguments: [ + .string(interface.rawValue), + .dictionary(dictionary), + .array(invalidatedArray) + ]) + } +} + +// MARK: - Dispatch + +internal extension DBusConnection { + + /// Route an incoming method call to an exported object. + /// + /// Runs the handler on a detached task so a slow implementation cannot stall the read loop, + /// and replies with the result or with an error. + func handleMethodCall(_ message: DBusMessage) { + + guard let path = message.path, let member = message.member else { + Task { await self.replyWithError(to: message, + DBusError(name: .invalidArguments, + message: "Method call is missing a path or member")) } + return + } + + let call = DBusMethodCall(message: message, + path: path, + interface: message.interface, + member: member) + + // Resolve while on the actor, so the handler closure is all the task needs. + let resolution = resolve(call) + + switch resolution { + + case let .handler(handler): + Task { + do { + let results = try await handler(call) + await self.reply(to: message, arguments: results) + } + catch let error as DBusError { + await self.replyWithError(to: message, error) + } + catch { + await self.replyWithError(to: message, + DBusError(name: .failed, message: "\(error)")) + } + } + + case let .error(error): + Task { await self.replyWithError(to: message, error) } + + case .unhandled: + // Nothing claims it; hand it to the catch-all so a caller can implement its own + // routing, and only error if there is no handler either. + if let messageHandler = messageHandler { + messageHandler(message) + } else { + Task { await self.replyWithError(to: message, + DBusError(name: .unknownObject, + message: "No object is exported at \(path)")) } + } + } + } + + /// What should happen to an incoming call. + enum CallResolution { + + case handler(@Sendable (DBusMethodCall) async throws -> [DBusMessageArgument]) + case error(DBusError) + case unhandled + } + + func resolve(_ call: DBusMethodCall) -> CallResolution { + + // The standard interfaces are answered for any exported path. + if let interface = call.interface, interface == DBusWellKnown.peerInterface { + return resolvePeer(call) + } + + guard let object = exportedObjects[call.path] else { + + // Peer.Ping is answered even at an unexported path, which is how callers check + // that a connection is alive. + return .unhandled + } + + if let interface = call.interface { + + if interface == DBusWellKnown.introspectableInterface { + return resolveIntrospectable(call) + } + + if interface == DBusWellKnown.propertiesInterface { + return resolveProperties(call, object: object) + } + + guard let implementation = object.interfaces[interface] else { + return .error(DBusError(name: .unknownInterface, + message: "\(call.path) does not implement \(interface)")) + } + + guard let method = implementation.methods[call.member] else { + return .error(DBusError(name: .unknownMethod, + message: "\(interface) has no method \(call.member)")) + } + + return validated(method, call: call) + } + + // Without an interface field the member must be unambiguous across the object. + let candidates = object.interfaces.values.compactMap { $0.methods[call.member] } + + guard let method = candidates.first else { + return .error(DBusError(name: .unknownMethod, + message: "\(call.path) has no method \(call.member)")) + } + + guard candidates.count == 1 else { + return .error(DBusError(name: .unknownMethod, + message: "\(call.member) is ambiguous on \(call.path); specify an interface")) + } + + return validated(method, call: call) + } + + /// Check the call's signature before invoking the handler. + private func validated(_ method: DBusInterfaceImplementation.Method, + call: DBusMethodCall) -> CallResolution { + + let actual = call.arguments.signature + + guard actual == method.inputSignature else { + return .error(DBusError( + name: .invalidArguments, + message: "\(method.name) expects '\(method.inputSignature.rawValue)' but got '\(actual.rawValue)'")) + } + + return .handler(method.handler) + } +} + +// MARK: - Replying + +internal extension DBusConnection { + + func reply(to message: DBusMessage, arguments: [DBusMessageArgument]) async { + + guard message.flags.contains(.noReplyExpected) == false + else { return } + + let reply = DBusMessage(methodReturn: message, arguments: arguments) + + // There is no one to report a send failure to; the connection's own error handling + // covers a dead socket. + _ = try? await send(oneWay: reply) + } + + func replyWithError(to message: DBusMessage, _ error: DBusError) async { + + guard message.flags.contains(.noReplyExpected) == false + else { return } + + _ = try? await send(oneWay: DBusMessage(replyTo: message, error: error)) + } +} diff --git a/Sources/DBus/Server/StandardInterfaces.swift b/Sources/DBus/Server/StandardInterfaces.swift new file mode 100644 index 0000000..277ec54 --- /dev/null +++ b/Sources/DBus/Server/StandardInterfaces.swift @@ -0,0 +1,437 @@ +// +// StandardInterfaces.swift +// DBus +// + +import SystemPackage + +// MARK: - Peer + +internal extension DBusConnection { + + /// `org.freedesktop.DBus.Peer`: `Ping` and `GetMachineId`. + /// + /// Answered for any path, exported or not, because callers use `Ping` to test whether a + /// connection is alive without knowing its object tree. + func resolvePeer(_ call: DBusMethodCall) -> CallResolution { + + switch call.member.rawValue { + + case "Ping": + guard call.arguments.isEmpty else { + return .error(DBusError(name: .invalidArguments, message: "Ping takes no arguments")) + } + return .handler { _ in [] } + + case "GetMachineId": + guard call.arguments.isEmpty else { + return .error(DBusError(name: .invalidArguments, message: "GetMachineId takes no arguments")) + } + return .handler { _ in + guard let machineID = MachineID.current + else { throw DBusError(name: .failed, message: "Could not read the machine ID") } + return [.string(machineID)] + } + + default: + return .error(DBusError(name: .unknownMethod, + message: "org.freedesktop.DBus.Peer has no method \(call.member)")) + } + } +} + +/// The machine's D-Bus UUID. +internal enum MachineID { + + /// Locations the machine ID is read from, in the order the reference implementation uses. + static let paths = ["/var/lib/dbus/machine-id", "/etc/machine-id"] + + /// The machine ID, read once. + static let current: String? = { + + for path in paths { + if let value = read(path) { + return value + } + } + + return nil + }() + + private static func read(_ path: String) -> String? { + + guard let descriptor = try? FileDescriptor.open(FilePath(path), .readOnly) + else { return nil } + + defer { try? descriptor.close() } + + // The file holds a 32 character hex UUID and a newline. + var buffer = [UInt8](repeating: 0, count: 64) + + guard let count = try? buffer.withUnsafeMutableBytes({ try descriptor.read(into: $0) }), + count > 0 + else { return nil } + + let bytes = buffer[0 ..< count].prefix { $0 != 0x0A && $0 != 0x00 } // stop at newline or NUL + + guard bytes.isEmpty == false, + let string = String(validatingUTF8: bytes) + else { return nil } + + return string + } +} + +// MARK: - Introspectable + +internal extension DBusConnection { + + /// `org.freedesktop.DBus.Introspectable.Introspect`. + func resolveIntrospectable(_ call: DBusMethodCall) -> CallResolution { + + guard call.member.rawValue == "Introspect" else { + return .error(DBusError(name: .unknownMethod, + message: "org.freedesktop.DBus.Introspectable has no method \(call.member)")) + } + + guard call.arguments.isEmpty else { + return .error(DBusError(name: .invalidArguments, message: "Introspect takes no arguments")) + } + + // Snapshot on the actor so the handler closure needs no further isolation. + let object = exportedObjects[call.path] + let children = childNodeNames(of: call.path) + + return .handler { _ in + [.string(DBusIntrospection.xml(for: object, children: children))] + } + } + + /// The names of exported objects directly beneath `path`. + func childNodeNames(of path: DBusObjectPath) -> [String] { + + var names = Set() + + for exported in exportedObjects.keys { + + guard exported != path, + exported.count == path.count + 1, + exported.isEqualToOrDescendant(of: path), + let last = exported.last + else { continue } + + names.insert(last.rawValue) + } + + return names.sorted() + } +} + +/// Generates `org.freedesktop.DBus.Introspectable` XML. +public enum DBusIntrospection { + + /// The DTD declaration every introspection document begins with. + public static let documentType = """ + + """ + + /// The introspection XML for an object and the child nodes beneath it. + public static func xml(for object: DBusExportedObject?, children: [String] = []) -> String { + + var lines = [documentType, ""] + + // The standard interfaces are always present, so advertise them. + lines.append(contentsOf: standardInterfaceElements(includeProperties: object != nil)) + + if let object = object { + + // Sorted so the output is stable between calls. + for name in object.interfaces.keys.map({ $0.rawValue }).sorted() { + + guard let interface = object.interfaces[DBusInterface(rawValue: name)!] + else { continue } + + lines.append(contentsOf: elements(for: interface)) + } + } + + for child in children { + lines.append(" ") + } + + lines.append("") + + return lines.joined(separator: "\n") + "\n" + } + + private static func elements(for interface: DBusInterfaceImplementation) -> [String] { + + var lines = [" "] + + for name in interface.methods.keys.map({ $0.rawValue }).sorted() { + + guard let method = interface.methods[DBusMember(rawValue: name)!] + else { continue } + + let arguments = argumentElements(method.inputSignature, method.inputNames, direction: "in") + + argumentElements(method.outputSignature, method.outputNames, direction: "out") + + if arguments.isEmpty { + lines.append(" ") + } else { + lines.append(" ") + lines.append(contentsOf: arguments) + lines.append(" ") + } + } + + for name in interface.signals.keys.map({ $0.rawValue }).sorted() { + + guard let signal = interface.signals[DBusMember(rawValue: name)!] + else { continue } + + // Signal arguments carry no direction attribute. + let arguments = argumentElements(signal.signature, signal.argumentNames, direction: nil) + + if arguments.isEmpty { + lines.append(" ") + } else { + lines.append(" ") + lines.append(contentsOf: arguments) + lines.append(" ") + } + } + + for name in interface.properties.keys.sorted() { + + guard let property = interface.properties[name] + else { continue } + + lines.append(" ") + } + + lines.append(" ") + + return lines + } + + private static func argumentElements(_ signature: DBusSignature, + _ names: [String], + direction: String?) -> [String] { + + return signature.enumerated().map { index, type in + + var attributes = "" + + if index < names.count { + attributes += " name=\"\(escape(names[index]))\"" + } + + attributes += " type=\"\(String(type))\"" + + if let direction = direction { + attributes += " direction=\"\(direction)\"" + } + + return " " + } + } + + private static func standardInterfaceElements(includeProperties: Bool) -> [String] { + + var lines = [ + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " " + ] + + guard includeProperties else { return lines } + + lines.append(contentsOf: [ + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " " + ]) + + return lines + } + + /// Escape the five XML predefined entities. + /// + /// - Note: Validated names cannot contain any of these, but property and argument names + /// are free-form strings supplied by the caller. + internal static func escape(_ string: String) -> String { + + var result = "" + result.reserveCapacity(string.count) + + for character in string { + switch character { + case "&": result += "&" + case "<": result += "<" + case ">": result += ">" + case "\"": result += """ + case "'": result += "'" + default: result.append(character) + } + } + + return result + } +} + +// MARK: - Properties + +internal extension DBusConnection { + + /// `org.freedesktop.DBus.Properties`: `Get`, `Set` and `GetAll`. + func resolveProperties(_ call: DBusMethodCall, object: DBusExportedObject) -> CallResolution { + + switch call.member.rawValue { + + case "Get": + guard case let .string(interfaceName)? = call.arguments.first, + call.arguments.count == 2, + case let .string(propertyName) = call.arguments[1] + else { return .error(signatureError("Get", "ss")) } + + guard let property = property(named: propertyName, interface: interfaceName, object: object) + else { return .error(unknownProperty(propertyName, interfaceName)) } + + guard property.access != .write, let get = property.get + else { return .error(DBusError(name: .unknownProperty, + message: "\(propertyName) is not readable")) } + + return .handler { _ in + [.variant(DBusMessageArgument.Variant(try await get()))] + } + + case "Set": + guard case let .string(interfaceName)? = call.arguments.first, + call.arguments.count == 3, + case let .string(propertyName) = call.arguments[1], + case let .variant(variant) = call.arguments[2] + else { return .error(signatureError("Set", "ssv")) } + + guard let property = property(named: propertyName, interface: interfaceName, object: object) + else { return .error(unknownProperty(propertyName, interfaceName)) } + + guard property.access != .read, let set = property.set + else { return .error(DBusError(name: .propertyReadOnly, + message: "\(propertyName) is read-only")) } + + let value = variant.element + + guard value.type == property.type + else { return .error(DBusError( + name: .invalidArguments, + message: "\(propertyName) is '\(String(property.type))' but got '\(String(value.type))'")) } + + return .handler { _ in + try await set(value) + return [] + } + + case "GetAll": + guard call.arguments.count == 1, + case let .string(interfaceName)? = call.arguments.first + else { return .error(signatureError("GetAll", "s")) } + + // An empty interface name means every interface on the object. + let implementations: [DBusInterfaceImplementation] + + if interfaceName.isEmpty { + implementations = Array(object.interfaces.values) + } else { + guard let interface = DBusInterface(rawValue: interfaceName), + let implementation = object.interfaces[interface] + else { return .error(DBusError(name: .unknownInterface, + message: "No such interface \(interfaceName)")) } + implementations = [implementation] + } + + // Collect the readable properties while on the actor; the getters run in the task. + let readable = implementations + .flatMap { $0.properties.values } + .filter { $0.access != .write && $0.get != nil } + .sorted { $0.name < $1.name } + + return .handler { _ in + + var entries = [DBusMessageArgument.Dictionary.Entry]() + + for property in readable { + let value = try await property.get!() + entries.append(.init(key: .string(property.name), + value: .variant(DBusMessageArgument.Variant(value)))) + } + + guard let dictionary = DBusMessageArgument.Dictionary(keyType: .string, + valueType: .variant, + entries) + else { throw DBusError(name: .failed, message: "Could not build the property dictionary") } + + return [.dictionary(dictionary)] + } + + default: + return .error(DBusError(name: .unknownMethod, + message: "org.freedesktop.DBus.Properties has no method \(call.member)")) + } + } + + private func property(named name: String, + interface interfaceName: String, + object: DBusExportedObject) -> DBusInterfaceImplementation.Property? { + + // An empty interface name means "search every interface", which the specification + // permits when the property name is unambiguous. + guard interfaceName.isEmpty == false else { + return object.interfaces.values.compactMap { $0.properties[name] }.first + } + + guard let interface = DBusInterface(rawValue: interfaceName) + else { return nil } + + return object.interfaces[interface]?.properties[name] + } + + private func signatureError(_ method: String, _ signature: String) -> DBusError { + + return DBusError(name: .invalidArguments, + message: "\(method) expects '\(signature)'") + } + + private func unknownProperty(_ name: String, _ interface: String) -> DBusError { + + return DBusError(name: .unknownProperty, + message: "No property \(name) on \(interface.isEmpty ? "any interface" : interface)") + } +} diff --git a/Sources/DBus/Server/XMLParser.swift b/Sources/DBus/Server/XMLParser.swift new file mode 100644 index 0000000..bf6b1c0 --- /dev/null +++ b/Sources/DBus/Server/XMLParser.swift @@ -0,0 +1,344 @@ +// +// XMLParser.swift +// DBus +// + +/// A minimal XML reader for introspection documents. +/// +/// Introspection XML is a small, fixed subset: elements, attributes, comments, an optional +/// declaration and doctype, and no meaningful text content. Parsing it here keeps the package +/// free of `FoundationXML`, which on Linux drags in libxml2. +/// +/// - Note: Deliberately not a general XML parser. It rejects what it does not understand rather +/// than guessing. +internal struct XMLElement: Equatable { + + /// The element name. + let name: String + + /// Attributes, in document order. + let attributes: [String: String] + + /// Child elements. + let children: [XMLElement] + + init(name: String, attributes: [String: String] = [:], children: [XMLElement] = []) { + + self.name = name + self.attributes = attributes + self.children = children + } +} + +internal extension XMLElement { + + /// Child elements with the given name. + func children(named name: String) -> [XMLElement] { + + return children.filter { $0.name == name } + } +} + +// MARK: - Parsing + +internal struct XMLReader { + + private let characters: [Character] + private var position = 0 + + private init(_ string: String) { + + self.characters = Array(string) + } + + /// Parse a document and return its root element. + static func parse(_ string: String) throws -> XMLElement { + + var reader = XMLReader(string) + return try reader.parseDocument() + } +} + +private extension XMLReader { + + var isAtEnd: Bool { position >= characters.count } + + var current: Character? { isAtEnd ? nil : characters[position] } + + mutating func parseDocument() throws -> XMLElement { + + skipProlog() + + guard isAtEnd == false + else { throw DBusProtocolError.invalidValue("XML document has no root element") } + + let root = try parseElement() + + skipProlog() // trailing comments and whitespace + + guard isAtEnd + else { throw DBusProtocolError.invalidValue("Trailing content after the root element") } + + return root + } + + /// Skip whitespace, comments, the XML declaration and the doctype. + mutating func skipProlog() { + + while isAtEnd == false { + + skipWhitespace() + + guard match("") + } else if consume("") + } else if consume(" Bool { + + let expected = Array(text) + + guard position + expected.count <= characters.count + else { return false } + + for (offset, character) in expected.enumerated() where characters[position + offset] != character { + return false + } + + return true + } + + mutating func consume(_ text: String) -> Bool { + + guard match(text) else { return false } + + position += text.count + return true + } + + mutating func skipUntil(_ text: String) { + + while isAtEnd == false { + + if consume(text) { return } + + position += 1 + } + } + + /// Skip a doctype, including any internal subset in brackets. + mutating func skipDoctype() { + + var depth = 0 + + while let character = current { + + position += 1 + + switch character { + case "\"", "'": + // Skip a quoted literal, which may contain '>'. + skipQuoted(character) + case "[": + depth += 1 + case "]": + depth -= 1 + case ">" where depth <= 0: + return + default: + break + } + } + } + + mutating func skipQuoted(_ quote: Character) { + + while let character = current { + position += 1 + if character == quote { return } + } + } + + mutating func parseElement() throws -> XMLElement { + + guard consume("<") + else { throw DBusProtocolError.invalidValue("Expected an element") } + + let name = try parseName() + + var attributes = [String: String]() + + while true { + + skipWhitespace() + + if consume("/>") { + return XMLElement(name: name, attributes: attributes) + } + + if consume(">") { + break + } + + let (key, value) = try parseAttribute() + + guard attributes[key] == nil + else { throw DBusProtocolError.invalidValue("Duplicate attribute '\(key)' on <\(name)>") } + + attributes[key] = value + } + + var children = [XMLElement]() + + while true { + + skipWhitespace() + + if consume("") + continue + } + + if consume(" closes <\(name)>") } + + skipWhitespace() + + guard consume(">") + else { throw DBusProtocolError.invalidValue("Unterminated closing tag for <\(name)>") } + + return XMLElement(name: name, attributes: attributes, children: children) + } + + guard current == "<" else { + + // Character content, which introspection documents do not use meaningfully. + guard isAtEnd == false + else { throw DBusProtocolError.invalidValue("Unterminated element <\(name)>") } + + position += 1 + continue + } + + children.append(try parseElement()) + } + } + + mutating func parseName() throws -> String { + + var name = "" + + while let character = current, + character.isLetter || character.isNumber || character == "_" || character == "-" + || character == ":" || character == "." { + + name.append(character) + position += 1 + } + + guard name.isEmpty == false + else { throw DBusProtocolError.invalidValue("Expected a name at offset \(position)") } + + return name + } + + mutating func parseAttribute() throws -> (name: String, value: String) { + + let name = try parseName() + + skipWhitespace() + + guard consume("=") + else { throw DBusProtocolError.invalidValue("Expected '=' after attribute '\(name)'") } + + skipWhitespace() + + guard let quote = current, quote == "\"" || quote == "'" + else { throw DBusProtocolError.invalidValue("Attribute '\(name)' is not quoted") } + + position += 1 + + var value = "" + + while let character = current, character != quote { + + position += 1 + + guard character == "&" else { + value.append(character) + continue + } + + value.append(try parseEntity()) + } + + guard consume(String(quote)) + else { throw DBusProtocolError.invalidValue("Unterminated value for attribute '\(name)'") } + + return (name, value) + } + + /// Decode an entity reference, the `&` already consumed. + mutating func parseEntity() throws -> Character { + + var reference = "" + + while let character = current, character != ";" { + reference.append(character) + position += 1 + } + + guard consume(";") + else { throw DBusProtocolError.invalidValue("Unterminated entity reference '&\(reference)'") } + + switch reference { + case "amp": return "&" + case "lt": return "<" + case "gt": return ">" + case "quot": return "\"" + case "apos": return "'" + default: + break + } + + // Numeric character references. + if reference.hasPrefix("#") { + + let digits = reference.dropFirst() + + let scalarValue: UInt32? + + if digits.hasPrefix("x") || digits.hasPrefix("X") { + scalarValue = UInt32(digits.dropFirst(), radix: 16) + } else { + scalarValue = UInt32(digits, radix: 10) + } + + if let scalarValue = scalarValue, let scalar = Unicode.Scalar(scalarValue) { + return Character(scalar) + } + } + + throw DBusProtocolError.invalidValue("Unknown entity reference '&\(reference);'") + } +} diff --git a/Sources/DBus/Signature.swift b/Sources/DBus/Signature.swift index b2e4f02..8777af8 100644 --- a/Sources/DBus/Signature.swift +++ b/Sources/DBus/Signature.swift @@ -5,15 +5,13 @@ // Created by Alsey Coleman Miller on 10/22/18. // -import CDBus - /// DBus Signature -public struct DBusSignature { - +public struct DBusSignature: Sendable { + /// Elements. @usableFromInline internal private(set) var elements: [Element] - + /// Cached string. /// This will be the original string the object path was created from. /// @@ -22,21 +20,21 @@ public struct DBusSignature { /// but for values created from either a string or an array of elements, this value is cached. @usableFromInline internal private(set) var string: String? - + public init(_ elements: [Element] = []) { - + self.elements = elements self.string = String(elements) } } internal extension DBusSignature { - + init(_ unsafe: String) { - + guard let value = DBusSignature(rawValue: unsafe) else { fatalError("Invalid signature \(unsafe)") } - + self = value } } @@ -44,84 +42,96 @@ internal extension DBusSignature { // MARK: - String Parsing internal extension DBusSignature { - + static let length = (min: 0, max: 255) - + + /// Maximum container nesting depth, per the D-Bus specification. + /// + /// Arrays and structs are counted separately. + static let maximumDepth = 32 + + /// Validates the signature string, throwing a descriptive error if it is malformed. static func validate(_ string: String) throws { - - let error = DBusError() - guard Bool(dbus_signature_validate(string, &error.internalValue)) - else { throw error } - + + _ = try parseThrowing(string) } - + /// Parse the DBus signature string. static func parse(_ string: String) -> [ValueType]? { - - guard string.count >= length.min, - string.count <= length.max - else { return nil } - - // validate with C library first - do { try validate(string) } - catch { return nil } - + + return try? parseThrowing(string) + } + + static func parseThrowing(_ string: String) throws -> [ValueType] { + + // The signature is a UTF-8 string, but only ASCII type codes are legal, so the + // byte count is the meaningful length. Measured in bytes, not `Character`s. + guard string.utf8.count >= length.min + else { throw DBusError.invalidSignature(string, "Signature is too short") } + + guard string.utf8.count <= length.max + else { throw DBusError.invalidSignature(string, "Signature exceeds maximum length of \(length.max) bytes") } + var characters = [Character]() - characters.reserveCapacity(string.count) - + characters.reserveCapacity(string.utf8.count) + for stringCharacter in string { - + // invalid character guard let character = Character(rawValue: String(stringCharacter)) - else { return nil } - + else { throw DBusError.invalidSignature(string, "Unknown typecode '\(stringCharacter)'") } + characters.append(character) } - - return parse(characters) + + var position = 0 + var elements = [Element]() + + while position < characters.count { + + let element = try parseFirst(characters, position: &position, arrayDepth: 0, structDepth: 0) + + elements.append(element) + } + + return elements } - + static func parse(_ characters: [Character]) -> [Element]? { - + guard characters.isEmpty == false else { return [] } - - var index = 0 - guard let elements = parse(characters, position: &index), - index == characters.count // no trailing characters - else { return nil } - - return elements - } - - static func parse(_ characters: [Character], position: inout Int) -> [Element]? { - + + var position = 0 var elements = [Element]() - + while position < characters.count { - - guard let element = parseFirst(characters, position: &position) + + guard let element = try? parseFirst(characters, position: &position, arrayDepth: 0, structDepth: 0) else { return nil } - + elements.append(element) } - + return elements } - - /// Parse valid DBus characters. - private static func parseFirst(_ characters: [Character], position: inout Int) -> ValueType? { - + + /// Parse a single complete type starting at `position`. + private static func parseFirst(_ characters: [Character], + position: inout Int, + arrayDepth: Int, + structDepth: Int) throws -> ValueType { + // get first character let character = characters[position] - + position += 1 - + let charactersLeft = characters.count - position assert(charactersLeft >= 0) - + switch character { - + // simple / single letter types case .byte: return .byte case .boolean: return .boolean @@ -137,96 +147,121 @@ internal extension DBusSignature { case .objectPath: return .objectPath case .signature: return .signature case .variant: return .variant - + // container types case .array: - + + guard arrayDepth < maximumDepth + else { throw DBusError.invalidSignature(String(characters), "Array nesting exceeds maximum depth of \(maximumDepth)") } + guard charactersLeft >= 1 - else { return nil } - + else { throw DBusError.invalidSignature(String(characters), "Missing array element type") } + if characters[position] == .dictionaryEntryStart { - + position += 1 - + var elements = [Element]() - + while position < characters.count, characters[position] != .dictionaryEntryEnd { - - guard let element = parseFirst(characters, position: &position) - else { return nil } - + + let element = try parseFirst(characters, + position: &position, + arrayDepth: arrayDepth + 1, + structDepth: structDepth) + elements.append(element) } - + guard elements.count == 2 - else { return nil } - + else { throw DBusError.invalidSignature(String(characters), "Dictionary entry must contain exactly two types") } + guard position < characters.count, characters[position] == .dictionaryEntryEnd - else { return nil } - + else { throw DBusError.invalidSignature(String(characters), "Dictionary entry started but not ended") } + guard let dictionary = DictionaryType(key: elements[0], value: elements[1]) - else { return nil } - + else { throw DBusError.invalidSignature(String(characters), "Dictionary entry key must be a basic type") } + position += 1 - + return .dictionary(dictionary) - + } else { - - guard let valueType = parseFirst(characters, position: &position) - else { return nil } - + + let valueType = try parseFirst(characters, + position: &position, + arrayDepth: arrayDepth + 1, + structDepth: structDepth) + return .array(valueType) } - + case .structStart: - + + guard structDepth < maximumDepth + else { throw DBusError.invalidSignature(String(characters), "Struct nesting exceeds maximum depth of \(maximumDepth)") } + guard charactersLeft >= 2 - else { return nil } - + else { throw DBusError.invalidSignature(String(characters), "Struct started but not ended") } + var elements = [Element]() - + while position < characters.count, characters[position] != .structEnd { - - guard let element = parseFirst(characters, position: &position) - else { return nil } - + + let element = try parseFirst(characters, + position: &position, + arrayDepth: arrayDepth, + structDepth: structDepth + 1) + elements.append(element) } - + guard position < characters.count, characters[position] == .structEnd - else { return nil } - + else { throw DBusError.invalidSignature(String(characters), "Struct started but not ended") } + position += 1 - + guard let structureType = StructureType(elements) - else { return nil } - + else { throw DBusError.invalidSignature(String(characters), "Empty structs are not allowed") } + return .struct(structureType) - - default: - return nil + + case .structEnd: + + throw DBusError.invalidSignature(String(characters), "Struct ended but not started") + + case .dictionaryEntryStart, .dictionaryEntryEnd: + + throw DBusError.invalidSignature(String(characters), "Dict entry not inside array") } } } +private extension DBusError { + + static func invalidSignature(_ string: String, _ reason: String) -> DBusError { + + return DBusError(name: .invalidSignature, message: "\(reason): '\(string)'") + } +} + // MARK: - RawRepresentable extension DBusSignature: RawRepresentable { - + public init?(rawValue: String) { - - guard let elements = DBusSignature.parse(rawValue) + + guard let elements = try? DBusSignature.parseThrowing(rawValue) else { return nil } - + self.elements = elements self.string = rawValue } - + public var rawValue: String { - + return string ?? String(elements) } } @@ -234,41 +269,41 @@ extension DBusSignature: RawRepresentable { // MARK: - Equatable extension DBusSignature: Equatable { - + public static func == (lhs: DBusSignature, rhs: DBusSignature) -> Bool { - + // fast path if let lhsString = lhs.string, let rhsString = rhs.string { - + return lhsString == rhsString } - + // slower comparison return lhs.elements == rhs.elements } } extension DBusSignature: Hashable { - - public var hashValue: Int { - - return rawValue.hashValue + + public func hash(into hasher: inout Hasher) { + + hasher.combine(rawValue) } } extension DBusSignature: CustomStringConvertible { - + public var description: String { - + return rawValue } } extension DBusSignature: ExpressibleByArrayLiteral { - + public init(arrayLiteral elements: Element...) { - + self.init(elements) } } @@ -276,80 +311,80 @@ extension DBusSignature: ExpressibleByArrayLiteral { // MARK: Collection extension DBusSignature: MutableCollection { - + public typealias Element = ValueType - + public typealias Index = Int - + public subscript (index: Index) -> Element { - + get { return elements[index] } - + mutating set { string = nil elements[index] = newValue } } - + public var count: Int { - + return elements.count } - + /// The start `Index`. public var startIndex: Index { return 0 } - + /// The end `Index`. /// /// This is the "one-past-the-end" position, and will always be equal to the `count`. public var endIndex: Index { return count } - + public func index(before i: Index) -> Index { return i - 1 } - + public func index(after i: Index) -> Index { return i + 1 } - + public func makeIterator() -> IndexingIterator { return IndexingIterator(_elements: self) } - + public mutating func append(_ element: Element) { - + string = nil elements.append(element) } - + @discardableResult public mutating func removeFirst() -> Element { - + string = nil return elements.removeFirst() } - + @discardableResult public mutating func removeLast() -> Element { - + string = nil return elements.removeLast() } - + @discardableResult public mutating func remove(at index: Int) -> Element { - + string = nil return elements.remove(at: index) } - + /// Removes all elements from the object path. public mutating func removeAll(keepingCapacity: Bool = false) { - + string = nil self.elements.removeAll(keepingCapacity: keepingCapacity) } @@ -358,82 +393,82 @@ extension DBusSignature: MutableCollection { extension DBusSignature: RandomAccessCollection { } public extension DBusSignature { - - indirect enum ValueType: Equatable { - + + indirect enum ValueType: Equatable, Hashable, Sendable { + /// Type code marking an 8-bit unsigned integer. case byte - + /// Type code marking a boolean. /// /// Boolean value: 0 is false, 1 is true, any other value allowed by the marshalling format is invalid. case boolean - + /// Type code marking a 16-bit signed integer case int16 - + /// Type code marking a 16-bit unsigned integer. case uint16 - + /// Signed (two's complement) 32-bit integer case int32 - + /// Unsigned 32-bit integer case uint32 - + /// Signed (two's complement) 64-bit integer case int64 - + /// Unsigned 64-bit integer case uint64 - + /// IEEE 754 double-precision floating point case double - + /// Unix file descriptor /// /// Unsigned 32-bit integer representing an index into an out-of-band array of file descriptors, transferred via some platform-specific mechanism case fileDescriptor - + /// Variant type (the type of the value is part of the value itself) case variant - + // String-like types - + /// String /// /// - Note: No extra constraints. case string - + /// DBus Object Path /// /// - Note: Must be a [syntactically valid object path](https://dbus.freedesktop.org/doc/dbus-specification.html#message-protocol-marshaling-object-path). case objectPath - + /// DBus Signature /// /// - Note: Zero or more single complete types case signature - + // Container Type - + /// STRUCT has a type code, ASCII character 'r', but this type code does not appear in signatures. /// Instead, ASCII characters '(' and ')' are used to mark the beginning and end of the struct. /// So for example, a struct containing two integers would have this signature: "`(ii)`". case `struct`(StructureType) - + /// Array case array(ValueType) - + /// Dictionary case dictionary(DictionaryType) } } public extension DBusSignature.ValueType { - + var isContainer: Bool { - + switch self { case .struct, .array, @@ -443,92 +478,122 @@ public extension DBusSignature.ValueType { return false } } + + /// A basic type is fully specified by a single type code, with no nested type information. + /// + /// - Note: `variant` is *not* a basic type, even though it is written as a single type code — + /// its contained type is part of the value rather than the signature. Only basic types are + /// permitted as dictionary keys. + var isBasic: Bool { + + switch self { + case .byte, + .boolean, + .int16, + .uint16, + .int32, + .uint32, + .int64, + .uint64, + .double, + .fileDescriptor, + .string, + .objectPath, + .signature: + return true + case .variant, + .struct, + .array, + .dictionary: + return false + } + } } public extension String { - + init(_ type: DBusSignature.ValueType) { - + self.init(type.characters) } } public extension String { - + init(_ signature: [DBusSignature.ValueType]) { - + self.init(signature.characters) } } public extension DBusSignature { - + /// DBus Signature Character - enum Character: String { - + enum Character: String, Sendable { + // MARK: - Fixed Length Types - + /// Type code marking an 8-bit unsigned integer. case byte = "y" // y (121) - + /// Type code marking a boolean. /// /// Boolean value: 0 is false, 1 is true, any other value allowed by the marshalling format is invalid. case boolean = "b" // b (98) - + /// Type code marking a 16-bit signed integer case int16 = "n" // n (110) - + /// Type code marking a 16-bit unsigned integer. case uint16 = "q" // q (113) - + /// Signed (two's complement) 32-bit integer case int32 = "i" // i (105) - + /// Unsigned 32-bit integer case uint32 = "u" // u (117) - + /// Signed (two's complement) 64-bit integer case int64 = "x" // x (120) - + /// Unsigned 64-bit integer case uint64 = "t" // t (116) - + /// IEEE 754 double-precision floating point case double = "d" // d (100) - + /// Unix file descriptor /// /// Unsigned 32-bit integer representing an index into an out-of-band array of file descriptors, transferred via some platform-specific mechanism case fileDescriptor = "h" // h (104) - + // MARK: - String-like types - + /// String /// /// - Note: No extra constraints. case string = "s" // s (115) - + /// DBus Object Path /// /// - Note: Must be a [syntactically valid object path](https://dbus.freedesktop.org/doc/dbus-specification.html#message-protocol-marshaling-object-path). case objectPath = "o" // o (111) - + /// DBus Signature /// /// - Note: Zero or more single complete types case signature = "g" // g (103) - + // MARK: - Container types - + /// Array case array = "a" // a (97) - + /// Variant type (the type of the value is part of the value itself) case variant = "v" // v (118) - + // Container - + /// Struct /// /// - Note: Struct has a type code, ASCII character 'r', but this type code does not appear in signatures. @@ -536,7 +601,7 @@ public extension DBusSignature { /// So for example, a struct containing two integers would have this signature: "`(ii)`". case structStart = "(" case structEnd = ")" - + /// Entry in a dict or map (array of key-value pairs). /// /// - Note: Type code 101 'e' is reserved for use in bindings and implementations @@ -547,9 +612,9 @@ public extension DBusSignature { } public extension DBusSignature.ValueType { - + var characters: [DBusSignature.Character] { - + switch self { case .byte: return [.byte] case .boolean: return [.boolean] @@ -573,17 +638,17 @@ public extension DBusSignature.ValueType { } public extension Collection where Element == DBusSignature.ValueType { - + var characters: [DBusSignature.Character] { - + return self.reduce([], { $0 + $1.characters }) } } public extension String { - + init(_ signature: [DBusSignature.Character]) { - + self = signature.reduce("", { $0 + $1.rawValue }) } } @@ -591,19 +656,21 @@ public extension String { // MARK: - DictionaryType public extension DBusSignature { - - struct DictionaryType: Equatable { - + + struct DictionaryType: Equatable, Hashable, Sendable { + public let key: ValueType - + public let value: ValueType - + + /// - Note: Returns `nil` if `key` is not a basic type. The specification restricts + /// dictionary keys to basic types, which excludes `variant` as well as the containers. public init?(key: ValueType, value: ValueType) { - - guard key.isContainer == false + + guard key.isBasic else { return nil } - + self.key = key self.value = value } @@ -611,23 +678,27 @@ public extension DBusSignature { } public extension DBusSignature.DictionaryType { - + var characters: [DBusSignature.Character] { - + return [.array, .dictionaryEntryStart] + key.characters + value.characters + [.dictionaryEntryEnd] } } extension DBusSignature.DictionaryType: RawRepresentable { - + public init?(rawValue: String) { - - // TODO: Implement string parsing - fatalError() + + guard let elements = DBusSignature.parse(rawValue), + elements.count == 1, + case let .dictionary(dictionaryType) = elements[0] + else { return nil } + + self = dictionaryType } - + public var rawValue: String { - + return String(characters) } } @@ -635,60 +706,72 @@ extension DBusSignature.DictionaryType: RawRepresentable { // MARK: - StructureType public extension DBusSignature { - - struct StructureType { - + + struct StructureType: Sendable { + @usableFromInline internal private(set) var elements: [ValueType] - + /// Empty structures are not allowed; there must be at least one type code between the parentheses. public init?(_ elements: [ValueType]) { - + guard elements.isEmpty == false else { return nil } - + self.elements = elements } } } extension DBusSignature.StructureType: Equatable { - + public static func == (lhs: DBusSignature.StructureType, rhs: DBusSignature.StructureType) -> Bool { - + return lhs.elements == rhs.elements } } +extension DBusSignature.StructureType: Hashable { + + public func hash(into hasher: inout Hasher) { + + hasher.combine(elements) + } +} + public extension DBusSignature.StructureType { - + var characters: [DBusSignature.Character] { - + return [.structStart] + elements.reduce([], { $0 + $1.characters }) + [.structEnd] } } extension DBusSignature.StructureType: RawRepresentable { - + public init?(rawValue: String) { - - // TODO: Implement string parsing - fatalError() + + guard let elements = DBusSignature.parse(rawValue), + elements.count == 1, + case let .struct(structureType) = elements[0] + else { return nil } + + self = structureType } - + public var rawValue: String { - + return String(characters) } } extension DBusSignature.StructureType: ExpressibleByArrayLiteral { - + public init(arrayLiteral elements: Element...) { - + guard let structureType = DBusSignature.StructureType(elements) else { fatalError("Invalid array literal \(elements)") } - + self = structureType } } @@ -696,73 +779,73 @@ extension DBusSignature.StructureType: ExpressibleByArrayLiteral { // MARK: Collection extension DBusSignature.StructureType: MutableCollection { - + public typealias Element = DBusSignature.ValueType - + public typealias Index = Int - + public subscript (index: Index) -> Element { - + get { return elements[index] } - + mutating set { elements[index] = newValue } } - + public var count: Int { - + return elements.count } - + /// The start `Index`. public var startIndex: Index { return 0 } - + /// The end `Index`. /// /// This is the "one-past-the-end" position, and will always be equal to the `count`. public var endIndex: Index { return count } - + public func index(before i: Index) -> Index { return i - 1 } - + public func index(after i: Index) -> Index { return i + 1 } - + public func makeIterator() -> IndexingIterator { return IndexingIterator(_elements: self) } - + public mutating func append(_ element: Element) { - + elements.append(element) } - + @discardableResult public mutating func removeFirst() -> Element { - + return elements.removeFirst() } - + @discardableResult public mutating func removeLast() -> Element { - + return elements.removeLast() } - + @discardableResult public mutating func remove(at index: Int) -> Element { - + return elements.remove(at: index) } - + /// Removes all elements from the object path. public mutating func removeAll(keepingCapacity: Bool = false) { - + self.elements.removeAll(keepingCapacity: keepingCapacity) } } diff --git a/Sources/DBus/Timeout.swift b/Sources/DBus/Timeout.swift deleted file mode 100644 index 2c5598d..0000000 --- a/Sources/DBus/Timeout.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// Timeout.swift -// DBus -// -// Created by Alsey Coleman Miller on 10/10/18. -// - -import CDBus - -/// DBus Timeout -public struct Timeout: RawRepresentable { - - public var rawValue: Int32 - - public init(rawValue: Int32) { - - self.rawValue = rawValue - } -} - -public extension Timeout { - - static let `default`: Timeout = -1 //Timeout(rawValue: DBUS_TIMEOUT_USE_DEFAULT) - - static let infinite: Timeout = Timeout(rawValue: .max) //Timeout(rawValue: DBUS_TIMEOUT_INFINITE) -} - -extension Timeout: ExpressibleByIntegerLiteral { - - public init(integerLiteral value: Int32) { - - self.init(rawValue: value) - } -} diff --git a/Sources/DBus/Transport/DBusAddress.swift b/Sources/DBus/Transport/DBusAddress.swift new file mode 100644 index 0000000..6d773a6 --- /dev/null +++ b/Sources/DBus/Transport/DBusAddress.swift @@ -0,0 +1,219 @@ +// +// DBusAddress.swift +// DBus +// + +/// A D-Bus server address. +/// +/// An address string is one or more alternatives separated by `;`, each of the form +/// `transport:key=value,key=value`. Values are percent-encoded: any byte may be written as +/// `%` followed by two hexadecimal digits, and the characters that are *not* optionally +/// escaped are `[-0-9A-Za-z_/.\\*]`. +/// +/// Reference: https://dbus.freedesktop.org/doc/dbus-specification.html#addresses +public struct DBusAddress: Equatable, Hashable, Sendable { + + /// The transport name, e.g. `unix`. + public let transport: String + + /// The transport-specific key/value parameters, in the order they appeared. + public let properties: [(key: String, value: String)] + + internal init(transport: String, properties: [(key: String, value: String)]) { + + self.transport = transport + self.properties = properties + } + + public static func == (lhs: DBusAddress, rhs: DBusAddress) -> Bool { + + return lhs.transport == rhs.transport + && lhs.properties.count == rhs.properties.count + && zip(lhs.properties, rhs.properties).allSatisfy { $0.key == $1.key && $0.value == $1.value } + } + + public func hash(into hasher: inout Hasher) { + + hasher.combine(transport) + for property in properties { + hasher.combine(property.key) + hasher.combine(property.value) + } + } +} + +public extension DBusAddress { + + /// The value of the given parameter, if present. + subscript (key: String) -> String? { + + return properties.first(where: { $0.key == key })?.value + } +} + +// MARK: - Parsing + +public extension DBusAddress { + + /// Parse an address string, which may list several alternatives separated by `;`. + /// + /// - Returns: Every alternative, in preference order. Empty alternatives are skipped, + /// since a trailing `;` is permitted. + static func parse(_ string: String) throws -> [DBusAddress] { + + var addresses = [DBusAddress]() + + for alternative in string.split(separator: ";", omittingEmptySubsequences: true) { + + addresses.append(try parseSingle(String(alternative), original: string)) + } + + guard addresses.isEmpty == false + else { throw DBusProtocolError.invalidAddress(string) } + + return addresses + } + + private static func parseSingle(_ string: String, original: String) throws -> DBusAddress { + + guard let colonIndex = string.firstIndex(of: ":") + else { throw DBusProtocolError.invalidAddress(original) } + + let transport = String(string[string.startIndex ..< colonIndex]) + + guard transport.isEmpty == false + else { throw DBusProtocolError.invalidAddress(original) } + + let parameterString = string[string.index(after: colonIndex)...] + + var properties = [(key: String, value: String)]() + + for parameter in parameterString.split(separator: ",", omittingEmptySubsequences: true) { + + guard let equalsIndex = parameter.firstIndex(of: "=") + else { throw DBusProtocolError.invalidAddress(original) } + + let key = String(parameter[parameter.startIndex ..< equalsIndex]) + let rawValue = parameter[parameter.index(after: equalsIndex)...] + + guard key.isEmpty == false, + let value = unescape(String(rawValue)) + else { throw DBusProtocolError.invalidAddress(original) } + + properties.append((key: key, value: value)) + } + + return DBusAddress(transport: transport, properties: properties) + } + + /// Decode percent-escaped bytes, e.g. `%2F` becomes `/`. + internal static func unescape(_ string: String) -> String? { + + guard string.contains("%") + else { return string } + + var bytes = [UInt8]() + var iterator = string.utf8.makeIterator() + + while let byte = iterator.next() { + + guard byte == 0x25 // '%' + else { bytes.append(byte); continue } + + guard let high = iterator.next().flatMap(hexDigit), + let low = iterator.next().flatMap(hexDigit) + else { return nil } + + bytes.append(high << 4 | low) + } + + return String(validatingUTF8: bytes) + } + + private static func hexDigit(_ byte: UInt8) -> UInt8? { + + switch byte { + case 0x30 ... 0x39: return byte - 0x30 // 0-9 + case 0x41 ... 0x46: return byte - 0x41 + 10 // A-F + case 0x61 ... 0x66: return byte - 0x61 + 10 // a-f + default: return nil + } + } +} + +// MARK: - Well Known Buses + +public extension DBusAddress { + + /// The address of the well known bus of the given type, from the environment. + /// + /// - Throws: `DBusProtocolError.invalidAddress` if the relevant environment variable is + /// unset and no default applies. + static func addresses(for busType: DBusBusType) throws -> [DBusAddress] { + + switch busType { + + case .session: + guard let string = ProcessEnvironment.value(for: "DBUS_SESSION_BUS_ADDRESS") + else { throw DBusProtocolError.invalidAddress("DBUS_SESSION_BUS_ADDRESS is not set") } + return try parse(string) + + case .system: + // Unlike the session bus, the system bus has a well known default location. + let string = ProcessEnvironment.value(for: "DBUS_SYSTEM_BUS_ADDRESS") + ?? "unix:path=/var/run/dbus/system_bus_socket" + return try parse(string) + + case .starter: + guard let string = ProcessEnvironment.value(for: "DBUS_STARTER_ADDRESS") + else { throw DBusProtocolError.invalidAddress("DBUS_STARTER_ADDRESS is not set") } + return try parse(string) + } + } +} + +// MARK: - Socket Address + +public extension DBusAddress { + + /// The Unix socket this address refers to. + /// + /// Recognises `unix:path=`, `unix:abstract=`, `unix:tmpdir=` and `unix:runtime=yes`. + /// + /// - Throws: `DBusProtocolError.invalidAddress` if the transport is not `unix`, or if no + /// recognised parameter is present. + func unixSocketAddress() throws -> DBusUnixSocketAddress { + + guard transport == "unix" + else { throw DBusProtocolError.invalidAddress("Unsupported transport '\(transport)'") } + + if let path = self["path"] { + return .path(path) + } + + if let abstract = self["abstract"] { + return .abstract(abstract) + } + + if self["runtime"] == "yes" { + + guard let runtimeDirectory = ProcessEnvironment.value(for: "XDG_RUNTIME_DIR") + else { throw DBusProtocolError.invalidAddress("XDG_RUNTIME_DIR is not set") } + + return .path(runtimeDirectory + "/bus") + } + + throw DBusProtocolError.invalidAddress("No socket path in unix address") + } +} + +// MARK: - Description + +extension DBusAddress: CustomStringConvertible { + + public var description: String { + + let parameters = properties.map { "\($0.key)=\($0.value)" }.joined(separator: ",") + return "\(transport):\(parameters)" + } +} diff --git a/Sources/DBus/Transport/DBusTCPAddress.swift b/Sources/DBus/Transport/DBusTCPAddress.swift new file mode 100644 index 0000000..6af8e39 --- /dev/null +++ b/Sources/DBus/Transport/DBusTCPAddress.swift @@ -0,0 +1,238 @@ +// +// DBusTCPAddress.swift +// DBus +// + +import Socket +import SystemPackage + +#if canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#elseif canImport(Darwin) +import Darwin +#elseif canImport(Bionic) +import Bionic +#endif + +/// A resolved TCP endpoint to connect to. +/// +/// - Note: An enum because `IPv4SocketAddress` and `IPv6SocketAddress` are distinct types with +/// distinct protocol identifiers, so the socket must be created differently for each. +public enum DBusTCPEndpoint: Equatable, Hashable, Sendable { + + case ipv4(IPv4SocketAddress) + case ipv6(IPv6SocketAddress) +} + +public extension DBusTCPEndpoint { + + /// The port the endpoint refers to. + var port: UInt16 { + + switch self { + case let .ipv4(address): return address.port + case let .ipv6(address): return address.port + } + } +} + +extension DBusTCPEndpoint: CustomStringConvertible { + + public var description: String { + + switch self { + case let .ipv4(address): return "\(address.address.rawValue):\(address.port)" + case let .ipv6(address): return "[\(address.address.rawValue)]:\(address.port)" + } + } +} + +// MARK: - Resolution + +public extension DBusTCPEndpoint { + + /// The address family a `tcp:` address may request. + enum Family: String, Sendable { + + case ipv4 + case ipv6 + } + + /// Resolve a host and port into every endpoint that can be tried, in the order the + /// resolver returned them. + /// + /// - Parameter family: Restricts the lookup when the address specified one; otherwise both + /// families are returned. + static func resolve(host: String, + port: UInt16, + family: Family? = nil) throws -> [DBusTCPEndpoint] { + + var hints = addrinfo() + // Glibc, Musl and Bionic declare the socket types as an enumeration; Darwin as Int32. + #if canImport(Darwin) + hints.ai_socktype = SOCK_STREAM + #else + hints.ai_socktype = Int32(SOCK_STREAM.rawValue) + #endif + hints.ai_protocol = Int32(IPPROTO_TCP) + + switch family { + case .ipv4: hints.ai_family = AF_INET + case .ipv6: hints.ai_family = AF_INET6 + case nil: hints.ai_family = AF_UNSPEC + } + + var result: UnsafeMutablePointer? + + let status = host.withCString { hostPointer in + String(port).withCString { portPointer in + getaddrinfo(hostPointer, portPointer, &hints, &result) + } + } + + guard status == 0, let first = result else { + + let reason = status == 0 ? "no addresses" : String(cString: gai_strerror(status)) + throw DBusProtocolError.invalidAddress("Could not resolve \(host):\(port): \(reason)") + } + + defer { freeaddrinfo(first) } + + var endpoints = [DBusTCPEndpoint]() + + var entry: UnsafeMutablePointer? = first + + while let current = entry { + + defer { entry = current.pointee.ai_next } + + guard let socketAddress = current.pointee.ai_addr + else { continue } + + switch current.pointee.ai_family { + + case AF_INET: + let value = socketAddress.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { $0.pointee } + endpoints.append(.ipv4(IPv4SocketAddress(address: IPv4Address(value.sin_addr), + port: UInt16(bigEndian: value.sin_port)))) + + case AF_INET6: + let value = socketAddress.withMemoryRebound(to: sockaddr_in6.self, capacity: 1) { $0.pointee } + endpoints.append(.ipv6(IPv6SocketAddress(address: IPv6Address(value.sin6_addr), + port: UInt16(bigEndian: value.sin6_port)))) + + default: + continue // a family this transport does not speak + } + } + + guard endpoints.isEmpty == false + else { throw DBusProtocolError.invalidAddress("No usable address for \(host):\(port)") } + + return endpoints + } +} + +// MARK: - Address + +public extension DBusAddress { + + /// The TCP endpoints this address refers to. + /// + /// Recognises the `tcp:` and `nonce-tcp:` transports, both of which take `host`, `port` and + /// an optional `family`. + /// + /// - Throws: `DBusProtocolError.invalidAddress` if the transport is not TCP, or the host or + /// port is missing or malformed. + func tcpEndpoints() throws -> [DBusTCPEndpoint] { + + guard transport == "tcp" || transport == "nonce-tcp" + else { throw DBusProtocolError.invalidAddress("Not a TCP transport: '\(transport)'") } + + guard let host = self["host"], host.isEmpty == false + else { throw DBusProtocolError.invalidAddress("No host in TCP address") } + + guard let portString = self["port"], let port = UInt16(portString) + else { throw DBusProtocolError.invalidAddress("No valid port in TCP address") } + + let family: DBusTCPEndpoint.Family? + + if let familyString = self["family"] { + guard let parsed = DBusTCPEndpoint.Family(rawValue: familyString) + else { throw DBusProtocolError.invalidAddress("Unknown address family '\(familyString)'") } + family = parsed + } else { + family = nil + } + + return try DBusTCPEndpoint.resolve(host: host, port: port, family: family) + } + + /// The nonce a `nonce-tcp:` address requires, read from the file it names. + /// + /// The client sends these bytes immediately after connecting and before the SASL handshake + /// begins, proving it can read a file only the server's user can read. + /// + /// - Returns: `nil` for a plain `tcp:` address, which needs no nonce. + func nonce() throws -> [UInt8]? { + + guard transport == "nonce-tcp" + else { return nil } + + guard let path = self["noncefile"] + else { throw DBusProtocolError.invalidAddress("nonce-tcp address has no noncefile") } + + guard let descriptor = try? FileDescriptor.open(FilePath(path), .readOnly) + else { throw DBusProtocolError.invalidAddress("Could not read the nonce file at \(path)") } + + defer { try? descriptor.close() } + + // The nonce is a fixed 16 bytes, but read a little more so a wrong-sized file is + // detected rather than silently truncated. + var buffer = [UInt8](repeating: 0, count: 32) + + guard let count = try? buffer.withUnsafeMutableBytes({ try descriptor.read(into: $0) }) + else { throw DBusProtocolError.invalidAddress("Could not read the nonce file at \(path)") } + + guard count == DBusAddress.nonceLength + else { throw DBusProtocolError.invalidAddress("Nonce file is \(count) bytes, expected \(DBusAddress.nonceLength)") } + + return Array(buffer[0 ..< count]) + } + + /// The length of a nonce-tcp nonce, in bytes. + static var nonceLength: Int { 16 } +} + +// MARK: - Endpoint + +/// Somewhere a connection can be established. +internal enum DBusTransportEndpoint { + + case unix(DBusUnixSocketAddress) + + /// A TCP endpoint, with the nonce to send first if the transport requires one. + case tcp(DBusTCPEndpoint, nonce: [UInt8]?) +} + +internal extension DBusAddress { + + /// Every endpoint this address can be reached at, in preference order. + func endpoints() throws -> [DBusTransportEndpoint] { + + switch transport { + + case "unix": + return [.unix(try unixSocketAddress())] + + case "tcp", "nonce-tcp": + let nonce = try self.nonce() + return try tcpEndpoints().map { .tcp($0, nonce: nonce) } + + default: + throw DBusProtocolError.invalidAddress("Unsupported transport '\(transport)'") + } + } +} diff --git a/Sources/DBus/Transport/DBusUnixSocketAddress.swift b/Sources/DBus/Transport/DBusUnixSocketAddress.swift new file mode 100644 index 0000000..190984a --- /dev/null +++ b/Sources/DBus/Transport/DBusUnixSocketAddress.swift @@ -0,0 +1,201 @@ +// +// DBusUnixSocketAddress.swift +// DBus +// + +import Socket +import SystemPackage + +/// The `AF_UNIX` / `SOCK_STREAM` protocol a D-Bus connection uses. +/// +/// - Note: `Socket` ships `UnixProtocol`, but its only case maps to `SOCK_RAW`. D-Bus runs +/// over a stream socket. +public enum DBusUnixProtocol: Int32, Sendable, SocketProtocol { + + case stream = 0 + + public static var family: SocketAddressFamily { .unix } + + public var type: SocketType { .stream } +} + +/// A Unix domain socket address, in either the filesystem or the Linux abstract namespace. +/// +/// - Note: `Socket.UnixSocketAddress` stores a `FilePath` and writes `sun_path` with +/// `withPlatformString`, so it cannot express an abstract address: those begin with a NUL byte +/// and their length is carried by the `socklen_t`, not by NUL termination. The session bus is +/// commonly reached through an abstract socket, so this type handles both forms. +/// +/// Reference: unix(7), "abstract sockets". +public struct DBusUnixSocketAddress: SocketAddress, Equatable, Hashable, Sendable { + + public typealias ProtocolID = DBusUnixProtocol + + /// Where the socket lives. + public enum Namespace: Equatable, Hashable, Sendable { + + /// A path in the filesystem, NUL terminated in `sun_path`. + case filesystem + + /// A name in the Linux abstract namespace, preceded by a NUL byte in `sun_path` + /// and delimited by the address length rather than by NUL termination. + case abstract + } + + /// The namespace the socket lives in. + public let namespace: Namespace + + /// The path or abstract name, without the leading NUL of the abstract form. + public let name: String + + internal init(namespace: Namespace, name: String) { + + self.namespace = namespace + self.name = name + } + + /// A socket at the given filesystem path. + public static func path(_ path: String) -> DBusUnixSocketAddress { + + return DBusUnixSocketAddress(namespace: .filesystem, name: path) + } + + /// A socket with the given name in the Linux abstract namespace. + public static func abstract(_ name: String) -> DBusUnixSocketAddress { + + return DBusUnixSocketAddress(namespace: .abstract, name: name) + } +} + +// MARK: - Capacity + +internal extension DBusUnixSocketAddress { + + /// The size of `sockaddr_un.sun_path`, which is 108 bytes on Linux. + static var pathCapacity: Int { + + return MemoryLayout.size(ofValue: CInterop.UnixSocketAddress().sun_path) + } + + /// The offset of `sun_path` within `sockaddr_un`, i.e. the size of `sun_family`. + static var pathOffset: Int { + + return MemoryLayout.size - pathCapacity + } + + /// The bytes written into `sun_path`, and the resulting address length. + /// + /// - Filesystem: the path bytes plus a terminating NUL. + /// - Abstract: a leading NUL, then the name bytes, with no terminator. + func pathBytes() throws -> (bytes: [UInt8], length: CInterop.SocketLength) { + + let nameBytes = Swift.Array(name.utf8) + + switch namespace { + + case .filesystem: + guard nameBytes.count + 1 <= DBusUnixSocketAddress.pathCapacity + else { throw DBusProtocolError.invalidAddress("Socket path is too long: '\(name)'") } + + let bytes = nameBytes + [0] + return (bytes, CInterop.SocketLength(DBusUnixSocketAddress.pathOffset + bytes.count)) + + case .abstract: + guard nameBytes.count + 1 <= DBusUnixSocketAddress.pathCapacity + else { throw DBusProtocolError.invalidAddress("Abstract socket name is too long: '\(name)'") } + + // The length, not a NUL, delimits an abstract name, so no terminator is appended. + let bytes = [0] + nameBytes + return (bytes, CInterop.SocketLength(DBusUnixSocketAddress.pathOffset + bytes.count)) + } + } +} + +// MARK: - SocketAddress + +public extension DBusUnixSocketAddress { + + func withUnsafePointer( + _ body: (UnsafePointer, CInterop.SocketLength) throws(Error) -> Result + ) rethrows -> Result where Error: Swift.Error { + + // `pathBytes()` only fails for an over-long name, which `init` callers should have + // rejected; trap rather than widen this protocol requirement to throwing. + guard let (bytes, length) = try? pathBytes() + else { fatalError("Socket name exceeds sun_path capacity: '\(name)'") } + + var socketAddress = CInterop.UnixSocketAddress() + socketAddress.sun_family = numericCast(Self.family.rawValue) + + withUnsafeMutableBytes(of: &socketAddress.sun_path) { pathBuffer in + for (index, byte) in bytes.enumerated() { + pathBuffer[index] = byte + } + } + + return try Swift.withUnsafeBytes(of: &socketAddress) { buffer throws(Error) -> Result in + try body(buffer.baseAddress!.assumingMemoryBound(to: CInterop.SocketAddress.self), length) + } + } + + static func withUnsafePointer( + _ pointer: UnsafeMutablePointer + ) -> Self { + + return pointer.withMemoryRebound(to: CInterop.UnixSocketAddress.self, capacity: 1) { + Self.init($0.pointee) + } + } + + static func withUnsafePointer( + _ body: (UnsafeMutablePointer, CInterop.SocketLength) throws -> () + ) rethrows -> Self { + + var socketAddress = CInterop.UnixSocketAddress() + + try withUnsafeMutableBytes(of: &socketAddress) { buffer in + try body(buffer.baseAddress!.assumingMemoryBound(to: CInterop.SocketAddress.self), + CInterop.SocketLength(MemoryLayout.size)) + } + + return Self.init(socketAddress) + } + + internal init(_ cValue: CInterop.UnixSocketAddress) { + + var value = cValue + + let (namespace, name): (Namespace, String) = withUnsafeBytes(of: &value.sun_path) { pathBuffer in + + let bytes = pathBuffer.bindMemory(to: UInt8.self) + + guard let first = bytes.first + else { return (.filesystem, "") } + + if first == 0 { + // Abstract. Without the true address length the name cannot be delimited + // exactly, so take everything up to the first trailing NUL run. + let remainder = bytes.dropFirst().prefix(while: { $0 != 0 }) + return (.abstract, String(decoding: remainder, as: UTF8.self)) + } else { + let path = bytes.prefix(while: { $0 != 0 }) + return (.filesystem, String(decoding: path, as: UTF8.self)) + } + } + + self.init(namespace: namespace, name: name) + } +} + +// MARK: - Description + +extension DBusUnixSocketAddress: CustomStringConvertible { + + public var description: String { + + switch namespace { + case .filesystem: return "unix:path=\(name)" + case .abstract: return "unix:abstract=\(name)" + } + } +} diff --git a/Sources/DBus/Transport/ProcessEnvironment.swift b/Sources/DBus/Transport/ProcessEnvironment.swift new file mode 100644 index 0000000..4faaf98 --- /dev/null +++ b/Sources/DBus/Transport/ProcessEnvironment.swift @@ -0,0 +1,86 @@ +// +// ProcessEnvironment.swift +// DBus +// + +#if canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#elseif canImport(Darwin) +import Darwin +#elseif canImport(Bionic) +import Bionic +#endif + +/// Access to environment variables and process credentials, without Foundation. +internal enum ProcessEnvironment { + + /// The value of the named environment variable, or `nil` if unset. + static func value(for name: String) -> String? { + + guard let pointer = name.withCString({ getenv($0) }) + else { return nil } + + let value = String(cString: pointer) + + // An empty variable is treated as unset, which is what the reference implementation does + // for the bus address variables. + return value.isEmpty ? nil : value + } + + /// The real user ID of the calling process. + /// + /// Used as the credential for SASL `EXTERNAL` authentication. + static var userID: UInt32 { + + return UInt32(getuid()) + } + + /// The login name of the calling user. + /// + /// Used as the credential for SASL `DBUS_COOKIE_SHA1`, which identifies the user whose + /// keyring holds the shared secret. + static var userName: String { + + if let name = passwordEntry(\.pw_name) { + return name + } + + // Fall back to the environment, then to the numeric uid, which some servers accept. + return value(for: "LOGNAME") ?? value(for: "USER") ?? String(userID) + } + + /// The calling user's home directory, where the keyring lives. + static var homeDirectory: String? { + + // `HOME` wins, matching the reference implementation, so a test can redirect it. + if let home = value(for: "HOME") { + return home + } + + return passwordEntry(\.pw_dir) + } + + /// Read a field from the calling user's password database entry. + private static func passwordEntry(_ field: KeyPath?>) -> String? { + + var entry = passwd() + var result: UnsafeMutablePointer? + + // Sized generously; `getpwuid_r` fails with ERANGE if the buffer is too small. + var buffer = [CChar](repeating: 0, count: 4096) + + let status = buffer.withUnsafeMutableBufferPointer { pointer in + getpwuid_r(getuid(), &entry, pointer.baseAddress!, pointer.count, &result) + } + + guard status == 0, result != nil, + let value = entry[keyPath: field] + else { return nil } + + let string = String(cString: value) + + return string.isEmpty ? nil : string + } +} diff --git a/Sources/DBus/Type.swift b/Sources/DBus/Type.swift index 6c73ad4..fa1b6d8 100644 --- a/Sources/DBus/Type.swift +++ b/Sources/DBus/Type.swift @@ -6,79 +6,80 @@ // Copyright © 2016 PureSwift. All rights reserved. // -import CDBus +/// DBus type code. +/// +/// A single ASCII character identifying a D-Bus type, as used in signatures and +/// in the type-code position of a marshalled value. +public enum DBusType: String, Sendable, CaseIterable { -/// DBus Type (for internal usage with libdbus) -public enum DBusType: String { - // MARK: - Fixed Length Types - + /// Type code marking an 8-bit unsigned integer. case byte = "y" // y (121) - + /// Type code marking a boolean. /// /// Boolean value: 0 is false, 1 is true, any other value allowed by the marshalling format is invalid. case boolean = "b" // b (98) - + /// Type code marking a 16-bit signed integer case int16 = "n" // n (110) - - /// Type code marking a 16-bit unsigned integer. + + /// Type code marking a 16-bit unsigned integer. case uint16 = "q" // q (113) - + /// Signed (two's complement) 32-bit integer case int32 = "i" // i (105) - + /// Unsigned 32-bit integer case uint32 = "u" // u (117) - + /// Signed (two's complement) 64-bit integer case int64 = "x" // x (120) - + /// Unsigned 64-bit integer case uint64 = "t" // t (116) - + /// IEEE 754 double-precision floating point case double = "d" // d (100) - + /// Unix file descriptor /// /// Unsigned 32-bit integer representing an index into an out-of-band array of file descriptors, transferred via some platform-specific mechanism case fileDescriptor = "h" // h (104) - + // MARK: - String-like types - + /// String /// /// - Note: No extra constraints. case string = "s" // s (115) - + /// DBus Object Path /// /// - Note: Must be a [syntactically valid object path](https://dbus.freedesktop.org/doc/dbus-specification.html#message-protocol-marshaling-object-path). case objectPath = "o" // o (111) - + /// DBus Signature /// /// - Note: Zero or more single complete types case signature = "g" // g (103) - + // MARK: - Container types - + /// Array case array = "a" // a (97) - + /// Variant type (the type of the value is part of the value itself) case variant = "v" // v (118) - + /// Struct /// /// - Note: Struct has a type code, ASCII character 'r', but this type code does not appear in signatures. /// Instead, ASCII characters '(' and ')' are used to mark the beginning and end of the struct. /// So for example, a struct containing two integers would have this signature: "`(ii)`". case `struct` = "r" // r (114) - + /// Entry in a dict or map (array of key-value pairs). /// /// - Note: Type code 101 'e' is reserved for use in bindings and implementations @@ -87,52 +88,81 @@ public enum DBusType: String { } public extension DBusType { - + /// A "basic type" is a somewhat arbitrary concept, but the intent is to include those types that /// are fully-specified by a single typecode, with no additional type information or nested values. var isBasic: Bool { - - return Bool(dbus_type_is_basic(Int32(integerValue))) + + return isFixed || isStringLike } - + /// A "container type" can contain basic types, or nested container types. var isContainer: Bool { - - return Bool(dbus_type_is_container(Int32(integerValue))) + + switch self { + case .array, + .variant, + .struct, + .dictionaryEntry: + return true + default: + return false + } } - + /// Tells you whether values of this type can change length if you set them to some other value. /// /// For this purpose, you assume that the first byte of the old and new value would be in the same location, /// so alignment padding is not a factor. var isFixed: Bool { - - return Bool(dbus_type_is_fixed(Int32(integerValue))) + + switch self { + case .byte, + .boolean, + .int16, + .uint16, + .int32, + .uint32, + .int64, + .uint64, + .double, + .fileDescriptor: + return true + default: + return false + } } -} -internal extension DBusType { - - /// Return `true` if the argument is a valid typecode. - var isValid: Bool { - - return Bool(dbus_type_is_valid(Int32(integerValue))) + /// Whether the type is one of the length-prefixed, NUL-terminated string types. + var isStringLike: Bool { + + switch self { + case .string, + .objectPath, + .signature: + return true + default: + return false + } } } internal extension DBusType { - - init?(_ integerValue: Int) { - - guard let scalar = Unicode.Scalar(integerValue) + + /// Initialize from the ASCII value of the type code. + init?(_ integerValue: UInt8) { + + let scalar = Unicode.Scalar(integerValue) + + guard scalar.isASCII else { return nil } - + self.init(rawValue: String(Character(scalar))) - - assert(isValid) } - - var integerValue: Int { - return Int(rawValue.utf8.first!) + + /// The ASCII value of the type code. + var integerValue: UInt8 { + + return rawValue.utf8.first! } } diff --git a/Sources/DBus/UTF8.swift b/Sources/DBus/UTF8.swift new file mode 100644 index 0000000..79057d8 --- /dev/null +++ b/Sources/DBus/UTF8.swift @@ -0,0 +1,29 @@ +// +// UTF8.swift +// DBus +// + +internal extension String { + + /// Decode UTF-8 bytes, returning `nil` if they are not valid UTF-8. + /// + /// Everything arriving from the bus is untrusted, and the specification requires strings, + /// object paths and interface names to be valid UTF-8, so invalid input must be rejected + /// rather than accepted with replacement characters substituted for the bad bytes. + /// + /// - Note: `String(validating:as:)` does exactly this, but is only available from macOS 15 + /// while this package supports macOS 13. `String(decoding:as:)` never fails — it substitutes + /// U+FFFD — so the decoded string is re-encoded and compared against the input. Only input + /// that was already valid UTF-8 can round trip: any substitution changes the bytes, and a + /// literal U+FFFD in the input encodes back to the same bytes it came from. + init?>(validatingUTF8 bytes: Bytes) { + + let bytes = Array(bytes) + let decoded = String(decoding: bytes, as: UTF8.self) + + guard Array(decoded.utf8) == bytes + else { return nil } + + self = decoded + } +} diff --git a/Tests/DBusTests/AddressTests.swift b/Tests/DBusTests/AddressTests.swift new file mode 100644 index 0000000..5ab8b8d --- /dev/null +++ b/Tests/DBusTests/AddressTests.swift @@ -0,0 +1,127 @@ +// +// AddressTests.swift +// DBusTests +// + +import Testing +@testable import DBus + +@Suite struct AddressTests { + + @Test func unixPath() throws { + + let addresses = try DBusAddress.parse("unix:path=/run/user/1000/bus") + + #expect(addresses.count == 1) + #expect(addresses[0].transport == "unix") + #expect(addresses[0]["path"] == "/run/user/1000/bus") + #expect(try addresses[0].unixSocketAddress() == .path("/run/user/1000/bus")) + } + + @Test func unixAbstract() throws { + + let addresses = try DBusAddress.parse("unix:abstract=/tmp/dbus-XyZ123,guid=deadbeef") + + #expect(addresses[0]["abstract"] == "/tmp/dbus-XyZ123") + #expect(addresses[0]["guid"] == "deadbeef") + #expect(try addresses[0].unixSocketAddress() == .abstract("/tmp/dbus-XyZ123")) + } + + @Test func alternatives() throws { + + let addresses = try DBusAddress.parse("unix:path=/a;unix:abstract=/b;") + + #expect(addresses.count == 2) + #expect(try addresses[0].unixSocketAddress() == .path("/a")) + #expect(try addresses[1].unixSocketAddress() == .abstract("/b")) + } + + @Test func percentEscaping() throws { + + // Values are percent-encoded; anything outside [-0-9A-Za-z_/.\*] may be escaped. + let addresses = try DBusAddress.parse("unix:path=/tmp/dbus%2Dtest%20one") + + #expect(addresses[0]["path"] == "/tmp/dbus-test one") + + #expect(DBusAddress.unescape("%C3%B1") == "ñ") + #expect(DBusAddress.unescape("plain") == "plain") + #expect(DBusAddress.unescape("%2f") == "/") // lowercase hex is accepted + #expect(DBusAddress.unescape("%2") == nil) // truncated escape + #expect(DBusAddress.unescape("%ZZ") == nil) // non-hex digits + } + + @Test func tcpTransportIsRejectedForSockets() throws { + + let addresses = try DBusAddress.parse("tcp:host=127.0.0.1,port=1234") + + #expect(addresses[0].transport == "tcp") + #expect(addresses[0]["port"] == "1234") + + // Parsing succeeds, but this branch only implements the unix transport. + #expect(throws: (any Error).self) { try addresses[0].unixSocketAddress() } + } + + @Test(arguments: [ + "", + ";", + "unix", // no colon + ":path=/a", // no transport + "unix:path", // parameter without '=' + "unix:=/a" // parameter without a key + ]) + func invalidAddress(string: String) { + + #expect(throws: (any Error).self, "\(string) should be invalid") { + try DBusAddress.parse(string) + } + } + + @Test func unixAddressWithoutPathIsRejected() throws { + + let addresses = try DBusAddress.parse("unix:guid=abc") + + #expect(throws: (any Error).self) { try addresses[0].unixSocketAddress() } + } + + // MARK: - Socket Address + + @Test func filesystemSocketAddressBytes() throws { + + let address = DBusUnixSocketAddress.path("/run/user/1000/bus") + let (bytes, length) = try address.pathBytes() + + // A filesystem path is NUL terminated, and the length covers sun_family plus the + // terminated path. + #expect(bytes.last == 0) + #expect(Array(bytes.dropLast()) == Array("/run/user/1000/bus".utf8)) + #expect(Int(length) == DBusUnixSocketAddress.pathOffset + bytes.count) + } + + /// The abstract namespace is why this package defines its own socket address type: + /// `Socket.UnixSocketAddress` writes `sun_path` as a C string and cannot represent it. + @Test func abstractSocketAddressBytes() throws { + + let address = DBusUnixSocketAddress.abstract("/tmp/dbus-XyZ") + let (bytes, length) = try address.pathBytes() + + // Leading NUL, then the name, with no terminator: the length delimits the name. + #expect(bytes.first == 0) + #expect(Array(bytes.dropFirst()) == Array("/tmp/dbus-XyZ".utf8)) + #expect(bytes.last != 0) + #expect(Int(length) == DBusUnixSocketAddress.pathOffset + 1 + "/tmp/dbus-XyZ".utf8.count) + } + + @Test func overlongSocketNameIsRejected() { + + let long = String(repeating: "a", count: DBusUnixSocketAddress.pathCapacity + 1) + + #expect(throws: (any Error).self) { try DBusUnixSocketAddress.path(long).pathBytes() } + #expect(throws: (any Error).self) { try DBusUnixSocketAddress.abstract(long).pathBytes() } + } + + @Test func socketAddressDescription() { + + #expect(DBusUnixSocketAddress.path("/a").description == "unix:path=/a") + #expect(DBusUnixSocketAddress.abstract("b").description == "unix:abstract=b") + } +} diff --git a/Tests/DBusTests/ConnectionTests.swift b/Tests/DBusTests/ConnectionTests.swift new file mode 100644 index 0000000..2868eb4 --- /dev/null +++ b/Tests/DBusTests/ConnectionTests.swift @@ -0,0 +1,207 @@ +// +// ConnectionTests.swift +// DBusTests +// + +import Testing +@testable import DBus + +/// End-to-end tests against a real message bus. +/// +/// These are the strongest check on the marshaller: the bus daemon is a reference +/// implementation, so if it accepts our `Hello` and answers our calls, the wire format is right +/// in a way no self-written round-trip test can establish. +/// +/// Serialized because each test opens real sockets; running them concurrently would multiply +/// connections against the daemon for no added coverage. +@Suite(.serialized, .enabled(if: hasSessionBus, "No session bus is available")) +struct ConnectionTests { + + private var interface: DBusInterface { DBusInterface(rawValue: "org.freedesktop.DBus")! } + private var path: DBusObjectPath { DBusObjectPath(rawValue: "/org/freedesktop/DBus")! } + private var destination: DBusBusName { DBusBusName(rawValue: "org.freedesktop.DBus")! } + + private func call(_ method: String, arguments: [DBusMessageArgument] = []) -> DBusMessage { + + return DBusMessage(methodCall: DBusMessage.MethodCall(destination: destination, + path: path, + interface: interface, + method: DBusMember(rawValue: method)!), + arguments: arguments) + } + + /// A successful `Hello` proves the whole stack: socket, SASL, header marshalling and framing. + @Test func helloAssignsUniqueName() async throws { + + try await withConnection { connection in + + let uniqueName = await connection.uniqueName + + #expect(uniqueName != nil) + #expect(uniqueName?.isUnique == true, "\(uniqueName?.rawValue ?? "nil") should start with ':'") + #expect(await connection.serverGUID != nil) + #expect(await connection.isConnected) + } + } + + @Test func listNames() async throws { + + try await withConnection { connection in + + let names = try await connection.listNames() + + #expect(names.contains("org.freedesktop.DBus"), "The bus daemon must own its own name") + + let ownName = try #require(await connection.uniqueName).rawValue + #expect(names.contains(ownName), "Our own unique name should be listed") + } + } + + /// `GetAll` returns `a{sv}`, which the libdbus-backed implementation could not decode at all. + @Test func getAllProperties() async throws { + + try await withConnection { connection in + + let reply = try await connection.send( + DBusMessage(methodCall: DBusMessage.MethodCall( + destination: destination, + path: path, + interface: DBusWellKnown.propertiesInterface, + method: DBusMember(rawValue: "GetAll")!), + arguments: [.string("org.freedesktop.DBus")]) + ) + + #expect(reply.type == .methodReturn) + #expect(reply.signature.rawValue == "a{sv}") + + guard case let .dictionary(properties)? = reply.arguments.first + else { Issue.record("Expected a dictionary, got \(reply.arguments)"); return } + + #expect(properties.keyType == .string) + #expect(properties.valueType == .variant) + + // Every value must be a variant carrying a decodable payload. + for entry in properties { + #expect(entry.key.stringValue != nil) + #expect(entry.value.variantValue != nil, "\(entry.key) is not a variant") + } + } + } + + /// Round-trips a value through the daemon and back, checking the bus agrees with our encoding. + @Test func getNameOwner() async throws { + + try await withConnection { connection in + + let owner = try await connection.getNameOwner(destination) + #expect(owner.rawValue == "org.freedesktop.DBus") + + #expect(try await connection.nameHasOwner(destination)) + #expect(try await connection.getBusID().isEmpty == false) + } + } + + /// An error reply must surface as a thrown `DBusError`, not as a returned message. + @Test func errorReplyThrows() async throws { + + try await withConnection { connection in + + let error = await #expect(throws: DBusError.self) { + try await connection.getNameOwner(DBusBusName(rawValue: "org.example.DoesNotExist")!) + } + + #expect(error?.name == .nameHasNoOwner) + } + } + + @Test func unknownMethodThrows() async throws { + + try await withConnection { connection in + + let error = await #expect(throws: DBusError.self) { + try await connection.send(call("ThisMethodDoesNotExist")) + } + + #expect(error?.name == .unknownMethod) + } + } + + /// Serials must stay distinct so replies match the right call, including under concurrency. + @Test func concurrentCalls() async throws { + + try await withConnection { connection in + + // Built outside the group so the closures capture a value, not `self`. + let message = call("GetId") + + let ids = try await withThrowingTaskGroup(of: String.self) { group -> Set in + + for _ in 0 ..< 20 { + group.addTask { + let reply = try await connection.send(message) + guard let id = reply.arguments.first?.stringValue + else { throw DBusProtocolError.invalidValue("No id") } + return id + } + } + + var ids = Set() + for try await id in group { + ids.insert(id) + } + return ids + } + + // Every call asks for the same value, so a mismatched reply shows up as a second id. + #expect(ids.count == 1, "Replies were mismatched: \(ids)") + } + } + + @Test func sendAfterCloseThrows() async throws { + + guard let address = sessionBusAddress else { return } + + let connection = try await DBusConnection.connect(to: address) + await connection.close() + + #expect(await connection.isConnected == false) + + let error = await #expect(throws: DBusError.self) { + try await connection.send(call("ListNames")) + } + + #expect(error?.name == .disconnected) + } + + /// Closing twice must be safe; the second call has nothing left to tear down. + @Test func closeIsIdempotent() async throws { + + guard let address = sessionBusAddress else { return } + + let connection = try await DBusConnection.connect(to: address) + await connection.close() + await connection.close() + + #expect(await connection.isConnected == false) + } + + /// The system bus is a different daemon with different permissions, so it exercises the + /// same path against a second reference implementation. + @Test func systemBus() async throws { + + let connection: DBusConnection + + do { connection = try await DBusConnection.connect(to: .system) } + catch { + // A container may have no system bus; that is not a failure of this code. + return + } + + #expect(await connection.uniqueName?.isUnique == true) + + let names = try await connection.listNames() + #expect(names.contains("org.freedesktop.DBus")) + + await connection.close() + } +} diff --git a/Tests/DBusTests/CookieAuthTests.swift b/Tests/DBusTests/CookieAuthTests.swift new file mode 100644 index 0000000..275c402 --- /dev/null +++ b/Tests/DBusTests/CookieAuthTests.swift @@ -0,0 +1,292 @@ +// +// CookieAuthTests.swift +// DBusTests +// + +import Testing +@testable import DBus + +@Suite struct SHA1Tests { + + /// Vectors from RFC 3174 and the usual reference set. + @Test(arguments: [ + ("", "da39a3ee5e6b4b0d3255bfef95601890afd80709"), + ("abc", "a9993e364706816aba3e25717850c26c9cd0d89d"), + ("a", "86f7e437faa5a7fce15d1ddcb9eaeaea377667b8"), + ("abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq", + "84983e441c3bd26ebaae4aa1f95129e5e54670f1"), + ("The quick brown fox jumps over the lazy dog", + "2fd4e1c67a2d28fced849ee1bb76e7391b93eb12"), + ("The quick brown fox jumps over the lazy cog", + "de9f2c7fd25e1b3afad3e85a0bd17d9b100db4b3") + ]) + func knownVectors(input: String, expected: String) { + + #expect(SHA1.hexDigest(input) == expected) + } + + /// One million 'a' characters, the RFC's long test case, which exercises multi-block + /// processing and the 64 bit length field. + @Test func millionCharacters() { + + let digest = SHA1.hash(repeatElement(UInt8(ascii: "a"), count: 1_000_000)) + + #expect(digest.hexEncoded == "34aa973cd4c4daa4f61eeb2bdbad27316534016f") + } + + /// A message whose length lands exactly on a block boundary forces an extra padding block. + @Test(arguments: [55, 56, 57, 63, 64, 65, 119, 120, 128]) + func paddingBoundaries(length: Int) { + + let input = String(repeating: "a", count: length) + + // Incremental and one-shot hashing must agree. + var incremental = SHA1() + for byte in input.utf8 { + incremental.update([byte]) + } + + #expect(incremental.finalize().hexEncoded == SHA1.hexDigest(input)) + } + + @Test func hexRoundTrip() { + + #expect([0x00, 0x0F, 0xFF].hexEncoded == "000fff") + #expect("000fff".hexDecoded == [0x00, 0x0F, 0xFF]) + #expect("DEADBEEF".hexDecoded == [0xDE, 0xAD, 0xBE, 0xEF]) // uppercase accepted + #expect("1234".hexDecodedString == "\u{12}\u{34}") + + #expect("abc".hexDecoded == nil) // odd length + #expect("zz".hexDecoded == nil) // not hex + } +} + +// MARK: - Keyring + +@Suite struct KeyringTests { + + @Test func parsesCookieLines() { + + let keyring = DBusKeyring(cookies: DBusKeyring.parse(""" + 1234 1700000000 deadbeef + 5678 1700000001 cafebabe + """)) + + #expect(keyring.cookies.count == 2) + #expect(keyring.cookie(for: "1234")?.value == "deadbeef") + #expect(keyring.cookie(for: "5678")?.creationTime == 1_700_000_001) + #expect(keyring.cookie(for: "9999") == nil) + } + + /// A malformed line must be skipped rather than discarding the whole keyring. + @Test func skipsMalformedLines() { + + let keyring = DBusKeyring(cookies: DBusKeyring.parse(""" + garbage + 1234 notanumber deadbeef + 5678 1700000001 cafebabe + + """)) + + #expect(keyring.cookies.count == 1) + #expect(keyring.cookie(for: "5678")?.value == "cafebabe") + } + + /// The context names a file under the keyring directory and arrives from the peer, so it + /// must not be able to escape that directory. + @Test(arguments: [ + "../../etc/passwd", + "..", + "a/b", + "", + "with space", + "dot.dot" + ]) + func rejectsUnsafeContext(context: String) { + + #expect(!DBusKeyring.isValidContext(context), "\(context) should be rejected") + + #expect(throws: (any Error).self) { + try DBusKeyring.load(context: context, homeDirectory: "/tmp") + } + } + + @Test(arguments: ["org_freedesktop_general", "abc", "A-1_b"]) + func acceptsSafeContext(context: String) { + + #expect(DBusKeyring.isValidContext(context)) + } + + @Test func missingHomeDirectoryFails() { + + #expect(throws: (any Error).self) { + try DBusKeyring.load(context: "general", homeDirectory: nil) + } + } +} + +// MARK: - Cookie handshake + +@Suite struct CookieAuthTests { + + /// A keyring loader that returns a fixed cookie, so the exchange can be driven without + /// touching the real home directory. + private static let cookie = DBusKeyring.Cookie(identifier: "3138363234", + creationTime: 1_700_000_000, + value: "5150714f6c6c6f6e") + + private func client(mechanisms: [DBusAuthenticationMechanism] = [.cookieSHA1]) -> DBusSASLClient { + + return DBusSASLClient( + mechanisms: mechanisms, + userID: 1000, + userName: "coleman", + keyringLoader: { context in + guard context == "org_freedesktop_general" + else { throw DBusProtocolError.authenticationFailed("No such context") } + return DBusKeyring(cookies: [CookieAuthTests.cookie]) + } + ) + } + + private func string(_ bytes: [UInt8]) -> String { + + return String(decoding: bytes, as: UTF8.self) + } + + /// The digest is computed over the *textual* hex forms joined with colons, not the decoded + /// bytes. This vector is checked against an independent SHA-1 of the same string. + @Test func digestShape() { + + let digest = DBusCookieChallenge.digest(serverChallenge: "aaaa", + clientChallenge: "bbbb", + cookie: "cccc") + + #expect(digest == SHA1.hexDigest("aaaa:bbbb:cccc")) + #expect(digest.count == 40) + } + + @Test func challengeParsing() throws { + + let parsed = try DBusCookieChallenge.parse("org_freedesktop_general 3138363234 7ab8f1") + + #expect(parsed.context == "org_freedesktop_general") + #expect(parsed.identifier == "3138363234") + #expect(parsed.challenge == "7ab8f1") + + #expect(throws: (any Error).self) { try DBusCookieChallenge.parse("too few") } + #expect(throws: (any Error).self) { try DBusCookieChallenge.parse("a b c d") } + } + + @Test func clientChallengeIsRandomHex() { + + let first = DBusCookieChallenge.clientChallenge() + let second = DBusCookieChallenge.clientChallenge() + + #expect(first.count == 32) + #expect(first.hexDecoded != nil) + #expect(first != second, "The challenge must not repeat") + } + + @Test func authCommandSendsHexUserName() { + + var client = self.client() + let start = client.start() + + #expect(start.first == 0x00) + #expect(string(Array(start.dropFirst())) == "AUTH DBUS_COOKIE_SHA1 636f6c656d616e\r\n") + #expect("coleman".hexEncodedASCII == "636f6c656d616e") + } + + /// The full exchange: challenge in, response out, then OK. + @Test func completesCookieExchange() throws { + + var client = self.client() + _ = client.start() + + let serverChallenge = "7ab8f1c93de0" + let challengeText = "org_freedesktop_general 3138363234 \(serverChallenge)" + + let response = try #require(try client.handle(.data(challengeText.hexEncodedASCII))) + + let line = string(response) + #expect(line.hasPrefix("DATA ")) + #expect(line.hasSuffix("\r\n")) + + // Sliced from the bytes rather than the String: CR LF is a single Swift `Character`, + // so `dropLast(2)` on the string would also drop a hex digit. + #expect(response.suffix(2) == [0x0D, 0x0A]) + let hex = string(Array(response.dropFirst("DATA ".utf8.count).dropLast(2))) + let decoded = try #require(hex.hexDecodedString) + + let fields = decoded.split(separator: " ") + #expect(fields.count == 2) + + let clientChallenge = String(fields[0]) + let digest = String(fields[1]) + + #expect(clientChallenge == client.clientChallenge) + #expect(digest == DBusCookieChallenge.digest(serverChallenge: serverChallenge, + clientChallenge: clientChallenge, + cookie: CookieAuthTests.cookie.value)) + + // The server accepts, and the handshake proceeds as usual. + let afterOK = try client.handle(.ok("guid")) + #expect(string(afterOK ?? []) == "NEGOTIATE_UNIX_FD\r\n") + } + + /// An unknown cookie must cancel the exchange rather than throw, so the server can offer + /// another mechanism. + @Test func unknownCookieCancels() throws { + + var client = self.client() + _ = client.start() + + let challenge = "org_freedesktop_general 9999 7ab8f1" + let response = try client.handle(.data(challenge.hexEncodedASCII)) + + #expect(string(response ?? []) == "CANCEL\r\n") + #expect(client.state == .authenticating(.cookieSHA1)) + } + + @Test func unreadableKeyringCancels() throws { + + var client = self.client() + _ = client.start() + + let challenge = "some_other_context 3138363234 7ab8f1" + let response = try client.handle(.data(challenge.hexEncodedASCII)) + + #expect(string(response ?? []) == "CANCEL\r\n") + } + + @Test func malformedChallengeCancels() throws { + + var client = self.client() + _ = client.start() + + #expect(string(try client.handle(.data("not hex!")) ?? []) == "CANCEL\r\n") + #expect(string(try client.handle(.data("6162".self)) ?? []) == "CANCEL\r\n") // "ab", too few fields + } + + /// EXTERNAL is still preferred; the cookie mechanism is a fallback. + @Test func fallsBackFromExternalToCookie() throws { + + var client = self.client(mechanisms: [.external, .cookieSHA1, .anonymous]) + _ = client.start() + + let next = try #require(try client.handle(.rejected(["DBUS_COOKIE_SHA1", "ANONYMOUS"]))) + #expect(string(next).hasPrefix("AUTH DBUS_COOKIE_SHA1 ")) + #expect(client.state == .authenticating(.cookieSHA1)) + } + + /// A DATA challenge for a mechanism that has no challenge/response step is a protocol error. + @Test func dataForNonCookieMechanismCancels() throws { + + var client = self.client(mechanisms: [.external]) + _ = client.start() + + #expect(string(try client.handle(.data("00")) ?? []) == "CANCEL\r\n") + #expect(client.state == .failed) + } +} diff --git a/Tests/DBusTests/ErrorTests.swift b/Tests/DBusTests/ErrorTests.swift deleted file mode 100644 index c38cddf..0000000 --- a/Tests/DBusTests/ErrorTests.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// ErrorTests.swift -// DBus -// -// Created by Tabor Kelly on 1/28/19. -// Copyright © 2019 PureSwift. All rights reserved. -// - -import Foundation -import XCTest -@testable import DBus - -final class ErrorTests: XCTestCase { - - static let allTests = [ - ("testNewGoodError", testNewGoodError), - ("testBadErrorThrows", testBadErrorThrows), - ] - - func testNewGoodError() { - do { - let name = "org.freedesktop.DBus.Error.InvalidArgs" - let message = "Foo!" - let e = try DBusError(name: name, message: message) - XCTAssertEqual(name, e.name) - XCTAssertEqual(message, e.message) - // let r = e.Reference() - } catch { - XCTFail("\(error)") - } - } - - func testBadErrorThrows() { - XCTAssertThrowsError(try DBusError(name: ".foo", message: "nobody loves buggy code")) - } -} diff --git a/Tests/DBusTests/FileDescriptorTests.swift b/Tests/DBusTests/FileDescriptorTests.swift new file mode 100644 index 0000000..d1f6fa5 --- /dev/null +++ b/Tests/DBusTests/FileDescriptorTests.swift @@ -0,0 +1,354 @@ +// +// FileDescriptorTests.swift +// DBusTests +// + +import Foundation +#if canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#elseif canImport(Darwin) +import Darwin +#elseif canImport(Bionic) +import Bionic +#endif +import Testing +@testable import DBus + +/// Tests for `UNIX_FD` marshalling and end-to-end descriptor passing. +/// +/// On the wire a `UNIX_FD` is an *index* into the descriptors sent out of band, never the +/// descriptor number itself, so these check the indirection in both directions. +@Suite struct FileDescriptorMarshalTests { + + @Test func marshalsAsIndexNotDescriptorNumber() throws { + + // Deliberately large, unlikely descriptor numbers. + let arguments: [DBusMessageArgument] = [ + .fileDescriptor(.init(rawValue: 41)), + .fileDescriptor(.init(rawValue: 57)) + ] + + let (bytes, descriptors) = try DBusMarshaller.marshalWithDescriptors(arguments, endianness: .little) + + // Two UInt32 indices, 0 and 1 — not 41 and 57. + #expect(bytes == [0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00]) + #expect(descriptors == [41, 57]) + } + + @Test func unmarshalsIndexBackToDescriptor() throws { + + let bytes: [UInt8] = [0x01, 0x00, 0x00, 0x00] // index 1 + + var unmarshaller = DBusUnmarshaller(bytes: bytes, + endianness: .little, + fileDescriptors: [41, 57]) + + let value = try unmarshaller.read(.fileDescriptor) + + #expect(value == .fileDescriptor(.init(rawValue: 57))) + } + + @Test func rejectsOutOfRangeIndex() { + + let bytes: [UInt8] = [0x05, 0x00, 0x00, 0x00] // index 5, but only one descriptor + + var unmarshaller = DBusUnmarshaller(bytes: bytes, + endianness: .little, + fileDescriptors: [41]) + + #expect(throws: (any Error).self) { try unmarshaller.read(.fileDescriptor) } + } + + /// A message with no descriptors must not gain a `UNIX_FDS` header field. + @Test func omitsHeaderFieldWhenNoDescriptors() throws { + + let message = DBusMessage(type: .methodCall, serial: 1, arguments: [.string("x")]) + + let (bytes, descriptors) = try message.encodeWithDescriptors() + + #expect(descriptors.isEmpty) + + let (decoded, _) = try DBusMessage.decode(bytes) + #expect(decoded.unixFileDescriptorCount == nil) + } + + /// The header field is derived from what marshalling produced, so it cannot disagree. + @Test func headerFieldCountsDescriptors() throws { + + var message = DBusMessage(type: .methodCall, serial: 1) + message.member = DBusMember(rawValue: "Send")! + message.arguments = [ + .fileDescriptor(.init(rawValue: 3)), + .string("meta"), + .fileDescriptor(.init(rawValue: 4)) + ] + + // Deliberately wrong; encoding must ignore it. + message.unixFileDescriptorCount = 99 + + let (bytes, descriptors) = try message.encodeWithDescriptors() + + #expect(descriptors == [3, 4]) + #expect(message.signature.rawValue == "hsh") + + let (decoded, _) = try DBusMessage.decode(bytes, fileDescriptors: descriptors) + + #expect(decoded.unixFileDescriptorCount == 2) + #expect(decoded.arguments == message.arguments) + } + + @Test func roundTripsInBothByteOrders() throws { + + for endianness in DBusEndianness.allCases { + + var message = DBusMessage(type: .methodCall, serial: 7) + message.member = DBusMember(rawValue: "Send")! + message.arguments = [ + .fileDescriptor(.init(rawValue: 11)), + .array(DBusMessageArgument.Array(type: .fileDescriptor, [ + .fileDescriptor(.init(rawValue: 12)), + .fileDescriptor(.init(rawValue: 13)) + ])!) + ] + + let (bytes, descriptors) = try message.encodeWithDescriptors(endianness: endianness) + + #expect(descriptors == [11, 12, 13], "\(endianness)") + + let (decoded, _) = try DBusMessage.decode(bytes, fileDescriptors: descriptors) + #expect(decoded.arguments == message.arguments, "\(endianness)") + } + } + + /// A descriptor inside a variant still resolves through the same index table. + @Test func handlesDescriptorInsideVariant() throws { + + var message = DBusMessage(type: .methodCall, serial: 1) + message.member = DBusMember(rawValue: "Send")! + message.arguments = [ + .variant(DBusMessageArgument.Variant(.fileDescriptor(.init(rawValue: 21)))) + ] + + let (bytes, descriptors) = try message.encodeWithDescriptors() + #expect(descriptors == [21]) + + let (decoded, _) = try DBusMessage.decode(bytes, fileDescriptors: descriptors) + #expect(decoded.arguments == message.arguments) + } +} + +// MARK: - Live + +/// Passing a real descriptor between two connections on the session bus. +@Suite(.serialized, .enabled(if: hasSessionBus, "No session bus is available")) +struct FileDescriptorPassingTests { + + private let testInterface = DBusInterface(rawValue: "com.example.FileDescriptors")! + private let testPath = DBusObjectPath(rawValue: "/com/example/FileDescriptors")! + + /// A temporary file holding `contents`, open for reading. + private func makeTemporaryFile(contents: String) throws -> (Int32, String) { + + let path = "/tmp/dbus-fd-test-\(UInt32.random(in: 0 ... .max))" + try contents.write(toFile: path, atomically: true, encoding: .utf8) + + let descriptor = open(path, O_RDONLY) + #expect(descriptor >= 0) + + return (descriptor, path) + } + + private func readAll(_ descriptor: Int32) -> String { + + var buffer = [UInt8](repeating: 0, count: 512) + let count = read(descriptor, &buffer, buffer.count) + + guard count > 0 else { return "" } + + return String(decoding: buffer[0 ..< count], as: UTF8.self) + } + + /// The bus must have agreed to descriptor passing during the handshake. + @Test func negotiatesDescriptorPassing() async throws { + + try await withConnection { connection in + #expect(await connection.unixFileDescriptorsSupported, + "The session bus should agree to NEGOTIATE_UNIX_FD") + } + } + + /// The real test: a descriptor sent through the bus refers to the same open file. + @Test func passesDescriptorThroughTheBus() async throws { + + let (file, path) = try makeTemporaryFile(contents: "contents behind a passed descriptor") + defer { close(file); unlink(path) } + + try await withConnections { server, client in + + // The server echoes back whatever it can read through the descriptor it receives. + let received = ReceivedText() + + let implementation = DBusInterfaceImplementation( + name: testInterface, + methods: [ + .init(name: DBusMember(rawValue: "Accept")!, + inputSignature: DBusSignature(rawValue: "h")!, + outputSignature: DBusSignature(rawValue: "s")!, + handler: { call in + guard case let .fileDescriptor(descriptor)? = call.arguments.first + else { throw DBusError(name: .invalidArguments, message: "Expected a descriptor") } + + var buffer = [UInt8](repeating: 0, count: 512) + let count = read(descriptor.rawValue, &buffer, buffer.count) + close(descriptor.rawValue) + + let text = count > 0 + ? String(decoding: buffer[0 ..< count], as: UTF8.self) + : "" + + await received.set(text) + return [.string(text)] + }) + ] + ) + + await server.export(DBusExportedObject([implementation]), at: testPath) + + let name = try #require(await server.uniqueName) + + let reply = try await client.callMethod( + destination: name, + path: testPath, + interface: testInterface, + method: DBusMember(rawValue: "Accept")!, + arguments: [.fileDescriptor(.init(rawValue: file))] + ) + + // The server read the file through a descriptor it received over the bus. + #expect(reply.first?.stringValue == "contents behind a passed descriptor") + #expect(await received.value == "contents behind a passed descriptor") + } + } + + /// Several descriptors in one message must arrive in order. + @Test func passesSeveralDescriptors() async throws { + + var files = [Int32]() + var paths = [String]() + + for index in 0 ..< 3 { + let (file, path) = try makeTemporaryFile(contents: "file \(index)") + files.append(file) + paths.append(path) + } + + defer { + files.forEach { close($0) } + paths.forEach { unlink($0) } + } + + try await withConnections { server, client in + + let implementation = DBusInterfaceImplementation( + name: testInterface, + methods: [ + .init(name: DBusMember(rawValue: "AcceptMany")!, + inputSignature: DBusSignature(rawValue: "ah")!, + outputSignature: DBusSignature(rawValue: "as")!, + handler: { call in + guard case let .array(array)? = call.arguments.first + else { throw DBusError(name: .invalidArguments, message: "Expected an array") } + + var contents = [DBusMessageArgument]() + + for element in array { + guard case let .fileDescriptor(descriptor) = element + else { continue } + + var buffer = [UInt8](repeating: 0, count: 128) + let count = read(descriptor.rawValue, &buffer, buffer.count) + close(descriptor.rawValue) + + contents.append(.string(count > 0 + ? String(decoding: buffer[0 ..< count], as: UTF8.self) + : "")) + } + + guard let result = DBusMessageArgument.Array(type: .string, contents) + else { throw DBusError(name: .failed, message: "Could not build the reply") } + + return [.array(result)] + }) + ] + ) + + await server.export(DBusExportedObject([implementation]), at: testPath) + + let name = try #require(await server.uniqueName) + + let descriptors = DBusMessageArgument.Array( + type: .fileDescriptor, + files.map { .fileDescriptor(.init(rawValue: $0)) } + )! + + let reply = try await client.callMethod( + destination: name, + path: testPath, + interface: testInterface, + method: DBusMember(rawValue: "AcceptMany")!, + arguments: [.array(descriptors)] + ) + + guard case let .array(result)? = reply.first + else { Issue.record("Expected an array, got \(reply)"); return } + + #expect(result.compactMap { $0.stringValue } == ["file 0", "file 1", "file 2"]) + } + } + + /// The original descriptor must stay usable; the peer receives its own copy. + @Test func senderKeepsItsOwnDescriptor() async throws { + + let (file, path) = try makeTemporaryFile(contents: "still readable") + defer { close(file); unlink(path) } + + try await withConnections { server, client in + + let implementation = DBusInterfaceImplementation( + name: testInterface, + methods: [ + .init(name: DBusMember(rawValue: "Accept")!, + inputSignature: DBusSignature(rawValue: "h")!, + handler: { call in + if case let .fileDescriptor(descriptor)? = call.arguments.first { + close(descriptor.rawValue) + } + return [] + }) + ] + ) + + await server.export(DBusExportedObject([implementation]), at: testPath) + let name = try #require(await server.uniqueName) + + try await client.callMethod(destination: name, path: testPath, + interface: testInterface, + method: DBusMember(rawValue: "Accept")!, + arguments: [.fileDescriptor(.init(rawValue: file))]) + + // Rewind and read through the original descriptor. + lseek(file, 0, SEEK_SET) + #expect(readAll(file) == "still readable") + } + } +} + +/// Somewhere for a `Sendable` handler to record what it read. +private actor ReceivedText { + + private(set) var value = "" + + func set(_ text: String) { value = text } +} diff --git a/Tests/DBusTests/InterfaceTests.swift b/Tests/DBusTests/InterfaceTests.swift index ba84295..0cf3cb2 100644 --- a/Tests/DBusTests/InterfaceTests.swift +++ b/Tests/DBusTests/InterfaceTests.swift @@ -5,93 +5,107 @@ // Created by Alsey Coleman Miller on 10/24/18. // -import Foundation -import XCTest +import Testing @testable import DBus -final class InterfaceTests: XCTestCase { - - static let allTests: [(String, (InterfaceTests) -> () -> Void)] = [ - ("testInvalid", testInvalid), - ("testValid", testValid) - ] - - func testInvalid() { - - let strings = [ - "org.7-zip.Plugin", - "org.7zip.Plugin", - "com.example..MusicPlayer1.Track", - "com.example.MusicPlayer1.Track.", - "com.example.", - "com.example.MusicPlayer1.Track@", - "com.example.MusicPlayer1.Trackñ", - "", - "/", - ".", - "..", - "com", - "com.", - "a.", - "a.ñ", - "a.😀" - ] - - for string in strings { - - XCTAssertNil(DBusInterface(rawValue: string), "\(string) should be invalid") - XCTAssertThrowsError(try DBusInterface.validate(string)) - do { try DBusInterface.validate(string) } - catch let error as DBusError { - XCTAssertEqual(error.name, DBusError.Name.invalidArguments) - print("\"\(string)\" is invalid: \(error.message)") - return - } - catch { - XCTFail("\(error)") - return - } - XCTFail("Error expected for \(string)") +@Suite struct InterfaceTests { + + @Test(arguments: [ + "org.7-zip.Plugin", // '-' is legal in bus names but not interface names + "org.7zip.Plugin", // an element may not begin with a digit + "com.example..MusicPlayer1.Track", + "com.example.MusicPlayer1.Track.", + "com.example.", + "com.example.MusicPlayer1.Track@", + "com.example.MusicPlayer1.Trackñ", + "", + "/", + ".", + "..", + "com", // must contain at least one period + "com.", + "a.", + "a.ñ", + "a.😀", + ".com.example" + ]) + func invalid(string: String) throws { + + #expect(DBusInterface(rawValue: string) == nil, "\(string) should be invalid") + + let error = try #require(throws: DBusError.self) { + try DBusInterface.validate(string) } - - XCTAssertNil(DBusInterface([])) + + #expect(error.name == .invalidArguments) } - - func testValid() { - - let values = [ - ("org._7_zip.Plugin", ["org", "_7_zip", "Plugin"]), - ("a.b", ["a", "b"]), - ("com.example", ["com", "example"]), - ("com.example.MusicPlayer1", ["com", "example", "MusicPlayer1"]), - ("com.example.MusicPlayer1.Track", ["com", "example", "MusicPlayer1", "Track"]) - ] - - for (string, elements) in values { - - XCTAssertNoThrow(try DBusInterface.validate(string)) - - guard let interface = DBusInterface(rawValue: string) - else { XCTFail("Could not parse \(string)"); return } - - XCTAssertEqual(interface.rawValue, string) - XCTAssertEqual(interface.rawValue, String(interface.elements)) - XCTAssertEqual(interface.elements.map { $0.rawValue }, elements) - XCTAssertEqual(Array(interface), interface.elements) - XCTAssert(interface.count > 1) - XCTAssertEqual(interface, DBusInterface(interface.elements)) - XCTAssertEqual(interface.hashValue, string.hashValue) - - // mutate - var mutable = interface - mutable.append(DBusInterface.Element(rawValue: "Object1")!) - XCTAssertNil(mutable.string) - XCTAssertNotEqual(mutable, interface) - XCTAssertNotEqual(mutable.rawValue, interface.rawValue) - XCTAssertNotEqual(mutable.elements, interface.elements) - XCTAssertNotEqual(mutable.hashValue, interface.hashValue) - XCTAssertNotEqual(mutable.hashValue, interface.hashValue) - XCTAssertNotEqual(mutable.last?.hashValue, interface.last?.hashValue) - } + + @Test(arguments: [ + ("org._7_zip.Plugin", ["org", "_7_zip", "Plugin"]), + ("a.b", ["a", "b"]), + ("com.example", ["com", "example"]), + ("com.example.MusicPlayer1", ["com", "example", "MusicPlayer1"]), + ("com.example.MusicPlayer1.Track", ["com", "example", "MusicPlayer1", "Track"]) + ]) + func valid(string: String, elements: [String]) throws { + + #expect(throws: Never.self) { try DBusInterface.validate(string) } + + let interface = try #require(DBusInterface(rawValue: string)) + + #expect(interface.rawValue == string) + #expect(interface.rawValue == String(interface.elements)) + #expect(interface.elements.map { $0.rawValue } == elements) + #expect(Array(interface) == interface.elements) + #expect(interface.count > 1) + #expect(interface == DBusInterface(interface.elements)) + #expect(interface.hashValue == string.hashValue) + + // Mutating clears the cached string. + var mutable = interface + mutable.append(DBusInterface.Element(rawValue: "Object1")!) + #expect(mutable.string == nil) + #expect(mutable != interface) + #expect(mutable.rawValue != interface.rawValue) + #expect(mutable.elements != interface.elements) + } + + @Test func requiresAtLeastTwoElements() { + + #expect(DBusInterface([]) == nil) + #expect(DBusInterface([DBusInterface.Element(rawValue: "com")!]) == nil) + #expect(DBusInterface([ + DBusInterface.Element(rawValue: "com")!, + DBusInterface.Element(rawValue: "example")! + ]) != nil) + } + + /// The limit is 255 bytes and applies to the whole name. + @Test func rejectsOverlongName() { + + let long = String(repeating: "a", count: 128) + "." + String(repeating: "b", count: 126) + #expect(long.utf8.count == 255) + #expect(DBusInterface(rawValue: long) != nil) + + #expect(DBusInterface(rawValue: long + "c") == nil) + } + + @Test func elementRules() { + + // Only "[A-Z][a-z][0-9]_", and never leading with a digit. + #expect(DBusInterface.Element(rawValue: "_7_zip") != nil) + #expect(DBusInterface.Element(rawValue: "Track1") != nil) + #expect(DBusInterface.Element(rawValue: "7zip") == nil) + #expect(DBusInterface.Element(rawValue: "") == nil) + #expect(DBusInterface.Element(rawValue: "a-b") == nil) + #expect(DBusInterface.Element(rawValue: "a.b") == nil) + } + + /// Error names follow interface name syntax. + @Test func errorNamesUseInterfaceSyntax() { + + #expect(DBusError.Name(rawValue: "org.freedesktop.DBus.Error.Failed") != nil) + #expect(DBusError.Name(rawValue: "NotNamespaced") == nil) + #expect(DBusError.Name(rawValue: "") == nil) } } diff --git a/Tests/DBusTests/IntrospectionParsingTests.swift b/Tests/DBusTests/IntrospectionParsingTests.swift new file mode 100644 index 0000000..c4d2eb0 --- /dev/null +++ b/Tests/DBusTests/IntrospectionParsingTests.swift @@ -0,0 +1,365 @@ +// +// IntrospectionParsingTests.swift +// DBusTests +// + +import Testing +@testable import DBus + +@Suite struct XMLReaderTests { + + @Test func parsesElementsAndAttributes() throws { + + let element = try XMLReader.parse(""" + + + + + + """) + + #expect(element.name == "node") + #expect(element.attributes["name"] == "thing") + #expect(element.children.count == 1) + #expect(element.children[0].name == "interface") + #expect(element.children[0].children(named: "method").count == 1) + } + + @Test func skipsDeclarationDoctypeAndComments() throws { + + let element = try XMLReader.parse(""" + + + + + + + + """) + + #expect(element.name == "node") + #expect(element.children.count == 1) + #expect(element.children[0].attributes["name"] == "child") + } + + /// A doctype may carry an internal subset in brackets containing '>'. + @Test func skipsDoctypeInternalSubset() throws { + + let element = try XMLReader.parse(""" + ]> + + """) + + #expect(element.attributes["name"] == "x") + } + + @Test func decodesEntities() throws { + + let element = try XMLReader.parse( + #""# + ) + + #expect(element.attributes["name"] == "ac&d\"e'fAB") + } + + @Test func acceptsSingleQuotedAttributes() throws { + + let element = try XMLReader.parse("") + + #expect(element.attributes["name"] == "thing") + } + + @Test(arguments: [ + "", // no root + "", // unterminated + "", // mismatched close + "", // unquoted attribute + "", // attribute without a value + "", // two roots + #""#, // unknown entity + #"" // duplicate attribute + ]) + func rejectsMalformedDocument(xml: String) { + + #expect(throws: (any Error).self, "\(xml) should be rejected") { + try XMLReader.parse(xml) + } + } +} + +// MARK: - Introspection model + +@Suite struct IntrospectionParsingTests { + + private let testInterface = DBusInterface(rawValue: "com.example.TestObject")! + + @Test func parsesInterfacesMethodsSignalsAndProperties() throws { + + let node = try DBusIntrospection.parse(""" + + + + + + + + + + + + + + + + + """) + + #expect(node.name == nil, "The root node of a reply carries no name") + #expect(node.interfaces.count == 1) + + let interface = try #require(node.interface(named: testInterface)) + #expect(node.implements(testInterface)) + + let echo = try #require(interface.method(named: DBusMember(rawValue: "Echo")!)) + #expect(echo.inputSignature.rawValue == "s") + #expect(echo.outputSignature.rawValue == "s") + #expect(echo.arguments.first?.name == "input") + + let reset = try #require(interface.method(named: DBusMember(rawValue: "Reset")!)) + #expect(reset.arguments.isEmpty) + + // Signal arguments have no direction attribute and are outbound. + let bounced = try #require(interface.signal(named: DBusMember(rawValue: "Bounced")!)) + #expect(bounced.signature.rawValue == "s") + #expect(bounced.arguments.first?.direction == .out) + + #expect(interface.property(named: "Greeting")?.access == .readwrite) + #expect(interface.property(named: "Counter")?.type == .uint32) + + #expect(node.children.count == 1) + #expect(node.children[0].name == "child") + } + + /// A method argument with no direction defaults to `in`, per the specification. + @Test func defaultsMethodArgumentDirectionToIn() throws { + + let node = try DBusIntrospection.parse(""" + + + + + + """) + + let method = try #require(node.interfaces.first?.methods.first) + #expect(method.arguments.first?.direction == .in) + #expect(method.inputSignature.rawValue == "s") + #expect(method.outputSignature.rawValue == "") + } + + @Test func ignoresAnnotations() throws { + + let node = try DBusIntrospection.parse(""" + + + + + + + + """) + + #expect(node.interfaces.count == 1) + #expect(node.interfaces[0].methods.count == 1) + } + + @Test func parsesComplexTypes() throws { + + let node = try DBusIntrospection.parse(""" + + + + + + + + + + """) + + let method = try #require(node.interfaces.first?.methods.first) + #expect(method.inputSignature.rawValue == "a{sv}") + #expect(method.outputSignature.rawValue == "a(is)") + + #expect(node.interfaces.first?.properties.first?.type == .array(.dictionary( + DBusSignature.DictionaryType(key: .string, value: .variant)! + ))) + } + + @Test(arguments: [ + "", // no name + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ]) + func rejectsInvalidDocument(xml: String) { + + #expect(throws: (any Error).self, "\(xml) should be rejected") { + try DBusIntrospection.parse(xml) + } + } + + /// What the generator writes must be what the parser reads back. + @Test func roundTripsGeneratedDocument() throws { + + let implementation = DBusInterfaceImplementation( + name: testInterface, + methods: [ + .init(name: DBusMember(rawValue: "Echo")!, + inputSignature: DBusSignature(rawValue: "s")!, + outputSignature: DBusSignature(rawValue: "a{sv}")!, + inputNames: ["input"], + outputNames: ["output"], + handler: { _ in [] }), + .init(name: DBusMember(rawValue: "Reset")!, handler: { _ in [] }) + ], + properties: [ + .init(name: "Greeting", type: .string, access: .readwrite, + get: { .string("") }, set: { _ in }), + .init(name: "Counter", type: .uint32, access: .read, get: { .uint32(0) }) + ], + signals: [ + .init(name: DBusMember(rawValue: "Bounced")!, + signature: DBusSignature(rawValue: "su")!, + argumentNames: ["text", "count"]) + ] + ) + + let xml = DBusIntrospection.xml(for: DBusExportedObject([implementation]), + children: ["child", "other"]) + + let node = try DBusIntrospection.parse(xml) + + // The standard interfaces are advertised too. + #expect(node.implements(DBusWellKnown.peerInterface)) + #expect(node.implements(DBusWellKnown.introspectableInterface)) + #expect(node.implements(DBusWellKnown.propertiesInterface)) + + let parsed = try #require(node.interface(named: testInterface)) + + let echo = try #require(parsed.method(named: DBusMember(rawValue: "Echo")!)) + #expect(echo.inputSignature.rawValue == "s") + #expect(echo.outputSignature.rawValue == "a{sv}") + #expect(echo.arguments.map { $0.name } == ["input", "output"]) + + #expect(parsed.method(named: DBusMember(rawValue: "Reset")!)?.arguments.isEmpty == true) + + let bounced = try #require(parsed.signal(named: DBusMember(rawValue: "Bounced")!)) + #expect(bounced.signature.rawValue == "su") + #expect(bounced.arguments.allSatisfy { $0.direction == .out }) + + #expect(parsed.property(named: "Greeting")?.access == .readwrite) + #expect(parsed.property(named: "Counter")?.type == .uint32) + + #expect(node.children.map { $0.name } == ["child", "other"]) + } + + /// Names needing escaping must survive generation and parsing intact. + @Test func roundTripsEscapedNames() throws { + + let implementation = DBusInterfaceImplementation( + name: testInterface, + properties: [ + .init(name: "Quote\"And&Amp", type: .string, access: .read, + get: { .string("") }) + ] + ) + + let xml = DBusIntrospection.xml(for: DBusExportedObject([implementation])) + let node = try DBusIntrospection.parse(xml) + + #expect(node.interface(named: testInterface)?.properties.first?.name + == "Quote\"And&Amp") + } +} + +// MARK: - Live + +@Suite(.serialized, .enabled(if: hasSessionBus, "No session bus is available")) +struct IntrospectionLiveTests { + + /// The bus daemon's own document, produced by the reference implementation. + @Test func parsesBusDaemonIntrospection() async throws { + + try await withConnection { connection in + + let node = try await connection.introspectNode(destination: DBusWellKnown.busName, + path: DBusWellKnown.busPath) + + #expect(node.implements(DBusWellKnown.busInterface)) + #expect(node.implements(DBusWellKnown.introspectableInterface)) + + let bus = try #require(node.interface(named: DBusWellKnown.busInterface)) + + let hello = try #require(bus.method(named: DBusMember(rawValue: "Hello")!)) + #expect(hello.inputSignature.rawValue == "") + #expect(hello.outputSignature.rawValue == "s") + + let listNames = try #require(bus.method(named: DBusMember(rawValue: "ListNames")!)) + #expect(listNames.outputSignature.rawValue == "as") + + #expect(bus.signal(named: DBusMember(rawValue: "NameOwnerChanged")!) != nil) + } + } + + /// Our own generated document, read back over the bus by the parser. + @Test func parsesOwnIntrospectionOverTheBus() async throws { + + let path = DBusObjectPath(rawValue: "/com/example/Introspected")! + let interface = DBusInterface(rawValue: "com.example.Introspected")! + + try await withConnections { server, client in + + let implementation = DBusInterfaceImplementation( + name: interface, + methods: [ + .init(name: DBusMember(rawValue: "Combine")!, + inputSignature: DBusSignature(rawValue: "si")!, + outputSignature: DBusSignature(rawValue: "s")!, + inputNames: ["text", "count"], + outputNames: ["result"], + handler: { _ in [] }) + ], + properties: [ + .init(name: "Enabled", type: .boolean, access: .readwrite, + get: { .boolean(true) }, set: { _ in }) + ] + ) + + await server.export(DBusExportedObject([implementation]), at: path) + + let name = try #require(await server.uniqueName) + + let node = try await client.introspectNode(destination: name, path: path) + + let parsed = try #require(node.interface(named: interface)) + let combine = try #require(parsed.method(named: DBusMember(rawValue: "Combine")!)) + + #expect(combine.inputSignature.rawValue == "si") + #expect(combine.outputSignature.rawValue == "s") + #expect(combine.arguments.map { $0.name } == ["text", "count", "result"]) + #expect(parsed.property(named: "Enabled")?.access == .readwrite) + } + } +} diff --git a/Tests/DBusTests/MarshalTests.swift b/Tests/DBusTests/MarshalTests.swift new file mode 100644 index 0000000..2712c1c --- /dev/null +++ b/Tests/DBusTests/MarshalTests.swift @@ -0,0 +1,393 @@ +// +// MarshalTests.swift +// DBusTests +// + +import Testing +@testable import DBus + +/// Tests for the wire format: alignment, padding, length prefixes and byte order. +/// +/// Round-trip tests alone cannot catch a marshaller that is self-consistently wrong, so the +/// byte-level tests here assert exact output computed by hand from the specification. +/// +/// Reference: https://dbus.freedesktop.org/doc/dbus-specification.html#message-protocol-marshaling +@Suite struct MarshalTests { + + // MARK: - Helpers + + private func encode(_ arguments: [DBusMessageArgument], + endianness: DBusEndianness = .little) throws -> [UInt8] { + + return try DBusMarshaller.marshal(arguments, endianness: endianness) + } + + private func decode(_ bytes: [UInt8], + _ signature: DBusSignature, + endianness: DBusEndianness = .little, + fileDescriptors: [Int32] = []) throws -> [DBusMessageArgument] { + + var unmarshaller = DBusUnmarshaller(bytes: bytes, + endianness: endianness, + fileDescriptors: fileDescriptors) + let arguments = try unmarshaller.read(signature: signature) + #expect(unmarshaller.isAtEnd, "\(unmarshaller.remaining) trailing bytes") + return arguments + } + + /// Encode and decode in both byte orders and check the value survives. + /// + /// - Note: Descriptors are threaded back in, because a `UNIX_FD` is marshalled as an index + /// into the out-of-band descriptor array rather than as the descriptor number. + private func assertRoundTrip(_ arguments: [DBusMessageArgument], + sourceLocation: SourceLocation = #_sourceLocation) throws { + + for endianness in DBusEndianness.allCases { + + let (bytes, descriptors) = try DBusMarshaller.marshalWithDescriptors(arguments, + endianness: endianness) + let decoded = try decode(bytes, arguments.signature, + endianness: endianness, + fileDescriptors: descriptors) + + #expect(decoded == arguments, "\(endianness)", sourceLocation: sourceLocation) + } + } + + // MARK: - Byte level + + @Test func stringBytes() throws { + + // A STRING is a UINT32 length, the UTF-8 bytes, then a NUL. The length excludes the NUL. + #expect(try encode([.string("foo")]) == [0x03, 0x00, 0x00, 0x00, 0x66, 0x6F, 0x6F, 0x00]) + #expect(try encode([.string("")]) == [0x00, 0x00, 0x00, 0x00, 0x00]) + + // Big endian differs only in the length prefix. + #expect(try encode([.string("foo")], endianness: .big) + == [0x00, 0x00, 0x00, 0x03, 0x66, 0x6F, 0x6F, 0x00]) + } + + @Test func signatureBytes() throws { + + // A SIGNATURE has a single byte length, so it needs no alignment padding. + #expect(try encode([.signature(DBusSignature(rawValue: "ai")!)]) == [0x02, 0x61, 0x69, 0x00]) + } + + @Test func alignmentPadding() throws { + + // An INT64 aligns to 8, so a leading BYTE forces seven padding bytes. + #expect(try encode([.byte(1), .int64(2)]) == [ + 0x01, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 + ]) + + // An INT16 aligns to 2. + #expect(try encode([.byte(1), .int16(2)]) == [0x01, 0x00, 0x02, 0x00]) + } + + @Test func booleanBytes() throws { + + // BOOLEAN is marshalled as a UINT32 and aligns to 4. + #expect(try encode([.boolean(true)]) == [0x01, 0x00, 0x00, 0x00]) + #expect(try encode([.boolean(false)]) == [0x00, 0x00, 0x00, 0x00]) + } + + @Test func emptyArrayBytes() throws { + + // An empty array still writes the padding needed to reach its element alignment, and + // that padding is not counted in the length. + #expect(try encode([.array(DBusMessageArgument.Array(type: .int64))]) == [ + 0x00, 0x00, 0x00, 0x00, // length 0 + 0x00, 0x00, 0x00, 0x00 // padding to the 8 byte element alignment + ]) + + // A byte array needs no such padding. + #expect(try encode([.array(DBusMessageArgument.Array(type: .byte))]) == [0x00, 0x00, 0x00, 0x00]) + } + + @Test func arrayLengthExcludesPadding() throws { + + // The length counts element data only: 8 bytes for the single INT64, not 12. + let array = DBusMessageArgument.Array(type: .int64, [.int64(1)])! + + #expect(try encode([.array(array)]) == [ + 0x08, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 + ]) + } + + @Test func structBytes() throws { + + let structure = DBusMessageArgument.Structure([.byte(1), .int32(2)])! + + // A struct aligns to 8 even when its first field is a byte. + #expect(try encode([.struct(structure)]) == [ + 0x01, + 0x00, 0x00, 0x00, // padding to the INT32's 4 byte alignment + 0x02, 0x00, 0x00, 0x00 + ]) + + // Preceded by a byte, the struct itself is padded to 8. + #expect(try encode([.byte(0xFF), .struct(structure)]) == [ + 0xFF, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x01, + 0x00, 0x00, 0x00, + 0x02, 0x00, 0x00, 0x00 + ]) + } + + @Test func variantBytes() throws { + + // A variant is its contained signature followed by the value, aligned to the contained + // type. + #expect(try encode([.variant(DBusMessageArgument.Variant(.int32(5)))]) == [ + 0x01, 0x69, 0x00, // signature "i" + 0x00, // padding to 4 + 0x05, 0x00, 0x00, 0x00 + ]) + } + + @Test func dictionaryBytes() throws { + + // a{sv} with a single entry {"a": <5>}. Dict entries align to 8 like structs. + let entry = DBusMessageArgument.Dictionary.Entry( + key: .string("a"), + value: .variant(DBusMessageArgument.Variant(.int32(5))) + ) + let dictionary = DBusMessageArgument.Dictionary(keyType: .string, valueType: .variant, [entry])! + + #expect(try encode([.dictionary(dictionary)]) == [ + 0x10, 0x00, 0x00, 0x00, // length 16 + 0x00, 0x00, 0x00, 0x00, // padding to the dict entry's 8 byte alignment + 0x01, 0x00, 0x00, 0x00, // key length 1 (offset 8) + 0x61, 0x00, // "a" NUL (offset 12) + 0x01, 0x69, 0x00, // variant signature "i" (offset 14) + 0x00, 0x00, 0x00, // padding 17 -> 20 + 0x05, 0x00, 0x00, 0x00 // 5 (offset 20) + ]) + } + + // MARK: - Round trip + + @Test func basicRoundTrip() throws { + + try assertRoundTrip([ + .byte(.min), .byte(.max), .byte(0x42), + .boolean(true), .boolean(false), + .int16(.min), .int16(.max), + .uint16(.min), .uint16(.max), + .int32(.min), .int32(.max), + .uint32(.min), .uint32(.max), + .int64(.min), .int64(.max), + .uint64(.min), .uint64(.max), + .double(0.1111), .double(-0.0), .double(.pi), + .string("Test String"), + .string(""), + .string("unicode: ñ 😀 中文"), + .objectPath(DBusObjectPath(rawValue: "/com/example/bus1")!), + .objectPath(DBusObjectPath()), + .signature(DBusSignature(rawValue: "a{s(ai)}")!), + .signature(DBusSignature()), + .fileDescriptor(DBusMessageArgument.FileDescriptor(rawValue: 3)) + ]) + } + + @Test func doubleSpecialValues() throws { + + try assertRoundTrip([.double(.infinity), .double(-.infinity), .double(.greatestFiniteMagnitude)]) + + // NaN never compares equal, so check the bit pattern survived instead. + let bytes = try encode([.double(.nan)]) + let decoded = try decode(bytes, [.double]) + + guard case let .double(value) = decoded[0] + else { Issue.record("Wrong type"); return } + + #expect(value.isNaN) + } + + @Test func arrayRoundTrip() throws { + + try assertRoundTrip([ + .array(DBusMessageArgument.Array([.int16(1), .int16(2), .int16(3)])!), + .array(DBusMessageArgument.Array(type: .int16)), + .array(DBusMessageArgument.Array(type: .int32, [.int32(1), .int32(2), .int32(3)])!), + .array(DBusMessageArgument.Array(type: .string, [.string("1"), .string("2")])!), + .array(DBusMessageArgument.Array(type: .string)), + .array(DBusMessageArgument.Array(type: .byte, [.byte(1), .byte(2)])!), + .array(DBusMessageArgument.Array(type: .double, [.double(1.5)])!), + .array(DBusMessageArgument.Array(type: .objectPath, [ + .objectPath(DBusObjectPath(rawValue: "/com/example/bus1")!), + .objectPath(DBusObjectPath(rawValue: "/com/example/bus2")!) + ])!), + .array(DBusMessageArgument.Array(type: .array(.string), [ + .array(DBusMessageArgument.Array(type: .string, [.string("A1"), .string("A2")])!), + .array(DBusMessageArgument.Array(type: .string, [.string("B1"), .string("B2")])!) + ])!) + ]) + } + + /// An empty array must preserve its element type rather than fall back to a byte array. + @Test(arguments: [ + DBusSignature.ValueType.byte, .boolean, .int16, .uint16, .int32, .uint32, + .int64, .uint64, .double, .string, .objectPath, .signature, .variant, + .array(.string), .struct([.int32, .string]) + ]) + func emptyArrayPreservesElementType(type: DBusSignature.ValueType) throws { + + let argument = DBusMessageArgument.array(DBusMessageArgument.Array(type: type)) + + #expect(argument.type == .array(type)) + + let bytes = try encode([argument]) + let decoded = try decode(bytes, [.array(type)]) + + guard case let .array(decodedArray) = decoded[0] + else { Issue.record("Wrong type for \(type)"); return } + + #expect(decodedArray.isEmpty) + #expect(decodedArray.type == type, "Element type lost for \(type)") + } + + @Test func structRoundTrip() throws { + + try assertRoundTrip([ + .struct(DBusMessageArgument.Structure([.int32(1), .string("Test String")])!), + .struct(DBusMessageArgument.Structure([ + .int32(1), + .string("Test String 1"), + .objectPath(DBusObjectPath(rawValue: "/com/example/bus1")!), + .struct(DBusMessageArgument.Structure([ + .int32(2), + .string("Test String 2") + ])!) + ])!), + .struct(DBusMessageArgument.Structure([.byte(1)])!) + ]) + } + + @Test func variantRoundTrip() throws { + + try assertRoundTrip([ + .variant(DBusMessageArgument.Variant(.string("hello"))), + .variant(DBusMessageArgument.Variant(.int32(42))), + .variant(DBusMessageArgument.Variant(.byte(7))), + .variant(DBusMessageArgument.Variant(.double(1.5))), + .variant(DBusMessageArgument.Variant( + .array(DBusMessageArgument.Array(type: .string, [.string("a")])!))), + .variant(DBusMessageArgument.Variant( + .struct(DBusMessageArgument.Structure([.int32(1), .string("x")])!))), + // a variant containing a variant + .variant(DBusMessageArgument.Variant( + .variant(DBusMessageArgument.Variant(.int32(9))))) + ]) + } + + @Test func dictionaryRoundTrip() throws { + + let stringToVariant = DBusMessageArgument.Dictionary(keyType: .string, valueType: .variant, [ + .init(key: .string("Name"), value: .variant(DBusMessageArgument.Variant(.string("Test")))), + .init(key: .string("Count"), value: .variant(DBusMessageArgument.Variant(.uint32(3)))), + .init(key: .string("Enabled"), value: .variant(DBusMessageArgument.Variant(.boolean(true)))) + ])! + + let intToString = DBusMessageArgument.Dictionary(keyType: .int32, valueType: .string, [ + .init(key: .int32(1), value: .string("one")), + .init(key: .int32(2), value: .string("two")) + ])! + + let empty = DBusMessageArgument.Dictionary(keyType: .string, valueType: .variant)! + + try assertRoundTrip([.dictionary(stringToVariant), .dictionary(intToString), .dictionary(empty)]) + } + + /// `a{sv}` is what `org.freedesktop.DBus.Properties.GetAll` returns, so it has to work. + @Test func propertiesGetAllShape() throws { + + let properties = DBusMessageArgument.Dictionary(keyType: .string, valueType: .variant, [ + .init(key: .string("Path"), + value: .variant(DBusMessageArgument.Variant( + .objectPath(DBusObjectPath(rawValue: "/org/example")!)))), + .init(key: .string("Interfaces"), + value: .variant(DBusMessageArgument.Variant( + .array(DBusMessageArgument.Array(type: .string, [.string("org.example.A")])!)))), + .init(key: .string("Nested"), + value: .variant(DBusMessageArgument.Variant( + .dictionary(DBusMessageArgument.Dictionary(keyType: .string, valueType: .uint32, [ + .init(key: .string("inner"), value: .uint32(1)) + ])!)))) + ])! + + #expect(String(DBusMessageArgument.dictionary(properties).type) == "a{sv}") + + try assertRoundTrip([.dictionary(properties)]) + } + + // MARK: - Rejection + + @Test func rejectsNonZeroPadding() throws { + + // [.byte(1), .int16(2)] is 01 00 02 00; corrupt the padding byte. + var bytes = try encode([.byte(1), .int16(2)]) + bytes[1] = 0xFF + + #expect(throws: DBusProtocolError.invalidPadding) { + try decode(bytes, [.byte, .int16]) + } + } + + @Test func rejectsInvalidBoolean() throws { + + var bytes = try encode([.boolean(true)]) + bytes[0] = 2 + + #expect(throws: (any Error).self) { try decode(bytes, [.boolean]) } + } + + @Test func rejectsTruncatedInput() throws { + + let bytes = try encode([.string("hello")]) + + for length in 0 ..< bytes.count { + #expect(throws: (any Error).self, "Should reject \(length) of \(bytes.count) bytes") { + try decode(Array(bytes.prefix(length)), [.string]) + } + } + } + + @Test func rejectsUnterminatedString() throws { + + var bytes = try encode([.string("foo")]) + bytes[bytes.count - 1] = 0x21 // replace the NUL + + #expect(throws: DBusProtocolError.invalidString) { try decode(bytes, [.string]) } + } + + @Test func rejectsInvalidUTF8() { + + // Length 2, then an invalid UTF-8 sequence, then NUL. + let bytes: [UInt8] = [0x02, 0x00, 0x00, 0x00, 0xC3, 0x28, 0x00] + + #expect(throws: DBusProtocolError.invalidString) { try decode(bytes, [.string]) } + } + + @Test func rejectsInvalidObjectPath() throws { + + // A syntactically valid string that is not a valid object path. + let bytes = try encode([.string("/com//example")]) + + #expect(throws: (any Error).self) { try decode(bytes, [.objectPath]) } + } + + @Test func rejectsOverlongArray() throws { + + // Declare a length longer than the data that follows. + var bytes = try encode([.array(DBusMessageArgument.Array(type: .byte, [.byte(1)])!)]) + bytes[0] = 0xFF + + #expect(throws: (any Error).self) { try decode(bytes, [.array(.byte)]) } + } +} diff --git a/Tests/DBusTests/MatchRuleTests.swift b/Tests/DBusTests/MatchRuleTests.swift new file mode 100644 index 0000000..c9bc76a --- /dev/null +++ b/Tests/DBusTests/MatchRuleTests.swift @@ -0,0 +1,229 @@ +// +// MatchRuleTests.swift +// DBusTests +// + +import Testing +@testable import DBus + +@Suite struct MatchRuleTests { + + private let interface = DBusInterface(rawValue: "com.example.Thing")! + private let member = DBusMember(rawValue: "Changed")! + private let path = DBusObjectPath(rawValue: "/com/example/thing")! + + private func signal(interface: String = "com.example.Thing", + member: String = "Changed", + path: String = "/com/example/thing", + arguments: [DBusMessageArgument] = []) -> DBusMessage { + + return DBusMessage(signal: DBusMessage.Signal( + path: DBusObjectPath(rawValue: path)!, + interface: DBusInterface(rawValue: interface)!, + name: DBusMember(rawValue: member)! + ), arguments: arguments) + } + + // MARK: - Encoding + + @Test func signalRuleEncoding() { + + let rule = DBusMatchRule.signal(interface: interface, member: member, path: path) + + #expect(rule.rawValue + == "type='signal',interface='com.example.Thing',member='Changed',path='/com/example/thing'") + } + + @Test func emptyRuleEncodesToEmptyString() { + + #expect(DBusMatchRule().rawValue == "") + } + + @Test func allFieldsEncoding() { + + var rule = DBusMatchRule(type: .methodCall, + sender: DBusBusName(rawValue: "com.example.Sender")!, + interface: interface, + member: member, + path: path, + destination: DBusBusName(rawValue: ":1.5")!, + eavesdrop: true) + rule.arguments = [0: "first", 2: "third"] + rule.argument0Namespace = "com.example" + rule.argument0Path = "/com/example/" + + #expect(rule.rawValue == [ + "type='method_call'", + "sender='com.example.Sender'", + "interface='com.example.Thing'", + "member='Changed'", + "path='/com/example/thing'", + "destination=':1.5'", + "arg0='first'", + "arg2='third'", + "arg0namespace='com.example'", + "arg0path='/com/example/'", + "eavesdrop='true'" + ].joined(separator: ",")) + } + + @Test func pathNamespaceEncoding() { + + let rule = DBusMatchRule(pathNamespace: DBusObjectPath(rawValue: "/com/example")!) + + #expect(rule.rawValue == "path_namespace='/com/example'") + } + + @Test func messageTypeNames() { + + #expect(DBusMessageType.methodCall.matchRuleName == "method_call") + #expect(DBusMessageType.methodReturn.matchRuleName == "method_return") + #expect(DBusMessageType.error.matchRuleName == "error") + #expect(DBusMessageType.signal.matchRuleName == "signal") + } + + /// A literal apostrophe cannot appear inside single quotes, so it is written by closing the + /// quote, escaping it, and reopening. + @Test func apostropheEscaping() { + + #expect(DBusMatchRule.escape("plain") == "'plain'") + #expect(DBusMatchRule.escape("it's") == "'it'\\''s'") + #expect(DBusMatchRule.escape("'") == "''\\'''") + #expect(DBusMatchRule.escape("") == "''") + + var rule = DBusMatchRule() + rule.arguments = [0: "it's"] + #expect(rule.rawValue == "arg0='it'\\''s'") + } + + /// The rule string is the key used to reference count AddMatch, so it must be stable. + @Test func argumentOrderIsDeterministic() { + + var rule = DBusMatchRule() + rule.arguments = [3: "d", 1: "b", 0: "a", 2: "c"] + + #expect(rule.rawValue == "arg0='a',arg1='b',arg2='c',arg3='d'") + #expect(rule.rawValue == rule.rawValue) + } + + @Test func nameOwnerChangedConvenience() { + + let rule = DBusMatchRule.nameOwnerChanged(name: DBusBusName(rawValue: "com.example.App")!) + + #expect(rule.rawValue == [ + "type='signal'", + "sender='org.freedesktop.DBus'", + "interface='org.freedesktop.DBus'", + "member='NameOwnerChanged'", + "path='/org/freedesktop/DBus'", + "arg0='com.example.App'" + ].joined(separator: ",")) + } + + @Test func propertiesChangedConvenience() { + + let rule = DBusMatchRule.propertiesChanged(interface: interface, path: path) + + #expect(rule.interface == DBusWellKnown.propertiesInterface) + #expect(rule.member?.rawValue == "PropertiesChanged") + #expect(rule.arguments[0] == "com.example.Thing") + #expect(rule.path == path) + } + + // MARK: - Matching + + @Test func emptyRuleMatchesEverything() { + + #expect(DBusMatchRule().matches(signal())) + #expect(DBusMatchRule().matches(DBusMessage(type: .methodCall))) + } + + @Test func typeMatching() { + + #expect(DBusMatchRule(type: .signal).matches(signal())) + #expect(!DBusMatchRule(type: .methodCall).matches(signal())) + } + + @Test func interfaceMemberPathMatching() { + + let rule = DBusMatchRule.signal(interface: interface, member: member, path: path) + + #expect(rule.matches(signal())) + #expect(!rule.matches(signal(interface: "com.example.Other"))) + #expect(!rule.matches(signal(member: "Other"))) + #expect(!rule.matches(signal(path: "/com/example/other"))) + } + + @Test func pathNamespaceMatching() { + + let rule = DBusMatchRule(pathNamespace: DBusObjectPath(rawValue: "/com/example")!) + + #expect(rule.matches(signal(path: "/com/example")), "The namespace itself matches") + #expect(rule.matches(signal(path: "/com/example/thing"))) + #expect(rule.matches(signal(path: "/com/example/thing/nested"))) + #expect(!rule.matches(signal(path: "/com/other"))) + #expect(!rule.matches(signal(path: "/com")), "A parent is not in the namespace") + + // A path that shares a textual prefix but not a path prefix must not match. + #expect(!rule.matches(signal(path: "/com/exampleother"))) + } + + @Test func argumentMatching() { + + var rule = DBusMatchRule() + rule.arguments = [0: "hello", 1: "world"] + + #expect(rule.matches(signal(arguments: [.string("hello"), .string("world")]))) + #expect(!rule.matches(signal(arguments: [.string("hello")])), "Missing argument 1") + #expect(!rule.matches(signal(arguments: [.string("hello"), .string("other")]))) + #expect(!rule.matches(signal(arguments: [.string("hello"), .int32(2)])), + "Only string-like arguments can be matched") + } + + @Test func objectPathArgumentIsMatchable() { + + var rule = DBusMatchRule() + rule.arguments = [0: "/com/example/thing"] + + #expect(rule.matches(signal(arguments: [ + .objectPath(DBusObjectPath(rawValue: "/com/example/thing")!) + ]))) + } + + @Test func argument0NamespaceMatching() { + + var rule = DBusMatchRule() + rule.argument0Namespace = "com.example" + + #expect(rule.matches(signal(arguments: [.string("com.example")]))) + #expect(rule.matches(signal(arguments: [.string("com.example.App")]))) + #expect(!rule.matches(signal(arguments: [.string("com.examplefoo")])), + "A textual prefix is not a namespace prefix") + #expect(!rule.matches(signal(arguments: [.string("org.example.App")]))) + #expect(!rule.matches(signal(arguments: []))) + } + + @Test func argument0PathMatching() { + + var rule = DBusMatchRule() + rule.argument0Path = "/aa/bb/" + + // Equal, or either one a path prefix of the other where the shorter ends in '/'. + #expect(rule.matches(signal(arguments: [.string("/aa/bb/")]))) + #expect(rule.matches(signal(arguments: [.string("/aa/bb/cc")]))) + #expect(rule.matches(signal(arguments: [.string("/aa/")]))) + #expect(!rule.matches(signal(arguments: [.string("/aa/cc/")]))) + } + + @Test func senderAndDestinationMatching() { + + var message = signal() + message.sender = DBusBusName(rawValue: ":1.5")! + message.destination = DBusBusName(rawValue: ":1.9")! + + #expect(DBusMatchRule(sender: DBusBusName(rawValue: ":1.5")!).matches(message)) + #expect(!DBusMatchRule(sender: DBusBusName(rawValue: ":1.6")!).matches(message)) + #expect(DBusMatchRule(destination: DBusBusName(rawValue: ":1.9")!).matches(message)) + #expect(!DBusMatchRule(destination: DBusBusName(rawValue: ":1.8")!).matches(message)) + } +} diff --git a/Tests/DBusTests/MessageTests.swift b/Tests/DBusTests/MessageTests.swift index 3d29bfd..242d224 100644 --- a/Tests/DBusTests/MessageTests.swift +++ b/Tests/DBusTests/MessageTests.swift @@ -5,22 +5,141 @@ // Created by Alsey Coleman Miller on 11/3/18. // -import Foundation -import XCTest +import Testing @testable import DBus -final class MessageTests: XCTestCase { - - static let allTests: [(String, (MessageTests) -> () -> Void)] = [ - ("testBasicValueArguments", testBasicValueArguments), - ("testArrayArguments", testArrayArguments), - ("testStructureArguments", testStructureArguments), - ("testErrorMessage", testErrorMessage) - ] - - func testBasicValueArguments() { - - let arguments: [DBusMessageArgument] = [ +@Suite struct MessageTests { + + /// Encode and decode in both byte orders and check the message survives intact. + private func assertRoundTrip(_ message: DBusMessage, + sourceLocation: SourceLocation = #_sourceLocation) throws { + + for endianness in DBusEndianness.allCases { + + let bytes = try message.encode(endianness: endianness) + + // The total length must be derivable from the first 16 bytes alone. + #expect(try DBusMessage.length(from: bytes) == bytes.count, + "\(endianness)", sourceLocation: sourceLocation) + + let (decoded, length) = try DBusMessage.decode(bytes) + + #expect(length == bytes.count, "\(endianness)", sourceLocation: sourceLocation) + #expect(decoded == message, "\(endianness)", sourceLocation: sourceLocation) + } + } + + // MARK: - Header + + @Test func methodCallRoundTrip() throws { + + var message = DBusMessage(methodCall: DBusMessage.MethodCall( + destination: DBusBusName(rawValue: "org.freedesktop.DBus")!, + path: DBusObjectPath(rawValue: "/org/freedesktop/DBus")!, + interface: DBusInterface(rawValue: "org.freedesktop.DBus")!, + method: DBusMember(rawValue: "ListNames")! + )) + message.serial = 1 + + try assertRoundTrip(message) + } + + /// `MethodCall` and `Signal` had only an internal memberwise initializer before the rewrite, + /// so `DBusMessage.init(methodCall:)` could not be reached from outside the module. + @Test func methodCallIsPubliclyConstructible() { + + let methodCall = DBusMessage.MethodCall( + path: DBusObjectPath(rawValue: "/org/example")!, + method: DBusMember(rawValue: "Ping")! + ) + + #expect(methodCall.destination == nil) + #expect(methodCall.interface == nil) + #expect(DBusMessage(methodCall: methodCall).type == .methodCall) + + let signal = DBusMessage.Signal( + path: DBusObjectPath(rawValue: "/org/example")!, + interface: DBusInterface(rawValue: "org.example.Thing")!, + name: DBusMember(rawValue: "Changed")! + ) + + #expect(DBusMessage(signal: signal).type == .signal) + } + + @Test func signalRoundTrip() throws { + + var message = DBusMessage(signal: DBusMessage.Signal( + path: DBusObjectPath(rawValue: "/org/freedesktop/DBus")!, + interface: DBusInterface(rawValue: "org.freedesktop.DBus")!, + name: DBusMember(rawValue: "NameAcquired")! + ), arguments: [.string(":1.42")]) + + message.serial = 7 + message.sender = DBusBusName(rawValue: "org.freedesktop.DBus")! + + try assertRoundTrip(message) + } + + @Test func allHeaderFieldsRoundTrip() throws { + + var message = DBusMessage(type: .methodCall) + message.serial = 0xDEADBEEF + message.flags = [.noReplyExpected, .noAutoStart, .allowInteractiveAuthorization] + message.path = DBusObjectPath(rawValue: "/com/example/bus1")! + message.interface = DBusInterface(rawValue: "com.example.MusicPlayer1")! + message.member = DBusMember(rawValue: "Play")! + message.replySerial = 12345 + message.destination = DBusBusName(rawValue: "com.example.MusicPlayer1")! + message.sender = DBusBusName(rawValue: ":1.99")! + message.arguments = [.string("track"), .uint32(3)] + + try assertRoundTrip(message) + #expect(message.signature.rawValue == "su") + } + + @Test func errorMessage() throws { + + var originalMessage = DBusMessage(type: .methodCall) + originalMessage.serial = .random(in: 1 ..< .max) + originalMessage.sender = DBusBusName(rawValue: ":1.5")! + + let error = DBusError(name: .failed, message: "Test Error") + let errorMessage = DBusMessage(replyTo: originalMessage, error: error) + + #expect(errorMessage.type == .error) + #expect(errorMessage.replySerial == originalMessage.serial) + #expect(errorMessage.destination == originalMessage.sender) + #expect(DBusError(message: errorMessage) == error) + + try assertRoundTrip(errorMessage) + } + + @Test func methodReturn() throws { + + var call = DBusMessage(type: .methodCall) + call.serial = 42 + call.sender = DBusBusName(rawValue: ":1.5")! + + let reply = DBusMessage(methodReturn: call, arguments: [.boolean(true)]) + + #expect(reply.type == .methodReturn) + #expect(reply.replySerial == 42) + #expect(reply.destination == call.sender) + + try assertRoundTrip(reply) + } + + @Test func errorFromNonErrorMessageIsNil() { + + #expect(DBusError(message: DBusMessage(type: .methodCall)) == nil) + } + + // MARK: - Body + + @Test func basicValueArguments() throws { + + var message = DBusMessage(type: .methodCall, serial: 1) + message.arguments = [ .byte(.max), .boolean(true), .int16(.max), @@ -31,127 +150,189 @@ final class MessageTests: XCTestCase { .uint64(.max), .double(0.1111), .string("Test String"), - .objectPath(DBusObjectPath("/com/example/bus1")), - .signature(DBusSignature("a{s(ai)}")), + .objectPath(DBusObjectPath(rawValue: "/com/example/bus1")!), + .signature(DBusSignature(rawValue: "a{s(ai)}")!) ] - - do { - - let message = try DBusMessage(type: .methodCall) - try message.append(contentsOf: arguments) - XCTAssertEqual(Array(message), arguments, "Could not iterate message") - } - catch { XCTFail("\(error)") } + + try assertRoundTrip(message) } - - func testArrayArguments() { - - let arguments: [DBusMessageArgument] = [ + + @Test func arrayArguments() throws { + + var message = DBusMessage(type: .methodCall, serial: 1) + message.arguments = [ .array(DBusMessageArgument.Array([.int16(1), .int16(2), .int16(3)])!), - .array(DBusMessageArgument.Array(type: .int16, [.int16(1), .int16(2), .int16(3)])!), .array(DBusMessageArgument.Array(type: .int16)), - .array(DBusMessageArgument.Array(type: .int32, [.int32(1), .int32(2), .int32(3)])!), - .array(DBusMessageArgument.Array(type: .int32)), - .array(DBusMessageArgument.Array(type: .string, [.string("1"), .string("2"), .string("3")])!), - .array(DBusMessageArgument.Array(type: .string)), - .array(DBusMessageArgument.Array(type: .objectPath, [ - .objectPath(DBusObjectPath("/com/example/bus1")), - .objectPath(DBusObjectPath("/com/example/bus2")), - .objectPath(DBusObjectPath("/com/example/bus3")) - ])!), - .array(DBusMessageArgument.Array(type: .objectPath)), + .array(DBusMessageArgument.Array(type: .string, [.string("1"), .string("2")])!), .array(DBusMessageArgument.Array(type: .array(.string), [ - .array(DBusMessageArgument.Array(type: .string, [.string("A1"), .string("A2"), .string("A3")])!), - .array(DBusMessageArgument.Array(type: .string, [.string("B1"), .string("B2"), .string("B3")])!), - .array(DBusMessageArgument.Array(type: .string, [.string("C1"), .string("C2"), .string("C3")])!) - ])!), + .array(DBusMessageArgument.Array(type: .string, [.string("A1"), .string("A2")])!) + ])!), .array(DBusMessageArgument.Array(type: .struct([.int32, .string]), [ - .struct(DBusMessageArgument.Structure([ - .int32(1), - .string("Test String 1") - ])!), - .struct(DBusMessageArgument.Structure([ - .int32(2), - .string("Test String 2") - ])!) - ])!) + .struct(DBusMessageArgument.Structure([.int32(1), .string("Test String 1")])!), + .struct(DBusMessageArgument.Structure([.int32(2), .string("Test String 2")])!) + ])!) ] - - do { - - let message = try DBusMessage(type: .methodCall) - try message.append(contentsOf: arguments) - XCTAssertEqual(Array(message), arguments, "Could not iterate message") - } - catch { XCTFail("\(error)") } + + try assertRoundTrip(message) } - - func testStructureArguments() { - - let arguments: [DBusMessageArgument] = [ - .struct(DBusMessageArgument.Structure([ - .int32(1), - .string("Test String") - ])!), + + @Test func structureArguments() throws { + + var message = DBusMessage(type: .methodCall, serial: 1) + message.arguments = [ + .struct(DBusMessageArgument.Structure([.int32(1), .string("Test String")])!), .struct(DBusMessageArgument.Structure([ .int32(1), .string("Test String 1"), - .objectPath(DBusObjectPath("/com/example/bus1")), + .objectPath(DBusObjectPath(rawValue: "/com/example/bus1")!), .struct(DBusMessageArgument.Structure([ .int32(2), .string("Test String 2"), - .objectPath(DBusObjectPath("/com/example/bus2")) - ])!) - ])!), - .struct(DBusMessageArgument.Structure([ - .int32(1), - .string("Test String"), - .array(DBusMessageArgument.Array(type: .array(.string), [ - .array(DBusMessageArgument.Array(type: .string, [.string("A1"), .string("A2"), .string("A3")])!), - .array(DBusMessageArgument.Array(type: .string, [.string("B1"), .string("B2"), .string("B3")])!), - .array(DBusMessageArgument.Array(type: .string, [.string("C1"), .string("C2"), .string("C3")])!) - ])!), - .array(DBusMessageArgument.Array(type: .struct([.int32, .string]), [ - .struct(DBusMessageArgument.Structure([ - .int32(1), - .string("Test String 1") - ])!), - .struct(DBusMessageArgument.Structure([ - .int32(2), - .string("Test String 2") - ])!) - ])!) - ])!), + .objectPath(DBusObjectPath(rawValue: "/com/example/bus2")!) + ])!) + ])!) ] - - do { - - let message = try DBusMessage(type: .methodCall) - try message.append(contentsOf: arguments) - XCTAssertEqual(Array(message), arguments, "Could not iterate message") + + try assertRoundTrip(message) + } + + /// The shape returned by `org.freedesktop.DBus.Properties.GetAll`, which crashed the + /// libdbus-backed implementation because `variant` and `dict` were unimplemented. + @Test func variantAndDictionaryArguments() throws { + + var message = DBusMessage(type: .methodReturn, serial: 1) + message.replySerial = 1 + message.arguments = [ + .dictionary(DBusMessageArgument.Dictionary(keyType: .string, valueType: .variant, [ + .init(key: .string("Name"), + value: .variant(DBusMessageArgument.Variant(.string("Example")))), + .init(key: .string("Version"), + value: .variant(DBusMessageArgument.Variant(.uint32(2)))), + .init(key: .string("Paths"), + value: .variant(DBusMessageArgument.Variant( + .array(DBusMessageArgument.Array(type: .objectPath, [ + .objectPath(DBusObjectPath(rawValue: "/a")!) + ])!)))) + ])!) + ] + + #expect(message.signature.rawValue == "a{sv}") + try assertRoundTrip(message) + } + + @Test func emptyBodyHasNoSignatureField() throws { + + var message = DBusMessage(type: .methodCall, serial: 1) + message.member = DBusMember(rawValue: "Ping")! + + let bytes = try message.encode() + let (decoded, _) = try DBusMessage.decode(bytes) + + #expect(decoded.arguments.isEmpty) + #expect(decoded.signature.rawValue == "") + } + + // MARK: - Framing + + @Test func lengthRequiresSixteenBytes() throws { + + let message = DBusMessage(type: .methodCall, serial: 1, + member: DBusMember(rawValue: "Ping")!) + let bytes = try message.encode() + + for count in 0 ..< DBusMessage.minimumHeaderLength { + #expect(try DBusMessage.length(from: Array(bytes.prefix(count))) == nil) } - catch { XCTFail("\(error)") } - } - - func testErrorMessage() { - - do { - - let originalMessage = try DBusMessage(type: .methodCall) - - #if swift(>=4.2) - originalMessage.serial = .random(in: 1 ..< .max) - #else - originalMessage.serial = 1 // fake it till you make it - #endif - - let error = try DBusError(name: DBusError.Name.failed, message: "Test Error") - - let errorMessage = try DBusMessage(error: DBusMessage.Error(replyTo: originalMessage, error: error)) - - XCTAssertEqual(DBusError(message: errorMessage), error) - XCTAssertEqual(errorMessage.replySerial, originalMessage.serial) + + #expect(try DBusMessage.length(from: Array(bytes.prefix(16))) == bytes.count) + } + + @Test func decodeIgnoresTrailingBytes() throws { + + let message = DBusMessage(type: .methodCall, serial: 1, + member: DBusMember(rawValue: "Ping")!, + arguments: [.string("x")]) + var bytes = try message.encode() + let expectedLength = bytes.count + bytes.append(contentsOf: [0xAA, 0xBB, 0xCC]) + + let (decoded, length) = try DBusMessage.decode(bytes) + #expect(length == expectedLength) + #expect(decoded == message) + } + + @Test func rejectsTruncatedMessage() throws { + + let message = DBusMessage(type: .methodCall, serial: 1, + member: DBusMember(rawValue: "Ping")!, + arguments: [.string("hello")]) + let bytes = try message.encode() + + for count in 0 ..< bytes.count { + #expect(throws: (any Error).self, "Should reject \(count) of \(bytes.count) bytes") { + try DBusMessage.decode(Array(bytes.prefix(count))) + } + } + } + + @Test func rejectsInvalidByteOrder() throws { + + var bytes = try DBusMessage(type: .methodCall, serial: 1).encode() + bytes[0] = 0x58 // 'X' + + #expect(throws: DBusProtocolError.invalidByteOrder(0x58)) { + try DBusMessage.decode(bytes) } - catch { XCTFail("\(error)") } + } + + @Test func rejectsInvalidMessageType() throws { + + var bytes = try DBusMessage(type: .methodCall, serial: 1).encode() + bytes[1] = 99 + + #expect(throws: DBusProtocolError.invalidMessageType(99)) { + try DBusMessage.decode(bytes) + } + } + + @Test func rejectsInvalidProtocolVersion() throws { + + var bytes = try DBusMessage(type: .methodCall, serial: 1).encode() + bytes[3] = 2 + + #expect(throws: DBusProtocolError.invalidProtocolVersion(2)) { + try DBusMessage.decode(bytes) + } + } + + /// Unknown header field codes must be skipped rather than rejected, so that additions to + /// the specification do not break this implementation. + @Test func ignoresUnknownHeaderField() throws { + + var message = DBusMessage(type: .methodCall, serial: 1) + message.member = DBusMember(rawValue: "Ping")! + + // Encode by hand with an extra field, code 200. + var fields = Array(message.headerFieldsArgument()) + fields.append(.struct(DBusMessageArgument.Structure([ + .byte(200), + .variant(DBusMessageArgument.Variant(.string("ignored"))) + ])!)) + + let array = DBusMessageArgument.Array(type: .struct([.byte, .variant]), fields)! + + var marshaller = DBusMarshaller(endianness: .little) + marshaller.append(DBusEndianness.little.rawValue) + marshaller.append(DBusMessageType.methodCall.rawValue) + marshaller.append(UInt8(0)) + marshaller.append(DBusMessage.protocolVersion) + marshaller.appendUnaligned(UInt32(0)) // body length + marshaller.appendUnaligned(UInt32(1)) // serial + try marshaller.append(.array(array)) + marshaller.pad(to: 8) + + let (decoded, _) = try DBusMessage.decode(marshaller.bytes) + #expect(decoded.member == message.member) + #expect(decoded.serial == 1) } } diff --git a/Tests/DBusTests/NameTests.swift b/Tests/DBusTests/NameTests.swift new file mode 100644 index 0000000..2e27a4b --- /dev/null +++ b/Tests/DBusTests/NameTests.swift @@ -0,0 +1,123 @@ +// +// NameTests.swift +// DBusTests +// + +import Testing +@testable import DBus + +/// Tests for `DBusMember` and `DBusBusName`. +/// +/// Both types validated purely by delegating to `dbus_validate_member` / +/// `dbus_validate_bus_name` before the pure-Swift rewrite, so these rules had no coverage. +@Suite struct NameTests { + + // MARK: - Member + + @Test(arguments: [ + "GetItems", + "ItemsChanged", + "a", + "_", + "_7", + "Get", + "GetAll", + "PropertiesChanged", + "Introspect", + "A1_b2_C3" + ]) + func validMember(string: String) throws { + + #expect(throws: Never.self) { try DBusMember.validate(string) } + #expect(DBusMember(rawValue: string)?.rawValue == string) + #expect(DBusMember(rawValue: string)?.description == string) + } + + @Test(arguments: [ + "", // must be at least 1 byte + "1", // may not begin with a digit + "1Get", + "Get.All", // must not contain a period + ".", + "Get-All", // '-' is legal in bus names but not members + "Get Items", + "Getñ", // ASCII only + "Get😀" + ]) + func invalidMember(string: String) throws { + + #expect(DBusMember(rawValue: string) == nil, "\(string) should be invalid") + + let error = try #require(throws: DBusError.self) { + try DBusMember.validate(string) + } + + #expect(error.name == .invalidArguments) + } + + @Test func memberLengthLimit() { + + #expect(DBusMember(rawValue: String(repeating: "a", count: 255)) != nil) + #expect(DBusMember(rawValue: String(repeating: "a", count: 256)) == nil) + } + + // MARK: - Bus Name + + @Test(arguments: [ + "org.freedesktop.DBus", + "com.example.MusicPlayer1", + "a.b", + "org._7_zip.Archiver", + "com.example.Music-Player", // '-' is discouraged but legal + ":1.0", // unique connection name + ":1.42", + ":0.1", + "org.freedesktop.NetworkManager" + ]) + func validBusName(string: String) throws { + + #expect(throws: Never.self) { try DBusBusName.validate(string) } + #expect(DBusBusName(rawValue: string)?.rawValue == string) + } + + @Test(arguments: [ + "", // must be at least 1 byte + "org", // must contain at least one period + ".org.freedesktop", // must not begin with a period + "org.freedesktop.", // no trailing period + "org..freedesktop", // no empty element + "org.7zip.Archiver", // a well known name may not have an element beginning with a digit + "1.0", // only legal with the ':' prefix + ":", // a colon alone is not a name + ":1", // still needs at least two elements + "org.freedesktop.DBus@", + "org.freedesktopñ.DBus" + ]) + func invalidBusName(string: String) throws { + + #expect(DBusBusName(rawValue: string) == nil, "\(string) should be invalid") + + let error = try #require(throws: DBusError.self) { + try DBusBusName.validate(string) + } + + #expect(error.name == .invalidArguments) + } + + @Test func busNameLengthLimit() { + + let long = String(repeating: "a", count: 128) + "." + String(repeating: "b", count: 126) + #expect(long.utf8.count == 255) + #expect(DBusBusName(rawValue: long) != nil) + #expect(DBusBusName(rawValue: long + "c") == nil) + } + + /// Only elements of a unique connection name may begin with a digit. + @Test func uniqueNames() { + + #expect(DBusBusName(rawValue: ":1.0")?.isUnique == true) + #expect(DBusBusName(rawValue: "org.freedesktop.DBus")?.isUnique == false) + #expect(DBusBusName(rawValue: ":1.0") != nil) + #expect(DBusBusName(rawValue: "1.0") == nil) + } +} diff --git a/Tests/DBusTests/ObjectPathTests.swift b/Tests/DBusTests/ObjectPathTests.swift index a6081cd..742547d 100644 --- a/Tests/DBusTests/ObjectPathTests.swift +++ b/Tests/DBusTests/ObjectPathTests.swift @@ -6,189 +6,147 @@ // import Foundation -import XCTest +import Testing @testable import DBus -final class ObjectPathTests: XCTestCase { - - static let allTests: [(String, (ObjectPathTests) -> () -> Void)] = [ - ("testInvalid", testInvalid), - ("testValid", testValid), - ("testEmpty", testEmpty), - ("testMultithread", testMultithread) - ] - - func testInvalid() { - - let strings = [ - "", - """ - /com//example/ - """, - "/com/example/ñanó", - "/com/example/b$@s1", - "/com/example/bus1/", - "/com/example/😀", - "//", - "///", - "\\" - ] - - for string in strings { - - XCTAssertNil(DBusObjectPath(rawValue: string), "\(string) should be invalid") - XCTAssertThrowsError(try DBusObjectPath.validate(string)) - do { try DBusObjectPath.validate(string) } - catch let error as DBusError { - XCTAssertEqual(error.name, DBusError.Name.invalidArguments) - print("\"\(string)\" is invalid: \(error.message)"); return - } - catch { XCTFail("\(error)"); return } - XCTFail("Error expected for \(string)") +@Suite struct ObjectPathTests { + + @Test(arguments: [ + "", + "/com//example/", + "/com/example/ñanó", + "/com/example/b$@s1", + "/com/example/bus1/", + "/com/example/😀", + "//", + "///", + "\\", + "/com//example", // multiple '/' cannot occur in sequence + "/com/example//", + "//com/example", + "com/example", // must begin with '/' + "/com/exa mple", + "/com/example/bus-1" // '-' is legal in bus names but not object paths + ]) + func invalid(string: String) throws { + + #expect(DBusObjectPath(rawValue: string) == nil, "\(string) should be invalid") + + let error = try #require(throws: DBusError.self) { + try DBusObjectPath.validate(string) } + + #expect(error.name == .invalidArguments) } - - func testValid() { - - let values: [(String, [String])] = [ - ("/", []), - ("/com", ["com"]), - ("/com/example/bus1", ["com", "example", "bus1"]) - ] - - for (string, elements) in values { - - XCTAssertNoThrow(try DBusObjectPath.validate(string)) - - // initialize - guard let objectPath = DBusObjectPath(rawValue: string) - else { XCTFail("Invalid string \(string)"); return } - - // test underlying values - XCTAssertEqual(objectPath.map { $0.rawValue }, elements, "Invalid elements") - XCTAssertEqual(objectPath.rawValue, string) - XCTAssertEqual(objectPath.description, string) - XCTAssertEqual(objectPath.hashValue, string.hashValue) - - // test collection / subscripting - XCTAssertEqual(objectPath.count, elements.count) - objectPath.enumerated().forEach { XCTAssertEqual(elements[$0.offset], $0.element.rawValue) } - elements.enumerated().forEach { XCTAssertEqual(objectPath[$0.offset].rawValue, $0.element) } - - // initialize with elements - let elementsObjectPath = DBusObjectPath(elements.compactMap({ DBusObjectPath.Element(rawValue: $0) })) - XCTAssertEqual(elementsObjectPath.map { $0.rawValue }, elements) - XCTAssertEqual(Array(elementsObjectPath), elements.compactMap({ DBusObjectPath.Element(rawValue: $0) })) - XCTAssertEqual(elementsObjectPath, objectPath) - - // test equality - XCTAssertEqual(objectPath, objectPath) - XCTAssertEqual(elementsObjectPath, elementsObjectPath) - XCTAssertEqual(objectPath, elementsObjectPath) - XCTAssertEqual(objectPath.rawValue, elementsObjectPath.rawValue) - XCTAssertEqual(Array(objectPath), Array(elementsObjectPath)) + + @Test(arguments: [ + ("/", [String]()), + ("/com", ["com"]), + ("/com/example/bus1", ["com", "example", "bus1"]), + ("/_", ["_"]), + ("/a0/B1", ["a0", "B1"]) + ]) + func valid(string: String, elements: [String]) throws { + + #expect(throws: Never.self) { try DBusObjectPath.validate(string) } + + let objectPath = try #require(DBusObjectPath(rawValue: string)) + + #expect(objectPath.map { $0.rawValue } == elements) + #expect(objectPath.rawValue == string) + #expect(objectPath.description == string) + #expect(objectPath.hashValue == string.hashValue) + + #expect(objectPath.count == elements.count) + + for (offset, element) in objectPath.enumerated() { + #expect(elements[offset] == element.rawValue) } + + // Building from elements must produce an equal value. + let fromElements = DBusObjectPath(elements.compactMap { DBusObjectPath.Element(rawValue: $0) }) + #expect(fromElements.map { $0.rawValue } == elements) + #expect(fromElements == objectPath) + #expect(fromElements.rawValue == objectPath.rawValue) } - - func testEmpty() { - - // empty object path + + @Test func empty() { + let objectPath = DBusObjectPath() - XCTAssertEqual(objectPath.rawValue, "/") - XCTAssert(objectPath.isEmpty) - XCTAssertEqual(objectPath, []) - XCTAssertEqual(DBusObjectPath(), DBusObjectPath(rawValue: "/")) - XCTAssertEqual(DBusObjectPath(), DBusObjectPath()) - XCTAssertNotEqual(DBusObjectPath(), DBusObjectPath(rawValue: "/com/example")!) - XCTAssertNotEqual(DBusObjectPath().rawValue, DBusObjectPath(rawValue: "/com/example")!.rawValue) - XCTAssertNotEqual(DBusObjectPath().elements, DBusObjectPath(rawValue: "/com/example")!.elements) - - // don't break value semantics by modifying instance + + #expect(objectPath.rawValue == "/") + #expect(objectPath.isEmpty) + #expect(objectPath == []) + #expect(DBusObjectPath() == DBusObjectPath(rawValue: "/")) + #expect(DBusObjectPath() != DBusObjectPath(rawValue: "/com/example")!) + + // Mutating a copy must not disturb the original. var mutable = DBusObjectPath() - XCTAssertEqual(mutable, objectPath) + #expect(mutable == objectPath) mutable.append(DBusObjectPath.Element(rawValue: "mutation1")!) mutable.removeLast() - XCTAssertEqual(mutable, objectPath) - XCTAssertEqual(mutable.rawValue, objectPath.rawValue) + #expect(mutable == objectPath) + #expect(mutable.rawValue == objectPath.rawValue) } - - func testMultithread() { - + + /// The cached string is dropped on mutation, so `rawValue` must rebuild it correctly from + /// many tasks at once without observing a torn value. + @Test func concurrentReadsAndCopies() async { + let string = "/com/example/bus1" - + let objectPath = DBusObjectPath([ DBusObjectPath.Element(rawValue: "com")!, DBusObjectPath.Element(rawValue: "example")!, DBusObjectPath.Element(rawValue: "bus1")! - ]) - - // instance for initializing string - let readStringCopy = objectPath - - // initialize string from another thread - let queue = DispatchQueue(label: "\(#function) Queue", attributes: [.concurrent]) - let end = Date() + 0.5 - while Date() < end { - - for _ in 0 ..< 100 { - - let mutableArray = [""] - - var newObjectPath: DBusObjectPath = [] - XCTAssertEqual(newObjectPath.rawValue, "/") - newObjectPath.append(DBusObjectPath.Element(rawValue: "example")!) - XCTAssertEqual(newObjectPath.rawValue, "/example") - newObjectPath.append(DBusObjectPath.Element(rawValue: "mutation")!) - - queue.async { - - // access variable from different threads - - // trigger lazy initialization from another thread - XCTAssertEqual(newObjectPath.rawValue, "/example/mutation") - - var mutableCopy1 = newObjectPath - var mutableCopy2 = newObjectPath - - var arrayCopy1 = mutableArray - var arrayCopy2 = mutableArray - - queue.async { - - mutableCopy1.append(DBusObjectPath.Element(rawValue: "1")!) - XCTAssertEqual(mutableCopy1.rawValue, "/example/mutation/1") - - XCTAssertEqual(arrayCopy1, [""]) - arrayCopy1.append("1") - XCTAssertEqual(arrayCopy1, ["", "1"]) - } - - queue.async { - - mutableCopy2.append(DBusObjectPath.Element(rawValue: "2")!) - XCTAssertEqual(mutableCopy2.rawValue, "/example/mutation/2") - - XCTAssertEqual(arrayCopy2, [""]) - arrayCopy2.append("2") - XCTAssertEqual(arrayCopy2, ["", "2"]) - } + ]) + + // Built by mutation, so its cached string is nil and `rawValue` has to be rebuilt. + var built: DBusObjectPath = [] + #expect(built.rawValue == "/") + built.append(DBusObjectPath.Element(rawValue: "example")!) + #expect(built.rawValue == "/example") + built.append(DBusObjectPath.Element(rawValue: "mutation")!) + + let shared = built + let readCopy = objectPath + + await withTaskGroup(of: Void.self) { group in + + for index in 0 ..< 100 { + + group.addTask { + + #expect(shared.rawValue == "/example/mutation") + #expect(readCopy.rawValue == string) + + var copy = shared + copy.append(DBusObjectPath.Element(rawValue: "n\(index)")!) + #expect(copy.rawValue == "/example/mutation/n\(index)") + #expect(shared.rawValue == "/example/mutation") + + var mutateCopy = readCopy + mutateCopy.append(DBusObjectPath.Element(rawValue: "mutation")!) + #expect(readCopy != mutateCopy) + #expect(mutateCopy.rawValue != string) } } - - queue.async { - - XCTAssertEqual(readStringCopy.rawValue, string) - } - - queue.async { - - var mutateCopy = readStringCopy - mutateCopy.append(DBusObjectPath.Element(rawValue: "mutation")!) - XCTAssertNotEqual(readStringCopy, mutateCopy) - XCTAssertNotEqual(mutateCopy.rawValue, string) - } } - - XCTAssertEqual(objectPath.rawValue, string) + + #expect(objectPath.rawValue == string) + #expect(shared.rawValue == "/example/mutation") + } + + @Test func namespaceContainment() { + + let parent = DBusObjectPath(rawValue: "/com/example")! + + #expect(parent.isEqualToOrDescendant(of: parent)) + #expect(DBusObjectPath(rawValue: "/com/example/thing")!.isEqualToOrDescendant(of: parent)) + #expect(!DBusObjectPath(rawValue: "/com")!.isEqualToOrDescendant(of: parent)) + #expect(!DBusObjectPath(rawValue: "/com/other")!.isEqualToOrDescendant(of: parent)) + + // The root is an ancestor of everything. + #expect(parent.isEqualToOrDescendant(of: DBusObjectPath())) } } diff --git a/Tests/DBusTests/SASLTests.swift b/Tests/DBusTests/SASLTests.swift new file mode 100644 index 0000000..d443b07 --- /dev/null +++ b/Tests/DBusTests/SASLTests.swift @@ -0,0 +1,165 @@ +// +// SASLTests.swift +// DBusTests +// + +import Testing +@testable import DBus + +/// The SASL handshake is a pure state machine, so it is driven here without a socket. +@Suite struct SASLTests { + + private func string(_ bytes: [UInt8]) -> String { + + return String(decoding: bytes, as: UTF8.self) + } + + @Test func hexEncoding() { + + // The EXTERNAL credential is the uid as ASCII decimal, hex encoded. + #expect("1000".hexEncodedASCII == "31303030") + #expect("0".hexEncodedASCII == "30") + #expect("".hexEncodedASCII == "") + } + + @Test func externalSuccess() throws { + + var client = DBusSASLClient(userID: 1000) + + let start = client.start() + + // The handshake opens with a single NUL byte before the first command. + #expect(start.first == 0x00) + #expect(string(Array(start.dropFirst())) == "AUTH EXTERNAL 31303030\r\n") + + let afterOK = try client.handle(.ok("1234deadbeef")) + #expect(string(afterOK ?? []) == "NEGOTIATE_UNIX_FD\r\n") + #expect(client.serverGUID == "1234deadbeef") + #expect(!client.isReady) + + let afterAgree = try client.handle(.agreeUnixFD) + #expect(string(afterAgree ?? []) == "BEGIN\r\n") + #expect(client.unixFileDescriptorsSupported) + #expect(client.isReady) + } + + @Test func unixFileDescriptorRefusalIsNotFatal() throws { + + var client = DBusSASLClient(userID: 1000) + _ = client.start() + _ = try client.handle(.ok("guid")) + + // A server without fd support answers ERROR; the handshake still completes. + let afterError = try client.handle(.error("not supported")) + #expect(string(afterError ?? []) == "BEGIN\r\n") + #expect(!client.unixFileDescriptorsSupported) + #expect(client.isReady) + } + + @Test func fallsBackToAnonymous() throws { + + var client = DBusSASLClient(mechanisms: [.external, .anonymous], userID: 1000) + _ = client.start() + + let next = try client.handle(.rejected(["ANONYMOUS", "DBUS_COOKIE_SHA1"])) + #expect(string(next ?? []).hasPrefix("AUTH ANONYMOUS ")) + + _ = try client.handle(.ok("guid")) + _ = try client.handle(.agreeUnixFD) + #expect(client.isReady) + } + + @Test func rejectsWhenNoMechanismIsShared() throws { + + var client = DBusSASLClient(mechanisms: [.external], userID: 1000) + _ = client.start() + + let error = try #require(throws: DBusProtocolError.self) { + try client.handle(.rejected(["DBUS_COOKIE_SHA1"])) + } + + guard case .authenticationRejected = error + else { Issue.record("Wrong error \(error)"); return } + + #expect(client.state == .failed) + } + + @Test func skipsMechanismTheServerDoesNotOffer() throws { + + var client = DBusSASLClient(mechanisms: [.external, .anonymous], userID: 0) + _ = client.start() + + // The server offers only EXTERNAL, which just failed, so there is nothing left. + #expect(throws: (any Error).self) { try client.handle(.rejected(["EXTERNAL"])) } + } + + @Test func errorDuringAuthenticationFails() throws { + + var client = DBusSASLClient(userID: 1000) + _ = client.start() + + #expect(throws: (any Error).self) { try client.handle(.error("go away")) } + #expect(client.state == .failed) + } + + @Test func withoutFileDescriptorNegotiation() throws { + + var client = DBusSASLClient(userID: 1000, negotiateUnixFileDescriptors: false) + _ = client.start() + + let afterOK = try client.handle(.ok("guid")) + #expect(string(afterOK ?? []) == "BEGIN\r\n") + #expect(client.isReady) + } + + // MARK: - Response parsing + + @Test func responseParsing() throws { + + #expect(try DBusSASLResponse(line: "OK 1234") == .ok("1234")) + #expect(try DBusSASLResponse(line: "AGREE_UNIX_FD") == .agreeUnixFD) + #expect(try DBusSASLResponse(line: "REJECTED EXTERNAL ANONYMOUS") + == .rejected(["EXTERNAL", "ANONYMOUS"])) + #expect(try DBusSASLResponse(line: "ERROR some text here") == .error("some text here")) + #expect(try DBusSASLResponse(line: "DATA cafe") == .data("cafe")) + + #expect(throws: (any Error).self) { try DBusSASLResponse(line: "") } + #expect(throws: (any Error).self) { try DBusSASLResponse(line: "NONSENSE") } + } + + // MARK: - Line buffer + + @Test func lineBuffer() throws { + + var buffer = DBusSASLLineBuffer() + + // A line split across two reads. + buffer.append(Array("OK 12".utf8)) + #expect(try buffer.next() == nil) + + buffer.append(Array("34\r\nAGREE_UNIX_FD\r\n".utf8)) + #expect(try buffer.next() == "OK 1234") + #expect(try buffer.next() == "AGREE_UNIX_FD") + #expect(try buffer.next() == nil) + #expect(buffer.remainder.isEmpty) + } + + /// Bytes after the final CRLF are the start of the message stream, not part of the handshake. + @Test func lineBufferKeepsRemainder() throws { + + var buffer = DBusSASLLineBuffer() + buffer.append(Array("OK 1234\r\n".utf8) + [0x6C, 0x01, 0x00, 0x01]) + + #expect(try buffer.next() == "OK 1234") + #expect(try buffer.next() == nil) + #expect(buffer.remainder == [0x6C, 0x01, 0x00, 0x01]) + } + + @Test func lineBufferRejectsOverlongLine() { + + var buffer = DBusSASLLineBuffer() + buffer.append(Array(repeating: 0x41, count: DBusSASLLineBuffer.maximumLineLength + 1)) + + #expect(throws: (any Error).self) { try buffer.next() } + } +} diff --git a/Tests/DBusTests/ServerTests.swift b/Tests/DBusTests/ServerTests.swift new file mode 100644 index 0000000..4b4ba19 --- /dev/null +++ b/Tests/DBusTests/ServerTests.swift @@ -0,0 +1,422 @@ +// +// ServerTests.swift +// DBusTests +// + +import Testing +@testable import DBus + +/// Mutable state for the exported test object, kept in an actor so the handler closures are +/// `Sendable`. +private actor TestState { + + var greeting = "hello" + var counter: UInt32 = 0 + + func setGreeting(_ value: String) { greeting = value } + func increment() -> UInt32 { counter += 1; return counter } +} + +// MARK: - Introspection XML + +/// These need no bus and so run in parallel with everything else. +@Suite struct IntrospectionTests { + + private let testInterface = DBusInterface(rawValue: "com.example.TestObject")! + + @Test func emptyNode() { + + let xml = DBusIntrospection.xml(for: nil) + + #expect(xml.hasPrefix("")) + #expect(xml.contains("")) + // Properties is only advertised for a real object. + #expect(!xml.contains("org.freedesktop.DBus.Properties")) + #expect(xml.hasSuffix("\n")) + } + + @Test func objectNode() { + + let implementation = DBusInterfaceImplementation( + name: testInterface, + methods: [ + .init(name: DBusMember(rawValue: "Echo")!, + inputSignature: DBusSignature(rawValue: "s")!, + outputSignature: DBusSignature(rawValue: "s")!, + inputNames: ["input"], + outputNames: ["output"], + handler: { _ in [] }), + .init(name: DBusMember(rawValue: "Reset")!, handler: { _ in [] }) + ], + properties: [ + .init(name: "Greeting", type: .string, access: .readwrite, get: { .string("") }, set: { _ in }), + .init(name: "Counter", type: .uint32, access: .read, get: { .uint32(0) }) + ], + signals: [ + .init(name: DBusMember(rawValue: "Bounced")!, + signature: DBusSignature(rawValue: "su")!, + argumentNames: ["text", "count"]) + ] + ) + + let xml = DBusIntrospection.xml(for: DBusExportedObject([implementation]), children: ["child"]) + + #expect(xml.contains(""), "\(xml)") + #expect(xml.contains(""), "\(xml)") + #expect(xml.contains(""), "\(xml)") + #expect(xml.contains(""), "\(xml)") + #expect(xml.contains(""), "A method with no arguments is self-closing") + #expect(xml.contains(""), "\(xml)") + #expect(xml.contains(""), "\(xml)") + #expect(xml.contains(""), "\(xml)") + #expect(xml.contains(""), "Signal arguments have no direction") + #expect(xml.contains(""), "\(xml)") + #expect(xml.contains("org.freedesktop.DBus.Properties"), "\(xml)") + } + + @Test func escapesXML() { + + #expect(DBusIntrospection.escape("ac&d\"e'f") == "a<b>c&d"e'f") + } + + @Test func isDeterministic() { + + let implementation = DBusInterfaceImplementation( + name: testInterface, + methods: (0 ..< 10).map { index in + .init(name: DBusMember(rawValue: "Method\(index)")!, handler: { _ in [] }) + } + ) + + let object = DBusExportedObject([implementation]) + + #expect(DBusIntrospection.xml(for: object) == DBusIntrospection.xml(for: object)) + } + + @Test func machineIDIsReadable() throws { + + // Present on any system with a working D-Bus installation. + guard let machineID = MachineID.current else { return } + + #expect(machineID.count == 32, "Expected a 32 character hex UUID, got '\(machineID)'") + #expect(!machineID.contains("\n")) + } +} + +// MARK: - Live + +@Suite(.serialized, .enabled(if: hasSessionBus, "No session bus is available")) +struct ServerTests { + + private let testInterface = DBusInterface(rawValue: "com.example.TestObject")! + private let testPath = DBusObjectPath(rawValue: "/com/example/TestObject")! + + /// Build the exported test object and register it on the connection. + private func export(on connection: DBusConnection, state: TestState) async { + + let implementation = DBusInterfaceImplementation( + name: testInterface, + methods: [ + .init(name: DBusMember(rawValue: "Echo")!, + inputSignature: DBusSignature(rawValue: "s")!, + outputSignature: DBusSignature(rawValue: "s")!, + handler: { call in + guard case let .string(text)? = call.arguments.first + else { throw DBusError(name: .invalidArguments, message: "Expected a string") } + return [.string(text)] + }), + .init(name: DBusMember(rawValue: "Increment")!, + outputSignature: DBusSignature(rawValue: "u")!, + handler: { _ in [.uint32(await state.increment())] }), + .init(name: DBusMember(rawValue: "Fail")!, + handler: { _ in + throw DBusError(name: .notSupported, message: "Deliberate failure") + }), + .init(name: DBusMember(rawValue: "Throw")!, + handler: { _ in + // A non-DBusError must become org.freedesktop.DBus.Error.Failed. + throw DBusProtocolError.endOfStream + }) + ], + properties: [ + .init(name: "Greeting", type: .string, access: .readwrite, + get: { .string(await state.greeting) }, + set: { value in + guard case let .string(text) = value + else { throw DBusError(name: .invalidArguments, message: "Expected a string") } + await state.setGreeting(text) + }), + .init(name: "ReadOnly", type: .uint32, access: .read, get: { .uint32(42) }) + ], + signals: [ + .init(name: DBusMember(rawValue: "Bounced")!, signature: DBusSignature(rawValue: "s")!) + ] + ) + + await connection.export(DBusExportedObject([implementation]), at: testPath) + } + + /// Run `body` with a server connection exporting the test object, plus a client connection. + private func withServerAndClient( + _ body: (DBusConnection, DBusConnection, DBusBusName) async throws -> Void + ) async throws { + + try await withConnections { server, client in + + await export(on: server, state: TestState()) + + let name = try #require(await server.uniqueName) + + try await body(server, client, name) + } + } + + private func call(_ client: DBusConnection, + _ name: DBusBusName, + _ method: String, + arguments: [DBusMessageArgument] = []) async throws -> [DBusMessageArgument] { + + return try await client.callMethod(destination: name, + path: testPath, + interface: testInterface, + method: DBusMember(rawValue: method)!, + arguments: arguments) + } + + // MARK: Method dispatch + + @Test func callExportedMethod() async throws { + + try await withServerAndClient { _, client, name in + + let reply = try await call(client, name, "Echo", arguments: [.string("round trip")]) + #expect(reply.first?.stringValue == "round trip") + } + } + + @Test func exportedMethodStateIsPreserved() async throws { + + try await withServerAndClient { _, client, name in + + var values = [UInt32]() + + for _ in 0 ..< 3 { + let reply = try await call(client, name, "Increment") + guard case let .uint32(value)? = reply.first + else { Issue.record("Expected a uint32, got \(reply)"); return } + values.append(value) + } + + #expect(values == [1, 2, 3]) + } + } + + @Test func handlerErrorBecomesErrorReply() async throws { + + try await withServerAndClient { _, client, name in + + let thrown = await #expect(throws: DBusError.self) { + try await call(client, name, "Fail") + } + + #expect(thrown?.name == .notSupported) + #expect(thrown?.message == "Deliberate failure") + + // A thrown error that is not a DBusError is reported as Failed. + let generic = await #expect(throws: DBusError.self) { + try await call(client, name, "Throw") + } + + #expect(generic?.name == .failed) + } + } + + @Test func unknownMemberAndPathErrors() async throws { + + try await withServerAndClient { _, client, name in + + let unknownMethod = await #expect(throws: DBusError.self) { + try await call(client, name, "Nope") + } + #expect(unknownMethod?.name == .unknownMethod) + + let unknownInterface = await #expect(throws: DBusError.self) { + try await client.callMethod(destination: name, path: testPath, + interface: DBusInterface(rawValue: "com.example.Missing")!, + method: DBusMember(rawValue: "Echo")!) + } + #expect(unknownInterface?.name == .unknownInterface) + + let unknownObject = await #expect(throws: DBusError.self) { + try await client.callMethod(destination: name, + path: DBusObjectPath(rawValue: "/com/example/Missing")!, + interface: testInterface, + method: DBusMember(rawValue: "Echo")!) + } + #expect(unknownObject?.name == .unknownObject) + } + } + + @Test func wrongArgumentSignatureIsRejected() async throws { + + try await withServerAndClient { _, client, name in + + let error = await #expect(throws: DBusError.self) { + try await call(client, name, "Echo", arguments: [.int32(5)]) + } + + #expect(error?.name == .invalidArguments) + } + } + + // MARK: Standard interfaces, served by us + + @Test func peerPing() async throws { + + try await withServerAndClient { _, client, name in + + try await client.ping(destination: name, path: testPath) + + // Ping is answered even at a path with no exported object. + try await client.ping(destination: name, path: DBusObjectPath(rawValue: "/anything")!) + } + } + + @Test func peerGetMachineID() async throws { + + try await withServerAndClient { _, client, name in + + let reply = try await client.callMethod(destination: name, path: testPath, + interface: DBusWellKnown.peerInterface, + method: DBusMember(rawValue: "GetMachineId")!) + + #expect(reply.first?.stringValue?.count == 32) + } + } + + @Test func introspectOverTheBus() async throws { + + try await withServerAndClient { _, client, name in + + let xml = try await client.introspect(destination: name, path: testPath) + + #expect(xml.contains("com.example.TestObject"), "\(xml)") + #expect(xml.contains(""), "\(xml)") + #expect(xml.contains(""), "\(xml)") + #expect(xml.contains(""), "\(xml)") + } + } + + @Test func propertiesGetSetGetAll() async throws { + + try await withServerAndClient { _, client, name in + + let initial = try await client.getProperty(destination: name, path: testPath, + interface: testInterface, name: "Greeting") + #expect(initial == .string("hello")) + + try await client.setProperty(destination: name, path: testPath, + interface: testInterface, name: "Greeting", + value: .string("goodbye")) + + let updated = try await client.getProperty(destination: name, path: testPath, + interface: testInterface, name: "Greeting") + #expect(updated == .string("goodbye")) + + let all = try await client.getAllProperties(destination: name, path: testPath, + interface: testInterface) + #expect(all["Greeting"] == .string("goodbye")) + #expect(all["ReadOnly"] == .uint32(42)) + } + } + + @Test func writingReadOnlyPropertyFails() async throws { + + try await withServerAndClient { _, client, name in + + let error = await #expect(throws: DBusError.self) { + try await client.setProperty(destination: name, path: testPath, + interface: testInterface, name: "ReadOnly", + value: .uint32(1)) + } + + #expect(error?.name == .propertyReadOnly) + } + } + + @Test func settingPropertyWithWrongTypeFails() async throws { + + try await withServerAndClient { _, client, name in + + let error = await #expect(throws: DBusError.self) { + try await client.setProperty(destination: name, path: testPath, + interface: testInterface, name: "Greeting", + value: .int32(5)) + } + + #expect(error?.name == .invalidArguments) + } + } + + @Test func unknownPropertyFails() async throws { + + try await withServerAndClient { _, client, name in + + let error = await #expect(throws: DBusError.self) { + try await client.getProperty(destination: name, path: testPath, + interface: testInterface, name: "Missing") + } + + #expect(error?.name == .unknownProperty) + } + } + + /// The bus daemon's own introspection must parse too, which checks we can be a client of a + /// reference server as well as a server ourselves. + @Test func introspectTheBusDaemon() async throws { + + try await withConnection { client in + + let xml = try await client.introspect(destination: DBusWellKnown.busName, + path: DBusWellKnown.busPath) + + #expect(xml.contains("org.freedesktop.DBus"), "\(xml)") + #expect(xml.contains(""), "\(xml)") + } + } + + // MARK: Object tree + + @Test func childNodesAppearInIntrospection() async throws { + + try await withConnection { connection in + + let root = DBusObjectPath(rawValue: "/com/example")! + let child = DBusObjectPath(rawValue: "/com/example/child")! + let grandchild = DBusObjectPath(rawValue: "/com/example/child/deep")! + + await connection.export(DBusExportedObject(), at: root) + await connection.export(DBusExportedObject(), at: child) + await connection.export(DBusExportedObject(), at: grandchild) + + #expect(await connection.childNodeNames(of: root) == ["child"], "Only direct children") + #expect(await connection.childNodeNames(of: child) == ["deep"]) + #expect(await connection.childNodeNames(of: grandchild) == []) + } + } + + @Test func unexport() async throws { + + try await withConnection { connection in + + await export(on: connection, state: TestState()) + + #expect(await connection.exportedObject(at: testPath) != nil) + + await connection.unexport(at: testPath) + + #expect(await connection.exportedObject(at: testPath) == nil) + } + } +} diff --git a/Tests/DBusTests/SignalTests.swift b/Tests/DBusTests/SignalTests.swift new file mode 100644 index 0000000..d014f74 --- /dev/null +++ b/Tests/DBusTests/SignalTests.swift @@ -0,0 +1,229 @@ +// +// SignalTests.swift +// DBusTests +// + +import Testing +@testable import DBus + +/// Live tests for match rules and signal delivery. +/// +/// A connection receives no broadcast signals until it installs a match rule, so these exercise +/// `AddMatch`, the bus's routing, and our local demultiplexing between subscriptions. +@Suite(.serialized, .enabled(if: hasSessionBus, "No session bus is available")) +struct SignalTests { + + /// Await the next signal satisfying `predicate`, or fail after a timeout. + /// + /// The bus can deliver unrelated traffic that the rule also matches, so tests filter rather + /// than assume the first message is the one they want. + private func next(from stream: AsyncStream, + timeout: Duration = .seconds(10), + where predicate: @escaping @Sendable (DBusMessage) -> Bool) + async throws -> DBusMessage? { + + return try await withThrowingTaskGroup(of: DBusMessage?.self) { group in + + group.addTask { + for await message in stream where predicate(message) { + return message + } + return nil + } + + group.addTask { + try await Task.sleep(for: timeout) + return nil + } + + let result = try await group.next() ?? nil + group.cancelAll() + return result + } + } + + /// Requesting a well known name makes the bus broadcast `NameOwnerChanged`, which is the + /// simplest signal to provoke on demand. + @Test func receivesNameOwnerChanged() async throws { + + let wellKnownName = DBusBusName(rawValue: "com.example.SwiftDBusSignalTest")! + + try await withConnections { observer, owner in + + let stream = try await observer.signals(matching: .nameOwnerChanged(name: wellKnownName)) + + let ownerName = try #require(await owner.uniqueName) + + let result = try await owner.requestName(wellKnownName) + #expect(result == .primaryOwner) + + let signal = try await next(from: stream) { message in + message.arguments.first?.stringValue == wellKnownName.rawValue + } + + let received = try #require(signal, "No NameOwnerChanged arrived") + + #expect(received.member?.rawValue == "NameOwnerChanged") + #expect(received.interface == DBusWellKnown.busInterface) + #expect(received.arguments.count == 3) + + // Arguments are (name, old owner, new owner); the new owner is the requester. + #expect(received.arguments[0].stringValue == wellKnownName.rawValue) + #expect(received.arguments[1].stringValue == "", "There was no previous owner") + #expect(received.arguments[2].stringValue == ownerName.rawValue) + + let release = try await owner.releaseName(wellKnownName) + #expect(release == .released) + } + } + + /// A signal emitted by an exported object must reach a subscriber on another connection. + @Test func receivesSignalFromExportedObject() async throws { + + let path = DBusObjectPath(rawValue: "/com/example/Emitter")! + let interface = DBusInterface(rawValue: "com.example.Emitter")! + let member = DBusMember(rawValue: "Pinged")! + + try await withConnections { emitter, observer in + + let stream = try await observer.signals(interface: interface, member: member, path: path) + + try await emitter.emit(DBusMessage.Signal(path: path, interface: interface, name: member), + arguments: [.string("payload"), .uint32(7)]) + + let signal = try await next(from: stream) { _ in true } + let received = try #require(signal, "No signal arrived") + + #expect(received.type == .signal) + #expect(received.path == path) + #expect(received.interface == interface) + #expect(received.member == member) + #expect(received.arguments == [.string("payload"), .uint32(7)]) + #expect(received.sender == (await emitter.uniqueName)) + } + } + + /// `PropertiesChanged` carries `a{sv}`, so this checks a real dictionary makes the trip. + @Test func receivesPropertiesChanged() async throws { + + let path = DBusObjectPath(rawValue: "/com/example/Props")! + let interface = DBusInterface(rawValue: "com.example.Props")! + + try await withConnections { emitter, observer in + + let stream = try await observer.signals(matching: .propertiesChanged(interface: interface, + path: path)) + + try await emitter.emitPropertiesChanged(at: path, + interface: interface, + changed: ["Greeting": .string("hi"), + "Count": .uint32(3)], + invalidated: ["Stale"]) + + let signal = try await next(from: stream) { _ in true } + let received = try #require(signal, "No PropertiesChanged arrived") + + #expect(received.signature.rawValue == "sa{sv}as") + #expect(received.arguments[0].stringValue == interface.rawValue) + + guard case let .dictionary(changed) = received.arguments[1] + else { Issue.record("Expected a{sv}, got \(received.arguments[1])"); return } + + var values = [String: DBusMessageArgument]() + for entry in changed { + if let key = entry.key.stringValue, let value = entry.value.variantValue { + values[key] = value + } + } + + #expect(values["Greeting"] == .string("hi")) + #expect(values["Count"] == .uint32(3)) + + guard case let .array(invalidated) = received.arguments[2] + else { Issue.record("Expected as, got \(received.arguments[2])"); return } + + #expect(invalidated.compactMap { $0.stringValue } == ["Stale"]) + } + } + + /// Two subscriptions on one connection must each receive only what their own rule matches. + @Test func subscriptionsAreDemultiplexed() async throws { + + let path = DBusObjectPath(rawValue: "/com/example/Multi")! + let interface = DBusInterface(rawValue: "com.example.Multi")! + let first = DBusMember(rawValue: "First")! + let second = DBusMember(rawValue: "Second")! + + try await withConnections { emitter, observer in + + let firstStream = try await observer.signals(interface: interface, member: first, path: path) + let secondStream = try await observer.signals(interface: interface, member: second, path: path) + + #expect(await observer.subscriptionCount == 2) + + try await emitter.emit(DBusMessage.Signal(path: path, interface: interface, name: second), + arguments: [.string("to second")]) + + // The second stream gets it; the first must not. + let received = try #require(try await next(from: secondStream) { _ in true }) + #expect(received.member == second) + #expect(received.arguments.first?.stringValue == "to second") + + let leaked = try await next(from: firstStream, timeout: .seconds(1)) { _ in true } + #expect(leaked == nil, "A signal leaked into the wrong subscription") + } + } + + /// Ending a subscription must remove its match rule, and must not disturb another + /// subscription that shares the same rule. + @Test func endingSubscriptionRemovesMatch() async throws { + + let rule = DBusMatchRule.nameOwnerChanged() + + try await withConnection { connection in + + do { + let stream = try await connection.signals(matching: rule) + #expect(await connection.subscriptionCount == 1) + #expect(await connection.matchRuleCounts[rule.rawValue] == 1) + + // A second subscription on the same rule shares the bus-side match. + let shared = try await connection.signals(matching: rule) + #expect(await connection.subscriptionCount == 2) + #expect(await connection.matchRuleCounts[rule.rawValue] == 2) + + _ = stream + _ = shared + } + + // Dropping both streams tears the subscriptions down asynchronously. + var attempts = 0 + while await connection.subscriptionCount > 0, attempts < 50 { + try await Task.sleep(for: .milliseconds(100)) + attempts += 1 + } + + #expect(await connection.subscriptionCount == 0, "Subscriptions were not cleaned up") + #expect(await connection.matchRuleCounts[rule.rawValue] == nil, + "The match rule reference count was not cleared") + } + } + + /// A rule the bus rejects must surface as an error from `signals(matching:)`, not as a + /// stream that silently never yields. + @Test func invalidMatchRuleThrows() async throws { + + try await withConnection { connection in + + // arg64 is beyond the maximum index the specification allows. + var rule = DBusMatchRule(type: .signal) + rule.arguments = [64: "too far"] + + await #expect(throws: DBusError.self) { + try await connection.signals(matching: rule) + } + + #expect(await connection.subscriptionCount == 0, "A failed AddMatch must not leave a subscription") + } + } +} diff --git a/Tests/DBusTests/SignatureTests.swift b/Tests/DBusTests/SignatureTests.swift index 6d3818d..bc4e92c 100644 --- a/Tests/DBusTests/SignatureTests.swift +++ b/Tests/DBusTests/SignatureTests.swift @@ -5,91 +5,147 @@ // Created by Alsey Coleman Miller on 10/22/18. // -import Foundation -import XCTest +import Testing @testable import DBus -final class SignatureTests: XCTestCase { - - static let allTests: [(String, (SignatureTests) -> () -> Void)] = [ - ("testInvalid", testInvalid), - ("testValid", testValid) - ] - - func testInvalid() { - - let strings = [ - "aa", - "(ii", - "ii)", - //"()", - "a", - "test", - "(ii)(ii) (ii)", - "{si}", - "a{i}", - "v{i}", - "a{s}", - "a{(i)a}", - "a{vs}" - ] - - for string in strings { - - XCTAssertNil(DBusSignature(rawValue: string), "\(string) should be invalid") - XCTAssertThrowsError(try DBusSignature.validate(string)) - do { try DBusSignature.validate(string) } - catch let error as DBusError { - XCTAssertEqual(error.name, DBusError.Name.invalidSignature) - print("\"\(string)\" is invalid: \(error.message)"); return - } - catch { XCTFail("Invalid error \(error)"); return } - XCTFail("Error expected for \(string)") +@Suite struct SignatureTests { + + @Test(arguments: [ + "aa", + "(ii", + "ii)", + "()", + "a", + "test", + "(ii)(ii) (ii)", + "{si}", + "a{i}", + "v{i}", + "a{s}", + "a{(i)a}", + "a{vs}", // a variant is not a basic type, so it cannot be a dictionary key + "a{av}", + "}", + ")" + ]) + func invalid(string: String) throws { + + #expect(DBusSignature(rawValue: string) == nil, "\(string) should be invalid") + + let error = try #require(throws: DBusError.self) { + try DBusSignature.validate(string) } + + #expect(error.name == .invalidSignature) + } + + /// Declared as an explicitly typed property: as an inline `arguments:` literal the array + /// literals for each signature overwhelm the type checker. + static let validSignatures: [(String, DBusSignature)] = [ + ("", DBusSignature([])), + ("s", DBusSignature([.string])), + ("v", DBusSignature([.variant])), + ("i", DBusSignature([.int32])), + ("ii", DBusSignature([.int32, .int32])), + ("aiai", DBusSignature([.array(.int32), .array(.int32)])), + ("(i)", DBusSignature([.struct([.int32])])), + ("(ii)", DBusSignature([.struct([.int32, .int32])])), + ("(aii)", DBusSignature([.struct([.array(.int32), .int32])])), + ("ai(i)", DBusSignature([.array(.int32), .struct([.int32])])), + ("a(i)", DBusSignature([.array(.struct([.int32]))])), + ("(ii)(ii)", DBusSignature([.struct([.int32, .int32]), .struct([.int32, .int32])])), + ("a{si}", DBusSignature([.dictionary(DBusSignature.DictionaryType(key: .string, value: .int32)!)])), + ("a{is}", DBusSignature([.dictionary(DBusSignature.DictionaryType(key: .int32, value: .string)!)])), + ("a{s(ai)}", DBusSignature([.dictionary(DBusSignature.DictionaryType(key: .string, value: .struct([.array(.int32)]))!)])), + ("a{sai}", DBusSignature([.dictionary(DBusSignature.DictionaryType(key: .string, value: .array(.int32))!)])), + ("a{sv}", DBusSignature([.dictionary(DBusSignature.DictionaryType(key: .string, value: .variant)!)])) + ] + + @Test(arguments: SignatureTests.validSignatures) + func valid(string: String, expected: DBusSignature) throws { + + #expect(throws: Never.self) { try DBusSignature.validate(string) } + + let signature = try #require(DBusSignature(rawValue: string), "Could not parse \(string)") + + #expect(signature == expected) + #expect(signature.rawValue == string) + #expect(signature.string == string) + #expect(signature.elements == expected.elements) + #expect(Array(signature) == Array(expected)) + + // Mutating clears the cached string, so `rawValue` has to rebuild it. + var mutable = signature + mutable.append(.double) + #expect(mutable.string == nil) + #expect(mutable != signature) + #expect(mutable.rawValue != signature.rawValue) + #expect(mutable.elements != signature.elements) + } + + /// The specification caps container nesting at 32 levels for arrays and structs alike. + @Test func rejectsExcessiveNesting() { + + #expect(DBusSignature(rawValue: String(repeating: "a", count: 32) + "i") != nil) + #expect(DBusSignature(rawValue: String(repeating: "a", count: 33) + "i") == nil) + + let deepStruct = String(repeating: "(", count: 33) + "i" + String(repeating: ")", count: 33) + #expect(DBusSignature(rawValue: deepStruct) == nil) + } + + /// The length limit is 255 bytes, not 255 characters. + @Test func rejectsOverlongSignature() { + + #expect(DBusSignature(rawValue: String(repeating: "i", count: 255)) != nil) + #expect(DBusSignature(rawValue: String(repeating: "i", count: 256)) == nil) } - - func testValid() { - - let values: [(String, DBusSignature)] = [ - ("", []), - ("s", [.string]), - ("v", [.variant]), - ("i", [.int32]), - ("ii", [.int32, .int32]), - ("aiai", [.array(.int32), .array(.int32)]), - ("(i)", [.struct([.int32])]), - ("(ii)", [.struct([.int32, .int32])]), - ("(aii)", [.struct([.array(.int32), .int32])]), - ("ai(i)", [.array(.int32), .struct([.int32])]), - ("a(i)", [.array(.struct([.int32]))]), - ("(ii)(ii)", [.struct([.int32, .int32]), .struct([.int32, .int32])]), - ("(ii)(ii)(ii)", [.struct([.int32, .int32]), .struct([.int32, .int32]), .struct([.int32, .int32])]), - ("a{si}", [.dictionary(DBusSignature.DictionaryType(key: .string, value: .int32)!)]), - ("a{is}", [.dictionary(DBusSignature.DictionaryType(key: .int32, value: .string)!)]), - ("a{s(ai)}", [.dictionary(DBusSignature.DictionaryType(key: .string, value: .struct([.array(.int32)]))!)]), - ("a{sai}", [.dictionary(DBusSignature.DictionaryType(key: .string, value: .array(.int32))!)]), - ("a{sv}", [.dictionary(DBusSignature.DictionaryType(key: .string, value: .variant)!)]) + + @Test func basicAndContainerTypes() { + + let basic: [DBusSignature.ValueType] = [ + .byte, .boolean, .int16, .uint16, .int32, .uint32, + .int64, .uint64, .double, .fileDescriptor, .string, .objectPath, .signature ] - - for (string, expectedSignature) in values { - - XCTAssertNoThrow(try DBusSignature.validate(string)) - - guard let signature = DBusSignature(rawValue: string) - else { XCTFail("Could not parse string \(string)"); continue } - - XCTAssertEqual(signature, expectedSignature) - XCTAssertEqual(signature.rawValue, string) - XCTAssertEqual(signature.string, string) - XCTAssertEqual(signature.elements, expectedSignature.elements) - XCTAssertEqual(Array(signature), Array(expectedSignature)) - - var mutable = signature - mutable.append(.double) - XCTAssertNil(mutable.string) - XCTAssertNotEqual(mutable, signature) - XCTAssertNotEqual(mutable.rawValue, signature.rawValue) - XCTAssertNotEqual(mutable.elements, signature.elements) + + for type in basic { + #expect(type.isBasic, "\(type) should be basic") + #expect(!type.isContainer, "\(type) should not be a container") } + + // A variant is written as a single type code but is not basic: its contained type is + // part of the value, which is why it cannot be a dictionary key. + #expect(!DBusSignature.ValueType.variant.isBasic) + #expect(!DBusSignature.ValueType.variant.isContainer) + + for type: DBusSignature.ValueType in [.array(.int32), .struct([.int32])] { + #expect(!type.isBasic) + #expect(type.isContainer) + } + } + + @Test func dictionaryKeyMustBeBasic() { + + #expect(DBusSignature.DictionaryType(key: .string, value: .variant) != nil) + #expect(DBusSignature.DictionaryType(key: .variant, value: .string) == nil) + #expect(DBusSignature.DictionaryType(key: .array(.int32), value: .string) == nil) + #expect(DBusSignature.DictionaryType(key: .struct([.int32]), value: .string) == nil) + } + + @Test func structureAndDictionaryRawValues() throws { + + let structure = try #require(DBusSignature.StructureType(rawValue: "(is)")) + #expect(structure.rawValue == "(is)") + + let dictionary = try #require(DBusSignature.DictionaryType(rawValue: "a{sv}")) + #expect(dictionary.rawValue == "a{sv}") + + // Not a single complete value of the expected kind. + #expect(DBusSignature.StructureType(rawValue: "is") == nil) + #expect(DBusSignature.DictionaryType(rawValue: "(is)") == nil) + } + + @Test func emptyStructureIsRejected() { + + #expect(DBusSignature.StructureType([]) == nil) } } diff --git a/Tests/DBusTests/TCPTests.swift b/Tests/DBusTests/TCPTests.swift new file mode 100644 index 0000000..23b909f --- /dev/null +++ b/Tests/DBusTests/TCPTests.swift @@ -0,0 +1,406 @@ +// +// TCPTests.swift +// DBusTests +// + +import Foundation +#if canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#elseif canImport(Darwin) +import Darwin +#elseif canImport(Bionic) +import Bionic +#endif +import Socket +import Testing +@testable import DBus + +// MARK: - Address parsing + +@Suite struct TCPAddressTests { + + @Test func parsesTCPAddress() throws { + + let address = try DBusAddress.parse("tcp:host=127.0.0.1,port=1234,family=ipv4")[0] + + #expect(address.transport == "tcp") + #expect(address["host"] == "127.0.0.1") + #expect(address["port"] == "1234") + #expect(address["family"] == "ipv4") + #expect(try address.nonce() == nil, "A plain tcp address needs no nonce") + } + + /// A numeric address needs no name service, so this resolves offline. + @Test func resolvesLoopbackIPv4() throws { + + let endpoints = try DBusTCPEndpoint.resolve(host: "127.0.0.1", port: 1234, family: .ipv4) + + #expect(endpoints.count >= 1) + + guard case let .ipv4(address) = endpoints[0] + else { Issue.record("Expected IPv4, got \(endpoints[0])"); return } + + #expect(address.port == 1234, "The port must be in host order") + #expect(address.address.rawValue == "127.0.0.1") + #expect(endpoints[0].description == "127.0.0.1:1234") + } + + @Test func resolvesLoopbackIPv6() throws { + + let endpoints = try DBusTCPEndpoint.resolve(host: "::1", port: 5678, family: .ipv6) + + guard case let .ipv6(address) = endpoints[0] + else { Issue.record("Expected IPv6, got \(endpoints[0])"); return } + + #expect(address.port == 5678) + #expect(endpoints[0].description == "[::1]:5678") + } + + @Test func familyRestrictsResolution() throws { + + // Asking for the wrong family for a numeric address yields nothing. + #expect(throws: (any Error).self) { + try DBusTCPEndpoint.resolve(host: "127.0.0.1", port: 1, family: .ipv6) + } + } + + @Test func rejectsUnresolvableHost() { + + #expect(throws: (any Error).self) { + try DBusTCPEndpoint.resolve(host: "this.host.does.not.exist.invalid", port: 1) + } + } + + @Test(arguments: [ + "tcp:port=1234", // no host + "tcp:host=127.0.0.1", // no port + "tcp:host=127.0.0.1,port=notanumber", + "tcp:host=127.0.0.1,port=99999", // out of UInt16 range + "tcp:host=127.0.0.1,port=1,family=ipx" // unknown family + ]) + func rejectsMalformedTCPAddress(string: String) throws { + + let address = try DBusAddress.parse(string)[0] + + #expect(throws: (any Error).self) { try address.tcpEndpoints() } + } + + @Test func unixAddressIsNotTCP() throws { + + let address = try DBusAddress.parse("unix:path=/run/bus")[0] + + #expect(throws: (any Error).self) { try address.tcpEndpoints() } + } + + @Test func nonceTCPRequiresNonceFile() throws { + + let address = try DBusAddress.parse("nonce-tcp:host=127.0.0.1,port=1")[0] + + #expect(throws: (any Error).self) { try address.nonce() } + } + + @Test func nonceMustBeSixteenBytes() throws { + + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("dbus-nonce-\(UInt32.random(in: 0 ... .max))") + + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let goodPath = directory.appendingPathComponent("good").path + let shortPath = directory.appendingPathComponent("short").path + + let nonce = [UInt8](repeating: 0xAB, count: 16) + try Data(nonce).write(to: URL(fileURLWithPath: goodPath)) + try Data([0x01, 0x02]).write(to: URL(fileURLWithPath: shortPath)) + + let good = try DBusAddress.parse("nonce-tcp:host=127.0.0.1,port=1,noncefile=\(goodPath)")[0] + #expect(try good.nonce() == nonce) + + let short = try DBusAddress.parse("nonce-tcp:host=127.0.0.1,port=1,noncefile=\(shortPath)")[0] + #expect(throws: (any Error).self) { try short.nonce() } + + let missing = try DBusAddress.parse("nonce-tcp:host=127.0.0.1,port=1,noncefile=/nope")[0] + #expect(throws: (any Error).self) { try missing.nonce() } + } +} + +// MARK: - Live + +/// A `dbus-daemon` listening on TCP, started for the duration of a test. +/// +/// Configured for `ANONYMOUS` because `EXTERNAL` cannot work over TCP: there are no peer +/// credentials to read, so this also exercises the mechanism fallback for real. (`dbus-send` +/// cannot talk to this bus at all, because libdbus disables ANONYMOUS on the client side.) +/// +/// The policy needs `eavesdrop="true"` on its allow rules; without it the daemon refuses even +/// `Hello`, and every call times out. +/// +/// - Note: Spawned with `posix_spawn` rather than Foundation's `Process`. `Process` offers only +/// blocking ways to observe the child — `waitUntilExit()` and reading a pipe — and blocking a +/// Swift concurrency cooperative thread from an async test deadlocks the run. Reading the +/// daemon's `--print-address` output is impossible for the same reason: its stdout is block +/// buffered when it is a pipe, so the address never arrives. The port is therefore chosen here +/// and readiness established by connecting. +private final class TCPDaemon { + + let address: String + private let processID: pid_t + private let directory: URL + + /// Where `dbus-daemon` lives, or `nil` if it is not installed. + /// + /// Homebrew installs outside `/usr/bin`, and to a different prefix on Apple Silicon than on + /// Intel, so the location is searched rather than assumed. + private static let executablePath: String? = [ + "/usr/bin/dbus-daemon", + "/opt/homebrew/bin/dbus-daemon", + "/usr/local/bin/dbus-daemon" + ].first { FileManager.default.isExecutableFile(atPath: $0) } + + /// Start a daemon, retrying on a different port if the chosen one is taken. + static func start() async -> TCPDaemon? { + + guard executablePath != nil + else { return nil } + + for _ in 0 ..< 3 { + + guard let daemon = TCPDaemon(port: UInt16.random(in: 30000 ... 60000)) + else { continue } + + if await daemon.waitUntilReady() { + return daemon + } + + daemon.stop() + } + + return nil + } + + private init?(port: UInt16) { + + directory = FileManager.default.temporaryDirectory + .appendingPathComponent("dbus-tcp-\(UInt32.random(in: 0 ... .max))") + + address = "tcp:host=127.0.0.1,port=\(port),family=ipv4" + + let configuration = """ + + + session + tcp:host=127.0.0.1,port=\(port),family=ipv4 + ANONYMOUS + + + + + + + + """ + + let configurationURL = directory.appendingPathComponent("bus.conf") + + do { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try configuration.write(to: configurationURL, atomically: true, encoding: .utf8) + } + catch { return nil } + + guard let executable = TCPDaemon.executablePath, + let pid = TCPDaemon.spawn([ + executable, + "--config-file=\(configurationURL.path)", + "--nofork" + ]) else { + try? FileManager.default.removeItem(at: directory) + return nil + } + + processID = pid + } + + /// Launch a process with stdout and stderr discarded, returning its process ID. + private static func spawn(_ arguments: [String]) -> pid_t? { + + // Darwin typedefs the file actions as an opaque pointer, the other platforms as a struct. + #if canImport(Darwin) + var fileActions: posix_spawn_file_actions_t? + #else + var fileActions = posix_spawn_file_actions_t() + #endif + + posix_spawn_file_actions_init(&fileActions) + defer { posix_spawn_file_actions_destroy(&fileActions) } + + posix_spawn_file_actions_addopen(&fileActions, 1, "/dev/null", O_WRONLY, 0) + posix_spawn_file_actions_addopen(&fileActions, 2, "/dev/null", O_WRONLY, 0) + + var argv: [UnsafeMutablePointer?] = arguments.map { strdup($0) } + argv.append(nil) + defer { argv.forEach { free($0) } } + + // Built from `ProcessInfo` rather than the `environ` global, which Darwin does not + // export to a linked image. + var envp: [UnsafeMutablePointer?] = ProcessInfo.processInfo.environment + .map { strdup("\($0.key)=\($0.value)") } + envp.append(nil) + defer { envp.forEach { free($0) } } + + var pid: pid_t = 0 + let status = posix_spawn(&pid, arguments[0], &fileActions, nil, argv, envp) + + return status == 0 ? pid : nil + } + + /// Poll until the daemon accepts a TCP connection, or give up. + /// + /// - Note: A plain socket connect rather than a full `DBusConnection`, so the probe does + /// not consume a bus connection or run the SASL handshake. Completing and then dropping a + /// real session made the daemon shut down the connection that followed. + private func waitUntilReady() async -> Bool { + + guard case let .ipv4(endpoint)? = try? DBusAddress.parse(address).first?.tcpEndpoints().first + else { return false } + + for _ in 0 ..< 30 { + + if let socket = try? await Socket(IPv4Protocol.tcp) { + + if (try? await socket.connect(to: endpoint)) != nil { + await socket.close() + return true + } + + await socket.close() + } + + try? await Task.sleep(for: .milliseconds(100)) + } + + return false + } + + func stop() { + + // SIGKILL rather than SIGTERM so the reap below cannot block on a slow shutdown. + kill(processID, SIGKILL) + + var status: Int32 = 0 + waitpid(processID, &status, 0) + + try? FileManager.default.removeItem(at: directory) + } +} + +@Suite(.serialized) struct TCPConnectionTests { + + /// Run `body` against a freshly started TCP daemon, or skip if one cannot be started. + /// + /// The daemon is always stopped, including when the body throws, so no process outlives + /// the test. + private func withTCPDaemon(_ body: (String) async throws -> Void) async throws { + + await SocketGate.shared.lock() + + guard let daemon = await TCPDaemon.start() else { + await SocketGate.shared.unlock() + return + } + + do { + try await body(daemon.address) + daemon.stop() + await SocketGate.shared.unlock() + } + catch { + daemon.stop() + await SocketGate.shared.unlock() + throw error + } + } + + @Test func connectsOverTCP() async throws { + + try await withTCPDaemon { address in + + #expect(address.hasPrefix("tcp:"), "Expected a TCP address, got \(address)") + + let connection = try await DBusConnection.connect(to: address) + + let uniqueName = await connection.uniqueName + #expect(uniqueName?.isUnique == true, "Hello did not complete over TCP") + #expect(await connection.serverGUID != nil) + + await connection.close() + } + } + + /// EXTERNAL cannot succeed over TCP, so the client must fall back to ANONYMOUS by itself. + @Test func fallsBackToAnonymousOverTCP() async throws { + + try await withTCPDaemon { address in + + let connection = try await DBusConnection.connect( + to: address, + mechanisms: [.external, .cookieSHA1, .anonymous] + ) + + #expect(await connection.isConnected) + + await connection.close() + } + } + + /// Method calls and replies must work the same over TCP as over a Unix socket, which also + /// checks the marshalling is transport independent. + @Test func callsMethodsOverTCP() async throws { + + try await withTCPDaemon { address in + + let connection = try await DBusConnection.connect(to: address) + + let names = try await connection.listNames() + #expect(names.contains("org.freedesktop.DBus")) + + let ownName = try #require(await connection.uniqueName) + #expect(names.contains(ownName.rawValue)) + + let busID = try await connection.getBusID() + #expect(busID.isEmpty == false) + + await connection.close() + } + } + + /// The daemon's address names a specific resolved endpoint, so this also checks that + /// `endpoints()` produces something connectable. + @Test func resolvesDaemonAddressToEndpoint() async throws { + + try await withTCPDaemon { address in + + let parsed = try DBusAddress.parse(address)[0] + let endpoints = try parsed.tcpEndpoints() + + #expect(endpoints.isEmpty == false) + #expect(endpoints.allSatisfy { $0.port != 0 }) + } + } + + /// Nothing is listening on a closed port, so connecting must fail rather than hang. + @Test func failsOnRefusedConnection() async throws { + + await SocketGate.shared.lock() + + await #expect(throws: (any Error).self) { + try await DBusConnection.connect(to: "tcp:host=127.0.0.1,port=1,family=ipv4") + } + + await SocketGate.shared.unlock() + } +} diff --git a/Tests/DBusTests/TestSupport.swift b/Tests/DBusTests/TestSupport.swift new file mode 100644 index 0000000..bfe7196 --- /dev/null +++ b/Tests/DBusTests/TestSupport.swift @@ -0,0 +1,134 @@ +// +// TestSupport.swift +// DBusTests +// + +import Testing +@testable import DBus + +/// The session bus address, if one is configured for this process. +let sessionBusAddress: String? = ProcessEnvironment.value(for: "DBUS_SESSION_BUS_ADDRESS") + +/// Whether the live tests can run. +/// +/// Used with `.enabled(if:)` so the suite reports the bus tests as skipped, rather than +/// silently passing, on a machine or container without a bus. +let hasSessionBus: Bool = sessionBusAddress != nil + +/// Raised when a helper is used without a bus available. +struct NoSessionBus: Error, CustomStringConvertible { + + var description: String { "DBUS_SESSION_BUS_ADDRESS is not set" } +} + +/// Serializes every test that opens a socket. +/// +/// `.serialized` only orders tests *within* one suite, and Swift Testing runs separate suites +/// concurrently. `Socket` routes all descriptors through a process-wide manager keyed by file +/// descriptor number, so suites opening and closing sockets at the same time can collide as +/// numbers are reused. This gate makes the socket-using tests take turns regardless of suite. +/// +/// - Note: Exposed as `lock`/`unlock` rather than a closure-taking method so that no +/// non-`Sendable` closure or result has to cross the actor boundary. +actor SocketGate { + + static let shared = SocketGate() + + private var isLocked = false + private var waiters: [CheckedContinuation] = [] + + func lock() async { + + while isLocked { + await withCheckedContinuation { waiters.append($0) } + } + + isLocked = true + } + + func unlock() { + + isLocked = false + + if waiters.isEmpty == false { + waiters.removeFirst().resume() + } + } +} + +/// Open a connection to the session bus, run `body`, and always close afterwards. +/// +/// Swift Testing has no asynchronous teardown hook, and `defer { Task { ... } }` would not +/// finish before the next test began, so connection lifetime is scoped by this helper instead. +@discardableResult +func withConnection(_ body: (DBusConnection) async throws -> T) async throws -> T { + + guard let address = sessionBusAddress + else { throw NoSessionBus() } + + await SocketGate.shared.lock() + + let connection: DBusConnection + + do { connection = try await DBusConnection.connect(to: address) } + catch { + await SocketGate.shared.unlock() + throw error + } + + do { + let result = try await body(connection) + await connection.close() + await SocketGate.shared.unlock() + return result + } + catch { + await connection.close() + await SocketGate.shared.unlock() + throw error + } +} + +/// Open two connections, run `body`, and always close both. +@discardableResult +func withConnections(_ body: (DBusConnection, DBusConnection) async throws -> T) async throws -> T { + + guard let address = sessionBusAddress + else { throw NoSessionBus() } + + await SocketGate.shared.lock() + + let first: DBusConnection + let second: DBusConnection + + do { + first = try await DBusConnection.connect(to: address) + } + catch { + await SocketGate.shared.unlock() + throw error + } + + do { + second = try await DBusConnection.connect(to: address) + } + catch { + await first.close() + await SocketGate.shared.unlock() + throw error + } + + do { + let result = try await body(first, second) + await first.close() + await second.close() + await SocketGate.shared.unlock() + return result + } + catch { + await first.close() + await second.close() + await SocketGate.shared.unlock() + throw error + } +} diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift deleted file mode 100755 index 3118593..0000000 --- a/Tests/LinuxMain.swift +++ /dev/null @@ -1,9 +0,0 @@ -import XCTest -@testable import DBusTests - -XCTMain([ - testCase(InterfaceTests.allTests), - testCase(MessageTests.allTests), - testCase(ObjectPathTests.allTests), - testCase(SignatureTests.allTests) - ])