diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..fe8f10f --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,38 @@ +name: Swift Test (Ubuntu 24.04) + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: blacksmith-4vcpu-ubuntu-2404-arm + container: + image: swift:6.2.3-noble + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Cache Swift dependencies + uses: actions/cache@v4 + with: + path: .build + key: ${{ runner.os }}-swift-${{ hashFiles('Package.resolved') }} + restore-keys: | + ${{ runner.os }}-swift- + + - name: Install dependencies + run: | + apt-get update + apt-get install -y dbus-x11 gnome-keyring + + - name: Test with DBus & Secret Service + run: | + # Use dbus-run-session to wrap the test execution + dbus-run-session -- sh -c ' + echo "test" | gnome-keyring-daemon --unlock --components=secrets + RUN_INTEGRATION_TESTS=1 swift test --parallel -Xcc -I/usr/include -Xlinker -L/usr/lib + ' \ No newline at end of file diff --git a/Sources/SecretService/Helper/Array.swift b/Sources/SecretService/Helper/Array.swift index 7858610..3b9ba24 100644 --- a/Sources/SecretService/Helper/Array.swift +++ b/Sources/SecretService/Helper/Array.swift @@ -17,6 +17,24 @@ extension Array where Element == DBusValue { return result } + + /// Tries to convert [DBusValue] to [String] as objectPaths + /// Returns nil if the array contains something not a byte + var asObjectPathArray: [String]? { + var result = [String]() + + for element in self { + guard let byte = element.objectPath else { + print("found unexpected type") + return nil + } + + result.append(byte) + } + + return result + } + } extension Array where Element == UInt8 { diff --git a/Sources/SecretService/Helper/Dictionary.swift b/Sources/SecretService/Helper/Dictionary.swift index a2e297c..8660de6 100644 --- a/Sources/SecretService/Helper/Dictionary.swift +++ b/Sources/SecretService/Helper/Dictionary.swift @@ -11,3 +11,15 @@ extension Dictionary where Key == String, Value == DBusValue { return new } } + +extension Dictionary where Key == String, Value == String { + var asStringToString: [DBusValue: DBusValue] { + var new = [DBusValue: DBusValue]() + + for (key, value) in self { + new[.string(key)] = .string(value) + } + + return new + } +} diff --git a/Sources/SecretService/Helper/MessageDecoding.swift b/Sources/SecretService/Helper/MessageDecoding.swift index 66aa747..8dc4ce8 100644 --- a/Sources/SecretService/Helper/MessageDecoding.swift +++ b/Sources/SecretService/Helper/MessageDecoding.swift @@ -74,4 +74,32 @@ extension DBusMessage { throw .unexpectedResponse(for: "GetSecrets") } } + + func decodeSearchItems() throws(SecSError) -> [String] { + if + case .methodReturn = self.messageType, + body.count >= 1, + let items = body[0].array?.asObjectPathArray + { + return items + } else if case .error = self.messageType { + throw .returnedError(body[0, nil]?.string) + } else { + throw .unexpectedResponse(for: "SearchItems") + } + } + + func decodeDeleteItem() throws(SecSError) -> String? { + if + case .methodReturn = self.messageType, + body.count >= 1, + let prompt = body[0].objectPath + { + return prompt != "/" ? prompt: nil + } else if case .error = self.messageType { + throw .returnedError(body[0, nil]?.string) + } else { + throw .unexpectedResponse(for: "Items.Delete") + } + } } diff --git a/Sources/SecretService/SecretService.swift b/Sources/SecretService/SecretService.swift index 696a50d..09b3355 100644 --- a/Sources/SecretService/SecretService.swift +++ b/Sources/SecretService/SecretService.swift @@ -134,6 +134,43 @@ public final class SecretService: Sendable { return try response.decodeGetSecrets(with: symmetricKey) } + /// Search for items with certain attributes in the collection + public func searchItems( + for attributes: [String: String], + in collection: String + ) async throws(SecSError) -> [String] { + let request = DBusRequest.createMethodCall( + destination: SecS.service, + path: collection, + interface: SecS.Iface.collection, + method: "SearchItems", + body: [ + .dictionary(attributes.asStringToString) + ] + ) + + guard let response = try await send(request) else { throw .noResponse } + + return try response.decodeSearchItems() + } + + /// Deletes an item + /// Returns prompt object or nil if no prompt is necessary + public func deleteItem( + item: String + ) async throws(SecSError) -> String? { + let request = DBusRequest.createMethodCall( + destination: SecS.service, + path: item, + interface: SecS.Iface.item, + method: "Delete", + ) + + guard let response = try await send(request) else { throw .noResponse } + + return try response.decodeDeleteItem() + } + public static func withDefaultConnection( _ block: @escaping @Sendable (DBusServerConnection) async throws -> R ) async throws -> R { @@ -143,7 +180,7 @@ public final class SecretService: Sendable { } /// Sends the request on the current connection and converts errors - private func send(_ request: DBusRequest) async throws(SecretServiceError) -> DBusMessage? { + private func send(_ request: DBusRequest) async throws(SecSError) -> DBusMessage? { do { return try await connection.send(request) } catch { diff --git a/Tests/swift-secret-serviceTests/IntegrationTests/swift_secret_serviceTests.swift b/Tests/swift-secret-serviceTests/IntegrationTests/swift_secret_serviceTests.swift index 6dc37a2..7c5778b 100644 --- a/Tests/swift-secret-serviceTests/IntegrationTests/swift_secret_serviceTests.swift +++ b/Tests/swift-secret-serviceTests/IntegrationTests/swift_secret_serviceTests.swift @@ -5,7 +5,7 @@ import CryptoSwift @testable import SecretService class IntegrationTests { - /// Integration tests should set this attribute as string to true on temporarily created items + /// Integration tests should set this attribute as string to "1" on temporarily created items /// They will be deleted in deinit in the future static let teardownDeleteAttributeName = "swift-secret-service-delete-on-teardown" @@ -29,7 +29,7 @@ class IntegrationTests { } @Test(.enabled(if: ProcessInfo.runIntegrationTests)) - func testCreateReadItem() async throws { + func testCreateReadDeleteItem() async throws { try await SecretService.withDefaultConnection { connection in let service = SecretService(connection: connection) try await service.connect() @@ -45,11 +45,11 @@ class IntegrationTests { "org.freedesktop.Secret.Item.Label": .string("test"), "org.freedesktop.Secret.Item.Attributes": .dictionary([ .string("service"): .string("de.amethystsoft.swift-secret-service.tests"), - .string(Self.teardownDeleteAttributeName): .boolean(true) + .string(Self.teardownDeleteAttributeName): .string("1") ]) ] - let (item, prompt) = try await service.createItem( + let (item, _) = try await service.createItem( secret: Secret(value: secret.bytes), collection: collection, properties: properties @@ -77,6 +77,26 @@ class IntegrationTests { #expect(key == item) #expect(value.value == secret.bytes) + + try await Self.teardown(collection: collection, service: service) + + let itemsAfterDelete = try await service.searchItems( + for: [Self.teardownDeleteAttributeName: "1"], + in: collection + ) + + #expect(itemsAfterDelete.isEmpty) + } + } + + static func teardown(collection: String, service: SecretService) async throws { + let items = try await service.searchItems( + for: [Self.teardownDeleteAttributeName: "1"], + in: collection + ) + + for item in items { + _ = try await service.deleteItem(item: item) } } }