@rovq/react-native 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +120 -2
  3. package/Rovq.podspec +19 -0
  4. package/android/build.gradle +86 -0
  5. package/android/src/main/AndroidManifest.xml +4 -0
  6. package/android/src/main/java/com/rovq/engine/EngineState.kt +176 -0
  7. package/android/src/main/java/com/rovq/engine/ManifestVerifier.kt +230 -0
  8. package/android/src/main/java/com/rovq/engine/PackageDownloader.kt +199 -0
  9. package/android/src/main/java/com/rovq/engine/PackageRules.kt +39 -0
  10. package/android/src/main/java/com/rovq/engine/ProgressThrottle.kt +17 -0
  11. package/android/src/main/java/com/rovq/engine/RovqEngine.kt +550 -0
  12. package/android/src/main/java/com/rovq/engine/RovqException.kt +31 -0
  13. package/android/src/main/java/com/rovq/engine/SafePackageExtractor.kt +121 -0
  14. package/android/src/main/java/com/rovq/engine/StrictBase64.kt +37 -0
  15. package/android/src/main/java/com/rovq/engine/UpdateInstaller.kt +347 -0
  16. package/android/src/main/java/com/rovq/engine/UpdateService.kt +175 -0
  17. package/android/src/main/java/com/rovq/engine/ZipStructureCheck.kt +194 -0
  18. package/android/src/react/java/com/rovq/engine/react/ResourceFiles.kt +52 -0
  19. package/android/src/react/java/com/rovq/engine/react/Rovq.kt +102 -0
  20. package/android/src/react/java/com/rovq/engine/react/RovqModule.kt +164 -0
  21. package/android/src/react/java/com/rovq/engine/react/RovqPackage.kt +26 -0
  22. package/android/src/react/java/com/rovq/engine/react/RovqReactHost.kt +74 -0
  23. package/ios/Engine/EngineState.swift +119 -0
  24. package/ios/Engine/ManifestVerifier.swift +279 -0
  25. package/ios/Engine/PackageDownloader.swift +249 -0
  26. package/ios/Engine/PackageRules.swift +38 -0
  27. package/ios/Engine/ProgressThrottle.swift +20 -0
  28. package/ios/Engine/RovqEngine.swift +646 -0
  29. package/ios/Engine/RovqError.swift +35 -0
  30. package/ios/Engine/SafePackageExtractor.swift +107 -0
  31. package/ios/Engine/UpdateInstaller.swift +348 -0
  32. package/ios/Engine/UpdateService.swift +185 -0
  33. package/ios/Engine/ZipReader.swift +378 -0
  34. package/ios/React/RovqHost.swift +255 -0
  35. package/ios/React/RovqModule.mm +91 -0
  36. package/package.json +78 -3
  37. package/react-native.config.js +11 -0
  38. package/src/NativeRovq.ts +72 -0
  39. package/src/api.ts +228 -0
  40. package/src/errors.ts +44 -0
  41. package/src/index.ts +10 -0
  42. package/src/react.tsx +158 -0
  43. package/src/update-flow.ts +91 -0
