@onekeyfe/react-native-range-downloader 3.0.81-alpha.1 → 3.0.81-alpha.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/ReactNativeRangeDownloader.podspec +1 -2
  2. package/android/build.gradle +0 -1
  3. package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveRules.kt +12 -5
  4. package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactOrphanSweep.kt +111 -0
  5. package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactStore.kt +194 -214
  6. package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt +21 -21
  7. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveRulesTest.kt +39 -0
  8. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactDeadlineTest.kt +26 -0
  9. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactOrphanSweepTest.kt +107 -0
  10. package/ios/FirmwareArtifactStore.swift +667 -435
  11. package/ios/RangeDownloadLogic.swift +317 -0
  12. package/ios/ReactNativeRangeDownloader.swift +60 -484
  13. package/lib/typescript/src/ReactNativeRangeDownloader.nitro.d.ts +4 -7
  14. package/lib/typescript/src/ReactNativeRangeDownloader.nitro.d.ts.map +1 -1
  15. package/nitrogen/generated/android/c++/JFirmwareArchiveMaterializeParams.hpp +7 -6
  16. package/nitrogen/generated/android/c++/JFirmwareArtifactDownloadParams.hpp +7 -7
  17. package/nitrogen/generated/android/c++/JFirmwareArtifactReceipt.hpp +7 -3
  18. package/nitrogen/generated/android/c++/JHybridReactNativeRangeDownloaderSpec.cpp +0 -19
  19. package/nitrogen/generated/android/c++/JHybridReactNativeRangeDownloaderSpec.hpp +0 -1
  20. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveMaterializeParams.kt +2 -2
  21. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactDownloadParams.kt +3 -3
  22. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactReceipt.kt +6 -3
  23. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/HybridReactNativeRangeDownloaderSpec.kt +0 -4
  24. package/nitrogen/generated/ios/ReactNativeRangeDownloader-Swift-Cxx-Bridge.hpp +15 -0
  25. package/nitrogen/generated/ios/ReactNativeRangeDownloader-Swift-Cxx-Umbrella.hpp +0 -3
  26. package/nitrogen/generated/ios/c++/HybridReactNativeRangeDownloaderSpecSwift.hpp +0 -11
  27. package/nitrogen/generated/ios/swift/FirmwareArchiveMaterializeParams.swift +32 -13
  28. package/nitrogen/generated/ios/swift/FirmwareArtifactDownloadParams.swift +39 -8
  29. package/nitrogen/generated/ios/swift/FirmwareArtifactReceipt.swift +13 -2
  30. package/nitrogen/generated/ios/swift/HybridReactNativeRangeDownloaderSpec.swift +0 -1
  31. package/nitrogen/generated/ios/swift/HybridReactNativeRangeDownloaderSpec_cxx.swift +0 -19
  32. package/nitrogen/generated/shared/c++/FirmwareArchiveMaterializeParams.hpp +6 -5
  33. package/nitrogen/generated/shared/c++/FirmwareArtifactDownloadParams.hpp +9 -9
  34. package/nitrogen/generated/shared/c++/FirmwareArtifactReceipt.hpp +6 -2
  35. package/nitrogen/generated/shared/c++/HybridReactNativeRangeDownloaderSpec.cpp +0 -1
  36. package/nitrogen/generated/shared/c++/HybridReactNativeRangeDownloaderSpec.hpp +0 -4
  37. package/package.json +1 -2
  38. package/src/ReactNativeRangeDownloader.nitro.ts +7 -11
  39. package/ios/FirmwareBackgroundSessionEventRouter.swift +0 -17
  40. package/nitrogen/generated/android/c++/JFirmwareArtifactLeaseReconcileParams.hpp +0 -76
  41. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactLeaseReconcileParams.kt +0 -38
  42. package/nitrogen/generated/ios/swift/FirmwareArtifactLeaseReconcileParams.swift +0 -48
  43. package/nitrogen/generated/shared/c++/FirmwareArtifactLeaseReconcileParams.hpp +0 -76
@@ -1,13 +1,32 @@
1
1
  import CryptoKit
2
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)
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
11
30
  }
12
31
 
13
32
  struct StoredFirmwareArtifact: Sendable {
@@ -29,7 +48,106 @@ private struct StagedFirmwareArchiveEntry {
29
48
  let stagingURL: URL
30
49
  }
31
50
 
32
- private final class FirmwareArtifactRedirectDelegate: NSObject, URLSessionTaskDelegate {
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
+
33
151
  func urlSession(
34
152
  _ session: URLSession,
35
153
  task: URLSessionTask,
@@ -38,6 +156,193 @@ private final class FirmwareArtifactRedirectDelegate: NSObject, URLSessionTaskDe
38
156
  completionHandler: @escaping (URLRequest?) -> Void
39
157
  ) {
40
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
+ }
41
346
  }
42
347
  }
