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

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 (38) hide show
  1. package/ReactNativeRangeDownloader.podspec +1 -1
  2. package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveRules.kt +12 -5
  3. package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactOrphanSweep.kt +111 -0
  4. package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactStore.kt +185 -189
  5. package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt +16 -20
  6. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveRulesTest.kt +39 -0
  7. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactDeadlineTest.kt +26 -0
  8. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactOrphanSweepTest.kt +107 -0
  9. package/ios/FirmwareArtifactStore.swift +662 -408
  10. package/ios/RangeDownloadLogic.swift +297 -0
  11. package/ios/ReactNativeRangeDownloader.swift +42 -471
  12. package/lib/typescript/src/ReactNativeRangeDownloader.nitro.d.ts +3 -7
  13. package/lib/typescript/src/ReactNativeRangeDownloader.nitro.d.ts.map +1 -1
  14. package/nitrogen/generated/android/c++/JFirmwareArchiveMaterializeParams.hpp +7 -6
  15. package/nitrogen/generated/android/c++/JFirmwareArtifactDownloadParams.hpp +7 -7
  16. package/nitrogen/generated/android/c++/JHybridReactNativeRangeDownloaderSpec.cpp +0 -19
  17. package/nitrogen/generated/android/c++/JHybridReactNativeRangeDownloaderSpec.hpp +0 -1
  18. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveMaterializeParams.kt +2 -2
  19. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactDownloadParams.kt +3 -3
  20. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/HybridReactNativeRangeDownloaderSpec.kt +0 -4
  21. package/nitrogen/generated/ios/ReactNativeRangeDownloader-Swift-Cxx-Bridge.hpp +15 -0
  22. package/nitrogen/generated/ios/ReactNativeRangeDownloader-Swift-Cxx-Umbrella.hpp +0 -3
  23. package/nitrogen/generated/ios/c++/HybridReactNativeRangeDownloaderSpecSwift.hpp +0 -11
  24. package/nitrogen/generated/ios/swift/FirmwareArchiveMaterializeParams.swift +32 -13
  25. package/nitrogen/generated/ios/swift/FirmwareArtifactDownloadParams.swift +39 -8
  26. package/nitrogen/generated/ios/swift/HybridReactNativeRangeDownloaderSpec.swift +0 -1
  27. package/nitrogen/generated/ios/swift/HybridReactNativeRangeDownloaderSpec_cxx.swift +0 -19
  28. package/nitrogen/generated/shared/c++/FirmwareArchiveMaterializeParams.hpp +6 -5
  29. package/nitrogen/generated/shared/c++/FirmwareArtifactDownloadParams.hpp +9 -9
  30. package/nitrogen/generated/shared/c++/HybridReactNativeRangeDownloaderSpec.cpp +0 -1
  31. package/nitrogen/generated/shared/c++/HybridReactNativeRangeDownloaderSpec.hpp +0 -4
  32. package/package.json +2 -2
  33. package/src/ReactNativeRangeDownloader.nitro.ts +4 -11
  34. package/ios/FirmwareBackgroundSessionEventRouter.swift +0 -17
  35. package/nitrogen/generated/android/c++/JFirmwareArtifactLeaseReconcileParams.hpp +0 -76
  36. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactLeaseReconcileParams.kt +0 -38
  37. package/nitrogen/generated/ios/swift/FirmwareArtifactLeaseReconcileParams.swift +0 -48
  38. package/nitrogen/generated/shared/c++/FirmwareArtifactLeaseReconcileParams.hpp +0 -76
@@ -1,13 +1,33 @@
1
1
  import CryptoKit
2
2
  import Foundation
3
+ import ReactNativeNativeLogger
3
4
  import SniConnect
4
5
 
