@rovq/react-native 0.0.0-stage → 0.1.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 (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +76 -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 +173 -0
  8. package/android/src/main/java/com/rovq/engine/PackageDownloader.kt +188 -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 +543 -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 +94 -0
  16. package/android/src/main/java/com/rovq/engine/UpdateService.kt +139 -0
  17. package/android/src/main/java/com/rovq/engine/ZipStructureCheck.kt +194 -0
  18. package/android/src/react/java/com/rovq/engine/react/Rovq.kt +101 -0
  19. package/android/src/react/java/com/rovq/engine/react/RovqModule.kt +164 -0
  20. package/android/src/react/java/com/rovq/engine/react/RovqPackage.kt +26 -0
  21. package/android/src/react/java/com/rovq/engine/react/RovqReactHost.kt +74 -0
  22. package/ios/Engine/EngineState.swift +119 -0
  23. package/ios/Engine/ManifestVerifier.swift +197 -0
  24. package/ios/Engine/PackageDownloader.swift +231 -0
  25. package/ios/Engine/PackageRules.swift +38 -0
  26. package/ios/Engine/ProgressThrottle.swift +20 -0
  27. package/ios/Engine/RovqEngine.swift +633 -0
  28. package/ios/Engine/RovqError.swift +35 -0
  29. package/ios/Engine/SafePackageExtractor.swift +107 -0
  30. package/ios/Engine/UpdateInstaller.swift +126 -0
  31. package/ios/Engine/UpdateService.swift +158 -0
  32. package/ios/Engine/ZipReader.swift +378 -0
  33. package/ios/React/RovqHost.swift +253 -0
  34. package/ios/React/RovqModule.mm +89 -0
  35. package/package.json +74 -3
  36. package/react-native.config.js +11 -0
  37. package/src/NativeRovq.ts +72 -0
  38. package/src/errors.ts +44 -0
  39. package/src/index.ts +228 -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,126 @@
1
+ import Foundation
2
+
3
+ /// What the server offered. None of this is trusted until the manifest has been verified.
4
+ public struct OfferedUpdate: Equatable {
5
+ public let manifest: String
6
+ public let signature: String
7
+ public let keyId: String
8
+ public let downloadURL: String
9
+
10
+ public init(manifest: String, signature: String, keyId: String, downloadURL: String) {
11
+ self.manifest = manifest
12
+ self.signature = signature
13
+ self.keyId = keyId
14
+ self.downloadURL = downloadURL
15
+ }
16
+ }
17
+
18
+ /// A verified release, fully extracted in its own folder.
19
+ public struct InstalledRelease: Equatable {
20
+ public let manifest: VerifiedManifest
21
+ public let folder: URL
22
+ public let bundleFile: URL
23
+ }
24
+
25
+ /// Takes an offered update to a verified folder on disk, without activating it.
26
+ ///
27
+ /// Each release gets its own folder, which keeps the design the same as Android, and makes
28
+ /// the final rename what decides that a release is complete.
29
+ public final class UpdateInstaller {
30
+ private let updatesFolder: URL
31
+ private let workFolder: URL
32
+ // Outside the work folder, because a partial download must survive a failure or a restart.
33
+ private let partialFolder: URL
34
+ private let verifier: ManifestVerifier
35
+ private let downloader: PackageDownloader
36
+ private let extractor = SafePackageExtractor()
37
+ private let lock = NSLock()
38
+
39
+ /// - Parameter rootFolder: a folder only the engine uses, inside the app's private storage.
40
+ public init(rootFolder: URL, verifier: ManifestVerifier, downloader: PackageDownloader) {
41
+ updatesFolder = rootFolder.appendingPathComponent("updates")
42
+ workFolder = rootFolder.appendingPathComponent("work")
43
+ partialFolder = rootFolder.appendingPathComponent("partial")
44
+ self.verifier = verifier
45
+ self.downloader = downloader
46
+ }
47
+
48
+ /// - Parameter highestSequence: the highest sequence this device has accepted in this stream.
49
+ /// - Throws: `RovqError` if any step fails. Nothing is left behind in that case.
50
+ public func install(
51
+ offer: OfferedUpdate,
52
+ highestSequence: Int64,
53
+ onProgress: @escaping (Int64, Int64) -> Void = { _, _ in }
54
+ ) throws -> InstalledRelease {
55
+ lock.lock()
56
+ defer { lock.unlock() }
57
+
58
+ // Verified first, so an unsigned update causes no network traffic or disk use.
59
+ let manifest = try verifier.verify(
60
+ manifest: offer.manifest,
61
+ signature: offer.signature,
62
+ keyId: offer.keyId,
63
+ highestSequence: highestSequence
64
+ )
65
+
66
+ let folder = updatesFolder.appendingPathComponent(manifest.releaseId, isDirectory: true)
67
+ // Folders appear only through the atomic rename below, so one that exists is complete.
68
+ var isFolder: ObjCBool = false
69
+ if !FileManager.default.fileExists(atPath: folder.path, isDirectory: &isFolder) || !isFolder.boolValue {
70
+ try installFiles(offer: offer, manifest: manifest, folder: folder, onProgress: onProgress)
71
+ }
72
+ return InstalledRelease(
73
+ manifest: manifest,
74
+ folder: folder,
75
+ bundleFile: folder.appendingPathComponent(PackageRules.bundleFileName)
76
+ )
77
+ }
78
+
79
+ private func installFiles(
80
+ offer: OfferedUpdate,
81
+ manifest: VerifiedManifest,
82
+ folder: URL,
83
+ onProgress: @escaping (Int64, Int64) -> Void
84
+ ) throws {
85
+ let files = FileManager.default
86
+ // Removes anything an earlier run left when the app was killed or the phone lost power.
87
+ try? files.removeItem(at: workFolder)
88
+ let extracted = workFolder.appendingPathComponent("extracted", isDirectory: true)
89
+ do {
90
+ try files.createDirectory(at: extracted, withIntermediateDirectories: true)
91
+ try files.createDirectory(at: updatesFolder, withIntermediateDirectories: true)
92
+ try files.createDirectory(at: partialFolder, withIntermediateDirectories: true)
93
+ } catch {
94
+ throw RovqError(.storageFailed, "Could not create the engine folders")
95
+ }
96
+ defer { try? files.removeItem(at: workFolder) }
97
+
98
+ let packageFile = partialFolder.appendingPathComponent("\(manifest.packageHash).zip")
99
+ // Only the current offer is worth finishing.
100
+ for file in (try? files.contentsOfDirectory(at: partialFolder, includingPropertiesForKeys: nil)) ?? []
101
+ where file.lastPathComponent != packageFile.lastPathComponent {
102
+ try? files.removeItem(at: file)
103
+ }
104
+ do {
105
+ try downloader.download(
106
+ url: offer.downloadURL,
107
+ manifest: manifest,
108
+ target: packageFile,
109
+ onProgress: onProgress
110
+ )
111
+ try extractor.extract(packageFile: packageFile, destination: extracted)
112
+ do {
113
+ try files.moveItem(at: extracted, to: folder)
114
+ } catch {
115
+ throw RovqError(.storageFailed, "Could not move the release into place")
116
+ }
117
+ try? files.removeItem(at: packageFile)
118
+ } catch {
119
+ // A file that failed anything but the network is not worth continuing from.
120
+ if (error as? RovqError)?.code != .downloadFailed {
121
+ try? files.removeItem(at: packageFile)
122
+ }
123
+ throw error
124
+ }
125
+ }
126
+ }
@@ -0,0 +1,158 @@
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
+
48
+ public final class HTTPUpdateService: UpdateService {
49
+ private let apiURL: String
50
+ private let allowInsecureURLs: Bool
51
+
52
+ public init(apiURL: String, allowInsecureURLs: Bool = false) {
53
+ self.apiURL = apiURL
54
+ self.allowInsecureURLs = allowInsecureURLs
55
+ }
56
+
57
+ public func check(_ request: CheckRequest) throws -> CheckAnswer {
58
+ var body: [String: Any] = [
59
+ "deploymentKey": request.deploymentKey,
60
+ "deviceId": request.deviceId,
61
+ "platform": "IOS",
62
+ "runtimeVersion": request.runtimeVersion,
63
+ "binaryVersion": request.binaryVersion,
64
+ "highestSequence": request.highestSequence,
65
+ ]
66
+ if let current = request.currentReleaseId { body["currentReleaseId"] = current }
67
+
68
+ let response = try post("/device/updates/check", body: body)
69
+ let revocations = readRevocations(response)
70
+ switch response["action"] as? String {
71
+ case "NONE":
72
+ return CheckAnswer(response: .none, revocations: revocations)
73
+ case "ROLLBACK":
74
+ return CheckAnswer(response: .rollback, revocations: revocations)
75
+ case "UPDATE":
76
+ guard let update = response["update"] as? [String: Any],
77
+ let manifest = update["manifest"] as? String,
78
+ let signature = update["signature"] as? String,
79
+ let keyId = update["keyId"] as? String,
80
+ let downloadURL = update["downloadUrl"] as? String
81
+ else { throw RovqError(.downloadFailed, "The server sent an unreadable response") }
82
+ return CheckAnswer(
83
+ response: .update(
84
+ OfferedUpdate(manifest: manifest, signature: signature, keyId: keyId, downloadURL: downloadURL)),
85
+ revocations: revocations
86
+ )
87
+ default:
88
+ throw RovqError(.downloadFailed, "The server sent an unknown action")
89
+ }
90
+ }
91
+
92
+ private func readRevocations(_ response: [String: Any]) -> [KeyRevocation] {
93
+ let list = response["revokedKeys"] as? [[String: Any]] ?? []
94
+ return list.compactMap { item in
95
+ guard let keyId = item["keyId"] as? String,
96
+ let statement = item["statement"] as? String,
97
+ let signature = item["signature"] as? String
98
+ else { return nil }
99
+ return KeyRevocation(keyId: keyId, statement: statement, signature: signature)
100
+ }
101
+ }
102
+
103
+ public func report(deploymentKey: String, deviceId: String, events: [QueuedEvent]) throws {
104
+ let list: [[String: Any]] = events.map { event in
105
+ var json: [String: Any] = [
106
+ "clientEventId": event.clientEventId,
107
+ "type": event.type,
108
+ "occurredAt": event.occurredAt,
109
+ ]
110
+ if let releaseId = event.releaseId { json["releaseId"] = releaseId }
111
+ if let errorCode = event.errorCode { json["errorCode"] = errorCode }
112
+ return json
113
+ }
114
+ _ = try post(
115
+ "/device/events",
116
+ body: ["deploymentKey": deploymentKey, "deviceId": deviceId, "events": list]
117
+ )
118
+ }
119
+
120
+ /// Blocks until finished, so call it away from the main thread.
121
+ private func post(_ path: String, body: [String: Any]) throws -> [String: Any] {
122
+ let base = apiURL.hasSuffix("/") ? String(apiURL.dropLast()) : apiURL
123
+ guard let url = URL(string: base + path), let scheme = url.scheme?.lowercased() else {
124
+ throw RovqError(.downloadFailed, "The API URL is not valid")
125
+ }
126
+ if scheme != "https" && !allowInsecureURLs {
127
+ throw RovqError(.insecureURL, "The API URL must use HTTPS")
128
+ }
129
+
130
+ var request = URLRequest(url: url, timeoutInterval: requestTimeout)
131
+ request.httpMethod = "POST"
132
+ request.setValue("application/json", forHTTPHeaderField: "content-type")
133
+ request.httpBody = try JSONSerialization.data(withJSONObject: body)
134
+
135
+ let finished = DispatchSemaphore(value: 0)
136
+ var answer: Data?
137
+ var status = 0
138
+ var failure: Error?
139
+ let session = URLSession(configuration: .ephemeral)
140
+ session.dataTask(with: request) { data, response, error in
141
+ answer = data
142
+ status = (response as? HTTPURLResponse)?.statusCode ?? 0
143
+ failure = error
144
+ finished.signal()
145
+ }.resume()
146
+ finished.wait()
147
+ session.finishTasksAndInvalidate()
148
+
149
+ if let failure {
150
+ throw RovqError(.downloadFailed, failure.localizedDescription)
151
+ }
152
+ guard status == 200 else { throw RovqError(.downloadFailed, "The server answered \(status)") }
153
+ guard let answer, let json = try? JSONSerialization.jsonObject(with: answer) as? [String: Any] else {
154
+ throw RovqError(.downloadFailed, "The server sent an unreadable response")
155
+ }
156
+ return json
157
+ }
158
+ }