Skip to content

Fix case-sensitive filesystem creating duplicate directories#941

Merged
utkarshdalal merged 1 commit intoutkarshdalal:masterfrom
jeremybernstein:jb/case-insensitive-paths
Mar 28, 2026
Merged

Fix case-sensitive filesystem creating duplicate directories#941
utkarshdalal merged 1 commit intoutkarshdalal:masterfrom
jeremybernstein:jb/case-insensitive-paths

Conversation

@jeremybernstein
Copy link
Copy Markdown
Contributor

@jeremybernstein jeremybernstein commented Mar 20, 2026

Summary

  • Add CaseInsensitiveFileSystem (Okio ForwardingFileSystem wrapper) that resolves each path component against on-disk casing before any I/O, with exact-match fast path and per-session cache
  • Pass it to DepotDownloader via the new filesystem constructor param (JavaSteam 1.8.0.1-14-SNAPSHOT)
  • Apply FileUtils.findFileCaseInsensitive to Epic and GOG cloud save download paths
  • Bump JavaSteam to 1.8.0.1-14-SNAPSHOT (adds injectable filesystem param)

Test plan

  • FEAR 2 (16450) — confirmed Game/game duplicate no longer created
  • Epic cloud save with {appdata}/../locallow path (Enter the Gungeon)
  • GOG cloud save download with mixed-case relative paths

Supersedes #701
Fixes #940


Summary by cubic

Fix duplicate directories on case-sensitive filesystems by resolving paths case-insensitively for Steam depot downloads and Epic/GOG cloud saves. Adds path traversal checks for Epic saves.

  • Bug Fixes

    • Added CaseInsensitiveFileSystem (Okio ForwardingFileSystem) and pass it to DepotDownloader via filesystem to resolve on-disk casing before I/O.
    • Steam: use FileUtils.findFileCaseInsensitive for manifest/config lookups.
    • Epic/GOG: use FileUtils.resolveCaseInsensitive for save writes to reuse on-disk casing and avoid duplicate dirs; Epic restricts resolved paths to the Wine prefix or install dir and skips any file that resolves outside saveDir.
  • Dependencies

    • Bumped javasteam to 1.8.0.1-14-SNAPSHOT to enable the injectable filesystem parameter.

Written for commit be7a2f0. Summary will update on new commits.

Summary by CodeRabbit

  • Bug Fixes

    • Improved cloud-save sync with case-insensitive path resolution and containment checks to prevent writes outside expected directories.
    • Steam depot downloads now use case-insensitive path handling for more reliable operation on case-sensitive filesystems.
  • New Features

    • Added a case-insensitive filesystem layer to normalize on-disk path casing and unify path resolution.
  • Tests

    • Added tests validating case-insensitive path resolution and concurrent/mixed-case behavior.
  • Chores

    • Updated Steam-related library version.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 20, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Replaces ad-hoc case-insensitive path handling with centralized FileUtils helpers and a CaseInsensitiveFileSystem; updates Steam depot downloader to use the case-insensitive filesystem; applies case-insensitive resolution to Epic and GOG save/download paths; adds unit tests and bumps javasteam snapshot version.

Changes

Cohort / File(s) Summary
Case-Insensitive FileSystem
app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt
Add Okio ForwardingFileSystem wrapper that resolves path segments to on-disk casing with per-instance caching and overrides onPathParameter.
File utilities
app/src/main/java/app/gamenative/utils/FileUtils.kt
Adjust resolveCaseInsensitive to return a non-null File and append unmatched trailing segments verbatim; add fast-path exact existence check in findFileCaseInsensitive and move final existence validation there.
Steam integration
app/src/main/java/app/gamenative/service/SteamService.kt
Remove local resolver; use FileUtils.findFileCaseInsensitive(...) for manifest/controller config lookups and instantiate DepotDownloader with filesystem = CaseInsensitiveFileSystem().
Epic cloud saves
app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt
Use FileUtils.resolveCaseInsensitive(...) for save directory resolution, unify {installdir} computation, and validate canonical containment to avoid path-traversal writes (warn + return null if outside prefix).
GOG cloud saves
app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt
Resolve download targets via FileUtils.resolveCaseInsensitive(syncDir, file.relativePath) before creating parent dirs and writing bytes.
Tests
app/src/test/java/app/gamenative/utils/CaseInsensitivePathResolutionTest.kt, app/src/test/java/app/gamenative/utils/CaseInsensitiveFileSystemTest.kt
Add unit tests exercising resolveCaseInsensitive/findFileCaseInsensitive behaviors and concurrency/mixed-case filesystem writes against a temp FS.
Dependencies
gradle/libs.versions.toml
Bump [versions].javasteam snapshot from 1.8.0.1-13-SNAPSHOT1.8.0.1-14-SNAPSHOT.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant SteamService
  participant DepotDownloader
  participant CaseInsensitiveFS as FileSystem(rgba(100,150,250,0.5))
  participant Disk as Disk(rgba(200,200,200,0.5))

  SteamService->>DepotDownloader: construct(..., filesystem=CaseInsensitiveFS)
  SteamService->>DepotDownloader: requestDepotExtraction(manifest, target)
  DepotDownloader->>CaseInsensitiveFS: open/create path segments
  CaseInsensitiveFS->>Disk: stat / list parent directory entries (case-insensitive match)
  Disk-->>CaseInsensitiveFS: entries / metadata
  CaseInsensitiveFS-->>DepotDownloader: resolved path with on-disk casing
  DepotDownloader-->>SteamService: extraction complete
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

Possibly related PRs

Poem

🐰
I hop through folders, sniff each name,
I match the case so paths stay tame.
Depots and saves now find their place,
No duplicate trees to chase.
A tiny hop — files home with grace.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 29.63% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Fix case-sensitive filesystem creating duplicate directories' directly and clearly summarizes the main change: resolving a filesystem bug where case sensitivity causes duplicate directories.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

3 issues found across 5 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt">

<violation number="1" location="app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt:526">
P2: Case-insensitive destination resolution fails for new files, allowing fallback path creation to reintroduce duplicate-case directories.</violation>
</file>

<file name="app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt">

<violation number="1" location="app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt:1259">
P1: Path normalization plus root-anchored resolution allows traversal-influenced save paths outside the intended Wine-prefix boundary.</violation>

