@onekeyfe/react-native-range-downloader 3.0.80 → 3.0.81-alpha.0

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 (113) hide show
  1. package/ReactNativeRangeDownloader.podspec +8 -0
  2. package/android/build.gradle +1 -0
  3. package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveRules.kt +284 -0
  4. package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactStore.kt +842 -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 +75 -0
  7. package/ios/FirmwareArchiveMinizipBridge.h +33 -0
  8. package/ios/FirmwareArchiveMinizipBridge.mm +213 -0
  9. package/ios/FirmwareArtifactStore.swift +1276 -0
  10. package/ios/FirmwareBackgroundSessionEventRouter.swift +17 -0
  11. package/ios/ReactNativeRangeDownloader.swift +638 -6
  12. package/lib/module/index.js +2 -1
  13. package/lib/module/index.js.map +1 -1
  14. package/lib/typescript/src/ReactNativeRangeDownloader.nitro.d.ts +95 -1
  15. package/lib/typescript/src/ReactNativeRangeDownloader.nitro.d.ts.map +1 -1
  16. package/lib/typescript/src/index.d.ts +1 -0
  17. package/lib/typescript/src/index.d.ts.map +1 -1
  18. package/nitrogen/generated/android/c++/JDownloadChannel.hpp +3 -0
  19. package/nitrogen/generated/android/c++/JFirmwareArchiveExpectedEntry.hpp +69 -0
  20. package/nitrogen/generated/android/c++/JFirmwareArchiveMaterializeParams.hpp +86 -0
  21. package/nitrogen/generated/android/c++/JFirmwareArchiveMaterializeResult.hpp +80 -0
  22. package/nitrogen/generated/android/c++/JFirmwareArchiveMaterializedArtifact.hpp +63 -0
  23. package/nitrogen/generated/android/c++/JFirmwareArtifactCancelParams.hpp +57 -0
  24. package/nitrogen/generated/android/c++/JFirmwareArtifactCapabilities.hpp +88 -0
  25. package/nitrogen/generated/android/c++/JFirmwareArtifactDownloadParams.hpp +98 -0
  26. package/nitrogen/generated/android/c++/JFirmwareArtifactLease.hpp +57 -0
  27. package/nitrogen/generated/android/c++/JFirmwareArtifactLeaseCreateParams.hpp +57 -0
  28. package/nitrogen/generated/android/c++/JFirmwareArtifactLeaseReconcileParams.hpp +76 -0
  29. package/nitrogen/generated/android/c++/JFirmwareArtifactLeaseReleaseParams.hpp +61 -0
  30. package/nitrogen/generated/android/c++/JFirmwareArtifactLeaseRetainParams.hpp +61 -0
  31. package/nitrogen/generated/android/c++/JFirmwareArtifactReaderCloseParams.hpp +57 -0
  32. package/nitrogen/generated/android/c++/JFirmwareArtifactReaderInfo.hpp +61 -0
  33. package/nitrogen/generated/android/c++/JFirmwareArtifactReaderReadParams.hpp +65 -0
  34. package/nitrogen/generated/android/c++/JFirmwareArtifactReceipt.hpp +65 -0
  35. package/nitrogen/generated/android/c++/JFirmwareArtifactRefParams.hpp +57 -0
  36. package/nitrogen/generated/android/c++/JFirmwareArtifactSweepResult.hpp +61 -0
  37. package/nitrogen/generated/android/c++/JHybridReactNativeRangeDownloaderSpec.cpp +267 -0
  38. package/nitrogen/generated/android/c++/JHybridReactNativeRangeDownloaderSpec.hpp +13 -0
  39. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/DownloadChannel.kt +2 -1
  40. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveExpectedEntry.kt +47 -0
  41. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveMaterializeParams.kt +44 -0
  42. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveMaterializeResult.kt +38 -0
  43. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveMaterializedArtifact.kt +41 -0
  44. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactCancelParams.kt +38 -0
  45. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactCapabilities.kt +47 -0
  46. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactDownloadParams.kt +68 -0
  47. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactLease.kt +38 -0
  48. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactLeaseCreateParams.kt +38 -0
  49. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactLeaseReconcileParams.kt +38 -0
  50. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactLeaseReleaseParams.kt +41 -0
  51. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactLeaseRetainParams.kt +41 -0
  52. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactReaderCloseParams.kt +38 -0
  53. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactReaderInfo.kt +41 -0
  54. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactReaderReadParams.kt +44 -0
  55. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactReceipt.kt +44 -0
  56. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactRefParams.kt +38 -0
  57. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactSweepResult.kt +41 -0
  58. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/HybridReactNativeRangeDownloaderSpec.kt +53 -0
  59. package/nitrogen/generated/ios/ReactNativeRangeDownloader-Swift-Cxx-Bridge.cpp +48 -0
  60. package/nitrogen/generated/ios/ReactNativeRangeDownloader-Swift-Cxx-Bridge.hpp +329 -0
  61. package/nitrogen/generated/ios/ReactNativeRangeDownloader-Swift-Cxx-Umbrella.hpp +56 -0
  62. package/nitrogen/generated/ios/c++/HybridReactNativeRangeDownloaderSpecSwift.hpp +163 -0
  63. package/nitrogen/generated/ios/swift/DownloadChannel.swift +4 -0
  64. package/nitrogen/generated/ios/swift/FirmwareArchiveExpectedEntry.swift +69 -0
  65. package/nitrogen/generated/ios/swift/FirmwareArchiveMaterializeParams.swift +70 -0
  66. package/nitrogen/generated/ios/swift/FirmwareArchiveMaterializeResult.swift +48 -0
  67. package/nitrogen/generated/ios/swift/FirmwareArchiveMaterializedArtifact.swift +47 -0
  68. package/nitrogen/generated/ios/swift/FirmwareArtifactCancelParams.swift +36 -0
  69. package/nitrogen/generated/ios/swift/FirmwareArtifactCapabilities.swift +81 -0
  70. package/nitrogen/generated/ios/swift/FirmwareArtifactDownloadParams.swift +177 -0
  71. package/nitrogen/generated/ios/swift/FirmwareArtifactLease.swift +36 -0
  72. package/nitrogen/generated/ios/swift/FirmwareArtifactLeaseCreateParams.swift +36 -0
  73. package/nitrogen/generated/ios/swift/FirmwareArtifactLeaseReconcileParams.swift +48 -0
  74. package/nitrogen/generated/ios/swift/FirmwareArtifactLeaseReleaseParams.swift +47 -0
  75. package/nitrogen/generated/ios/swift/FirmwareArtifactLeaseRetainParams.swift +47 -0
  76. package/nitrogen/generated/ios/swift/FirmwareArtifactReaderCloseParams.swift +36 -0
  77. package/nitrogen/generated/ios/swift/FirmwareArtifactReaderInfo.swift +47 -0
  78. package/nitrogen/generated/ios/swift/FirmwareArtifactReaderReadParams.swift +58 -0
  79. package/nitrogen/generated/ios/swift/FirmwareArtifactReceipt.swift +58 -0
  80. package/nitrogen/generated/ios/swift/FirmwareArtifactRefParams.swift +36 -0
  81. package/nitrogen/generated/ios/swift/FirmwareArtifactSweepResult.swift +47 -0
  82. package/nitrogen/generated/ios/swift/Func_void_FirmwareArchiveMaterializeResult.swift +47 -0
  83. package/nitrogen/generated/ios/swift/Func_void_FirmwareArtifactLease.swift +47 -0
  84. package/nitrogen/generated/ios/swift/Func_void_FirmwareArtifactReaderInfo.swift +47 -0
  85. package/nitrogen/generated/ios/swift/Func_void_FirmwareArtifactReceipt.swift +47 -0
  86. package/nitrogen/generated/ios/swift/Func_void_FirmwareArtifactSweepResult.swift +47 -0
  87. package/nitrogen/generated/ios/swift/Func_void_std__shared_ptr_ArrayBuffer_.swift +47 -0
  88. package/nitrogen/generated/ios/swift/HybridReactNativeRangeDownloaderSpec.swift +13 -0
  89. package/nitrogen/generated/ios/swift/HybridReactNativeRangeDownloaderSpec_cxx.swift +240 -0
  90. package/nitrogen/generated/shared/c++/DownloadChannel.hpp +4 -0
  91. package/nitrogen/generated/shared/c++/FirmwareArchiveExpectedEntry.hpp +87 -0
  92. package/nitrogen/generated/shared/c++/FirmwareArchiveMaterializeParams.hpp +86 -0
  93. package/nitrogen/generated/shared/c++/FirmwareArchiveMaterializeResult.hpp +77 -0
  94. package/nitrogen/generated/shared/c++/FirmwareArchiveMaterializedArtifact.hpp +81 -0
  95. package/nitrogen/generated/shared/c++/FirmwareArtifactCancelParams.hpp +75 -0
  96. package/nitrogen/generated/shared/c++/FirmwareArtifactCapabilities.hpp +88 -0
  97. package/nitrogen/generated/shared/c++/FirmwareArtifactDownloadParams.hpp +116 -0
  98. package/nitrogen/generated/shared/c++/FirmwareArtifactLease.hpp +75 -0
  99. package/nitrogen/generated/shared/c++/FirmwareArtifactLeaseCreateParams.hpp +75 -0
  100. package/nitrogen/generated/shared/c++/FirmwareArtifactLeaseReconcileParams.hpp +76 -0
  101. package/nitrogen/generated/shared/c++/FirmwareArtifactLeaseReleaseParams.hpp +79 -0
  102. package/nitrogen/generated/shared/c++/FirmwareArtifactLeaseRetainParams.hpp +79 -0
  103. package/nitrogen/generated/shared/c++/FirmwareArtifactReaderCloseParams.hpp +75 -0
  104. package/nitrogen/generated/shared/c++/FirmwareArtifactReaderInfo.hpp +79 -0
  105. package/nitrogen/generated/shared/c++/FirmwareArtifactReaderReadParams.hpp +83 -0
  106. package/nitrogen/generated/shared/c++/FirmwareArtifactReceipt.hpp +83 -0
  107. package/nitrogen/generated/shared/c++/FirmwareArtifactRefParams.hpp +75 -0
  108. package/nitrogen/generated/shared/c++/FirmwareArtifactSweepResult.hpp +79 -0
  109. package/nitrogen/generated/shared/c++/HybridReactNativeRangeDownloaderSpec.cpp +13 -0
  110. package/nitrogen/generated/shared/c++/HybridReactNativeRangeDownloaderSpec.hpp +62 -0
  111. package/package.json +4 -2
  112. package/src/ReactNativeRangeDownloader.nitro.ts +134 -1
  113. package/src/index.tsx +3 -1
