@onekeyfe/react-native-range-downloader 3.0.81 → 3.0.82

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 (146) hide show
  1. package/ReactNativeRangeDownloader.podspec +7 -0
  2. package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveRules.kt +291 -0
  3. package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactOrphanSweep.kt +111 -0
  4. package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactStore.kt +822 -0
  5. package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt +148 -0
  6. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveRulesTest.kt +114 -0
  7. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactDeadlineTest.kt +26 -0
  8. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactOrphanSweepTest.kt +107 -0
  9. package/ios/FirmwareArchiveMinizipBridge.h +33 -0
  10. package/ios/FirmwareArchiveMinizipBridge.mm +213 -0
  11. package/ios/FirmwareArtifactStore.swift +1508 -0
  12. package/ios/RangeDownloadLogic.swift +317 -0
  13. package/ios/ReactNativeRangeDownloader.swift +214 -6
  14. package/lib/module/index.js +2 -1
  15. package/lib/module/index.js.map +1 -1
  16. package/lib/typescript/src/ReactNativeRangeDownloader.nitro.d.ts +92 -1
  17. package/lib/typescript/src/ReactNativeRangeDownloader.nitro.d.ts.map +1 -1
  18. package/lib/typescript/src/index.d.ts +1 -0
  19. package/lib/typescript/src/index.d.ts.map +1 -1
  20. package/nitrogen/generated/android/c++/JDownloadChannel.hpp +8 -6
  21. package/nitrogen/generated/android/c++/JFirmwareArchiveExpectedEntry.hpp +69 -0
  22. package/nitrogen/generated/android/c++/JFirmwareArchiveMaterializeParams.hpp +87 -0
  23. package/nitrogen/generated/android/c++/JFirmwareArchiveMaterializeResult.hpp +80 -0
  24. package/nitrogen/generated/android/c++/JFirmwareArchiveMaterializedArtifact.hpp +63 -0
  25. package/nitrogen/generated/android/c++/JFirmwareArtifactCancelParams.hpp +57 -0
  26. package/nitrogen/generated/android/c++/JFirmwareArtifactCapabilities.hpp +88 -0
  27. package/nitrogen/generated/android/c++/JFirmwareArtifactDownloadParams.hpp +98 -0
  28. package/nitrogen/generated/android/c++/JFirmwareArtifactLease.hpp +57 -0
  29. package/nitrogen/generated/android/c++/JFirmwareArtifactLeaseCreateParams.hpp +57 -0
  30. package/nitrogen/generated/android/c++/JFirmwareArtifactLeaseReleaseParams.hpp +61 -0
  31. package/nitrogen/generated/android/c++/JFirmwareArtifactLeaseRetainParams.hpp +61 -0
  32. package/nitrogen/generated/android/c++/JFirmwareArtifactReaderCloseParams.hpp +57 -0
  33. package/nitrogen/generated/android/c++/JFirmwareArtifactReaderInfo.hpp +61 -0
  34. package/nitrogen/generated/android/c++/JFirmwareArtifactReaderReadParams.hpp +65 -0
  35. package/nitrogen/generated/android/c++/JFirmwareArtifactReceipt.hpp +69 -0
  36. package/nitrogen/generated/android/c++/JFirmwareArtifactRefParams.hpp +57 -0
  37. package/nitrogen/generated/android/c++/JFirmwareArtifactSweepResult.hpp +61 -0
  38. package/nitrogen/generated/android/c++/JFunc_void_RangeDownloadEvent.hpp +3 -3
  39. package/nitrogen/generated/android/c++/JHybridReactNativeRangeDownloaderSpec.cpp +274 -25
  40. package/nitrogen/generated/android/c++/JHybridReactNativeRangeDownloaderSpec.hpp +32 -22
  41. package/nitrogen/generated/android/c++/JRangeDownloadEvent.hpp +2 -2
  42. package/nitrogen/generated/android/c++/JRangeDownloadOutcome.hpp +6 -7
  43. package/nitrogen/generated/android/c++/JRangeDownloadParams.hpp +2 -2
  44. package/nitrogen/generated/android/c++/JRangeDownloadResult.hpp +2 -2
  45. package/nitrogen/generated/android/c++/JRangeFallbackKind.hpp +12 -13
  46. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/DownloadChannel.kt +5 -2
  47. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveExpectedEntry.kt +47 -0
  48. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveMaterializeParams.kt +44 -0
  49. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveMaterializeResult.kt +38 -0
  50. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveMaterializedArtifact.kt +41 -0
  51. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactCancelParams.kt +38 -0
  52. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactCapabilities.kt +47 -0
  53. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactDownloadParams.kt +68 -0
  54. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactLease.kt +38 -0
  55. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactLeaseCreateParams.kt +38 -0
  56. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactLeaseReleaseParams.kt +41 -0
  57. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactLeaseRetainParams.kt +41 -0
  58. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactReaderCloseParams.kt +38 -0
  59. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactReaderInfo.kt +41 -0
  60. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactReaderReadParams.kt +44 -0
  61. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactReceipt.kt +47 -0
  62. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactRefParams.kt +38 -0
  63. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactSweepResult.kt +41 -0
  64. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/Func_void_RangeDownloadEvent.kt +1 -1
  65. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/HybridReactNativeRangeDownloaderSpec.kt +65 -19
  66. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeDownloadEvent.kt +2 -2
  67. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeDownloadOutcome.kt +3 -1
  68. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeDownloadParams.kt +2 -2
  69. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeDownloadResult.kt +2 -2
  70. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeFallbackKind.kt +3 -1
  71. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/reactnativerangedownloaderOnLoad.kt +1 -1
  72. package/nitrogen/generated/android/reactnativerangedownloader+autolinking.cmake +1 -1
  73. package/nitrogen/generated/android/reactnativerangedownloader+autolinking.gradle +1 -1
  74. package/nitrogen/generated/android/reactnativerangedownloaderOnLoad.cpp +28 -18
  75. package/nitrogen/generated/android/reactnativerangedownloaderOnLoad.hpp +14 -5
  76. package/nitrogen/generated/ios/ReactNativeRangeDownloader+autolinking.rb +2 -2
  77. package/nitrogen/generated/ios/ReactNativeRangeDownloader-Swift-Cxx-Bridge.cpp +49 -1
  78. package/nitrogen/generated/ios/ReactNativeRangeDownloader-Swift-Cxx-Bridge.hpp +348 -4
  79. package/nitrogen/generated/ios/ReactNativeRangeDownloader-Swift-Cxx-Umbrella.hpp +54 -1
  80. package/nitrogen/generated/ios/ReactNativeRangeDownloaderAutolinking.mm +1 -1
  81. package/nitrogen/generated/ios/ReactNativeRangeDownloaderAutolinking.swift +9 -8
  82. package/nitrogen/generated/ios/c++/HybridReactNativeRangeDownloaderSpecSwift.cpp +1 -1
  83. package/nitrogen/generated/ios/c++/HybridReactNativeRangeDownloaderSpecSwift.hpp +159 -1
  84. package/nitrogen/generated/ios/swift/DownloadChannel.swift +5 -1
  85. package/nitrogen/generated/ios/swift/FirmwareArchiveExpectedEntry.swift +44 -0
  86. package/nitrogen/generated/ios/swift/FirmwareArchiveMaterializeParams.swift +58 -0
  87. package/nitrogen/generated/ios/swift/FirmwareArchiveMaterializeResult.swift +35 -0
  88. package/nitrogen/generated/ios/swift/FirmwareArchiveMaterializedArtifact.swift +34 -0
  89. package/nitrogen/generated/ios/swift/FirmwareArtifactCancelParams.swift +29 -0
  90. package/nitrogen/generated/ios/swift/FirmwareArtifactCapabilities.swift +50 -0
  91. package/nitrogen/generated/ios/swift/FirmwareArtifactDownloadParams.swift +131 -0
  92. package/nitrogen/generated/ios/swift/FirmwareArtifactLease.swift +29 -0
  93. package/nitrogen/generated/ios/swift/FirmwareArtifactLeaseCreateParams.swift +29 -0
  94. package/nitrogen/generated/ios/swift/FirmwareArtifactLeaseReleaseParams.swift +34 -0
  95. package/nitrogen/generated/ios/swift/FirmwareArtifactLeaseRetainParams.swift +34 -0
  96. package/nitrogen/generated/ios/swift/FirmwareArtifactReaderCloseParams.swift +29 -0
  97. package/nitrogen/generated/ios/swift/FirmwareArtifactReaderInfo.swift +34 -0
  98. package/nitrogen/generated/ios/swift/FirmwareArtifactReaderReadParams.swift +39 -0
  99. package/nitrogen/generated/ios/swift/FirmwareArtifactReceipt.swift +44 -0
  100. package/nitrogen/generated/ios/swift/FirmwareArtifactRefParams.swift +29 -0
  101. package/nitrogen/generated/ios/swift/FirmwareArtifactSweepResult.swift +34 -0
  102. package/nitrogen/generated/ios/swift/Func_void.swift +1 -2
  103. package/nitrogen/generated/ios/swift/Func_void_FirmwareArchiveMaterializeResult.swift +46 -0
  104. package/nitrogen/generated/ios/swift/Func_void_FirmwareArtifactLease.swift +46 -0
  105. package/nitrogen/generated/ios/swift/Func_void_FirmwareArtifactReaderInfo.swift +46 -0
  106. package/nitrogen/generated/ios/swift/Func_void_FirmwareArtifactReceipt.swift +46 -0
  107. package/nitrogen/generated/ios/swift/Func_void_FirmwareArtifactSweepResult.swift +46 -0
  108. package/nitrogen/generated/ios/swift/Func_void_RangeDownloadEvent.swift +1 -2
  109. package/nitrogen/generated/ios/swift/Func_void_RangeDownloadResult.swift +1 -2
  110. package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +1 -2
  111. package/nitrogen/generated/ios/swift/Func_void_std__shared_ptr_ArrayBuffer_.swift +46 -0
  112. package/nitrogen/generated/ios/swift/HybridReactNativeRangeDownloaderSpec.swift +15 -4
  113. package/nitrogen/generated/ios/swift/HybridReactNativeRangeDownloaderSpec_cxx.swift +230 -2
  114. package/nitrogen/generated/ios/swift/RangeDownloadEvent.swift +11 -42
  115. package/nitrogen/generated/ios/swift/RangeDownloadOutcome.swift +1 -1
  116. package/nitrogen/generated/ios/swift/RangeDownloadParams.swift +72 -139
  117. package/nitrogen/generated/ios/swift/RangeDownloadResult.swift +16 -53
  118. package/nitrogen/generated/ios/swift/RangeFallbackKind.swift +1 -1
  119. package/nitrogen/generated/shared/c++/DownloadChannel.hpp +5 -1
  120. package/nitrogen/generated/shared/c++/FirmwareArchiveExpectedEntry.hpp +95 -0
  121. package/nitrogen/generated/shared/c++/FirmwareArchiveMaterializeParams.hpp +95 -0
  122. package/nitrogen/generated/shared/c++/FirmwareArchiveMaterializeResult.hpp +85 -0
  123. package/nitrogen/generated/shared/c++/FirmwareArchiveMaterializedArtifact.hpp +89 -0
  124. package/nitrogen/generated/shared/c++/FirmwareArtifactCancelParams.hpp +83 -0
  125. package/nitrogen/generated/shared/c++/FirmwareArtifactCapabilities.hpp +96 -0
  126. package/nitrogen/generated/shared/c++/FirmwareArtifactDownloadParams.hpp +124 -0
  127. package/nitrogen/generated/shared/c++/FirmwareArtifactLease.hpp +83 -0
  128. package/nitrogen/generated/shared/c++/FirmwareArtifactLeaseCreateParams.hpp +83 -0
  129. package/nitrogen/generated/shared/c++/FirmwareArtifactLeaseReleaseParams.hpp +87 -0
  130. package/nitrogen/generated/shared/c++/FirmwareArtifactLeaseRetainParams.hpp +87 -0
  131. package/nitrogen/generated/shared/c++/FirmwareArtifactReaderCloseParams.hpp +83 -0
  132. package/nitrogen/generated/shared/c++/FirmwareArtifactReaderInfo.hpp +87 -0
  133. package/nitrogen/generated/shared/c++/FirmwareArtifactReaderReadParams.hpp +91 -0
  134. package/nitrogen/generated/shared/c++/FirmwareArtifactReceipt.hpp +95 -0
  135. package/nitrogen/generated/shared/c++/FirmwareArtifactRefParams.hpp +83 -0
  136. package/nitrogen/generated/shared/c++/FirmwareArtifactSweepResult.hpp +87 -0
  137. package/nitrogen/generated/shared/c++/HybridReactNativeRangeDownloaderSpec.cpp +13 -1
  138. package/nitrogen/generated/shared/c++/HybridReactNativeRangeDownloaderSpec.hpp +59 -1
  139. package/nitrogen/generated/shared/c++/RangeDownloadEvent.hpp +25 -17
  140. package/nitrogen/generated/shared/c++/RangeDownloadOutcome.hpp +1 -1
  141. package/nitrogen/generated/shared/c++/RangeDownloadParams.hpp +43 -35
  142. package/nitrogen/generated/shared/c++/RangeDownloadResult.hpp +22 -14
  143. package/nitrogen/generated/shared/c++/RangeFallbackKind.hpp +1 -1
  144. package/package.json +4 -4
  145. package/src/ReactNativeRangeDownloader.nitro.ts +130 -1
  146. package/src/index.tsx +3 -1
