@bota.dev/react-native-app-sdk 2.0.0-beta.10 → 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.
- package/README.md +12 -1
- package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKPackage.kt +9 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/upload/BotaUploadV2BackendModule.kt +66 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/upload/NativeUpload.kt +400 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/upload/UploadHttp.kt +158 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/upload/UploadJournal.kt +112 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/upload/UploadModels.kt +171 -0
- package/generated/codegen-contract.json +167 -8
- package/ios/BotaUploadV2Backend.mm +49 -0
- package/ios/UploadBackend/BotaUploadV2BackendBridge.swift +98 -0
- package/ios/UploadBackend/UploadCoordinator.swift +84 -0
- package/ios/UploadBackend/UploadHTTP.swift +82 -0
- package/ios/UploadBackend/UploadJournal.swift +187 -0
- package/ios/UploadBackend/UploadModels.swift +273 -0
- package/ios/UploadBackend/UploadOperation.swift +391 -0
- package/lib/commonjs/encryptedUploadV2Backend.js +200 -0
- package/lib/commonjs/encryptedUploadV2Backend.js.map +1 -0
- package/lib/commonjs/index.js +5 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/sdkIdentity.js +1 -1
- package/lib/commonjs/specs/NativeBotaUploadV2Backend.js +11 -0
- package/lib/commonjs/specs/NativeBotaUploadV2Backend.js.map +1 -0
- package/lib/module/encryptedUploadV2Backend.js +196 -0
- package/lib/module/encryptedUploadV2Backend.js.map +1 -0
- package/lib/module/index.js +3 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/sdkIdentity.js +1 -1
- package/lib/module/specs/NativeBotaUploadV2Backend.js +9 -0
- package/lib/module/specs/NativeBotaUploadV2Backend.js.map +1 -0
- package/lib/typescript/encryptedUploadV2Backend.d.ts +41 -0
- package/lib/typescript/encryptedUploadV2Backend.d.ts.map +1 -0
- package/lib/typescript/index.d.ts +3 -0
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/sdkIdentity.d.ts +1 -1
- package/lib/typescript/specs/NativeBotaUploadV2Backend.d.ts +18 -0
- package/lib/typescript/specs/NativeBotaUploadV2Backend.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/encryptedUploadV2Backend.ts +171 -0
- package/src/index.ts +6 -0
- package/src/sdkIdentity.ts +1 -1
- package/src/specs/NativeBotaUploadV2Backend.ts +19 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#import <BotaDeviceSDKSpec/BotaDeviceSDKSpec.h>
|
|
2
|
+
#if __has_include(<BotaDeviceSDK/BotaDeviceSDK-Swift.h>)
|
|
3
|
+
#import <BotaDeviceSDK/BotaDeviceSDK-Swift.h>
|
|
4
|
+
#else
|
|
5
|
+
#import "BotaDeviceSDK-Swift.h"
|
|
6
|
+
#endif
|
|
7
|
+
|
|
8
|
+
@interface BotaUploadV2Backend : NativeBotaUploadV2BackendSpecBase <NativeBotaUploadV2BackendSpec>
|
|
9
|
+
@property(nonatomic, strong) BotaUploadV2BackendBridge *backend;
|
|
10
|
+
@end
|
|
11
|
+
|
|
12
|
+
@implementation BotaUploadV2Backend
|
|
13
|
+
RCT_EXPORT_MODULE(BotaUploadV2Backend)
|
|
14
|
+
+ (BOOL)requiresMainQueueSetup { return NO; }
|
|
15
|
+
- (instancetype)init {
|
|
16
|
+
if ((self = [super init])) { _backend = [BotaUploadV2BackendBridge new]; }
|
|
17
|
+
return self;
|
|
18
|
+
}
|
|
19
|
+
- (void)prepare:(NSString *)inputJSON resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
|
|
20
|
+
__weak BotaUploadV2Backend *weakSelf = self;
|
|
21
|
+
[_backend prepare:inputJSON credentialsRequested:^(NSString *requestId, NSString *operationId) {
|
|
22
|
+
[weakSelf emitOnCredentialsRequested:@{@"requestId": requestId, @"operationId": operationId}];
|
|
23
|
+
} completion:^(NSString *result, NSError *error) {
|
|
24
|
+
if (error) { reject(@"encrypted_upload_v2_backend_failed", error.localizedDescription, nil); }
|
|
25
|
+
else { resolve(result); }
|
|
26
|
+
}];
|
|
27
|
+
}
|
|
28
|
+
- (void)cancel:(NSString *)operationId resolve:(RCTPromiseResolveBlock)resolve reject:(__unused RCTPromiseRejectBlock)reject {
|
|
29
|
+
[_backend cancel:operationId completion:^{ resolve(nil); }];
|
|
30
|
+
}
|
|
31
|
+
- (void)complete:(NSString *)operationId resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
|
|
32
|
+
[_backend complete:operationId completion:^(NSError *error) {
|
|
33
|
+
if (error) { reject(@"encrypted_upload_v2_backend_failed", error.localizedDescription, nil); }
|
|
34
|
+
else { resolve(nil); }
|
|
35
|
+
}];
|
|
36
|
+
}
|
|
37
|
+
- (void)resolveCredentials:(NSString *)requestId token:(NSString *)token resolve:(RCTPromiseResolveBlock)resolve reject:(__unused RCTPromiseRejectBlock)reject {
|
|
38
|
+
[_backend resolveCredentials:requestId token:token completion:^{ resolve(nil); }];
|
|
39
|
+
}
|
|
40
|
+
- (void)rejectCredentials:(NSString *)requestId resolve:(RCTPromiseResolveBlock)resolve reject:(__unused RCTPromiseRejectBlock)reject {
|
|
41
|
+
[_backend rejectCredentials:requestId completion:^{ resolve(nil); }];
|
|
42
|
+
}
|
|
43
|
+
- (void)invalidate { [_backend invalidate]; }
|
|
44
|
+
#if RCT_NEW_ARCH_ENABLED
|
|
45
|
+
- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:(const facebook::react::ObjCTurboModule::InitParams &)params {
|
|
46
|
+
return std::make_shared<facebook::react::NativeBotaUploadV2BackendSpecJSI>(params);
|
|
47
|
+
}
|
|
48
|
+
#endif
|
|
49
|
+
@end
|
|
@@ -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
|
+
}
|