Skip to content

fix(envvars): escape spaces on round-trip; fix crash on save#1429

Open
jeremybernstein wants to merge 2 commits into
utkarshdalal:masterfrom
jeremybernstein:jb/fix-env-var-input
Open

fix(envvars): escape spaces on round-trip; fix crash on save#1429
jeremybernstein wants to merge 2 commits into
utkarshdalal:masterfrom
jeremybernstein:jb/fix-env-var-input

Conversation

@jeremybernstein
Copy link
Copy Markdown
Contributor

@jeremybernstein jeremybernstein commented May 12, 2026

Description

Fixes a crash when saving an environment variable whose value contains a space (e.g. DXVK_CONFIG = d3d9.presentInterval = 1;).

EnvVars.toString() joined KEY=value pairs with a literal space, but EnvVars.putAll(String) split on a literal space and then substring(0, indexOf("=")) on each token. A spaced value produced tokens without =, so indexOf returned -1 and substring(0, -1) threw StringIndexOutOfBoundsException on the recompose right after the user tapped OK.

Made toString() / putAll(String) escape-aware (backslash-escapes \ and ). toStringArray() stays raw — that's the env vector handed to execve, which must not see escapes. toEscapedString() (used to compose env KEY=val ... cmd shell strings) now aliases toString() since the new format is also shell-safe and was previously slightly different (didn't escape \).

Closes #1428

Recording

N/A — crash fix, no visible behavior change beyond "doesn't crash". Happy to attach a before/after if needed.

Type of Change

  • Bug fix
  • Performance / stability improvement
  • Compatibility improvements
  • Other (requires prior approval)

Checklist

  • If I have access to #code-changes, I have discussed this change there and it has been green-lighted. If I do not have access, I have still provided clear context in this PR. If I skip both, I accept that this change may face delays in review, may not be reviewed at all, or may be closed.
  • This change aligns with the current project scope (core functionality, stability, or performance). If not, it has been explicitly approved beforehand.
  • I have attached a recording of the change.
  • I have read and agree to the contribution guidelines in CONTRIBUTING.md.

Tests

New EnvVarsTest covers the round-trip contract: plain values, value-with-spaces (the crash repro), value with multiple spaces + = + ; (multi-clause DXVK config), semicolon-only values, embedded backslashes, pre-escaped \<space> literal in source, empty values, and that toStringArray() returns raw values for execve.

Verified on device

Built :app:assembleDebug, installed on a physical device, manually entered DXVK_CONFIG = d3d9.presentInterval = 1; (with and without surrounding quotes) — no crash, value persists across dialog open/close.


Summary by cubic

Fixes a crash when saving environment variables with spaces by making EnvVars parse/serialize escape-aware. Round-trips are now lossless while execve env values stay raw.

  • Bug Fixes
    • EnvVars: escape-aware toString() and putAll(String) for spaces and backslashes; tolerates stray tokens without =.
    • toEscapedString() now aliases toString(); toStringArray() stays raw for execve.
    • Added EnvVarsTest covering spaces, backslashes, empty values, order, and raw toStringArray(); switched to JUnit assertTrue so checks run without -ea.

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

Summary by CodeRabbit

  • Bug Fixes

    • Parsing now correctly handles escaped spaces and skips stray/invalid tokens to tolerate corrupted legacy data.
  • Improvements

    • Canonicalized serialization with consistent escaping rules; shell-facing escaped-string API unified with persistence format.
    • Env var array output remains raw KEY=VALUE entries; insertion order and empty/null values preserved.
  • Tests

    • Added comprehensive round-trip tests covering spaces, backslashes, empty values, and ordering.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 12, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c8361bfb-080c-4976-9360-4c483efd7464

📥 Commits

Reviewing files that changed from the base of the PR and between 3f34bea and d5d38be.

📒 Files selected for processing (1)
  • app/src/test/java/com/winlator/core/envvars/EnvVarsTest.kt
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/src/test/java/com/winlator/core/envvars/EnvVarsTest.kt

📝 Walkthrough

Walkthrough

EnvVars now escapes/unescapes backslashes and spaces for persistent serialization and parsing; putAll(String) parses escaped tokens, toString() emits canonical escaped form, and tests verify round-trip preservation and execve-compatible arrays.

Changes

