From 2d2584eb0d19bfed8c5a42bf0b3145edf8f72bbb Mon Sep 17 00:00:00 2001 From: OrcaWin <293788423+OrcaWin@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:55:01 -0700 Subject: [PATCH] fix(mobile): prevent cache cleanup symlink traversal --- ...hybrid-webview-implementation-checklist.md | 3 + ...-mobile-hybrid-webview-parity-inventory.md | 6 +- ...bile-hybrid-webview-single-pr-migration.md | 7 + ...27-mobile-hybrid-webview-remaining-work.md | 21 ++- .../MobileWebCacheTreeBoundary.kt | 43 +++++ .../mobilewebshell/MobileWebPackageStore.kt | 59 +++++-- .../MobileWebCacheCleanupBoundaryTest.kt | 136 +++++++++++++++ .../MobileWebCacheCleanupBoundaryTests.swift | 155 ++++++++++++++++++ .../MobileWebPackageStoreTests.swift | 3 + .../ios/MobileWebCacheFileBoundary.swift | 19 ++- .../ios/MobileWebPackageStore.swift | 7 +- .../run-ios-mobile-web-store-tests.mjs | 4 + 12 files changed, 434 insertions(+), 29 deletions(-) create mode 100644 mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebCacheTreeBoundary.kt create mode 100644 mobile/packages/expo-mobile-web-shell/android/src/test/java/expo/modules/mobilewebshell/MobileWebCacheCleanupBoundaryTest.kt create mode 100644 mobile/packages/expo-mobile-web-shell/ios-tests/MobileWebCacheCleanupBoundaryTests.swift diff --git a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-implementation-checklist.md b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-implementation-checklist.md index 5a1c2ea4457..ef5374c0baa 100644 --- a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-implementation-checklist.md +++ b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-implementation-checklist.md @@ -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. | diff --git a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-parity-inventory.md b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-parity-inventory.md index ef3b61b755b..7dbb0ad0afa 100644 --- a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-parity-inventory.md +++ b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-parity-inventory.md @@ -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 diff --git a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md index a4986bc6431..ec94a168095 100644 --- a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md +++ b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md @@ -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 diff --git a/docs/reference/plans/2026-07-27-mobile-hybrid-webview-remaining-work.md b/docs/reference/plans/2026-07-27-mobile-hybrid-webview-remaining-work.md index a60777a5153..0d25a948fb7 100644 --- a/docs/reference/plans/2026-07-27-mobile-hybrid-webview-remaining-work.md +++ b/docs/reference/plans/2026-07-27-mobile-hybrid-webview-remaining-work.md @@ -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, diff --git a/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebCacheTreeBoundary.kt b/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebCacheTreeBoundary.kt new file mode 100644 index 00000000000..fe59053a3cc --- /dev/null +++ b/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebCacheTreeBoundary.kt @@ -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 +} diff --git a/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebPackageStore.kt b/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebPackageStore.kt index 589877a966c..f6e6d90b684 100644 --- a/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebPackageStore.kt +++ b/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebPackageStore.kt @@ -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 = 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 { diff --git a/mobile/packages/expo-mobile-web-shell/android/src/test/java/expo/modules/mobilewebshell/MobileWebCacheCleanupBoundaryTest.kt b/mobile/packages/expo-mobile-web-shell/android/src/test/java/expo/modules/mobilewebshell/MobileWebCacheCleanupBoundaryTest.kt new file mode 100644 index 00000000000..f727b849f61 --- /dev/null +++ b/mobile/packages/expo-mobile-web-shell/android/src/test/java/expo/modules/mobilewebshell/MobileWebCacheCleanupBoundaryTest.kt @@ -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 { + val bytes = "Orca".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 + } +} diff --git a/mobile/packages/expo-mobile-web-shell/ios-tests/MobileWebCacheCleanupBoundaryTests.swift b/mobile/packages/expo-mobile-web-shell/ios-tests/MobileWebCacheCleanupBoundaryTests.swift new file mode 100644 index 00000000000..e151564875b --- /dev/null +++ b/mobile/packages/expo-mobile-web-shell/ios-tests/MobileWebCacheCleanupBoundaryTests.swift @@ -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("Orca".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) + } +} diff --git a/mobile/packages/expo-mobile-web-shell/ios-tests/MobileWebPackageStoreTests.swift b/mobile/packages/expo-mobile-web-shell/ios-tests/MobileWebPackageStoreTests.swift index 858a0b1ee5c..7ef96153e36 100644 --- a/mobile/packages/expo-mobile-web-shell/ios-tests/MobileWebPackageStoreTests.swift +++ b/mobile/packages/expo-mobile-web-shell/ios-tests/MobileWebPackageStoreTests.swift @@ -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")) diff --git a/mobile/packages/expo-mobile-web-shell/ios/MobileWebCacheFileBoundary.swift b/mobile/packages/expo-mobile-web-shell/ios/MobileWebCacheFileBoundary.swift index 35babd58b3d..b3927259fb8 100644 --- a/mobile/packages/expo-mobile-web-shell/ios/MobileWebCacheFileBoundary.swift +++ b/mobile/packages/expo-mobile-web-shell/ios/MobileWebCacheFileBoundary.swift @@ -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 } diff --git a/mobile/packages/expo-mobile-web-shell/ios/MobileWebPackageStore.swift b/mobile/packages/expo-mobile-web-shell/ios/MobileWebPackageStore.swift index c47d7a4ac0d..97e66f3c906 100644 --- a/mobile/packages/expo-mobile-web-shell/ios/MobileWebPackageStore.swift +++ b/mobile/packages/expo-mobile-web-shell/ios/MobileWebPackageStore.swift @@ -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) } } diff --git a/mobile/scripts/run-ios-mobile-web-store-tests.mjs b/mobile/scripts/run-ios-mobile-web-store-tests.mjs index 27decf89c7c..85d0723e6e8 100644 --- a/mobile/scripts/run-ios-mobile-web-store-tests.mjs +++ b/mobile/scripts/run-ios-mobile-web-store-tests.mjs @@ -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'