fix(mobile): prevent cache cleanup symlink traversal

This commit is contained in:
OrcaWin
2026-08-09 18:34:59 -04:00
committed by Jinwoo-H
parent 0c2e8eb73d
commit 2d2584eb0d
12 changed files with 434 additions and 29 deletions
@@ -2661,4 +2661,7 @@ and diff hygiene pass. A fresh production RNW build remains
| 2026-07-28 | Finding | Swift `JSONSerialization` collapsed duplicate object keys while Android rejected them, and Android's `JSONObject(String)` accepted trailing tokens. Deep remote JSON also had no pre-parser nesting ceiling. Primary/canonical manifests and activation metadata now pass one exact JSON grammar before platform parsing. |
| 2026-07-28 | Complete | Mirrored Swift/Kotlin corpora reject literal or escaped-equivalent duplicate keys, nested duplicates, trailing tokens, malformed scalars, and more than 32 nesting levels. Store-level primary/canonical-manifest and activation mutations fail before staging on both platforms. |
| 2026-07-28 | Complete | Native Debug/Release gates, the 569-file mobile suite, typechecks, lints, reliability, max-lines, formatting, diff hygiene, and unchanged `b17ead7a…` package verification pass after exact JSON preflight. |
| 2026-07-28 | Finding | Android `File.deleteRecursively()` followed an orphan-stage directory symlink and removed its external sentinel. Both platforms also skipped dangling host links because ordinary existence checks followed the links. |
| 2026-07-28 | Complete | Android cache deletion now stays inside the lexical cache root for failed/aborted stages, duplicate commits, unused generations, quota eviction, orphan/temp cleanup, and host removal. Quota accounting and candidates ignore linked trees. |
| 2026-07-28 | Complete | Mirrored native faults preserve external sentinels across direct/nested orphan, live-stage replacement, host-subtree, generation, and dangling-host links. Native Debug/Release gates, the 569-file mobile suite, typechecks, lints, reliability, max-lines, formatting, diff hygiene, and unchanged `b17ead7a…` package verification pass. |
| 2026-07-28 | Next | Complete the remaining gated cutover cleanup, then execute the physical-device, topology, security, performance, packaged-release, and App Store gates. |
@@ -531,7 +531,11 @@ manifest, activation, or executable asset bytes are consumed. Primary and
canonical manifests plus activation metadata also pass a bounded exact JSON
grammar before native parsing. Duplicate decoded keys, trailing tokens,
malformed scalars, and nesting beyond 32 levels fail consistently on both
platforms.
platforms. Cache mutation faults additionally require every destructive
Android store path to delete only lexical descendants without following
symlinks; quota traversal ignores linked trees. Direct, nested, and
live-stage-replacement links plus host-subtree and dangling host links preserve
external sentinels on both native stores.
The standalone renderer-based workspace, session, files, terminal,
source-control, and review presentation is also removed with its Vite-only
package path. A production-source boundary requires `src/mobile-web/` to remain
@@ -2831,6 +2831,13 @@ assessment.
- Never edit native `activation.json` or cached generation assets. Use the
host-scoped recovery controls and verified release artifacts.
Native cache deletion is boundary checked as strictly as cache reads. Android
does not use `File.deleteRecursively()` for staged, generation, eviction,
activation-temp, or host cleanup because it follows directory symlinks. Both
stores remove a linked entry itself, never its target, and remove dangling host
links even though ordinary existence checks follow links. Quota measurement and
eviction ignore linked trees.
### Native-shell rollback
A defect in the asset origin, credential broker, native bridge, audio/picker
@@ -221,6 +221,18 @@ Primary/canonical manifests and activation metadata now pass the same exact
JSON grammar before platform parsing. Literal and escaped-equivalent duplicate
keys, nested duplicates, trailing tokens, malformed scalars, and nesting beyond
32 levels fail consistently in the mirrored native corpora.
Cache cleanup now uses a cache-root-boundary deletion path on Android instead
of `File.deleteRecursively()`, which followed a staged directory symlink during
the fault probe and removed an external sentinel. Cleanup, abort, duplicate
commit, unused-generation removal, quota eviction, host removal, and activation
temp cleanup no longer follow linked trees. Quota accounting and eviction also
ignore linked generations. Mirrored iOS/Android faults cover direct and nested
orphan links, live stages replaced by links, host-subtree links, and dangling
host links; the dangling probe also closed an iOS `fileExists` removal skip.
The iOS native fault executable, Android Debug unit/Release Kotlin gates,
569-file mobile suite, mobile/mobile-web typechecks and lints, reliability,
max-lines, focused formatting, diff hygiene, and unchanged `b17ead7a…` package
verification pass after the cleanup repair.
The remaining security work below is release-app corpus testing, fuzzing,
cross-scope races, privacy/authorization audit, and independent review.
@@ -278,9 +290,12 @@ cross-scope races, privacy/authorization audit, and independent review.
symlinks, directories, and missing files before opening bytes. A fresh
exact-JSON preflight also rejects duplicate decoded keys, trailing
tokens, malformed scalars, and more than 32 nesting levels across primary
manifests, canonical manifests, and activation metadata. A fresh
exact-app rerun, further generated mutation, cache mutation/cleanup
faults, and the other listed boundaries remain.
manifests, canonical manifests, and activation metadata. Native cleanup
faults now reject direct, nested, host-subtree, generation, and dangling
symlink traversal without touching external sentinels; Android quota
accounting ignores linked external bytes. A fresh exact-app rerun,
further generated mutation, concurrent cache mutation, and the other
listed boundaries remain.
- [ ] Attempt cross-host, cross-build, cross-workspace, cross-session, replay,
reconnect, process-loss, and host-removal races.
- [ ] Verify no credential or privileged host identity reaches URLs, DOM state,
@@ -0,0 +1,43 @@
package expo.modules.mobilewebshell
import java.io.File
internal fun removeMobileWebCacheTree(entry: File, withinRoot: File): Boolean {
val parent = entry.parentFile ?: return false
if (!isMobileWebUnlinkedPath(parent, withinRoot)) return false
if (!isMobileWebUnlinkedPath(entry, withinRoot)) return entry.delete()
if (!entry.exists()) return entry.delete() || !entry.exists()
if (!entry.isDirectory) return entry.delete()
val children = entry.listFiles() ?: return false
if (!children.all { removeMobileWebCacheTree(it, withinRoot) }) return false
return entry.delete()
}
internal fun mobileWebCacheLogicalByteLength(entry: File, withinRoot: File): Long {
if (!isMobileWebUnlinkedPath(entry, withinRoot) || !entry.exists()) return 0
if (entry.isFile) return entry.length()
if (!entry.isDirectory) return 0
return entry.listFiles()
?.sumOf { mobileWebCacheLogicalByteLength(it, withinRoot) }
?: 0
}
internal fun isMobileWebUnlinkedPath(entry: File, withinRoot: File): Boolean {
val expected = expectedMobileWebCanonicalPath(entry, withinRoot) ?: return false
return runCatching { entry.canonicalFile.path == expected }.getOrDefault(false)
}
private fun expectedMobileWebCanonicalPath(entry: File, root: File): String? {
val rootPath = root.absoluteFile.path.trimEnd(File.separatorChar)
val entryPath = entry.absoluteFile.path
if (entryPath == rootPath) return runCatching { root.canonicalFile.path }.getOrNull()
val prefix = rootPath + File.separator
if (!entryPath.startsWith(prefix)) return null
val relativePath = entryPath.removePrefix(prefix)
val components = relativePath.split(File.separatorChar)
if (components.any { it.isEmpty() || it == "." || it == ".." }) return null
val canonicalRoot = runCatching { root.canonicalFile }.getOrNull() ?: return null
return components.fold(canonicalRoot) { parent, component ->
File(parent, component)
}.absoluteFile.path
}
@@ -112,7 +112,7 @@ internal class MobileWebPackageStore internal constructor(
require(file.createNewFile()) { "mobile_web_stage_create_failed" }
}
} catch (error: Exception) {
stageRoot.deleteRecursively()
removeMobileWebCacheTree(stageRoot, cacheRoot)
throw storageException(error, "mobile_web_stage_create_failed")
}
stages[stageId] = MobileWebStageRecord(hostKey, stageRoot, manifest, reservedByteLength)
@@ -188,7 +188,9 @@ internal class MobileWebPackageStore internal constructor(
try {
if (destination.exists()) {
verifyCommittedGeneration(destination, stage.manifest)
require(stage.root.deleteRecursively()) { "mobile_web_stage_cleanup_failed" }
require(removeMobileWebCacheTree(stage.root, cacheRoot)) {
"mobile_web_stage_cleanup_failed"
}
} else {
require(stage.root.renameTo(destination)) { "mobile_web_generation_commit_failed" }
}
@@ -201,7 +203,7 @@ internal class MobileWebPackageStore internal constructor(
@Synchronized
fun abortStage(stageId: String) {
stages.remove(stageId)?.root?.deleteRecursively()
stages.remove(stageId)?.root?.let { removeMobileWebCacheTree(it, cacheRoot) }
}
@Synchronized
@@ -333,7 +335,9 @@ internal class MobileWebPackageStore internal constructor(
stages.entries.removeAll { it.value.hostKey == hostKey }
sessions.entries.removeAll { it.value.hostKey == hostKey }
val hostRoot = File(cacheRoot, hostKey)
require(!hostRoot.exists() || hostRoot.deleteRecursively()) { "mobile_web_host_cleanup_failed" }
require(removeMobileWebCacheTree(hostRoot, cacheRoot)) {
"mobile_web_host_cleanup_failed"
}
}
private fun parseManifest(
@@ -497,7 +501,9 @@ internal class MobileWebPackageStore internal constructor(
val retained = (sessionBuilds + listOfNotNull(active, previous)).toSet()
File(hostRoot, "generations").listFiles()?.forEach { child ->
if (child.name !in retained) {
require(child.deleteRecursively()) { "mobile_web_generation_cleanup_failed" }
require(removeMobileWebCacheTree(child, cacheRoot)) {
"mobile_web_generation_cleanup_failed"
}
}
}
}
@@ -523,7 +529,9 @@ internal class MobileWebPackageStore internal constructor(
projectedGlobalBytes = projectedGlobalBytes
) ?: throw IllegalArgumentException("mobile_web_cache_quota_exceeded")
plan.forEach { candidate ->
require(candidate.root.deleteRecursively()) { "mobile_web_cache_quota_exceeded" }
require(removeMobileWebCacheTree(candidate.root, cacheRoot)) {
"mobile_web_cache_quota_exceeded"
}
}
val reservedFreeBytes = allStageReservations + requestedBytes
@@ -535,10 +543,18 @@ internal class MobileWebPackageStore internal constructor(
private fun evictionCandidates(): List<MobileWebCacheGenerationCandidate> =
cacheRoot.listFiles()
?.filter { it.isDirectory && isMobileWebSha256(it.name) }
?.filter {
it.isDirectory &&
isMobileWebSha256(it.name) &&
isMobileWebUnlinkedPath(it, cacheRoot)
}
?.flatMap { hostRoot ->
val generationRoots = File(hostRoot, "generations").listFiles()
?.filter { it.isDirectory && isMobileWebSha256(it.name) }
?.filter {
it.isDirectory &&
isMobileWebSha256(it.name) &&
isMobileWebUnlinkedPath(it, cacheRoot)
}
.orEmpty()
val buildIds = generationRoots.map { it.name }.toSet()
val protected = sessions.values
@@ -570,19 +586,32 @@ internal class MobileWebPackageStore internal constructor(
private fun cleanupOrphanedWrites() {
try {
val liveStageRoots = stages.values.mapTo(mutableSetOf()) { it.root.canonicalPath }
val liveStageRoots = stages.values.mapTo(mutableSetOf()) { it.root.absoluteFile.path }
cacheRoot.listFiles()
?.filter { it.isDirectory && isMobileWebSha256(it.name) }
?.filter { isMobileWebSha256(it.name) }
?.forEach { hostRoot ->
if (!isMobileWebUnlinkedPath(hostRoot, cacheRoot)) {
require(removeMobileWebCacheTree(hostRoot, cacheRoot)) {
"mobile_web_cache_cleanup_failed"
}
return@forEach
}
File(hostRoot, "staging").listFiles()?.forEach { stagedRoot ->
if (stagedRoot.canonicalPath !in liveStageRoots) {
require(stagedRoot.deleteRecursively()) { "mobile_web_cache_cleanup_failed" }
if (
stagedRoot.absoluteFile.path !in liveStageRoots ||
!isMobileWebUnlinkedPath(stagedRoot, cacheRoot)
) {
require(removeMobileWebCacheTree(stagedRoot, cacheRoot)) {
"mobile_web_cache_cleanup_failed"
}
}
}
hostRoot.listFiles()
?.filter { it.name.startsWith("activation-") && it.extension == "tmp" }
?.forEach { temporary ->
require(temporary.delete()) { "mobile_web_cache_cleanup_failed" }
require(removeMobileWebCacheTree(temporary, cacheRoot)) {
"mobile_web_cache_cleanup_failed"
}
}
}
} catch (error: Exception) {
@@ -591,9 +620,7 @@ internal class MobileWebPackageStore internal constructor(
}
private fun logicalByteLength(root: File): Long {
if (!root.exists()) return 0
if (root.isFile) return root.length()
return root.walkTopDown().filter { it.isFile }.sumOf { it.length() }
return mobileWebCacheLogicalByteLength(root, cacheRoot)
}
private fun validatedHostKey(hostIdentity: String): String {
@@ -0,0 +1,136 @@
package expo.modules.mobilewebshell
import java.io.File
import java.io.RandomAccessFile
import java.nio.file.Files
import java.nio.file.LinkOption
import java.security.MessageDigest
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
class MobileWebCacheCleanupBoundaryTest {
@get:Rule
val temporary = TemporaryFolder()
@Test
fun cleanupAndHostRemovalDoNotFollowSymbolicLinks() {
val cacheRoot = temporary.newFolder("cache")
val externalRoot = temporary.newFolder("external")
val sentinel = File(externalRoot, "sentinel").apply { writeText("keep") }
val hostRoot = File(cacheRoot, sha256Hex("paired-host".toByteArray()))
val stagingRoot = File(hostRoot, "staging")
require(stagingRoot.mkdirs())
val orphanLink = File(stagingRoot, "orphan")
Files.createSymbolicLink(orphanLink.toPath(), externalRoot.toPath())
val store = MobileWebPackageStore(cacheRoot)
assertTrue(sentinel.exists())
assertFalse(Files.exists(orphanLink.toPath(), LinkOption.NOFOLLOW_LINKS))
val hostLink = File(hostRoot, "linked-external")
Files.createSymbolicLink(hostLink.toPath(), externalRoot.toPath())
store.removeHost("paired-host")
assertTrue(sentinel.exists())
assertFalse(hostRoot.exists())
}
@Test
fun orphanTreeCleanupDoesNotFollowNestedSymbolicLinks() {
val cacheRoot = temporary.newFolder("cache-nested")
val externalRoot = temporary.newFolder("external-nested")
val sentinel = File(externalRoot, "sentinel").apply { writeText("keep") }
val hostRoot = File(cacheRoot, sha256Hex("nested-host".toByteArray()))
val orphanRoot = File(hostRoot, "staging/orphan")
require(orphanRoot.mkdirs())
File(orphanRoot, "local").writeText("remove")
Files.createSymbolicLink(File(orphanRoot, "external").toPath(), externalRoot.toPath())
MobileWebPackageStore(cacheRoot)
assertTrue(sentinel.exists())
assertFalse(orphanRoot.exists())
}
@Test
fun cleanupRejectsLiveStageReplacedBySymbolicLink() {
val cacheRoot = temporary.newFolder("cache-live")
val externalRoot = temporary.newFolder("external-live")
val sentinel = File(externalRoot, "sentinel").apply { writeText("keep") }
val fixture = packageFixture()
val store = MobileWebPackageStore(cacheRoot)
val firstStage = store.beginStage("live-host", fixture.first, fixture.second)
val hostRoot = File(cacheRoot, sha256Hex("live-host".toByteArray()))
val stageRoot = requireNotNull(File(hostRoot, "staging").listFiles()?.single())
assertTrue(stageRoot.deleteRecursively())
Files.createSymbolicLink(stageRoot.toPath(), externalRoot.toPath())
val secondStage = store.beginStage("live-host", fixture.first, fixture.second)
assertTrue(sentinel.exists())
assertFalse(Files.exists(stageRoot.toPath(), LinkOption.NOFOLLOW_LINKS))
store.abortStage(firstStage)
store.abortStage(secondStage)
}
@Test
fun quotaAccountingIgnoresLinkedExternalGenerationBytes() {
val cacheRoot = temporary.newFolder("cache-quota")
val externalRoot = temporary.newFolder("external-quota")
val sentinel = File(externalRoot, "sentinel")
RandomAccessFile(sentinel, "rw").use {
it.setLength(MOBILE_WEB_GLOBAL_CACHE_BYTE_LIMIT + 1)
}
val hostRoot = File(cacheRoot, sha256Hex("quota-host".toByteArray()))
val generationsRoot = File(hostRoot, "generations")
require(generationsRoot.mkdirs())
val linkedGeneration = File(generationsRoot, "a".repeat(64))
Files.createSymbolicLink(linkedGeneration.toPath(), externalRoot.toPath())
assertTrue(sentinel.exists())
assertTrue(mobileWebCacheLogicalByteLength(hostRoot, cacheRoot) == 0L)
}
@Test
fun hostRemovalDeletesDanglingSymbolicLink() {
val cacheRoot = temporary.newFolder("cache-dangling")
val hostRoot = File(cacheRoot, sha256Hex("dangling-host".toByteArray()))
Files.createSymbolicLink(
hostRoot.toPath(),
File(temporary.root, "missing-target").toPath()
)
val store = MobileWebPackageStore(cacheRoot)
store.removeHost("dangling-host")
assertFalse(Files.exists(hostRoot.toPath(), LinkOption.NOFOLLOW_LINKS))
}
private fun sha256Hex(bytes: ByteArray): String =
MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
private fun packageFixture(): Pair<String, String> {
val bytes = "<!doctype html><title>Orca</title>".toByteArray()
val asset = JSONObject()
.put("path", "index.html")
.put("sha256", sha256Hex(bytes))
.put("byteLength", bytes.size)
.put("contentType", "text/html; charset=utf-8")
.put("role", "document")
val canonical = JSONObject()
.put("schemaVersion", 1)
.put("bridge", JSONObject().put("minimum", 1).put("testedThrough", 1))
.put("entrypoint", "index.html")
.put("totalBytes", bytes.size)
.put("assets", JSONArray().put(asset))
.toString()
val manifest = JSONObject(canonical)
.put("buildId", sha256Hex(canonical.toByteArray()))
.toString()
return manifest to canonical
}
}
@@ -0,0 +1,155 @@
import CryptoKit
import Foundation
enum MobileWebCacheCleanupBoundaryTests {
static func run(root: URL) throws {
let cacheRoot = root.appendingPathComponent("cache")
let externalRoot = root.appendingPathComponent("external")
try FileManager.default.createDirectory(at: externalRoot, withIntermediateDirectories: true)
let sentinel = externalRoot.appendingPathComponent("sentinel")
try Data("keep".utf8).write(to: sentinel)
let hostRoot =
cacheRoot
.appendingPathComponent(sha256Hex(Data("paired-host".utf8)))
let stagingRoot = hostRoot.appendingPathComponent("staging")
try FileManager.default.createDirectory(at: stagingRoot, withIntermediateDirectories: true)
let orphanLink = stagingRoot.appendingPathComponent("orphan")
try FileManager.default.createSymbolicLink(
at: orphanLink,
withDestinationURL: externalRoot
)
let store = MobileWebPackageStore(cacheRoot: cacheRoot)
precondition(FileManager.default.fileExists(atPath: sentinel.path))
precondition(!FileManager.default.fileExists(atPath: orphanLink.path))
let hostLink = hostRoot.appendingPathComponent("linked-external")
try FileManager.default.createSymbolicLink(
at: hostLink,
withDestinationURL: externalRoot
)
try store.removeHost(hostIdentity: "paired-host")
precondition(FileManager.default.fileExists(atPath: sentinel.path))
precondition(!FileManager.default.fileExists(atPath: hostRoot.path))
try verifyNestedCleanup(root: root)
try verifyLiveStageReplacement(root: root)
try verifyDanglingHostRemoval(root: root)
}
private static func verifyNestedCleanup(root: URL) throws {
let cacheRoot = root.appendingPathComponent("cache-nested")
let externalRoot = root.appendingPathComponent("external-nested")
try FileManager.default.createDirectory(at: externalRoot, withIntermediateDirectories: true)
let sentinel = externalRoot.appendingPathComponent("sentinel")
try Data("keep".utf8).write(to: sentinel)
let orphanRoot =
cacheRoot
.appendingPathComponent(sha256Hex(Data("nested-host".utf8)))
.appendingPathComponent("staging/orphan")
try FileManager.default.createDirectory(at: orphanRoot, withIntermediateDirectories: true)
try Data("remove".utf8).write(to: orphanRoot.appendingPathComponent("local"))
try FileManager.default.createSymbolicLink(
at: orphanRoot.appendingPathComponent("external"),
withDestinationURL: externalRoot
)
_ = MobileWebPackageStore(cacheRoot: cacheRoot)
precondition(FileManager.default.fileExists(atPath: sentinel.path))
precondition(!FileManager.default.fileExists(atPath: orphanRoot.path))
}
private static func verifyLiveStageReplacement(root: URL) throws {
let cacheRoot = root.appendingPathComponent("cache-live")
let externalRoot = root.appendingPathComponent("external-live")
try FileManager.default.createDirectory(at: externalRoot, withIntermediateDirectories: true)
let sentinel = externalRoot.appendingPathComponent("sentinel")
try Data("keep".utf8).write(to: sentinel)
let fixture = try packageFixture()
let store = MobileWebPackageStore(cacheRoot: cacheRoot)
let firstStage = try store.beginStage(
hostIdentity: "live-host",
manifestJson: fixture.manifest,
canonicalManifestJson: fixture.canonical
)
let stagingRoot =
cacheRoot
.appendingPathComponent(sha256Hex(Data("live-host".utf8)))
.appendingPathComponent("staging")
let stageRoot = try FileManager.default.contentsOfDirectory(
at: stagingRoot,
includingPropertiesForKeys: nil
).first!
try FileManager.default.removeItem(at: stageRoot)
try FileManager.default.createSymbolicLink(at: stageRoot, withDestinationURL: externalRoot)
let secondStage = try store.beginStage(
hostIdentity: "live-host",
manifestJson: fixture.manifest,
canonicalManifestJson: fixture.canonical
)
precondition(FileManager.default.fileExists(atPath: sentinel.path))
precondition(
(try? FileManager.default.destinationOfSymbolicLink(atPath: stageRoot.path)) == nil
)
store.abortStage(stageId: firstStage)
store.abortStage(stageId: secondStage)
}
private static func verifyDanglingHostRemoval(root: URL) throws {
let cacheRoot = root.appendingPathComponent("cache-dangling")
try FileManager.default.createDirectory(at: cacheRoot, withIntermediateDirectories: true)
let hostRoot =
cacheRoot
.appendingPathComponent(sha256Hex(Data("dangling-host".utf8)))
try FileManager.default.createSymbolicLink(
at: hostRoot,
withDestinationURL: root.appendingPathComponent("missing-target")
)
let store = MobileWebPackageStore(cacheRoot: cacheRoot)
try store.removeHost(hostIdentity: "dangling-host")
precondition(
(try? FileManager.default.destinationOfSymbolicLink(atPath: hostRoot.path)) == nil
)
}
private static func sha256Hex(_ data: Data) -> String {
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
}
private static func packageFixture() throws -> (manifest: String, canonical: String) {
let bytes = Data("<!doctype html><title>Orca</title>".utf8)
let canonicalObject: [String: Any] = [
"schemaVersion": 1,
"bridge": ["minimum": 1, "testedThrough": 1],
"entrypoint": "index.html",
"totalBytes": bytes.count,
"assets": [
[
"path": "index.html",
"sha256": sha256Hex(bytes),
"byteLength": bytes.count,
"contentType": "text/html; charset=utf-8",
"role": "document",
]
],
]
let canonicalData = try JSONSerialization.data(
withJSONObject: canonicalObject,
options: [.sortedKeys]
)
let canonical = String(decoding: canonicalData, as: UTF8.self)
var manifestObject = canonicalObject
manifestObject["buildId"] = sha256Hex(canonicalData)
let manifestData = try JSONSerialization.data(
withJSONObject: manifestObject,
options: [.sortedKeys]
)
return (String(decoding: manifestData, as: UTF8.self), canonical)
}
}
@@ -32,6 +32,9 @@ enum MobileWebPackageStoreTests {
try MobileWebCacheFileBoundaryTests.run(
root: root.appendingPathComponent("cache-file-boundary")
)
try MobileWebCacheCleanupBoundaryTests.run(
root: root.appendingPathComponent("cache-cleanup-boundary")
)
try rejectsLowStorage(root: root.appendingPathComponent("low-storage"))
try evictsUnprotectedGeneration(root: root.appendingPathComponent("eviction"))
try evictsAnotherHostForGlobalQuota(root: root.appendingPathComponent("global-eviction"))
@@ -39,20 +39,25 @@ private func requireMobileWebRegularFile(
within cacheRoot: URL,
errorCode: String
) throws {
guard isMobileWebUnlinkedPath(url, within: cacheRoot) else {
throw MobileWebStoreError(errorCode)
}
let values = try url.resourceValues(forKeys: [.isRegularFileKey])
guard values.isRegularFile == true else {
throw MobileWebStoreError(errorCode)
}
}
func isMobileWebUnlinkedPath(_ url: URL, within cacheRoot: URL) -> Bool {
let root = cacheRoot.standardizedFileURL
let file = url.standardizedFileURL
let prefix = root.path.hasSuffix("/") ? root.path : root.path + "/"
guard file.path.hasPrefix(prefix) else {
throw MobileWebStoreError(errorCode)
}
guard file.path.hasPrefix(prefix) else { return false }
let relativePath = String(file.path.dropFirst(prefix.count))
let resolvedRoot = root.resolvingSymlinksInPath().standardizedFileURL
let expected = relativePath.split(separator: "/").reduce(resolvedRoot) { parent, component in
parent.appendingPathComponent(String(component), isDirectory: false)
}
let resolvedFile = file.resolvingSymlinksInPath().standardizedFileURL
let values = try resolvedFile.resourceValues(forKeys: [.isRegularFileKey])
guard resolvedFile.path == expected.standardizedFileURL.path, values.isRegularFile == true else {
throw MobileWebStoreError(errorCode)
}
return resolvedFile.path == expected.standardizedFileURL.path
}
@@ -481,7 +481,8 @@ final class MobileWebPackageStore {
sessions.removeValue(forKey: sessionId)
}
let root = try cacheRoot().appendingPathComponent(hostKey, isDirectory: true)
if fileManager.fileExists(atPath: root.path) {
let isSymbolicLink = (try? fileManager.destinationOfSymbolicLink(atPath: root.path)) != nil
if fileManager.fileExists(atPath: root.path) || isSymbolicLink {
try fileManager.removeItem(at: root)
}
}
@@ -802,7 +803,9 @@ final class MobileWebPackageStore {
includingPropertiesForKeys: [.isDirectoryKey]
) {
for stagedRoot in stagedRoots
where !liveStageRoots.contains(stagedRoot.standardizedFileURL.path) {
where !liveStageRoots.contains(stagedRoot.standardizedFileURL.path)
|| !isMobileWebUnlinkedPath(stagedRoot, within: cacheRoot)
{
try fileManager.removeItem(at: stagedRoot)
}
}
@@ -32,6 +32,10 @@ try {
mobileRoot,
'packages/expo-mobile-web-shell/ios-tests/MobileWebCacheFileBoundaryTests.swift'
),
join(
mobileRoot,
'packages/expo-mobile-web-shell/ios-tests/MobileWebCacheCleanupBoundaryTests.swift'
),
join(
mobileRoot,
'packages/expo-mobile-web-shell/ios-tests/MobileWebPackageStoreProcessInterruptionTests.swift'