diff --git a/app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt b/app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt index 414efaaedb..7d81ff15d1 100644 --- a/app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt +++ b/app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt @@ -1263,15 +1263,8 @@ object EpicCloudSavesManager { } // Resolve against on-disk casing to avoid creating duplicate dirs (e.g. locallow vs LocalLow). - val joinedPath = "/${canonicalizeAppDataSegments(normalizedParts).joinToString("/")}" - val trustedRoots = buildList { - add(usersPath) - add(File(winePrefix)) - if (installDir.isNotEmpty()) { - add(File(installDir)) - } - } - val resolved = resolveAbsolutePathCaseInsensitive(joinedPath, trustedRoots) + val joinedPath = normalizedParts.joinToString("/") + val resolved = FileUtils.resolveCaseInsensitive(File("/"), joinedPath) // guard against path traversal escaping the wine prefix val absPath = resolved.absolutePath val withinPrefix = absPath.startsWith("$winePrefix/") || absPath == winePrefix || @@ -1329,51 +1322,6 @@ object EpicCloudSavesManager { return actualPath } - internal fun resolveAbsolutePathCaseInsensitive( - path: String, - trustedRoots: List = emptyList(), - ): File { - val normalizedPath = path.replace('\\', '/') - trustedRoots.firstNotNullOfOrNull { root -> - val rootPath = root.absolutePath.replace('\\', '/').trimEnd('/') - if (normalizedPath == rootPath || normalizedPath.startsWith("$rootPath/")) { - val relativePath = normalizedPath.removePrefix(rootPath).trimStart('/') - FileUtils.resolveCaseInsensitive(root, relativePath) - } else { - null - } - }?.let { return it } - - val pathFile = File(normalizedPath) - val rootPath = pathFile.toPath().root?.toString()?.replace('\\', '/') - val base = when { - pathFile.isAbsolute && rootPath != null -> File(rootPath) - normalizedPath.startsWith("/") -> File("/") - else -> File("") - } - val relativePath = when { - pathFile.isAbsolute && rootPath != null -> normalizedPath.removePrefix(rootPath).trimStart('/') - else -> normalizedPath.trimStart('/') - } - return FileUtils.resolveCaseInsensitive(base, relativePath) - } - - // Fixes issue where saves were being lost due to inconsistencies in lower-case sub-folders in AppData - internal fun canonicalizeAppDataSegments(segments: List): List { - return segments.mapIndexed { index, segment -> - if (index > 0 && segments[index - 1].equals("AppData", ignoreCase = true)) { - when (segment.lowercase()) { - "local" -> "Local" - "locallow" -> "LocalLow" - "roaming" -> "Roaming" - else -> segment - } - } else { - segment - } - } - } - private fun getSyncTimestamp(context: Context, appId: Int): String? { val prefs = context.getSharedPreferences("epic_cloud_saves", Context.MODE_PRIVATE) return prefs.getString("sync_timestamp_$appId", null) diff --git a/app/src/main/java/app/gamenative/utils/FileUtils.kt b/app/src/main/java/app/gamenative/utils/FileUtils.kt index 7ad8339fa8..90bde3d2ec 100644 --- a/app/src/main/java/app/gamenative/utils/FileUtils.kt +++ b/app/src/main/java/app/gamenative/utils/FileUtils.kt @@ -226,15 +226,9 @@ object FileUtils { fun resolveCaseInsensitive(baseDir: File, relativePath: String): File { val segments = relativePath.replace('\\', '/').split('/').filter { it.isNotEmpty() } var current = baseDir - for ((i, segment) in segments.withIndex()) { + for (segment in segments) { val match = current.listFiles()?.firstOrNull { it.name.equals(segment, ignoreCase = true) } - if (match != null) { - current = match - } else { - // append remaining segments verbatim - for (j in i until segments.size) current = File(current, segments[j]) - return current - } + current = match ?: File(current, segment) } return current } diff --git a/app/src/test/java/app/gamenative/service/epic/EpicCloudSavesTest.kt b/app/src/test/java/app/gamenative/service/epic/EpicCloudSavesTest.kt index aed62ba000..5ff138932d 100644 --- a/app/src/test/java/app/gamenative/service/epic/EpicCloudSavesTest.kt +++ b/app/src/test/java/app/gamenative/service/epic/EpicCloudSavesTest.kt @@ -9,6 +9,7 @@ import app.gamenative.service.epic.manifest.EpicManifest import app.gamenative.service.epic.manifest.FileManifest import app.gamenative.service.epic.manifest.FileManifestList import app.gamenative.service.epic.manifest.ManifestMeta +import app.gamenative.utils.FileUtils import java.io.File import java.nio.ByteBuffer import java.nio.ByteOrder @@ -92,22 +93,21 @@ class EpicCloudSavesTest { .absolutePath .replace('\\', '/') - val resolved = EpicCloudSavesManager.resolveAbsolutePathCaseInsensitive( - metadataPath, - trustedRoots = listOf(wineUserDir), - ) + val resolved = FileUtils.resolveCaseInsensitive(File("/"), metadataPath) assertEquals(saveDir.absolutePath, resolved.absolutePath) assertTrue(resolved.exists()) } @Test - fun `AppData child segments are canonicalized before creating save paths`() { - val segments = listOf("data", "prefix", "AppData", "locallow", "ZAUM Studio") + fun `resolveCaseInsensitive keeps matching after an unmatched parent segment`() { + val base = tmpDir.newFolder("base") + val deep = File(base, "Existing/Nested/LocalLow").apply { mkdirs() } - val canonicalized = EpicCloudSavesManager.canonicalizeAppDataSegments(segments) + val resolved = FileUtils.resolveCaseInsensitive(base, "existing/nested/locallow") - assertEquals(listOf("data", "prefix", "AppData", "LocalLow", "ZAUM Studio"), canonicalized) + assertEquals(deep.absolutePath, resolved.absolutePath) + assertTrue(resolved.exists()) } @Test