<violation number="2" location="app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt:1260">
P2: Case-insensitive resolution is bypassed when the leaf segment is missing, so fallback may create duplicate-case parent directories.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt Outdated
Comment thread app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt Outdated
Comment thread app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt Outdated
@jeremybernstein jeremybernstein force-pushed the jb/case-insensitive-paths branch from b343ec7 to e6665f4 Compare March 20, 2026 11:52
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt`:
- Around line 1191-1195: The fallback for installDir uses
EpicConstants.getGameInstallPath(context, game.appName) but should use the
game's display/title so the install lookup matches how Epic stores installs;
change the fallback to EpicConstants.getGameInstallPath(context,
game.displayTitle) (or the actual display title property) and ensure the
computed installDir and the "{installdir}" entry in pathVars still use that
value (refer to the installDir variable, EpicConstants.getGameInstallPath(...)
call, and the pathVars map).
- Around line 1260-1264: The comparison uses the raw installDir string which can
have wrong casing; call FileUtils.resolveCaseInsensitive(File("/"), installDir)
to produce a case-corrected path (e.g., resolvedInstallDir) and use
resolvedInstallDir.absolutePath in the withinPrefix check instead of installDir
so that absPath is compared against the on-disk-cased install prefix; keep
existing checks for winePrefix (or resolve it similarly if needed) and replace
occurrences of installDir in the startsWith()/== comparisons with the
resolvedInstallDir.absolutePath.

In `@app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt`:
- Around line 21-29: The cache in CaseInsensitiveFileSystem (private val cache:
ConcurrentHashMap<Pair<Path, String>, Path>) is keyed by the raw segment string
so "Game" and "game" are cached separately; update onPathParameter to normalize
the segment when constructing the cache key (e.g., use
segment.lowercase(Locale.ROOT) or similar) both when reading (cache.getOrPut)
and when inserting so the Pair<Path, String> key uses the lowercased/normalized
segment consistently, preventing duplicate entries for differing case during
parallel operations.
- Around line 17-19: The constructor uses an unqualified SYSTEM symbol that is
undefined; update the default delegate in CaseInsensitiveFileSystem to reference
the companion constant via FileSystem.SYSTEM (or add the import
okio.FileSystem.Companion.SYSTEM if you prefer) so the delegate: FileSystem =
FileSystem.SYSTEM resolves correctly; ensure this change is applied in the
CaseInsensitiveFileSystem declaration that extends ForwardingFileSystem and
keeps the delegate parameter name unchanged.

In `@app/src/main/java/app/gamenative/utils/FileUtils.kt`:
- Around line 212-239: The code allows path traversal because
findFileCaseInsensitive/resolveCaseInsensitive accept relativePath with "." or
".."; normalize the relativePath into canonical segments before any filesystem
lookups and reject or clamp inputs that would escape baseDir. Specifically, in
findFileCaseInsensitive and resolveCaseInsensitive, first split and resolve
"."/".." (or compute a normalized relative path and the target canonical path)
and ensure the resolved path remains within baseDir.canonicalPath (fail-closed
or return null) before doing exact check or the case-insensitive segment
matching/append fallback; this prevents "../" from escaping baseDir while
preserving behavior for new files under on-disk casing differences.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e0cff235-7b88-42e6-b2c6-b120fc6058bf

📥 Commits

Reviewing files that changed from the base of the PR and between b343ec7 and e6665f4.

📒 Files selected for processing (6)
  • app/src/main/java/app/gamenative/service/SteamService.kt
  • app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt
  • app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt
  • app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt
  • app/src/main/java/app/gamenative/utils/FileUtils.kt
  • gradle/libs.versions.toml
✅ Files skipped from review due to trivial changes (1)
  • gradle/libs.versions.toml
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt

Comment thread app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt
Comment thread app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt Outdated
Comment thread app/src/main/java/app/gamenative/utils/FileUtils.kt
Comment on lines +34 to +40
// 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
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.

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.

@jeremybernstein
Copy link
Copy Markdown
Contributor Author

@utkarshdalal responded to your concerns, let me know if you have other things you want to talk out. I think this is a big improvement (for users experiencing problems, that is), though.

Copy link
Copy Markdown
Contributor

@phobos665 phobos665 left a comment

Choose a reason for hiding this comment

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

Since this has fundamental changes to the filesystem changes, I think we should ideally put in some integration tests to ensure the logic is sound. If we get this wrong it could be pretty dangerous for existing cloud-save functionality

Comment thread gradle/libs.versions.toml
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
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 required for the current PR?

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.

yes, it adds the ability to pass in a custom FileSystem!

Comment on lines +1257 to +1264
// 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))
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.

@jeremybernstein
Copy link
Copy Markdown
Contributor Author

jeremybernstein commented Mar 27, 2026

Since this has fundamental changes to the filesystem changes, I think we should ideally put in some integration tests to ensure the logic is sound. If we get this wrong it could be pretty dangerous for existing cloud-save functionality

agreed, added in force-push coming... now.

@jeremybernstein jeremybernstein force-pushed the jb/case-insensitive-paths branch from e6665f4 to 035bdfb Compare March 27, 2026 09:46
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/src/main/java/app/gamenative/service/SteamService.kt (1)

1549-1559: ⚠️ Potential issue | 🔴 Critical

CaseInsensitiveFileSystem still leaves a mixed-case first-creation race here.

DepotDownloader is doing parallel work here, but app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt resolves missing segments from current disk state and caches them by the original segment spelling. If two workers hit Game/... and game/... before either directory exists, they can still choose different exact paths and create both trees, so the duplicate-directory bug survives on first creation. Please make the first resolution/creation atomic per parent + case-folded segment, and add a regression test for concurrent mixed-case writes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/app/gamenative/service/SteamService.kt` around lines 1549 -
1559, The CaseInsensitiveFileSystem currently races on first-creation because
resolve/create logic can be invoked concurrently by DepotDownloader workers;
modify CaseInsensitiveFileSystem (the resolution/creation code that looks up or
creates path segments) to perform an atomic check-and-create per parent
directory + case-folded segment (e.g., use a synchronized/locked map keyed by
parent inode/path + normalizedSegment or a per-parent Mutex) so only one
canonical on-disk segment is chosen/created for a given lowercase key, and
update DepotDownloader usage if needed to rely on this atomic API; add a
regression test that spawns concurrent tasks performing mixed-case writes (e.g.,
"Game/..." vs "game/...") to assert only one directory tree is created and no
duplicate casings exist.
🧹 Nitpick comments (1)
app/src/test/java/app/gamenative/utils/CaseInsensitivePathResolutionTest.kt (1)

91-99: Assert the resolved casing, not just existence.

These cases are host-filesystem dependent right now: on a case-insensitive dev machine, returning the unresolved path would still pass. Compare against the exact file path you created so the test verifies normalization everywhere.

Proposed test tightening
 `@Test`
 fun `findFile wrong casing returns file`() {
     val base = tmpDir.newFolder("game")
-    File(base, "CheckApplication.exe").createNewFile()
+    val file = File(base, "CheckApplication.exe")
+    file.createNewFile()
 
     val result = FileUtils.findFileCaseInsensitive(base, "checkapplication.exe")
     assertNotNull(result)
-    assertTrue(result!!.exists())
+    assertEquals(file.absolutePath, result!!.absolutePath)
 }
 
 `@Test`
 fun `findFile nested wrong casing`() {
     val base = tmpDir.newFolder("game")
     val dir = File(base, "Data/SaveFiles")
     dir.mkdirs()
-    File(dir, "slot1.sav").createNewFile()
+    val file = File(dir, "slot1.sav")
+    file.createNewFile()
 
     val result = FileUtils.findFileCaseInsensitive(base, "data/savefiles/slot1.sav")
     assertNotNull(result)
-    assertTrue(result!!.exists())
+    assertEquals(file.absolutePath, result!!.absolutePath)
 }

Also applies to: 108-118

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/test/java/app/gamenative/utils/CaseInsensitivePathResolutionTest.kt`
around lines 91 - 99, Update the test `findFile wrong casing returns file` to
assert the resolved path matches the exact file you created instead of only
checking existence: after creating File(base, "CheckApplication.exe"), call
FileUtils.findFileCaseInsensitive(base, "checkapplication.exe") and assert that
result is not null and result!!.absolutePath (or result.canonicalPath) equals
the created File's absolutePath/canonicalPath; do the same tightening for the
other similar test that exercises `FileUtils.findFileCaseInsensitive` (the test
covering the other filename case) so the tests validate correct
case-normalization across filesystems.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@app/src/main/java/app/gamenative/service/SteamService.kt`:
- Around line 1549-1559: The CaseInsensitiveFileSystem currently races on
first-creation because resolve/create logic can be invoked concurrently by
DepotDownloader workers; modify CaseInsensitiveFileSystem (the
resolution/creation code that looks up or creates path segments) to perform an
atomic check-and-create per parent directory + case-folded segment (e.g., use a
synchronized/locked map keyed by parent inode/path + normalizedSegment or a
per-parent Mutex) so only one canonical on-disk segment is chosen/created for a
given lowercase key, and update DepotDownloader usage if needed to rely on this
atomic API; add a regression test that spawns concurrent tasks performing
mixed-case writes (e.g., "Game/..." vs "game/...") to assert only one directory
tree is created and no duplicate casings exist.

---

Nitpick comments:
In `@app/src/test/java/app/gamenative/utils/CaseInsensitivePathResolutionTest.kt`:
- Around line 91-99: Update the test `findFile wrong casing returns file` to
assert the resolved path matches the exact file you created instead of only
checking existence: after creating File(base, "CheckApplication.exe"), call
FileUtils.findFileCaseInsensitive(base, "checkapplication.exe") and assert that
result is not null and result!!.absolutePath (or result.canonicalPath) equals
the created File's absolutePath/canonicalPath; do the same tightening for the
other similar test that exercises `FileUtils.findFileCaseInsensitive` (the test
covering the other filename case) so the tests validate correct
case-normalization across filesystems.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0deaca0a-1793-4ed5-8a9d-307f4808a7e8

📥 Commits

Reviewing files that changed from the base of the PR and between e6665f4 and 035bdfb.

