From 39937dee7171481c0d152188a9f5fc768ce95b00 Mon Sep 17 00:00:00 2001 From: Hal Date: Sat, 10 Feb 2024 13:20:39 -0500 Subject: [PATCH] Keychain dependency implementation --- .gitattributes | 2 - .github/workflows/ci.yml | 23 ---- .github/workflows/code-quality.yml | 16 +++ .github/workflows/test.yml | 22 ++++ .swiftlint.yml | 10 +- Package.resolved | 69 ++++++++++++ Package.swift | 27 ++++- README.md | 8 +- Sources/Keychain/InMemoryKeychain.swift | 33 ++++++ Sources/Keychain/Keychain+Dependencies.swift | 19 ++++ Sources/Keychain/Keychain.swift | 13 +++ .../KeychainConfiguration+Dependencies.swift | 20 ++++ Sources/Keychain/KeychainConfiguration.swift | 34 ++++++ Sources/Keychain/ValetKeychain.swift | 106 ++++++++++++++++++ Sources/SwiftPackage/SwiftPackage.swift | 1 - Tests/KeychainTests/KeychainTests.swift | 20 ++++ Tests/SwiftPackageTests/Tests.swift | 6 - 17 files changed, 379 insertions(+), 50 deletions(-) delete mode 100644 .gitattributes delete mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/code-quality.yml create mode 100644 .github/workflows/test.yml create mode 100644 Package.resolved create mode 100644 Sources/Keychain/InMemoryKeychain.swift create mode 100644 Sources/Keychain/Keychain+Dependencies.swift create mode 100644 Sources/Keychain/Keychain.swift create mode 100644 Sources/Keychain/KeychainConfiguration+Dependencies.swift create mode 100644 Sources/Keychain/KeychainConfiguration.swift create mode 100644 Sources/Keychain/ValetKeychain.swift delete mode 100644 Sources/SwiftPackage/SwiftPackage.swift create mode 100644 Tests/KeychainTests/KeychainTests.swift delete mode 100644 Tests/SwiftPackageTests/Tests.swift diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index ebbc625..0000000 --- a/.gitattributes +++ /dev/null @@ -1,2 +0,0 @@ -* text=auto -*.podspec linguist-vendored diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 5998d8c..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Lint & Test - -on: - pull_request: - -jobs: - SwiftLint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: SwiftLint - uses: norio-nomura/action-swiftlint@3.2.1 - Test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Cache Swift build - uses: actions/cache@v3 - with: - path: .build - key: ${{ runner.os }}-build - - name: Run Swift tests - run: swift test diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml new file mode 100644 index 0000000..824d2af --- /dev/null +++ b/.github/workflows/code-quality.yml @@ -0,0 +1,16 @@ +name: Code Quality + +on: + workflow_dispatch: + pull_request: + +jobs: + SwiftLint: + name: Swift files are formatted + runs-on: self-hosted + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - name: SwiftLint + run: swiftlint --quiet --reporter github-actions-logging diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..e6e33c4 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,22 @@ +name: Test + +on: + workflow_dispatch: + pull_request: + +jobs: + Test: + name: Unit tests pass + runs-on: self-hosted + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - id: cache + uses: actions/cache@v4 + with: + path: | + .build + .swiftpm + key: ${{ runner.os }}-cache + - run: swift test diff --git a/.swiftlint.yml b/.swiftlint.yml index 7affe1f..0c1cb10 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,11 +1,9 @@ disabled_rules: - - closure_parameter_position - identifier_name - multiple_closures_with_trailing_closure + - todo opt_in_rules: - - attributes - closure_end_indentation - - closure_parameter_position - closure_spacing - contains_over_filter_count - contains_over_filter_is_empty @@ -19,11 +17,9 @@ opt_in_rules: - first_where - flatmap_over_map_reduce - indentation_width - - legacy_random - literal_expression_end_indentation - lower_acl_than_parent - operator_usage_whitespace - - redundant_nil_coalescing - redundant_type_annotation - sorted_first_last - sorted_imports @@ -35,8 +31,8 @@ opt_in_rules: - vertical_parameter_alignment_on_call - yoda_condition excluded: - - .build - - .swiftpm + - "**/.build/" + - "**/.swiftpm/" indentation_width: include_comments: false include_compiler_directives: false diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..20e759a --- /dev/null +++ b/Package.resolved @@ -0,0 +1,69 @@ +{ + "originHash" : "a9b23d82f5250c6c8e985d858107c64aa64534998518eb29a0cdc55f6e978eb8", + "pins" : [ + { + "identity" : "combine-schedulers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/combine-schedulers", + "state" : { + "revision" : "5928286acce13def418ec36d05a001a9641086f2", + "version" : "1.0.3" + } + }, + { + "identity" : "swift-clocks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-clocks", + "state" : { + "revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e", + "version" : "1.0.6" + } + }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "state" : { + "revision" : "82a4ae7170d98d8538ec77238b7eb8e7199ef2e8", + "version" : "1.3.1" + } + }, + { + "identity" : "swift-dependencies", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-dependencies", + "state" : { + "revision" : "fee6aa29908a75437506ddcbe7434c460605b7e6", + "version" : "1.9.1" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax", + "state" : { + "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2", + "version" : "601.0.1" + } + }, + { + "identity" : "valet", + "kind" : "remoteSourceControl", + "location" : "https://github.com/square/Valet", + "state" : { + "revision" : "05c9e514fbd352a6866877ca31326b4e0b7d6d01", + "version" : "5.0.0" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "39de59b2d47f7ef3ca88a039dff3084688fe27f4", + "version" : "1.5.2" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift index 32a7795..f28a2a6 100644 --- a/Package.swift +++ b/Package.swift @@ -1,13 +1,30 @@ -// swift-tools-version:5.9 +// swift-tools-version:6.0 import PackageDescription let package = Package( - name: "SwiftPackage", + name: "keychain", + platforms: [ + .iOS(.v16), + .macOS(.v13), + .tvOS(.v16), + .visionOS(.v1), + .watchOS(.v9) + ], products: [ - .library(name: "SwiftPackage", targets: ["SwiftPackage"]) + .library(name: "Keychain", targets: ["Keychain"]) + ], + dependencies: [ + .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.9.1"), + .package(url: "https://github.com/square/Valet", from: "5.0.0") ], targets: [ - .target(name: "SwiftPackage"), - .testTarget(name: "SwiftPackageTests", dependencies: ["SwiftPackage"]) + .target( + name: "Keychain", + dependencies: [ + .product(name: "Dependencies", package: "swift-dependencies"), + .product(name: "Valet", package: "Valet") + ] + ), + .testTarget(name: "KeychainTests", dependencies: ["Keychain"]) ] ) diff --git a/README.md b/README.md index 9e7c9a8..1d8c1d5 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,3 @@ -# Swift Package Repository Template +# Keychain dependency -This is a project template for Swift packages. - -* Sensible `.gitignore` -* `.swiftlint.yml` for linting -* `.editorconfig` enforcing tabs with a size of 2. +Vends a [dependency](https://github.com/pointfreeco/swift-dependencies) for accessing the keychain diff --git a/Sources/Keychain/InMemoryKeychain.swift b/Sources/Keychain/InMemoryKeychain.swift new file mode 100644 index 0000000..06d0927 --- /dev/null +++ b/Sources/Keychain/InMemoryKeychain.swift @@ -0,0 +1,33 @@ +import Dependencies +import Foundation +import os + +public final class InMemoryKeychain { + + private static let data = OSAllocatedUnfairLock(initialState: [String: Data]()) + + init() {} + + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + +} + +// MARK: Keychain + +extension InMemoryKeychain: Keychain { + + public func load(key: String) throws -> T where T: Decodable { + return try decoder.decode(T.self, from: InMemoryKeychain.data.withLock { $0[key] } ?? Data()) + } + + public func save(key: String, value: T) throws where T: Encodable { + let data = try encoder.encode(value) + InMemoryKeychain.data.withLock { $0[key] = data } + } + + public func delete(key: String) throws { + _ = InMemoryKeychain.data.withLock { $0.removeValue(forKey: key) } + } + +} diff --git a/Sources/Keychain/Keychain+Dependencies.swift b/Sources/Keychain/Keychain+Dependencies.swift new file mode 100644 index 0000000..a31c75f --- /dev/null +++ b/Sources/Keychain/Keychain+Dependencies.swift @@ -0,0 +1,19 @@ +import Dependencies +import Foundation + +extension DependencyValues { + + public var keychain: any Keychain { + get { self[KeychainDependencyKey.self] } + set { self[KeychainDependencyKey.self] = newValue } + } + +} + +public enum KeychainDependencyKey: DependencyKey { + public static let liveValue: any Keychain = ValetKeychain() +} + +extension KeychainDependencyKey: TestDependencyKey { + public static let testValue: any Keychain = InMemoryKeychain() +} diff --git a/Sources/Keychain/Keychain.swift b/Sources/Keychain/Keychain.swift new file mode 100644 index 0000000..3993979 --- /dev/null +++ b/Sources/Keychain/Keychain.swift @@ -0,0 +1,13 @@ +import Foundation + +public protocol Keychain: Sendable { + + @inlinable + func load(key: String) throws -> T where T: Decodable + + @inlinable + func save(key: String, value: T) throws where T: Encodable + + func delete(key: String) throws + +} diff --git a/Sources/Keychain/KeychainConfiguration+Dependencies.swift b/Sources/Keychain/KeychainConfiguration+Dependencies.swift new file mode 100644 index 0000000..451cfa9 --- /dev/null +++ b/Sources/Keychain/KeychainConfiguration+Dependencies.swift @@ -0,0 +1,20 @@ +import Dependencies +import Foundation +import XCTestDynamicOverlay + +extension DependencyValues { + + public var keychainConfiguration: KeychainConfiguration { + get { self[KeychainConfiguration.self] } + set { self[KeychainConfiguration.self] = newValue } + } + +} + +extension KeychainConfiguration: TestDependencyKey { + + public static var testValue: KeychainConfiguration { + KeychainConfiguration(type: .local, identifier: Identifier.identifier("test"), accessibility: .afterFirstUnlock) + } + +} diff --git a/Sources/Keychain/KeychainConfiguration.swift b/Sources/Keychain/KeychainConfiguration.swift new file mode 100644 index 0000000..86d7447 --- /dev/null +++ b/Sources/Keychain/KeychainConfiguration.swift @@ -0,0 +1,34 @@ +import Foundation + +public struct KeychainConfiguration: Hashable, Sendable { + + public let type: Type + public let identifier: Identifier + public let accessibility: Accessibility + + public init( + type: `Type`, + identifier: Identifier, + accessibility: Accessibility + ) { + self.type = type + self.identifier = identifier + self.accessibility = accessibility + } + + public enum `Type`: Hashable, Sendable { + case iCloud + case local + } + + public enum Identifier: Hashable, Sendable { + case identifier(String) + case sharedGroup(appIDPrefix: String, groupIdentifier: String) + } + + public enum Accessibility: Hashable, Sendable { + case whenUnlocked + case afterFirstUnlock + } + +} diff --git a/Sources/Keychain/ValetKeychain.swift b/Sources/Keychain/ValetKeychain.swift new file mode 100644 index 0000000..dd348e8 --- /dev/null +++ b/Sources/Keychain/ValetKeychain.swift @@ -0,0 +1,106 @@ +import Dependencies +import Foundation +import Valet + +struct ValetKeychain { + + @Dependency(\.keychainConfiguration) var configuration + + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + private func valet() throws -> Valet { + switch configuration.type { + case .iCloud: + switch configuration.identifier { + case let .identifier(identifier): + guard let identifier = Identifier(nonEmpty: identifier) else { + throw Error.invalidIdentifier + } + return Valet.iCloudValet( + with: identifier, + accessibility: configuration.accessibility.cloudAccessibility + ) + + case let .sharedGroup(appIDPrefix, groupIdentifier): + guard let identifier = SharedGroupIdentifier(appIDPrefix: appIDPrefix, nonEmptyGroup: groupIdentifier) else { + throw Error.invalidIdentifier + } + return Valet.iCloudSharedGroupValet( + with: identifier, + accessibility: configuration.accessibility.cloudAccessibility + ) + } + + case .local: + switch configuration.identifier { + case let .identifier(identifier): + guard let identifier = Identifier(nonEmpty: identifier) else { + throw Error.invalidIdentifier + } + return Valet.valet( + with: identifier, + accessibility: configuration.accessibility.accessibility + ) + + case let .sharedGroup(appIDPrefix, groupIdentifier): + guard let identifier = SharedGroupIdentifier(appIDPrefix: appIDPrefix, nonEmptyGroup: groupIdentifier) else { + throw Error.invalidIdentifier + } + return Valet.sharedGroupValet( + with: identifier, + accessibility: configuration.accessibility.accessibility + ) + } + } + } + + enum Error: Swift.Error { + case invalidIdentifier + } + +} + +// MARK: Keychain + +extension ValetKeychain: Keychain { + + @inlinable + func load(key: String) throws -> T where T: Decodable { + let data = try valet().object(forKey: key) + return try decoder.decode(T.self, from: data) + } + + @inlinable + func save(key: String, value: T) throws where T: Encodable { + let data = try encoder.encode(value) + try valet().setObject(data, forKey: key) + } + + func delete(key: String) throws { + try valet().removeObject(forKey: key) + } + +} + +extension KeychainConfiguration.Accessibility { + + fileprivate var accessibility: Accessibility { + switch self { + case .afterFirstUnlock: + return .afterFirstUnlock + case .whenUnlocked: + return .whenUnlocked + } + } + + fileprivate var cloudAccessibility: CloudAccessibility { + switch self { + case .afterFirstUnlock: + return .afterFirstUnlock + case .whenUnlocked: + return .whenUnlocked + } + } + +} diff --git a/Sources/SwiftPackage/SwiftPackage.swift b/Sources/SwiftPackage/SwiftPackage.swift deleted file mode 100644 index 8b13789..0000000 --- a/Sources/SwiftPackage/SwiftPackage.swift +++ /dev/null @@ -1 +0,0 @@ - diff --git a/Tests/KeychainTests/KeychainTests.swift b/Tests/KeychainTests/KeychainTests.swift new file mode 100644 index 0000000..c11df96 --- /dev/null +++ b/Tests/KeychainTests/KeychainTests.swift @@ -0,0 +1,20 @@ +@testable import Keychain +import Dependencies +import XCTest + +class KeychainTests: XCTestCase { + + @Dependency(\.keychain) var keychain + + func testInMemoryKeychain() throws { + try withDependencies { + $0.keychain = InMemoryKeychain() + } operation: { + try keychain.save(key: "key", value: "test") + XCTAssertEqual(try keychain.load(key: "key"), "test") + try keychain.delete(key: "key") + XCTAssertEqual(try? keychain.load(key: "key"), Optional.none) + } + } + +} diff --git a/Tests/SwiftPackageTests/Tests.swift b/Tests/SwiftPackageTests/Tests.swift deleted file mode 100644 index 18dc365..0000000 --- a/Tests/SwiftPackageTests/Tests.swift +++ /dev/null @@ -1,6 +0,0 @@ -@testable import SwiftPackage -import XCTest - -class Tests: XCTestCase { - -}