@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.
Files changed (28) hide show
  1. package/android/build.gradle +16 -4
  2. package/android/src/main/java/com/mentra/crust/CrustModule.kt +6 -0
  3. package/android/src/main/java/com/mentra/crust/jsc/JSCPolyfillBridge.kt +83 -30
  4. package/android/src/main/java/com/mentra/crust/jsc/MiniappNetworkPolicy.kt +25 -0
  5. package/android/src/main/java/com/mentra/crust/utils/CanonicalFilePath.kt +22 -0
  6. package/android/src/test/java/com/mentra/crust/jsc/FetchBridgeTest.kt +277 -0
  7. package/android/src/test/java/com/mentra/crust/jsc/MiniappNetworkPolicyTest.kt +45 -0
  8. package/android/src/test/java/com/mentra/crust/utils/CanonicalFilePathTest.kt +39 -0
  9. package/build/CrustModule.d.ts +6 -0
  10. package/build/CrustModule.d.ts.map +1 -1
  11. package/build/CrustModule.js.map +1 -1
  12. package/build/CrustModule.web.d.ts +2 -0
  13. package/build/CrustModule.web.d.ts.map +1 -1
  14. package/build/CrustModule.web.js +4 -0
  15. package/build/CrustModule.web.js.map +1 -1
  16. package/ios/Crust.podspec +3 -0
  17. package/ios/CrustModule.swift +5 -0
  18. package/ios/MiniappPolicyKit/Package.swift +13 -0
  19. package/ios/MiniappPolicyKit/Sources/MiniappPolicy/CanonicalFilePath.swift +13 -0
  20. package/ios/MiniappPolicyKit/Sources/MiniappPolicy/MiniappNetworkPolicy.swift +24 -0
  21. package/ios/MiniappPolicyKit/Tests/MiniappPolicyTests/MiniappPolicyTests.swift +117 -0
  22. package/ios/MiniappPolicyKit/Tests/MiniappPolicyTests/RedirectServer.swift +33 -0
  23. package/ios/PermissionStatusReceipt.swift +72 -1
  24. package/ios/Resources/startup.js +199 -87
  25. package/ios/Source/JSCPolyfillBridge.swift +56 -14
  26. package/package.json +4 -2
  27. package/src/CrustModule.ts +6 -0
  28. package/src/CrustModule.web.ts +4 -0
@@ -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(method: String, url: String, headers: Map<String, String>, bodyString: String?): Request {
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
- bodyString != null -> bodyString.toRequestBody((contentType ?: "application/octet-stream").toMediaTypeOrNull())
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 = Request.Builder().url(url)
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 = buildHttpRequest(method, url, headers, bodyString)
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 { r ->
214
- val status = r.code
215
- val statusText = r.message
216
- val headerMap = mutableMapOf<String, String>()
217
- for (name in r.headers.names()) {
218
- headerMap[name.lowercase()] = r.headers.values(name).joinToString(", ")
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
+ }
@@ -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>;