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