-
-
Notifications
You must be signed in to change notification settings - Fork 404
Fix case-sensitive filesystem creating duplicate directories #941
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<String, String>( | ||
| "{epicid}" to accountId, | ||
| "{installdir}" to (game.installPath.ifEmpty { EpicConstants.getGameInstallPath(context, game.appName) }), | ||
| "{installdir}" to installDir, | ||
| "{appname}" to game.appName, | ||
|
jeremybernstein marked this conversation as resolved.
|
||
| ) | ||
|
|
||
|
|
@@ -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)) | ||
|
jeremybernstein marked this conversation as resolved.
Comment on lines
+1265
to
+1272
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this something that's worth extracting out of here into another place? I imagine it's probably similar in GOG?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
coderabbitai[bot] marked this conversation as resolved.
|
||
| 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.) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) { | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| // (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<Pair<Path, String>, 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 | ||
|
Comment on lines
+38
to
+44
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. how slow is the "slow" path? Worried it might affect external storage badly. Having said that case sensitivitiy bugs are worse than slow SD reads/writes
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Slow = one With the ConcurrentHashMap cache, each unique (parent, segment) pair only hits the slow path once per download session. Subsequent lookups are O(1). On SD cards the first |
||
| } | ||
| } | ||
| } | ||
| return resolved | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
how does it work with DD?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the tiny DD change was made and the pointer updated in Gradle deps. works great.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the
CaseInsensitiveFileSystemis passed as thefilesystemparam toDepotDownloader, which uses Okio. So all file writes during download go through the case-insensitive resolver, which means that DD writes to the correctly-cased existing directory instead of creating a new one.