📒 Files selected for processing (7)
  • app/src/main/java/app/gamenative/service/SteamService.kt
  • app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt
  • app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt
  • app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt
  • app/src/main/java/app/gamenative/utils/FileUtils.kt
  • app/src/test/java/app/gamenative/utils/CaseInsensitivePathResolutionTest.kt
  • gradle/libs.versions.toml
✅ Files skipped from review due to trivial changes (1)
  • app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt
🚧 Files skipped from review as they are similar to previous changes (4)
  • app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt
  • app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt
  • gradle/libs.versions.toml
  • app/src/main/java/app/gamenative/utils/FileUtils.kt

@jeremybernstein jeremybernstein force-pushed the jb/case-insensitive-paths branch from 035bdfb to ab8765a Compare March 27, 2026 10:06
@jeremybernstein
Copy link
Copy Markdown
Contributor Author

@coderabbitai:

nitpick commit is

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)

🧹 Nitpick comments (1)

🤖 Prompt for all review comments with AI agents

ℹ️ Review info

The race condition concern: ConcurrentHashMap.getOrPut is not atomic. So two threads can both evaluate the lambda for the same key before either inserts. Thread A lists parent, finds nothing, returns exact (creating _Work). Thread B lists parent, also finds nothing (or finds _Work before it's fully created), returns its own exact (_work). Both get cached differently.

In practice this is a non-issue here. onPathParameter only resolves paths -- it doesn't create directories. The actual createDirectories / write call happens through the delegate after resolution. Two threads resolving the same missing segment will both fall through to ?: exact and return the same original-casing path (since the segment string is the same within each thread's own path). The duplicate-dir problem this class prevents is about different casing in different depot manifests (_Work vs _work), not concurrent creation of the same path.

