Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 5 additions & 30 deletions app/src/main/java/app/gamenative/service/SteamService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
}
Expand All @@ -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 {
Expand Down Expand Up @@ -1581,6 +1555,7 @@ class SteamService : Service(), IChallengeUrlChanged {
maxDecompress = maxDecompress,
parentJob = coroutineContext[Job],
autoStartDownload = false,
filesystem = CaseInsensitiveFileSystem(),

Copy link
Copy Markdown
Owner

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?

Copy link
Copy Markdown
Contributor Author

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the CaseInsensitiveFileSystem is passed as the filesystem param to DepotDownloader, 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.

)

// Create listeners for DLC apps
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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,
Comment thread
jeremybernstein marked this conversation as resolved.
)

Expand Down Expand Up @@ -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))
Comment thread
jeremybernstein marked this conversation as resolved.
Comment on lines +1265 to +1272

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FileUtils.resolveCaseInsensitive() is already the centralized logic extraction. The surrounding context (path normalization & traversal limiting) is Epic-specific (GOG doesn't require it AFAICT, but tell me if I'm wrong). So I don't think there's anything worth generalizing, but let me know if you disagree.

Comment thread
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.)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 ->
Expand Down
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) {
Comment thread
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

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Slow = one readdir() syscall per cache miss

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 list() might be a few ms slower than internal storage, but it only happens once per directory.

}
}
}
return resolved
}
}
28 changes: 24 additions & 4 deletions app/src/main/java/app/gamenative/utils/FileUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return current
}
}
return current.takeIf { it.exists() }
return current
Comment thread
jeremybernstein marked this conversation as 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)
}
}
Loading
Loading