From be7a2f044f99f8c92a1893d5ba7100173dd61f2a Mon Sep 17 00:00:00 2001 From: Jeremy Bernstein Date: Fri, 27 Mar 2026 10:45:47 +0100 Subject: [PATCH] fix: resolve cloud save paths case-insensitively to avoid duplicate dirs --- .../app/gamenative/service/SteamService.kt | 35 +---- .../service/epic/EpicCloudSavesManager.kt | 27 +++- .../service/gog/GOGCloudSavesManager.kt | 5 +- .../utils/CaseInsensitiveFileSystem.kt | 50 +++++++ .../java/app/gamenative/utils/FileUtils.kt | 28 +++- .../utils/CaseInsensitiveFileSystemTest.kt | 123 +++++++++++++++++ .../CaseInsensitivePathResolutionTest.kt | 124 ++++++++++++++++++ gradle/libs.versions.toml | 2 +- 8 files changed, 355 insertions(+), 39 deletions(-) create mode 100644 app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt create mode 100644 app/src/test/java/app/gamenative/utils/CaseInsensitiveFileSystemTest.kt create mode 100644 app/src/test/java/app/gamenative/utils/CaseInsensitivePathResolutionTest.kt diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index b9c30ef760..9c66edae78 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -47,7 +47,9 @@ import app.gamenative.enums.SaveLocation import app.gamenative.enums.SyncResult import app.gamenative.events.AndroidEvent import app.gamenative.events.SteamEvent +import app.gamenative.utils.CaseInsensitiveFileSystem import app.gamenative.utils.ContainerUtils +import app.gamenative.utils.FileUtils import app.gamenative.utils.LicenseSerializer import app.gamenative.utils.MarkerUtils import app.gamenative.utils.Net @@ -1294,7 +1296,7 @@ class SteamService : Service(), IChallengeUrlChanged { .orEmpty() if (manifestPath.isEmpty()) return null - return resolvePathCaseInsensitive(appDirPath, manifestPath) + return FileUtils.findFileCaseInsensitive(File(appDirPath), manifestPath) } private fun loadConfigFromManifest( @@ -1347,7 +1349,7 @@ class SteamService : Service(), IChallengeUrlChanged { val configPath = pathNode.asString().orEmpty() if (pathNode === KeyValue.INVALID || configPath.isEmpty()) continue - val configFile = resolvePathCaseInsensitive(manifestDirPath, configPath) + val configFile = FileUtils.findFileCaseInsensitive(File(manifestDirPath), configPath) ?: continue return configFile.readText(Charsets.UTF_8) } @@ -1360,34 +1362,6 @@ class SteamService : Service(), IChallengeUrlChanged { } } - private fun resolvePathCaseInsensitive( - baseDirPath: String, - relativePath: String, - ): File? { - val directFile = File(baseDirPath, relativePath) - if (directFile.exists()) return directFile - - var currentDir = File(baseDirPath) - if (!currentDir.exists() || !currentDir.isDirectory) return null - - val segments = relativePath.split('/', '\\').filter { it.isNotEmpty() } - for ((index, segment) in segments.withIndex()) { - val entries = currentDir.listFiles() ?: return null - val matched = entries.firstOrNull { - it.name.equals(segment, ignoreCase = true) - } ?: return null - - if (index == segments.lastIndex) { - return matched - } - - if (!matched.isDirectory) return null - currentDir = matched - } - - return null - } - private fun readBuiltInSteamInputTemplate(fileName: String): String? { val assets = instance?.assets ?: return null return runCatching { @@ -1581,6 +1555,7 @@ class SteamService : Service(), IChallengeUrlChanged { maxDecompress = maxDecompress, parentJob = coroutineContext[Job], autoStartDownload = false, + filesystem = CaseInsensitiveFileSystem(), ) // Create listeners for DLC apps 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 abe280eea5..711bfb7108 100644 --- a/app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt +++ b/app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt @@ -3,6 +3,7 @@ package app.gamenative.service.epic import android.content.Context import app.gamenative.data.EpicGame import app.gamenative.service.epic.manifest.EpicManifest +import app.gamenative.utils.FileUtils import app.gamenative.utils.Net import java.io.File import java.time.Instant @@ -474,6 +475,10 @@ object EpicCloudSavesManager { if (toDownload.contains(fileManifest.filename)) { try { val outputFile = File(saveDir, fileManifest.filename) + if (!outputFile.canonicalPath.startsWith(saveDir.canonicalPath)) { + Timber.tag("Epic").w("[Cloud Saves] Skipping path traversal: ${fileManifest.filename}") + return@forEach + } outputFile.parentFile?.mkdirs() Timber.tag("Epic").d("[Cloud Saves] Reconstructing file: ${fileManifest.filename}") @@ -640,6 +645,10 @@ object EpicCloudSavesManager { manifest.fileManifestList?.elements?.forEach { fileManifest -> try { val outputFile = File(saveDir, fileManifest.filename) + if (!outputFile.canonicalPath.startsWith(saveDir.canonicalPath)) { + Timber.tag("Epic").w("[Cloud Saves] Skipping path traversal: ${fileManifest.filename}") + return@forEach + } outputFile.parentFile?.mkdirs() Timber.tag("Epic").d("[Cloud Saves] Reconstructing file: ${fileManifest.filename}") @@ -1187,9 +1196,10 @@ object EpicCloudSavesManager { Timber.tag("Epic").d("[Cloud Saves] Using Wine prefix: $winePrefix") // Resolve path variables used by Epic Games (case-insensitive) + val installDir = game.installPath.ifEmpty { EpicConstants.getGameInstallPath(context, game.appName) } val pathVars = mutableMapOf( "{epicid}" to accountId, - "{installdir}" to (game.installPath.ifEmpty { EpicConstants.getGameInstallPath(context, game.appName) }), + "{installdir}" to installDir, "{appname}" to game.appName, ) @@ -1252,7 +1262,20 @@ object EpicCloudSavesManager { } } - val finalPath = File(normalizedParts.joinToString("/")) + // resolve against on-disk casing to avoid creating duplicate dirs (e.g. locallow vs LocalLow) + // supersedes PR #701 + 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 || + (installDir.isNotEmpty() && (absPath.startsWith("$installDir/") || absPath == installDir)) + val finalPath = if (withinPrefix) { + resolved + } else { + Timber.tag("Epic").w("[Cloud Saves] Resolved path outside prefix, ignoring: ${resolved.absolutePath}") + return null + } // Check subdirectories for save files // Some games store saves in user-specific subdirectories (e.g., "0/", "1/", etc.) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt index eeac19fe5e..aaba899bde 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt @@ -1,6 +1,7 @@ package app.gamenative.service.gog import android.content.Context +import app.gamenative.utils.FileUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType @@ -521,8 +522,8 @@ class GOGCloudSavesManager( val bytes = response.body?.bytes() ?: return@withContext Timber.tag("GOG-CloudSaves").d("Downloaded ${bytes.size} bytes for ${file.relativePath}") - // Save to local file - val localFile = File(syncDir, file.relativePath) + // resolve against on-disk casing to avoid creating duplicate dirs + val localFile = FileUtils.resolveCaseInsensitive(syncDir, file.relativePath) localFile.parentFile?.mkdirs() FileOutputStream(localFile).use { fos -> diff --git a/app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt b/app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt new file mode 100644 index 0000000000..759f1bb099 --- /dev/null +++ b/app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt @@ -0,0 +1,50 @@ +package app.gamenative.utils + +import okio.FileSystem +import okio.ForwardingFileSystem +import okio.Path +import java.util.concurrent.ConcurrentHashMap + +/** + * Okio [FileSystem] wrapper that resolves each path component against on-disk + * casing before delegating to [FileSystem.SYSTEM]. Prevents duplicate directories + * when Steam depot manifests use different casing than what's already installed + * (e.g. DLC referencing `_Work` when the base game created `_work`). + * + * Resolved segments are cached for the lifetime of this instance (one download + * session) so repeated lookups for the same parent+segment are O(1). + */ +class CaseInsensitiveFileSystem( + delegate: FileSystem = SYSTEM, +) : ForwardingFileSystem(delegate) { + + // (parent, lowercased segment) → resolved child path. + // keyed by lowercase so all casing variants ("Saves", "saves", "SAVES") hit + // the same entry. computeIfAbsent is atomic on ConcurrentHashMap, so + // concurrent threads won't race to create duplicate directories. + private val cache = ConcurrentHashMap, Path>() + + override fun onPathParameter(path: Path, functionName: String, parameterName: String): Path { + val root = path.root ?: return path + var resolved = root + for (segment in path.segments) { + val key = resolved to segment.lowercase() + resolved = cache.computeIfAbsent(key) { + // fast path: exact casing exists + val exact = resolved / segment + if (delegate.metadataOrNull(exact) != null) { + exact + } else { + // slow path: list parent and match case-insensitively. + // if multiple entries match (e.g. leftover _Work + _work from a + // prior bug), the first one returned by the filesystem wins — + // non-deterministic but unavoidable without deeper heuristics. + delegate.listOrNull(resolved) + ?.firstOrNull { it.name.equals(segment, ignoreCase = true) } + ?: exact + } + } + } + return resolved + } +} diff --git a/app/src/main/java/app/gamenative/utils/FileUtils.kt b/app/src/main/java/app/gamenative/utils/FileUtils.kt index 926d61ac23..74bc64a602 100644 --- a/app/src/main/java/app/gamenative/utils/FileUtils.kt +++ b/app/src/main/java/app/gamenative/utils/FileUtils.kt @@ -210,12 +210,32 @@ object FileUtils { * Info file may list e.g. "checkapplication.exe" while the actual file is "CheckApplication.exe" (Linux/Android are case-sensitive). */ fun findFileCaseInsensitive(baseDir: File, relativePath: String): File? { + // fast path: exact casing matches (common case, single stat vs N listFiles) + val direct = File(baseDir, relativePath) + if (direct.exists()) return direct + return resolveCaseInsensitive(baseDir, relativePath).takeIf { it.exists() } + } + + /** + * Resolves a relative path against [baseDir] using case-insensitive matching + * for each segment. Existing segments are matched against on-disk casing; + * remaining (non-existent) segments are appended with their original casing. + * Never returns null — safe for new files whose parent dirs may already exist + * with different casing. + */ + fun resolveCaseInsensitive(baseDir: File, relativePath: String): File { val segments = relativePath.replace('\\', '/').split('/').filter { it.isNotEmpty() } var current = baseDir - for (segment in segments) { - val match = current.listFiles()?.firstOrNull { it.name.equals(segment, ignoreCase = true) } ?: return null - current = match + for ((i, segment) in segments.withIndex()) { + 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 + } } - return current.takeIf { it.exists() } + return current } } diff --git a/app/src/test/java/app/gamenative/utils/CaseInsensitiveFileSystemTest.kt b/app/src/test/java/app/gamenative/utils/CaseInsensitiveFileSystemTest.kt new file mode 100644 index 0000000000..0054bbb008 --- /dev/null +++ b/app/src/test/java/app/gamenative/utils/CaseInsensitiveFileSystemTest.kt @@ -0,0 +1,123 @@ +package app.gamenative.utils + +import okio.Path.Companion.toOkioPath +import okio.Path.Companion.toPath +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import java.io.File + +/** + * Simulates a depot download where manifests reference the same directory tree + * with different casing (e.g. base game creates "Game/_work", DLC references + * "Game/_Work"). Verifies CaseInsensitiveFileSystem resolves both to a single + * on-disk directory. + * + * LIMITATION: on macOS (case-insensitive FS) these tests pass trivially because + * the OS itself prevents duplicate-cased dirs. The tests are meaningful on + * Linux/Android (case-sensitive FS) where duplicates would actually be created + * without CaseInsensitiveFileSystem. CI typically runs on Linux. + */ +class CaseInsensitiveFileSystemTest { + + private lateinit var tmpDir: File + private lateinit var fs: CaseInsensitiveFileSystem + + @Before + fun setUp() { + tmpDir = createTempDir("depot_test") + fs = CaseInsensitiveFileSystem() + } + + @After + fun tearDown() { + tmpDir.deleteRecursively() + } + + @Test + fun `mixed-case writes land in single directory tree`() { + val root = tmpDir.toOkioPath() + + // base game creates directories with one casing + val baseDir = root / "steamapps" / "common" / "MyGame" / "_work" / "data" + fs.createDirectories(baseDir) + fs.write(baseDir / "base.pak") { writeUtf8("base") } + + // DLC references same tree with different casing + val dlcDir = root / "steamapps" / "common" / "MyGame" / "_Work" / "Data" + fs.createDirectories(dlcDir) + fs.write(dlcDir / "dlc.pak") { writeUtf8("dlc") } + + // verify only one directory tree exists on disk + val gameDir = File(tmpDir, "steamapps/common/MyGame") + val subdirs = gameDir.listFiles()?.filter { it.isDirectory } ?: emptyList() + + // on case-sensitive FS with the fix: 1 dir. on case-insensitive FS: trivially 1 dir. + // without the fix on case-sensitive FS: would be 2 (_work and _Work) + assertEquals( + "expected single directory, got: ${subdirs.map { it.name }}", + 1, + subdirs.size, + ) + + // both files should be reachable under the single tree + val workDir = subdirs[0] + val dataDir = workDir.listFiles()?.filter { it.isDirectory } ?: emptyList() + assertEquals( + "expected single data dir, got: ${dataDir.map { it.name }}", + 1, + dataDir.size, + ) + + val files = dataDir[0].listFiles()?.map { it.name }?.sorted() ?: emptyList() + assertEquals(listOf("base.pak", "dlc.pak"), files) + } + + @Test + fun `concurrent mixed-case writes from multiple threads`() { + val root = tmpDir.toOkioPath() + val base = root / "steamapps" / "common" / "TestGame" + fs.createDirectories(base) + + // simulate concurrent depot workers writing with different casing + val threads = listOf( + Thread { + val dir = base / "Saves" / "Profile" + fs.createDirectories(dir) + fs.write(dir / "slot1.sav") { writeUtf8("save1") } + }, + Thread { + val dir = base / "saves" / "profile" + fs.createDirectories(dir) + fs.write(dir / "slot2.sav") { writeUtf8("save2") } + }, + Thread { + val dir = base / "SAVES" / "PROFILE" + fs.createDirectories(dir) + fs.write(dir / "slot3.sav") { writeUtf8("save3") } + }, + ) + + threads.forEach { it.start() } + threads.forEach { it.join() } + + val gameDir = File(tmpDir, "steamapps/common/TestGame") + val saveDirs = gameDir.listFiles()?.filter { it.isDirectory } ?: emptyList() + assertEquals( + "expected single saves dir, got: ${saveDirs.map { it.name }}", + 1, + saveDirs.size, + ) + + val profileDirs = saveDirs[0].listFiles()?.filter { it.isDirectory } ?: emptyList() + assertEquals( + "expected single profile dir, got: ${profileDirs.map { it.name }}", + 1, + profileDirs.size, + ) + + val files = profileDirs[0].listFiles()?.map { it.name }?.sorted() ?: emptyList() + assertEquals(listOf("slot1.sav", "slot2.sav", "slot3.sav"), files) + } +} diff --git a/app/src/test/java/app/gamenative/utils/CaseInsensitivePathResolutionTest.kt b/app/src/test/java/app/gamenative/utils/CaseInsensitivePathResolutionTest.kt new file mode 100644 index 0000000000..a8f4330d0d --- /dev/null +++ b/app/src/test/java/app/gamenative/utils/CaseInsensitivePathResolutionTest.kt @@ -0,0 +1,124 @@ +package app.gamenative.utils + +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class CaseInsensitivePathResolutionTest { + + @get:Rule + val tmpDir = TemporaryFolder() + + // -- resolveCaseInsensitive -- + + @Test + fun `exact casing returns exact path`() { + val base = tmpDir.newFolder("game") + File(base, "Data/Saves").mkdirs() + + val result = FileUtils.resolveCaseInsensitive(base, "Data/Saves") + assertEquals(File(base, "Data/Saves").absolutePath, result.absolutePath) + } + + @Test + fun `wrong casing resolves to on-disk casing`() { + val base = tmpDir.newFolder("game") + File(base, "Data/Saves").mkdirs() + + val result = FileUtils.resolveCaseInsensitive(base, "data/saves") + assertEquals(File(base, "Data/Saves").absolutePath, result.absolutePath) + } + + @Test + fun `mixed casing resolves each segment independently`() { + val base = tmpDir.newFolder("game") + File(base, "LocalLow/CompanyName").mkdirs() + + val result = FileUtils.resolveCaseInsensitive(base, "locallow/companyname") + assertEquals(File(base, "LocalLow/CompanyName").absolutePath, result.absolutePath) + } + + @Test + fun `nonexistent segments appended with original casing`() { + val base = tmpDir.newFolder("game") + File(base, "Data").mkdirs() + + val result = FileUtils.resolveCaseInsensitive(base, "data/NewFolder/file.txt") + // Data exists → resolved to on-disk "Data" + // NewFolder and file.txt don't exist → appended verbatim + assertEquals(File(base, "Data/NewFolder/file.txt").absolutePath, result.absolutePath) + } + + @Test + fun `completely nonexistent path appended verbatim`() { + val base = tmpDir.newFolder("game") + + val result = FileUtils.resolveCaseInsensitive(base, "Foo/Bar/baz.txt") + assertEquals(File(base, "Foo/Bar/baz.txt").absolutePath, result.absolutePath) + } + + @Test + fun `backslash separators normalized`() { + val base = tmpDir.newFolder("game") + File(base, "Data/Saves").mkdirs() + + val result = FileUtils.resolveCaseInsensitive(base, "data\\saves") + assertEquals(File(base, "Data/Saves").absolutePath, result.absolutePath) + } + + @Test + fun `empty relative path returns base dir`() { + val base = tmpDir.newFolder("game") + val result = FileUtils.resolveCaseInsensitive(base, "") + assertEquals(base.absolutePath, result.absolutePath) + } + + // -- findFileCaseInsensitive -- + + @Test + fun `findFile exact casing returns file`() { + val base = tmpDir.newFolder("game") + val file = File(base, "CheckApplication.exe") + file.createNewFile() + + val result = FileUtils.findFileCaseInsensitive(base, "CheckApplication.exe") + assertNotNull(result) + assertEquals(file.absolutePath, result!!.absolutePath) + } + + @Test + fun `findFile wrong casing resolves to existing file`() { + val base = tmpDir.newFolder("game") + val created = File(base, "CheckApplication.exe") + created.createNewFile() + + val result = FileUtils.findFileCaseInsensitive(base, "checkapplication.exe") + assertNotNull(result) + // on case-insensitive FS (macOS) the fast path returns query casing; + // on case-sensitive FS (Android/Linux) the slow path resolves to on-disk casing. + // both point to the same file — assert via canonical path. + assertEquals(created.canonicalPath, result!!.canonicalPath) + } + + @Test + fun `findFile nonexistent returns null`() { + val base = tmpDir.newFolder("game") + val result = FileUtils.findFileCaseInsensitive(base, "nope.exe") + assertNull(result) + } + + @Test + fun `findFile nested wrong casing resolves to existing file`() { + val base = tmpDir.newFolder("game") + val dir = File(base, "Data/SaveFiles") + dir.mkdirs() + val created = File(dir, "slot1.sav") + created.createNewFile() + + val result = FileUtils.findFileCaseInsensitive(base, "data/savefiles/slot1.sav") + assertNotNull(result) + assertEquals(created.canonicalPath, result!!.canonicalPath) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fc41eacae8..f187fc9fc5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,7 +12,7 @@ espressoCore = "3.6.1" # https://mvnrepository.com/artifact/androidx.test.espres feature-delivery = "2.1.0" # https://mvnrepository.com/artifact/com.google.android.play/feature-delivery play-integrity = "1.6.0" # https://mvnrepository.com/artifact/com.google.android.play/integrity hiltNavigationCompose = "1.2.0" # https://mvnrepository.com/artifact/androidx.hilt/hilt-navigation-compose -javasteam = "1.8.0.1-13-SNAPSHOT" # https://mvnrepository.com/artifact/in.dragonbra/javasteam +javasteam = "1.8.0.1-14-SNAPSHOT" # https://mvnrepository.com/artifact/in.dragonbra/javasteam json = "1.8.0" # https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-serialization-json junit = "4.13.2" # https://mvnrepository.com/artifact/junit/junit junitVersion = "1.2.1" # https://mvnrepository.com/artifact/androidx.test.ext/junit