@bota.dev/react-native-app-sdk 2.0.0-beta.1 → 2.0.0-beta.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/README.md +115 -4
  2. package/android/build.gradle +1 -0
  3. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidDevices.kt +22 -0
  4. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidDiagnostics.kt +53 -0
  5. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidLogs.kt +16 -0
  6. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingUploads.kt +144 -14
  7. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordings.kt +150 -29
  8. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKEncryptedUploadV2Materials.kt +27 -0
  9. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKModule.kt +86 -3
  10. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKPackage.kt +9 -0
  11. package/android/src/main/java/dev/bota/sdk/reactnative/upload/BotaUploadV2BackendModule.kt +66 -0
  12. package/android/src/main/java/dev/bota/sdk/reactnative/upload/NativeUpload.kt +400 -0
  13. package/android/src/main/java/dev/bota/sdk/reactnative/upload/UploadHttp.kt +158 -0
  14. package/android/src/main/java/dev/bota/sdk/reactnative/upload/UploadJournal.kt +112 -0
  15. package/android/src/main/java/dev/bota/sdk/reactnative/upload/UploadModels.kt +171 -0
  16. package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidDevicesTest.kt +35 -0
  17. package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidLogsTest.kt +33 -0
  18. package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingUploadsTest.kt +135 -6
  19. package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingsTest.kt +236 -10
  20. package/generated/codegen-contract.json +751 -32
  21. package/ios/BotaDeviceSDK.mm +114 -0
  22. package/ios/BotaDeviceSDKAppleBridge.swift +141 -4
  23. package/ios/BotaDeviceSDKAppleDevices.swift +11 -0
  24. package/ios/BotaDeviceSDKAppleDiagnostics.swift +40 -0
  25. package/ios/BotaDeviceSDKAppleLogs.swift +18 -0
  26. package/ios/BotaDeviceSDKAppleRecordingUploads.swift +138 -6
  27. package/ios/BotaDeviceSDKAppleRecordings.swift +113 -5
  28. package/ios/BotaDeviceSDKEncryptedUploadV2Materials.swift +40 -0
  29. package/ios/BotaUploadV2Backend.mm +49 -0
  30. package/ios/UploadBackend/BotaUploadV2BackendBridge.swift +98 -0
  31. package/ios/UploadBackend/UploadCoordinator.swift +84 -0
  32. package/ios/UploadBackend/UploadHTTP.swift +82 -0
  33. package/ios/UploadBackend/UploadJournal.swift +187 -0
  34. package/ios/UploadBackend/UploadModels.swift +273 -0
  35. package/ios/UploadBackend/UploadOperation.swift +391 -0
  36. package/lib/commonjs/BotaClient.js +16 -2
  37. package/lib/commonjs/BotaClient.js.map +1 -1
  38. package/lib/commonjs/ble/constants.js +5 -1
  39. package/lib/commonjs/ble/constants.js.map +1 -1
  40. package/lib/commonjs/ble/deviceDiagnostics.js +240 -0
  41. package/lib/commonjs/ble/deviceDiagnostics.js.map +1 -0
  42. package/lib/commonjs/client.js +183 -28
  43. package/lib/commonjs/client.js.map +1 -1
  44. package/lib/commonjs/clientPresence.js +26 -0
  45. package/lib/commonjs/clientPresence.js.map +1 -0
  46. package/lib/commonjs/encryptedUploadV2Backend.js +200 -0
  47. package/lib/commonjs/encryptedUploadV2Backend.js.map +1 -0
  48. package/lib/commonjs/index.js +34 -0
  49. package/lib/commonjs/index.js.map +1 -1
  50. package/lib/commonjs/managers/DeviceManager.js +23 -0
  51. package/lib/commonjs/managers/DeviceManager.js.map +1 -1
  52. package/lib/commonjs/managers/NativeUploadQueue.js +386 -0
  53. package/lib/commonjs/managers/NativeUploadQueue.js.map +1 -0
  54. package/lib/commonjs/managers/RecordingManager.js +83 -102
  55. package/lib/commonjs/managers/RecordingManager.js.map +1 -1
  56. package/lib/commonjs/managers/uploadRecoveryMetadata.js +83 -0
  57. package/lib/commonjs/managers/uploadRecoveryMetadata.js.map +1 -0
  58. package/lib/commonjs/sdkIdentity.js +10 -0
  59. package/lib/commonjs/sdkIdentity.js.map +1 -0
  60. package/lib/commonjs/specs/NativeBotaDeviceSDK.js.map +1 -1
  61. package/lib/commonjs/specs/NativeBotaUploadV2Backend.js +11 -0
  62. package/lib/commonjs/specs/NativeBotaUploadV2Backend.js.map +1 -0
  63. package/lib/commonjs/uploadRecovery.js +68 -0
  64. package/lib/commonjs/uploadRecovery.js.map +1 -0
  65. package/lib/module/BotaClient.js +16 -2
  66. package/lib/module/BotaClient.js.map +1 -1
  67. package/lib/module/ble/constants.js +4 -0
  68. package/lib/module/ble/constants.js.map +1 -1
  69. package/lib/module/ble/deviceDiagnostics.js +234 -0
  70. package/lib/module/ble/deviceDiagnostics.js.map +1 -0
  71. package/lib/module/client.js +183 -28
  72. package/lib/module/client.js.map +1 -1
  73. package/lib/module/clientPresence.js +22 -0
  74. package/lib/module/clientPresence.js.map +1 -0
  75. package/lib/module/encryptedUploadV2Backend.js +196 -0
  76. package/lib/module/encryptedUploadV2Backend.js.map +1 -0
  77. package/lib/module/index.js +7 -0
  78. package/lib/module/index.js.map +1 -1
  79. package/lib/module/managers/DeviceManager.js +23 -0
  80. package/lib/module/managers/DeviceManager.js.map +1 -1
  81. package/lib/module/managers/NativeUploadQueue.js +380 -0
  82. package/lib/module/managers/NativeUploadQueue.js.map +1 -0
  83. package/lib/module/managers/RecordingManager.js +83 -102
  84. package/lib/module/managers/RecordingManager.js.map +1 -1
  85. package/lib/module/managers/uploadRecoveryMetadata.js +77 -0
  86. package/lib/module/managers/uploadRecoveryMetadata.js.map +1 -0
  87. package/lib/module/sdkIdentity.js +6 -0
  88. package/lib/module/sdkIdentity.js.map +1 -0
  89. package/lib/module/specs/NativeBotaDeviceSDK.js.map +1 -1
  90. package/lib/module/specs/NativeBotaUploadV2Backend.js +9 -0
  91. package/lib/module/specs/NativeBotaUploadV2Backend.js.map +1 -0
  92. package/lib/module/uploadRecovery.js +64 -0
  93. package/lib/module/uploadRecovery.js.map +1 -0
  94. package/lib/typescript/BotaClient.d.ts +2 -0
  95. package/lib/typescript/BotaClient.d.ts.map +1 -1
  96. package/lib/typescript/ble/constants.d.ts +4 -0
  97. package/lib/typescript/ble/constants.d.ts.map +1 -1
  98. package/lib/typescript/ble/deviceDiagnostics.d.ts +10 -0
  99. package/lib/typescript/ble/deviceDiagnostics.d.ts.map +1 -0
  100. package/lib/typescript/client.d.ts +24 -3
  101. package/lib/typescript/client.d.ts.map +1 -1
  102. package/lib/typescript/clientPresence.d.ts +26 -0
  103. package/lib/typescript/clientPresence.d.ts.map +1 -0
  104. package/lib/typescript/encryptedUploadV2Backend.d.ts +41 -0
  105. package/lib/typescript/encryptedUploadV2Backend.d.ts.map +1 -0
  106. package/lib/typescript/index.d.ts +13 -4
  107. package/lib/typescript/index.d.ts.map +1 -1
  108. package/lib/typescript/managers/DeviceManager.d.ts +5 -1
  109. package/lib/typescript/managers/DeviceManager.d.ts.map +1 -1
  110. package/lib/typescript/managers/NativeUploadQueue.d.ts +41 -0
  111. package/lib/typescript/managers/NativeUploadQueue.d.ts.map +1 -0
  112. package/lib/typescript/managers/RecordingManager.d.ts +3 -8
  113. package/lib/typescript/managers/RecordingManager.d.ts.map +1 -1
  114. package/lib/typescript/managers/types.d.ts +6 -1
  115. package/lib/typescript/managers/types.d.ts.map +1 -1
  116. package/lib/typescript/managers/uploadRecoveryMetadata.d.ts +6 -0
  117. package/lib/typescript/managers/uploadRecoveryMetadata.d.ts.map +1 -0
  118. package/lib/typescript/models/Device.d.ts +42 -0
  119. package/lib/typescript/models/Device.d.ts.map +1 -1
  120. package/lib/typescript/models/Recording.d.ts +47 -0
  121. package/lib/typescript/models/Recording.d.ts.map +1 -1
  122. package/lib/typescript/models/Status.d.ts +5 -1
  123. package/lib/typescript/models/Status.d.ts.map +1 -1
  124. package/lib/typescript/sdkIdentity.d.ts +3 -0
  125. package/lib/typescript/sdkIdentity.d.ts.map +1 -0
  126. package/lib/typescript/specs/NativeBotaDeviceSDK.d.ts +70 -0
  127. package/lib/typescript/specs/NativeBotaDeviceSDK.d.ts.map +1 -1
  128. package/lib/typescript/specs/NativeBotaUploadV2Backend.d.ts +18 -0
  129. package/lib/typescript/specs/NativeBotaUploadV2Backend.d.ts.map +1 -0
  130. package/lib/typescript/uploadRecovery.d.ts +27 -0
  131. package/lib/typescript/uploadRecovery.d.ts.map +1 -0
  132. package/package.json +1 -1
  133. package/scripts/bota_device_sdk_spm_workaround.rb +26 -15
  134. package/src/BotaClient.ts +17 -3
  135. package/src/ble/constants.ts +5 -0
  136. package/src/ble/deviceDiagnostics.ts +238 -0
  137. package/src/client.ts +189 -18
  138. package/src/clientPresence.ts +33 -0
  139. package/src/encryptedUploadV2Backend.ts +171 -0
  140. package/src/index.ts +24 -0
  141. package/src/managers/DeviceManager.ts +23 -0
  142. package/src/managers/NativeUploadQueue.ts +330 -0
  143. package/src/managers/RecordingManager.ts +83 -116
  144. package/src/managers/types.ts +7 -1
  145. package/src/managers/uploadRecoveryMetadata.ts +59 -0
  146. package/src/models/Device.ts +43 -0
  147. package/src/models/Recording.ts +52 -0
  148. package/src/models/Status.ts +5 -1
  149. package/src/sdkIdentity.ts +3 -0
  150. package/src/specs/NativeBotaDeviceSDK.ts +66 -0
  151. package/src/specs/NativeBotaUploadV2Backend.ts +19 -0
  152. package/src/uploadRecovery.ts +66 -0