Can two concurrent DepotDownloader workers have paths with different casing for the same segment? Yes — but ConcurrentHashMap.getOrPut is safe enough here because both threads would resolve to the first-on-disk match, and if nothing exists yet, they both return the exact path (same casing from whichever manifest they're processing). The first createDirectories wins, and subsequent lookups find it.

The actual race window is tiny and ConcurrentHashMap.getOrPut will at worst compute the lambda twice for the same key. Both computations would list the same parent and see the same state. I don't think it's worth adding synchronization overhead, but push back if you disagree.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
app/src/main/java/app/gamenative/service/SteamService.kt (1)

1549-1559: Please add the mixed-case download regression around this hook.

This constructor path is now the Steam-side guard against recreating duplicate Game/ vs game/ directories on case-sensitive storage. A small automated fixture here would make future dependency bumps much safer.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/app/gamenative/service/SteamService.kt` around lines 1549 -
1559, Add a small automated mixed-case download regression around the
DepotDownloader constructor in SteamService.kt: create a temporary test fixture
that invokes DepotDownloader (the instance used in the shown constructor call)
with two tiny test depots or paths whose target directories differ only by case
(e.g., "Game/" and "game/"), run the downloader with autoStartDownload=false and
a controlled filesystem (reuse CaseInsensitiveFileSystem or a temp real FS),
then assert after the operation that the on-disk layout did not produce
duplicate case-variant directories (only one canonical directory exists) to
catch regressions that would recreate both "Game/" and "game/". Ensure the
fixture is executed near the constructor call and fails the build/test when
duplicates are detected.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt`:
- Around line 1257-1264: The current withinPrefix check only validates the root
but not each manifest entry; before writing any manifest entry (where code uses
fileManifest.filename in the reconstruction loops), join the filename to
saveDir, resolve case-insensitively (using FileUtils.resolveCaseInsensitive with
normalizedParts/joinedPath pattern), then compute outputFile and assert
outputFile.absolutePath.startsWith(saveDir.absolutePath) or equals it; if not,
skip/log the entry and do not write. Apply this guard in both reconstruction
loops (the write sites referencing fileManifest.filename), ensuring you use the
same resolved path/casing as used for the root to prevent path traversal and
duplicate mixed-case directories.

In `@app/src/main/java/app/gamenative/utils/FileUtils.kt`:
- Around line 229-235: The loop inside for ((i, segment) in
segments.withIndex()) currently uses firstOrNull on current.listFiles() with
ignoreCase, which can pick an arbitrary case-only sibling; replace that logic by
collecting all candidates: val matches = current.listFiles()?.filter {
it.name.equals(segment, ignoreCase = true) } ?: emptyList(); if matches.size > 1
then fail fast (throw IllegalStateException or return null) to avoid ambiguous
case-only siblings; if matches.size == 1 set current = matches[0]; otherwise (no
match) fall back to the existing behavior that appends remaining segments
verbatim. Also guard against listFiles() returning null.

---

Nitpick comments:
In `@app/src/main/java/app/gamenative/service/SteamService.kt`:
- Around line 1549-1559: Add a small automated mixed-case download regression
around the DepotDownloader constructor in SteamService.kt: create a temporary
test fixture that invokes DepotDownloader (the instance used in the shown
constructor call) with two tiny test depots or paths whose target directories
differ only by case (e.g., "Game/" and "game/"), run the downloader with
autoStartDownload=false and a controlled filesystem (reuse
CaseInsensitiveFileSystem or a temp real FS), then assert after the operation
that the on-disk layout did not produce duplicate case-variant directories (only
one canonical directory exists) to catch regressions that would recreate both
"Game/" and "game/". Ensure the fixture is executed near the constructor call
and fails the build/test when duplicates are detected.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5ff0e951-d46f-4b3a-8640-97e368102ed6

📥 Commits

Reviewing files that changed from the base of the PR and between 035bdfb and ab8765a.

📒 Files selected for processing (7)
  • app/src/main/java/app/gamenative/service/SteamService.kt
  • app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt
  • app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt
  • app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt
  • app/src/main/java/app/gamenative/utils/FileUtils.kt
  • app/src/test/java/app/gamenative/utils/CaseInsensitivePathResolutionTest.kt
  • gradle/libs.versions.toml
✅ Files skipped from review due to trivial changes (2)
  • app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt
  • app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt
🚧 Files skipped from review as they are similar to previous changes (2)
  • gradle/libs.versions.toml
  • app/src/test/java/app/gamenative/utils/CaseInsensitivePathResolutionTest.kt

Comment thread app/src/main/java/app/gamenative/utils/FileUtils.kt
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 27, 2026

Tip

For best results, initiate chat on the files or code changes.

@jeremybernstein: Your analysis is correct and I agree — no synchronization overhead is warranted here.

The key points that make this safe:

  1. Same-key race is idempotent. If two threads race on the identical Pair(parentPath, "segment") key, both lambdas list the same parent directory at essentially the same instant and produce the same result. getOrPut may compute twice, but it caches a consistent value either way.

  2. Cross-casing paths are different keys. _Work and _work produce distinct Pair(parentPath, segment) entries, so threads carrying manifests with different casings never collide in the cache at all. Each resolves independently to whatever is first found on disk (or falls back to its own exact path).

  3. onPathParameter is purely a resolver. No directories are created here; the race window for "segment doesn't exist yet" closes as soon as the first createDirectories call completes downstream, after which all subsequent lookups converge on the on-disk entry.

Adding this to the long-term memory to avoid re-flagging in future reviews.


✏️ Learnings added
Learnt from: jeremybernstein
URL: https://github.com/utkarshdalal/GameNative/pull/941

Timestamp: 2026-03-27T10:17:59.692Z
Learning: In `app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt`, the `ConcurrentHashMap.getOrPut` usage in `onPathParameter` is intentionally not synchronized. The non-atomicity is benign: (1) same-key races produce an idempotent result because both threads list the same parent and see the same filesystem state; (2) different-casing segments (e.g., `_Work` vs `_work`) are different cache keys and never collide; (3) `onPathParameter` is a pure resolver — no directories are created here. The first `createDirectories` call downstream wins, and all subsequent resolutions converge on the on-disk entry. Do not flag the `getOrPut` non-atomicity as a race condition in future reviews of this file.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: unbelievableflavour
Repo: utkarshdalal/GameNative PR: 860
File: app/src/main/java/com/winlator/xenvironment/ImageFsInstaller.java:361-367
Timestamp: 2026-03-15T20:17:26.633Z
Learning: In `app/src/main/java/com/winlator/xenvironment/ImageFsInstaller.java`, the `opt/proton` directory inside a Bionic variant root is a brand-new path introduced in PR `#860`'s imagefs symlink layout. It has no prior existence in any older layout, so the early `if (optProton.exists()) return;` guard in `ensureSharedProtonDir()` is safe — there is no migration scenario where a real (non-symlink) `opt/proton` directory could already be present. Do not flag this guard as a migration issue in future reviews.

Learnt from: jeremybernstein
Repo: utkarshdalal/GameNative PR: 965
File: app/src/main/java/app/gamenative/PrefManager.kt:758-769
Timestamp: 2026-03-21T23:32:49.557Z
Learning: In `app/src/main/java/app/gamenative/PrefManager.kt`, `libraryTab` and `libraryScrollAnchor` are intentionally written as separate fire-and-forget DataStore updates. A torn crash state (mismatched tab + anchor) is benign: the anchor simply fails to resolve in the wrong tab and falls back to the top, self-healing. An atomic/transactional DataStore write for this pair is not justified. Do not flag the separate writes as a torn-state risk in future reviews.

Learnt from: jeremybernstein
Repo: utkarshdalal/GameNative PR: 941
File: app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt:1260-1264
Timestamp: 2026-03-20T12:09:32.974Z
Learning: In `app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt`, the `installDir` variable in `resolveSaveDirectory` is either `game.installPath` (stored by the app during install, deterministic casing) or the result of `EpicConstants.getGameInstallPath(context, game.appName)` (pure string concatenation, no disk lookup). In both cases the casing is controlled by the app and is guaranteed to match what is on disk. Do not flag the `withinPrefix` check comparing `resolved.absolutePath` against `installDir` as a potential casing mismatch — no case-resolution of `installDir` is needed there.

Learnt from: jeremybernstein
Repo: utkarshdalal/GameNative PR: 0
File: :0-0
Timestamp: 2026-03-18T16:30:26.610Z
Learning: In `app/src/main/java/app/gamenative/MainActivity.kt`, `peekPendingLaunchRequest()` is an intentional non-destructive, synchronized read of `pendingLaunchRequest`. The peek + separate consume pattern (also present at line 436) is pre-existing and accepted; the worst-case outcome of a race is an unnecessary snackbar, which is benign. Do not flag `peekPendingLaunchRequest()` as a TOCTOU race issue in future reviews.

Learnt from: jeremybernstein
Repo: utkarshdalal/GameNative PR: 929
File: app/src/main/java/app/gamenative/service/SteamService.kt:763-765
Timestamp: 2026-03-19T17:57:45.184Z
Learning: In `app/src/main/java/app/gamenative/service/SteamService.kt`, the `has64Bit` and `hasNonDeckWin` preference flags in `getMainAppDepots()`, `getDownloadableDepots()`, and the DLC branch are intentionally computed from the same `eligibleDepots` set. These flags are orthogonal (64-bit vs 32-bit and Deck vs non-Deck are independent axes), so computing them from the same set does not cause conflation. The theoretical edge case of a Deck-only x64 depot paired with a non-Deck x86 depot (with no non-Deck x64 depot) is practically non-existent in the Steam depot ecosystem. Do not flag this pattern as a potential self-cancellation issue in future reviews.

Learnt from: jeremybernstein
Repo: utkarshdalal/GameNative PR: 918
File: app/src/main/java/app/gamenative/service/SteamService.kt:3091-3102
Timestamp: 2026-03-19T10:03:51.598Z
Learning: In `app/src/main/java/app/gamenative/service/SteamService.kt`, `connectToSteam()` uses a `retryAttemptAtStart` snapshot to guard the 5s watchdog: after the delay, the watchdog only marks the endpoint bad and disconnects if `!isConnected && retryAttempt == retryAttemptAtStart`. Since `onConnected` resets `retryAttempt = 0` and `onDisconnected` increments `retryAttempt++`, any callback activity during the 5s window changes `retryAttempt`, causing the watchdog to back off. This is the intentional, correct design. Do not flag the `delay(5000)` + `retryAttempt` guard pattern as a race condition in future reviews.

Learnt from: jeremybernstein
Repo: utkarshdalal/GameNative PR: 941
File: app/src/main/java/app/gamenative/utils/FileUtils.kt:212-239
Timestamp: 2026-03-20T12:12:57.725Z
Learning: In `app/src/main/java/app/gamenative/utils/FileUtils.kt`, `resolveCaseInsensitive` and `findFileCaseInsensitive` do NOT need internal path-traversal normalization. All callers handle this upstream: Epic normalizes `..`/`.` via a manual stack algorithm (lines 1239–1254 of `EpicCloudSavesManager.kt`) and enforces a `withinPrefix` guard (lines 1257–1268); GOG uses trusted server-supplied `file.relativePath`; all other callers (executable/config lookups) never have `../` in their paths. Do not flag absence of traversal guards in the utility itself as a security issue.

Learnt from: jeremybernstein
Repo: utkarshdalal/GameNative PR: 941
File: app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt:17-19
Timestamp: 2026-03-20T12:11:03.930Z
Learning: In `app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt`, the constructor default `delegate: FileSystem = SYSTEM` (line ~19) resolves correctly because `CaseInsensitiveFileSystem` extends `ForwardingFileSystem` which extends `FileSystem`, and `SYSTEM` is `FileSystem.Companion.SYSTEM` — accessible in this context on the JVM/Android target. The code compiles and runs. Do not flag the unqualified `SYSTEM` reference as an unresolved symbol in future reviews of this file.

Learnt from: utkarshdalal
Repo: utkarshdalal/GameNative PR: 344
File: app/src/main/java/app/gamenative/service/SteamAutoCloud.kt:415-419
Timestamp: 2025-12-17T05:14:05.133Z
Learning: In SteamAutoCloud.kt, when uploading files to Steam Cloud API (beginFileUpload and commitFileUpload), the filename parameter intentionally uses different formats: `file.path + file.filename` (relative path without placeholder) when `appInfo.ufs.saveFilePatterns.isEmpty()` is true (fallback case), and `file.prefixPath` (includes placeholder like %SteamUserData%) when patterns are configured. This difference is by design.

Learnt from: jeremybernstein
Repo: utkarshdalal/GameNative PR: 929
File: app/src/main/java/app/gamenative/service/SteamService.kt:807-814
Timestamp: 2026-03-20T09:44:35.386Z
Learning: In `app/src/main/java/app/gamenative/service/SteamService.kt`, in `getDownloadableDepots()`, the base app's `has64Bit` flag (derived from the base app's `eligibleDepots`) is intentionally reused when filtering indirect DLC depots. DLC depots install into the same directory as the base game, so architecture must be consistent across the entire install. Using a per-DLC `dlcHas64Bit` would be wrong because a 32-bit DLC depot is incompatible with a 64-bit base game install. Do not flag this reuse of the base app's `has64Bit` for DLC depot filtering as a bug in future reviews.

Learnt from: unbelievableflavour
Repo: utkarshdalal/GameNative PR: 921
File: app/src/main/java/app/gamenative/utils/preInstallSteps/UbisoftConnectStep.kt:58-73
Timestamp: 2026-03-19T10:46:06.073Z
Learning: In `app/src/main/java/app/gamenative/utils/preInstallSteps/UbisoftConnectStep.kt`, the `ensureInstallerAtCommonRedist` function is intentionally fail-closed: if `Files.createSymbolicLink` throws or the installer is not found in the game root, the function returns `false` and the Ubisoft Connect pre-install step is skipped entirely. There is no copy fallback — if the symlink cannot be created, installation is simply not attempted. Do not suggest adding a copy fallback in future reviews.

@jeremybernstein
Copy link
Copy Markdown
Contributor Author

@coderabbitai , your nitpick:

app/src/main/java/app/gamenative/service/SteamService.kt (1)

1549-1559: Please add the mixed-case download regression around this hook.

This constructor path is now the Steam-side guard against recreating duplicate Game/ vs game/ directories on case-sensitive storage. A small automated fixture here would make future dependency bumps much safer.

This suggestion is impractical, given what DepotDownloader does and how it connects to a real online depot server over which we have no influence. I can add a test which simulates a response from the DD and shows that we do the right thing. It will only work when executed on a case-sensitive file system like a Linux CI server, though.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 27, 2026

Tip

For best results, initiate chat on the files or code changes.

@jeremybernstein: That's a fair and correct pushback — I was wrong to suggest a full regression fixture at that hook. Since DepotDownloader communicates with a live depot server over which you have no control, a realistic integration test is not feasible without a far heavier mocking infrastructure.

