mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 00:03:15 +00:00
fix(mobile): prevent cache cleanup symlink traversal
This commit is contained in:
@@ -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,
|
||||
|
||||
+43
@@ -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
|
||||
}
|
||||
+43
-16
@@ -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 {
|
||||
|
||||
+136
@@ -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
|
||||
}
|
||||
}
|
||||
+155
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user