Environment Variable Parsing and Serialization (Fixes #1428)

Layer / File(s) Summary
Escape/Unescape Utilities and Parsing Strategy
app/src/main/java/com/winlator/core/envvars/EnvVars.java
New private methods escape, unescape, and splitOnUnescapedSpaces. putAll(String) tokenizes on unescaped spaces, skips tokens missing =, and unescapes keys and values before storing.
Serialization with Proper Escaping
app/src/main/java/com/winlator/core/envvars/EnvVars.java
toString() emits canonical persistence form by escaping backslashes then spaces in keys and values. toEscapedString() now returns toString().
Round-Trip Parsing and Serialization Tests
app/src/test/java/com/winlator/core/envvars/EnvVarsTest.kt
Adds tests validating round-trip parsing/serialization for values with spaces, semicolons, backslashes, escaped-space literals, empty/null inputs, insertion-order, and that toStringArray() yields raw KEY=VALUE entries for execve.

Sequence Diagram

sequenceDiagram
  participant Input as Serialized String
  participant Split as splitOnUnescapedSpaces
  participant Parse as putAll loop
  participant Unescape as unescape
  participant Store as EnvVars map
  participant Output as toString
  Input->>Split: split on unescaped spaces
  Split->>Parse: iterate tokens ("KEY=VALUE")
  Parse->>Parse: find '=' delimiter
  Parse->>Unescape: unescape key & value
  Unescape->>Store: put(key, value)
  Store->>Output: reconstruct escaped string via escape()
  Output->>Input: emits escaped persistence form
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

I hop through strings with whiskers keen,
Escaping spaces, backslashes seen.
From saved line to parsed array,
No more crashes on the way. 🐇
Round-trips pass — the fields stay clean.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.81% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main fix: addressing a crash when saving environment variables with spaces through escape-aware round-trip serialization.
Description check ✅ Passed The description fully covers the issue, root cause, implementation approach, and testing. All required sections are present and well-documented, including type of change and checklist items.
Linked Issues check ✅ Passed The code changes directly address all objectives from #1428: fixing the crash on space-containing values, making values persist correctly, implementing escape-aware serialization for round-trips, and preserving raw values for execve.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the environment variable serialization crash; no unrelated modifications are present beyond what's required to resolve issue #1428.

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

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

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


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.

EnvVars.toString() joined K=V pairs with " " but putAll() split on " "
and substring'd at indexOf("="). A value with a space (e.g. DXVK_CONFIG=
"d3d9.presentInterval = 1;") produced tokens with no "=", indexOf
returned -1, and substring(0, -1) threw StringIndexOutOfBoundsException
on the next recompose of the Environment dialog.

Make toString()/putAll() escape-aware (backslash-escape \ and space);
keep toStringArray() raw for execve. toEscapedString() now aliases
toString() since the new format is shell-safe too.
@jeremybernstein
Copy link
Copy Markdown
Contributor Author

sorry, accidentally pushed with my dev environment still on the branch. re-pushed with it removed

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 (4)
app/src/main/java/app/gamenative/ui/components/BootingSplash.kt (1)

165-167: ⚡ Quick win

Avoid redundant LocalContext.current accesses.

LocalContext.current is accessed twice on consecutive lines. In Compose, reading composition locals has a small cost, and the value doesn't change within the same composition.

♻️ Use a local variable
-                    val appLabel = androidx.compose.ui.platform.LocalContext.current.applicationInfo.loadLabel(
-                        androidx.compose.ui.platform.LocalContext.current.packageManager
-                    ).toString()
+                    val context = LocalContext.current
+                    val appLabel = context.applicationInfo.loadLabel(context.packageManager).toString()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/app/gamenative/ui/components/BootingSplash.kt` around lines
165 - 167, The code redundantly calls LocalContext.current twice when computing
appLabel; capture LocalContext.current once into a local val (e.g., val context
= LocalContext.current) and then call
context.applicationInfo.loadLabel(context.packageManager).toString() so appLabel
uses the single cached context value (update the expression that creates
appLabel accordingly).
app/src/main/java/app/gamenative/ui/screen/login/UserLoginScreen.kt (1)

353-355: ⚡ Quick win

Avoid redundant LocalContext.current accesses.

LocalContext.current is accessed twice on consecutive lines. In Compose, reading composition locals has a small cost, and the value doesn't change within the same composition.

♻️ Use a local variable
+                val context = LocalContext.current
                 Text(
-                    text = androidx.compose.ui.platform.LocalContext.current.applicationInfo.loadLabel(
-                        androidx.compose.ui.platform.LocalContext.current.packageManager
-                    ).toString(),
+                    text = context.applicationInfo.loadLabel(context.packageManager).toString(),
                     style = MaterialTheme.typography.headlineSmall.copy(
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/app/gamenative/ui/screen/login/UserLoginScreen.kt` around
lines 353 - 355, Redundant accesses to LocalContext.current are used when
computing the app label; capture the composition local once into a local val
(e.g., val context = LocalContext.current) inside the UserLoginScreen scope and
replace both occurrences with context, then call
context.applicationInfo.loadLabel(context.packageManager).toString(); this
reduces repeated composition-local reads while preserving behavior.
app/src/main/cpp/extras/evshim.c (2)

95-97: 💤 Low value

Remove temporary "NEW" comments.