@@ -0,0 +1,1276 @@
1
+ import CryptoKit
2
+ import Foundation
3
+ import SniConnect
4
+
5
+ enum FirmwareArtifactStoreError: Error {
6
+ case invalidInput(String)
7
+ case downloadFailed(String)
8
+ case integrityMismatch(String)
9
+ case readerInvalid(String)
10
+ case archiveInvalid(String)
11
+ }
12
+
13
+ struct StoredFirmwareArtifact: Sendable {
14
+ let artifactRef: String
15
+ let size: Int64
16
+ let sha256: String
17
+ let fileURL: URL
18
+ }
19
+
20
+ struct StoredFirmwareArchiveEntry {
21
+ let entryName: String
22
+ let artifact: StoredFirmwareArtifact
23
+ }
24
+
25
+ private struct StagedFirmwareArchiveEntry {
26
+ let entryName: String
27
+ let size: Int64
28
+ let sha256: String
29
+ let stagingURL: URL
30
+ }
31
+
32
+ private final class FirmwareArtifactRedirectDelegate: NSObject, URLSessionTaskDelegate {
33
+ func urlSession(
34
+ _ session: URLSession,
35
+ task: URLSessionTask,
36
+ willPerformHTTPRedirection response: HTTPURLResponse,
37
+ newRequest request: URLRequest,
38
+ completionHandler: @escaping (URLRequest?) -> Void
39
+ ) {
40
+ completionHandler(nil)
41
+ }
42
+ }
43
+
44
+ private actor FirmwareArtifactDownloadCoordinator {
45
+ private struct ActiveDownload {
46
+ let transactionId: String
47
+ let task: Task<StoredFirmwareArtifact, Error>
48
+ }
49
+
50
+ private var downloads: [String: ActiveDownload] = [:]
51
+
52
+ func run(
53
+ key: String,
54
+ transactionId: String,
55
+ operation: @escaping () async throws -> StoredFirmwareArtifact
56
+ ) async throws -> StoredFirmwareArtifact {
57
+ if let download = downloads[key] {
58
+ return try await download.task.value
59
+ }
60
+ let task = Task {
61
+ try await operation()
62
+ }
63
+ downloads[key] = ActiveDownload(
64
+ transactionId: transactionId,
65
+ task: task
66
+ )
67
+ defer { downloads[key] = nil }
68
+ return try await task.value
69
+ }
70
+
71
+ func cancel(transactionId: String) {
72
+ for download in downloads.values
73
+ where download.transactionId == transactionId {
74
+ download.task.cancel()
75
+ }
76
+ }
77
+ }
78
+
79
+ final class FirmwareArtifactStore {
80
+ static let shared = FirmwareArtifactStore()
81
+ static let maxReadBytes = 256 * 1024
82
+ private static let maxLeaseMetadataBytes: Int64 = 1024 * 1024
83
+ private static let maxTotalLeaseRefs = 8192
84
+ private static let finalArtifactGrace: TimeInterval = 24 * 60 * 60
85
+ private static let partialArtifactGrace: TimeInterval = 7 * 24 * 60 * 60
86
+
87
+ private struct OpenReader {
88
+ let handle: FileHandle
89
+ let fileURL: URL
90
+ let size: Int64
91
+ }
92
+
93
+ private struct LeaseState: Codable {
94
+ let transactionId: String
95
+ var artifactRefs: Set<String>
96
+ }
97
+
98
+ private struct LeaseEnvelope: Codable {
99
+ let schemaVersion: Int
100
+ var leases: [String: LeaseState]
101
+ }
102
+
103
+ private let fileManager = FileManager.default
104
+ private let downloadCoordinator = FirmwareArtifactDownloadCoordinator()
105
+ private let leaseLock = NSLock()
106
+ private let activeDownloadLock = NSLock()
107
+ private var activeDownloadCounts: [String: Int] = [:]
108
+ private let cancellationLock = NSLock()
109
+ private var cancelledTransactions: Set<String> = []
110
+ private let readerLock = NSLock()
111
+ private var readers: [String: OpenReader] = [:]
112
+
113
+ private lazy var rootURL: URL = {
114
+ let base = fileManager.urls(
115
+ for: .applicationSupportDirectory,
116
+ in: .userDomainMask
117
+ ).first!
118
+ let root = base.appendingPathComponent("onekey-firmware-artifacts", isDirectory: true)
119
+ try? fileManager.createDirectory(at: root, withIntermediateDirectories: true)
120
+ return root
121
+ }()
122
+
123
+ private init() {}
124
+
125
+ static func validateDownloadParams(_ params: FirmwareArtifactDownloadParams) throws {
126
+ guard
127
+ !params.taskId.isEmpty,
128
+ params.taskId.count <= 100,
129
+ params.taskId.allSatisfy({ $0.isLetter || $0.isNumber || "._-".contains($0) })
130
+ else {
131
+ throw FirmwareArtifactStoreError.invalidInput("Invalid firmware taskId")
132
+ }
133
+ guard
134
+ isSafeIdentifier(params.transactionId),
135
+ isSafeIdentifier(params.artifactId),
136
+ isValidLeaseRef(params.leaseRef)
137
+ else {
138
+ throw FirmwareArtifactStoreError.invalidInput(
139
+ "Invalid firmware transaction or lease identity"
140
+ )
141
+ }
142
+ guard
143
+ let url = URL(string: params.url),
144
+ url.scheme?.lowercased() == "https",
145
+ url.host?.isEmpty == false,
146
+ url.user == nil,
147
+ url.password == nil,
148
+ url.fragment == nil,
149
+ url.port == nil || url.port == 443
150
+ else {
151
+ throw FirmwareArtifactStoreError.invalidInput("Firmware URL must use HTTPS port 443")
152
+ }
153
+ guard
154
+ params.expectedSize.isFinite,
155
+ params.expectedSize > 0,
156
+ params.expectedSize <= Double(Int64.max),
157
+ params.expectedSize.rounded() == params.expectedSize,
158
+ params.maxBytes.isFinite,
159
+ params.maxBytes == params.expectedSize,
160
+ params.maxBytes <= Double(512 * 1024 * 1024)
161
+ else {
162
+ throw FirmwareArtifactStoreError.invalidInput("Invalid firmware artifact size")
163
+ }
164
+ guard params.expectedSha256.range(
165
+ of: "^[a-fA-F0-9]{64}$",
166
+ options: .regularExpression
167
+ ) != nil else {
168
+ throw FirmwareArtifactStoreError.invalidInput("Invalid firmware artifact SHA-256")
169
+ }
170
+ guard params.routeType == "domain" || params.routeType == "pinnedIp" else {
171
+ throw FirmwareArtifactStoreError.invalidInput("Invalid firmware route type")
172
+ }
173
+ if params.routeType == "pinnedIp" {
174
+ guard let resolvedIp = params.resolvedIp, !resolvedIp.isEmpty else {
175
+ throw FirmwareArtifactStoreError.invalidInput("Pinned route requires resolvedIp")
176
+ }
177
+ } else if params.resolvedIp != nil {
178
+ throw FirmwareArtifactStoreError.invalidInput("Domain route must not include resolvedIp")
179
+ }
180
+ }
181
+
182
+ func download(_ params: FirmwareArtifactDownloadParams) async throws -> StoredFirmwareArtifact {
183
+ try Self.validateDownloadParams(params)
184
+ try rejectIfCancelled(transactionId: params.transactionId)
185
+ let expectedSha256 = params.expectedSha256.lowercased()
186
+ try retainExpectedArtifact(
187
+ leaseRef: params.leaseRef,
188
+ transactionId: params.transactionId,
189
+ artifactRef: "fw:\(expectedSha256)"
190
+ )
191
+ let key =
192
+ "\(params.transactionId):\(expectedSha256):\(Int64(params.expectedSize))"
193
+ markDownloadActive(expectedSha256, delta: 1)
194
+ do {
195
+ let artifact = try await downloadCoordinator.run(
196
+ key: key,
197
+ transactionId: params.transactionId
198
+ ) { [self] in
199
+ try rejectIfCancelled(transactionId: params.transactionId)
200
+ return try await downloadLocked(
201
+ params,
202
+ expectedSha256: expectedSha256
203
+ )
204
+ }
205
+ markDownloadActive(expectedSha256, delta: -1)
206
+ return artifact
207
+ } catch {
208
+ markDownloadActive(expectedSha256, delta: -1)
209
+ if error is CancellationError ||
210
+ isTransactionCancelled(params.transactionId) {
211
+ throw FirmwareArtifactStoreError.downloadFailed(
212
+ "ARTIFACT_CANCELLED: firmware artifact download was cancelled"
213
+ )
214
+ }
215
+ throw error
216
+ }
217
+ }
218
+
219
+ func cancelDownloads(transactionId: String) async throws {
220
+ guard Self.isSafeIdentifier(transactionId) else {
221
+ throw FirmwareArtifactStoreError.invalidInput(
222
+ "Invalid firmware transactionId"
223
+ )
224
+ }
225
+ cancellationLock.withFirmwareArtifactLock {
226
+ cancelledTransactions.insert(transactionId)
227
+ }
228
+ await downloadCoordinator.cancel(transactionId: transactionId)
229
+ try await RangeDownloader.shared.cancelFirmwareArtifactDownloads(
230
+ transactionId: transactionId
231
+ )
232
+ }
233
+
234
+ private func downloadLocked(
235
+ _ params: FirmwareArtifactDownloadParams,
236
+ expectedSha256: String
237
+ ) async throws -> StoredFirmwareArtifact {
238
+ let finalURL = artifactURL(sha256: expectedSha256)
239
+ if let existing = try? validateStoredArtifact(
240
+ fileURL: finalURL,
241
+ expectedSize: Int64(params.expectedSize),
242
+ expectedSha256: expectedSha256
243
+ ) {
244
+ return existing
245
+ }
246
+
247
+ if params.routeType == "domain" {
248
+ return try await RangeDownloader.shared.downloadFirmwareArtifact(
249
+ params: params
250
+ )
251
+ }
252
+
253
+ let partialURL = rootURL.appendingPathComponent(
254
+ "\(expectedSha256).\(params.taskId).partial",
255
+ isDirectory: false
256
+ )
257
+ if !fileManager.fileExists(atPath: partialURL.path) {
258
+ fileManager.createFile(atPath: partialURL.path, contents: nil)
259
+ }
260
+ var currentSize = try fileSize(partialURL)
261
+ if currentSize > Int64(params.expectedSize) {
262
+ try fileManager.removeItem(at: partialURL)
263
+ fileManager.createFile(atPath: partialURL.path, contents: nil)
264
+ currentSize = 0
265
+ }
266
+ if currentSize == Int64(params.expectedSize) {
267
+ if let completed = try? validateStoredArtifact(
268
+ fileURL: partialURL,
269
+ expectedSize: Int64(params.expectedSize),
270
+ expectedSha256: expectedSha256
271
+ ) {
272
+ try promote(source: partialURL, destination: finalURL)
273
+ return StoredFirmwareArtifact(
274
+ artifactRef: completed.artifactRef,
275
+ size: completed.size,
276
+ sha256: completed.sha256,
277
+ fileURL: finalURL
278
+ )
279
+ }
280
+ try fileManager.removeItem(at: partialURL)
281
+ fileManager.createFile(atPath: partialURL.path, contents: nil)
282
+ currentSize = 0
283
+ }
284
+
285
+ try await streamDownload(
286
+ params,
287
+ partialURL: partialURL,
288
+ resumeOffset: min(currentSize, Int64(params.expectedSize))
289
+ )
290
+ let artifact: StoredFirmwareArtifact
291
+ do {
292
+ artifact = try validateStoredArtifact(
293
+ fileURL: partialURL,
294
+ expectedSize: Int64(params.expectedSize),
295
+ expectedSha256: expectedSha256
296
+ )
297
+ } catch {
298
+ try? fileManager.removeItem(at: partialURL)
299
+ throw error
300
+ }
301
+ try promote(source: partialURL, destination: finalURL)
302
+ return StoredFirmwareArtifact(
303
+ artifactRef: artifact.artifactRef,
304
+ size: artifact.size,
305
+ sha256: artifact.sha256,
306
+ fileURL: finalURL
307
+ )
308
+ }
309
+
310
+ func acceptBackgroundDownload(
311
+ temporaryURL: URL,
312
+ leaseRef: String,
313
+ transactionId: String,
314
+ expectedSize: Int64,
315
+ expectedSha256: String
316
+ ) throws -> StoredFirmwareArtifact {
317
+ let normalizedExpectedSha256 = expectedSha256.lowercased()
318
+ try retainExpectedArtifact(
319
+ leaseRef: leaseRef,
320
+ transactionId: transactionId,
321
+ artifactRef: "fw:\(normalizedExpectedSha256)"
322
+ )
323
+ let finalURL = artifactURL(sha256: normalizedExpectedSha256)
324
+ if let existing = try? validateStoredArtifact(
325
+ fileURL: finalURL,
326
+ expectedSize: expectedSize,
327
+ expectedSha256: normalizedExpectedSha256
328
+ ) {
329
+ return existing
330
+ }
331
+ let artifact = try validateStoredArtifact(
332
+ fileURL: temporaryURL,
333
+ expectedSize: expectedSize,
334
+ expectedSha256: normalizedExpectedSha256
335
+ )
336
+ try promote(source: temporaryURL, destination: finalURL)
337
+ return StoredFirmwareArtifact(
338
+ artifactRef: artifact.artifactRef,
339
+ size: artifact.size,
340
+ sha256: artifact.sha256,
341
+ fileURL: finalURL
342
+ )
343
+ }
344
+
345
+ func storedArtifact(
346
+ expectedSize: Int64,
347
+ expectedSha256: String
348
+ ) throws -> StoredFirmwareArtifact? {
349
+ let finalURL = artifactURL(sha256: expectedSha256)
350
+ guard fileManager.fileExists(atPath: finalURL.path) else {
351
+ return nil
352
+ }
353
+ return try validateStoredArtifact(
354
+ fileURL: finalURL,
355
+ expectedSize: expectedSize,
356
+ expectedSha256: expectedSha256
357
+ )
358
+ }
359
+
360
+ func discard(artifactRef: String) throws {
361
+ let fileURL = try resolveArtifactURL(artifactRef)
362
+ leaseLock.lock()
363
+ let isRetained: Bool
364
+ do {
365
+ isRetained = try loadLeasesLocked().leases.values.contains {
366
+ $0.artifactRefs.contains(artifactRef)
367
+ }
368
+ } catch {
369
+ leaseLock.unlock()
370
+ throw error
371
+ }
372
+ leaseLock.unlock()
373
+ guard !isRetained else {
374
+ throw FirmwareArtifactStoreError.invalidInput(
375
+ "ARTIFACT_LEASED: firmware artifact is retained"
376
+ )
377
+ }
378
+ readerLock.lock()
379
+ let hasMatchingReader = readers.values.contains {
380
+ $0.fileURL == fileURL
381
+ }
382
+ readerLock.unlock()
383
+ guard !hasMatchingReader else {
384
+ throw FirmwareArtifactStoreError.readerInvalid(
385
+ "ARTIFACT_BUSY: firmware artifact has an open reader"
386
+ )
387
+ }
388
+ if fileManager.fileExists(atPath: fileURL.path) {
389
+ try fileManager.removeItem(at: fileURL)
390
+ }
391
+ }
392
+
393
+ func open(artifactRef: String) throws -> (readerId: String, size: Int64) {
394
+ let fileURL = try resolveArtifactURL(artifactRef)
395
+ let size = try fileSize(fileURL)
396
+ guard size > 0 else {
397
+ throw FirmwareArtifactStoreError.readerInvalid("Firmware artifact is empty")
398
+ }
399
+ let expectedSha256 = String(artifactRef.dropFirst(3))
400
+ guard try hashFile(fileURL) == expectedSha256 else {
401
+ throw FirmwareArtifactStoreError.integrityMismatch(
402
+ "Firmware artifact SHA-256 mismatch"
403
+ )
404
+ }
405
+ let readerId = UUID().uuidString
406
+ let reader = OpenReader(
407
+ handle: try FileHandle(forReadingFrom: fileURL),
408
+ fileURL: fileURL,
409
+ size: size
410
+ )
411
+ readerLock.lock()
412
+ readers[readerId] = reader
413
+ readerLock.unlock()
414
+ return (readerId, size)
415
+ }
416
+
417
+ func read(readerId: String, offset: Int64, length: Int) throws -> Data {
418
+ guard length > 0, length <= Self.maxReadBytes, offset >= 0 else {
419
+ throw FirmwareArtifactStoreError.readerInvalid("Invalid firmware artifact read")
420
+ }
421
+ readerLock.lock()
422
+ defer { readerLock.unlock() }
423
+ guard
424
+ let reader = readers[readerId],
425
+ offset <= reader.size - Int64(length)
426
+ else {
427
+ throw FirmwareArtifactStoreError.readerInvalid("Firmware artifact read is out of bounds")
428
+ }
429
+ try reader.handle.seek(toOffset: UInt64(offset))
430
+ let data = try reader.handle.read(upToCount: length) ?? Data()
431
+ guard data.count == length else {
432
+ throw FirmwareArtifactStoreError.readerInvalid("Firmware artifact returned a short read")
433
+ }
434
+ return data
435
+ }
436
+
437
+ func close(readerId: String) throws {
438
+ readerLock.lock()
439
+ let reader = readers.removeValue(forKey: readerId)
440
+ readerLock.unlock()
441
+ guard let reader else {
442
+ return
443
+ }
444
+ try reader.handle.close()
445
+ }
446
+
447
+ func materializeArchive(
448
+ leaseRef: String,
449
+ artifactRef: String,
450
+ expectedEntries: [FirmwareArchiveExpectedEntry]
451
+ ) throws -> [StoredFirmwareArchiveEntry] {
452
+ try requireLease(leaseRef)
453
+ let archiveURL = try resolveArtifactURL(artifactRef)
454
+ let scratchURL = rootURL.appendingPathComponent(
455
+ "archive-\(UUID().uuidString)",
456
+ isDirectory: true
457
+ )
458
+ try fileManager.createDirectory(at: scratchURL, withIntermediateDirectories: true)
459
+ defer { try? fileManager.removeItem(at: scratchURL) }
460
+
461
+ let requirements = try validateArchiveRequirements(expectedEntries)
462
+ let archiveEntries = try FirmwareArchiveMinizipBridge.scanArchive(
463
+ atPath: archiveURL.path
464
+ )
465
+ try validateArchiveEntries(
466
+ archiveEntries,
467
+ requirements: requirements
468
+ )
469
+
470
+ var staged: [StagedFirmwareArchiveEntry] = []
471
+ for (index, requirement) in requirements.enumerated() {
472
+ let stagingURL = scratchURL.appendingPathComponent(
473
+ "\(index).entry",
474
+ isDirectory: false
475
+ )
476
+ fileManager.createFile(atPath: stagingURL.path, contents: nil)
477
+ let handle = try FileHandle(forWritingTo: stagingURL)
478
+ var hasher = SHA256()
479
+ var actualSize: Int64 = 0
480
+ var consumerError: Error?
481
+ do {
482
+ try FirmwareArchiveMinizipBridge.extractEntryNamed(
483
+ requirement.entryName,
484
+ archivePath: archiveURL.path
485
+ ) { chunk in
486
+ guard consumerError == nil else {
487
+ return false
488
+ }
489
+ do {
490
+ actualSize += Int64(chunk.count)
491
+ guard actualSize <= Int64(requirement.expectedSize) else {
492
+ throw FirmwareArtifactStoreError.archiveInvalid(
493
+ "Firmware archive entry exceeds its expected size"
494
+ )
495
+ }
496
+ hasher.update(data: chunk)
497
+ try handle.write(contentsOf: chunk)
498
+ return true
499
+ } catch {
500
+ consumerError = error
501
+ return false
502
+ }
503
+ }
504
+ if let consumerError {
505
+ throw consumerError
506
+ }
507
+ try handle.synchronize()
508
+ try handle.close()
509
+ } catch {
510
+ try? handle.close()
511
+ throw error
512
+ }
513
+ let sha256 = hasher.finalize().map {
514
+ String(format: "%02x", $0)
515
+ }.joined()
516
+ guard
517
+ actualSize == Int64(requirement.expectedSize),
518
+ sha256 == requirement.expectedSha256.lowercased()
519
+ else {
520
+ throw FirmwareArtifactStoreError.archiveInvalid(
521
+ "Firmware archive entry integrity mismatch"
522
+ )
523
+ }
524
+ staged.append(
525
+ StagedFirmwareArchiveEntry(
526
+ entryName: requirement.entryName,
527
+ size: actualSize,
528
+ sha256: sha256,
529
+ stagingURL: stagingURL
530
+ )
531
+ )
532
+ }
533
+
534
+ return try staged.map { entry in
535
+ let destination = artifactURL(sha256: entry.sha256)
536
+ if (try? validateStoredArtifact(
537
+ fileURL: destination,
538
+ expectedSize: entry.size,
539
+ expectedSha256: entry.sha256
540
+ )) == nil {
541
+ try promote(source: entry.stagingURL, destination: destination)
542
+ }
543
+ let stored = StoredFirmwareArtifact(
544
+ artifactRef: "fw:\(entry.sha256)",
545
+ size: entry.size,
546
+ sha256: entry.sha256,
547
+ fileURL: destination
548
+ )
549
+ try retainExpectedArtifact(
550
+ leaseRef: leaseRef,
551
+ transactionId: nil,
552
+ artifactRef: stored.artifactRef
553
+ )
554
+ return StoredFirmwareArchiveEntry(
555
+ entryName: entry.entryName,
556
+ artifact: stored
557
+ )
558
+ }
559
+ }
560
+
561
+ func createLease(transactionId: String) throws -> String {
562
+ guard Self.isSafeIdentifier(transactionId) else {
563
+ throw FirmwareArtifactStoreError.invalidInput(
564
+ "Invalid firmware transactionId"
565
+ )
566
+ }
567
+ leaseLock.lock()
568
+ defer { leaseLock.unlock() }
569
+ var envelope = try loadLeasesLocked()
570
+ guard envelope.leases.count < 32 else {
571
+ throw FirmwareArtifactStoreError.invalidInput(
572
+ "Too many firmware artifact leases"
573
+ )
574
+ }
575
+ let leaseRef = "fwlease:\(UUID().uuidString.lowercased())"
576
+ envelope.leases[leaseRef] = LeaseState(
577
+ transactionId: transactionId,
578
+ artifactRefs: []
579
+ )
580
+ try saveLeasesLocked(envelope)
581
+ return leaseRef
582
+ }
583
+
584
+ func retain(leaseRef: String, artifactRef: String) throws {
585
+ _ = try resolveArtifactURL(artifactRef)
586
+ try retainExpectedArtifact(
587
+ leaseRef: leaseRef,
588
+ transactionId: nil,
589
+ artifactRef: artifactRef
590
+ )
591
+ }
592
+
593
+ func releaseLease(leaseRef: String, disposition: String) throws {
594
+ guard
595
+ disposition == "completed" ||
596
+ disposition == "safeCancelled" ||
597
+ disposition == "safeAbandoned"
598
+ else {
599
+ throw FirmwareArtifactStoreError.invalidInput(
600
+ "Invalid firmware lease disposition"
601
+ )
602
+ }
603
+ let transactionId: String = try {
604
+ leaseLock.lock()
605
+ defer { leaseLock.unlock() }
606
+ var envelope = try loadLeasesLocked()
607
+ guard
608
+ let lease = envelope.leases.removeValue(
609
+ forKey: try validateLeaseRef(leaseRef)
610
+ )
611
+ else {
612
+ throw FirmwareArtifactStoreError.invalidInput(
613
+ "Firmware artifact lease is unavailable"
614
+ )
615
+ }
616
+ try saveLeasesLocked(envelope)
617
+ return lease.transactionId
618
+ }()
619
+ cancellationLock.withFirmwareArtifactLock {
620
+ cancelledTransactions.remove(transactionId)
621
+ }
622
+ }
623
+
624
+ func reconcileLeases(activeLeaseRefs: [String]) throws {
625
+ guard activeLeaseRefs.count <= 32 else {
626
+ throw FirmwareArtifactStoreError.invalidInput(
627
+ "Too many active firmware artifact leases"
628
+ )
629
+ }
630
+ let active = try Set(activeLeaseRefs.map(validateLeaseRef))
631
+ guard active.count == activeLeaseRefs.count else {
632
+ throw FirmwareArtifactStoreError.invalidInput(
633
+ "Duplicate active firmware artifact lease"
634
+ )
635
+ }
636
+ leaseLock.lock()
637
+ defer { leaseLock.unlock() }
638
+ var envelope = try loadLeasesLocked()
639
+ guard active.allSatisfy({ envelope.leases[$0] != nil }) else {
640
+ throw FirmwareArtifactStoreError.invalidInput(
641
+ "Firmware artifact lease reconciliation is incomplete"
642
+ )
643
+ }
644
+ envelope.leases = envelope.leases.filter { active.contains($0.key) }
645
+ try saveLeasesLocked(envelope)
646
+ }
647
+
648
+ func sweepOrphans() throws -> (deletedFiles: Int, deletedBytes: Int64) {
649
+ leaseLock.lock()
650
+ let retained: Set<String>
651
+ do {
652
+ retained = Set(
653
+ try loadLeasesLocked().leases.values
654
+ .flatMap(\.artifactRefs)
655
+ .map { String($0.dropFirst(3)) }
656
+ )
657
+ } catch {
658
+ leaseLock.unlock()
659
+ throw error
660
+ }
661
+ leaseLock.unlock()
662
+ activeDownloadLock.lock()
663
+ let active = Set(activeDownloadCounts.filter { $0.value > 0 }.keys)
664
+ activeDownloadLock.unlock()
665
+ readerLock.lock()
666
+ let openPaths = Set(readers.values.map(\.fileURL.path))
667
+ readerLock.unlock()
668
+
669
+ let now = Date()
670
+ var deletedFiles = 0
671
+ var deletedBytes: Int64 = 0
672
+ let files = try fileManager.contentsOfDirectory(
673
+ at: rootURL,
674
+ includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey],
675
+ options: [.skipsHiddenFiles]
676
+ )
677
+ for fileURL in files {
678
+ let name = fileURL.lastPathComponent
679
+ guard name != "leases.json", name.count >= 64 else { continue }
680
+ let sha256 = String(name.prefix(64))
681
+ guard
682
+ sha256.range(of: "^[a-f0-9]{64}$", options: .regularExpression) != nil,
683
+ !retained.contains(sha256),
684
+ !active.contains(sha256),
685
+ !openPaths.contains(fileURL.path)
686
+ else {
687
+ continue
688
+ }
689
+ let grace: TimeInterval
690
+ if name.hasSuffix(".bin") {
691
+ grace = Self.finalArtifactGrace
692
+ } else if name.hasSuffix(".partial") {
693
+ grace = Self.partialArtifactGrace
694
+ } else {
695
+ continue
696
+ }
697
+ let values = try fileURL.resourceValues(
698
+ forKeys: [.contentModificationDateKey, .fileSizeKey]
699
+ )
700
+ guard
701
+ let modifiedAt = values.contentModificationDate,
702
+ now.timeIntervalSince(modifiedAt) >= grace
703
+ else {
704
+ continue
705
+ }
706
+ let size = Int64(values.fileSize ?? 0)
707
+ try fileManager.removeItem(at: fileURL)
708
+ deletedFiles += 1
709
+ deletedBytes += size
710
+ }
711
+ return (deletedFiles, deletedBytes)
712
+ }
713
+
714
+ private func requireLease(_ leaseRef: String) throws {
715
+ leaseLock.lock()
716
+ defer { leaseLock.unlock() }
717
+ guard try loadLeasesLocked().leases[validateLeaseRef(leaseRef)] != nil else {
718
+ throw FirmwareArtifactStoreError.invalidInput(
719
+ "Firmware artifact lease is unavailable"
720
+ )
721
+ }
722
+ }
723
+
724
+ private func retainExpectedArtifact(
725
+ leaseRef: String,
726
+ transactionId: String?,
727
+ artifactRef: String
728
+ ) throws {
729
+ guard artifactRef.range(
730
+ of: "^fw:[a-f0-9]{64}$",
731
+ options: .regularExpression
732
+ ) != nil else {
733
+ throw FirmwareArtifactStoreError.invalidInput(
734
+ "Invalid firmware artifactRef"
735
+ )
736
+ }
737
+ leaseLock.lock()
738
+ defer { leaseLock.unlock() }
739
+ var envelope = try loadLeasesLocked()
740
+ let validatedLeaseRef = try validateLeaseRef(leaseRef)
741
+ guard var lease = envelope.leases[validatedLeaseRef] else {
742
+ throw FirmwareArtifactStoreError.invalidInput(
743
+ "Firmware artifact lease is unavailable"
744
+ )
745
+ }
746
+ if let transactionId, lease.transactionId != transactionId {
747
+ throw FirmwareArtifactStoreError.invalidInput(
748
+ "Firmware artifact lease transaction mismatch"
749
+ )
750
+ }
751
+ if lease.artifactRefs.insert(artifactRef).inserted {
752
+ envelope.leases[validatedLeaseRef] = lease
753
+ try saveLeasesLocked(envelope)
754
+ }
755
+ }
756
+
757
+ private func validateLeaseRef(_ leaseRef: String) throws -> String {
758
+ guard Self.isValidLeaseRef(leaseRef) else {
759
+ throw FirmwareArtifactStoreError.invalidInput(
760
+ "Invalid firmware leaseRef"
761
+ )
762
+ }
763
+ return leaseRef
764
+ }
765
+
766
+ private static func isValidLeaseRef(_ value: String) -> Bool {
767
+ value.range(
768
+ of: "^fwlease:[a-f0-9-]{36}$",
769
+ options: .regularExpression
770
+ ) != nil
771
+ }
772
+
773
+ private static func isSafeIdentifier(_ value: String) -> Bool {
774
+ value.range(
775
+ of: "^[A-Za-z0-9._:-]{1,160}$",
776
+ options: .regularExpression
777
+ ) != nil
778
+ }
779
+
780
+ private func loadLeasesLocked() throws -> LeaseEnvelope {
781
+ let url = rootURL.appendingPathComponent("leases.json", isDirectory: false)
782
+ guard fileManager.fileExists(atPath: url.path) else {
783
+ return LeaseEnvelope(schemaVersion: 1, leases: [:])
784
+ }
785
+ let fileSize = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0
786
+ guard fileSize > 0, Int64(fileSize) <= Self.maxLeaseMetadataBytes else {
787
+ throw FirmwareArtifactStoreError.invalidInput(
788
+ "Firmware lease metadata is too large"
789
+ )
790
+ }
791
+ let envelope = try JSONDecoder().decode(
792
+ LeaseEnvelope.self,
793
+ from: Data(contentsOf: url)
794
+ )
795
+ guard envelope.schemaVersion == 1, envelope.leases.count <= 32 else {
796
+ throw FirmwareArtifactStoreError.invalidInput(
797
+ "Unsupported firmware lease schema"
798
+ )
799
+ }
800
+ guard
801
+ envelope.leases.values.reduce(0, { $0 + $1.artifactRefs.count })
802
+ <= Self.maxTotalLeaseRefs
803
+ else {
804
+ throw FirmwareArtifactStoreError.invalidInput(
805
+ "Firmware lease metadata is too large"
806
+ )
807
+ }
808
+ for (leaseRef, lease) in envelope.leases {
809
+ guard
810
+ Self.isValidLeaseRef(leaseRef),
811
+ Self.isSafeIdentifier(lease.transactionId),
812
+ lease.artifactRefs.count <= 4096,
813
+ lease.artifactRefs.allSatisfy({
814
+ $0.range(
815
+ of: "^fw:[a-f0-9]{64}$",
816
+ options: .regularExpression
817
+ ) != nil
818
+ })
819
+ else {
820
+ throw FirmwareArtifactStoreError.invalidInput(
821
+ "Invalid persisted firmware lease"
822
+ )
823
+ }
824
+ }
825
+ return envelope
826
+ }
827
+
828
+ private func saveLeasesLocked(_ envelope: LeaseEnvelope) throws {
829
+ guard
830
+ envelope.schemaVersion == 1,
831
+ envelope.leases.count <= 32,
832
+ envelope.leases.values.allSatisfy({
833
+ $0.artifactRefs.count <= 4096
834
+ }),
835
+ envelope.leases.values.reduce(0, {
836
+ $0 + $1.artifactRefs.count
837
+ }) <= Self.maxTotalLeaseRefs
838
+ else {
839
+ throw FirmwareArtifactStoreError.invalidInput(
840
+ "Firmware lease metadata is too large"
841
+ )
842
+ }
843
+ let data = try JSONEncoder().encode(envelope)
844
+ guard Int64(data.count) <= Self.maxLeaseMetadataBytes else {
845
+ throw FirmwareArtifactStoreError.invalidInput(
846
+ "Firmware lease metadata is too large"
847
+ )
848
+ }
849
+ let url = rootURL.appendingPathComponent("leases.json", isDirectory: false)
850
+ try data.write(to: url, options: [.atomic])
851
+ }
852
+
853
+ private func markDownloadActive(_ sha256: String, delta: Int) {
854
+ activeDownloadLock.lock()
855
+ let next = (activeDownloadCounts[sha256] ?? 0) + delta
856
+ if next <= 0 {
857
+ activeDownloadCounts.removeValue(forKey: sha256)
858
+ } else {
859
+ activeDownloadCounts[sha256] = next
860
+ }
861
+ activeDownloadLock.unlock()
862
+ }
863
+
864
+ private func validateArchiveRequirements(
865
+ _ expectedEntries: [FirmwareArchiveExpectedEntry]
866
+ ) throws -> [FirmwareArchiveExpectedEntry] {
867
+ guard !expectedEntries.isEmpty, expectedEntries.count <= 4096 else {
868
+ throw FirmwareArtifactStoreError.archiveInvalid(
869
+ "Firmware archive expected entry count is invalid"
870
+ )
871
+ }
872
+ var names = Set<String>()
873
+ var canonicalNames = Set<String>()
874
+ var totalSize: Int64 = 0
875
+ for entry in expectedEntries {
876
+ guard
877
+ !entry.artifactId.isEmpty,
878
+ entry.artifactId.count <= 160,
879
+ entry.artifactId.allSatisfy({
880
+ $0.isLetter || $0.isNumber || "._-".contains($0)
881
+ }),
882
+ isPortableArchiveEntryName(
883
+ entry.entryName,
884
+ canonicalNames: &canonicalNames
885
+ ),
886
+ names.insert(entry.entryName).inserted,
887
+ entry.expectedSize.isFinite,
888
+ entry.expectedSize > 0,
889
+ entry.expectedSize <= Double(128 * 1024 * 1024),
890
+ entry.expectedSize.rounded() == entry.expectedSize,
891
+ entry.expectedSha256.range(
892
+ of: "^[a-fA-F0-9]{64}$",
893
+ options: .regularExpression
894
+ ) != nil
895
+ else {
896
+ throw FirmwareArtifactStoreError.archiveInvalid(
897
+ "Firmware archive expected entry is invalid"
898
+ )
899
+ }
900
+ totalSize += Int64(entry.expectedSize)
901
+ guard totalSize <= 512 * 1024 * 1024 else {
902
+ throw FirmwareArtifactStoreError.archiveInvalid(
903
+ "Firmware archive expected entries exceed limits"
904
+ )
905
+ }
906
+ }
907
+ return expectedEntries
908
+ }
909
+
910
+ private func validateArchiveEntries(
911
+ _ entries: [FirmwareArchiveEntryInfo],
912
+ requirements: [FirmwareArchiveExpectedEntry]
913
+ ) throws {
914
+ guard entries.count == requirements.count else {
915
+ throw FirmwareArtifactStoreError.archiveInvalid(
916
+ "Firmware archive has missing or extra entries"
917
+ )
918
+ }
919
+ let requirementsByName = Dictionary(
920
+ uniqueKeysWithValues: requirements.map { ($0.entryName, $0) }
921
+ )
922
+ var names = Set<String>()
923
+ var canonicalNames = Set<String>()
924
+ for entry in entries {
925
+ guard
926
+ names.insert(entry.name).inserted,
927
+ isPortableArchiveEntryName(
928
+ entry.name,
929
+ canonicalNames: &canonicalNames
930
+ ),
931
+ let requirement = requirementsByName[entry.name],
932
+ entry.uncompressedSize == Int64(requirement.expectedSize),
933
+ entry.uncompressedSize > 0,
934
+ entry.compressedSize >= 0,
935
+ entry.compressedSize <= 512 * 1024 * 1024,
936
+ entry.uncompressedSize <= max(entry.compressedSize, 1) * 1000,
937
+ entry.diskNumber == 0,
938
+ entry.flags & 1 == 0,
939
+ entry.compressionMethod == 0 || entry.compressionMethod == 8,
940
+ entry.linkName?.isEmpty != false,
941
+ isRegularArchiveEntry(entry)
942
+ else {
943
+ throw FirmwareArtifactStoreError.archiveInvalid(
944
+ "Firmware archive entry metadata is invalid"
945
+ )
946
+ }
947
+ }
948
+ }
949
+
950
+ private func isPortableArchiveEntryName(
951
+ _ name: String,
952
+ canonicalNames: inout Set<String>
953
+ ) -> Bool {
954
+ let lowered = name.precomposedStringWithCanonicalMapping.lowercased()
955
+ let components = name.split(
956
+ separator: "/",
957
+ omittingEmptySubsequences: false
958
+ )
959
+ let nestedArchiveExtensions = [
960
+ ".zip", ".7z", ".rar", ".tar", ".gz", ".tgz",
961
+ ]
962
+ guard
963
+ !name.isEmpty,
964
+ name.count <= 512,
965
+ name == name.precomposedStringWithCanonicalMapping,
966
+ !name.hasPrefix("/"),
967
+ !name.hasPrefix("\\"),
968
+ !name.contains("\\"),
969
+ !name.contains(":"),
970
+ name.unicodeScalars.allSatisfy({
971
+ !CharacterSet.controlCharacters.contains($0)
972
+ }),
973
+ components.allSatisfy({
974
+ !$0.isEmpty &&
975
+ $0 != "." &&
976
+ $0 != ".." &&
977
+ !$0.hasSuffix(".") &&
978
+ !$0.hasSuffix(" ")
979
+ }),
980
+ !nestedArchiveExtensions.contains(where: {
981
+ lowered.hasSuffix($0)
982
+ }),
983
+ canonicalNames.insert(lowered).inserted
984
+ else {
985
+ return false
986
+ }
987
+ return true
988
+ }
989
+
990
+ private func isRegularArchiveEntry(
991
+ _ entry: FirmwareArchiveEntryInfo
992
+ ) -> Bool {
993
+ let hostSystem = UInt8(entry.versionMadeBy >> 8)
994
+ if hostSystem == 3 || hostSystem == 19 {
995
+ let fileType = (entry.externalAttributes >> 16) & 0xF000
996
+ return fileType == 0 || fileType == 0x8000
997
+ }
998
+ return entry.externalAttributes & 0x10 == 0
999
+ }
1000
+
1001
+ private func streamDownload(
1002
+ _ params: FirmwareArtifactDownloadParams,
1003
+ partialURL: URL,
1004
+ resumeOffset: Int64
1005
+ ) async throws {
1006
+ guard let url = URL(string: params.url), let hostname = url.host else {
1007
+ throw FirmwareArtifactStoreError.invalidInput("Invalid firmware URL")
1008
+ }
1009
+ var request = URLRequest(url: url)
1010
+ request.cachePolicy = .reloadIgnoringLocalCacheData
1011
+ request.timeoutInterval = params.overallDeadlineSeconds ?? 180
1012
+ request.setValue("identity", forHTTPHeaderField: "Accept-Encoding")
1013
+ if resumeOffset > 0 {
1014
+ request.setValue("bytes=\(resumeOffset)-", forHTTPHeaderField: "Range")
1015
+ }
1016
+
1017
+ let pinnedSession: SniConnectPinnedSession?
1018
+ if params.routeType == "pinnedIp" {
1019
+ pinnedSession = try SniConnectPinnedTransport.makeSession(
1020
+ hostname: hostname,
1021
+ ip: params.resolvedIp!
1022
+ )
1023
+ } else {
1024
+ pinnedSession = nil
1025
+ }
1026
+ let domainDelegate = FirmwareArtifactRedirectDelegate()
1027
+ let session = pinnedSession?.session ?? URLSession(
1028
+ configuration: .ephemeral,
1029
+ delegate: domainDelegate,
1030
+ delegateQueue: nil
1031
+ )
1032
+ defer {
1033
+ if let pinnedSession {
1034
+ pinnedSession.close()
1035
+ } else {
1036
+ session.finishTasksAndInvalidate()
1037
+ }
1038
+ }
1039
+
1040
+ let bytes: URLSession.AsyncBytes
1041
+ let response: URLResponse
1042
+ do {
1043
+ (bytes, response) = try await session.bytes(for: request)
1044
+ } catch {
1045
+ if isCancellationError(
1046
+ error,
1047
+ transactionId: params.transactionId
1048
+ ) {
1049
+ throw FirmwareArtifactStoreError.downloadFailed(
1050
+ "ARTIFACT_CANCELLED: firmware artifact download was cancelled"
1051
+ )
1052
+ }
1053
+ throw FirmwareArtifactStoreError.downloadFailed(
1054
+ "ARTIFACT_NETWORK_FAILED: firmware request failed"
1055
+ )
1056
+ }
1057
+ guard let httpResponse = response as? HTTPURLResponse else {
1058
+ throw FirmwareArtifactStoreError.downloadFailed("Firmware response is not HTTP")
1059
+ }
1060
+ guard httpResponse.statusCode == 200 || httpResponse.statusCode == 206 else {
1061
+ throw FirmwareArtifactStoreError.downloadFailed(
1062
+ "ARTIFACT_HTTP_\(httpResponse.statusCode): firmware request failed"
1063
+ )
1064
+ }
1065
+ let append = resumeOffset > 0 && httpResponse.statusCode == 206
1066
+ if httpResponse.statusCode == 206 {
1067
+ guard
1068
+ let contentRange = httpResponse.value(forHTTPHeaderField: "Content-Range"),
1069
+ validateContentRange(
1070
+ contentRange,
1071
+ expectedStart: append ? resumeOffset : 0,
1072
+ expectedTotal: Int64(params.expectedSize)
1073
+ )
1074
+ else {
1075
+ throw FirmwareArtifactStoreError.downloadFailed(
1076
+ "ARTIFACT_PROTOCOL_INVALID: firmware resume Content-Range is invalid"
1077
+ )
1078
+ }
1079
+ }
1080
+ let handle = try FileHandle(forWritingTo: partialURL)
1081
+ defer { try? handle.close() }
1082
+ if append {
1083
+ try handle.seekToEnd()
1084
+ } else {
1085
+ try handle.truncate(atOffset: 0)
1086
+ }
1087
+
1088
+ var written = append ? resumeOffset : 0
1089
+ var buffer = Data()
1090
+ buffer.reserveCapacity(64 * 1024)
1091
+ do {
1092
+ for try await byte in bytes {
1093
+ buffer.append(byte)
1094
+ if buffer.count >= 64 * 1024 {
1095
+ written += Int64(buffer.count)
1096
+ guard written <= Int64(params.maxBytes) else {
1097
+ throw FirmwareArtifactStoreError.downloadFailed(
1098
+ "ARTIFACT_PROTOCOL_INVALID: firmware artifact exceeds maxBytes"
1099
+ )
1100
+ }
1101
+ try handle.write(contentsOf: buffer)
1102
+ buffer.removeAll(keepingCapacity: true)
1103
+ }
1104
+ }
1105
+ } catch let error as FirmwareArtifactStoreError {
1106
+ throw error
1107
+ } catch {
1108
+ if isCancellationError(
1109
+ error,
1110
+ transactionId: params.transactionId
1111
+ ) {
1112
+ throw FirmwareArtifactStoreError.downloadFailed(
1113
+ "ARTIFACT_CANCELLED: firmware artifact download was cancelled"
1114
+ )
1115
+ }
1116
+ throw FirmwareArtifactStoreError.downloadFailed(
1117
+ "ARTIFACT_NETWORK_FAILED: firmware response stream failed"
1118
+ )
1119
+ }
1120
+ if !buffer.isEmpty {
1121
+ written += Int64(buffer.count)
1122
+ guard written <= Int64(params.maxBytes) else {
1123
+ throw FirmwareArtifactStoreError.downloadFailed(
1124
+ "ARTIFACT_PROTOCOL_INVALID: firmware artifact exceeds maxBytes"
1125
+ )
1126
+ }
1127
+ try handle.write(contentsOf: buffer)
1128
+ }
1129
+ try handle.synchronize()
1130
+ try rejectIfCancelled(transactionId: params.transactionId)
1131
+ }
1132
+
1133
+ private func rejectIfCancelled(transactionId: String) throws {
1134
+ if isTransactionCancelled(transactionId) {
1135
+ throw FirmwareArtifactStoreError.downloadFailed(
1136
+ "ARTIFACT_CANCELLED: firmware artifact download was cancelled"
1137
+ )
1138
+ }
1139
+ }
1140
+
1141
+ func isTransactionCancelled(_ transactionId: String) -> Bool {
1142
+ cancellationLock.withFirmwareArtifactLock {
1143
+ cancelledTransactions.contains(transactionId)
1144
+ }
1145
+ }
1146
+
1147
+ private func isCancellationError(
1148
+ _ error: Error,
1149
+ transactionId: String
1150
+ ) -> Bool {
1151
+ if error is CancellationError ||
1152
+ isTransactionCancelled(transactionId) {
1153
+ return true
1154
+ }
1155
+ let nsError = error as NSError
1156
+ return nsError.domain == NSURLErrorDomain &&
1157
+ nsError.code == NSURLErrorCancelled
1158
+ }
1159
+
1160
+ private func validateContentRange(
1161
+ _ value: String,
1162
+ expectedStart: Int64,
1163
+ expectedTotal: Int64
1164
+ ) -> Bool {
1165
+ let pattern = #"^bytes ([0-9]+)-([0-9]+)/([0-9]+)$"#
1166
+ guard
1167
+ let expression = try? NSRegularExpression(pattern: pattern),
1168
+ let match = expression.firstMatch(
1169
+ in: value.lowercased(),
1170
+ range: NSRange(value.startIndex..., in: value)
1171
+ ),
1172
+ match.range.location != NSNotFound,
1173
+ let startRange = Range(match.range(at: 1), in: value),
1174
+ let endRange = Range(match.range(at: 2), in: value),
1175
+ let totalRange = Range(match.range(at: 3), in: value),
1176
+ let start = Int64(value[startRange]),
1177
+ let end = Int64(value[endRange]),
1178
+ let total = Int64(value[totalRange])
1179
+ else {
1180
+ return false
1181
+ }
1182
+ return start == expectedStart &&
1183
+ end >= start &&
1184
+ end < total &&
1185
+ total == expectedTotal
1186
+ }
1187
+
1188
+ private func validateStoredArtifact(
1189
+ fileURL: URL,
1190
+ expectedSize: Int64,
1191
+ expectedSha256: String
1192
+ ) throws -> StoredFirmwareArtifact {
1193
+ let size = try fileSize(fileURL)
1194
+ guard size == expectedSize else {
1195
+ throw FirmwareArtifactStoreError.integrityMismatch(
1196
+ "ARTIFACT_INTEGRITY_FAILED: firmware artifact size mismatch"
1197
+ )
1198
+ }
1199
+ let sha256 = try hashFile(fileURL)
1200
+ guard sha256 == expectedSha256 else {
1201
+ throw FirmwareArtifactStoreError.integrityMismatch(
1202
+ "ARTIFACT_INTEGRITY_FAILED: firmware artifact SHA-256 mismatch"
1203
+ )
1204
+ }
1205
+ return StoredFirmwareArtifact(
1206
+ artifactRef: "fw:\(sha256)",
1207
+ size: size,
1208
+ sha256: sha256,
1209
+ fileURL: fileURL
1210
+ )
1211
+ }
1212
+
1213
+ private func resolveArtifactURL(_ artifactRef: String) throws -> URL {
1214
+ guard artifactRef.hasPrefix("fw:") else {
1215
+ throw FirmwareArtifactStoreError.invalidInput("Invalid firmware artifactRef")
1216
+ }
1217
+ let sha256 = String(artifactRef.dropFirst(3))
1218
+ guard sha256.range(of: "^[a-f0-9]{64}$", options: .regularExpression) != nil else {
1219
+ throw FirmwareArtifactStoreError.invalidInput("Invalid firmware artifactRef")
1220
+ }
1221
+ let url = artifactURL(sha256: sha256)
1222
+ guard fileManager.fileExists(atPath: url.path) else {
1223
+ throw FirmwareArtifactStoreError.invalidInput("Firmware artifact not found")
1224
+ }
1225
+ return url
1226
+ }
1227
+
1228
+ private func artifactURL(sha256: String) -> URL {
1229
+ rootURL.appendingPathComponent("\(sha256).bin", isDirectory: false)
1230
+ }
1231
+
1232
+ private func fileSize(_ url: URL) throws -> Int64 {
1233
+ let attributes = try fileManager.attributesOfItem(atPath: url.path)
1234
+ return (attributes[.size] as? NSNumber)?.int64Value ?? 0
1235
+ }
1236
+
1237
+ private func hashFile(_ url: URL) throws -> String {
1238
+ let handle = try FileHandle(forReadingFrom: url)
1239
+ defer { try? handle.close() }
1240
+ var hasher = SHA256()
1241
+ while let data = try handle.read(upToCount: 256 * 1024), !data.isEmpty {
1242
+ hasher.update(data: data)
1243
+ }
1244
+ return hasher.finalize().map { String(format: "%02x", $0) }.joined()
1245
+ }
1246
+
1247
+ private func promote(source: URL, destination: URL) throws {
1248
+ let stagingURL = destination
1249
+ .deletingLastPathComponent()
1250
+ .appendingPathComponent(".promote-\(UUID().uuidString)", isDirectory: false)
1251
+ try fileManager.moveItem(at: source, to: stagingURL)
1252
+ defer {
1253
+ if fileManager.fileExists(atPath: stagingURL.path) {
1254
+ try? fileManager.removeItem(at: stagingURL)
1255
+ }
1256
+ }
1257
+ if fileManager.fileExists(atPath: destination.path) {
1258
+ _ = try fileManager.replaceItemAt(
1259
+ destination,
1260
+ withItemAt: stagingURL,
1261
+ backupItemName: nil,
1262
+ options: []
1263
+ )
1264
+ } else {
1265
+ try fileManager.moveItem(at: stagingURL, to: destination)
1266
+ }
1267
+ }
1268
+ }
1269
+
1270
+ private extension NSLock {
1271
+ func withFirmwareArtifactLock<T>(_ body: () -> T) -> T {
1272
+ lock()
1273
+ defer { unlock() }
1274
+ return body()
1275
+ }
1276
+ }