The simulated approach you describe — wiring a fake file-write response through DepotDownloader to show that CaseInsensitiveFileSystem is correctly plumbed and resolves casing as expected — is exactly the right compromise. The Linux CI case-sensitive-FS constraint is also a standard and well-understood limitation for this class of test; it's clearly documentable in the test itself (e.g., assumeTrue(isCaseSensitiveFilesystem())) so it's a no-op on case-insensitive hosts without failing the suite.


✏️ Learnings added
Learnt from: jeremybernstein
URL: https://github.com/utkarshdalal/GameNative/pull/941

Timestamp: 2026-03-27T10:36:22.471Z
Learning: In `app/src/main/java/app/gamenative/service/SteamService.kt`, around lines 1549-1559, a full integration regression test for the `CaseInsensitiveFileSystem` wired into `DepotDownloader` is not feasible: `DepotDownloader` connects to a real online depot server that cannot be controlled in tests. The appropriate test strategy is a simulated-response fixture that exercises the filesystem wiring directly. Such tests only pass on case-sensitive filesystems (Linux CI) and should guard with an `assumeTrue(isCaseSensitiveFilesystem())` check. Do not ask for a live-depot integration fixture at this constructor site in future reviews.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: jeremybernstein
Repo: utkarshdalal/GameNative PR: 941
File: app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt:1260-1264
Timestamp: 2026-03-20T12:09:32.974Z
Learning: In `app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt`, the `installDir` variable in `resolveSaveDirectory` is either `game.installPath` (stored by the app during install, deterministic casing) or the result of `EpicConstants.getGameInstallPath(context, game.appName)` (pure string concatenation, no disk lookup). In both cases the casing is controlled by the app and is guaranteed to match what is on disk. Do not flag the `withinPrefix` check comparing `resolved.absolutePath` against `installDir` as a potential casing mismatch — no case-resolution of `installDir` is needed there.

Learnt from: jeremybernstein
Repo: utkarshdalal/GameNative PR: 929
File: app/src/main/java/app/gamenative/service/SteamService.kt:807-814
Timestamp: 2026-03-20T09:44:35.386Z
Learning: In `app/src/main/java/app/gamenative/service/SteamService.kt`, in `getDownloadableDepots()`, the base app's `has64Bit` flag (derived from the base app's `eligibleDepots`) is intentionally reused when filtering indirect DLC depots. DLC depots install into the same directory as the base game, so architecture must be consistent across the entire install. Using a per-DLC `dlcHas64Bit` would be wrong because a 32-bit DLC depot is incompatible with a 64-bit base game install. Do not flag this reuse of the base app's `has64Bit` for DLC depot filtering as a bug in future reviews.

Learnt from: jeremybernstein
Repo: utkarshdalal/GameNative PR: 929
File: app/src/main/java/app/gamenative/service/SteamService.kt:763-765
Timestamp: 2026-03-19T17:57:45.184Z
Learning: In `app/src/main/java/app/gamenative/service/SteamService.kt`, the `has64Bit` and `hasNonDeckWin` preference flags in `getMainAppDepots()`, `getDownloadableDepots()`, and the DLC branch are intentionally computed from the same `eligibleDepots` set. These flags are orthogonal (64-bit vs 32-bit and Deck vs non-Deck are independent axes), so computing them from the same set does not cause conflation. The theoretical edge case of a Deck-only x64 depot paired with a non-Deck x86 depot (with no non-Deck x64 depot) is practically non-existent in the Steam depot ecosystem. Do not flag this pattern as a potential self-cancellation issue in future reviews.

Learnt from: jeremybernstein
Repo: utkarshdalal/GameNative PR: 941
File: app/src/main/java/app/gamenative/utils/FileUtils.kt:229-235
Timestamp: 2026-03-27T10:29:59.174Z
Learning: In `app/src/main/java/app/gamenative/utils/FileUtils.kt`, `resolveCaseInsensitive` intentionally uses `firstOrNull` when multiple case-only siblings exist (e.g. `_Work` and `_work` left from a prior bug). The non-deterministic first-match behavior is a documented trade-off: failing fast would break downloads for users who already have duplicate directories, and resilience is the explicit design goal. The KDoc for the function documents this. Do not flag this as a correctness or determinism issue in future reviews. Additionally, `listFiles()` returning `null` is already handled: the `match == null` branch falls through to the `else` clause that appends remaining segments verbatim.

Learnt from: jeremybernstein
Repo: utkarshdalal/GameNative PR: 941
File: app/src/main/java/app/gamenative/utils/FileUtils.kt:212-239
Timestamp: 2026-03-20T12:12:57.725Z
Learning: In `app/src/main/java/app/gamenative/utils/FileUtils.kt`, `resolveCaseInsensitive` and `findFileCaseInsensitive` do NOT need internal path-traversal normalization. All callers handle this upstream: Epic normalizes `..`/`.` via a manual stack algorithm (lines 1239–1254 of `EpicCloudSavesManager.kt`) and enforces a `withinPrefix` guard (lines 1257–1268); GOG uses trusted server-supplied `file.relativePath`; all other callers (executable/config lookups) never have `../` in their paths. Do not flag absence of traversal guards in the utility itself as a security issue.

Learnt from: unbelievableflavour
Repo: utkarshdalal/GameNative PR: 921
File: app/src/main/java/app/gamenative/utils/preInstallSteps/UbisoftConnectStep.kt:58-73
Timestamp: 2026-03-19T10:46:06.073Z
Learning: In `app/src/main/java/app/gamenative/utils/preInstallSteps/UbisoftConnectStep.kt`, the `ensureInstallerAtCommonRedist` function is intentionally fail-closed: if `Files.createSymbolicLink` throws or the installer is not found in the game root, the function returns `false` and the Ubisoft Connect pre-install step is skipped entirely. There is no copy fallback — if the symlink cannot be created, installation is simply not attempted. Do not suggest adding a copy fallback in future reviews.

Learnt from: jeremybernstein
Repo: utkarshdalal/GameNative PR: 0
File: :0-0
Timestamp: 2026-03-27T10:17:59.692Z
Learning: In `app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt`, the `ConcurrentHashMap.getOrPut` usage in `onPathParameter` is intentionally not synchronized. The non-atomicity is benign: (1) same-key races produce an idempotent result because both threads list the same parent and see the same filesystem state; (2) different-casing segments (e.g., `_Work` vs `_work`) are different cache keys and never collide; (3) `onPathParameter` is a pure resolver — no directories are created here. The first `createDirectories` call downstream wins, and all subsequent resolutions converge on the on-disk entry. Do not flag the `getOrPut` non-atomicity as a race condition in future reviews of this file.

Learnt from: unbelievableflavour
Repo: utkarshdalal/GameNative PR: 860
File: app/src/main/java/com/winlator/xenvironment/ImageFsInstaller.java:361-367
Timestamp: 2026-03-15T20:17:26.633Z
Learning: In `app/src/main/java/com/winlator/xenvironment/ImageFsInstaller.java`, the `opt/proton` directory inside a Bionic variant root is a brand-new path introduced in PR `#860`'s imagefs symlink layout. It has no prior existence in any older layout, so the early `if (optProton.exists()) return;` guard in `ensureSharedProtonDir()` is safe — there is no migration scenario where a real (non-symlink) `opt/proton` directory could already be present. Do not flag this guard as a migration issue in future reviews.

Learnt from: jeremybernstein
Repo: utkarshdalal/GameNative PR: 801
File: app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt:174-188
Timestamp: 2026-03-10T22:12:03.164Z
Learning: In `app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt`, the `applyConfigForContainer` function calls `installMissingComponentsForConfig` before `BestConfigService.parseConfigToContainerData`. This install-before-parse ordering is pre-existing behavior, not introduced by any recent PR. Do not flag this ordering as an issue in future reviews of this file.

Learnt from: jeremybernstein
Repo: utkarshdalal/GameNative PR: 0
File: :0-0
Timestamp: 2026-03-10T22:13:54.453Z
Learning: In `app/src/main/java/app/gamenative/utils/BestConfigService.kt`, around lines 403-418, missing Box64/FEXCore preset failures (via `Box86_64PresetManager.getPreset("box64", ...)` and `FEXCorePresetManager.getPreset(...)`) are intentionally appended to the same `missingComponents` list as installable components, even though `resolveMissingManifestInstallRequests()` cannot install presets. This is a known limitation deferred for future work — the long-term direction is to serialize non-default presets into config files and auto-install them. Do not flag this mixing of installable vs. non-installable blockers in `missingComponents` as an issue in future reviews of this file.