@@ -0,0 +1,98 @@
1
+ import BotaAppSDK
2
+ import Foundation
3
+
4
+ @objc(BotaUploadV2BackendBridge)
5
+ public final class BotaUploadV2BackendBridge: NSObject, @unchecked Sendable {
6
+ @MainActor private var coordinator: UploadCoordinator?
7
+ @MainActor private var destroyed = false
8
+ @MainActor private var credentialsRequested: (@Sendable (String, String) -> Void)?
9
+
10
+ @objc(prepare:credentialsRequested:completion:)
11
+ public func prepare(_ json: String,
12
+ credentialsRequested: @escaping @Sendable (String, String) -> Void,
13
+ completion: @escaping @Sendable (String?, NSError?) -> Void) {
14
+ Task { @MainActor in
15
+ do {
16
+ guard !destroyed else { throw UploadFailure.cancelled }
17
+ self.credentialsRequested = credentialsRequested
18
+ let input = try UploadInput.decode(Data(json.utf8))
19
+ let result = try await service().prepare(input)
20
+ let data = try JSONSerialization.data(withJSONObject: result.dictionary)
21
+ completion(String(decoding: data, as: UTF8.self), nil)
22
+ } catch { completion(nil, UploadFailure.safe(error) as NSError) }
23
+ }
24
+ }
25
+
26
+ @objc(cancel:completion:)
27
+ public func cancel(_ id: String, completion: @escaping @Sendable () -> Void) {
28
+ Task { @MainActor in coordinator?.cancel(id); completion() }
29
+ }
30
+ @objc(complete:completion:)
31
+ public func complete(_ id: String, completion: @escaping @Sendable (NSError?) -> Void) {
32
+ Task { @MainActor in
33
+ do {
34
+ guard !destroyed, let coordinator else { throw UploadFailure.cancelled }
35
+ try coordinator.complete(id); completion(nil)
36
+ } catch { completion(UploadFailure.safe(error) as NSError) }
37
+ }
38
+ }
39
+ @objc(resolveCredentials:token:completion:)
40
+ public func resolveCredentials(_ id: String, token: String, completion: @escaping @Sendable () -> Void) {
41
+ Task { @MainActor in coordinator?.credentials.resolve(requestId: id, token: token); completion() }
42
+ }
43
+ @objc(rejectCredentials:completion:)
44
+ public func rejectCredentials(_ id: String, completion: @escaping @Sendable () -> Void) {
45
+ Task { @MainActor in coordinator?.credentials.reject(requestId: id); completion() }
46
+ }
47
+ @objc public func invalidate() {
48
+ Task { @MainActor in destroyed = true; coordinator?.cancelAll(); credentialsRequested = nil }
49
+ }
50
+
51
+ @MainActor private func service() -> UploadCoordinator {
52
+ if let coordinator { return coordinator }
53
+ let credentials = UploadCredentials()
54
+ credentials.emit = { [weak self] requestId, operationId in
55
+ self?.credentialsRequested?(requestId, operationId)
56
+ }
57
+ let journal = UploadJournal(root: FileManager.default.urls(for: .applicationSupportDirectory,
58
+ in: .userDomainMask)[0].appendingPathComponent("BotaSDKUploadV2", isDirectory: true))
59
+ let http = UploadURLSessionHTTP()
60
+ let coordinator = UploadCoordinator(credentials: credentials, makeOperation: { input in
61
+ UploadOperation(input: input, journal: journal, http: http, credentials: credentials,
62
+ readNonce: { operationId in try await BotaDeviceSDKEncryptedUploadV2Materials.readAuthNonce(operationId) })
63
+ }, register: { operation, prepared in
64
+ let policy: EncryptedUploadV2SecurityPolicy
65
+ switch prepared.session.policy {
66
+ case "legacy_allowed": policy = .legacyAllowed
67
+ case "v2_preferred": policy = .v2Preferred
68
+ case "v2_required": policy = .v2Required
69
+ default: throw UploadFailure.invalidDocument
70
+ }
71
+ guard let sessionId = UUID(uuidString: prepared.session.session_id) else { throw UploadFailure.invalidDocument }
72
+ let material = EncryptedUploadV2Material(
73
+ materialID: UUID().uuidString.lowercased(), recordingID: operation.input.recording.uuid,
74
+ uploadSessionID: sessionId, ownerRevision: prepared.session.owner_revision, policy: policy,
75
+ authorization: prepared.authorization,
76
+ stagingRequest: { try await operation.stagingRequest(Self.evidence($0)) },
77
+ submitManifest: { try await operation.submitManifest($0, evidence: Self.evidence($1)) },
78
+ finalize: { try await operation.finalize(Self.evidence($0)) },
79
+ completionReceipt: { try await operation.completionReceipt(Self.evidence($0)) },
80
+ cancel: { await operation.cancel() },
81
+ uploadContext: { nonce in
82
+ let context = try await operation.uploadContext(nonce)
83
+ return EncryptedUploadV2ContextExchange(challenge: context.challenge, exchangeProof: context.exchangeProof)
84
+ },
85
+ shouldUploadCiphertext: { try await operation.shouldUpload(Self.evidence($0)) },
86
+ reconcileStaging: { try await operation.reconcileStaging($0, evidence: Self.evidence($1)) }
87
+ )
88
+ return BotaDeviceSDKEncryptedUploadV2Materials.register(material)
89
+ }, unregister: { id in _ = BotaDeviceSDKEncryptedUploadV2Materials.remove(id) })
90
+ self.coordinator = coordinator
91
+ return coordinator
92
+ }
93
+
94
+ private static func evidence(_ evidence: EncryptedUploadV2TransferEvidence) -> UploadEvidence {
95
+ UploadEvidence(ciphertextLength: evidence.ciphertextLength, ciphertextSHA256: evidence.ciphertextSHA256,
96
+ manifestLength: evidence.manifestLength, manifestSHA256: evidence.manifestSHA256)
97
+ }
98
+ }
@@ -0,0 +1,84 @@
1
+ import Foundation
2
+
3
+ struct UploadSelection: Sendable {
4
+ let profile = "encrypted_upload_v2"
5
+ let uploadSessionId: String
6
+ let ownerRevision: UInt32
7
+ let securityPolicy: String
8
+ let materialRegistrationId: String
9
+ let recordingId: String
10
+
11
+ var dictionary: [String: Any] {
12
+ ["profile": profile, "uploadSessionId": uploadSessionId, "ownerRevision": ownerRevision,
13
+ "securityPolicy": securityPolicy, "materialRegistrationId": materialRegistrationId, "recordingId": recordingId]
14
+ }
15
+ }
16
+
17
+ @MainActor final class UploadCoordinator {
18
+ private struct Slot {
19
+ let operation: UploadOperation
20
+ var registrationId: String?
21
+ var preparing: Bool = true
22
+ }
23
+ let credentials: UploadCredentials
24
+ private let makeOperation: (UploadInput) -> UploadOperation
25
+ private let register: (UploadOperation, UploadPrepared) throws -> String
26
+ private let unregister: (String) -> Void
27
+ private var slots: [String: Slot] = [:]
28
+ private var usedIDs = Set<String>()
29
+
30
+ init(credentials: UploadCredentials, makeOperation: @escaping (UploadInput) -> UploadOperation,
31
+ register: @escaping (UploadOperation, UploadPrepared) throws -> String,
32
+ unregister: @escaping (String) -> Void) {
33
+ self.credentials = credentials
34
+ self.makeOperation = makeOperation
35
+ self.register = register
36
+ self.unregister = unregister
37
+ }
38
+
39
+ func prepare(_ input: UploadInput) async throws -> UploadSelection {
40
+ try input.validate()
41
+ guard !usedIDs.contains(input.operationId) else { throw UploadFailure.cancelled }
42
+ guard !slots.values.contains(where: { $0.operation.input.journalKey == input.journalKey }) else {
43
+ throw UploadFailure.state
44
+ }
45
+ usedIDs.insert(input.operationId)
46
+ let operation = makeOperation(input)
47
+ slots[input.operationId] = Slot(operation: operation)
48
+ do {
49
+ let prepared = try await operation.prepare()
50
+ try operation.check()
51
+ let id = try register(operation, prepared)
52
+ slots[input.operationId]?.registrationId = id
53
+ slots[input.operationId]?.preparing = false
54
+ return UploadSelection(uploadSessionId: prepared.session.session_id, ownerRevision: prepared.session.owner_revision,
55
+ securityPolicy: prepared.session.policy, materialRegistrationId: id, recordingId: prepared.recordingId)
56
+ } catch {
57
+ operation.cancel()
58
+ remove(input.operationId)
59
+ throw UploadFailure.safe(error)
60
+ }
61
+ }
62
+
63
+ func cancel(_ operationId: String) {
64
+ usedIDs.insert(operationId)
65
+ guard let slot = slots[operationId] else { return }
66
+ slot.operation.cancel()
67
+ // A preparing operation retains its journal lease until committed responses drain.
68
+ if !slot.preparing { remove(operationId) }
69
+ }
70
+
71
+ func complete(_ operationId: String) throws {
72
+ guard let slot = slots[operationId], !slot.preparing else { throw UploadFailure.state }
73
+ try slot.operation.complete()
74
+ remove(operationId)
75
+ }
76
+
77
+ func cancelAll() {
78
+ for id in Array(slots.keys) { cancel(id) }
79
+ }
80
+
81
+ private func remove(_ operationId: String) {
82
+ if let id = slots.removeValue(forKey: operationId)?.registrationId { unregister(id) }
83
+ }
84
+ }
@@ -0,0 +1,82 @@
1
+ import Foundation
2
+
3
+ struct UploadHTTPResponse: Sendable { let status: Int; let body: Data }
4
+
5
+ @MainActor protocol UploadHTTP {
6
+ func send(_ request: URLRequest) async throws -> UploadHTTPResponse
7
+ }
8
+
9
+ // Refuse redirects so Cognito credentials and opaque bodies never change origin.
10
+ private final class UploadRedirectPolicy: NSObject, URLSessionTaskDelegate, Sendable {
11
+ func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse,
12
+ newRequest request: URLRequest, completionHandler: @escaping @Sendable (URLRequest?) -> Void) {
13
+ completionHandler(nil)
14
+ }
15
+ }
16
+
17
+ @MainActor final class UploadURLSessionHTTP: UploadHTTP {
18
+ private let session: URLSession
19
+
20
+ init() {
21
+ let configuration = URLSessionConfiguration.ephemeral
22
+ configuration.urlCache = nil
23
+ configuration.httpCookieStorage = nil
24
+ configuration.urlCredentialStorage = nil
25
+ configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
26
+ configuration.timeoutIntervalForRequest = 30
27
+ configuration.timeoutIntervalForResource = 60
28
+ session = URLSession(configuration: configuration, delegate: UploadRedirectPolicy(), delegateQueue: nil)
29
+ }
30
+ deinit { session.invalidateAndCancel() }
31
+
32
+ func send(_ request: URLRequest) async throws -> UploadHTTPResponse {
33
+ do {
34
+ let (body, response) = try await session.data(for: request)
35
+ guard let response = response as? HTTPURLResponse, body.count <= 1_048_576 else { throw UploadFailure.invalidDocument }
36
+ return UploadHTTPResponse(status: response.statusCode, body: body)
37
+ } catch { throw UploadFailure.safe(error) }
38
+ }
39
+ }
40
+
41
+ @MainActor final class UploadCredentials {
42
+ private struct Pending {
43
+ let operationId: String
44
+ let continuation: CheckedContinuation<String, Error>
45
+ let timer: Task<Void, Never>
46
+ }
47
+ var emit: ((String, String) -> Void)?
48
+ private var pending: [String: Pending] = [:]
49
+
50
+ func request(operationId: String) async throws -> String {
51
+ try Task.checkCancellation()
52
+ let id = UUID().uuidString.lowercased()
53
+ return try await withTaskCancellationHandler {
54
+ try await withCheckedThrowingContinuation { continuation in
55
+ let timer = Task {
56
+ do { try await Task.sleep(nanoseconds: 30_000_000_000) }
57
+ catch { return }
58
+ self.reject(requestId: id)
59
+ }
60
+ pending[id] = Pending(operationId: operationId, continuation: continuation, timer: timer)
61
+ emit?(id, operationId)
62
+ }
63
+ } onCancel: { Task { @MainActor in self.finish(id, result: .failure(UploadFailure.cancelled)) } }
64
+ }
65
+
66
+ func resolve(requestId: String, token: String) {
67
+ guard !token.isEmpty, token.utf8.count <= 16_384,
68
+ token.utf8.allSatisfy({ (33...126).contains($0) }) else { reject(requestId: requestId); return }
69
+ finish(requestId, result: .success(token))
70
+ }
71
+ func reject(requestId: String) { finish(requestId, result: .failure(UploadFailure.http(401))) }
72
+ func cancel(operationId: String) {
73
+ for id in pending.filter({ $0.value.operationId == operationId }).map(\.key) {
74
+ finish(id, result: .failure(UploadFailure.cancelled))
75
+ }
76
+ }
77
+ private func finish(_ id: String, result: Result<String, Error>) {
78
+ guard let request = pending.removeValue(forKey: id) else { return }
79
+ request.timer.cancel()
80
+ request.continuation.resume(with: result)
81
+ }
82
+ }
@@ -0,0 +1,187 @@
1
+ import Darwin
2
+ import Foundation
3
+
4
+ final class UploadJournal {
5
+ private struct Record: Codable {
6
+ let version: Int
7
+ let identity: String
8
+ let phase: String
9
+ let entry: UploadJournalEntry?
10
+ let importedPriorSHA256: String?
11
+ let authorizationNonceSHA256: String?
12
+ let recoveryNonceBase64: String?
13
+ }
14
+ private let root: URL
15
+ private let synchronize: (Int32) -> Int32
16
+
17
+ init(root: URL, synchronize: @escaping (Int32) -> Int32 = { fsync($0) }) {
18
+ self.root = root
19
+ self.synchronize = synchronize
20
+ }
21
+
22
+ private func rootFD() throws -> Int32 {
23
+ let manager = FileManager.default
24
+ do {
25
+ try manager.createDirectory(at: root, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
26
+ let fd = open(root.path, O_RDONLY | O_DIRECTORY | O_NOFOLLOW)
27
+ guard fd >= 0 else { throw UploadFailure.journal }
28
+ do {
29
+ guard fchmod(fd, 0o700) == 0 else { throw UploadFailure.journal }
30
+ var url = root
31
+ var values = URLResourceValues()
32
+ values.isExcludedFromBackup = true
33
+ try url.setResourceValues(values)
34
+ guard synchronize(fd) == 0 else { throw UploadFailure.journal }
35
+ let parent = open(root.deletingLastPathComponent().path, O_RDONLY | O_DIRECTORY | O_NOFOLLOW)
36
+ guard parent >= 0 else { throw UploadFailure.journal }
37
+ defer { close(parent) }
38
+ guard synchronize(parent) == 0 else { throw UploadFailure.journal }
39
+ return fd
40
+ } catch { close(fd); throw error }
41
+ } catch { throw UploadFailure.journal }
42
+ }
43
+
44
+ private func name(_ input: UploadInput) throws -> String {
45
+ guard UploadValidation.hex(input.journalKey, bytes: 32) else { throw UploadFailure.invalidInput }
46
+ return input.journalKey + ".json"
47
+ }
48
+
49
+ func load(_ input: UploadInput) throws -> UploadJournalEntry? {
50
+ guard let record = try read(input) else { return nil }
51
+ guard record.phase == "ready" else { throw UploadFailure.pending }
52
+ guard let entry = record.entry else { throw UploadFailure.journal }
53
+ if let prior = input.priorJournalEntry {
54
+ let priorIdentity = try prior.identity()
55
+ guard prior.agrees(with: entry) || record.importedPriorSHA256 == priorIdentity else {
56
+ throw UploadFailure.identityConflict
57
+ }
58
+ }
59
+ return entry
60
+ }
61
+
62
+ func authorizationNonceSHA256(_ input: UploadInput) throws -> String? {
63
+ try read(input)?.authorizationNonceSHA256
64
+ }
65
+
66
+ func recoveryNonceBase64(_ input: UploadInput) throws -> String? {
67
+ try read(input)?.recoveryNonceBase64
68
+ }
69
+
70
+ private func read(_ input: UploadInput) throws -> Record? {
71
+ let name = try name(input)
72
+ let directory = try rootFD()
73
+ defer { close(directory) }
74
+ let fd = openat(directory, name, O_RDONLY | O_NOFOLLOW | O_NONBLOCK)
75
+ if fd < 0 {
76
+ if errno == ENOENT { return nil }
77
+ throw UploadFailure.journal
78
+ }
79
+ defer { close(fd) }
80
+ var info = stat()
81
+ guard fstat(fd, &info) == 0, info.st_mode & S_IFMT == S_IFREG, info.st_size <= 16_384 else {
82
+ throw UploadFailure.journal
83
+ }
84
+ do {
85
+ let data = try FileHandle(fileDescriptor: fd, closeOnDealloc: false).readToEnd() ?? Data()
86
+ let record = try JSONDecoder().decode(Record.self, from: data)
87
+ guard (1...2).contains(record.version),
88
+ record.identity == (try input.identity(includeTransport: record.version == 1)) else {
89
+ throw UploadFailure.identityConflict
90
+ }
91
+ guard ["ready", "recording_create_unknown", "session_create_unknown"].contains(record.phase),
92
+ record.importedPriorSHA256 == nil || UploadValidation.hex(record.importedPriorSHA256!, bytes: 32) else {
93
+ throw UploadFailure.journal
94
+ }
95
+ try record.entry?.validate()
96
+ guard record.authorizationNonceSHA256 == nil ||
97
+ (record.entry?.sessionId != nil && UploadValidation.hex(record.authorizationNonceSHA256!, bytes: 32)) else {
98
+ throw UploadFailure.journal
99
+ }
100
+ if let nonce = record.recoveryNonceBase64 {
101
+ guard record.phase == "ready", record.entry?.sessionId != nil else { throw UploadFailure.journal }
102
+ _ = try UploadValidation.document(nonce, size: 16)
103
+ }
104
+ if record.version == 1 {
105
+ let migrated = Record(version: 2, identity: try input.identity(), phase: record.phase,
106
+ entry: record.entry, importedPriorSHA256: record.importedPriorSHA256,
107
+ authorizationNonceSHA256: record.authorizationNonceSHA256, recoveryNonceBase64: record.recoveryNonceBase64)
108
+ try persist(input, record: migrated)
109
+ return migrated
110
+ }
111
+ return record
112
+ } catch { throw (error as? UploadFailure) ?? .journal }
113
+ }
114
+
115
+ func save(_ input: UploadInput, entry: UploadJournalEntry, authorizationNonceSHA256: String? = nil,
116
+ recoveryNonceBase64: String? = nil) throws {
117
+ try entry.validate()
118
+ try write(input, entry: entry, phase: "ready", authorizationNonceSHA256: authorizationNonceSHA256,
119
+ recoveryNonceBase64: recoveryNonceBase64)
120
+ }
121
+
122
+ func markUnknown(_ input: UploadInput, entry: UploadJournalEntry?) throws {
123
+ try entry?.validate()
124
+ try write(input, entry: entry, phase: entry == nil ? "recording_create_unknown" : "session_create_unknown")
125
+ }
126
+
127
+ func rollbackRejectedCreate(_ input: UploadInput, entry: UploadJournalEntry?) throws {
128
+ let phase = entry == nil ? "recording_create_unknown" : "session_create_unknown"
129
+ guard let record = try read(input), record.phase == phase, record.entry == entry else {
130
+ throw UploadFailure.identityConflict
131
+ }
132
+ if let entry {
133
+ try save(input, entry: entry)
134
+ } else {
135
+ let directory = try rootFD()
136
+ defer { close(directory) }
137
+ guard unlinkat(directory, try name(input), 0) == 0, synchronize(directory) == 0 else { throw UploadFailure.journal }
138
+ }
139
+ }
140
+
141
+ private func write(_ input: UploadInput, entry: UploadJournalEntry?, phase: String,
142
+ authorizationNonceSHA256: String? = nil, recoveryNonceBase64: String? = nil) throws {
143
+ let previous = try read(input)
144
+ let imported = try previous?.importedPriorSHA256 ?? input.priorJournalEntry?.identity()
145
+ try persist(input, record: Record(version: 2, identity: try input.identity(), phase: phase,
146
+ entry: entry, importedPriorSHA256: imported,
147
+ authorizationNonceSHA256: authorizationNonceSHA256 ??
148
+ (previous?.entry?.sessionId == entry?.sessionId ? previous?.authorizationNonceSHA256 : nil),
149
+ recoveryNonceBase64: recoveryNonceBase64))
150
+ }
151
+
152
+ private func persist(_ input: UploadInput, record: Record) throws {
153
+ let name = try name(input)
154
+ let directory = try rootFD()
155
+ defer { close(directory) }
156
+ let temporary = UUID().uuidString.lowercased() + ".tmp"
157
+ var fd = openat(directory, temporary, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0o600)
158
+ guard fd >= 0 else { throw UploadFailure.journal }
159
+ defer {
160
+ if fd >= 0 { close(fd) }
161
+ unlinkat(directory, temporary, 0)
162
+ }
163
+ do {
164
+ let data = try JSONEncoder().encode(record)
165
+ try FileHandle(fileDescriptor: fd, closeOnDealloc: false).write(contentsOf: data)
166
+ var url = root.appendingPathComponent(temporary)
167
+ var values = URLResourceValues()
168
+ values.isExcludedFromBackup = true
169
+ try url.setResourceValues(values)
170
+ #if os(iOS)
171
+ try FileManager.default.setAttributes([.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], ofItemAtPath: url.path)
172
+ #endif
173
+ guard synchronize(fd) == 0 else { throw UploadFailure.journal }
174
+ let result = close(fd)
175
+ fd = -1
176
+ guard result == 0, renameat(directory, temporary, directory, name) == 0,
177
+ synchronize(directory) == 0 else { throw UploadFailure.journal }
178
+ } catch { throw UploadFailure.journal }
179
+ }
180
+
181
+ func remove(_ input: UploadInput, expected: UploadJournalEntry) throws {
182
+ guard try load(input) == expected else { throw UploadFailure.identityConflict }
183
+ let directory = try rootFD()
184
+ defer { close(directory) }
185
+ guard unlinkat(directory, try name(input), 0) == 0, synchronize(directory) == 0 else { throw UploadFailure.journal }
186
+ }
187
+ }