43
348
 
@@ -78,11 +383,10 @@ private actor FirmwareArtifactDownloadCoordinator {
78
383
 
79
384
  final class FirmwareArtifactStore {
80
385
  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
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
86
390
 
87
391
  private struct OpenReader {
88
392
  let handle: FileHandle
@@ -90,16 +394,11 @@ final class FirmwareArtifactStore {
90
394
  let size: Int64
91
395
  }
92
396
 
93
- private struct LeaseState: Codable {
397
+ private struct LeaseState {
94
398
  let transactionId: String
95
399
  var artifactRefs: Set<String>
96
400
  }
97
401
 
98
- private struct LeaseEnvelope: Codable {
99
- let schemaVersion: Int
100
- var leases: [String: LeaseState]
101
- }
102
-
103
402
  private let fileManager = FileManager.default
104
403
  private let downloadCoordinator = FirmwareArtifactDownloadCoordinator()
105
404
  private let leaseLock = NSLock()
@@ -109,6 +408,7 @@ final class FirmwareArtifactStore {
109
408
  private var cancelledTransactions: Set<String> = []
110
409
  private let readerLock = NSLock()
111
410
  private var readers: [String: OpenReader] = [:]
411
+ private var leases: [String: LeaseState] = [:]
112
412
 
113
413
  private lazy var rootURL: URL = {
114
414
  let base = fileManager.urls(
@@ -122,7 +422,16 @@ final class FirmwareArtifactStore {
122
422
 
123
423
  private init() {}
124
424
 
125
- static func validateDownloadParams(_ params: FirmwareArtifactDownloadParams) throws {
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 {
126
435
  guard
127
436
  !params.taskId.isEmpty,
128
437
  params.taskId.count <= 100,
@@ -150,47 +459,92 @@ final class FirmwareArtifactStore {
150
459
  else {
151
460
  throw FirmwareArtifactStoreError.invalidInput("Firmware URL must use HTTPS port 443")
152
461
  }
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 {
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 {
162
482
  throw FirmwareArtifactStoreError.invalidInput("Invalid firmware artifact size")
163
483
  }
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")
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
+ }
169
499
  }
170
- guard params.routeType == "domain" || params.routeType == "pinnedIp" else {
500
+ guard params.routeType == "domain" else {
171
501
  throw FirmwareArtifactStoreError.invalidInput("Invalid firmware route type")
172
502
  }
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 {
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 {
178
512
  throw FirmwareArtifactStoreError.invalidInput("Domain route must not include resolvedIp")
179
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
+ )
180
523
  }
181
524
 
182
525
  func download(_ params: FirmwareArtifactDownloadParams) async throws -> StoredFirmwareArtifact {
183
- try Self.validateDownloadParams(params)
526
+ let validated = try Self.validateDownloadParams(params)
184
527
  try rejectIfCancelled(transactionId: params.transactionId)
185
- let expectedSha256 = params.expectedSha256.lowercased()
186
- try retainExpectedArtifact(
187
- leaseRef: params.leaseRef,
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(
188
541
  transactionId: params.transactionId,
189
- artifactRef: "fw:\(expectedSha256)"
542
+ taskId: params.taskId,
543
+ expectedSize: validated.expectedSize,
544
+ expectedSha256: validated.expectedSha256,
545
+ downloadToken: validated.downloadToken
190
546
  )
191
- let key =
192
- "\(params.transactionId):\(expectedSha256):\(Int64(params.expectedSize))"
193
- markDownloadActive(expectedSha256, delta: 1)
547
+ markDownloadActive(validated.downloadToken, delta: 1)
194
548
  do {
195
549
  let artifact = try await downloadCoordinator.run(
196
550
  key: key,
@@ -199,13 +553,23 @@ final class FirmwareArtifactStore {
199
553
  try rejectIfCancelled(transactionId: params.transactionId)
200
554
  return try await downloadLocked(
201
555
  params,
202
- expectedSha256: expectedSha256
556
+ validated: validated
203
557
  )
204
558
  }
205
- markDownloadActive(expectedSha256, delta: -1)
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)
206
566
  return artifact
207
567
  } catch {
208
- markDownloadActive(expectedSha256, delta: -1)
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
+ )
209
573
  if error is CancellationError ||
210
574
  isTransactionCancelled(params.transactionId) {
211
575
  throw FirmwareArtifactStoreError.downloadFailed(
@@ -216,6 +580,32 @@ final class FirmwareArtifactStore {
216
580
  }
217
581
  }
218
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
+
219
609
  func cancelDownloads(transactionId: String) async throws {
220
610
  guard Self.isSafeIdentifier(transactionId) else {
221
611
  throw FirmwareArtifactStoreError.invalidInput(
@@ -226,49 +616,55 @@ final class FirmwareArtifactStore {
226
616
  cancelledTransactions.insert(transactionId)
227
617
  }
228
618
  await downloadCoordinator.cancel(transactionId: transactionId)
229
- try await RangeDownloader.shared.cancelFirmwareArtifactDownloads(
230
- transactionId: transactionId
231
- )
232
619
  }
233
620
 
234
621
  private func downloadLocked(
235
622
  _ params: FirmwareArtifactDownloadParams,
236
- expectedSha256: String
623
+ validated: ValidatedDownload
237
624
  ) 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
- )
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
+ }
251
635
  }
252
636
 
637
+ // Firmware preflight is foreground-bound, so both routes use the same
638
+ // cancellable stream instead of waiting on process-owned background tasks.
253
639
  let partialURL = rootURL.appendingPathComponent(
254
- "\(expectedSha256).\(params.taskId).partial",
640
+ firmwareArtifactPartialFileName(
641
+ transactionId: params.transactionId,
642
+ taskId: params.taskId,
643
+ downloadToken: validated.downloadToken
644
+ ),
255
645
  isDirectory: false
256
646
  )
257
647
  if !fileManager.fileExists(atPath: partialURL.path) {
258
648
  fileManager.createFile(atPath: partialURL.path, contents: nil)
259
649
  }
260
650
  var currentSize = try fileSize(partialURL)
261
- if currentSize > Int64(params.expectedSize) {
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 {
262
656
  try fileManager.removeItem(at: partialURL)
263
657
  fileManager.createFile(atPath: partialURL.path, contents: nil)
264
658
  currentSize = 0
265
659
  }
266
- if currentSize == Int64(params.expectedSize) {
267
- if let completed = try? validateStoredArtifact(
660
+ if let expectedSize = validated.expectedSize, currentSize == expectedSize {
661
+ if let completed = try? validateDownloadedArtifact(
268
662
  fileURL: partialURL,
269
- expectedSize: Int64(params.expectedSize),
270
- expectedSha256: expectedSha256
663
+ expectedSize: expectedSize,
664
+ expectedSha256: validated.expectedSha256,
665
+ maxBytes: validated.maxBytes
271
666
  ) {
667
+ let finalURL = artifactURL(sha256: completed.sha256)
272
668
  try promote(source: partialURL, destination: finalURL)
273
669
  return StoredFirmwareArtifact(
274
670
  artifactRef: completed.artifactRef,
@@ -282,22 +678,33 @@ final class FirmwareArtifactStore {
282
678
  currentSize = 0
283
679
  }
284
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
+ )
285
685
  try await streamDownload(
286
686
  params,
287
687
  partialURL: partialURL,
288
- resumeOffset: min(currentSize, Int64(params.expectedSize))
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)"
289
694
  )
290
695
  let artifact: StoredFirmwareArtifact
291
696
  do {
292
- artifact = try validateStoredArtifact(
697
+ artifact = try validateDownloadedArtifact(
293
698
  fileURL: partialURL,
294
- expectedSize: Int64(params.expectedSize),
295
- expectedSha256: expectedSha256
699
+ expectedSize: validated.expectedSize,
700
+ expectedSha256: validated.expectedSha256,
701
+ maxBytes: validated.maxBytes
296
702
  )
297
703
  } catch {
298
704
  try? fileManager.removeItem(at: partialURL)
299
705
  throw error
300
706
  }
707
+ let finalURL = artifactURL(sha256: artifact.sha256)
301
708
  try promote(source: partialURL, destination: finalURL)
302
709
  return StoredFirmwareArtifact(
303
710
  artifactRef: artifact.artifactRef,
@@ -307,67 +714,11 @@ final class FirmwareArtifactStore {
307
714
  )
308
715
  }
309
716
 
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
717
  func discard(artifactRef: String) throws {
361
718
  let fileURL = try resolveArtifactURL(artifactRef)
362
719
  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
720
+ let isRetained = leases.values.contains {
721
+ $0.artifactRefs.contains(artifactRef)
371
722
  }
372
723
  leaseLock.unlock()
373
724
  guard !isRetained else {
@@ -447,7 +798,7 @@ final class FirmwareArtifactStore {
447
798
  func materializeArchive(
448
799
  leaseRef: String,
449
800
  artifactRef: String,
450
- expectedEntries: [FirmwareArchiveExpectedEntry]
801
+ expectedEntries: [FirmwareArchiveExpectedEntry]?
451
802
  ) throws -> [StoredFirmwareArchiveEntry] {
452
803
  try requireLease(leaseRef)
453
804
  let archiveURL = try resolveArtifactURL(artifactRef)
@@ -458,10 +809,13 @@ final class FirmwareArtifactStore {
458
809
  try fileManager.createDirectory(at: scratchURL, withIntermediateDirectories: true)
459
810
  defer { try? fileManager.removeItem(at: scratchURL) }
460
811
 
461
- let requirements = try validateArchiveRequirements(expectedEntries)
462
812
  let archiveEntries = try FirmwareArchiveMinizipBridge.scanArchive(
463
813
  atPath: archiveURL.path
464
814
  )
815
+ let requirements = try resolveArchiveRequirements(
816
+ expectedEntries,
817
+ archiveEntries: archiveEntries
818
+ )
465
819
  try validateArchiveEntries(
466
820
  archiveEntries,
467
821
  requirements: requirements
@@ -488,7 +842,7 @@ final class FirmwareArtifactStore {
488
842
  }
489
843
  do {
490
844
  actualSize += Int64(chunk.count)
491
- guard actualSize <= Int64(requirement.expectedSize) else {
845
+ guard actualSize <= requirement.expectedSize else {
492
846
  throw FirmwareArtifactStoreError.archiveInvalid(
493
847
  "Firmware archive entry exceeds its expected size"
494
848
  )
@@ -514,8 +868,8 @@ final class FirmwareArtifactStore {
514
868
  String(format: "%02x", $0)
515
869
  }.joined()
516
870
  guard
517
- actualSize == Int64(requirement.expectedSize),
518
- sha256 == requirement.expectedSha256.lowercased()
871
+ actualSize == requirement.expectedSize,
872
+ requirement.expectedSha256.map({ sha256 == $0 }) ?? true
519
873
  else {
520
874
  throw FirmwareArtifactStoreError.archiveInvalid(
521
875
  "Firmware archive entry integrity mismatch"
@@ -566,18 +920,16 @@ final class FirmwareArtifactStore {
566
920
  }
567
921
  leaseLock.lock()
568
922
  defer { leaseLock.unlock() }
569
- var envelope = try loadLeasesLocked()
570
- guard envelope.leases.count < 32 else {
923
+ guard leases.count < 32 else {
571
924
  throw FirmwareArtifactStoreError.invalidInput(
572
925
  "Too many firmware artifact leases"
573
926
  )
574
927
  }
575
928
  let leaseRef = "fwlease:\(UUID().uuidString.lowercased())"
576
- envelope.leases[leaseRef] = LeaseState(
929
+ leases[leaseRef] = LeaseState(
577
930
  transactionId: transactionId,
578
931
  artifactRefs: []
579
932
  )
580
- try saveLeasesLocked(envelope)
581
933
  return leaseRef
582
934
  }
583
935
 
@@ -603,9 +955,8 @@ final class FirmwareArtifactStore {
603
955
  let transactionId: String = try {
604
956
  leaseLock.lock()
605
957
  defer { leaseLock.unlock() }
606
- var envelope = try loadLeasesLocked()
607
958
  guard
608
- let lease = envelope.leases.removeValue(
959
+ let lease = leases.removeValue(
609
960
  forKey: try validateLeaseRef(leaseRef)
610
961
  )
611
962
  else {
@@ -613,7 +964,6 @@ final class FirmwareArtifactStore {
613
964
  "Firmware artifact lease is unavailable"
614
965
  )
615
966
  }
616
- try saveLeasesLocked(envelope)
617
967
  return lease.transactionId
618
968
  }()
619
969
  cancellationLock.withFirmwareArtifactLock {
@@ -621,43 +971,13 @@ final class FirmwareArtifactStore {
621
971
  }
622
972
  }
623
973
 
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
974
  func sweepOrphans() throws -> (deletedFiles: Int, deletedBytes: Int64) {
649
975
  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
- }
976
+ let retained = Set(
977
+ leases.values
978
+ .flatMap(\.artifactRefs)
979
+ .map { String($0.dropFirst(3)) }
980
+ )
661
981
  leaseLock.unlock()
662
982
  activeDownloadLock.lock()
663
983
  let active = Set(activeDownloadCounts.filter { $0.value > 0 }.keys)
@@ -666,61 +986,41 @@ final class FirmwareArtifactStore {
666
986
  let openPaths = Set(readers.values.map(\.fileURL.path))
667
987
  readerLock.unlock()
668
988
 
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]
989
+ return try sweepFirmwareArtifactOrphansAtRoot(
990
+ rootURL,
991
+ retainedSha256: retained,
992
+ activeSha256: active,
993
+ openPaths: openPaths,
994
+ fileManager: fileManager
676
995
  )
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
996
  }
713
997
 
714
998
  private func requireLease(_ leaseRef: String) throws {
715
999
  leaseLock.lock()
716
1000
  defer { leaseLock.unlock() }
717
- guard try loadLeasesLocked().leases[validateLeaseRef(leaseRef)] != nil else {
1001
+ guard leases[try validateLeaseRef(leaseRef)] != nil else {
718
1002
  throw FirmwareArtifactStoreError.invalidInput(
719
1003
  "Firmware artifact lease is unavailable"
720
1004
  )
721
1005
  }
722
1006
  }
723
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
+
724
1024
  private func retainExpectedArtifact(
725
1025
  leaseRef: String,
726
1026
  transactionId: String?,
@@ -736,9 +1036,8 @@ final class FirmwareArtifactStore {
736
1036
  }
737
1037
  leaseLock.lock()
738
1038
  defer { leaseLock.unlock() }
739
- var envelope = try loadLeasesLocked()
740
1039
  let validatedLeaseRef = try validateLeaseRef(leaseRef)
741
- guard var lease = envelope.leases[validatedLeaseRef] else {
1040
+ guard var lease = leases[validatedLeaseRef] else {
742
1041
  throw FirmwareArtifactStoreError.invalidInput(
743
1042
  "Firmware artifact lease is unavailable"
744
1043
  )
@@ -749,8 +1048,7 @@ final class FirmwareArtifactStore {
749
1048
  )
750
1049
  }
751
1050
  if lease.artifactRefs.insert(artifactRef).inserted {
752
- envelope.leases[validatedLeaseRef] = lease
753
- try saveLeasesLocked(envelope)
1051
+ leases[validatedLeaseRef] = lease
754
1052
  }
755
1053
  }
756
1054
 
@@ -770,84 +1068,8 @@ final class FirmwareArtifactStore {
770
1068
  ) != nil
771
1069
  }
772
1070
 
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])
1071
+ static func isSafeIdentifier(_ value: String) -> Bool {
1072
+ firmwareArtifactIdentifierIsSafe(value)
851
1073
  }
852
1074
 
853
1075
  private func markDownloadActive(_ sha256: String, delta: Int) {
@@ -863,7 +1085,7 @@ final class FirmwareArtifactStore {
863
1085
 
864
1086
  private func validateArchiveRequirements(
865
1087
  _ expectedEntries: [FirmwareArchiveExpectedEntry]
866
- ) throws -> [FirmwareArchiveExpectedEntry] {
1088
+ ) throws -> [FirmwareArchiveRequirement] {
867
1089
  guard !expectedEntries.isEmpty, expectedEntries.count <= 4096 else {
868
1090
  throw FirmwareArtifactStoreError.archiveInvalid(
869
1091
  "Firmware archive expected entry count is invalid"
@@ -904,12 +1126,48 @@ final class FirmwareArtifactStore {
904
1126
  )
905
1127
  }
906
1128
  }
907
- return expectedEntries
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
+ }
908
1166
  }
909
1167
 
910
1168
  private func validateArchiveEntries(
911
1169
  _ entries: [FirmwareArchiveEntryInfo],
912
- requirements: [FirmwareArchiveExpectedEntry]
1170
+ requirements: [FirmwareArchiveRequirement]
913
1171
  ) throws {
914
1172
  guard entries.count == requirements.count else {
915
1173
  throw FirmwareArtifactStoreError.archiveInvalid(
@@ -921,16 +1179,32 @@ final class FirmwareArtifactStore {
921
1179
  )
922
1180
  var names = Set<String>()
923
1181
  var canonicalNames = Set<String>()
1182
+ var totalSize: Int64 = 0
924
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
925
1198
  guard
926
1199
  names.insert(entry.name).inserted,
927
1200
  isPortableArchiveEntryName(
928
1201
  entry.name,
929
1202
  canonicalNames: &canonicalNames
930
1203
  ),
931
- let requirement = requirementsByName[entry.name],
932
- entry.uncompressedSize == Int64(requirement.expectedSize),
1204
+ entry.uncompressedSize == requirement.expectedSize,
933
1205
  entry.uncompressedSize > 0,
1206
+ entry.uncompressedSize <= 128 * 1024 * 1024,
1207
+ totalSize <= 512 * 1024 * 1024,
934
1208
  entry.compressedSize >= 0,
935
1209
  entry.compressedSize <= 512 * 1024 * 1024,
936
1210
  entry.uncompressedSize <= max(entry.compressedSize, 1) * 1000,
@@ -1001,107 +1275,68 @@ final class FirmwareArtifactStore {
1001
1275
  private func streamDownload(
1002
1276
  _ params: FirmwareArtifactDownloadParams,
1003
1277
  partialURL: URL,
1004
- resumeOffset: Int64
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
1005
1305
  ) async throws {
1006
1306
  guard let url = URL(string: params.url), let hostname = url.host else {
1007
1307
  throw FirmwareArtifactStoreError.invalidInput("Invalid firmware URL")
1008
1308
  }
1009
1309
  var request = URLRequest(url: url)
1010
1310
  request.cachePolicy = .reloadIgnoringLocalCacheData
1011
- request.timeoutInterval = params.overallDeadlineSeconds ?? 180
1311
+ request.timeoutInterval =
1312
+ params.overallDeadlineSeconds ?? Self.defaultDownloadDeadline
1012
1313
  request.setValue("identity", forHTTPHeaderField: "Accept-Encoding")
1013
1314
  if resumeOffset > 0 {
1014
1315
  request.setValue("bytes=\(resumeOffset)-", forHTTPHeaderField: "Range")
1015
1316
  }
1016
1317
 
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(
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(
1028
1330
  configuration: .ephemeral,
1029
- delegate: domainDelegate,
1331
+ delegate: streamDelegate,
1030
1332
  delegateQueue: nil
1031
1333
  )
1032
1334
  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)
1335
+ session.finishTasksAndInvalidate()
1086
1336
  }
1087
1337
 
1088
- var written = append ? resumeOffset : 0
1089
- var buffer = Data()
1090
- buffer.reserveCapacity(64 * 1024)
1091
1338
  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
- }
1339
+ try await streamDelegate.run(session: session, request: request)
1105
1340
  } catch let error as FirmwareArtifactStoreError {
1106
1341
  throw error
1107
1342
  } catch {
@@ -1113,20 +1348,15 @@ final class FirmwareArtifactStore {
1113
1348
  "ARTIFACT_CANCELLED: firmware artifact download was cancelled"
1114
1349
  )
1115
1350
  }
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 {
1351
+ if isFirmwareArtifactTLSError(error) {
1123
1352
  throw FirmwareArtifactStoreError.downloadFailed(
1124
- "ARTIFACT_PROTOCOL_INVALID: firmware artifact exceeds maxBytes"
1353
+ "ARTIFACT_TLS_FAILED: firmware TLS validation failed"
1125
1354
  )
1126
1355
  }
1127
- try handle.write(contentsOf: buffer)
1356
+ throw FirmwareArtifactStoreError.downloadFailed(
1357
+ "ARTIFACT_NETWORK_FAILED: firmware request failed"
1358
+ )
1128
1359
  }
1129
- try handle.synchronize()
1130
1360
  try rejectIfCancelled(transactionId: params.transactionId)
1131
1361
  }
1132
1362
 
@@ -1157,34 +1387,6 @@ final class FirmwareArtifactStore {
1157
1387
  nsError.code == NSURLErrorCancelled
1158
1388
  }
1159
1389
 
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
1390
  private func validateStoredArtifact(
1189
1391
  fileURL: URL,
1190
1392
  expectedSize: Int64,
@@ -1210,6 +1412,36 @@ final class FirmwareArtifactStore {
1210
1412
  )
1211
1413
  }
1212
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
+
1213
1445
  private func resolveArtifactURL(_ artifactRef: String) throws -> URL {
1214
1446
  guard artifactRef.hasPrefix("fw:") else {
1215
1447
  throw FirmwareArtifactStoreError.invalidInput("Invalid firmware artifactRef")