@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.
- package/LICENSE +21 -0
- package/README.md +120 -2
- package/Rovq.podspec +19 -0
- package/android/build.gradle +86 -0
- package/android/src/main/AndroidManifest.xml +4 -0
- package/android/src/main/java/com/rovq/engine/EngineState.kt +176 -0
- package/android/src/main/java/com/rovq/engine/ManifestVerifier.kt +230 -0
- package/android/src/main/java/com/rovq/engine/PackageDownloader.kt +199 -0
- package/android/src/main/java/com/rovq/engine/PackageRules.kt +39 -0
- package/android/src/main/java/com/rovq/engine/ProgressThrottle.kt +17 -0
- package/android/src/main/java/com/rovq/engine/RovqEngine.kt +550 -0
- package/android/src/main/java/com/rovq/engine/RovqException.kt +31 -0
- package/android/src/main/java/com/rovq/engine/SafePackageExtractor.kt +121 -0
- package/android/src/main/java/com/rovq/engine/StrictBase64.kt +37 -0
- package/android/src/main/java/com/rovq/engine/UpdateInstaller.kt +347 -0
- package/android/src/main/java/com/rovq/engine/UpdateService.kt +175 -0
- package/android/src/main/java/com/rovq/engine/ZipStructureCheck.kt +194 -0
- package/android/src/react/java/com/rovq/engine/react/ResourceFiles.kt +52 -0
- package/android/src/react/java/com/rovq/engine/react/Rovq.kt +102 -0
- package/android/src/react/java/com/rovq/engine/react/RovqModule.kt +164 -0
- package/android/src/react/java/com/rovq/engine/react/RovqPackage.kt +26 -0
- package/android/src/react/java/com/rovq/engine/react/RovqReactHost.kt +74 -0
- package/ios/Engine/EngineState.swift +119 -0
- package/ios/Engine/ManifestVerifier.swift +279 -0
- package/ios/Engine/PackageDownloader.swift +249 -0
- package/ios/Engine/PackageRules.swift +38 -0
- package/ios/Engine/ProgressThrottle.swift +20 -0
- package/ios/Engine/RovqEngine.swift +646 -0
- package/ios/Engine/RovqError.swift +35 -0
- package/ios/Engine/SafePackageExtractor.swift +107 -0
- package/ios/Engine/UpdateInstaller.swift +348 -0
- package/ios/Engine/UpdateService.swift +185 -0
- package/ios/Engine/ZipReader.swift +378 -0
- package/ios/React/RovqHost.swift +255 -0
- package/ios/React/RovqModule.mm +91 -0
- package/package.json +78 -3
- package/react-native.config.js +11 -0
- package/src/NativeRovq.ts +72 -0
- package/src/api.ts +228 -0
- package/src/errors.ts +44 -0
- package/src/index.ts +10 -0
- package/src/react.tsx +158 -0
- 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
|
+
}
|