Learnt from: utkarshdalal
Repo: utkarshdalal/GameNative PR: 344
File: app/src/main/java/app/gamenative/service/SteamAutoCloud.kt:415-419
Timestamp: 2025-12-17T05:14:05.133Z
Learning: In SteamAutoCloud.kt, when uploading files to Steam Cloud API (beginFileUpload and commitFileUpload), the filename parameter intentionally uses different formats: `file.path + file.filename` (relative path without placeholder) when `appInfo.ufs.saveFilePatterns.isEmpty()` is true (fallback case), and `file.prefixPath` (includes placeholder like %SteamUserData%) when patterns are configured. This difference is by design.

Learnt from: utkarshdalal
Repo: utkarshdalal/GameNative PR: 176
File: app/src/main/java/app/gamenative/utils/SteamUtils.kt:651-655
Timestamp: 2025-09-28T13:54:16.048Z
Learning: In the GameNative project, when creating depots.txt files in SteamUtils.ensureSteamSettings, only create the file when SteamService.getInstalledDepotsOf() returns actual data (non-null). Don't create empty files or write empty strings when no data is available.

Learnt from: utkarshdalal
Repo: utkarshdalal/GameNative PR: 159
File: app/src/main/java/app/gamenative/service/DownloadService.kt:6-6
Timestamp: 2025-09-19T17:07:27.941Z
Learning: In app/src/main/java/app/gamenative/service/DownloadService.kt, the getSizeFromStoreDisplay() function uses runBlocking to call SteamService.getDownloadableDepots() because the latter was converted from synchronous to suspend in this PR. The user confirmed the previous synchronous implementation worked fine, and if performance is acceptable, keeping runBlocking is a valid approach.

Learnt from: jeremybernstein
Repo: utkarshdalal/GameNative PR: 941
File: app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt:17-19
Timestamp: 2026-03-20T12:11:03.930Z
Learning: In `app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt`, the constructor default `delegate: FileSystem = SYSTEM` (line ~19) resolves correctly because `CaseInsensitiveFileSystem` extends `ForwardingFileSystem` which extends `FileSystem`, and `SYSTEM` is `FileSystem.Companion.SYSTEM` — accessible in this context on the JVM/Android target. The code compiles and runs. Do not flag the unqualified `SYSTEM` reference as an unresolved symbol in future reviews of this file.

@jeremybernstein jeremybernstein force-pushed the jb/case-insensitive-paths branch from ab8765a to ab06af8 Compare March 27, 2026 10:40
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt (1)

477-483: ⚠️ Potential issue | 🟠 Major

Resolve each manifest filename through the case-insensitive helper, and make the prefix check boundary-safe.

The raw File(saveDir, fileManifest.filename) join still recreates mixed-case child directories under an already-correct root, and startsWith(saveDir.canonicalPath) also accepts sibling escapes like ../MyGameBackup/... because it is only a string-prefix test.