@@ -0,0 +1,1508 @@
1
+ import CryptoKit
2
+ import Foundation
3
+ import ReactNativeNativeLogger
4
+
5
+ func isFirmwareArtifactTLSError(_ error: Error) -> Bool {
6
+ var current: NSError? = error as NSError
7
+ var visited = Set<ObjectIdentifier>()
8
+ while let candidate = current {
9
+ let identifier = ObjectIdentifier(candidate)
10
+ guard visited.insert(identifier).inserted else {
11
+ break
12
+ }
13
+ if candidate.domain == NSURLErrorDomain,
14
+ [
15
+ NSURLErrorSecureConnectionFailed,
16
+ NSURLErrorServerCertificateHasBadDate,
17
+ NSURLErrorServerCertificateUntrusted,
18
+ NSURLErrorServerCertificateHasUnknownRoot,
19
+ NSURLErrorServerCertificateNotYetValid,
20
+ NSURLErrorClientCertificateRejected,
21
+ NSURLErrorClientCertificateRequired,
22
+ NSURLErrorAppTransportSecurityRequiresSecureConnection,
23
+ ].contains(candidate.code)
24
+ {
25
+ return true
26
+ }
27
+ current = candidate.userInfo[NSUnderlyingErrorKey] as? NSError
28
+ }
29
+ return false
30
+ }
31
+
32
+ struct StoredFirmwareArtifact: Sendable {
33
+ let artifactRef: String
34
+ let size: Int64
35
+ let sha256: String
36
+ let fileURL: URL
37
+ }
38
+
39
+ struct StoredFirmwareArchiveEntry {
40
+ let entryName: String
41
+ let artifact: StoredFirmwareArtifact
42
+ }
43
+
44
+ private struct StagedFirmwareArchiveEntry {
45
+ let entryName: String
46
+ let size: Int64
47
+ let sha256: String
48
+ let stagingURL: URL
49
+ }
50
+
51
+ private struct FirmwareArchiveRequirement {
52
+ let entryName: String
53
+ let expectedSize: Int64
54
+ let expectedSha256: String?
55
+ }
56
+
57
+ private final class FirmwareArtifactStreamDelegate: NSObject, URLSessionDataDelegate {
58
+ private let partialURL: URL
59
+ private let hostname: String
60
+ private let resumeOffset: Int64
61
+ private let expectedSize: Int64?
62
+ private let maxBytes: Int64
63
+ private let isCancelled: () -> Bool
64
+ private let stateLock = NSLock()
65
+ private var continuation: CheckedContinuation<Void, Error>?
66
+ private var dataTask: URLSessionDataTask?
67
+ private var cancellationRequested = false
68
+ private var completed = false
69
+ private var responseAccepted = false
70
+ private var handle: FileHandle?
71
+ private var written: Int64 = 0
72
+
73
+ init(
74
+ partialURL: URL,
75
+ hostname: String,
76
+ resumeOffset: Int64,
77
+ expectedSize: Int64?,
78
+ maxBytes: Int64,
79
+ isCancelled: @escaping () -> Bool
80
+ ) {
81
+ self.partialURL = partialURL
82
+ self.hostname = hostname
83
+ self.resumeOffset = resumeOffset
84
+ self.expectedSize = expectedSize
85
+ self.maxBytes = maxBytes
86
+ self.isCancelled = isCancelled
87
+ }
88
+
89
+ func run(session: URLSession, request: URLRequest) async throws {
90
+ try await withTaskCancellationHandler {
91
+ try await withCheckedThrowingContinuation {
92
+ (continuation: CheckedContinuation<Void, Error>) in
93
+ let task = session.dataTask(with: request)
94
+ stateLock.lock()
95
+ if cancellationRequested || completed {
96
+ stateLock.unlock()
97
+ task.cancel()
98
+ continuation.resume(throwing: CancellationError())
99
+ return
100
+ }
101
+ self.continuation = continuation
102
+ dataTask = task
103
+ stateLock.unlock()
104
+ task.resume()
105
+ }
106
+ } onCancel: {
107
+ self.cancel()
108
+ }
109
+ }
110
+
111
+ private func cancel() {
112
+ stateLock.lock()
113
+ cancellationRequested = true
114
+ let task = dataTask
115
+ stateLock.unlock()
116
+ task?.cancel()
117
+ }
118
+
119
+ private func finish(_ result: Result<Void, Error>) {
120
+ stateLock.lock()
121
+ guard !completed else {
122
+ stateLock.unlock()
123
+ return
124
+ }
125
+ completed = true
126
+ let continuation = continuation
127
+ self.continuation = nil
128
+ dataTask = nil
129
+ let handle = handle
130
+ self.handle = nil
131
+ stateLock.unlock()
132
+
133
+ try? handle?.close()
134
+ continuation?.resume(with: result)
135
+ }
136
+
137
+ private func reject(
138
+ _ dataTask: URLSessionDataTask,
139
+ completionHandler: @escaping (URLSession.ResponseDisposition) -> Void,
140
+ message: String
141
+ ) {
142
+ completionHandler(.cancel)
143
+ fail(dataTask, message: message)
144
+ }
145
+
146
+ private func fail(_ task: URLSessionTask, message: String) {
147
+ task.cancel()
148
+ finish(.failure(FirmwareArtifactStoreError.downloadFailed(message)))
149
+ }
150
+
151
+ func urlSession(
152
+ _ session: URLSession,
153
+ task: URLSessionTask,
154
+ willPerformHTTPRedirection response: HTTPURLResponse,
155
+ newRequest request: URLRequest,
156
+ completionHandler: @escaping (URLRequest?) -> Void
157
+ ) {
158
+ completionHandler(nil)
159
+ fail(
160
+ task,
161
+ message:
162
+ "ARTIFACT_REDIRECT_REJECTED: firmware response changed canonical identity"
163
+ )
164
+ }
165
+
166
+ func urlSession(
167
+ _ session: URLSession,
168
+ dataTask: URLSessionDataTask,
169
+ didReceive response: URLResponse,
170
+ completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
171
+ ) {
172
+ guard !isCancelled() else {
173
+ reject(
174
+ dataTask,
175
+ completionHandler: completionHandler,
176
+ message: "ARTIFACT_CANCELLED: firmware artifact download was cancelled"
177
+ )
178
+ return
179
+ }
180
+ guard let httpResponse = response as? HTTPURLResponse else {
181
+ reject(
182
+ dataTask,
183
+ completionHandler: completionHandler,
184
+ message: "Firmware response is not HTTP"
185
+ )
186
+ return
187
+ }
188
+ guard
189
+ let responseURL = httpResponse.url,
190
+ responseURL.scheme?.lowercased() == "https",
191
+ responseURL.host?.lowercased() == hostname.lowercased(),
192
+ responseURL.port == nil || responseURL.port == 443
193
+ else {
194
+ reject(
195
+ dataTask,
196
+ completionHandler: completionHandler,
197
+ message:
198
+ "ARTIFACT_REDIRECT_REJECTED: firmware response changed canonical identity"
199
+ )
200
+ return
201
+ }
202
+ guard httpResponse.statusCode == 200 || httpResponse.statusCode == 206 else {
203
+ reject(
204
+ dataTask,
205
+ completionHandler: completionHandler,
206
+ message:
207
+ "ARTIFACT_HTTP_\(httpResponse.statusCode): firmware request failed"
208
+ )
209
+ return
210
+ }
211
+
212
+ let append = resumeOffset > 0 && httpResponse.statusCode == 206
213
+ if httpResponse.statusCode == 206 {
214
+ guard
215
+ let contentRange = httpResponse.value(
216
+ forHTTPHeaderField: "Content-Range"
217
+ ),
218
+ firmwareArtifactContentRangeIsValid(
219
+ contentRange,
220
+ expectedStart: append ? resumeOffset : 0,
221
+ expectedTotal: expectedSize,
222
+ maxBytes: maxBytes
223
+ )
224
+ else {
225
+ reject(
226
+ dataTask,
227
+ completionHandler: completionHandler,
228
+ message:
229
+ "ARTIFACT_PROTOCOL_INVALID: firmware resume Content-Range is invalid"
230
+ )
231
+ return
232
+ }
233
+ }
234
+
235
+ let baseOffset = append ? resumeOffset : 0
236
+ guard
237
+ firmwareArtifactResponseFits(
238
+ expectedContentLength: httpResponse.expectedContentLength,
239
+ baseOffset: baseOffset,
240
+ maxBytes: maxBytes
241
+ )
242
+ else {
243
+ reject(
244
+ dataTask,
245
+ completionHandler: completionHandler,
246
+ message:
247
+ "ARTIFACT_PROTOCOL_INVALID: firmware artifact exceeds maxBytes"
248
+ )
249
+ return
250
+ }
251
+
252
+ do {
253
+ let handle = try FileHandle(forWritingTo: partialURL)
254
+ if append {
255
+ try handle.seekToEnd()
256
+ } else {
257
+ try handle.truncate(atOffset: 0)
258
+ }
259
+ self.handle = handle
260
+ written = baseOffset
261
+ responseAccepted = true
262
+ completionHandler(.allow)
263
+ } catch {
264
+ reject(
265
+ dataTask,
266
+ completionHandler: completionHandler,
267
+ message:
268
+ "ARTIFACT_NETWORK_FAILED: firmware partial file could not be opened"
269
+ )
270
+ }
271
+ }
272
+
273
+ func urlSession(
274
+ _ session: URLSession,
275
+ dataTask: URLSessionDataTask,
276
+ didReceive data: Data
277
+ ) {
278
+ guard responseAccepted, let handle else {
279
+ fail(
280
+ dataTask,
281
+ message:
282
+ "ARTIFACT_PROTOCOL_INVALID: firmware response stream was not accepted"
283
+ )
284
+ return
285
+ }
286
+ guard !isCancelled() else {
287
+ fail(
288
+ dataTask,
289
+ message: "ARTIFACT_CANCELLED: firmware artifact download was cancelled"
290
+ )
291
+ return
292
+ }
293
+ guard
294
+ firmwareArtifactResponseFits(
295
+ expectedContentLength: Int64(data.count),
296
+ baseOffset: written,
297
+ maxBytes: maxBytes
298
+ )
299
+ else {
300
+ fail(
301
+ dataTask,
302
+ message:
303
+ "ARTIFACT_PROTOCOL_INVALID: firmware artifact exceeds maxBytes"
304
+ )
305
+ return
306
+ }
307
+ do {
308
+ try handle.write(contentsOf: data)
309
+ written += Int64(data.count)
310
+ } catch {
311
+ fail(
312
+ dataTask,
313
+ message:
314
+ "ARTIFACT_NETWORK_FAILED: firmware response stream could not be persisted"
315
+ )
316
+ }
317
+ }
318
+
319
+ func urlSession(
320
+ _ session: URLSession,
321
+ task: URLSessionTask,
322
+ didCompleteWithError error: Error?
323
+ ) {
324
+ if let error {
325
+ finish(.failure(error))
326
+ return
327
+ }
328
+ guard responseAccepted, let handle else {
329
+ finish(
330
+ .failure(FirmwareArtifactStoreError.downloadFailed(
331
+ "ARTIFACT_PROTOCOL_INVALID: firmware response stream was not accepted"
332
+ ))
333
+ )
334
+ return
335
+ }
336
+ do {
337
+ try handle.synchronize()
338
+ finish(.success(()))
339
+ } catch {
340
+ finish(
341
+ .failure(FirmwareArtifactStoreError.downloadFailed(
342
+ "ARTIFACT_NETWORK_FAILED: firmware partial file could not be synchronized"
343
+ ))
344
+ )
345
+ }
346
+ }
347
+ }
348
+
349
+ private actor FirmwareArtifactDownloadCoordinator {
350
+ private struct ActiveDownload {
351
+ let transactionId: String
352
+ let task: Task<StoredFirmwareArtifact, Error>
353
+ }
354
+
355
+ private var downloads: [String: ActiveDownload] = [:]
356
+
357
+ func run(
358
+ key: String,
359
+ transactionId: String,
360
+ operation: @escaping () async throws -> StoredFirmwareArtifact
361
+ ) async throws -> StoredFirmwareArtifact {
362
+ if let download = downloads[key] {
363
+ return try await download.task.value
364
+ }
365
+ let task = Task {
366
+ try await operation()
367
+ }
368
+ downloads[key] = ActiveDownload(
369
+ transactionId: transactionId,
370
+ task: task
371
+ )
372
+ defer { downloads[key] = nil }
373
+ return try await task.value
374
+ }
375
+
376
+ func cancel(transactionId: String) {
377
+ for download in downloads.values
378
+ where download.transactionId == transactionId {
379
+ download.task.cancel()
380
+ }
381
+ }
382
+ }
383
+
384
+ final class FirmwareArtifactStore {
385
+ static let shared = FirmwareArtifactStore()
386
+ static let maxReadBytes = Int(firmwareArtifactMaxReadBytes)
387
+ static let maxArtifactBytes = firmwareArtifactMaxBytes
388
+ private static let defaultDownloadDeadline: TimeInterval = 180
389
+ private static let maxDownloadDeadline: TimeInterval = 24 * 60 * 60
390
+
391
+ private struct OpenReader {
392
+ let handle: FileHandle
393
+ let fileURL: URL
394
+ let size: Int64
395
+ }
396
+
397
+ private struct LeaseState {
398
+ let transactionId: String
399
+ var artifactRefs: Set<String>
400
+ }
401
+
402
+ private let fileManager = FileManager.default
403
+ private let downloadCoordinator = FirmwareArtifactDownloadCoordinator()
404
+ private let leaseLock = NSLock()
405
+ private let activeDownloadLock = NSLock()
406
+ private var activeDownloadCounts: [String: Int] = [:]
407
+ private let cancellationLock = NSLock()
408
+ private var cancelledTransactions: Set<String> = []
409
+ private let readerLock = NSLock()
410
+ private var readers: [String: OpenReader] = [:]
411
+ private var leases: [String: LeaseState] = [:]
412
+
413
+ private lazy var rootURL: URL = {
414
+ let base = fileManager.urls(
415
+ for: .applicationSupportDirectory,
416
+ in: .userDomainMask
417
+ ).first!
418
+ let root = base.appendingPathComponent("onekey-firmware-artifacts", isDirectory: true)
419
+ try? fileManager.createDirectory(at: root, withIntermediateDirectories: true)
420
+ return root
421
+ }()
422
+
423
+ private init() {}
424
+
425
+ private struct ValidatedDownload {
426
+ let expectedSize: Int64?
427
+ let expectedSha256: String?
428
+ let maxBytes: Int64
429
+ let downloadToken: String
430
+ }
431
+
432
+ private static func validateDownloadParams(
433
+ _ params: FirmwareArtifactDownloadParams
434
+ ) throws -> ValidatedDownload {
435
+ guard
436
+ !params.taskId.isEmpty,
437
+ params.taskId.count <= 100,
438
+ params.taskId.allSatisfy({ $0.isLetter || $0.isNumber || "._-".contains($0) })
439
+ else {
440
+ throw FirmwareArtifactStoreError.invalidInput("Invalid firmware taskId")
441
+ }
442
+ guard
443
+ isSafeIdentifier(params.transactionId),
444
+ isSafeIdentifier(params.artifactId),
445
+ isValidLeaseRef(params.leaseRef)
446
+ else {
447
+ throw FirmwareArtifactStoreError.invalidInput(
448
+ "Invalid firmware transaction or lease identity"
449
+ )
450
+ }
451
+ guard
452
+ let url = URL(string: params.url),
453
+ url.scheme?.lowercased() == "https",
454
+ url.host?.isEmpty == false,
455
+ url.user == nil,
456
+ url.password == nil,
457
+ url.fragment == nil,
458
+ url.port == nil || url.port == 443
459
+ else {
460
+ throw FirmwareArtifactStoreError.invalidInput("Firmware URL must use HTTPS port 443")
461
+ }
462
+ let expectedSize: Int64?
463
+ if let value = params.expectedSize {
464
+ guard let converted = firmwareArtifactExactInt64(
465
+ value,
466
+ minimum: 1,
467
+ maximum: Self.maxArtifactBytes
468
+ ) else {
469
+ throw FirmwareArtifactStoreError.invalidInput(
470
+ "Invalid firmware artifact expected size"
471
+ )
472
+ }
473
+ expectedSize = converted
474
+ } else {
475
+ expectedSize = nil
476
+ }
477
+ guard let maxBytes = firmwareArtifactExactInt64(
478
+ params.maxBytes,
479
+ minimum: 1,
480
+ maximum: Self.maxArtifactBytes
481
+ ) else {
482
+ throw FirmwareArtifactStoreError.invalidInput("Invalid firmware artifact size")
483
+ }
484
+ guard expectedSize.map({ $0 <= maxBytes }) ?? true else {
485
+ throw FirmwareArtifactStoreError.invalidInput(
486
+ "Firmware artifact expected size exceeds maxBytes"
487
+ )
488
+ }
489
+ let expectedSha256 = params.expectedSha256?.lowercased()
490
+ if let expectedSha256 {
491
+ guard expectedSha256.range(
492
+ of: "^[a-f0-9]{64}$",
493
+ options: .regularExpression
494
+ ) != nil else {
495
+ throw FirmwareArtifactStoreError.invalidInput(
496
+ "Invalid firmware artifact SHA-256"
497
+ )
498
+ }
499
+ }
500
+ guard params.routeType == "domain" else {
501
+ throw FirmwareArtifactStoreError.invalidInput("Invalid firmware route type")
502
+ }
503
+ let deadline = params.overallDeadlineSeconds ?? defaultDownloadDeadline
504
+ guard
505
+ deadline.isFinite,
506
+ deadline > 0,
507
+ deadline <= maxDownloadDeadline
508
+ else {
509
+ throw FirmwareArtifactStoreError.invalidInput("Invalid firmware download deadline")
510
+ }
511
+ if params.resolvedIp != nil {
512
+ throw FirmwareArtifactStoreError.invalidInput("Domain route must not include resolvedIp")
513
+ }
514
+ return ValidatedDownload(
515
+ expectedSize: expectedSize,
516
+ expectedSha256: expectedSha256,
517
+ maxBytes: maxBytes,
518
+ downloadToken: firmwareArtifactDownloadToken(
519
+ expectedSha256: expectedSha256,
520
+ url: params.url
521
+ )
522
+ )
523
+ }
524
+
525
+ func download(_ params: FirmwareArtifactDownloadParams) async throws -> StoredFirmwareArtifact {
526
+ let validated = try Self.validateDownloadParams(params)
527
+ try rejectIfCancelled(transactionId: params.transactionId)
528
+ if let expectedSha256 = validated.expectedSha256 {
529
+ try retainExpectedArtifact(
530
+ leaseRef: params.leaseRef,
531
+ transactionId: params.transactionId,
532
+ artifactRef: "fw:\(expectedSha256)"
533
+ )
534
+ } else {
535
+ try requireLeaseTransaction(
536
+ leaseRef: params.leaseRef,
537
+ transactionId: params.transactionId
538
+ )
539
+ }
540
+ let key = firmwareArtifactDownloadKey(
541
+ transactionId: params.transactionId,
542
+ taskId: params.taskId,
543
+ expectedSize: validated.expectedSize,
544
+ expectedSha256: validated.expectedSha256,
545
+ downloadToken: validated.downloadToken
546
+ )
547
+ markDownloadActive(validated.downloadToken, delta: 1)
548
+ do {
549
+ let artifact = try await downloadCoordinator.run(
550
+ key: key,
551
+ transactionId: params.transactionId
552
+ ) { [self] in
553
+ try rejectIfCancelled(transactionId: params.transactionId)
554
+ return try await downloadLocked(
555
+ params,
556
+ validated: validated
557
+ )
558
+ }
559
+ try rejectIfCancelled(transactionId: params.transactionId)
560
+ try retainExpectedArtifact(
561
+ leaseRef: params.leaseRef,
562
+ transactionId: params.transactionId,
563
+ artifactRef: artifact.artifactRef
564
+ )
565
+ markDownloadActive(validated.downloadToken, delta: -1)
566
+ return artifact
567
+ } catch {
568
+ markDownloadActive(validated.downloadToken, delta: -1)
569
+ OneKeyLog.error(
570
+ "FirmwareArtifact",
571
+ "event=download_failed transactionId=\(params.transactionId) artifactId=\(params.artifactId) route=\(params.routeType) errorType=\(String(describing: type(of: error)))"
572
+ )
573
+ if error is CancellationError ||
574
+ isTransactionCancelled(params.transactionId) {
575
+ throw FirmwareArtifactStoreError.downloadFailed(
576
+ "ARTIFACT_CANCELLED: firmware artifact download was cancelled"
577
+ )
578
+ }
579
+ throw error
580
+ }
581
+ }
582
+
583
+ func cachedArtifact(
584
+ _ params: FirmwareArtifactDownloadParams
585
+ ) throws -> StoredFirmwareArtifact? {
586
+ let validated = try Self.validateDownloadParams(params)
587
+ try rejectIfCancelled(transactionId: params.transactionId)
588
+ guard let expectedSha256 = validated.expectedSha256 else {
589
+ return nil
590
+ }
591
+ let finalURL = artifactURL(sha256: expectedSha256)
592
+ guard let artifact = try? validateDownloadedArtifact(
593
+ fileURL: finalURL,
594
+ expectedSize: validated.expectedSize,
595
+ expectedSha256: expectedSha256,
596
+ maxBytes: validated.maxBytes
597
+ ) else {
598
+ return nil
599
+ }
600
+ try retainExpectedArtifact(
601
+ leaseRef: params.leaseRef,
602
+ transactionId: params.transactionId,
603
+ artifactRef: artifact.artifactRef
604
+ )
605
+ try rejectIfCancelled(transactionId: params.transactionId)
606
+ return artifact
607
+ }
608
+
609
+ func cancelDownloads(transactionId: String) async throws {
610
+ guard Self.isSafeIdentifier(transactionId) else {
611
+ throw FirmwareArtifactStoreError.invalidInput(
612
+ "Invalid firmware transactionId"
613
+ )
614
+ }
615
+ cancellationLock.withFirmwareArtifactLock {
616
+ cancelledTransactions.insert(transactionId)
617
+ }
618
+ await downloadCoordinator.cancel(transactionId: transactionId)
619
+ }
620
+
621
+ private func downloadLocked(
622
+ _ params: FirmwareArtifactDownloadParams,
623
+ validated: ValidatedDownload
624
+ ) async throws -> StoredFirmwareArtifact {
625
+ if let expectedSha256 = validated.expectedSha256 {
626
+ let finalURL = artifactURL(sha256: expectedSha256)
627
+ if let existing = try? validateDownloadedArtifact(
628
+ fileURL: finalURL,
629
+ expectedSize: validated.expectedSize,
630
+ expectedSha256: expectedSha256,
631
+ maxBytes: validated.maxBytes
632
+ ) {
633
+ return existing
634
+ }
635
+ }
636
+
637
+ // Firmware preflight is foreground-bound, so both routes use the same
638
+ // cancellable stream instead of waiting on process-owned background tasks.
639
+ let partialURL = rootURL.appendingPathComponent(
640
+ firmwareArtifactPartialFileName(
641
+ transactionId: params.transactionId,
642
+ taskId: params.taskId,
643
+ downloadToken: validated.downloadToken
644
+ ),
645
+ isDirectory: false
646
+ )
647
+ if !fileManager.fileExists(atPath: partialURL.path) {
648
+ fileManager.createFile(atPath: partialURL.path, contents: nil)
649
+ }
650
+ var currentSize = try fileSize(partialURL)
651
+ if validated.expectedSha256 == nil && currentSize > 0 {
652
+ try fileManager.removeItem(at: partialURL)
653
+ fileManager.createFile(atPath: partialURL.path, contents: nil)
654
+ currentSize = 0
655
+ } else if currentSize > validated.maxBytes {
656
+ try fileManager.removeItem(at: partialURL)
657
+ fileManager.createFile(atPath: partialURL.path, contents: nil)
658
+ currentSize = 0
659
+ }
660
+ if let expectedSize = validated.expectedSize, currentSize == expectedSize {
661
+ if let completed = try? validateDownloadedArtifact(
662
+ fileURL: partialURL,
663
+ expectedSize: expectedSize,
664
+ expectedSha256: validated.expectedSha256,
665
+ maxBytes: validated.maxBytes
666
+ ) {
667
+ let finalURL = artifactURL(sha256: completed.sha256)
668
+ try promote(source: partialURL, destination: finalURL)
669
+ return StoredFirmwareArtifact(
670
+ artifactRef: completed.artifactRef,
671
+ size: completed.size,
672
+ sha256: completed.sha256,
673
+ fileURL: finalURL
674
+ )
675
+ }
676
+ try fileManager.removeItem(at: partialURL)
677
+ fileManager.createFile(atPath: partialURL.path, contents: nil)
678
+ currentSize = 0
679
+ }
680
+
681
+ OneKeyLog.info(
682
+ "FirmwareArtifact",
683
+ "event=stream_start transactionId=\(params.transactionId) artifactId=\(params.artifactId) route=\(params.routeType) expectedBytes=\(validated.expectedSize ?? -1) resumeBytes=\(currentSize)"
684
+ )
685
+ try await streamDownload(
686
+ params,
687
+ partialURL: partialURL,
688
+ resumeOffset: min(currentSize, validated.maxBytes),
689
+ validated: validated
690
+ )
691
+ OneKeyLog.info(
692
+ "FirmwareArtifact",
693
+ "event=stream_complete transactionId=\(params.transactionId) artifactId=\(params.artifactId) route=\(params.routeType) expectedBytes=\(validated.expectedSize ?? -1)"
694
+ )
695
+ let artifact: StoredFirmwareArtifact
696
+ do {
697
+ artifact = try validateDownloadedArtifact(
698
+ fileURL: partialURL,
699
+ expectedSize: validated.expectedSize,
700
+ expectedSha256: validated.expectedSha256,
701
+ maxBytes: validated.maxBytes
702
+ )
703
+ } catch {
704
+ try? fileManager.removeItem(at: partialURL)
705
+ throw error
706
+ }
707
+ let finalURL = artifactURL(sha256: artifact.sha256)
708
+ try promote(source: partialURL, destination: finalURL)
709
+ return StoredFirmwareArtifact(
710
+ artifactRef: artifact.artifactRef,
711
+ size: artifact.size,
712
+ sha256: artifact.sha256,
713
+ fileURL: finalURL
714
+ )
715
+ }
716
+
717
+ func discard(artifactRef: String) throws {
718
+ let fileURL = try resolveArtifactURL(artifactRef)
719
+ leaseLock.lock()
720
+ let isRetained = leases.values.contains {
721
+ $0.artifactRefs.contains(artifactRef)
722
+ }
723
+ leaseLock.unlock()
724
+ guard !isRetained else {
725
+ throw FirmwareArtifactStoreError.invalidInput(
726
+ "ARTIFACT_LEASED: firmware artifact is retained"
727
+ )
728
+ }
729
+ readerLock.lock()
730
+ let hasMatchingReader = readers.values.contains {
731
+ $0.fileURL == fileURL
732
+ }
733
+ readerLock.unlock()
734
+ guard !hasMatchingReader else {
735
+ throw FirmwareArtifactStoreError.readerInvalid(
736
+ "ARTIFACT_BUSY: firmware artifact has an open reader"
737
+ )
738
+ }
739
+ if fileManager.fileExists(atPath: fileURL.path) {
740
+ try fileManager.removeItem(at: fileURL)
741
+ }
742
+ }
743
+
744
+ func open(artifactRef: String) throws -> (readerId: String, size: Int64) {
745
+ let fileURL = try resolveArtifactURL(artifactRef)
746
+ let size = try fileSize(fileURL)
747
+ guard size > 0 else {
748
+ throw FirmwareArtifactStoreError.readerInvalid("Firmware artifact is empty")
749
+ }
750
+ let expectedSha256 = String(artifactRef.dropFirst(3))
751
+ guard try hashFile(fileURL) == expectedSha256 else {
752
+ throw FirmwareArtifactStoreError.integrityMismatch(
753
+ "Firmware artifact SHA-256 mismatch"
754
+ )
755
+ }
756
+ let readerId = UUID().uuidString
757
+ let reader = OpenReader(
758
+ handle: try FileHandle(forReadingFrom: fileURL),
759
+ fileURL: fileURL,
760
+ size: size
761
+ )
762
+ readerLock.lock()
763
+ readers[readerId] = reader
764
+ readerLock.unlock()
765
+ return (readerId, size)
766
+ }
767
+
768
+ func read(readerId: String, offset: Int64, length: Int) throws -> Data {
769
+ guard length > 0, length <= Self.maxReadBytes, offset >= 0 else {
770
+ throw FirmwareArtifactStoreError.readerInvalid("Invalid firmware artifact read")
771
+ }
772
+ readerLock.lock()
773
+ defer { readerLock.unlock() }
774
+ guard
775
+ let reader = readers[readerId],
776
+ offset <= reader.size - Int64(length)
777
+ else {
778
+ throw FirmwareArtifactStoreError.readerInvalid("Firmware artifact read is out of bounds")
779
+ }
780
+ try reader.handle.seek(toOffset: UInt64(offset))
781
+ let data = try reader.handle.read(upToCount: length) ?? Data()
782
+ guard data.count == length else {
783
+ throw FirmwareArtifactStoreError.readerInvalid("Firmware artifact returned a short read")
784
+ }
785
+ return data
786
+ }
787
+
788
+ func close(readerId: String) throws {
789
+ readerLock.lock()
790
+ let reader = readers.removeValue(forKey: readerId)
791
+ readerLock.unlock()
792
+ guard let reader else {
793
+ return
794
+ }
795
+ try reader.handle.close()
796
+ }
797
+
798
+ func materializeArchive(
799
+ leaseRef: String,
800
+ artifactRef: String,
801
+ expectedEntries: [FirmwareArchiveExpectedEntry]?
802
+ ) throws -> [StoredFirmwareArchiveEntry] {
803
+ try requireLease(leaseRef)
804
+ let archiveURL = try resolveArtifactURL(artifactRef)
805
+ let scratchURL = rootURL.appendingPathComponent(
806
+ "archive-\(UUID().uuidString)",
807
+ isDirectory: true
808
+ )
809
+ try fileManager.createDirectory(at: scratchURL, withIntermediateDirectories: true)
810
+ defer { try? fileManager.removeItem(at: scratchURL) }
811
+
812
+ let archiveEntries = try FirmwareArchiveMinizipBridge.scanArchive(
813
+ atPath: archiveURL.path
814
+ )
815
+ let requirements = try resolveArchiveRequirements(
816
+ expectedEntries,
817
+ archiveEntries: archiveEntries
818
+ )
819
+ try validateArchiveEntries(
820
+ archiveEntries,
821
+ requirements: requirements
822
+ )
823
+
824
+ var staged: [StagedFirmwareArchiveEntry] = []
825
+ for (index, requirement) in requirements.enumerated() {
826
+ let stagingURL = scratchURL.appendingPathComponent(
827
+ "\(index).entry",
828
+ isDirectory: false
829
+ )
830
+ fileManager.createFile(atPath: stagingURL.path, contents: nil)
831
+ let handle = try FileHandle(forWritingTo: stagingURL)
832
+ var hasher = SHA256()
833
+ var actualSize: Int64 = 0
834
+ var consumerError: Error?
835
+ do {
836
+ try FirmwareArchiveMinizipBridge.extractEntryNamed(
837
+ requirement.entryName,
838
+ archivePath: archiveURL.path
839
+ ) { chunk in
840
+ guard consumerError == nil else {
841
+ return false
842
+ }
843
+ do {
844
+ actualSize += Int64(chunk.count)
845
+ guard actualSize <= requirement.expectedSize else {
846
+ throw FirmwareArtifactStoreError.archiveInvalid(
847
+ "Firmware archive entry exceeds its expected size"
848
+ )
849
+ }
850
+ hasher.update(data: chunk)
851
+ try handle.write(contentsOf: chunk)
852
+ return true
853
+ } catch {
854
+ consumerError = error
855
+ return false
856
+ }
857
+ }
858
+ if let consumerError {
859
+ throw consumerError
860
+ }
861
+ try handle.synchronize()
862
+ try handle.close()
863
+ } catch {
864
+ try? handle.close()
865
+ throw error
866
+ }
867
+ let sha256 = hasher.finalize().map {
868
+ String(format: "%02x", $0)
869
+ }.joined()
870
+ guard
871
+ actualSize == requirement.expectedSize,
872
+ requirement.expectedSha256.map({ sha256 == $0 }) ?? true
873
+ else {
874
+ throw FirmwareArtifactStoreError.archiveInvalid(
875
+ "Firmware archive entry integrity mismatch"
876
+ )
877
+ }
878
+ staged.append(
879
+ StagedFirmwareArchiveEntry(
880
+ entryName: requirement.entryName,
881
+ size: actualSize,
882
+ sha256: sha256,
883
+ stagingURL: stagingURL
884
+ )
885
+ )
886
+ }
887
+
888
+ return try staged.map { entry in
889
+ let destination = artifactURL(sha256: entry.sha256)
890
+ if (try? validateStoredArtifact(
891
+ fileURL: destination,
892
+ expectedSize: entry.size,
893
+ expectedSha256: entry.sha256
894
+ )) == nil {
895
+ try promote(source: entry.stagingURL, destination: destination)
896
+ }
897
+ let stored = StoredFirmwareArtifact(
898
+ artifactRef: "fw:\(entry.sha256)",
899
+ size: entry.size,
900
+ sha256: entry.sha256,
901
+ fileURL: destination
902
+ )
903
+ try retainExpectedArtifact(
904
+ leaseRef: leaseRef,
905
+ transactionId: nil,
906
+ artifactRef: stored.artifactRef
907
+ )
908
+ return StoredFirmwareArchiveEntry(
909
+ entryName: entry.entryName,
910
+ artifact: stored
911
+ )
912
+ }
913
+ }
914
+
915
+ func createLease(transactionId: String) throws -> String {
916
+ guard Self.isSafeIdentifier(transactionId) else {
917
+ throw FirmwareArtifactStoreError.invalidInput(
918
+ "Invalid firmware transactionId"
919
+ )
920
+ }
921
+ leaseLock.lock()
922
+ defer { leaseLock.unlock() }
923
+ guard leases.count < 32 else {
924
+ throw FirmwareArtifactStoreError.invalidInput(
925
+ "Too many firmware artifact leases"
926
+ )
927
+ }
928
+ let leaseRef = "fwlease:\(UUID().uuidString.lowercased())"
929
+ leases[leaseRef] = LeaseState(
930
+ transactionId: transactionId,
931
+ artifactRefs: []
932
+ )
933
+ return leaseRef
934
+ }
935
+
936
+ func retain(leaseRef: String, artifactRef: String) throws {
937
+ _ = try resolveArtifactURL(artifactRef)
938
+ try retainExpectedArtifact(
939
+ leaseRef: leaseRef,
940
+ transactionId: nil,
941
+ artifactRef: artifactRef
942
+ )
943
+ }
944
+
945
+ func releaseLease(leaseRef: String, disposition: String) throws {
946
+ guard
947
+ disposition == "completed" ||
948
+ disposition == "safeCancelled" ||
949
+ disposition == "safeAbandoned"
950
+ else {
951
+ throw FirmwareArtifactStoreError.invalidInput(
952
+ "Invalid firmware lease disposition"
953
+ )
954
+ }
955
+ let transactionId: String = try {
956
+ leaseLock.lock()
957
+ defer { leaseLock.unlock() }
958
+ guard
959
+ let lease = leases.removeValue(
960
+ forKey: try validateLeaseRef(leaseRef)
961
+ )
962
+ else {
963
+ throw FirmwareArtifactStoreError.invalidInput(
964
+ "Firmware artifact lease is unavailable"
965
+ )
966
+ }
967
+ return lease.transactionId
968
+ }()
969
+ cancellationLock.withFirmwareArtifactLock {
970
+ cancelledTransactions.remove(transactionId)
971
+ }
972
+ }
973
+
974
+ func sweepOrphans() throws -> (deletedFiles: Int, deletedBytes: Int64) {
975
+ leaseLock.lock()
976
+ let retained = Set(
977
+ leases.values
978
+ .flatMap(\.artifactRefs)
979
+ .map { String($0.dropFirst(3)) }
980
+ )
981
+ leaseLock.unlock()
982
+ activeDownloadLock.lock()
983
+ let active = Set(activeDownloadCounts.filter { $0.value > 0 }.keys)
984
+ activeDownloadLock.unlock()
985
+ readerLock.lock()
986
+ let openPaths = Set(readers.values.map(\.fileURL.path))
987
+ readerLock.unlock()
988
+
989
+ return try sweepFirmwareArtifactOrphansAtRoot(
990
+ rootURL,
991
+ retainedSha256: retained,
992
+ activeSha256: active,
993
+ openPaths: openPaths,
994
+ fileManager: fileManager
995
+ )
996
+ }
997
+
998
+ private func requireLease(_ leaseRef: String) throws {
999
+ leaseLock.lock()
1000
+ defer { leaseLock.unlock() }
1001
+ guard leases[try validateLeaseRef(leaseRef)] != nil else {
1002
+ throw FirmwareArtifactStoreError.invalidInput(
1003
+ "Firmware artifact lease is unavailable"
1004
+ )
1005
+ }
1006
+ }
1007
+
1008
+ private func requireLeaseTransaction(
1009
+ leaseRef: String,
1010
+ transactionId: String
1011
+ ) throws {
1012
+ leaseLock.lock()
1013
+ defer { leaseLock.unlock() }
1014
+ guard
1015
+ let lease = leases[try validateLeaseRef(leaseRef)],
1016
+ lease.transactionId == transactionId
1017
+ else {
1018
+ throw FirmwareArtifactStoreError.invalidInput(
1019
+ "Firmware artifact lease transaction mismatch"
1020
+ )
1021
+ }
1022
+ }
1023
+
1024
+ private func retainExpectedArtifact(
1025
+ leaseRef: String,
1026
+ transactionId: String?,
1027
+ artifactRef: String
1028
+ ) throws {
1029
+ guard artifactRef.range(
1030
+ of: "^fw:[a-f0-9]{64}$",
1031
+ options: .regularExpression
1032
+ ) != nil else {
1033
+ throw FirmwareArtifactStoreError.invalidInput(
1034
+ "Invalid firmware artifactRef"
1035
+ )
1036
+ }
1037
+ leaseLock.lock()
1038
+ defer { leaseLock.unlock() }
1039
+ let validatedLeaseRef = try validateLeaseRef(leaseRef)
1040
+ guard var lease = leases[validatedLeaseRef] else {
1041
+ throw FirmwareArtifactStoreError.invalidInput(
1042
+ "Firmware artifact lease is unavailable"
1043
+ )
1044
+ }
1045
+ if let transactionId, lease.transactionId != transactionId {
1046
+ throw FirmwareArtifactStoreError.invalidInput(
1047
+ "Firmware artifact lease transaction mismatch"
1048
+ )
1049
+ }
1050
+ if lease.artifactRefs.insert(artifactRef).inserted {
1051
+ leases[validatedLeaseRef] = lease
1052
+ }
1053
+ }
1054
+
1055
+ private func validateLeaseRef(_ leaseRef: String) throws -> String {
1056
+ guard Self.isValidLeaseRef(leaseRef) else {
1057
+ throw FirmwareArtifactStoreError.invalidInput(
1058
+ "Invalid firmware leaseRef"
1059
+ )
1060
+ }
1061
+ return leaseRef
1062
+ }
1063
+
1064
+ private static func isValidLeaseRef(_ value: String) -> Bool {
1065
+ value.range(
1066
+ of: "^fwlease:[a-f0-9-]{36}$",
1067
+ options: .regularExpression
1068
+ ) != nil
1069
+ }
1070
+
1071
+ static func isSafeIdentifier(_ value: String) -> Bool {
1072
+ firmwareArtifactIdentifierIsSafe(value)
1073
+ }
1074
+
1075
+ private func markDownloadActive(_ sha256: String, delta: Int) {
1076
+ activeDownloadLock.lock()
1077
+ let next = (activeDownloadCounts[sha256] ?? 0) + delta
1078
+ if next <= 0 {
1079
+ activeDownloadCounts.removeValue(forKey: sha256)
1080
+ } else {
1081
+ activeDownloadCounts[sha256] = next
1082
+ }
1083
+ activeDownloadLock.unlock()
1084
+ }
1085
+
1086
+ private func validateArchiveRequirements(
1087
+ _ expectedEntries: [FirmwareArchiveExpectedEntry]
1088
+ ) throws -> [FirmwareArchiveRequirement] {
1089
+ guard !expectedEntries.isEmpty, expectedEntries.count <= 4096 else {
1090
+ throw FirmwareArtifactStoreError.archiveInvalid(
1091
+ "Firmware archive expected entry count is invalid"
1092
+ )
1093
+ }
1094
+ var names = Set<String>()
1095
+ var canonicalNames = Set<String>()
1096
+ var totalSize: Int64 = 0
1097
+ for entry in expectedEntries {
1098
+ guard
1099
+ !entry.artifactId.isEmpty,
1100
+ entry.artifactId.count <= 160,
1101
+ entry.artifactId.allSatisfy({
1102
+ $0.isLetter || $0.isNumber || "._-".contains($0)
1103
+ }),
1104
+ isPortableArchiveEntryName(
1105
+ entry.entryName,
1106
+ canonicalNames: &canonicalNames
1107
+ ),
1108
+ names.insert(entry.entryName).inserted,
1109
+ entry.expectedSize.isFinite,
1110
+ entry.expectedSize > 0,
1111
+ entry.expectedSize <= Double(128 * 1024 * 1024),
1112
+ entry.expectedSize.rounded() == entry.expectedSize,
1113
+ entry.expectedSha256.range(
1114
+ of: "^[a-fA-F0-9]{64}$",
1115
+ options: .regularExpression
1116
+ ) != nil
1117
+ else {
1118
+ throw FirmwareArtifactStoreError.archiveInvalid(
1119
+ "Firmware archive expected entry is invalid"
1120
+ )
1121
+ }
1122
+ totalSize += Int64(entry.expectedSize)
1123
+ guard totalSize <= 512 * 1024 * 1024 else {
1124
+ throw FirmwareArtifactStoreError.archiveInvalid(
1125
+ "Firmware archive expected entries exceed limits"
1126
+ )
1127
+ }
1128
+ }
1129
+ return expectedEntries.map {
1130
+ FirmwareArchiveRequirement(
1131
+ entryName: $0.entryName,
1132
+ expectedSize: Int64($0.expectedSize),
1133
+ expectedSha256: $0.expectedSha256.lowercased()
1134
+ )
1135
+ }
1136
+ }
1137
+
1138
+ private func resolveArchiveRequirements(
1139
+ _ expectedEntries: [FirmwareArchiveExpectedEntry]?,
1140
+ archiveEntries: [FirmwareArchiveEntryInfo]
1141
+ ) throws -> [FirmwareArchiveRequirement] {
1142
+ if let expectedEntries {
1143
+ return try validateArchiveRequirements(expectedEntries)
1144
+ }
1145
+ if let issue = firmwareArchiveDiscoveredEntriesIssue(
1146
+ archiveEntries.map(\.name)
1147
+ ) {
1148
+ switch issue {
1149
+ case .empty:
1150
+ throw FirmwareArtifactStoreError.archiveInvalid(
1151
+ "Firmware archive must contain at least one entry"
1152
+ )
1153
+ case .duplicateName:
1154
+ throw FirmwareArtifactStoreError.archiveInvalid(
1155
+ "Firmware archive contains duplicate entry names"
1156
+ )
1157
+ }
1158
+ }
1159
+ return archiveEntries.map {
1160
+ FirmwareArchiveRequirement(
1161
+ entryName: $0.name,
1162
+ expectedSize: $0.uncompressedSize,
1163
+ expectedSha256: nil
1164
+ )
1165
+ }
1166
+ }
1167
+
1168
+ private func validateArchiveEntries(
1169
+ _ entries: [FirmwareArchiveEntryInfo],
1170
+ requirements: [FirmwareArchiveRequirement]
1171
+ ) throws {
1172
+ guard entries.count == requirements.count else {
1173
+ throw FirmwareArtifactStoreError.archiveInvalid(
1174
+ "Firmware archive has missing or extra entries"
1175
+ )
1176
+ }
1177
+ let requirementsByName = Dictionary(
1178
+ uniqueKeysWithValues: requirements.map { ($0.entryName, $0) }
1179
+ )
1180
+ var names = Set<String>()
1181
+ var canonicalNames = Set<String>()
1182
+ var totalSize: Int64 = 0
1183
+ for entry in entries {
1184
+ guard let requirement = requirementsByName[entry.name] else {
1185
+ throw FirmwareArtifactStoreError.archiveInvalid(
1186
+ "Firmware archive contains an unexpected entry"
1187
+ )
1188
+ }
1189
+ let (nextTotalSize, overflowed) = totalSize.addingReportingOverflow(
1190
+ entry.uncompressedSize
1191
+ )
1192
+ guard !overflowed else {
1193
+ throw FirmwareArtifactStoreError.archiveInvalid(
1194
+ "Firmware archive expanded size is invalid"
1195
+ )
1196
+ }
1197
+ totalSize = nextTotalSize
1198
+ guard
1199
+ names.insert(entry.name).inserted,
1200
+ isPortableArchiveEntryName(
1201
+ entry.name,
1202
+ canonicalNames: &canonicalNames
1203
+ ),
1204
+ entry.uncompressedSize == requirement.expectedSize,
1205
+ entry.uncompressedSize > 0,
1206
+ entry.uncompressedSize <= 128 * 1024 * 1024,
1207
+ totalSize <= 512 * 1024 * 1024,
1208
+ entry.compressedSize >= 0,
1209
+ entry.compressedSize <= 512 * 1024 * 1024,
1210
+ entry.uncompressedSize <= max(entry.compressedSize, 1) * 1000,
1211
+ entry.diskNumber == 0,
1212
+ entry.flags & 1 == 0,
1213
+ entry.compressionMethod == 0 || entry.compressionMethod == 8,
1214
+ entry.linkName?.isEmpty != false,
1215
+ isRegularArchiveEntry(entry)
1216
+ else {
1217
+ throw FirmwareArtifactStoreError.archiveInvalid(
1218
+ "Firmware archive entry metadata is invalid"
1219
+ )
1220
+ }
1221
+ }
1222
+ }
1223
+
1224
+ private func isPortableArchiveEntryName(
1225
+ _ name: String,
1226
+ canonicalNames: inout Set<String>
1227
+ ) -> Bool {
1228
+ let lowered = name.precomposedStringWithCanonicalMapping.lowercased()
1229
+ let components = name.split(
1230
+ separator: "/",
1231
+ omittingEmptySubsequences: false
1232
+ )
1233
+ let nestedArchiveExtensions = [
1234
+ ".zip", ".7z", ".rar", ".tar", ".gz", ".tgz",
1235
+ ]
1236
+ guard
1237
+ !name.isEmpty,
1238
+ name.count <= 512,
1239
+ name == name.precomposedStringWithCanonicalMapping,
1240
+ !name.hasPrefix("/"),
1241
+ !name.hasPrefix("\\"),
1242
+ !name.contains("\\"),
1243
+ !name.contains(":"),
1244
+ name.unicodeScalars.allSatisfy({
1245
+ !CharacterSet.controlCharacters.contains($0)
1246
+ }),
1247
+ components.allSatisfy({
1248
+ !$0.isEmpty &&
1249
+ $0 != "." &&
1250
+ $0 != ".." &&
1251
+ !$0.hasSuffix(".") &&
1252
+ !$0.hasSuffix(" ")
1253
+ }),
1254
+ !nestedArchiveExtensions.contains(where: {
1255
+ lowered.hasSuffix($0)
1256
+ }),
1257
+ canonicalNames.insert(lowered).inserted
1258
+ else {
1259
+ return false
1260
+ }
1261
+ return true
1262
+ }
1263
+
1264
+ private func isRegularArchiveEntry(
1265
+ _ entry: FirmwareArchiveEntryInfo
1266
+ ) -> Bool {
1267
+ let hostSystem = UInt8(entry.versionMadeBy >> 8)
1268
+ if hostSystem == 3 || hostSystem == 19 {
1269
+ let fileType = (entry.externalAttributes >> 16) & 0xF000
1270
+ return fileType == 0 || fileType == 0x8000
1271
+ }
1272
+ return entry.externalAttributes & 0x10 == 0
1273
+ }
1274
+
1275
+ private func streamDownload(
1276
+ _ params: FirmwareArtifactDownloadParams,
1277
+ partialURL: URL,
1278
+ resumeOffset: Int64,
1279
+ validated: ValidatedDownload
1280
+ ) async throws {
1281
+ do {
1282
+ try await FirmwareArtifactWallClockDeadline.run(
1283
+ timeoutSeconds:
1284
+ params.overallDeadlineSeconds ?? Self.defaultDownloadDeadline
1285
+ ) { [self] in
1286
+ try await streamDownloadWithinDeadline(
1287
+ params,
1288
+ partialURL: partialURL,
1289
+ resumeOffset: resumeOffset,
1290
+ validated: validated
1291
+ )
1292
+ }
1293
+ } catch FirmwareArtifactDeadlineError.exceeded {
1294
+ throw FirmwareArtifactStoreError.downloadFailed(
1295
+ "ARTIFACT_DEADLINE_EXCEEDED: firmware download exceeded its deadline"
1296
+ )
1297
+ }
1298
+ }
1299
+
1300
+ private func streamDownloadWithinDeadline(
1301
+ _ params: FirmwareArtifactDownloadParams,
1302
+ partialURL: URL,
1303
+ resumeOffset: Int64,
1304
+ validated: ValidatedDownload
1305
+ ) async throws {
1306
+ guard let url = URL(string: params.url), let hostname = url.host else {
1307
+ throw FirmwareArtifactStoreError.invalidInput("Invalid firmware URL")
1308
+ }
1309
+ var request = URLRequest(url: url)
1310
+ request.cachePolicy = .reloadIgnoringLocalCacheData
1311
+ request.timeoutInterval =
1312
+ params.overallDeadlineSeconds ?? Self.defaultDownloadDeadline
1313
+ request.setValue("identity", forHTTPHeaderField: "Accept-Encoding")
1314
+ if resumeOffset > 0 {
1315
+ request.setValue("bytes=\(resumeOffset)-", forHTTPHeaderField: "Range")
1316
+ }
1317
+
1318
+ let streamDelegate = FirmwareArtifactStreamDelegate(
1319
+ partialURL: partialURL,
1320
+ hostname: hostname,
1321
+ resumeOffset: resumeOffset,
1322
+ expectedSize: validated.expectedSize,
1323
+ maxBytes: validated.maxBytes,
1324
+ isCancelled: { [weak self] in
1325
+ Task.isCancelled ||
1326
+ self?.isTransactionCancelled(params.transactionId) == true
1327
+ }
1328
+ )
1329
+ let session = URLSession(
1330
+ configuration: .ephemeral,
1331
+ delegate: streamDelegate,
1332
+ delegateQueue: nil
1333
+ )
1334
+ defer {
1335
+ session.finishTasksAndInvalidate()
1336
+ }
1337
+
1338
+ do {
1339
+ try await streamDelegate.run(session: session, request: request)
1340
+ } catch let error as FirmwareArtifactStoreError {
1341
+ throw error
1342
+ } catch {
1343
+ if isCancellationError(
1344
+ error,
1345
+ transactionId: params.transactionId
1346
+ ) {
1347
+ throw FirmwareArtifactStoreError.downloadFailed(
1348
+ "ARTIFACT_CANCELLED: firmware artifact download was cancelled"
1349
+ )
1350
+ }
1351
+ if isFirmwareArtifactTLSError(error) {
1352
+ throw FirmwareArtifactStoreError.downloadFailed(
1353
+ "ARTIFACT_TLS_FAILED: firmware TLS validation failed"
1354
+ )
1355
+ }
1356
+ throw FirmwareArtifactStoreError.downloadFailed(
1357
+ "ARTIFACT_NETWORK_FAILED: firmware request failed"
1358
+ )
1359
+ }
1360
+ try rejectIfCancelled(transactionId: params.transactionId)
1361
+ }
1362
+
1363
+ private func rejectIfCancelled(transactionId: String) throws {
1364
+ if isTransactionCancelled(transactionId) {
1365
+ throw FirmwareArtifactStoreError.downloadFailed(
1366
+ "ARTIFACT_CANCELLED: firmware artifact download was cancelled"
1367
+ )
1368
+ }
1369
+ }
1370
+
1371
+ func isTransactionCancelled(_ transactionId: String) -> Bool {
1372
+ cancellationLock.withFirmwareArtifactLock {
1373
+ cancelledTransactions.contains(transactionId)
1374
+ }
1375
+ }
1376
+
1377
+ private func isCancellationError(
1378
+ _ error: Error,
1379
+ transactionId: String
1380
+ ) -> Bool {
1381
+ if error is CancellationError ||
1382
+ isTransactionCancelled(transactionId) {
1383
+ return true
1384
+ }
1385
+ let nsError = error as NSError
1386
+ return nsError.domain == NSURLErrorDomain &&
1387
+ nsError.code == NSURLErrorCancelled
1388
+ }
1389
+
1390
+ private func validateStoredArtifact(
1391
+ fileURL: URL,
1392
+ expectedSize: Int64,
1393
+ expectedSha256: String
1394
+ ) throws -> StoredFirmwareArtifact {
1395
+ let size = try fileSize(fileURL)
1396
+ guard size == expectedSize else {
1397
+ throw FirmwareArtifactStoreError.integrityMismatch(
1398
+ "ARTIFACT_INTEGRITY_FAILED: firmware artifact size mismatch"
1399
+ )
1400
+ }
1401
+ let sha256 = try hashFile(fileURL)
1402
+ guard sha256 == expectedSha256 else {
1403
+ throw FirmwareArtifactStoreError.integrityMismatch(
1404
+ "ARTIFACT_INTEGRITY_FAILED: firmware artifact SHA-256 mismatch"
1405
+ )
1406
+ }
1407
+ return StoredFirmwareArtifact(
1408
+ artifactRef: "fw:\(sha256)",
1409
+ size: size,
1410
+ sha256: sha256,
1411
+ fileURL: fileURL
1412
+ )
1413
+ }
1414
+
1415
+ private func validateDownloadedArtifact(
1416
+ fileURL: URL,
1417
+ expectedSize: Int64?,
1418
+ expectedSha256: String?,
1419
+ maxBytes: Int64
1420
+ ) throws -> StoredFirmwareArtifact {
1421
+ let size = try fileSize(fileURL)
1422
+ guard
1423
+ size > 0,
1424
+ size <= maxBytes,
1425
+ expectedSize.map({ size == $0 }) ?? true
1426
+ else {
1427
+ throw FirmwareArtifactStoreError.integrityMismatch(
1428
+ "ARTIFACT_INTEGRITY_FAILED: firmware artifact size mismatch"
1429
+ )
1430
+ }
1431
+ let sha256 = try hashFile(fileURL)
1432
+ guard expectedSha256.map({ sha256 == $0 }) ?? true else {
1433
+ throw FirmwareArtifactStoreError.integrityMismatch(
1434
+ "ARTIFACT_INTEGRITY_FAILED: firmware artifact SHA-256 mismatch"
1435
+ )
1436
+ }
1437
+ return StoredFirmwareArtifact(
1438
+ artifactRef: "fw:\(sha256)",
1439
+ size: size,
1440
+ sha256: sha256,
1441
+ fileURL: fileURL
1442
+ )
1443
+ }
1444
+
1445
+ private func resolveArtifactURL(_ artifactRef: String) throws -> URL {
1446
+ guard artifactRef.hasPrefix("fw:") else {
1447
+ throw FirmwareArtifactStoreError.invalidInput("Invalid firmware artifactRef")
1448
+ }
1449
+ let sha256 = String(artifactRef.dropFirst(3))
1450
+ guard sha256.range(of: "^[a-f0-9]{64}$", options: .regularExpression) != nil else {
1451
+ throw FirmwareArtifactStoreError.invalidInput("Invalid firmware artifactRef")
1452
+ }
1453
+ let url = artifactURL(sha256: sha256)
1454
+ guard fileManager.fileExists(atPath: url.path) else {
1455
+ throw FirmwareArtifactStoreError.invalidInput("Firmware artifact not found")
1456
+ }
1457
+ return url
1458
+ }
1459
+
1460
+ private func artifactURL(sha256: String) -> URL {
1461
+ rootURL.appendingPathComponent("\(sha256).bin", isDirectory: false)
1462
+ }
1463
+
1464
+ private func fileSize(_ url: URL) throws -> Int64 {
1465
+ let attributes = try fileManager.attributesOfItem(atPath: url.path)
1466
+ return (attributes[.size] as? NSNumber)?.int64Value ?? 0
1467
+ }
1468
+
1469
+ private func hashFile(_ url: URL) throws -> String {
1470
+ let handle = try FileHandle(forReadingFrom: url)
1471
+ defer { try? handle.close() }
1472
+ var hasher = SHA256()
1473
+ while let data = try handle.read(upToCount: 256 * 1024), !data.isEmpty {
1474
+ hasher.update(data: data)
1475
+ }
1476
+ return hasher.finalize().map { String(format: "%02x", $0) }.joined()
1477
+ }
1478
+
1479
+ private func promote(source: URL, destination: URL) throws {
1480
+ let stagingURL = destination
1481
+ .deletingLastPathComponent()
1482
+ .appendingPathComponent(".promote-\(UUID().uuidString)", isDirectory: false)
1483
+ try fileManager.moveItem(at: source, to: stagingURL)
1484
+ defer {
1485
+ if fileManager.fileExists(atPath: stagingURL.path) {
1486
+ try? fileManager.removeItem(at: stagingURL)
1487
+ }
1488
+ }
1489
+ if fileManager.fileExists(atPath: destination.path) {
1490
+ _ = try fileManager.replaceItemAt(
1491
+ destination,
1492
+ withItemAt: stagingURL,
1493
+ backupItemName: nil,
1494
+ options: []
1495
+ )
1496
+ } else {
1497
+ try fileManager.moveItem(at: stagingURL, to: destination)
1498
+ }
1499
+ }
1500
+ }
1501
+
1502
+ private extension NSLock {
1503
+ func withFirmwareArtifactLock<T>(_ body: () -> T) -> T {
1504
+ lock()
1505
+ defer { unlock() }
1506
+ return body()
1507
+ }
1508
+ }