@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,107 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Extracts a package without trusting it, even though its signature was already verified.
|
|
4
|
+
/// If a signing key ever leaks, this keeps an attacker inside the package folder, so the
|
|
5
|
+
/// files of the app itself stay out of reach.
|
|
6
|
+
struct SafePackageExtractor {
|
|
7
|
+
/// - Parameter destination: an empty folder that only this extraction uses.
|
|
8
|
+
func extract(packageFile: URL, destination: URL) throws {
|
|
9
|
+
do {
|
|
10
|
+
let reader = try ZipReader(url: packageFile)
|
|
11
|
+
try extractEntries(reader, destination: destination)
|
|
12
|
+
} catch let error as ZipError {
|
|
13
|
+
throw invalid(error.message)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
private func extractEntries(_ reader: ZipReader, destination: URL) throws {
|
|
18
|
+
let root = destination.resolvingSymlinksInPath().standardizedFileURL.path + "/"
|
|
19
|
+
var seen = Set<String>()
|
|
20
|
+
var hasBundle = false
|
|
21
|
+
var totalSize: Int64 = 0
|
|
22
|
+
|
|
23
|
+
if reader.entries.count > PackageRules.maxEntries { throw invalid("Too many files") }
|
|
24
|
+
|
|
25
|
+
for entry in reader.entries {
|
|
26
|
+
if entry.isFolder { throw invalid("Folder entries are not allowed") }
|
|
27
|
+
if let problem = PackageRules.findPathProblem(entry.name) {
|
|
28
|
+
throw invalid("The file \(entry.name) \(problem)")
|
|
29
|
+
}
|
|
30
|
+
if !seen.insert(PackageRules.pathIdentity(entry.name)).inserted {
|
|
31
|
+
throw invalid("The file \(entry.name) appears more than once")
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let target = destination.appendingPathComponent(entry.name)
|
|
35
|
+
// A second, independent check: whatever the rules allow, the real path must stay
|
|
36
|
+
// inside the folder.
|
|
37
|
+
let parent = target.deletingLastPathComponent()
|
|
38
|
+
try createFolder(parent)
|
|
39
|
+
let resolved = parent.resolvingSymlinksInPath().standardizedFileURL.path + "/"
|
|
40
|
+
if !resolved.hasPrefix(root) { throw invalid("The file \(entry.name) leaves the folder") }
|
|
41
|
+
|
|
42
|
+
let written = try write(
|
|
43
|
+
entry,
|
|
44
|
+
from: reader,
|
|
45
|
+
to: target,
|
|
46
|
+
allowance: PackageRules.maxTotalSize - totalSize
|
|
47
|
+
)
|
|
48
|
+
totalSize += written
|
|
49
|
+
if entry.name == PackageRules.bundleFileName {
|
|
50
|
+
if written == 0 { throw invalid("The bundle file is empty") }
|
|
51
|
+
hasBundle = true
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if !hasBundle { throw invalid("The bundle file is missing") }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private func write(
|
|
58
|
+
_ entry: ZipEntry,
|
|
59
|
+
from reader: ZipReader,
|
|
60
|
+
to target: URL,
|
|
61
|
+
allowance: Int64
|
|
62
|
+
) throws -> Int64 {
|
|
63
|
+
guard FileManager.default.createFile(atPath: target.path, contents: nil) else {
|
|
64
|
+
throw RovqError(.storageFailed, "Could not create \(entry.name)")
|
|
65
|
+
}
|
|
66
|
+
let output: FileHandle
|
|
67
|
+
do {
|
|
68
|
+
output = try FileHandle(forWritingTo: target)
|
|
69
|
+
} catch {
|
|
70
|
+
throw RovqError(.storageFailed, "Could not create \(entry.name)")
|
|
71
|
+
}
|
|
72
|
+
defer { try? output.close() }
|
|
73
|
+
|
|
74
|
+
let written: Int64
|
|
75
|
+
do {
|
|
76
|
+
written = try reader.extract(entry, allowance: allowance) { chunk in
|
|
77
|
+
do {
|
|
78
|
+
try output.write(contentsOf: chunk)
|
|
79
|
+
} catch {
|
|
80
|
+
throw RovqError(.storageFailed, "Could not write \(entry.name)")
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// Forces the bytes to disk, because the release becomes active by renaming its
|
|
84
|
+
// folder, and that rename must not outrun the data.
|
|
85
|
+
try output.synchronize()
|
|
86
|
+
} catch let error as RovqError {
|
|
87
|
+
throw error
|
|
88
|
+
} catch let error as ZipError {
|
|
89
|
+
throw error
|
|
90
|
+
} catch {
|
|
91
|
+
throw RovqError(.storageFailed, "Could not write \(entry.name)")
|
|
92
|
+
}
|
|
93
|
+
return written
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private func createFolder(_ url: URL) throws {
|
|
97
|
+
do {
|
|
98
|
+
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
|
99
|
+
} catch {
|
|
100
|
+
throw RovqError(.storageFailed, "Could not create \(url.lastPathComponent)")
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private func invalid(_ message: String) -> RovqError {
|
|
105
|
+
RovqError(.invalidPackage, message)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import CryptoKit
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
// Download links last an hour, so they are fetched a few files at a time, just before use.
|
|
5
|
+
private let linkBatchSize = 20
|
|
6
|
+
private let emptyFileHash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
|
7
|
+
|
|
8
|
+
/// What the server offered. None of this is trusted until the manifest has been verified.
|
|
9
|
+
public struct OfferedUpdate: Equatable {
|
|
10
|
+
public let manifest: String
|
|
11
|
+
public let signature: String
|
|
12
|
+
public let keyId: String
|
|
13
|
+
/// Only for a ZIP package. A release made of files is fetched file by file.
|
|
14
|
+
public let downloadURL: String?
|
|
15
|
+
|
|
16
|
+
public init(manifest: String, signature: String, keyId: String, downloadURL: String?) {
|
|
17
|
+
self.manifest = manifest
|
|
18
|
+
self.signature = signature
|
|
19
|
+
self.keyId = keyId
|
|
20
|
+
self.downloadURL = downloadURL
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/// A verified release, fully extracted in its own folder.
|
|
25
|
+
public struct InstalledRelease: Equatable {
|
|
26
|
+
public let manifest: VerifiedManifest
|
|
27
|
+
public let folder: URL
|
|
28
|
+
public let bundleFile: URL
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/// Asks the server for download links, by hash, for files of one release.
|
|
32
|
+
public typealias FileLinks = (_ releaseId: String, _ hashes: [String]) throws -> [String: String]
|
|
33
|
+
|
|
34
|
+
/// Takes an offered update to a verified folder on disk, without activating it.
|
|
35
|
+
///
|
|
36
|
+
/// Each release gets its own folder, which keeps the design the same as Android, and makes
|
|
37
|
+
/// the final rename what decides that a release is complete.
|
|
38
|
+
public final class UpdateInstaller {
|
|
39
|
+
private let updatesFolder: URL
|
|
40
|
+
private let workFolder: URL
|
|
41
|
+
// Outside the work folder, because a partial download must survive a failure or a restart.
|
|
42
|
+
private let partialFolder: URL
|
|
43
|
+
/// The app's own files, where assets sit at the same paths as in a package.
|
|
44
|
+
private let builtInFolder: URL?
|
|
45
|
+
private let verifier: ManifestVerifier
|
|
46
|
+
private let downloader: PackageDownloader
|
|
47
|
+
private let extractor = SafePackageExtractor()
|
|
48
|
+
private let lock = NSLock()
|
|
49
|
+
|
|
50
|
+
/// - Parameters:
|
|
51
|
+
/// - rootFolder: a folder only the engine uses, inside the app's private storage.
|
|
52
|
+
/// - builtInFolder: the app bundle, whose files can be reused instead of downloaded.
|
|
53
|
+
public init(
|
|
54
|
+
rootFolder: URL,
|
|
55
|
+
verifier: ManifestVerifier,
|
|
56
|
+
downloader: PackageDownloader,
|
|
57
|
+
builtInFolder: URL? = nil
|
|
58
|
+
) {
|
|
59
|
+
updatesFolder = rootFolder.appendingPathComponent("updates")
|
|
60
|
+
workFolder = rootFolder.appendingPathComponent("work")
|
|
61
|
+
partialFolder = rootFolder.appendingPathComponent("partial")
|
|
62
|
+
self.builtInFolder = builtInFolder
|
|
63
|
+
self.verifier = verifier
|
|
64
|
+
self.downloader = downloader
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/// - Parameters:
|
|
68
|
+
/// - highestSequence: the highest sequence this device has accepted in this stream.
|
|
69
|
+
/// - fileLinks: download links for a release made of files.
|
|
70
|
+
/// - Throws: `RovqError` if any step fails. Nothing is left behind in that case.
|
|
71
|
+
public func install(
|
|
72
|
+
offer: OfferedUpdate,
|
|
73
|
+
highestSequence: Int64,
|
|
74
|
+
fileLinks: FileLinks = { _, _ in throw RovqError(.downloadFailed, "No way to fetch files") },
|
|
75
|
+
onProgress: @escaping (Int64, Int64) -> Void = { _, _ in }
|
|
76
|
+
) throws -> InstalledRelease {
|
|
77
|
+
lock.lock()
|
|
78
|
+
defer { lock.unlock() }
|
|
79
|
+
|
|
80
|
+
// Verified first, so an unsigned update causes no network traffic or disk use.
|
|
81
|
+
let manifest = try verifier.verify(
|
|
82
|
+
manifest: offer.manifest,
|
|
83
|
+
signature: offer.signature,
|
|
84
|
+
keyId: offer.keyId,
|
|
85
|
+
highestSequence: highestSequence
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
let folder = updatesFolder.appendingPathComponent(manifest.releaseId, isDirectory: true)
|
|
89
|
+
// Folders appear only through the atomic rename below, so one that exists is complete.
|
|
90
|
+
var isFolder: ObjCBool = false
|
|
91
|
+
if !FileManager.default.fileExists(atPath: folder.path, isDirectory: &isFolder) || !isFolder.boolValue {
|
|
92
|
+
if let files = manifest.files {
|
|
93
|
+
try installFileList(
|
|
94
|
+
files, releaseId: manifest.releaseId, folder: folder, fileLinks: fileLinks,
|
|
95
|
+
onProgress: onProgress)
|
|
96
|
+
} else {
|
|
97
|
+
try installZip(offer: offer, manifest: manifest, folder: folder, onProgress: onProgress)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return InstalledRelease(
|
|
101
|
+
manifest: manifest,
|
|
102
|
+
folder: folder,
|
|
103
|
+
bundleFile: folder.appendingPathComponent(PackageRules.bundleFileName)
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/// Creates an empty work folder for assembling a release, and the folders around it.
|
|
108
|
+
private func startWork() throws -> URL {
|
|
109
|
+
let files = FileManager.default
|
|
110
|
+
// Removes anything an earlier run left when the app was killed or the phone lost power.
|
|
111
|
+
try? files.removeItem(at: workFolder)
|
|
112
|
+
let assembled = workFolder.appendingPathComponent("extracted", isDirectory: true)
|
|
113
|
+
do {
|
|
114
|
+
try files.createDirectory(at: assembled, withIntermediateDirectories: true)
|
|
115
|
+
try files.createDirectory(at: updatesFolder, withIntermediateDirectories: true)
|
|
116
|
+
try files.createDirectory(at: partialFolder, withIntermediateDirectories: true)
|
|
117
|
+
} catch {
|
|
118
|
+
throw RovqError(.storageFailed, "Could not create the engine folders")
|
|
119
|
+
}
|
|
120
|
+
return assembled
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/// Only the current offer is worth finishing.
|
|
124
|
+
private func removePartialFiles(except keep: Set<String>) {
|
|
125
|
+
let files = FileManager.default
|
|
126
|
+
for file in (try? files.contentsOfDirectory(at: partialFolder, includingPropertiesForKeys: nil)) ?? []
|
|
127
|
+
where !keep.contains(file.lastPathComponent) {
|
|
128
|
+
try? files.removeItem(at: file)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private func moveIntoPlace(_ assembled: URL, _ folder: URL) throws {
|
|
133
|
+
do {
|
|
134
|
+
try FileManager.default.moveItem(at: assembled, to: folder)
|
|
135
|
+
} catch {
|
|
136
|
+
throw RovqError(.storageFailed, "Could not move the release into place")
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private func installZip(
|
|
141
|
+
offer: OfferedUpdate,
|
|
142
|
+
manifest: VerifiedManifest,
|
|
143
|
+
folder: URL,
|
|
144
|
+
onProgress: @escaping (Int64, Int64) -> Void
|
|
145
|
+
) throws {
|
|
146
|
+
guard let downloadURL = offer.downloadURL else {
|
|
147
|
+
throw RovqError(.downloadFailed, "The server sent no download URL")
|
|
148
|
+
}
|
|
149
|
+
let extracted = try startWork()
|
|
150
|
+
defer { try? FileManager.default.removeItem(at: workFolder) }
|
|
151
|
+
|
|
152
|
+
let packageFile = partialFolder.appendingPathComponent("\(manifest.packageHash).zip")
|
|
153
|
+
removePartialFiles(except: [packageFile.lastPathComponent])
|
|
154
|
+
do {
|
|
155
|
+
// Extracting needs room too, and its size is unknown until then, so this asks for
|
|
156
|
+
// the package twice. A package can still expand further, and extraction reports that.
|
|
157
|
+
try downloader.download(
|
|
158
|
+
url: downloadURL,
|
|
159
|
+
hash: manifest.packageHash,
|
|
160
|
+
size: manifest.packageSize,
|
|
161
|
+
target: packageFile,
|
|
162
|
+
extraSpace: manifest.packageSize,
|
|
163
|
+
onProgress: onProgress
|
|
164
|
+
)
|
|
165
|
+
try extractor.extract(packageFile: packageFile, destination: extracted)
|
|
166
|
+
try moveIntoPlace(extracted, folder)
|
|
167
|
+
try? FileManager.default.removeItem(at: packageFile)
|
|
168
|
+
} catch {
|
|
169
|
+
// A file that failed anything but the network is not worth continuing from.
|
|
170
|
+
if (error as? RovqError)?.code != .downloadFailed {
|
|
171
|
+
try? FileManager.default.removeItem(at: packageFile)
|
|
172
|
+
}
|
|
173
|
+
throw error
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/// Builds a release from its files: each comes from a kept release, from the app itself,
|
|
178
|
+
/// or from the network, in that order, and only after its content matches the signed hash.
|
|
179
|
+
/// See docs/file-packages.md.
|
|
180
|
+
private func installFileList(
|
|
181
|
+
_ list: [ManifestFile],
|
|
182
|
+
releaseId: String,
|
|
183
|
+
folder: URL,
|
|
184
|
+
fileLinks: FileLinks,
|
|
185
|
+
onProgress: @escaping (Int64, Int64) -> Void
|
|
186
|
+
) throws {
|
|
187
|
+
let files = FileManager.default
|
|
188
|
+
let assembled = try startWork()
|
|
189
|
+
defer { try? files.removeItem(at: workFolder) }
|
|
190
|
+
removePartialFiles(except: Set(list.map(\.hash)))
|
|
191
|
+
let root = assembled.resolvingSymlinksInPath().standardizedFileURL.path + "/"
|
|
192
|
+
|
|
193
|
+
// A hash already placed, so the same content at another path is linked, not fetched.
|
|
194
|
+
var placed: [String: URL] = [:]
|
|
195
|
+
// Files with no local copy at their own path. Another path may still provide them.
|
|
196
|
+
var unresolved: [(file: ManifestFile, target: URL)] = []
|
|
197
|
+
|
|
198
|
+
for file in list {
|
|
199
|
+
let target = assembled.appendingPathComponent(file.path)
|
|
200
|
+
let parent = target.deletingLastPathComponent()
|
|
201
|
+
do {
|
|
202
|
+
try files.createDirectory(at: parent, withIntermediateDirectories: true)
|
|
203
|
+
} catch {
|
|
204
|
+
throw RovqError(.storageFailed, "Could not create a folder")
|
|
205
|
+
}
|
|
206
|
+
// A second, independent check: whatever the rules allow, the real path must stay
|
|
207
|
+
// inside the folder.
|
|
208
|
+
let resolved = parent.resolvingSymlinksInPath().standardizedFileURL.path + "/"
|
|
209
|
+
if !resolved.hasPrefix(root) {
|
|
210
|
+
throw RovqError(.invalidPackage, "The file \(file.path) leaves the folder")
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if let source = placed[file.hash] {
|
|
214
|
+
try link(source, to: target)
|
|
215
|
+
} else if file.size == 0 {
|
|
216
|
+
guard file.hash == emptyFileHash else {
|
|
217
|
+
throw RovqError(.hashMismatch, "An empty file does not match its signed hash")
|
|
218
|
+
}
|
|
219
|
+
guard files.createFile(atPath: target.path, contents: nil) else {
|
|
220
|
+
throw RovqError(.storageFailed, "Could not create the file")
|
|
221
|
+
}
|
|
222
|
+
placed[file.hash] = target
|
|
223
|
+
} else if let local = findLocalCopy(of: file, excluding: folder) {
|
|
224
|
+
if local.isOwn {
|
|
225
|
+
try link(local.url, to: target)
|
|
226
|
+
} else {
|
|
227
|
+
// On APFS a copy shares the blocks until either side changes, so it is free.
|
|
228
|
+
try copy(local.url, to: target)
|
|
229
|
+
}
|
|
230
|
+
placed[file.hash] = target
|
|
231
|
+
} else {
|
|
232
|
+
unresolved.append((file, target))
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Files to download, by hash, with every path that needs each one, in manifest order.
|
|
237
|
+
var targets: [String: [URL]] = [:]
|
|
238
|
+
var missing: [ManifestFile] = []
|
|
239
|
+
for (file, target) in unresolved {
|
|
240
|
+
if let source = placed[file.hash] {
|
|
241
|
+
try link(source, to: target)
|
|
242
|
+
} else if targets[file.hash] != nil {
|
|
243
|
+
targets[file.hash]!.append(target)
|
|
244
|
+
} else {
|
|
245
|
+
targets[file.hash] = [target]
|
|
246
|
+
missing.append(file)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
try download(missing, targets: targets, releaseId: releaseId, fileLinks: fileLinks, onProgress: onProgress)
|
|
251
|
+
|
|
252
|
+
try moveIntoPlace(assembled, folder)
|
|
253
|
+
// Linked into the release, so the content stays where it is needed.
|
|
254
|
+
for file in missing {
|
|
255
|
+
try? files.removeItem(at: partialFolder.appendingPathComponent(file.hash))
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private func download(
|
|
260
|
+
_ missing: [ManifestFile],
|
|
261
|
+
targets: [String: [URL]],
|
|
262
|
+
releaseId: String,
|
|
263
|
+
fileLinks: FileLinks,
|
|
264
|
+
onProgress: @escaping (Int64, Int64) -> Void
|
|
265
|
+
) throws {
|
|
266
|
+
if missing.isEmpty { return }
|
|
267
|
+
let total = missing.reduce(Int64(0)) { $0 + $1.size }
|
|
268
|
+
// Checked for the whole release first, so a full disk fails before any download.
|
|
269
|
+
var remaining: Int64 = 0
|
|
270
|
+
for file in missing {
|
|
271
|
+
let partial = partialFolder.appendingPathComponent(file.hash)
|
|
272
|
+
remaining += file.size - min(fileSize(partial) ?? 0, file.size)
|
|
273
|
+
}
|
|
274
|
+
if downloader.freeSpace(partialFolder) < remaining {
|
|
275
|
+
throw RovqError(.storageFailed, "Not enough free space for this update")
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
var finished: Int64 = 0
|
|
279
|
+
onProgress(0, total)
|
|
280
|
+
for start in stride(from: 0, to: missing.count, by: linkBatchSize) {
|
|
281
|
+
let batch = Array(missing[start..<min(start + linkBatchSize, missing.count)])
|
|
282
|
+
let links = try fileLinks(releaseId, batch.map(\.hash))
|
|
283
|
+
for file in batch {
|
|
284
|
+
guard let url = links[file.hash] else {
|
|
285
|
+
throw RovqError(.downloadFailed, "The server sent no link for a file")
|
|
286
|
+
}
|
|
287
|
+
let partial = partialFolder.appendingPathComponent(file.hash)
|
|
288
|
+
let before = finished
|
|
289
|
+
try downloader.download(url: url, hash: file.hash, size: file.size, target: partial) {
|
|
290
|
+
received, _ in onProgress(before + received, total)
|
|
291
|
+
}
|
|
292
|
+
finished += file.size
|
|
293
|
+
for target in targets[file.hash] ?? [] { try link(partial, to: target) }
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/// The same path in a kept release, or in the app itself, if its content is the signed one.
|
|
299
|
+
/// Hashed every time, because reusing a damaged file would break the new release.
|
|
300
|
+
private func findLocalCopy(of file: ManifestFile, excluding folder: URL) -> (url: URL, isOwn: Bool)? {
|
|
301
|
+
let releases =
|
|
302
|
+
(try? FileManager.default.contentsOfDirectory(at: updatesFolder, includingPropertiesForKeys: nil)) ?? []
|
|
303
|
+
var candidates = releases
|
|
304
|
+
.filter { $0.lastPathComponent != folder.lastPathComponent }
|
|
305
|
+
.map { ($0.appendingPathComponent(file.path), true) }
|
|
306
|
+
if let builtIn = builtInFolder {
|
|
307
|
+
candidates.append((builtIn.appendingPathComponent(file.path), false))
|
|
308
|
+
}
|
|
309
|
+
for (url, isOwn) in candidates where fileSize(url) == file.size && hashOf(url) == file.hash {
|
|
310
|
+
return (url, isOwn)
|
|
311
|
+
}
|
|
312
|
+
return nil
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private func fileSize(_ url: URL) -> Int64? {
|
|
316
|
+
guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
|
|
317
|
+
attributes[.type] as? FileAttributeType == .typeRegular
|
|
318
|
+
else { return nil }
|
|
319
|
+
return (attributes[.size] as? NSNumber)?.int64Value
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private func hashOf(_ url: URL) -> String? {
|
|
323
|
+
guard let input = try? FileHandle(forReadingFrom: url) else { return nil }
|
|
324
|
+
defer { try? input.close() }
|
|
325
|
+
var digest = SHA256()
|
|
326
|
+
while let chunk = try? input.read(upToCount: 1024 * 1024), !chunk.isEmpty {
|
|
327
|
+
digest.update(data: chunk)
|
|
328
|
+
}
|
|
329
|
+
return digest.finalize().map { String(format: "%02x", $0) }.joined()
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/// A hard link costs no space. Files in a release never change, so sharing them is safe.
|
|
333
|
+
private func link(_ source: URL, to target: URL) throws {
|
|
334
|
+
do {
|
|
335
|
+
try FileManager.default.linkItem(at: source, to: target)
|
|
336
|
+
} catch {
|
|
337
|
+
try copy(source, to: target)
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
private func copy(_ source: URL, to target: URL) throws {
|
|
342
|
+
do {
|
|
343
|
+
try FileManager.default.copyItem(at: source, to: target)
|
|
344
|
+
} catch {
|
|
345
|
+
throw RovqError(.storageFailed, "Could not place a file")
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
private let requestTimeout: TimeInterval = 30
|
|
4
|
+
|
|
5
|
+
/// What the device tells the server when asking for updates.
|
|
6
|
+
public struct CheckRequest: Equatable {
|
|
7
|
+
public let deploymentKey: String
|
|
8
|
+
public let deviceId: String
|
|
9
|
+
public let runtimeVersion: String
|
|
10
|
+
public let binaryVersion: String
|
|
11
|
+
public let currentReleaseId: String?
|
|
12
|
+
public let highestSequence: Int64
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/// The server's answer. It is advice only, because `OfferedUpdate` is verified before use.
|
|
16
|
+
public enum CheckResponse: Equatable {
|
|
17
|
+
case none
|
|
18
|
+
case rollback
|
|
19
|
+
case update(OfferedUpdate)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/// A key's signed statement that it must no longer be trusted. The engine verifies it.
|
|
23
|
+
public struct KeyRevocation: Equatable {
|
|
24
|
+
public let keyId: String
|
|
25
|
+
public let statement: String
|
|
26
|
+
public let signature: String
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
public struct CheckAnswer: Equatable {
|
|
30
|
+
public let response: CheckResponse
|
|
31
|
+
public let revocations: [KeyRevocation]
|
|
32
|
+
|
|
33
|
+
public init(response: CheckResponse, revocations: [KeyRevocation] = []) {
|
|
34
|
+
self.response = response
|
|
35
|
+
self.revocations = revocations
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// The server API the engine needs, kept separate so tests can replace it.
|
|
40
|
+
public protocol UpdateService {
|
|
41
|
+
/// - Throws: if the server cannot be reached or answers with an error.
|
|
42
|
+
func check(_ request: CheckRequest) throws -> CheckAnswer
|
|
43
|
+
|
|
44
|
+
/// - Throws: if the events could not be delivered.
|
|
45
|
+
func report(deploymentKey: String, deviceId: String, events: [QueuedEvent]) throws
|
|
46
|
+
|
|
47
|
+
/// Download links for files of a release made of files, by hash. Each file is checked
|
|
48
|
+
/// against the signed manifest, so the links are trusted for nothing.
|
|
49
|
+
///
|
|
50
|
+
/// - Throws: if the server cannot be reached or answers with an error.
|
|
51
|
+
func fileLinks(deploymentKey: String, deviceId: String, releaseId: String, hashes: [String]) throws
|
|
52
|
+
-> [String: String]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
public final class HTTPUpdateService: UpdateService {
|
|
56
|
+
private let apiURL: String
|
|
57
|
+
private let allowInsecureURLs: Bool
|
|
58
|
+
|
|
59
|
+
public init(apiURL: String, allowInsecureURLs: Bool = false) {
|
|
60
|
+
self.apiURL = apiURL
|
|
61
|
+
self.allowInsecureURLs = allowInsecureURLs
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
public func check(_ request: CheckRequest) throws -> CheckAnswer {
|
|
65
|
+
var body: [String: Any] = [
|
|
66
|
+
"deploymentKey": request.deploymentKey,
|
|
67
|
+
"deviceId": request.deviceId,
|
|
68
|
+
"platform": "IOS",
|
|
69
|
+
"runtimeVersion": request.runtimeVersion,
|
|
70
|
+
"binaryVersion": request.binaryVersion,
|
|
71
|
+
"highestSequence": request.highestSequence,
|
|
72
|
+
// Both a ZIP package and a list of files. See docs/file-packages.md.
|
|
73
|
+
"packageFormats": [1, 2],
|
|
74
|
+
]
|
|
75
|
+
if let current = request.currentReleaseId { body["currentReleaseId"] = current }
|
|
76
|
+
|
|
77
|
+
let response = try post("/device/updates/check", body: body)
|
|
78
|
+
let revocations = readRevocations(response)
|
|
79
|
+
switch response["action"] as? String {
|
|
80
|
+
case "NONE":
|
|
81
|
+
return CheckAnswer(response: .none, revocations: revocations)
|
|
82
|
+
case "ROLLBACK":
|
|
83
|
+
return CheckAnswer(response: .rollback, revocations: revocations)
|
|
84
|
+
case "UPDATE":
|
|
85
|
+
// A release made of files has no single download URL.
|
|
86
|
+
guard let update = response["update"] as? [String: Any],
|
|
87
|
+
let manifest = update["manifest"] as? String,
|
|
88
|
+
let signature = update["signature"] as? String,
|
|
89
|
+
let keyId = update["keyId"] as? String
|
|
90
|
+
else { throw RovqError(.downloadFailed, "The server sent an unreadable response") }
|
|
91
|
+
let downloadURL = update["downloadUrl"] as? String
|
|
92
|
+
return CheckAnswer(
|
|
93
|
+
response: .update(
|
|
94
|
+
OfferedUpdate(manifest: manifest, signature: signature, keyId: keyId, downloadURL: downloadURL)),
|
|
95
|
+
revocations: revocations
|
|
96
|
+
)
|
|
97
|
+
default:
|
|
98
|
+
throw RovqError(.downloadFailed, "The server sent an unknown action")
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private func readRevocations(_ response: [String: Any]) -> [KeyRevocation] {
|
|
103
|
+
let list = response["revokedKeys"] as? [[String: Any]] ?? []
|
|
104
|
+
return list.compactMap { item in
|
|
105
|
+
guard let keyId = item["keyId"] as? String,
|
|
106
|
+
let statement = item["statement"] as? String,
|
|
107
|
+
let signature = item["signature"] as? String
|
|
108
|
+
else { return nil }
|
|
109
|
+
return KeyRevocation(keyId: keyId, statement: statement, signature: signature)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
public func report(deploymentKey: String, deviceId: String, events: [QueuedEvent]) throws {
|
|
114
|
+
let list: [[String: Any]] = events.map { event in
|
|
115
|
+
var json: [String: Any] = [
|
|
116
|
+
"clientEventId": event.clientEventId,
|
|
117
|
+
"type": event.type,
|
|
118
|
+
"occurredAt": event.occurredAt,
|
|
119
|
+
]
|
|
120
|
+
if let releaseId = event.releaseId { json["releaseId"] = releaseId }
|
|
121
|
+
if let errorCode = event.errorCode { json["errorCode"] = errorCode }
|
|
122
|
+
return json
|
|
123
|
+
}
|
|
124
|
+
_ = try post(
|
|
125
|
+
"/device/events",
|
|
126
|
+
body: ["deploymentKey": deploymentKey, "deviceId": deviceId, "events": list]
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
public func fileLinks(deploymentKey: String, deviceId: String, releaseId: String, hashes: [String]) throws
|
|
131
|
+
-> [String: String]
|
|
132
|
+
{
|
|
133
|
+
let response = try post(
|
|
134
|
+
"/device/updates/files",
|
|
135
|
+
body: ["deploymentKey": deploymentKey, "deviceId": deviceId, "releaseId": releaseId, "hashes": hashes]
|
|
136
|
+
)
|
|
137
|
+
guard let files = response["files"] as? [[String: Any]] else {
|
|
138
|
+
throw RovqError(.downloadFailed, "The server sent an unreadable response")
|
|
139
|
+
}
|
|
140
|
+
var links: [String: String] = [:]
|
|
141
|
+
for file in files {
|
|
142
|
+
if let hash = file["hash"] as? String, let url = file["url"] as? String { links[hash] = url }
|
|
143
|
+
}
|
|
144
|
+
return links
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/// Blocks until finished, so call it away from the main thread.
|
|
148
|
+
private func post(_ path: String, body: [String: Any]) throws -> [String: Any] {
|
|
149
|
+
let base = apiURL.hasSuffix("/") ? String(apiURL.dropLast()) : apiURL
|
|
150
|
+
guard let url = URL(string: base + path), let scheme = url.scheme?.lowercased() else {
|
|
151
|
+
throw RovqError(.downloadFailed, "The API URL is not valid")
|
|
152
|
+
}
|
|
153
|
+
if scheme != "https" && !allowInsecureURLs {
|
|
154
|
+
throw RovqError(.insecureURL, "The API URL must use HTTPS")
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
var request = URLRequest(url: url, timeoutInterval: requestTimeout)
|
|
158
|
+
request.httpMethod = "POST"
|
|
159
|
+
request.setValue("application/json", forHTTPHeaderField: "content-type")
|
|
160
|
+
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
|
161
|
+
|
|
162
|
+
let finished = DispatchSemaphore(value: 0)
|
|
163
|
+
var answer: Data?
|
|
164
|
+
var status = 0
|
|
165
|
+
var failure: Error?
|
|
166
|
+
let session = URLSession(configuration: .ephemeral)
|
|
167
|
+
session.dataTask(with: request) { data, response, error in
|
|
168
|
+
answer = data
|
|
169
|
+
status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
|
170
|
+
failure = error
|
|
171
|
+
finished.signal()
|
|
172
|
+
}.resume()
|
|
173
|
+
finished.wait()
|
|
174
|
+
session.finishTasksAndInvalidate()
|
|
175
|
+
|
|
176
|
+
if let failure {
|
|
177
|
+
throw RovqError(.downloadFailed, failure.localizedDescription)
|
|
178
|
+
}
|
|
179
|
+
guard status == 200 else { throw RovqError(.downloadFailed, "The server answered \(status)") }
|
|
180
|
+
guard let answer, let json = try? JSONSerialization.jsonObject(with: answer) as? [String: Any] else {
|
|
181
|
+
throw RovqError(.downloadFailed, "The server sent an unreadable response")
|
|
182
|
+
}
|
|
183
|
+
return json
|
|
184
|
+
}
|
|
185
|
+
}
|