The /* NEW */ comments on lines 95 and 97 are no longer needed now that the change is complete.

♻️ Clean up temporary markers
-    pthread_mutex_lock(&shm_mutex);             /* NEW */
+    pthread_mutex_lock(&shm_mutex);
     ssize_t w = pwrite(rumble_fd[idx], vals, sizeof(vals), 32);
-    pthread_mutex_unlock(&shm_mutex);           /* NEW */
+    pthread_mutex_unlock(&shm_mutex);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/cpp/extras/evshim.c` around lines 95 - 97, Remove the temporary
"/* NEW */" markers left around the mutex operations: delete the comment tokens
adjacent to pthread_mutex_lock(&shm_mutex) and pthread_mutex_unlock(&shm_mutex)
in the evshim.c change so the lines read simply pthread_mutex_lock(&shm_mutex);
and pthread_mutex_unlock(&shm_mutex); surrounding the pwrite call that uses
rumble_fd[idx], vals and sizeof(vals).

133-156: ⚡ Quick win

Volatile qualifier is stripped by casts, defeating synchronization intent.

Declaring cur as volatile (line 133) but then casting it to void* (line 140) and const void* (lines 142, 156) strips the volatile qualifier. This defeats the purpose of volatile, which is to prevent the compiler from optimizing away accesses.

Since you're already using shm_mutex to protect this critical section (lines 138, 162), volatile is unnecessary. The mutex provides the required memory ordering guarantees.

♻️ Remove unnecessary volatile qualifier
-    volatile struct gamepad_io cur;
+    struct gamepad_io cur;
     struct gamepad_io last_state = {0};
-    memset((void*)&cur, 0, sizeof cur);
+    memset(&cur, 0, sizeof cur);
 
     for (;;) {
         pthread_mutex_lock(&shm_mutex);
 
-        ssize_t n = pread(fd, (void*)&cur, sizeof cur, 0);
+        ssize_t n = pread(fd, &cur, sizeof cur, 0);
 
-        if (n == (ssize_t)sizeof cur && memcmp((const void*)&cur, &last_state, sizeof cur) != 0) {
+        if (n == (ssize_t)sizeof cur && memcmp(&cur, &last_state, sizeof cur) != 0) {
 
             p_SDL_JoystickSetVirtualAxis (js, 0, cur.lx);
             p_SDL_JoystickSetVirtualAxis (js, 1, cur.ly);
@@ -152,7 +152,7 @@
 
             p_SDL_JoystickSetVirtualHat(js, 0, cur.hat);
 
-            memcpy(&last_state, (const void*)&cur, sizeof cur);
+            memcpy(&last_state, &cur, sizeof cur);
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/cpp/extras/evshim.c` around lines 133 - 156, The local variable
declared as "volatile struct gamepad_io cur;" should drop the volatile
qualifier—remove "volatile" from the declaration of cur and update the pointer
uses so you're casting to plain (void*)/ (const void*) rather than casting away
volatile; keep the existing pthread_mutex_lock(&shm_mutex) critical section and
leave the pread(fd, &cur, ...), memcmp(&cur, &last_state, ...), and
memcpy(&last_state, &cur, ...) calls inside it to preserve synchronization.
Ensure symbols touched: cur, last_state, pread, memcmp, memcpy,
pthread_mutex_lock(&shm_mutex), and the p_SDL_JoystickSetVirtual* calls remain
unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/build.gradle.kts`:
- Around line 130-131: The release build is incorrectly inheriting debug
identity and label; remove or relocate applicationIdSuffix = ".debug" and
manifestPlaceholders["appLabel"] = "GameNative Dev" from the shared config and
instead set them only inside the debug buildType (or debug productFlavor) block
so that release keeps the clean applicationId and proper appLabel; update the
buildTypes { debug { applicationIdSuffix = ".debug";
manifestPlaceholders["appLabel"] = "GameNative Dev" } release { /* no debug
suffix/label */ } } ensuring applicationIdSuffix and
manifestPlaceholders["appLabel"] are not applied to the release variant.

In `@app/src/test/java/com/winlator/core/envvars/EnvVarsTest.kt`:
- Line 69: Replace usages of Kotlin's assert with JUnit assertions to ensure
tests run regardless of JVM assertion settings: import and use
org.junit.Assert.assertTrue (or assertFalse/assertEquals as appropriate) in
EnvVarsTest, e.g. replace the call that checks parsed.has("EMPTY") and the other
assert(...) calls with assertTrue(parsed.has("EMPTY")) (and corresponding
assertTrue/assertFalse for the other two assertions) so the test framework
enforces them reliably.

---

Nitpick comments:
In `@app/src/main/cpp/extras/evshim.c`:
- Around line 95-97: Remove the temporary "/* NEW */" markers left around the
mutex operations: delete the comment tokens adjacent to
pthread_mutex_lock(&shm_mutex) and pthread_mutex_unlock(&shm_mutex) in the
evshim.c change so the lines read simply pthread_mutex_lock(&shm_mutex); and
pthread_mutex_unlock(&shm_mutex); surrounding the pwrite call that uses
rumble_fd[idx], vals and sizeof(vals).
- Around line 133-156: The local variable declared as "volatile struct
gamepad_io cur;" should drop the volatile qualifier—remove "volatile" from the
declaration of cur and update the pointer uses so you're casting to plain
(void*)/ (const void*) rather than casting away volatile; keep the existing
pthread_mutex_lock(&shm_mutex) critical section and leave the pread(fd, &cur,
...), memcmp(&cur, &last_state, ...), and memcpy(&last_state, &cur, ...) calls
inside it to preserve synchronization. Ensure symbols touched: cur, last_state,
pread, memcmp, memcpy, pthread_mutex_lock(&shm_mutex), and the
p_SDL_JoystickSetVirtual* calls remain unchanged.

In `@app/src/main/java/app/gamenative/ui/components/BootingSplash.kt`:
- Around line 165-167: The code redundantly calls LocalContext.current twice
when computing appLabel; capture LocalContext.current once into a local val
(e.g., val context = LocalContext.current) and then call
context.applicationInfo.loadLabel(context.packageManager).toString() so appLabel
uses the single cached context value (update the expression that creates
appLabel accordingly).

In `@app/src/main/java/app/gamenative/ui/screen/login/UserLoginScreen.kt`:
- Around line 353-355: Redundant accesses to LocalContext.current are used when
computing the app label; capture the composition local once into a local val
(e.g., val context = LocalContext.current) inside the UserLoginScreen scope and
replace both occurrences with context, then call
context.applicationInfo.loadLabel(context.packageManager).toString(); this
reduces repeated composition-local reads while preserving behavior.
🪄 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: 8c705470-48bf-48cc-9f24-26f8edd6cf15

📥 Commits

Reviewing files that changed from the base of the PR and between a634c4a and 38dd417.

⛔ Files ignored due to path filters (1)
  • app/src/main/jniLibs/arm64-v8a/libevshim.so is excluded by !**/*.so
📒 Files selected for processing (15)
  • ARCHITECTURE.md
  • app/build.gradle.kts
  • app/src/main/AndroidManifest.xml
  • app/src/main/cpp/evshim/CMakeLists.txt
  • app/src/main/cpp/extras/evshim.c
  • app/src/main/java/app/gamenative/ui/components/BootingSplash.kt
  • app/src/main/java/app/gamenative/ui/screen/login/UserLoginScreen.kt
  • app/src/main/java/app/gamenative/utils/IntentLaunchManager.kt
  • app/src/main/java/com/winlator/container/Container.java
  • app/src/main/java/com/winlator/core/DXVKHelper.java
  • app/src/main/java/com/winlator/core/WineUtils.java
  • app/src/main/java/com/winlator/core/envvars/EnvVars.java
  • app/src/main/java/com/winlator/winhandler/WinHandler.java
  • app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java
  • app/src/test/java/com/winlator/core/envvars/EnvVarsTest.kt

Comment thread app/build.gradle.kts Outdated
Comment thread app/src/test/java/com/winlator/core/envvars/EnvVarsTest.kt Outdated
kotlin assert() is a no-op when JVM assertions aren't enabled
(gradle's Test task default). switch to org.junit.Assert.assertTrue
so the framework enforces the checks regardless of -ea.
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 16 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/ui/components/BootingSplash.kt">

<violation number="1" location="app/src/main/java/app/gamenative/ui/components/BootingSplash.kt:165">
P2: App label resolved from PackageManager on every animation-driven recomposition instead of being memoized</violation>
</file>

<file name="app/src/main/java/com/winlator/core/WineUtils.java">

<violation number="1" location="app/src/main/java/com/winlator/core/WineUtils.java:48">
P2: Existing E: drive targets are not migrated to the new APPLICATION_ID-based storage path, so upgraded containers can keep a stale/broken E: mapping.</violation>
</file>

<file name="app/src/main/AndroidManifest.xml">

<violation number="1" location="app/src/main/AndroidManifest.xml:60">
P2: Changing the launch action to `${applicationId}.LAUNCH_GAME` breaks the existing hardcoded sender that still uses `app.gamenative.LAUNCH_GAME`, so launcher shortcuts can stop resolving in non-release variants.</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/ui/components/BootingSplash.kt Outdated
Comment thread app/src/main/java/com/winlator/core/WineUtils.java Outdated
Comment thread app/src/main/AndroidManifest.xml Outdated
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.

[Bug]: Crash when saving environment variable with space in value

1 participant