Suggested fix
+            val saveDirCanonicalPath = saveDir.canonicalPath
             manifest.fileManifestList?.elements?.forEach { fileManifest ->
                 try {
-                    val outputFile = File(saveDir, fileManifest.filename)
-                    if (!outputFile.canonicalPath.startsWith(saveDir.canonicalPath)) {
+                    val outputFile = FileUtils.resolveCaseInsensitive(
+                        saveDir,
+                        fileManifest.filename.replace('\\', '/'),
+                    )
+                    val outputCanonicalPath = outputFile.canonicalPath
+                    if (
+                        outputCanonicalPath != saveDirCanonicalPath &&
+                        !outputCanonicalPath.startsWith("$saveDirCanonicalPath${File.separator}")
+                    ) {
                         Timber.tag("Epic").w("[Cloud Saves] Skipping path traversal: ${fileManifest.filename}")
                         return@forEach
                     }

Apply the same change in both reconstruction loops.

Also applies to: 647-653

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt`
around lines 477 - 483, Resolve each manifest filename through the existing
case-insensitive path resolver before joining with saveDir (i.e., pass
fileManifest.filename into the project's case-insensitive helper and use its
returned relative path), then build outputFile from saveDir + resolved name;
replace the naive startsWith(canonicalPath) check with a boundary-safe check
(e.g., compare outputFile.canonicalPath to saveDir.canonicalPath +
File.separator or compute a relative path and ensure it does not start with
"..") to prevent sibling escapes; update both reconstruction loops that create
outputFile, use outputFile.parentFile?.mkdirs() as before and keep the same
skip/log behavior if the safety check fails.
🧹 Nitpick comments (2)
app/src/test/java/app/gamenative/utils/CaseInsensitiveFileSystemTest.kt (2)

27-31: createTempDir is deprecated; consider using Files.createTempDirectory.

createTempDir has been deprecated since Kotlin 1.4. The modern replacement is java.nio.file.Files.createTempDirectory or kotlin.io.path.createTempDirectory.

♻️ Suggested fix
+import java.nio.file.Files
 import java.io.File

 ...

     `@Before`
     fun setUp() {
-        tmpDir = createTempDir("depot_test")
+        tmpDir = Files.createTempDirectory("depot_test").toFile()
         fs = CaseInsensitiveFileSystem()
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/test/java/app/gamenative/utils/CaseInsensitiveFileSystemTest.kt`
around lines 27 - 31, Replace the deprecated Kotlin createTempDir call in the
setUp() method with the java.nio API: change tmpDir =
createTempDir("depot_test") to create a temp directory using
Files.createTempDirectory (or kotlin.io.path.createTempDirectory) and assign its
path to tmpDir, ensuring tmpDir's type matches (convert to Path or File as
needed) and keep fs = CaseInsensitiveFileSystem() unchanged; update imports to
include java.nio.file.Files and handle any IOException if required.

22-31: Consider adding assumeTrue guard to skip on case-insensitive filesystems.

The docstring correctly notes these tests pass trivially on macOS. Adding a runtime guard would skip the tests on case-insensitive filesystems, preventing false confidence when run locally on macOS.

♻️ Suggested guard
+import org.junit.Assume.assumeTrue
 import org.junit.Before
 
 ...
 
+    private fun isCaseSensitiveFilesystem(): Boolean {
+        val testDir = createTempDir("case_test")
+        try {
+            val lower = File(testDir, "test")
+            val upper = File(testDir, "TEST")
+            lower.createNewFile()
+            return !upper.exists()
+        } finally {
+            testDir.deleteRecursively()
+        }
+    }
+
     `@Before`
     fun setUp() {
         tmpDir = createTempDir("depot_test")
         fs = CaseInsensitiveFileSystem()
+        assumeTrue("Test requires case-sensitive filesystem", isCaseSensitiveFilesystem())
     }

Based on learnings: "Such tests only pass on case-sensitive filesystems (Linux CI) and should guard with an assumeTrue(isCaseSensitiveFilesystem()) check."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/test/java/app/gamenative/utils/CaseInsensitiveFileSystemTest.kt`
around lines 22 - 31, Add a runtime guard in CaseInsensitiveFileSystemTest to
skip on case-insensitive filesystems: in the setUp method of
CaseInsensitiveFileSystemTest, call
Assume.assumeTrue(isCaseSensitiveFilesystem()) (or equivalent helper) before
creating tmpDir and fs so the test is skipped on macOS/other case-insensitive
environments; ensure you import org.junit.Assume.assumeTrue and/or provide the
isCaseSensitiveFilesystem() helper used to detect filesystem sensitivity.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt`:
- Around line 477-483: Resolve each manifest filename through the existing
case-insensitive path resolver before joining with saveDir (i.e., pass
fileManifest.filename into the project's case-insensitive helper and use its
returned relative path), then build outputFile from saveDir + resolved name;
replace the naive startsWith(canonicalPath) check with a boundary-safe check
(e.g., compare outputFile.canonicalPath to saveDir.canonicalPath +
File.separator or compute a relative path and ensure it does not start with
"..") to prevent sibling escapes; update both reconstruction loops that create
outputFile, use outputFile.parentFile?.mkdirs() as before and keep the same
skip/log behavior if the safety check fails.

---

Nitpick comments:
In `@app/src/test/java/app/gamenative/utils/CaseInsensitiveFileSystemTest.kt`:
- Around line 27-31: Replace the deprecated Kotlin createTempDir call in the
setUp() method with the java.nio API: change tmpDir =
createTempDir("depot_test") to create a temp directory using
Files.createTempDirectory (or kotlin.io.path.createTempDirectory) and assign its
path to tmpDir, ensuring tmpDir's type matches (convert to Path or File as
needed) and keep fs = CaseInsensitiveFileSystem() unchanged; update imports to
include java.nio.file.Files and handle any IOException if required.
- Around line 22-31: Add a runtime guard in CaseInsensitiveFileSystemTest to
skip on case-insensitive filesystems: in the setUp method of
CaseInsensitiveFileSystemTest, call
Assume.assumeTrue(isCaseSensitiveFilesystem()) (or equivalent helper) before
creating tmpDir and fs so the test is skipped on macOS/other case-insensitive
environments; ensure you import org.junit.Assume.assumeTrue and/or provide the
isCaseSensitiveFilesystem() helper used to detect filesystem sensitivity.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c0bd8986-ff33-4ffa-9e07-1d60b268525f

📥 Commits

Reviewing files that changed from the base of the PR and between ab8765a and ab06af8.

📒 Files selected for processing (8)
  • app/src/main/java/app/gamenative/service/SteamService.kt
  • app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt
  • app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt
  • app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt
  • app/src/main/java/app/gamenative/utils/FileUtils.kt
  • app/src/test/java/app/gamenative/utils/CaseInsensitiveFileSystemTest.kt
  • app/src/test/java/app/gamenative/utils/CaseInsensitivePathResolutionTest.kt
  • gradle/libs.versions.toml
✅ Files skipped from review due to trivial changes (3)
  • app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt
  • gradle/libs.versions.toml
  • app/src/test/java/app/gamenative/utils/CaseInsensitivePathResolutionTest.kt
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/src/main/java/app/gamenative/utils/FileUtils.kt

@jeremybernstein jeremybernstein force-pushed the jb/case-insensitive-paths branch from ab06af8 to be7a2f0 Compare March 27, 2026 10:54
@jeremybernstein
Copy link
Copy Markdown
Contributor Author

@coderabbitai, but good catch -- those new tests confirmed that the race condition concern which I dismissed above (#941 (comment)) can in fact arise in practice. Fixed and fixed.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 27, 2026

Tip

For best results, initiate chat on the files or code changes.

@jeremybernstein: Thanks for the update — glad the tests caught it! I was too quick to agree that the race was benign; the concurrent-write scenario with different casings resolving to different paths and both being cached was a real correctness hazard, not just a theoretical one. Good that it's been caught and fixed before merge.

I'll remove the stale learning that declared the getOrPut non-atomicity safe, and add a corrected one.


✏️ Learnings added
Learnt from: jeremybernstein
URL: https://github.com/utkarshdalal/GameNative/pull/941

Timestamp: 2026-03-27T11:02:11.489Z
Learning: In `app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt`, the `ConcurrentHashMap.getOrPut` non-atomicity in `onPathParameter` was initially dismissed as benign but was confirmed by tests to be a real race condition in practice: two threads with differently-cased path segments for the same directory could each compute and cache a different resolution, causing duplicate directories to be created — the exact problem the class is meant to prevent. The race was subsequently fixed (PR `#941`). Do not treat the `getOrPut` pattern as safe without proper synchronization in future implementations of this class.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


⛔ Learnings removed
Learnt from: jeremybernstein
Repo: utkarshdalal/GameNative PR: 0
File: :0-0
Timestamp: 2026-03-27T10:17:59.692Z
Learning: In `app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt`, the `ConcurrentHashMap.getOrPut` usage in `onPathParameter` is intentionally not synchronized. The non-atomicity is benign: (1) same-key races produce an idempotent result because both threads list the same parent and see the same filesystem state; (2) different-casing segments (e.g., `_Work` vs `_work`) are different cache keys and never collide; (3) `onPathParameter` is a pure resolver — no directories are created here. The first `createDirectories` call downstream wins, and all subsequent resolutions converge on the on-disk entry. Do not flag the `getOrPut` non-atomicity as a race condition in future reviews of this file.

🧠 Learnings used
Learnt from: jeremybernstein
Repo: utkarshdalal/GameNative PR: 0
File: :0-0
Timestamp: 2026-03-27T10:36:22.471Z
Learning: In `app/src/main/java/app/gamenative/service/SteamService.kt`, around lines 1549-1559, a full integration regression test for the `CaseInsensitiveFileSystem` wired into `DepotDownloader` is not feasible: `DepotDownloader` connects to a real online depot server that cannot be controlled in tests. The appropriate test strategy is a simulated-response fixture that exercises the filesystem wiring directly. Such tests only pass on case-sensitive filesystems (Linux CI) and should guard with an `assumeTrue(isCaseSensitiveFilesystem())` check. Do not ask for a live-depot integration fixture at this constructor site in future reviews.

Learnt from: jeremybernstein
Repo: utkarshdalal/GameNative PR: 929
File: app/src/main/java/app/gamenative/service/SteamService.kt:763-765
Timestamp: 2026-03-19T17:57:45.184Z
Learning: In `app/src/main/java/app/gamenative/service/SteamService.kt`, the `has64Bit` and `hasNonDeckWin` preference flags in `getMainAppDepots()`, `getDownloadableDepots()`, and the DLC branch are intentionally computed from the same `eligibleDepots` set. These flags are orthogonal (64-bit vs 32-bit and Deck vs non-Deck are independent axes), so computing them from the same set does not cause conflation. The theoretical edge case of a Deck-only x64 depot paired with a non-Deck x86 depot (with no non-Deck x64 depot) is practically non-existent in the Steam depot ecosystem. Do not flag this pattern as a potential self-cancellation issue in future reviews.

Learnt from: utkarshdalal
Repo: utkarshdalal/GameNative PR: 344
File: app/src/main/java/app/gamenative/service/SteamAutoCloud.kt:415-419
Timestamp: 2025-12-17T05:14:05.133Z
Learning: In SteamAutoCloud.kt, when uploading files to Steam Cloud API (beginFileUpload and commitFileUpload), the filename parameter intentionally uses different formats: `file.path + file.filename` (relative path without placeholder) when `appInfo.ufs.saveFilePatterns.isEmpty()` is true (fallback case), and `file.prefixPath` (includes placeholder like %SteamUserData%) when patterns are configured. This difference is by design.

Learnt from: jeremybernstein
Repo: utkarshdalal/GameNative PR: 929
File: app/src/main/java/app/gamenative/service/SteamService.kt:807-814
Timestamp: 2026-03-20T09:44:35.386Z
Learning: In `app/src/main/java/app/gamenative/service/SteamService.kt`, in `getDownloadableDepots()`, the base app's `has64Bit` flag (derived from the base app's `eligibleDepots`) is intentionally reused when filtering indirect DLC depots. DLC depots install into the same directory as the base game, so architecture must be consistent across the entire install. Using a per-DLC `dlcHas64Bit` would be wrong because a 32-bit DLC depot is incompatible with a 64-bit base game install. Do not flag this reuse of the base app's `has64Bit` for DLC depot filtering as a bug in future reviews.

Learnt from: jeremybernstein
Repo: utkarshdalal/GameNative PR: 941
File: app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt:1260-1264
Timestamp: 2026-03-20T12:09:32.974Z
Learning: In `app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt`, the `installDir` variable in `resolveSaveDirectory` is either `game.installPath` (stored by the app during install, deterministic casing) or the result of `EpicConstants.getGameInstallPath(context, game.appName)` (pure string concatenation, no disk lookup). In both cases the casing is controlled by the app and is guaranteed to match what is on disk. Do not flag the `withinPrefix` check comparing `resolved.absolutePath` against `installDir` as a potential casing mismatch — no case-resolution of `installDir` is needed there.

Learnt from: utkarshdalal
Repo: utkarshdalal/GameNative PR: 176
File: app/src/main/java/app/gamenative/utils/SteamUtils.kt:651-655
Timestamp: 2025-09-28T13:54:16.048Z
Learning: In the GameNative project, when creating depots.txt files in SteamUtils.ensureSteamSettings, only create the file when SteamService.getInstalledDepotsOf() returns actual data (non-null). Don't create empty files or write empty strings when no data is available.

Learnt from: jeremybernstein
Repo: utkarshdalal/GameNative PR: 0
File: :0-0
Timestamp: 2026-03-10T22:13:54.453Z
Learning: In `app/src/main/java/app/gamenative/utils/BestConfigService.kt`, around lines 403-418, missing Box64/FEXCore preset failures (via `Box86_64PresetManager.getPreset("box64", ...)` and `FEXCorePresetManager.getPreset(...)`) are intentionally appended to the same `missingComponents` list as installable components, even though `resolveMissingManifestInstallRequests()` cannot install presets. This is a known limitation deferred for future work — the long-term direction is to serialize non-default presets into config files and auto-install them. Do not flag this mixing of installable vs. non-installable blockers in `missingComponents` as an issue in future reviews of this file.

Learnt from: jeremybernstein
Repo: utkarshdalal/GameNative PR: 801
File: app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt:174-188
Timestamp: 2026-03-10T22:12:03.164Z
Learning: In `app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt`, the `applyConfigForContainer` function calls `installMissingComponentsForConfig` before `BestConfigService.parseConfigToContainerData`. This install-before-parse ordering is pre-existing behavior, not introduced by any recent PR. Do not flag this ordering as an issue in future reviews of this file.

Learnt from: jeremybernstein
Repo: utkarshdalal/GameNative PR: 941
File: app/src/main/java/app/gamenative/utils/FileUtils.kt:229-235
Timestamp: 2026-03-27T10:29:59.174Z
Learning: In `app/src/main/java/app/gamenative/utils/FileUtils.kt`, `resolveCaseInsensitive` intentionally uses `firstOrNull` when multiple case-only siblings exist (e.g. `_Work` and `_work` left from a prior bug). The non-deterministic first-match behavior is a documented trade-off: failing fast would break downloads for users who already have duplicate directories, and resilience is the explicit design goal. The KDoc for the function documents this. Do not flag this as a correctness or determinism issue in future reviews. Additionally, `listFiles()` returning `null` is already handled: the `match == null` branch falls through to the `else` clause that appends remaining segments verbatim.

Learnt from: jeremybernstein
Repo: utkarshdalal/GameNative PR: 941
File: app/src/main/java/app/gamenative/utils/FileUtils.kt:212-239
Timestamp: 2026-03-20T12:12:57.725Z
Learning: In `app/src/main/java/app/gamenative/utils/FileUtils.kt`, `resolveCaseInsensitive` and `findFileCaseInsensitive` do NOT need internal path-traversal normalization. All callers handle this upstream: Epic normalizes `..`/`.` via a manual stack algorithm (lines 1239–1254 of `EpicCloudSavesManager.kt`) and enforces a `withinPrefix` guard (lines 1257–1268); GOG uses trusted server-supplied `file.relativePath`; all other callers (executable/config lookups) never have `../` in their paths. Do not flag absence of traversal guards in the utility itself as a security issue.

Learnt from: utkarshdalal
Repo: utkarshdalal/GameNative PR: 159
File: app/src/main/java/app/gamenative/service/DownloadService.kt:6-6
Timestamp: 2025-09-19T17:07:27.941Z
Learning: In app/src/main/java/app/gamenative/service/DownloadService.kt, the getSizeFromStoreDisplay() function uses runBlocking to call SteamService.getDownloadableDepots() because the latter was converted from synchronous to suspend in this PR. The user confirmed the previous synchronous implementation worked fine, and if performance is acceptable, keeping runBlocking is a valid approach.

Learnt from: jeremybernstein
Repo: utkarshdalal/GameNative PR: 918
File: app/src/main/java/app/gamenative/service/SteamService.kt:3091-3102
Timestamp: 2026-03-19T10:03:51.598Z
Learning: In `app/src/main/java/app/gamenative/service/SteamService.kt`, `connectToSteam()` uses a `retryAttemptAtStart` snapshot to guard the 5s watchdog: after the delay, the watchdog only marks the endpoint bad and disconnects if `!isConnected && retryAttempt == retryAttemptAtStart`. Since `onConnected` resets `retryAttempt = 0` and `onDisconnected` increments `retryAttempt++`, any callback activity during the 5s window changes `retryAttempt`, causing the watchdog to back off. This is the intentional, correct design. Do not flag the `delay(5000)` + `retryAttempt` guard pattern as a race condition in future reviews.

Learnt from: jeremybernstein
Repo: utkarshdalal/GameNative PR: 781
File: app/src/main/java/app/gamenative/service/DownloadService.kt:40-44
Timestamp: 2026-03-10T08:43:03.081Z
Learning: In the GameNative app (utkarshdalal/GameNative), games installed on external storage (SD cards, USB) are stored inside the app's `files` directory: `.../Android/data/app.gamenative/files/Steam/steamapps/common`. Therefore, `context.getExternalFilesDirs(null).map { it.absolutePath }` correctly provides the base paths for scanning external volume installs in `DownloadService.kt`. The `parentFile` usage in `baseExternalAppDirPath` is only for the write-path concern (creating siblings of the `files` dir) and is unrelated to scan paths.

Learnt from: jeremybernstein
Repo: utkarshdalal/GameNative PR: 0
File: :0-0
Timestamp: 2026-03-18T16:30:26.610Z
Learning: In `app/src/main/java/app/gamenative/MainActivity.kt`, `peekPendingLaunchRequest()` is an intentional non-destructive, synchronized read of `pendingLaunchRequest`. The peek + separate consume pattern (also present at line 436) is pre-existing and accepted; the worst-case outcome of a race is an unnecessary snackbar, which is benign. Do not flag `peekPendingLaunchRequest()` as a TOCTOU race issue in future reviews.

Learnt from: jeremybernstein
Repo: utkarshdalal/GameNative PR: 0
File: :0-0
Timestamp: 2026-03-27T10:17:59.692Z
Learning: In `app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt`, the `ConcurrentHashMap.getOrPut` usage in `onPathParameter` is intentionally not synchronized. The non-atomicity is benign: (1) same-key races produce an idempotent result because both threads list the same parent and see the same filesystem state; (2) different-casing segments (e.g., `_Work` vs `_work`) are different cache keys and never collide; (3) `onPathParameter` is a pure resolver — no directories are created here. The first `createDirectories` call downstream wins, and all subsequent resolutions converge on the on-disk entry. Do not flag the `getOrPut` non-atomicity as a race condition in future reviews of this file.

Learnt from: jeremybernstein
Repo: utkarshdalal/GameNative PR: 941
File: app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt:17-19
Timestamp: 2026-03-20T12:11:03.930Z
Learning: In `app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt`, the constructor default `delegate: FileSystem = SYSTEM` (line ~19) resolves correctly because `CaseInsensitiveFileSystem` extends `ForwardingFileSystem` which extends `FileSystem`, and `SYSTEM` is `FileSystem.Companion.SYSTEM` — accessible in this context on the JVM/Android target. The code compiles and runs. Do not flag the unqualified `SYSTEM` reference as an unresolved symbol in future reviews of this file.

Learnt from: unbelievableflavour
Repo: utkarshdalal/GameNative PR: 860
File: app/src/main/java/com/winlator/xenvironment/ImageFsInstaller.java:361-367
Timestamp: 2026-03-15T20:17:26.633Z
Learning: In `app/src/main/java/com/winlator/xenvironment/ImageFsInstaller.java`, the `opt/proton` directory inside a Bionic variant root is a brand-new path introduced in PR `#860`'s imagefs symlink layout. It has no prior existence in any older layout, so the early `if (optProton.exists()) return;` guard in `ensureSharedProtonDir()` is safe — there is no migration scenario where a real (non-symlink) `opt/proton` directory could already be present. Do not flag this guard as a migration issue in future reviews.

If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

@utkarshdalal utkarshdalal merged commit a4849b0 into utkarshdalal:master Mar 28, 2026
2 checks passed
@jeremybernstein jeremybernstein deleted the jb/case-insensitive-paths branch March 28, 2026 07:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Case-sensitive filesystem creates duplicate directories during downloads and cloud saves

3 participants