@@ -0,0 +1,119 @@
1
+ import Foundation
2
+
3
+ /// A release on the device, identified by its folder name and its signed sequence.
4
+ public struct ReleaseRef: Codable, Equatable {
5
+ public let id: String
6
+ public let sequence: Int64
7
+ }
8
+
9
+ /// Waiting to be sent to the server. Device events are retried, so each has its own ID.
10
+ public struct QueuedEvent: Codable, Equatable {
11
+ public let clientEventId: String
12
+ public let type: String
13
+ public let releaseId: String?
14
+ public let errorCode: String?
15
+ public let occurredAt: String
16
+ }
17
+
18
+ /// A release that failed its checks after downloading, and when it may be tried again.
19
+ struct PostponedDownload: Codable, Equatable {
20
+ let releaseId: String
21
+ let attempts: Int
22
+ let until: TimeInterval
23
+ }
24
+
25
+ /// Everything the engine must remember across process deaths. It holds IDs and never full
26
+ /// paths, because iOS moves an app's container whenever the app is updated.
27
+ ///
28
+ /// A release moves from `downloaded` to `pending` to `trying` to `current`:
29
+ /// - `downloaded`: verified and on disk, but nothing will load it until the app installs it.
30
+ /// - `pending`: installed, and loads on the next JavaScript start.
31
+ /// - `trying`: being loaded right now. If the next start finds it still here, it never
32
+ /// confirmed that it works, so it is abandoned.
33
+ /// - `current`: confirmed to work.
34
+ /// - `previous`: the confirmed release before `current`, kept to go back to.
35
+ struct EngineState: Codable, Equatable {
36
+ var deviceId: String
37
+ var binaryId: String
38
+ var runtimeVersion: String
39
+ var highestSequence: Int64 = 0
40
+ var current: ReleaseRef?
41
+ var previous: ReleaseRef?
42
+ var downloaded: ReleaseRef?
43
+ var pending: ReleaseRef?
44
+ var trying: ReleaseRef?
45
+ /// True if the app went to the background while `trying`, so a person probably closed it.
46
+ var backgroundedWhileTrying = false
47
+ /// How many more starts `trying` was given because it was closed before confirming.
48
+ var tryingRetries = 0
49
+ var postponed: PostponedDownload?
50
+ var revokedPublicKeys: Set<String> = []
51
+ var events: [QueuedEvent] = []
52
+
53
+ enum CodingKeys: String, CodingKey {
54
+ case deviceId, binaryId, runtimeVersion, highestSequence, current, previous, downloaded
55
+ case pending, trying, backgroundedWhileTrying, tryingRetries, postponed, revokedPublicKeys
56
+ case events
57
+ }
58
+
59
+ init(deviceId: String, binaryId: String, runtimeVersion: String) {
60
+ self.deviceId = deviceId
61
+ self.binaryId = binaryId
62
+ self.runtimeVersion = runtimeVersion
63
+ }
64
+
65
+ /// Fields added after the first version are optional, so an older saved state still loads.
66
+ init(from decoder: Decoder) throws {
67
+ let values = try decoder.container(keyedBy: CodingKeys.self)
68
+ deviceId = try values.decode(String.self, forKey: .deviceId)
69
+ binaryId = try values.decode(String.self, forKey: .binaryId)
70
+ runtimeVersion = try values.decode(String.self, forKey: .runtimeVersion)
71
+ highestSequence = try values.decode(Int64.self, forKey: .highestSequence)
72
+ current = try values.decodeIfPresent(ReleaseRef.self, forKey: .current)
73
+ previous = try values.decodeIfPresent(ReleaseRef.self, forKey: .previous)
74
+ downloaded = try values.decodeIfPresent(ReleaseRef.self, forKey: .downloaded)
75
+ pending = try values.decodeIfPresent(ReleaseRef.self, forKey: .pending)
76
+ trying = try values.decodeIfPresent(ReleaseRef.self, forKey: .trying)
77
+ backgroundedWhileTrying =
78
+ try values.decodeIfPresent(Bool.self, forKey: .backgroundedWhileTrying) ?? false
79
+ tryingRetries = try values.decodeIfPresent(Int.self, forKey: .tryingRetries) ?? 0
80
+ postponed = try values.decodeIfPresent(PostponedDownload.self, forKey: .postponed)
81
+ revokedPublicKeys =
82
+ try values.decodeIfPresent(Set<String>.self, forKey: .revokedPublicKeys) ?? []
83
+ events = try values.decode([QueuedEvent].self, forKey: .events)
84
+ }
85
+ }
86
+
87
+ /// Saves the state so that a crash at any moment leaves either the old state or the new one,
88
+ /// never a mixture. The app's first screen depends on this file being readable.
89
+ struct EngineStateStore {
90
+ private let file: URL
91
+ private let temporary: URL
92
+
93
+ init(rootFolder: URL) {
94
+ file = rootFolder.appendingPathComponent("state.json")
95
+ temporary = rootFolder.appendingPathComponent("state.json.tmp")
96
+ }
97
+
98
+ /// Returns nil if there is no state, or if it is unreadable.
99
+ func read() -> EngineState? {
100
+ guard let data = try? Data(contentsOf: file) else { return nil }
101
+ return try? JSONDecoder().decode(EngineState.self, from: data)
102
+ }
103
+
104
+ func write(_ state: EngineState) throws {
105
+ // Writing to another file first means a crash cannot leave a half-written state.
106
+ let data = try JSONEncoder().encode(state)
107
+ guard FileManager.default.createFile(atPath: temporary.path, contents: nil) else {
108
+ throw RovqError(.storageFailed, "Could not save the engine state")
109
+ }
110
+ let handle = try FileHandle(forWritingTo: temporary)
111
+ defer { try? handle.close() }
112
+ try handle.write(contentsOf: data)
113
+ try handle.synchronize()
114
+ // POSIX rename replaces the old file atomically.
115
+ guard rename(temporary.path, file.path) == 0 else {
116
+ throw RovqError(.storageFailed, "Could not save the engine state")
117
+ }
118
+ }
119
+ }
@@ -0,0 +1,279 @@
1
+ import CryptoKit
2
+ import Foundation
3
+
4
+ private let publicKeySize = 32
5
+ private let signatureSize = 64
6
+ private let platform = "IOS"
7
+ private let streamFields: Set<String> = [
8
+ "version", "releaseId", "deploymentKey", "platform", "runtimeVersion", "binaryVersion",
9
+ "sequence", "mandatory",
10
+ ]
11
+ /// Version 1 names one ZIP package. Version 2 lists files. See docs/file-packages.md.
12
+ private let zipManifestFields = streamFields.union(["package"])
13
+ private let filesManifestFields = streamFields.union(["files"])
14
+ private let packageFields: Set<String> = ["hash", "size"]
15
+ private let fileFields: Set<String> = ["path", "hash", "size"]
16
+ private let uuidPattern = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
17
+ private let hashPattern = "^[0-9a-f]{64}$"
18
+
19
+ /// One file of a release made of files, from a verified manifest.
20
+ public struct ManifestFile: Equatable {
21
+ public let path: String
22
+ public let hash: String
23
+ public let size: Int64
24
+ }
25
+
26
+ /// Facts from a manifest whose signature has been verified. Nothing else should be trusted.
27
+ public struct VerifiedManifest: Equatable {
28
+ public let releaseId: String
29
+ public let sequence: Int64
30
+ public let mandatory: Bool
31
+ /// The ZIP package's hash. Empty for a release made of files.
32
+ public let packageHash: String
33
+ /// The ZIP package's size, or all files together.
34
+ public let packageSize: Int64
35
+ /// Set for a release made of files, sorted by path. Nil for a ZIP package.
36
+ public let files: [ManifestFile]?
37
+
38
+ init(
39
+ releaseId: String,
40
+ sequence: Int64,
41
+ mandatory: Bool,
42
+ packageHash: String,
43
+ packageSize: Int64,
44
+ files: [ManifestFile]? = nil
45
+ ) {
46
+ self.releaseId = releaseId
47
+ self.sequence = sequence
48
+ self.mandatory = mandatory
49
+ self.packageHash = packageHash
50
+ self.packageSize = packageSize
51
+ self.files = files
52
+ }
53
+ }
54
+
55
+ /// Decodes standard Base64, accepting only one spelling of each value, as the server does.
56
+ func decodeStrictBase64(_ value: String, expectedSize: Int) -> Data? {
57
+ guard let data = Data(base64Encoded: value), data.count == expectedSize,
58
+ data.base64EncodedString() == value
59
+ else { return nil }
60
+ return data
61
+ }
62
+
63
+ /// The device's own security check. It trusts only the public keys built into the app, so
64
+ /// a compromised server cannot get code onto the device.
65
+ public final class ManifestVerifier {
66
+ private let keys: [String: Curve25519.Signing.PublicKey]
67
+ private let trustedKeys: [String: String]
68
+ private let revokedLock = NSLock()
69
+ private var revoked: Set<String> = []
70
+ private let deploymentKey: String
71
+ private let runtimeVersion: String
72
+
73
+ /// - Parameter trustedKeys: key IDs and their Base64 Ed25519 public keys, built into the app.
74
+ /// - Throws: if a built-in key is malformed, which is a mistake in the app itself.
75
+ public init(trustedKeys: [String: String], deploymentKey: String, runtimeVersion: String) throws {
76
+ keys = try trustedKeys.mapValues { publicKey in
77
+ guard let bytes = decodeStrictBase64(publicKey, expectedSize: publicKeySize) else {
78
+ throw RovqError(.invalidManifest, "A built-in public key is not a Base64 Ed25519 key")
79
+ }
80
+ return try Curve25519.Signing.PublicKey(rawRepresentation: bytes)
81
+ }
82
+ self.trustedKeys = trustedKeys
83
+ self.deploymentKey = deploymentKey
84
+ self.runtimeVersion = runtimeVersion
85
+ }
86
+
87
+ /// Public keys, not IDs, so a later version of the app can reuse an ID with a new key.
88
+ public var revokedPublicKeys: Set<String> {
89
+ get {
90
+ revokedLock.lock()
91
+ defer { revokedLock.unlock() }
92
+ return revoked
93
+ }
94
+ set {
95
+ revokedLock.lock()
96
+ defer { revokedLock.unlock() }
97
+ revoked = newValue
98
+ }
99
+ }
100
+
101
+ /// Checks a key's statement that it must no longer be trusted, and returns its public key.
102
+ /// Only the key's own signature counts, so a stolen key can revoke only itself. Comparing the
103
+ /// exact text means nothing unsigned reaches a parser.
104
+ ///
105
+ /// - Returns: nil if the statement is not valid, in which case it must be ignored.
106
+ public func verifyRevocation(keyId: String, statement: String, signature: String) -> String? {
107
+ guard let publicKey = trustedKeys[keyId],
108
+ statement == "{\"version\":1,\"type\":\"key-revocation\",\"publicKey\":\"\(publicKey)\"}",
109
+ (try? verifySignature(manifest: statement, signature: signature, keyId: keyId)) != nil
110
+ else { return nil }
111
+ return publicKey
112
+ }
113
+
114
+ /// - Parameter highestSequence: the highest sequence this device has accepted in this stream.
115
+ /// - Throws: `RovqError` if anything fails. The device must then ignore the update.
116
+ public func verify(
117
+ manifest: String,
118
+ signature: String,
119
+ keyId: String,
120
+ highestSequence: Int64
121
+ ) throws -> VerifiedManifest {
122
+ if let publicKey = trustedKeys[keyId], revokedPublicKeys.contains(publicKey) {
123
+ throw RovqError(.revokedSigningKey, "Signing key \(keyId) was revoked")
124
+ }
125
+ try verifySignature(manifest: manifest, signature: signature, keyId: keyId)
126
+ // Only signed text reaches the JSON parser.
127
+ let verified = try parseAndCheck(manifest)
128
+ if verified.sequence <= highestSequence {
129
+ throw RovqError(.staleSequence, "Sequence \(verified.sequence) is not above \(highestSequence)")
130
+ }
131
+ return verified
132
+ }
133
+
134
+ private func verifySignature(manifest: String, signature: String, keyId: String) throws {
135
+ guard let key = keys[keyId] else {
136
+ throw RovqError(.unknownSigningKey, "Unknown signing key \(keyId)")
137
+ }
138
+ guard let bytes = decodeStrictBase64(signature, expectedSize: signatureSize) else {
139
+ throw RovqError(.invalidSignature, "Malformed signature")
140
+ }
141
+ guard key.isValidSignature(bytes, for: Data(manifest.utf8)) else {
142
+ throw RovqError(.invalidSignature, "Signature does not match")
143
+ }
144
+ }
145
+
146
+ private func parseAndCheck(_ manifest: String) throws -> VerifiedManifest {
147
+ let parsed = try? JSONSerialization.jsonObject(with: Data(manifest.utf8))
148
+ guard let root = parsed as? [String: Any] else {
149
+ throw invalid("Manifest is not a JSON object")
150
+ }
151
+ let version = try integerField(root, "version")
152
+ guard version == 1 || version == 2 else { throw invalid("Unknown manifest version") }
153
+ try requireOnlyFields(root, allowed: version == 1 ? zipManifestFields : filesManifestFields)
154
+
155
+ let releaseId = try stringField(root, "releaseId")
156
+ // Later used as a folder name, so it must be exactly a UUID.
157
+ guard matches(releaseId, uuidPattern) else { throw invalid("releaseId is not a UUID") }
158
+
159
+ let sequence = try integerField(root, "sequence")
160
+ guard sequence > 0 else { throw invalid("sequence must be positive") }
161
+
162
+ guard let mandatoryNumber = root["mandatory"] as? NSNumber, isBoolean(mandatoryNumber) else {
163
+ throw invalid("mandatory must be a boolean")
164
+ }
165
+ _ = try stringField(root, "binaryVersion")
166
+
167
+ // A valid signature for another deployment, platform or runtime must not install here.
168
+ try requireMatch("deploymentKey", try stringField(root, "deploymentKey"), deploymentKey)
169
+ try requireMatch("platform", try stringField(root, "platform"), platform)
170
+ try requireMatch("runtimeVersion", try stringField(root, "runtimeVersion"), runtimeVersion)
171
+
172
+ if version == 2 {
173
+ let files = try fileList(root["files"])
174
+ return VerifiedManifest(
175
+ releaseId: releaseId,
176
+ sequence: sequence,
177
+ mandatory: mandatoryNumber.boolValue,
178
+ packageHash: "",
179
+ packageSize: files.reduce(0) { $0 + $1.size },
180
+ files: files
181
+ )
182
+ }
183
+
184
+ guard let package = root["package"] as? [String: Any] else {
185
+ throw invalid("package must be an object")
186
+ }
187
+ try requireOnlyFields(package, allowed: packageFields)
188
+ let hash = try stringField(package, "hash")
189
+ guard matches(hash, hashPattern) else { throw invalid("package hash is malformed") }
190
+ let size = try integerField(package, "size")
191
+ guard size > 0 else { throw invalid("package size must be positive") }
192
+
193
+ return VerifiedManifest(
194
+ releaseId: releaseId,
195
+ sequence: sequence,
196
+ mandatory: mandatoryNumber.boolValue,
197
+ packageHash: hash,
198
+ packageSize: size
199
+ )
200
+ }
201
+
202
+ /// The same rules as a ZIP package, because each path is written to disk the same way.
203
+ /// Sorted by UTF-16 code units, as JavaScript sorts, so one set of files has one signed form.
204
+ private func fileList(_ value: Any?) throws -> [ManifestFile] {
205
+ guard let list = value as? [Any] else { throw invalid("files must be an array") }
206
+ if list.isEmpty || list.count > PackageRules.maxEntries {
207
+ throw invalid("A package must have from 1 to \(PackageRules.maxEntries) files")
208
+ }
209
+ var files: [ManifestFile] = []
210
+ var seen = Set<String>()
211
+ var total: Int64 = 0
212
+ for item in list {
213
+ guard let entry = item as? [String: Any] else { throw invalid("Each file must be an object") }
214
+ try requireOnlyFields(entry, allowed: fileFields)
215
+ let path = try stringField(entry, "path")
216
+ if let problem = PackageRules.findPathProblem(path) {
217
+ throw invalid("The file \(path) \(problem)")
218
+ }
219
+ if let previous = files.last, !Array(previous.path.utf16).lexicographicallyPrecedes(path.utf16) {
220
+ throw invalid("The files must be sorted by path, each path once")
221
+ }
222
+ if !seen.insert(PackageRules.pathIdentity(path)).inserted {
223
+ throw invalid("The file \(path) differs from another only by letter case")
224
+ }
225
+ let hash = try stringField(entry, "hash")
226
+ guard matches(hash, hashPattern) else { throw invalid("The file \(path) has a malformed hash") }
227
+ let size = try integerField(entry, "size")
228
+ // Bounded before adding, because an overflow would stop the app.
229
+ guard size >= 0, size <= PackageRules.maxTotalSize else {
230
+ throw invalid("The file \(path) has an invalid size")
231
+ }
232
+ total += size
233
+ if total > PackageRules.maxTotalSize { throw invalid("The files are larger than the allowed total") }
234
+ files.append(ManifestFile(path: path, hash: hash, size: size))
235
+ }
236
+ guard let bundle = files.first(where: { $0.path == PackageRules.bundleFileName }) else {
237
+ throw invalid("The bundle file is missing")
238
+ }
239
+ if bundle.size == 0 { throw invalid("The bundle file is empty") }
240
+ return files
241
+ }
242
+
243
+ private func matches(_ value: String, _ pattern: String) -> Bool {
244
+ value.range(of: pattern, options: .regularExpression) != nil
245
+ }
246
+
247
+ private func requireOnlyFields(_ value: [String: Any], allowed: Set<String>) throws {
248
+ if let unknown = value.keys.first(where: { !allowed.contains($0) }) {
249
+ throw invalid("Unknown field \(unknown)")
250
+ }
251
+ }
252
+
253
+ private func stringField(_ value: [String: Any], _ name: String) throws -> String {
254
+ guard let text = value[name] as? String else { throw invalid("\(name) must be a string") }
255
+ return text
256
+ }
257
+
258
+ // JSON numbers and booleans both arrive as NSNumber, so they must be told apart.
259
+ private func integerField(_ value: [String: Any], _ name: String) throws -> Int64 {
260
+ guard let number = value[name] as? NSNumber, !isBoolean(number),
261
+ number.doubleValue == Double(number.int64Value)
262
+ else { throw invalid("\(name) must be a whole number") }
263
+ return number.int64Value
264
+ }
265
+
266
+ private func isBoolean(_ number: NSNumber) -> Bool {
267
+ CFGetTypeID(number) == CFBooleanGetTypeID()
268
+ }
269
+
270
+ private func requireMatch(_ name: String, _ actual: String, _ expected: String) throws {
271
+ if actual != expected {
272
+ throw RovqError(.manifestMismatch, "\(name) does not match this app")
273
+ }
274
+ }
275
+
276
+ private func invalid(_ message: String) -> RovqError {
277
+ RovqError(.invalidManifest, message)
278
+ }
279
+ }
@@ -0,0 +1,249 @@
1
+ import CryptoKit
2
+ import Foundation
3
+
4
+ private let requestTimeout: TimeInterval = 30
5
+
6
+ /// Downloads a package and proves it is the signed one. HTTPS protects the transfer, but the
7
+ /// hash is what makes the file trustworthy.
8
+ public final class PackageDownloader {
9
+ private let allowInsecureURLs: Bool
10
+ /// Room left in a folder. Replaced in tests.
11
+ let freeSpace: (URL) -> Int64
12
+
13
+ /// - Parameter allowInsecureURLs: allows http:// for local development only.
14
+ public init(
15
+ allowInsecureURLs: Bool = false,
16
+ freeSpace: @escaping (URL) -> Int64 = PackageDownloader.availableSpace
17
+ ) {
18
+ self.allowInsecureURLs = allowInsecureURLs
19
+ self.freeSpace = freeSpace
20
+ }
21
+
22
+ public static func availableSpace(in folder: URL) -> Int64 {
23
+ let values = try? folder.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey])
24
+ return values?.volumeAvailableCapacityForImportantUsage ?? Int64.max
25
+ }
26
+
27
+ /// Blocks until finished, so call it away from the main thread. Continues from what `target`
28
+ /// already holds, because on a poor connection a large file might otherwise never finish. A
29
+ /// network failure keeps the file for that reason. Any other failure deletes it. It counts as
30
+ /// good only once the whole file matches the signed hash, so a partial or wrong file can
31
+ /// never be mistaken for a good one.
32
+ ///
33
+ /// - Parameters:
34
+ /// - hash: the signed SHA-256 of the whole file.
35
+ /// - size: the signed size of the whole file.
36
+ /// - extraSpace: room needed beyond the file itself, such as for extracting it.
37
+ /// - onProgress: receives bytes received so far and the total.
38
+ func download(
39
+ url: String,
40
+ hash: String,
41
+ size: Int64,
42
+ target: URL,
43
+ extraSpace: Int64 = 0,
44
+ onProgress: @escaping (Int64, Int64) -> Void = { _, _ in }
45
+ ) throws {
46
+ let expected = Expected(hash: hash, size: size)
47
+ do {
48
+ try downloadToFile(
49
+ url: url, expected: expected, target: target, extraSpace: extraSpace, onProgress: onProgress)
50
+ } catch {
51
+ if (error as? RovqError)?.code != .downloadFailed {
52
+ try? FileManager.default.removeItem(at: target)
53
+ }
54
+ throw error
55
+ }
56
+ }
57
+
58
+ /// The signed facts a download must end up matching.
59
+ private struct Expected {
60
+ let hash: String
61
+ let size: Int64
62
+ }
63
+
64
+ private func downloadToFile(
65
+ url: String,
66
+ expected: Expected,
67
+ target: URL,
68
+ extraSpace: Int64,
69
+ onProgress: @escaping (Int64, Int64) -> Void
70
+ ) throws {
71
+ guard let parsed = URL(string: url), let scheme = parsed.scheme?.lowercased() else {
72
+ throw RovqError(.downloadFailed, "Malformed download URL")
73
+ }
74
+ if scheme != "https" && !allowInsecureURLs {
75
+ throw RovqError(.insecureURL, "Download URL must use HTTPS")
76
+ }
77
+
78
+ var digest = SHA256()
79
+ let existing = try readExisting(target: target, expected: expected, digest: &digest)
80
+ let needed = expected.size - existing + extraSpace
81
+ if freeSpace(target.deletingLastPathComponent()) < needed {
82
+ throw RovqError(.storageFailed, "Not enough free space for this update")
83
+ }
84
+ if existing == expected.size {
85
+ // An empty file has nothing to download, but must still exist.
86
+ if existing == 0, !FileManager.default.createFile(atPath: target.path, contents: nil) {
87
+ throw RovqError(.storageFailed, "Could not create the file")
88
+ }
89
+ try verifyHash(digest, expected: expected)
90
+ return
91
+ }
92
+ if existing == 0, !FileManager.default.createFile(atPath: target.path, contents: nil) {
93
+ throw RovqError(.storageFailed, "Could not create the file")
94
+ }
95
+ guard let output = try? FileHandle(forWritingTo: target) else {
96
+ throw RovqError(.storageFailed, "Could not create the file")
97
+ }
98
+ defer { try? output.close() }
99
+
100
+ let delegate = DownloadDelegate(
101
+ expectedSize: expected.size,
102
+ existing: existing,
103
+ digest: digest,
104
+ output: output,
105
+ onProgress: onProgress
106
+ )
107
+ var request = URLRequest(url: parsed)
108
+ if existing > 0 { request.setValue("bytes=\(existing)-", forHTTPHeaderField: "Range") }
109
+ let configuration = URLSessionConfiguration.ephemeral
110
+ configuration.timeoutIntervalForRequest = requestTimeout
111
+ let session = URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil)
112
+ session.dataTask(with: request).resume()
113
+ delegate.finished.wait()
114
+ session.finishTasksAndInvalidate()
115
+
116
+ if delegate.discardFile { try? FileManager.default.removeItem(at: target) }
117
+ if let failure = delegate.failure { throw failure }
118
+
119
+ do {
120
+ // Forces the bytes to disk, so power loss cannot leave a file that looks complete.
121
+ try output.synchronize()
122
+ } catch {
123
+ throw RovqError(.storageFailed, "Could not write the file")
124
+ }
125
+ // A lost connection can look like a normal end of file, so a short download counts as
126
+ // a network failure, which is worth retrying.
127
+ if delegate.received < expected.size {
128
+ throw RovqError(.downloadFailed, "The download ended early")
129
+ }
130
+ try verifyHash(delegate.digest, expected: expected)
131
+ }
132
+
133
+ /// Returns how many bytes are already there, after adding them to `digest`.
134
+ private func readExisting(target: URL, expected: Expected, digest: inout SHA256) throws -> Int64 {
135
+ let files = FileManager.default
136
+ guard files.fileExists(atPath: target.path),
137
+ let size = (try? files.attributesOfItem(atPath: target.path))?[.size] as? Int64
138
+ else { return 0 }
139
+ if size > expected.size {
140
+ try? files.removeItem(at: target)
141
+ return 0
142
+ }
143
+ guard let input = try? FileHandle(forReadingFrom: target) else { return 0 }
144
+ defer { try? input.close() }
145
+ while let chunk = try? input.read(upToCount: 64 * 1024), !chunk.isEmpty {
146
+ digest.update(data: chunk)
147
+ }
148
+ return size
149
+ }
150
+
151
+ private func verifyHash(_ digest: SHA256, expected: Expected) throws {
152
+ let hash = digest.finalize().map { String(format: "%02x", $0) }.joined()
153
+ if hash != expected.hash {
154
+ throw RovqError(.hashMismatch, "Download does not match the signed hash")
155
+ }
156
+ }
157
+ }
158
+
159
+ /// Handles the bytes as they arrive, which allows stopping as soon as something is wrong.
160
+ private final class DownloadDelegate: NSObject, URLSessionDataDelegate {
161
+ let finished = DispatchSemaphore(value: 0)
162
+ private(set) var failure: RovqError?
163
+ private(set) var received: Int64
164
+ private(set) var digest: SHA256
165
+ /// True when appending to the existing file could only produce a wrong file.
166
+ private(set) var discardFile = false
167
+ private let existing: Int64
168
+ private let expectedSize: Int64
169
+ private let output: FileHandle
170
+ private let onProgress: (Int64, Int64) -> Void
171
+
172
+ init(
173
+ expectedSize: Int64,
174
+ existing: Int64,
175
+ digest: SHA256,
176
+ output: FileHandle,
177
+ onProgress: @escaping (Int64, Int64) -> Void
178
+ ) {
179
+ self.expectedSize = expectedSize
180
+ self.existing = existing
181
+ self.received = existing
182
+ self.digest = digest
183
+ self.output = output
184
+ self.onProgress = onProgress
185
+ }
186
+
187
+ func urlSession(
188
+ _ session: URLSession,
189
+ dataTask: URLSessionDataTask,
190
+ didReceive response: URLResponse,
191
+ completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
192
+ ) {
193
+ let http = response as? HTTPURLResponse
194
+ let status = http?.statusCode ?? 0
195
+ do {
196
+ if existing > 0 && status == 206 {
197
+ let range = http?.value(forHTTPHeaderField: "Content-Range") ?? ""
198
+ guard range.hasPrefix("bytes \(existing)-") else {
199
+ discardFile = true
200
+ failure = RovqError(.downloadFailed, "The server continued from the wrong place")
201
+ completionHandler(.cancel)
202
+ return
203
+ }
204
+ try output.seekToEnd()
205
+ completionHandler(.allow)
206
+ } else if status == 200 {
207
+ // The server sent the whole file, whether or not a part was asked for.
208
+ digest = SHA256()
209
+ received = 0
210
+ try output.truncate(atOffset: 0)
211
+ completionHandler(.allow)
212
+ } else {
213
+ if status == 416 { discardFile = true }
214
+ failure = RovqError(.downloadFailed, "Server answered \(status)")
215
+ completionHandler(.cancel)
216
+ }
217
+ } catch {
218
+ failure = RovqError(.storageFailed, "Could not write the file")
219
+ completionHandler(.cancel)
220
+ }
221
+ }
222
+
223
+ func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
224
+ guard failure == nil else { return }
225
+ received += Int64(data.count)
226
+ // Stops at once, so a wrong server cannot fill the disk.
227
+ if received > expectedSize {
228
+ failure = RovqError(.sizeMismatch, "Download is larger than the signed size")
229
+ dataTask.cancel()
230
+ return
231
+ }
232
+ digest.update(data: data)
233
+ do {
234
+ try output.write(contentsOf: data)
235
+ } catch {
236
+ failure = RovqError(.storageFailed, "Could not write the file")
237
+ dataTask.cancel()
238
+ return
239
+ }
240
+ onProgress(received, expectedSize)
241
+ }
242
+
243
+ func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
244
+ if failure == nil, let error {
245
+ failure = RovqError(.downloadFailed, "Connection lost: \(error.localizedDescription)")
246
+ }
247
+ finished.signal()
248
+ }
249
+ }
@@ -0,0 +1,38 @@
1
+ import Foundation
2
+
3
+ /// The same rules as packages/package-format/src/format.ts. The file
4
+ /// packages/package-format/test/path-rules.json is checked against both, so a change on one
5
+ /// side fails the tests until the other side matches.
6
+ enum PackageRules {
7
+ static let bundleFileName = "main.jsbundle"
8
+ static let maxEntries = 10_000
9
+ static let maxTotalSize: Int64 = 2 * 1024 * 1024 * 1024
10
+ private static let maxPathLength = 512
11
+
12
+ /// Returns why a path is not allowed, or nil if it is fine.
13
+ static func findPathProblem(_ path: String) -> String? {
14
+ // Counted in UTF-16 units, as JavaScript does.
15
+ if path.isEmpty || path.utf16.count > maxPathLength { return "has an invalid length" }
16
+ if path.unicodeScalars.contains(where: { $0.value <= 0x1f || $0.value == 0x7f }) {
17
+ return "contains a control character"
18
+ }
19
+ if path.contains("\\") { return "contains a backslash" }
20
+ if path.hasPrefix("/") || path.range(of: "^[A-Za-z]:", options: .regularExpression) != nil {
21
+ return "is an absolute path"
22
+ }
23
+
24
+ let segments = path.components(separatedBy: "/")
25
+ if segments.contains(where: { $0.isEmpty || $0 == "." || $0 == ".." }) {
26
+ return "contains an empty, \".\" or \"..\" segment"
27
+ }
28
+ if path == bundleFileName { return nil }
29
+ if segments.count < 2 { return "is not the bundle file" }
30
+ return segments[0] == "assets" ? nil : "is outside the assets folder"
31
+ }
32
+
33
+ /// The same name in another letter case or Unicode form is one file on some file systems.
34
+ /// The default iOS file system is one of them.
35
+ static func pathIdentity(_ path: String) -> String {
36
+ path.precomposedStringWithCanonicalMapping.lowercased()
37
+ }
38
+ }