@mentra/crust 3.3.0-dev.944 → 3.4.0-beta.1237
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/android/build.gradle +16 -4
- package/android/src/main/java/com/mentra/crust/CrustModule.kt +6 -0
- package/android/src/main/java/com/mentra/crust/jsc/JSCPolyfillBridge.kt +83 -30
- package/android/src/main/java/com/mentra/crust/jsc/MiniappNetworkPolicy.kt +25 -0
- package/android/src/main/java/com/mentra/crust/utils/CanonicalFilePath.kt +22 -0
- package/android/src/test/java/com/mentra/crust/jsc/FetchBridgeTest.kt +277 -0
- package/android/src/test/java/com/mentra/crust/jsc/MiniappNetworkPolicyTest.kt +45 -0
- package/android/src/test/java/com/mentra/crust/utils/CanonicalFilePathTest.kt +39 -0
- package/build/CrustModule.d.ts +6 -0
- package/build/CrustModule.d.ts.map +1 -1
- package/build/CrustModule.js.map +1 -1
- package/build/CrustModule.web.d.ts +2 -0
- package/build/CrustModule.web.d.ts.map +1 -1
- package/build/CrustModule.web.js +4 -0
- package/build/CrustModule.web.js.map +1 -1
- package/ios/Crust.podspec +3 -0
- package/ios/CrustModule.swift +5 -0
- package/ios/MiniappPolicyKit/Package.swift +13 -0
- package/ios/MiniappPolicyKit/Sources/MiniappPolicy/CanonicalFilePath.swift +13 -0
- package/ios/MiniappPolicyKit/Sources/MiniappPolicy/MiniappNetworkPolicy.swift +24 -0
- package/ios/MiniappPolicyKit/Tests/MiniappPolicyTests/MiniappPolicyTests.swift +117 -0
- package/ios/MiniappPolicyKit/Tests/MiniappPolicyTests/RedirectServer.swift +33 -0
- package/ios/PermissionStatusReceipt.swift +72 -1
- package/ios/Resources/startup.js +199 -87
- package/ios/Source/JSCPolyfillBridge.swift +56 -14
- package/package.json +4 -2
- package/src/CrustModule.ts +6 -0
- package/src/CrustModule.web.ts +4 -0
package/android/build.gradle
CHANGED
|
@@ -100,13 +100,16 @@ dependencies {
|
|
|
100
100
|
// APK weight). `:navigation` is the core engine; `:tripdata` carries the
|
|
101
101
|
// banner/maneuver formatting used to label turns.
|
|
102
102
|
// Version pinned to 3.25.0 — re-check https://docs.mapbox.com/android/navigation/releases/
|
|
103
|
+
// The -ndk27 artifacts are Mapbox's 16 KB page-aligned builds. The
|
|
104
|
+
// unsuffixed 3.25.0 artifacts still ship 4 KB-aligned native libraries,
|
|
105
|
+
// which Google Play rejects for 64-bit updates after Feb 1, 2027.
|
|
103
106
|
def isChinaBuild = System.getenv("EXPO_PUBLIC_DEPLOYMENT_REGION") == "china"
|
|
104
107
|
if (isChinaBuild) {
|
|
105
|
-
compileOnly 'com.mapbox.navigationcore:navigation:3.25.0'
|
|
106
|
-
compileOnly 'com.mapbox.navigationcore:tripdata:3.25.0'
|
|
108
|
+
compileOnly 'com.mapbox.navigationcore:navigation-ndk27:3.25.0'
|
|
109
|
+
compileOnly 'com.mapbox.navigationcore:tripdata-ndk27:3.25.0'
|
|
107
110
|
} else {
|
|
108
|
-
implementation 'com.mapbox.navigationcore:navigation:3.25.0'
|
|
109
|
-
implementation 'com.mapbox.navigationcore:tripdata:3.25.0'
|
|
111
|
+
implementation 'com.mapbox.navigationcore:navigation-ndk27:3.25.0'
|
|
112
|
+
implementation 'com.mapbox.navigationcore:tripdata-ndk27:3.25.0'
|
|
110
113
|
}
|
|
111
114
|
|
|
112
115
|
// Required for Java 8+ APIs on minSdk < 26 (was needed by Google Nav SDK;
|
|
@@ -149,6 +152,15 @@ dependencies {
|
|
|
149
152
|
dependencies {
|
|
150
153
|
testImplementation 'junit:junit:4.13.2'
|
|
151
154
|
testImplementation 'org.robolectric:robolectric:4.11.1'
|
|
155
|
+
// Local HTTP server for the miniapp fetch bridge tests; matches okhttp above.
|
|
156
|
+
testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0'
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Robolectric loads classes, and so QuickJS's JNI library, in its own sandbox
|
|
160
|
+
// class loader. Plain JUnit QuickJS tests in the same JVM then crash inside
|
|
161
|
+
// JNI, so every test class runs in a fresh JVM.
|
|
162
|
+
tasks.withType(Test).configureEach {
|
|
163
|
+
forkEvery = 1
|
|
152
164
|
}
|
|
153
165
|
|
|
154
166
|
// A desktop JVM cannot load the Android QuickJS library, so local unit tests
|
|
@@ -24,6 +24,7 @@ import com.mentra.crust.jsc.InstalledMiniappManifest
|
|
|
24
24
|
import com.mentra.crust.jsc.JSCPolyfillBridge
|
|
25
25
|
import com.mentra.crust.http.NativeHttpRequests
|
|
26
26
|
import com.mentra.crust.http.nativeHttpFailureCode
|
|
27
|
+
import com.mentra.crust.utils.CanonicalFilePath
|
|
27
28
|
|
|
28
29
|
class CrustModule : Module() {
|
|
29
30
|
companion object {
|
|
@@ -185,6 +186,11 @@ class CrustModule : Module() {
|
|
|
185
186
|
sendEvent("onChange", mapOf("value" to value))
|
|
186
187
|
}
|
|
187
188
|
|
|
189
|
+
// Where a path really is, symlinks resolved; the engine checks miniapp-named paths with it.
|
|
190
|
+
Function("canonicalFilePath") { path: String ->
|
|
191
|
+
CanonicalFilePath.of(path)
|
|
192
|
+
}
|
|
193
|
+
|
|
188
194
|
AsyncFunction("captureMiniappPreview") Coroutine { viewTag: Int ->
|
|
189
195
|
val activity = appContext.currentActivity ?: error("No activity available for screenshot")
|
|
190
196
|
PixelCopyPreview.capture(activity, viewTag)
|
|
@@ -2,6 +2,8 @@ package com.mentra.crust.jsc
|
|
|
2
2
|
|
|
3
3
|
import android.util.Log
|
|
4
4
|
import java.io.IOException
|
|
5
|
+
import java.nio.ByteBuffer
|
|
6
|
+
import java.nio.charset.CharacterCodingException
|
|
5
7
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
|
6
8
|
import okhttp3.OkHttpClient
|
|
7
9
|
import okhttp3.Request
|
|
@@ -12,6 +14,7 @@ import okhttp3.Callback
|
|
|
12
14
|
import okhttp3.WebSocket
|
|
13
15
|
import okhttp3.WebSocketListener
|
|
14
16
|
import okio.ByteString
|
|
17
|
+
import okio.ByteString.Companion.decodeBase64
|
|
15
18
|
import okio.ByteString.Companion.toByteString
|
|
16
19
|
import org.json.JSONObject
|
|
17
20
|
import org.json.JSONArray
|
|
@@ -46,15 +49,23 @@ object JSCPolyfillBridge {
|
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
/** Keep host HTTP and miniapp fetch body semantics identical, including empty POSTs. */
|
|
49
|
-
internal fun buildHttpRequest(
|
|
52
|
+
internal fun buildHttpRequest(
|
|
53
|
+
method: String,
|
|
54
|
+
url: String,
|
|
55
|
+
headers: Map<String, String>,
|
|
56
|
+
bodyString: String?,
|
|
57
|
+
bodyBytes: ByteString? = null,
|
|
58
|
+
): Request {
|
|
50
59
|
val builder = Request.Builder().url(url)
|
|
51
60
|
for ((name, value) in headers) builder.header(name, value)
|
|
52
61
|
val contentType = headers.entries
|
|
53
62
|
.firstOrNull { (name, _) -> name.equals("content-type", ignoreCase = true) }
|
|
54
63
|
?.value
|
|
64
|
+
val mediaType = (contentType ?: "application/octet-stream").toMediaTypeOrNull()
|
|
55
65
|
val upperMethod = method.uppercase()
|
|
56
66
|
val requestBody = when {
|
|
57
|
-
|
|
67
|
+
bodyBytes != null -> bodyBytes.toRequestBody(mediaType)
|
|
68
|
+
bodyString != null -> bodyString.toRequestBody(mediaType)
|
|
58
69
|
upperMethod == "POST" || upperMethod == "PUT" || upperMethod == "PATCH" -> "".toRequestBody(null)
|
|
59
70
|
else -> null
|
|
60
71
|
}
|
|
@@ -62,6 +73,49 @@ object JSCPolyfillBridge {
|
|
|
62
73
|
return builder.build()
|
|
63
74
|
}
|
|
64
75
|
|
|
76
|
+
/**
|
|
77
|
+
* The `result` of a `fetch.request` response envelope. The body is sent
|
|
78
|
+
* as the string `body` only when its bytes are exactly that string's
|
|
79
|
+
* UTF-8 encoding, and as `bodyBase64` otherwise, so binary bodies keep
|
|
80
|
+
* every byte. The JS polyfill decodes base64 bodies and applies UTF-8 rules.
|
|
81
|
+
*/
|
|
82
|
+
internal fun fetchResult(response: Response): JSONObject {
|
|
83
|
+
val headerMap = mutableMapOf<String, String>()
|
|
84
|
+
for (name in response.headers.names()) {
|
|
85
|
+
headerMap[name.lowercase()] = response.headers.values(name).joinToString(", ")
|
|
86
|
+
}
|
|
87
|
+
val body = try {
|
|
88
|
+
response.body?.bytes() ?: ByteArray(0)
|
|
89
|
+
} catch (e: Throwable) {
|
|
90
|
+
Log.w(TAG, "fetch read body threw: ${e.message}")
|
|
91
|
+
ByteArray(0)
|
|
92
|
+
}
|
|
93
|
+
val text = exactUtf8(body)
|
|
94
|
+
return JSONObject().apply {
|
|
95
|
+
put("status", response.code)
|
|
96
|
+
put("statusText", response.message)
|
|
97
|
+
put("headers", JSONObject(headerMap as Map<*, *>))
|
|
98
|
+
if (text != null) {
|
|
99
|
+
put("body", text)
|
|
100
|
+
} else {
|
|
101
|
+
put("bodyBase64", body.toByteString().base64())
|
|
102
|
+
}
|
|
103
|
+
put("ok", response.code in 200..299)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** [bytes] as a String when they are exactly its UTF-8 encoding, else null. */
|
|
108
|
+
private fun exactUtf8(bytes: ByteArray): String? {
|
|
109
|
+
// A new decoder reports malformed input, so binary fails at its first invalid byte.
|
|
110
|
+
val text = try {
|
|
111
|
+
Charsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString()
|
|
112
|
+
} catch (_: CharacterCodingException) {
|
|
113
|
+
return null
|
|
114
|
+
}
|
|
115
|
+
// Re-encoding proves the round trip even if a decoder lets an unpaired surrogate through.
|
|
116
|
+
return text.takeIf { it.toByteArray(Charsets.UTF_8).contentEquals(bytes) }
|
|
117
|
+
}
|
|
118
|
+
|
|
65
119
|
/** Idempotent. Call once on host boot, after the dispatcher is created. */
|
|
66
120
|
fun install(runtime: JSCRuntime) {
|
|
67
121
|
installFetch(runtime)
|
|
@@ -84,8 +138,15 @@ object JSCPolyfillBridge {
|
|
|
84
138
|
?: return@register JSCDispatchOutcome.Error("INVALID_ARGS", "ws.open: missing sid")
|
|
85
139
|
val url = req["url"] as? String
|
|
86
140
|
?: return@register JSCDispatchOutcome.Error("INVALID_ARGS", "ws.open: missing url")
|
|
141
|
+
if (!MiniappNetworkPolicy.allowsWebSocket(url)) {
|
|
142
|
+
return@register JSCDispatchOutcome.Error("UNSUPPORTED_SCHEME", MiniappNetworkPolicy.WEB_SOCKET_REFUSAL)
|
|
143
|
+
}
|
|
87
144
|
val protocols = (req["protocols"] as? List<*>)?.mapNotNull { it as? String } ?: emptyList()
|
|
88
|
-
val builder =
|
|
145
|
+
val builder = try {
|
|
146
|
+
Request.Builder().url(url)
|
|
147
|
+
} catch (e: IllegalArgumentException) {
|
|
148
|
+
return@register JSCDispatchOutcome.Error("INVALID_ARGS", "ws.open: ${e.message}")
|
|
149
|
+
}
|
|
89
150
|
if (protocols.isNotEmpty()) {
|
|
90
151
|
builder.header("Sec-WebSocket-Protocol", protocols.joinToString(", "))
|
|
91
152
|
}
|
|
@@ -193,6 +254,9 @@ object JSCPolyfillBridge {
|
|
|
193
254
|
if (reqId == null) {
|
|
194
255
|
return@register JSCDispatchOutcome.Error("INVALID_ARGS", "fetch.request requires reqId (use __mentraSendRequest)")
|
|
195
256
|
}
|
|
257
|
+
if (!MiniappNetworkPolicy.allowsFetch(url)) {
|
|
258
|
+
return@register JSCDispatchOutcome.Error("UNSUPPORTED_SCHEME", MiniappNetworkPolicy.FETCH_REFUSAL)
|
|
259
|
+
}
|
|
196
260
|
|
|
197
261
|
val method = (req["method"] as? String) ?: "GET"
|
|
198
262
|
val headers = (req["headers"] as? Map<*, *>)?.mapNotNull { (k, v) ->
|
|
@@ -201,8 +265,16 @@ object JSCPolyfillBridge {
|
|
|
201
265
|
ks to vs
|
|
202
266
|
}?.toMap() ?: emptyMap()
|
|
203
267
|
val bodyString = req["body"] as? String
|
|
268
|
+
val bodyBytes = (req["bodyBase64"] as? String)?.let { encoded ->
|
|
269
|
+
encoded.decodeBase64()
|
|
270
|
+
?: return@register JSCDispatchOutcome.Error("INVALID_ARGS", "fetch.request: bodyBase64 is not base64")
|
|
271
|
+
}
|
|
204
272
|
|
|
205
|
-
val request =
|
|
273
|
+
val request = try {
|
|
274
|
+
buildHttpRequest(method, url, headers, bodyString, bodyBytes)
|
|
275
|
+
} catch (e: IllegalArgumentException) {
|
|
276
|
+
return@register JSCDispatchOutcome.Error("INVALID_ARGS", "fetch.request: ${e.message}")
|
|
277
|
+
}
|
|
206
278
|
|
|
207
279
|
httpClient.newCall(request).enqueue(object : Callback {
|
|
208
280
|
override fun onFailure(call: Call, e: IOException) {
|
|
@@ -210,33 +282,14 @@ object JSCPolyfillBridge {
|
|
|
210
282
|
}
|
|
211
283
|
|
|
212
284
|
override fun onResponse(call: Call, response: Response) {
|
|
213
|
-
response.use {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
220
|
-
val bodyStr = try {
|
|
221
|
-
r.body?.string() ?: ""
|
|
222
|
-
} catch (e: Throwable) {
|
|
223
|
-
Log.w(TAG, "fetch read body threw: ${e.message}")
|
|
224
|
-
""
|
|
225
|
-
}
|
|
226
|
-
val envelope = JSONObject().apply {
|
|
227
|
-
put("kind", "response")
|
|
228
|
-
put("reqId", reqId)
|
|
229
|
-
put("ok", true)
|
|
230
|
-
put("result", JSONObject().apply {
|
|
231
|
-
put("status", status)
|
|
232
|
-
put("statusText", statusText)
|
|
233
|
-
put("headers", JSONObject(headerMap as Map<*, *>))
|
|
234
|
-
put("body", bodyStr)
|
|
235
|
-
put("ok", status in 200..299)
|
|
236
|
-
})
|
|
237
|
-
}
|
|
238
|
-
runtime.dispatchToJs(packageName, envelope.toString())
|
|
285
|
+
val result = response.use { fetchResult(it) }
|
|
286
|
+
val envelope = JSONObject().apply {
|
|
287
|
+
put("kind", "response")
|
|
288
|
+
put("reqId", reqId)
|
|
289
|
+
put("ok", true)
|
|
290
|
+
put("result", result)
|
|
239
291
|
}
|
|
292
|
+
runtime.dispatchToJs(packageName, envelope.toString())
|
|
240
293
|
}
|
|
241
294
|
})
|
|
242
295
|
JSCDispatchOutcome.Async
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
package com.mentra.crust.jsc
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Which URLs miniapp code may open through the polyfill's network bridge. Miniapp code is
|
|
5
|
+
* untrusted, so `fetch` and `WebSocket` reach only the network: never a `file:` URL (the
|
|
6
|
+
* Mentra App's own files), a `content:` URI or any other local scheme. Mirrors iOS
|
|
7
|
+
* `MiniappNetworkPolicy.swift` and the polyfill's own check, which miniapp code can skip by
|
|
8
|
+
* calling the bridge directly.
|
|
9
|
+
*/
|
|
10
|
+
object MiniappNetworkPolicy {
|
|
11
|
+
/** Leading C0 controls and spaces are skipped, as OkHttp's URL parser does. */
|
|
12
|
+
private val SCHEME = Regex("^[\\u0000-\\u0020]*([A-Za-z][A-Za-z0-9+.-]*):")
|
|
13
|
+
private val FETCH_SCHEMES = setOf("http", "https")
|
|
14
|
+
private val WEB_SOCKET_SCHEMES = setOf("ws", "wss", "http", "https")
|
|
15
|
+
|
|
16
|
+
const val FETCH_REFUSAL = "fetch: only http: and https: URLs are allowed"
|
|
17
|
+
const val WEB_SOCKET_REFUSAL = "ws.open: only ws:, wss:, http: and https: URLs are allowed"
|
|
18
|
+
|
|
19
|
+
/** The URL's scheme, lowercased, or null for a URL without one. */
|
|
20
|
+
fun scheme(url: String): String? = SCHEME.find(url)?.groupValues?.get(1)?.lowercase()
|
|
21
|
+
|
|
22
|
+
fun allowsFetch(url: String): Boolean = scheme(url) in FETCH_SCHEMES
|
|
23
|
+
|
|
24
|
+
fun allowsWebSocket(url: String): Boolean = scheme(url) in WEB_SOCKET_SCHEMES
|
|
25
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
package com.mentra.crust.utils
|
|
2
|
+
|
|
3
|
+
import java.io.File
|
|
4
|
+
import java.io.IOException
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The real location of a path, for checking that a path a miniapp names stays inside the
|
|
8
|
+
* directory the host allows: absolute, with `.`, `..` and every symlink resolved.
|
|
9
|
+
*/
|
|
10
|
+
object CanonicalFilePath {
|
|
11
|
+
/** The canonical path of an existing file or directory, or null when there is none. */
|
|
12
|
+
fun of(path: String): String? {
|
|
13
|
+
if (!path.startsWith("/") || path.contains('\u0000')) return null
|
|
14
|
+
return try {
|
|
15
|
+
File(path).canonicalFile.takeIf { it.exists() }?.path
|
|
16
|
+
} catch (e: IOException) {
|
|
17
|
+
null
|
|
18
|
+
} catch (e: SecurityException) {
|
|
19
|
+
null
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
package com.mentra.crust.jsc
|
|
2
|
+
|
|
3
|
+
import java.io.File
|
|
4
|
+
import java.util.concurrent.LinkedBlockingQueue
|
|
5
|
+
import java.util.concurrent.TimeUnit
|
|
6
|
+
import kotlin.math.PI
|
|
7
|
+
import kotlin.math.roundToInt
|
|
8
|
+
import kotlin.math.sin
|
|
9
|
+
import okhttp3.MediaType.Companion.toMediaType
|
|
10
|
+
import okhttp3.Protocol
|
|
11
|
+
import okhttp3.Request
|
|
12
|
+
import okhttp3.Response
|
|
13
|
+
import okhttp3.ResponseBody.Companion.toResponseBody
|
|
14
|
+
import okhttp3.mockwebserver.Dispatcher
|
|
15
|
+
import okhttp3.mockwebserver.MockResponse
|
|
16
|
+
import okhttp3.mockwebserver.MockWebServer
|
|
17
|
+
import okhttp3.mockwebserver.RecordedRequest
|
|
18
|
+
import okio.Buffer
|
|
19
|
+
import okio.ByteString.Companion.decodeBase64
|
|
20
|
+
import org.junit.After
|
|
21
|
+
import org.junit.Assert.*
|
|
22
|
+
import org.junit.Before
|
|
23
|
+
import org.junit.Test
|
|
24
|
+
import org.junit.runner.RunWith
|
|
25
|
+
import org.robolectric.RobolectricTestRunner
|
|
26
|
+
import org.robolectric.RuntimeEnvironment
|
|
27
|
+
import org.robolectric.annotation.Config
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Miniapp `fetch` end to end on Android's real stack: the shipped startup.js
|
|
31
|
+
* polyfill in QuickJS, [JSCRuntime]'s dispatcher and `__deliver` path, this
|
|
32
|
+
* bridge, and OkHttp against a local server. Each miniapp script reports
|
|
33
|
+
* through a `test` dispatch, which the runtime forwards to `onOutbound`.
|
|
34
|
+
*/
|
|
35
|
+
@RunWith(RobolectricTestRunner::class)
|
|
36
|
+
@Config(sdk = [28], manifest = Config.NONE)
|
|
37
|
+
class FetchBridgeTest {
|
|
38
|
+
private val packageName = "com.mentra.fetchtest"
|
|
39
|
+
private val results = LinkedBlockingQueue<Map<*, *>>()
|
|
40
|
+
private lateinit var server: MockWebServer
|
|
41
|
+
private lateinit var runtime: JSCRuntime
|
|
42
|
+
@Volatile private var redirectTarget = "file:///etc/hosts"
|
|
43
|
+
|
|
44
|
+
/** A 24 kHz 16-bit sine followed by every byte value: mostly not valid UTF-8. */
|
|
45
|
+
private val pcm = ByteArray(4800 * 2 + 256).also { out ->
|
|
46
|
+
for (i in 0 until 4800) {
|
|
47
|
+
val sample = (sin(2 * PI * 220 * i / 24000) * 20000).roundToInt()
|
|
48
|
+
out[2 * i] = sample.toByte()
|
|
49
|
+
out[2 * i + 1] = (sample shr 8).toByte()
|
|
50
|
+
}
|
|
51
|
+
for (value in 0 until 256) out[9600 + value] = value.toByte()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Valid UTF-8 that is still binary data: a BOM, NUL and DEL must all survive. */
|
|
55
|
+
private val utf8Bytes = byteArrayOf(0xEF.toByte(), 0xBB.toByte(), 0xBF.toByte()) +
|
|
56
|
+
"{\"a\":\"é😀\"}".toByteArray(Charsets.UTF_8) + byteArrayOf(0x00, 0x7F)
|
|
57
|
+
|
|
58
|
+
/** Two MB of valid UTF-8 text: arrayBuffer() re-encodes it inside the 32 MB miniapp heap. */
|
|
59
|
+
private val largeUtf8Text: ByteArray =
|
|
60
|
+
"héllo 😀 中文 chunk of reply text ".repeat(52_000).toByteArray(Charsets.UTF_8)
|
|
61
|
+
|
|
62
|
+
// crust's build.gradle ships this file as the runtime's startup.js asset.
|
|
63
|
+
private val polyfill: String by lazy { File("../../jspolyfill/assets/startup.js").readText() }
|
|
64
|
+
|
|
65
|
+
@Before fun setUp() {
|
|
66
|
+
server = MockWebServer().apply {
|
|
67
|
+
dispatcher = object : Dispatcher() {
|
|
68
|
+
override fun dispatch(request: RecordedRequest): MockResponse = when (request.path) {
|
|
69
|
+
"/reply.pcm" -> response("application/octet-stream").setBody(Buffer().write(pcm))
|
|
70
|
+
"/utf8.bin" -> response("application/octet-stream").setBody(Buffer().write(utf8Bytes))
|
|
71
|
+
"/large.txt" -> response("text/plain").setBody(Buffer().write(largeUtf8Text))
|
|
72
|
+
"/data.json" -> response("application/json").setBody("{\"message\":\"héllo 😀 中文\"}")
|
|
73
|
+
"/upload" -> response("text/plain").setBody("ok")
|
|
74
|
+
"/to-file" -> MockResponse().setResponseCode(302).setHeader("Location", redirectTarget)
|
|
75
|
+
else -> MockResponse().setResponseCode(404)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
start()
|
|
79
|
+
}
|
|
80
|
+
runtime = JSCRuntime.shared(RuntimeEnvironment.getApplication())
|
|
81
|
+
JSCPolyfillBridge.install(runtime)
|
|
82
|
+
runtime.onOutbound = { message ->
|
|
83
|
+
if (message.payload["iface"] == "test") {
|
|
84
|
+
results.add((message.payload["args"] as List<*>).first() as Map<*, *>)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
@After fun tearDown() {
|
|
90
|
+
runtime.kill(packageName)
|
|
91
|
+
runtime.onOutbound = null
|
|
92
|
+
server.shutdown()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
@Test fun `arrayBuffer returns the exact bytes of a binary response`() {
|
|
96
|
+
val result = runMiniapp(
|
|
97
|
+
"""
|
|
98
|
+
const response = await fetch(BASE + "/reply.pcm")
|
|
99
|
+
const bytes = new Uint8Array(await response.arrayBuffer())
|
|
100
|
+
return {status: response.status, type: response.headers.get("content-type"), bytes: Array.from(bytes)}
|
|
101
|
+
""",
|
|
102
|
+
)
|
|
103
|
+
assertEquals(200, result["status"])
|
|
104
|
+
assertEquals("application/octet-stream", result["type"])
|
|
105
|
+
assertArrayEquals(pcm, bytes(result["bytes"]))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
@Test fun `arrayBuffer returns the exact bytes of a body that is valid UTF-8`() {
|
|
109
|
+
val result = runMiniapp(
|
|
110
|
+
"""
|
|
111
|
+
const response = await fetch(BASE + "/utf8.bin")
|
|
112
|
+
return {bytes: Array.from(new Uint8Array(await response.arrayBuffer()))}
|
|
113
|
+
""",
|
|
114
|
+
)
|
|
115
|
+
assertArrayEquals(utf8Bytes, bytes(result["bytes"]))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
@Test fun `arrayBuffer re-encodes a large UTF-8 body within the miniapp heap`() {
|
|
119
|
+
val result = runMiniapp(
|
|
120
|
+
"""
|
|
121
|
+
const bytes = new Uint8Array(await (await fetch(BASE + "/large.txt")).arrayBuffer())
|
|
122
|
+
let sum = 0
|
|
123
|
+
for (let i = 0; i < bytes.length; i++) sum = (sum * 31 + bytes[i]) >>> 0
|
|
124
|
+
return {length: bytes.length, sum}
|
|
125
|
+
""",
|
|
126
|
+
)
|
|
127
|
+
var sum = 0L
|
|
128
|
+
for (byte in largeUtf8Text) sum = (sum * 31 + (byte.toInt() and 0xFF)) and 0xFFFFFFFFL
|
|
129
|
+
assertEquals(largeUtf8Text.size, (result["length"] as Number).toInt())
|
|
130
|
+
assertEquals(sum, (result["sum"] as Number).toLong())
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
@Test fun `responses are sent as text only when that text re-encodes to the same bytes`() {
|
|
134
|
+
val text = JSCPolyfillBridge.fetchResult(responseWith(utf8Bytes))
|
|
135
|
+
assertEquals(String(utf8Bytes, Charsets.UTF_8), text.getString("body"))
|
|
136
|
+
assertFalse(text.has("bodyBase64"))
|
|
137
|
+
|
|
138
|
+
val empty = JSCPolyfillBridge.fetchResult(responseWith(ByteArray(0)))
|
|
139
|
+
assertEquals("", empty.getString("body"))
|
|
140
|
+
|
|
141
|
+
// Truncated, overlong, surrogate and out-of-range sequences, then raw PCM.
|
|
142
|
+
for (binary in listOf(byteArrayOf(0x41, 0xE2.toByte(), 0x82.toByte()), byteArrayOf(0xC0.toByte(), 0x80.toByte()),
|
|
143
|
+
byteArrayOf(0xED.toByte(), 0xA0.toByte(), 0x80.toByte()), byteArrayOf(0xF4.toByte(), 0x90.toByte(), 0x80.toByte(), 0x80.toByte()), pcm)) {
|
|
144
|
+
val result = JSCPolyfillBridge.fetchResult(responseWith(binary))
|
|
145
|
+
assertFalse(result.has("body"))
|
|
146
|
+
assertArrayEquals(binary, result.getString("bodyBase64").decodeBase64()!!.toByteArray())
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
@Test fun `text and json decode a UTF-8 response`() {
|
|
151
|
+
val result = runMiniapp(
|
|
152
|
+
"""
|
|
153
|
+
const text = await (await fetch(BASE + "/data.json")).text()
|
|
154
|
+
const json = await (await fetch(BASE + "/data.json")).json()
|
|
155
|
+
return {text, message: json.message}
|
|
156
|
+
""",
|
|
157
|
+
)
|
|
158
|
+
assertEquals("{\"message\":\"héllo 😀 中文\"}", result["text"])
|
|
159
|
+
assertEquals("héllo 😀 中文", result["message"])
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
@Test fun `binary request bodies reach the server byte for byte`() {
|
|
163
|
+
val result = runMiniapp(
|
|
164
|
+
"""
|
|
165
|
+
const backing = Uint8Array.from({length: 600}, (_, i) => (i * 37 + 11) & 255)
|
|
166
|
+
const response = await fetch(BASE + "/upload", {
|
|
167
|
+
method: "POST",
|
|
168
|
+
headers: {"Content-Type": "audio/L16; rate=24000"},
|
|
169
|
+
body: backing.subarray(44),
|
|
170
|
+
})
|
|
171
|
+
return {status: response.status, text: await response.text()}
|
|
172
|
+
""",
|
|
173
|
+
)
|
|
174
|
+
assertEquals(200, result["status"])
|
|
175
|
+
assertEquals("ok", result["text"])
|
|
176
|
+
val upload = server.takeRequest()
|
|
177
|
+
val expected = ByteArray(600) { ((it * 37 + 11) and 255).toByte() }.copyOfRange(44, 600)
|
|
178
|
+
assertArrayEquals(expected, upload.body.readByteArray())
|
|
179
|
+
assertEquals("audio/L16; rate=24000", upload.getHeader("Content-Type"))
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
@Test fun `string request bodies keep their UTF-8 text and content type`() {
|
|
183
|
+
runMiniapp(
|
|
184
|
+
"""
|
|
185
|
+
await fetch(BASE + "/upload", {method: "POST", headers: {"Content-Type": "application/json"}, body: '{"t":"é😀"}'})
|
|
186
|
+
return {}
|
|
187
|
+
""",
|
|
188
|
+
)
|
|
189
|
+
val upload = server.takeRequest()
|
|
190
|
+
assertEquals("{\"t\":\"é😀\"}", upload.body.readUtf8())
|
|
191
|
+
assertEquals("application/json; charset=utf-8", upload.getHeader("Content-Type"))
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Miniapp code is untrusted and can call the bridge without the polyfill's own check, so
|
|
196
|
+
* native must refuse a local URL itself. A file: URL would read the Mentra App's files.
|
|
197
|
+
*/
|
|
198
|
+
@Test fun `native fetch and WebSocket refuse local URLs even when the polyfill is skipped`() {
|
|
199
|
+
val secret = File.createTempFile("mentra-credentials", ".txt").apply {
|
|
200
|
+
writeText("refresh-token-that-must-not-leak")
|
|
201
|
+
deleteOnExit()
|
|
202
|
+
}
|
|
203
|
+
val urls = listOf(secret.toURI().toString(), "FILE://${secret.absolutePath}", "content://com.mentra.mentra.fileprovider/x", "data:text/plain,hi")
|
|
204
|
+
val result = runMiniapp(
|
|
205
|
+
"""
|
|
206
|
+
const outcomes = []
|
|
207
|
+
for (const url of ${org.json.JSONArray(urls)}) {
|
|
208
|
+
outcomes.push(await __mentraSendRequest("fetch", "request", [{url, method: "GET", headers: {}}]).then(
|
|
209
|
+
(r) => ({read: String(r && (r.body || r.bodyBase64))}),
|
|
210
|
+
(e) => ({error: String(e && (e.message || e.code))}),
|
|
211
|
+
))
|
|
212
|
+
let opened = false
|
|
213
|
+
try {
|
|
214
|
+
__dispatch("ws", "open", JSON.stringify([{sid: "s-" + outcomes.length, url, protocols: []}]))
|
|
215
|
+
opened = true
|
|
216
|
+
} catch (e) {
|
|
217
|
+
outcomes.push({error: String(e && e.message)})
|
|
218
|
+
}
|
|
219
|
+
if (opened) outcomes.push({read: "ws opened"})
|
|
220
|
+
}
|
|
221
|
+
return {outcomes}
|
|
222
|
+
""",
|
|
223
|
+
)
|
|
224
|
+
val outcomes = result["outcomes"] as List<*>
|
|
225
|
+
assertEquals(urls.size * 2, outcomes.size)
|
|
226
|
+
for (outcome in outcomes) {
|
|
227
|
+
val map = outcome as Map<*, *>
|
|
228
|
+
assertNull("a local URL was read: $map", map["read"])
|
|
229
|
+
assertTrue("unexpected refusal: $map", (map["error"] as String).contains("URLs are allowed"))
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** OkHttp follows a redirect only to another http(s) URL, so a 302 to a file: URL reads nothing. */
|
|
234
|
+
@Test fun `a redirect to a file URL is not followed`() {
|
|
235
|
+
val secret = File.createTempFile("mentra-credentials", ".txt").apply {
|
|
236
|
+
writeText("refresh-token-that-must-not-leak")
|
|
237
|
+
deleteOnExit()
|
|
238
|
+
}
|
|
239
|
+
redirectTarget = secret.toURI().toString()
|
|
240
|
+
val result = runMiniapp(
|
|
241
|
+
"""
|
|
242
|
+
const response = await fetch(BASE + "/to-file")
|
|
243
|
+
return {status: response.status, text: await response.text()}
|
|
244
|
+
""",
|
|
245
|
+
)
|
|
246
|
+
assertEquals(302, result["status"])
|
|
247
|
+
assertFalse((result["text"] as String).contains("refresh-token"))
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private fun runMiniapp(body: String): Map<*, *> {
|
|
251
|
+
val script =
|
|
252
|
+
"""
|
|
253
|
+
const BASE = "${server.url("/").toString().removeSuffix("/")}"
|
|
254
|
+
const report = (value) => __mentraSendOneShot("test", "result", [value])
|
|
255
|
+
;(async () => {
|
|
256
|
+
$body
|
|
257
|
+
})().then(report, (error) => report({error: String(error && (error.message || error))}))
|
|
258
|
+
""".trimIndent()
|
|
259
|
+
assertTrue("spawn failed", runtime.spawn(packageName, polyfill, script))
|
|
260
|
+
val result = results.poll(15, TimeUnit.SECONDS) ?: throw AssertionError("miniapp did not report a result")
|
|
261
|
+
assertNull("miniapp script threw", result["error"])
|
|
262
|
+
return result
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private fun bytes(value: Any?): ByteArray =
|
|
266
|
+
(value as List<*>).map { (it as Number).toInt().toByte() }.toByteArray()
|
|
267
|
+
|
|
268
|
+
private fun response(contentType: String) = MockResponse().setHeader("Content-Type", contentType)
|
|
269
|
+
|
|
270
|
+
private fun responseWith(body: ByteArray): Response = Response.Builder()
|
|
271
|
+
.request(Request.Builder().url("https://example.com/body").build())
|
|
272
|
+
.protocol(Protocol.HTTP_1_1)
|
|
273
|
+
.code(200)
|
|
274
|
+
.message("OK")
|
|
275
|
+
.body(body.toResponseBody("application/octet-stream".toMediaType()))
|
|
276
|
+
.build()
|
|
277
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
package com.mentra.crust.jsc
|
|
2
|
+
|
|
3
|
+
import org.junit.Assert.assertEquals
|
|
4
|
+
import org.junit.Assert.assertFalse
|
|
5
|
+
import org.junit.Assert.assertNull
|
|
6
|
+
import org.junit.Assert.assertTrue
|
|
7
|
+
import org.junit.Test
|
|
8
|
+
|
|
9
|
+
class MiniappNetworkPolicyTest {
|
|
10
|
+
@Test fun `fetch reaches only http and https`() {
|
|
11
|
+
for (url in listOf("http://example.com", "https://example.com/a?b", "HTTPS://EXAMPLE.COM", " https://example.com")) {
|
|
12
|
+
assertTrue(url, MiniappNetworkPolicy.allowsFetch(url))
|
|
13
|
+
}
|
|
14
|
+
for (url in listOf(
|
|
15
|
+
"file:///data/data/com.mentra.mentra/files/mmkv/mmkv.default",
|
|
16
|
+
"FILE:///etc/hosts",
|
|
17
|
+
"\tfile:///etc/hosts",
|
|
18
|
+
"content://com.mentra.mentra.fileprovider/cache/x",
|
|
19
|
+
"data:text/plain,hi",
|
|
20
|
+
"jar:file:///x!/y",
|
|
21
|
+
"ws://example.com",
|
|
22
|
+
"/data/data/com.mentra.mentra/files",
|
|
23
|
+
"relative/path",
|
|
24
|
+
"",
|
|
25
|
+
)) {
|
|
26
|
+
assertFalse(url, MiniappNetworkPolicy.allowsFetch(url))
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@Test fun `WebSockets reach only ws, wss, http and https`() {
|
|
31
|
+
for (url in listOf("ws://10.0.0.2:9000", "WSS://example.com/s", "http://example.com", "https://example.com")) {
|
|
32
|
+
assertTrue(url, MiniappNetworkPolicy.allowsWebSocket(url))
|
|
33
|
+
}
|
|
34
|
+
for (url in listOf("file:///etc/hosts", "content://x/y", "data:,x", "/socket")) {
|
|
35
|
+
assertFalse(url, MiniappNetworkPolicy.allowsWebSocket(url))
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@Test fun `scheme is lowercased and absent for a relative URL`() {
|
|
40
|
+
assertEquals("file", MiniappNetworkPolicy.scheme("FiLe:///x"))
|
|
41
|
+
assertEquals("https", MiniappNetworkPolicy.scheme("\u0000 https://x"))
|
|
42
|
+
assertNull(MiniappNetworkPolicy.scheme("/x"))
|
|
43
|
+
assertNull(MiniappNetworkPolicy.scheme("1http://x"))
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
package com.mentra.crust.utils
|
|
2
|
+
|
|
3
|
+
import java.io.File
|
|
4
|
+
import java.nio.file.Files
|
|
5
|
+
import org.junit.After
|
|
6
|
+
import org.junit.Assert.assertEquals
|
|
7
|
+
import org.junit.Assert.assertNull
|
|
8
|
+
import org.junit.Before
|
|
9
|
+
import org.junit.Test
|
|
10
|
+
|
|
11
|
+
class CanonicalFilePathTest {
|
|
12
|
+
private lateinit var root: File
|
|
13
|
+
|
|
14
|
+
@Before fun setUp() {
|
|
15
|
+
root = Files.createTempDirectory("canonical-path-test").toFile().canonicalFile
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
@After fun tearDown() {
|
|
19
|
+
root.deleteRecursively()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@Test fun `resolves dot segments and symlinks to the real location`() {
|
|
23
|
+
val sandbox = File(root, "sandbox").apply { mkdirs() }
|
|
24
|
+
val outside = File(root, "outside.txt").apply { writeText("secret") }
|
|
25
|
+
val link = File(sandbox, "link.txt")
|
|
26
|
+
Files.createSymbolicLink(link.toPath(), outside.toPath())
|
|
27
|
+
|
|
28
|
+
assertEquals(outside.path, CanonicalFilePath.of(link.path))
|
|
29
|
+
assertEquals(outside.path, CanonicalFilePath.of("${sandbox.path}/../outside.txt"))
|
|
30
|
+
assertEquals(sandbox.path, CanonicalFilePath.of("${sandbox.path}/./"))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@Test fun `is null for a missing, relative or malformed path`() {
|
|
34
|
+
assertNull(CanonicalFilePath.of("${root.path}/missing.txt"))
|
|
35
|
+
assertNull(CanonicalFilePath.of("relative/path"))
|
|
36
|
+
assertNull(CanonicalFilePath.of("${root.path}/a\u0000b"))
|
|
37
|
+
assertNull(CanonicalFilePath.of(""))
|
|
38
|
+
}
|
|
39
|
+
}
|
package/build/CrustModule.d.ts
CHANGED
|
@@ -9,6 +9,12 @@ declare class CrustModule extends NativeModule<CrustModuleEvents> {
|
|
|
9
9
|
getPermissionStatus(requestId: string): Promise<AppPermissionReceipt>;
|
|
10
10
|
PI: number;
|
|
11
11
|
hello(): string;
|
|
12
|
+
/**
|
|
13
|
+
* The canonical path of an existing file or directory: absolute, with `.`,
|
|
14
|
+
* `..` and every symlink resolved. Null when nothing is there. The engine
|
|
15
|
+
* checks that a path a miniapp names stays inside the directory it may use.
|
|
16
|
+
*/
|
|
17
|
+
canonicalFilePath(path: string): string | null;
|
|
12
18
|
setValueAsync(value: string): Promise<void>;
|
|
13
19
|
/** Android: copy the displayed view's window pixels to a temporary JPEG URI. */
|
|
14
20
|
captureMiniappPreview(viewTag: number): Promise<string>;
|