5
- enum FirmwareArtifactStoreError: Error {
6
- case invalidInput(String)
7
- case downloadFailed(String)
8
- case integrityMismatch(String)
9
- case readerInvalid(String)
10
- case archiveInvalid(String)
6
+ func isFirmwareArtifactTLSError(_ error: Error) -> Bool {
7
+ var current: NSError? = error as NSError
8
+ var visited = Set<ObjectIdentifier>()
9
+ while let candidate = current {
10
+ let identifier = ObjectIdentifier(candidate)
11
+ guard visited.insert(identifier).inserted else {
12
+ break
13
+ }
14
+ if candidate.domain == NSURLErrorDomain,
15
+ [
16
+ NSURLErrorSecureConnectionFailed,
17
+ NSURLErrorServerCertificateHasBadDate,
18
+ NSURLErrorServerCertificateUntrusted,
19
+ NSURLErrorServerCertificateHasUnknownRoot,
20
+ NSURLErrorServerCertificateNotYetValid,
21
+ NSURLErrorClientCertificateRejected,
22
+ NSURLErrorClientCertificateRequired,
23
+ NSURLErrorAppTransportSecurityRequiresSecureConnection,
24
+ ].contains(candidate.code)
25
+ {
26
+ return true
27
+ }
28
+ current = candidate.userInfo[NSUnderlyingErrorKey] as? NSError
29
+ }
30
+ return false
11
31
  }
12
32
 
13
33
  struct StoredFirmwareArtifact: Sendable {
@@ -29,7 +49,106 @@ private struct StagedFirmwareArchiveEntry {
29
49
  let stagingURL: URL
30
50
  }
31
51
 
32
- private final class FirmwareArtifactRedirectDelegate: NSObject, URLSessionTaskDelegate {
52
+ private struct FirmwareArchiveRequirement {
53
+ let entryName: String
54
+ let expectedSize: Int64
55
+ let expectedSha256: String?
56
+ }
57
+
58
+ private final class FirmwareArtifactStreamDelegate: NSObject, URLSessionDataDelegate {
59
+ private let partialURL: URL
60
+ private let hostname: String
61
+ private let resumeOffset: Int64
62
+ private let expectedSize: Int64?
63
+ private let maxBytes: Int64
64
+ private let isCancelled: () -> Bool
65
+ private let stateLock = NSLock()
66
+ private var continuation: CheckedContinuation<Void, Error>?
67
+ private var dataTask: URLSessionDataTask?
68
+ private var cancellationRequested = false
69
+ private var completed = false
70
+ private var responseAccepted = false
71
+ private var handle: FileHandle?
72
+ private var written: Int64 = 0
73
+
74
+ init(
75
+ partialURL: URL,
76
+ hostname: String,
77
+ resumeOffset: Int64,
78
+ expectedSize: Int64?,
79
+ maxBytes: Int64,
80
+ isCancelled: @escaping () -> Bool
81
+ ) {
82
+ self.partialURL = partialURL
83
+ self.hostname = hostname
84
+ self.resumeOffset = resumeOffset
85
+ self.expectedSize = expectedSize
86
+ self.maxBytes = maxBytes
87
+ self.isCancelled = isCancelled
88
+ }
89
+
90
+ func run(session: URLSession, request: URLRequest) async throws {
91
+ try await withTaskCancellationHandler {
92
+ try await withCheckedThrowingContinuation {
93
+ (continuation: CheckedContinuation<Void, Error>) in
94
+ let task = session.dataTask(with: request)
95
+ stateLock.lock()
96
+ if cancellationRequested || completed {
97
+ stateLock.unlock()
98
+ task.cancel()
99
+ continuation.resume(throwing: CancellationError())
100
+ return
101
+ }
102
+ self.continuation = continuation
103
+ dataTask = task
104
+ stateLock.unlock()
105
+ task.resume()
106
+ }
107
+ } onCancel: {
108
+ self.cancel()
109
+ }
110
+ }
111
+
112
+ private func cancel() {
113
+ stateLock.lock()
114
+ cancellationRequested = true
115
+ let task = dataTask
116
+ stateLock.unlock()
117
+ task?.cancel()
118
+ }
119
+
120
+ private func finish(_ result: Result<Void, Error>) {
121
+ stateLock.lock()
122
+ guard !completed else {
123
+ stateLock.unlock()
124
+ return
125
+ }
126
+ completed = true
127
+ let continuation = continuation
128
+ self.continuation = nil
129
+ dataTask = nil
130
+ let handle = handle
131
+ self.handle = nil
132
+ stateLock.unlock()
133
+
134
+ try? handle?.close()
135
+ continuation?.resume(with: result)
136
+ }
137
+
138
+ private func reject(
139
+ _ dataTask: URLSessionDataTask,
140
+ completionHandler: @escaping (URLSession.ResponseDisposition) -> Void,
141
+ message: String
142
+ ) {
143
+ completionHandler(.cancel)
144
+ fail(dataTask, message: message)
145
+ }
146
+
147
+ private func fail(_ task: URLSessionTask, message: String) {
148
+ task.cancel()
149
+ finish(.failure(FirmwareArtifactStoreError.downloadFailed(message)))
150
+ }
151
+
33
152
  func urlSession(
34
153
  _ session: URLSession,
35
154
  task: URLSessionTask,
@@ -38,6 +157,193 @@ private final class FirmwareArtifactRedirectDelegate: NSObject, URLSessionTaskDe
38
157
  completionHandler: @escaping (URLRequest?) -> Void
39
158
  ) {
40
159
  completionHandler(nil)
160
+ fail(
161
+ task,
162
+ message:
163
+ "ARTIFACT_REDIRECT_REJECTED: firmware response changed canonical identity"
164
+ )
165
+ }
166
+
167
+ func urlSession(
168
+ _ session: URLSession,
169
+ dataTask: URLSessionDataTask,
170
+ didReceive response: URLResponse,
171
+ completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
172
+ ) {
173
+ guard !isCancelled() else {
174
+ reject(
175
+ dataTask,
176
+ completionHandler: completionHandler,
177
+ message: "ARTIFACT_CANCELLED: firmware artifact download was cancelled"
178
+ )
179
+ return
180
+ }
181
+ guard let httpResponse = response as? HTTPURLResponse else {
182
+ reject(
183
+ dataTask,
184
+ completionHandler: completionHandler,
185
+ message: "Firmware response is not HTTP"
186
+ )
187
+ return
188
+ }
189
+ guard
190
+ let responseURL = httpResponse.url,
191
+ responseURL.scheme?.lowercased() == "https",
192
+ responseURL.host?.lowercased() == hostname.lowercased(),
193
+ responseURL.port == nil || responseURL.port == 443
194
+ else {
195
+ reject(
196
+ dataTask,
197
+ completionHandler: completionHandler,
198
+ message:
199
+ "ARTIFACT_REDIRECT_REJECTED: firmware response changed canonical identity"
200
+ )
201
+ return
202
+ }
203
+ guard httpResponse.statusCode == 200 || httpResponse.statusCode == 206 else {
204
+ reject(
205
+ dataTask,
206
+ completionHandler: completionHandler,
207
+ message:
208
+ "ARTIFACT_HTTP_\(httpResponse.statusCode): firmware request failed"
209
+ )
210
+ return
211
+ }
212
+
213
+ let append = resumeOffset > 0 && httpResponse.statusCode == 206
214
+ if httpResponse.statusCode == 206 {
215
+ guard
216
+ let contentRange = httpResponse.value(
217
+ forHTTPHeaderField: "Content-Range"
218
+ ),
219
+ firmwareArtifactContentRangeIsValid(
220
+ contentRange,
221
+ expectedStart: append ? resumeOffset : 0,
222
+ expectedTotal: expectedSize,
223
+ maxBytes: maxBytes
224
+ )
225
+ else {
226
+ reject(
227
+ dataTask,
228
+ completionHandler: completionHandler,
229
+ message:
230
+ "ARTIFACT_PROTOCOL_INVALID: firmware resume Content-Range is invalid"
231
+ )
232
+ return
233
+ }
234
+ }
235
+
236
+ let baseOffset = append ? resumeOffset : 0
237
+ guard
238
+ firmwareArtifactResponseFits(
239
+ expectedContentLength: httpResponse.expectedContentLength,
240
+ baseOffset: baseOffset,
241
+ maxBytes: maxBytes
242
+ )
243
+ else {
244
+ reject(
245
+ dataTask,
246
+ completionHandler: completionHandler,
247
+ message:
248
+ "ARTIFACT_PROTOCOL_INVALID: firmware artifact exceeds maxBytes"
249
+ )
250
+ return
251
+ }
252
+
253
+ do {
254
+ let handle = try FileHandle(forWritingTo: partialURL)
255
+ if append {
256
+ try handle.seekToEnd()
257
+ } else {
258
+ try handle.truncate(atOffset: 0)
259
+ }
260
+ self.handle = handle
261
+ written = baseOffset
262
+ responseAccepted = true
263
+ completionHandler(.allow)
264
+ } catch {
265
+ reject(
266
+ dataTask,
267
+ completionHandler: completionHandler,
268
+ message:
269
+ "ARTIFACT_NETWORK_FAILED: firmware partial file could not be opened"
270
+ )
271
+ }
272
+ }
273
+
274
+ func urlSession(
275
+ _ session: URLSession,
276
+ dataTask: URLSessionDataTask,
277
+ didReceive data: Data
278
+ ) {
279
+ guard responseAccepted, let handle else {
280
+ fail(
281
+ dataTask,
282
+ message:
283
+ "ARTIFACT_PROTOCOL_INVALID: firmware response stream was not accepted"
284
+ )
285
+ return
286
+ }
287
+ guard !isCancelled() else {
288
+ fail(
289
+ dataTask,
290
+ message: "ARTIFACT_CANCELLED: firmware artifact download was cancelled"
291
+ )
292
+ return
293
+ }
294
+ guard
295
+ firmwareArtifactResponseFits(
296
+ expectedContentLength: Int64(data.count),
297
+ baseOffset: written,
298
+ maxBytes: maxBytes
299
+ )
300
+ else {
301
+ fail(
302
+ dataTask,
303
+ message:
304
+ "ARTIFACT_PROTOCOL_INVALID: firmware artifact exceeds maxBytes"
305
+ )
306
+ return
307
+ }
308
+ do {
309
+ try handle.write(contentsOf: data)
310
+ written += Int64(data.count)
311
+ } catch {
312
+ fail(
313
+ dataTask,
314
+ message:
315
+ "ARTIFACT_NETWORK_FAILED: firmware response stream could not be persisted"
316
+ )
317
+ }
318
+ }
319
+
320
+ func urlSession(
321
+ _ session: URLSession,
322
+ task: URLSessionTask,
323
+ didCompleteWithError error: Error?
324
+ ) {
325
+ if let error {
326
+ finish(.failure(error))
327
+ return
328
+ }
329
+ guard responseAccepted, let handle else {
330
+ finish(
331
+ .failure(FirmwareArtifactStoreError.downloadFailed(
332
+ "ARTIFACT_PROTOCOL_INVALID: firmware response stream was not accepted"
333
+ ))
334
+ )
335
+ return
336
+ }
337
+ do {
338
+ try handle.synchronize()
339
+ finish(.success(()))
340
+ } catch {
341
+ finish(
342
+ .failure(FirmwareArtifactStoreError.downloadFailed(
343
+ "ARTIFACT_NETWORK_FAILED: firmware partial file could not be synchronized"
344
+ ))
345
+ )
346
+ }
41
347
  }
42
348
  }
43
349
 
@@ -79,10 +385,8 @@ private actor FirmwareArtifactDownloadCoordinator {
79
385
  final class FirmwareArtifactStore {
80
386
  static let shared = FirmwareArtifactStore()
81
387
  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
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,26 +459,59 @@ final class FirmwareArtifactStore {
150
459
  else {
151
460
  throw FirmwareArtifactStoreError.invalidInput("Firmware URL must use HTTPS port 443")
152
461
  }
462
+ let expectedSize: Int64?
463
+ if let value = params.expectedSize {
464
+ guard
465
+ value.isFinite,
466
+ value > 0,
467
+ value <= Double(Int64.max),
468
+ value.rounded() == value
469
+ else {
470
+ throw FirmwareArtifactStoreError.invalidInput(
471
+ "Invalid firmware artifact expected size"
472
+ )
473
+ }
474
+ expectedSize = Int64(value)
475
+ } else {
476
+ expectedSize = nil
477
+ }
153
478
  guard
154
- params.expectedSize.isFinite,
155
- params.expectedSize > 0,
156
- params.expectedSize <= Double(Int64.max),
157
- params.expectedSize.rounded() == params.expectedSize,
158
479
  params.maxBytes.isFinite,
159
- params.maxBytes == params.expectedSize,
480
+ params.maxBytes > 0,
481
+ params.maxBytes <= Double(Int64.max),
482
+ params.maxBytes.rounded() == params.maxBytes,
160
483
  params.maxBytes <= Double(512 * 1024 * 1024)
161
484
  else {
162
485
  throw FirmwareArtifactStoreError.invalidInput("Invalid firmware artifact size")
163
486
  }
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")
487
+ let maxBytes = Int64(params.maxBytes)
488
+ guard expectedSize.map({ $0 <= maxBytes }) ?? true else {
489
+ throw FirmwareArtifactStoreError.invalidInput(
490
+ "Firmware artifact expected size exceeds maxBytes"
491
+ )
492
+ }
493
+ let expectedSha256 = params.expectedSha256?.lowercased()
494
+ if let expectedSha256 {
495
+ guard expectedSha256.range(
496
+ of: "^[a-f0-9]{64}$",
497
+ options: .regularExpression
498
+ ) != nil else {
499
+ throw FirmwareArtifactStoreError.invalidInput(
500
+ "Invalid firmware artifact SHA-256"
501
+ )
502
+ }
169
503
  }
170
504
  guard params.routeType == "domain" || params.routeType == "pinnedIp" else {
171
505
  throw FirmwareArtifactStoreError.invalidInput("Invalid firmware route type")
172
506
  }
507
+ let deadline = params.overallDeadlineSeconds ?? defaultDownloadDeadline
508
+ guard
509
+ deadline.isFinite,
510
+ deadline > 0,
511
+ deadline <= maxDownloadDeadline
512
+ else {
513
+ throw FirmwareArtifactStoreError.invalidInput("Invalid firmware download deadline")
514
+ }
173
515
  if params.routeType == "pinnedIp" {
174
516
  guard let resolvedIp = params.resolvedIp, !resolvedIp.isEmpty else {
175
517
  throw FirmwareArtifactStoreError.invalidInput("Pinned route requires resolvedIp")
@@ -177,20 +519,40 @@ final class FirmwareArtifactStore {
177
519
  } else if params.resolvedIp != nil {
178
520
  throw FirmwareArtifactStoreError.invalidInput("Domain route must not include resolvedIp")
179
521
  }
522
+ return ValidatedDownload(
523
+ expectedSize: expectedSize,
524
+ expectedSha256: expectedSha256,
525
+ maxBytes: maxBytes,
526
+ downloadToken: firmwareArtifactDownloadToken(
527
+ expectedSha256: expectedSha256,
528
+ url: params.url
529
+ )
530
+ )
180
531
  }
181
532
 
182
533
  func download(_ params: FirmwareArtifactDownloadParams) async throws -> StoredFirmwareArtifact {
183
- try Self.validateDownloadParams(params)
534
+ let validated = try Self.validateDownloadParams(params)
184
535
  try rejectIfCancelled(transactionId: params.transactionId)
185
- let expectedSha256 = params.expectedSha256.lowercased()
186
- try retainExpectedArtifact(
187
- leaseRef: params.leaseRef,
536
+ if let expectedSha256 = validated.expectedSha256 {
537
+ try retainExpectedArtifact(
538
+ leaseRef: params.leaseRef,
539
+ transactionId: params.transactionId,
540
+ artifactRef: "fw:\(expectedSha256)"
541
+ )
542
+ } else {
543
+ try requireLeaseTransaction(
544
+ leaseRef: params.leaseRef,
545
+ transactionId: params.transactionId
546
+ )
547
+ }
548
+ let key = firmwareArtifactDownloadKey(
188
549
  transactionId: params.transactionId,
189
- artifactRef: "fw:\(expectedSha256)"
550
+ taskId: params.taskId,
551
+ expectedSize: validated.expectedSize,
552
+ expectedSha256: validated.expectedSha256,
553
+ downloadToken: validated.downloadToken
190
554
  )
191
- let key =
192
- "\(params.transactionId):\(expectedSha256):\(Int64(params.expectedSize))"
193
- markDownloadActive(expectedSha256, delta: 1)
555
+ markDownloadActive(validated.downloadToken, delta: 1)
194
556
  do {
195
557
  let artifact = try await downloadCoordinator.run(
196
558
  key: key,
@@ -199,13 +561,23 @@ final class FirmwareArtifactStore {
199
561
  try rejectIfCancelled(transactionId: params.transactionId)
200
562
  return try await downloadLocked(
201
563
  params,
202
- expectedSha256: expectedSha256
564
+ validated: validated
203
565
  )
204
566
  }
205
- markDownloadActive(expectedSha256, delta: -1)
567
+ try rejectIfCancelled(transactionId: params.transactionId)
568
+ try retainExpectedArtifact(
569
+ leaseRef: params.leaseRef,
570
+ transactionId: params.transactionId,
571
+ artifactRef: artifact.artifactRef
572
+ )
573
+ markDownloadActive(validated.downloadToken, delta: -1)
206
574
  return artifact
207
575
  } catch {
208
- markDownloadActive(expectedSha256, delta: -1)
576
+ markDownloadActive(validated.downloadToken, delta: -1)
577
+ OneKeyLog.error(
578
+ "FirmwareArtifact",
579
+ "event=download_failed transactionId=\(params.transactionId) artifactId=\(params.artifactId) route=\(params.routeType) errorType=\(String(describing: type(of: error)))"
580
+ )
209
581
  if error is CancellationError ||
210
582
  isTransactionCancelled(params.transactionId) {
211
583
  throw FirmwareArtifactStoreError.downloadFailed(
@@ -216,6 +588,32 @@ final class FirmwareArtifactStore {
216
588
  }
217
589
  }
218
590
 
591
+ func cachedArtifact(
592
+ _ params: FirmwareArtifactDownloadParams
593
+ ) throws -> StoredFirmwareArtifact? {
594
+ let validated = try Self.validateDownloadParams(params)
595
+ try rejectIfCancelled(transactionId: params.transactionId)
596
+ guard let expectedSha256 = validated.expectedSha256 else {
597
+ return nil
598
+ }
599
+ let finalURL = artifactURL(sha256: expectedSha256)
600
+ guard let artifact = try? validateDownloadedArtifact(
601
+ fileURL: finalURL,
602
+ expectedSize: validated.expectedSize,
603
+ expectedSha256: expectedSha256,
604
+ maxBytes: validated.maxBytes
605
+ ) else {
606
+ return nil
607
+ }
608
+ try retainExpectedArtifact(
609
+ leaseRef: params.leaseRef,
610
+ transactionId: params.transactionId,
611
+ artifactRef: artifact.artifactRef
612
+ )
613
+ try rejectIfCancelled(transactionId: params.transactionId)
614
+ return artifact
615
+ }
616
+
219
617
  func cancelDownloads(transactionId: String) async throws {
220
618
  guard Self.isSafeIdentifier(transactionId) else {
221
619
  throw FirmwareArtifactStoreError.invalidInput(
@@ -226,49 +624,55 @@ final class FirmwareArtifactStore {
226
624
  cancelledTransactions.insert(transactionId)
227
625
  }
228
626
  await downloadCoordinator.cancel(transactionId: transactionId)
229
- try await RangeDownloader.shared.cancelFirmwareArtifactDownloads(
230
- transactionId: transactionId
231
- )
232
627
  }
233
628
 
234
629
  private func downloadLocked(
235
630
  _ params: FirmwareArtifactDownloadParams,
236
- expectedSha256: String
631
+ validated: ValidatedDownload
237
632
  ) 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
- )
633
+ if let expectedSha256 = validated.expectedSha256 {
634
+ let finalURL = artifactURL(sha256: expectedSha256)
635
+ if let existing = try? validateDownloadedArtifact(
636
+ fileURL: finalURL,
637
+ expectedSize: validated.expectedSize,
638
+ expectedSha256: expectedSha256,
639
+ maxBytes: validated.maxBytes
640
+ ) {
641
+ return existing
642
+ }
251
643
  }
252
644
 
645
+ // Firmware preflight is foreground-bound, so both routes use the same
646
+ // cancellable stream instead of waiting on process-owned background tasks.
253
647
  let partialURL = rootURL.appendingPathComponent(
254
- "\(expectedSha256).\(params.taskId).partial",
648
+ firmwareArtifactPartialFileName(
649
+ transactionId: params.transactionId,
650
+ taskId: params.taskId,
651
+ downloadToken: validated.downloadToken
652
+ ),
255
653
  isDirectory: false
256
654
  )
257
655
  if !fileManager.fileExists(atPath: partialURL.path) {
258
656
  fileManager.createFile(atPath: partialURL.path, contents: nil)
259
657
  }
260
658
  var currentSize = try fileSize(partialURL)
261
- if currentSize > Int64(params.expectedSize) {
659
+ if validated.expectedSha256 == nil && currentSize > 0 {
660
+ try fileManager.removeItem(at: partialURL)
661
+ fileManager.createFile(atPath: partialURL.path, contents: nil)
662
+ currentSize = 0
663
+ } else if currentSize > validated.maxBytes {
262
664
  try fileManager.removeItem(at: partialURL)
263
665
  fileManager.createFile(atPath: partialURL.path, contents: nil)
264
666
  currentSize = 0
265
667
  }
266
- if currentSize == Int64(params.expectedSize) {
267
- if let completed = try? validateStoredArtifact(
668
+ if let expectedSize = validated.expectedSize, currentSize == expectedSize {
669
+ if let completed = try? validateDownloadedArtifact(
268
670
  fileURL: partialURL,
269
- expectedSize: Int64(params.expectedSize),
270
- expectedSha256: expectedSha256
671
+ expectedSize: expectedSize,
672
+ expectedSha256: validated.expectedSha256,
673
+ maxBytes: validated.maxBytes
271
674
  ) {
675
+ let finalURL = artifactURL(sha256: completed.sha256)
272
676
  try promote(source: partialURL, destination: finalURL)
273
677
  return StoredFirmwareArtifact(
274
678
  artifactRef: completed.artifactRef,
@@ -282,22 +686,33 @@ final class FirmwareArtifactStore {
282
686
  currentSize = 0
283
687
  }
284
688
 
689
+ OneKeyLog.info(
690
+ "FirmwareArtifact",
691
+ "event=stream_start transactionId=\(params.transactionId) artifactId=\(params.artifactId) route=\(params.routeType) expectedBytes=\(validated.expectedSize ?? -1) resumeBytes=\(currentSize)"
692
+ )
285
693
  try await streamDownload(
286
694
  params,
287
695
  partialURL: partialURL,
288
- resumeOffset: min(currentSize, Int64(params.expectedSize))
696
+ resumeOffset: min(currentSize, validated.maxBytes),
697
+ validated: validated
698
+ )
699
+ OneKeyLog.info(
700
+ "FirmwareArtifact",
701
+ "event=stream_complete transactionId=\(params.transactionId) artifactId=\(params.artifactId) route=\(params.routeType) expectedBytes=\(validated.expectedSize ?? -1)"
289
702
  )
290
703
  let artifact: StoredFirmwareArtifact
291
704
  do {
292
- artifact = try validateStoredArtifact(
705
+ artifact = try validateDownloadedArtifact(
293
706
  fileURL: partialURL,
294
- expectedSize: Int64(params.expectedSize),
295
- expectedSha256: expectedSha256
707
+ expectedSize: validated.expectedSize,
708
+ expectedSha256: validated.expectedSha256,
709
+ maxBytes: validated.maxBytes
296
710
  )
297
711
  } catch {
298
712
  try? fileManager.removeItem(at: partialURL)
299
713
  throw error
300
714
  }
715
+ let finalURL = artifactURL(sha256: artifact.sha256)
301
716
  try promote(source: partialURL, destination: finalURL)
302
717
  return StoredFirmwareArtifact(
303
718
  artifactRef: artifact.artifactRef,
@@ -307,67 +722,11 @@ final class FirmwareArtifactStore {
307
722
  )
308
723
  }
309
724
 
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
725
  func discard(artifactRef: String) throws {
361
726
  let fileURL = try resolveArtifactURL(artifactRef)
362
727
  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
728
+ let isRetained = leases.values.contains {
729
+ $0.artifactRefs.contains(artifactRef)
371
730
  }
372
731
  leaseLock.unlock()
373
732
  guard !isRetained else {
@@ -447,7 +806,7 @@ final class FirmwareArtifactStore {
447
806
  func materializeArchive(
448
807
  leaseRef: String,
449
808
  artifactRef: String,
450
- expectedEntries: [FirmwareArchiveExpectedEntry]
809
+ expectedEntries: [FirmwareArchiveExpectedEntry]?
451
810
  ) throws -> [StoredFirmwareArchiveEntry] {
452
811
  try requireLease(leaseRef)
453
812
  let archiveURL = try resolveArtifactURL(artifactRef)
@@ -458,10 +817,13 @@ final class FirmwareArtifactStore {
458
817
  try fileManager.createDirectory(at: scratchURL, withIntermediateDirectories: true)
459
818
  defer { try? fileManager.removeItem(at: scratchURL) }
460
819
 
461
- let requirements = try validateArchiveRequirements(expectedEntries)
462
820
  let archiveEntries = try FirmwareArchiveMinizipBridge.scanArchive(
463
821
  atPath: archiveURL.path
464
822
  )
823
+ let requirements = try resolveArchiveRequirements(
824
+ expectedEntries,
825
+ archiveEntries: archiveEntries
826
+ )
465
827
  try validateArchiveEntries(
466
828
  archiveEntries,
467
829
  requirements: requirements
@@ -488,7 +850,7 @@ final class FirmwareArtifactStore {
488
850
  }
489
851
  do {
490
852
  actualSize += Int64(chunk.count)
491
- guard actualSize <= Int64(requirement.expectedSize) else {
853
+ guard actualSize <= requirement.expectedSize else {
492
854
  throw FirmwareArtifactStoreError.archiveInvalid(
493
855
  "Firmware archive entry exceeds its expected size"
494
856
  )
@@ -514,8 +876,8 @@ final class FirmwareArtifactStore {
514
876
  String(format: "%02x", $0)
515
877
  }.joined()
516
878
  guard
517
- actualSize == Int64(requirement.expectedSize),
518
- sha256 == requirement.expectedSha256.lowercased()
879
+ actualSize == requirement.expectedSize,
880
+ requirement.expectedSha256.map({ sha256 == $0 }) ?? true
519
881
  else {
520
882
  throw FirmwareArtifactStoreError.archiveInvalid(
521
883
  "Firmware archive entry integrity mismatch"
@@ -566,18 +928,16 @@ final class FirmwareArtifactStore {
566
928
  }
567
929
  leaseLock.lock()
568
930
  defer { leaseLock.unlock() }
569
- var envelope = try loadLeasesLocked()
570
- guard envelope.leases.count < 32 else {
931
+ guard leases.count < 32 else {
571
932
  throw FirmwareArtifactStoreError.invalidInput(
572
933
  "Too many firmware artifact leases"
573
934
  )
574
935
  }
575
936
  let leaseRef = "fwlease:\(UUID().uuidString.lowercased())"
576
- envelope.leases[leaseRef] = LeaseState(
937
+ leases[leaseRef] = LeaseState(
577
938
  transactionId: transactionId,
578
939
  artifactRefs: []
579
940
  )
580
- try saveLeasesLocked(envelope)
581
941
  return leaseRef
582
942
  }
583
943
 
@@ -603,9 +963,8 @@ final class FirmwareArtifactStore {
603
963
  let transactionId: String = try {
604
964
  leaseLock.lock()
605
965
  defer { leaseLock.unlock() }
606
- var envelope = try loadLeasesLocked()
607
966
  guard
608
- let lease = envelope.leases.removeValue(
967
+ let lease = leases.removeValue(
609
968
  forKey: try validateLeaseRef(leaseRef)
610
969
  )
611
970
  else {
@@ -613,7 +972,6 @@ final class FirmwareArtifactStore {
613
972
  "Firmware artifact lease is unavailable"
614
973
  )
615
974
  }
616
- try saveLeasesLocked(envelope)
617
975
  return lease.transactionId
618
976
  }()
619
977
  cancellationLock.withFirmwareArtifactLock {
@@ -621,43 +979,13 @@ final class FirmwareArtifactStore {
621
979
  }
622
980
  }
623
981
 
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
982
  func sweepOrphans() throws -> (deletedFiles: Int, deletedBytes: Int64) {
649
983
  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
- }
984
+ let retained = Set(
985
+ leases.values
986
+ .flatMap(\.artifactRefs)
987
+ .map { String($0.dropFirst(3)) }
988
+ )
661
989
  leaseLock.unlock()
662
990
  activeDownloadLock.lock()
663
991
  let active = Set(activeDownloadCounts.filter { $0.value > 0 }.keys)
@@ -666,61 +994,41 @@ final class FirmwareArtifactStore {
666
994
  let openPaths = Set(readers.values.map(\.fileURL.path))
667
995
  readerLock.unlock()
668
996
 
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]
997
+ return try sweepFirmwareArtifactOrphansAtRoot(
998
+ rootURL,
999
+ retainedSha256: retained,
1000
+ activeSha256: active,
1001
+ openPaths: openPaths,
1002
+ fileManager: fileManager
676
1003
  )
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
1004
  }
713
1005
 
714
1006
  private func requireLease(_ leaseRef: String) throws {
715
1007
  leaseLock.lock()
716
1008
  defer { leaseLock.unlock() }
717
- guard try loadLeasesLocked().leases[validateLeaseRef(leaseRef)] != nil else {
1009
+ guard leases[try validateLeaseRef(leaseRef)] != nil else {
718
1010
  throw FirmwareArtifactStoreError.invalidInput(
719
1011
  "Firmware artifact lease is unavailable"
720
1012
  )
721
1013
  }
722
1014
  }
723
1015
 
1016
+ private func requireLeaseTransaction(
1017
+ leaseRef: String,
1018
+ transactionId: String
1019
+ ) throws {
1020
+ leaseLock.lock()
1021
+ defer { leaseLock.unlock() }
1022
+ guard
1023
+ let lease = leases[try validateLeaseRef(leaseRef)],
1024
+ lease.transactionId == transactionId
1025
+ else {
1026
+ throw FirmwareArtifactStoreError.invalidInput(
1027
+ "Firmware artifact lease transaction mismatch"
1028
+ )
1029
+ }
1030
+ }
1031
+
724
1032
  private func retainExpectedArtifact(
725
1033
  leaseRef: String,
726
1034
  transactionId: String?,
@@ -736,9 +1044,8 @@ final class FirmwareArtifactStore {
736
1044
  }
737
1045
  leaseLock.lock()
738
1046
  defer { leaseLock.unlock() }
739
- var envelope = try loadLeasesLocked()
740
1047
  let validatedLeaseRef = try validateLeaseRef(leaseRef)
741
- guard var lease = envelope.leases[validatedLeaseRef] else {
1048
+ guard var lease = leases[validatedLeaseRef] else {
742
1049
  throw FirmwareArtifactStoreError.invalidInput(
743
1050
  "Firmware artifact lease is unavailable"
744
1051
  )
@@ -749,8 +1056,7 @@ final class FirmwareArtifactStore {
749
1056
  )
750
1057
  }
751
1058
  if lease.artifactRefs.insert(artifactRef).inserted {
752
- envelope.leases[validatedLeaseRef] = lease
753
- try saveLeasesLocked(envelope)
1059
+ leases[validatedLeaseRef] = lease
754
1060
  }
755
1061
  }
756
1062
 
@@ -770,84 +1076,8 @@ final class FirmwareArtifactStore {
770
1076
  ) != nil
771
1077
  }
772
1078
 
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])
1079
+ static func isSafeIdentifier(_ value: String) -> Bool {
1080
+ firmwareArtifactIdentifierIsSafe(value)
851
1081
  }
852
1082
 
853
1083
  private func markDownloadActive(_ sha256: String, delta: Int) {
@@ -863,7 +1093,7 @@ final class FirmwareArtifactStore {
863
1093
 
864
1094
  private func validateArchiveRequirements(
865
1095
  _ expectedEntries: [FirmwareArchiveExpectedEntry]
866
- ) throws -> [FirmwareArchiveExpectedEntry] {
1096
+ ) throws -> [FirmwareArchiveRequirement] {
867
1097
  guard !expectedEntries.isEmpty, expectedEntries.count <= 4096 else {
868
1098
  throw FirmwareArtifactStoreError.archiveInvalid(
869
1099
  "Firmware archive expected entry count is invalid"
@@ -904,12 +1134,48 @@ final class FirmwareArtifactStore {
904
1134
  )
905
1135
  }
906
1136
  }
907
- return expectedEntries
1137
+ return expectedEntries.map {
1138
+ FirmwareArchiveRequirement(
1139
+ entryName: $0.entryName,
1140
+ expectedSize: Int64($0.expectedSize),
1141
+ expectedSha256: $0.expectedSha256.lowercased()
1142
+ )
1143
+ }
1144
+ }
1145
+
1146
+ private func resolveArchiveRequirements(
1147
+ _ expectedEntries: [FirmwareArchiveExpectedEntry]?,
1148
+ archiveEntries: [FirmwareArchiveEntryInfo]
1149
+ ) throws -> [FirmwareArchiveRequirement] {
1150
+ if let expectedEntries {
1151
+ return try validateArchiveRequirements(expectedEntries)
1152
+ }
1153
+ if let issue = firmwareArchiveDiscoveredEntriesIssue(
1154
+ archiveEntries.map(\.name)
1155
+ ) {
1156
+ switch issue {
1157
+ case .empty:
1158
+ throw FirmwareArtifactStoreError.archiveInvalid(
1159
+ "Firmware archive must contain at least one entry"
1160
+ )
1161
+ case .duplicateName:
1162
+ throw FirmwareArtifactStoreError.archiveInvalid(
1163
+ "Firmware archive contains duplicate entry names"
1164
+ )
1165
+ }
1166
+ }
1167
+ return archiveEntries.map {
1168
+ FirmwareArchiveRequirement(
1169
+ entryName: $0.name,
1170
+ expectedSize: $0.uncompressedSize,
1171
+ expectedSha256: nil
1172
+ )
1173
+ }
908
1174
  }
909
1175
 
910
1176
  private func validateArchiveEntries(
911
1177
  _ entries: [FirmwareArchiveEntryInfo],
912
- requirements: [FirmwareArchiveExpectedEntry]
1178
+ requirements: [FirmwareArchiveRequirement]
913
1179
  ) throws {
914
1180
  guard entries.count == requirements.count else {
915
1181
  throw FirmwareArtifactStoreError.archiveInvalid(
@@ -921,16 +1187,32 @@ final class FirmwareArtifactStore {
921
1187
  )
922
1188
  var names = Set<String>()
923
1189
  var canonicalNames = Set<String>()
1190
+ var totalSize: Int64 = 0
924
1191
  for entry in entries {
1192
+ guard let requirement = requirementsByName[entry.name] else {
1193
+ throw FirmwareArtifactStoreError.archiveInvalid(
1194
+ "Firmware archive contains an unexpected entry"
1195
+ )
1196
+ }
1197
+ let (nextTotalSize, overflowed) = totalSize.addingReportingOverflow(
1198
+ entry.uncompressedSize
1199
+ )
1200
+ guard !overflowed else {
1201
+ throw FirmwareArtifactStoreError.archiveInvalid(
1202
+ "Firmware archive expanded size is invalid"
1203
+ )
1204
+ }
1205
+ totalSize = nextTotalSize
925
1206
  guard
926
1207
  names.insert(entry.name).inserted,
927
1208
  isPortableArchiveEntryName(
928
1209
  entry.name,
929
1210
  canonicalNames: &canonicalNames
930
1211
  ),
931
- let requirement = requirementsByName[entry.name],
932
- entry.uncompressedSize == Int64(requirement.expectedSize),
1212
+ entry.uncompressedSize == requirement.expectedSize,
933
1213
  entry.uncompressedSize > 0,
1214
+ entry.uncompressedSize <= 128 * 1024 * 1024,
1215
+ totalSize <= 512 * 1024 * 1024,
934
1216
  entry.compressedSize >= 0,
935
1217
  entry.compressedSize <= 512 * 1024 * 1024,
936
1218
  entry.uncompressedSize <= max(entry.compressedSize, 1) * 1000,
@@ -1001,32 +1283,70 @@ final class FirmwareArtifactStore {
1001
1283
  private func streamDownload(
1002
1284
  _ params: FirmwareArtifactDownloadParams,
1003
1285
  partialURL: URL,
1004
- resumeOffset: Int64
1286
+ resumeOffset: Int64,
1287
+ validated: ValidatedDownload
1288
+ ) async throws {
1289
+ do {
1290
+ try await FirmwareArtifactWallClockDeadline.run(
1291
+ timeoutSeconds:
1292
+ params.overallDeadlineSeconds ?? Self.defaultDownloadDeadline
1293
+ ) { [self] in
1294
+ try await streamDownloadWithinDeadline(
1295
+ params,
1296
+ partialURL: partialURL,
1297
+ resumeOffset: resumeOffset,
1298
+ validated: validated
1299
+ )
1300
+ }
1301
+ } catch FirmwareArtifactDeadlineError.exceeded {
1302
+ throw FirmwareArtifactStoreError.downloadFailed(
1303
+ "ARTIFACT_DEADLINE_EXCEEDED: firmware download exceeded its deadline"
1304
+ )
1305
+ }
1306
+ }
1307
+
1308
+ private func streamDownloadWithinDeadline(
1309
+ _ params: FirmwareArtifactDownloadParams,
1310
+ partialURL: URL,
1311
+ resumeOffset: Int64,
1312
+ validated: ValidatedDownload
1005
1313
  ) async throws {
1006
1314
  guard let url = URL(string: params.url), let hostname = url.host else {
1007
1315
  throw FirmwareArtifactStoreError.invalidInput("Invalid firmware URL")
1008
1316
  }
1009
1317
  var request = URLRequest(url: url)
1010
1318
  request.cachePolicy = .reloadIgnoringLocalCacheData
1011
- request.timeoutInterval = params.overallDeadlineSeconds ?? 180
1319
+ request.timeoutInterval =
1320
+ params.overallDeadlineSeconds ?? Self.defaultDownloadDeadline
1012
1321
  request.setValue("identity", forHTTPHeaderField: "Accept-Encoding")
1013
1322
  if resumeOffset > 0 {
1014
1323
  request.setValue("bytes=\(resumeOffset)-", forHTTPHeaderField: "Range")
1015
1324
  }
1016
1325
 
1326
+ let streamDelegate = FirmwareArtifactStreamDelegate(
1327
+ partialURL: partialURL,
1328
+ hostname: hostname,
1329
+ resumeOffset: resumeOffset,
1330
+ expectedSize: validated.expectedSize,
1331
+ maxBytes: validated.maxBytes,
1332
+ isCancelled: { [weak self] in
1333
+ Task.isCancelled ||
1334
+ self?.isTransactionCancelled(params.transactionId) == true
1335
+ }
1336
+ )
1017
1337
  let pinnedSession: SniConnectPinnedSession?
1018
1338
  if params.routeType == "pinnedIp" {
1019
1339
  pinnedSession = try SniConnectPinnedTransport.makeSession(
1020
1340
  hostname: hostname,
1021
- ip: params.resolvedIp!
1341
+ ip: params.resolvedIp!,
1342
+ dataDelegate: streamDelegate
1022
1343
  )
1023
1344
  } else {
1024
1345
  pinnedSession = nil
1025
1346
  }
1026
- let domainDelegate = FirmwareArtifactRedirectDelegate()
1027
1347
  let session = pinnedSession?.session ?? URLSession(
1028
1348
  configuration: .ephemeral,
1029
- delegate: domainDelegate,
1349
+ delegate: streamDelegate,
1030
1350
  delegateQueue: nil
1031
1351
  )
1032
1352
  defer {
@@ -1037,71 +1357,8 @@ final class FirmwareArtifactStore {
1037
1357
  }
1038
1358
  }
1039
1359
 
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
1360
  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
- }
1361
+ try await streamDelegate.run(session: session, request: request)
1105
1362
  } catch let error as FirmwareArtifactStoreError {
1106
1363
  throw error
1107
1364
  } catch {
@@ -1113,20 +1370,15 @@ final class FirmwareArtifactStore {
1113
1370
  "ARTIFACT_CANCELLED: firmware artifact download was cancelled"
1114
1371
  )
1115
1372
  }
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 {
1373
+ if isFirmwareArtifactTLSError(error) {
1123
1374
  throw FirmwareArtifactStoreError.downloadFailed(
1124
- "ARTIFACT_PROTOCOL_INVALID: firmware artifact exceeds maxBytes"
1375
+ "ARTIFACT_TLS_FAILED: firmware TLS validation failed"
1125
1376
  )
1126
1377
  }
1127
- try handle.write(contentsOf: buffer)
1378
+ throw FirmwareArtifactStoreError.downloadFailed(
1379
+ "ARTIFACT_NETWORK_FAILED: firmware request failed"
1380
+ )
1128
1381
  }
1129
- try handle.synchronize()
1130
1382
  try rejectIfCancelled(transactionId: params.transactionId)
1131
1383
  }
1132
1384
 
@@ -1157,34 +1409,6 @@ final class FirmwareArtifactStore {
1157
1409
  nsError.code == NSURLErrorCancelled
1158
1410
  }
1159
1411
 
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
1412
  private func validateStoredArtifact(
1189
1413
  fileURL: URL,
1190
1414
  expectedSize: Int64,
@@ -1210,6 +1434,36 @@ final class FirmwareArtifactStore {
1210
1434
  )
1211
1435
  }
1212
1436
 
1437
+ private func validateDownloadedArtifact(
1438
+ fileURL: URL,
1439
+ expectedSize: Int64?,
1440
+ expectedSha256: String?,
1441
+ maxBytes: Int64
1442
+ ) throws -> StoredFirmwareArtifact {
1443
+ let size = try fileSize(fileURL)
1444
+ guard
1445
+ size > 0,
1446
+ size <= maxBytes,
1447
+ expectedSize.map({ size == $0 }) ?? true
1448
+ else {
1449
+ throw FirmwareArtifactStoreError.integrityMismatch(
1450
+ "ARTIFACT_INTEGRITY_FAILED: firmware artifact size mismatch"
1451
+ )
1452
+ }
1453
+ let sha256 = try hashFile(fileURL)
1454
+ guard expectedSha256.map({ sha256 == $0 }) ?? true else {
1455
+ throw FirmwareArtifactStoreError.integrityMismatch(
1456
+ "ARTIFACT_INTEGRITY_FAILED: firmware artifact SHA-256 mismatch"
1457
+ )
1458
+ }
1459
+ return StoredFirmwareArtifact(
1460
+ artifactRef: "fw:\(sha256)",
1461
+ size: size,
1462
+ sha256: sha256,
1463
+ fileURL: fileURL
1464
+ )
1465
+ }
1466
+
1213
1467
  private func resolveArtifactURL(_ artifactRef: String) throws -> URL {
1214
1468
  guard artifactRef.hasPrefix("fw:") else {
1215
1469
  throw FirmwareArtifactStoreError.invalidInput("Invalid firmware artifactRef")