Skip to content

Commit 9c7dc23

Browse files
fix(shims): Preserve system PATH case on Windows in goenv exec (#557)
Windows environment variables are case-insensitive but os.Environ() preserves the original casing (e.g., Path, PATH, path). Previously, setEnvVar() and prependToPath() used case-sensitive string matching, causing duplicate environment variables when the casing didn't match exactly. This caused PATH to be overwritten instead of prepended, breaking CGO builds when gcc was in the system PATH but not found after goenv exec. Changes: - Added findEnvVar() helper that uses case-insensitive matching on Windows - Updated setEnvVar() to use findEnvVar and preserve original casing - Updated prependToPath() to use findEnvVar and preserve original casing - Added comprehensive unit tests including regression test for issue #557 Fixes #557
1 parent ea61d60 commit 9c7dc23

2 files changed

Lines changed: 290 additions & 17 deletions

File tree

cmd/shims/exec.go

Lines changed: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -374,16 +374,44 @@ func runRehashSilent(cfg *config.Config, env *utils.GoenvEnvironment) error {
374374
return shimMgr.Rehash()
375375
}
376376

377-
// setEnvVar sets or updates an environment variable
378-
func setEnvVar(env []string, key, value string) []string {
379-
prefix := key + "="
377+
// findEnvVar finds an environment variable in the env slice using case-insensitive matching on Windows.
378+
// Returns the index and the actual key name (with original casing) if found, or -1 and empty string if not found.
379+
// On Unix systems, matching is case-sensitive.
380+
func findEnvVar(env []string, key string) (int, string) {
380381
for i, envVar := range env {
381-
if strings.HasPrefix(envVar, prefix) {
382-
env[i] = prefix + value
383-
return env
382+
// Find the equals sign to separate key from value
383+
eqIdx := strings.Index(envVar, "=")
384+
if eqIdx == -1 {
385+
continue
386+
}
387+
actualKey := envVar[:eqIdx]
388+
389+
// On Windows, environment variables are case-insensitive
390+
if utils.IsWindows() {
391+
if strings.EqualFold(actualKey, key) {
392+
return i, actualKey
393+
}
394+
} else {
395+
// On Unix, case-sensitive match
396+
if actualKey == key {
397+
return i, actualKey
398+
}
384399
}
385400
}
386-
return append(env, prefix+value)
401+
return -1, ""
402+
}
403+
404+
// setEnvVar sets or updates an environment variable.
405+
// On Windows, this handles case-insensitive environment variable names to prevent
406+
// creating duplicate entries (e.g., both "Path=" and "PATH=").
407+
func setEnvVar(env []string, key, value string) []string {
408+
if idx, actualKey := findEnvVar(env, key); idx != -1 {
409+
// Update existing variable, preserving original key casing
410+
env[idx] = actualKey + "=" + value
411+
return env
412+
}
413+
// Variable not found, add it with the requested key
414+
return append(env, key+"="+value)
387415
}
388416

389417
// buildCacheSuffix constructs a cache directory suffix that includes ABI variants.
@@ -392,17 +420,19 @@ func buildCacheSuffix(goBinaryPath, goos, goarch string, env []string) string {
392420
return cache.BuildCacheSuffix(goBinaryPath, goos, goarch, env)
393421
}
394422

395-
// prependToPath prepends a directory to the PATH environment variable
423+
// prependToPath prepends a directory to the PATH environment variable.
424+
// On Windows, this handles case-insensitive PATH matching to prevent creating duplicate
425+
// PATH entries (e.g., both "Path=" and "PATH="), which would cause the original system
426+
// PATH to be lost.
396427
func prependToPath(env []string, dir string) []string {
397-
const pathPrefix = "PATH="
398-
for i, envVar := range env {
399-
if strings.HasPrefix(envVar, pathPrefix) {
400-
currentPath := envVar[len(pathPrefix):]
401-
newPath := dir + string(os.PathListSeparator) + currentPath
402-
env[i] = pathPrefix + newPath
403-
return env
404-
}
428+
if idx, actualKey := findEnvVar(env, "PATH"); idx != -1 {
429+
// Found PATH, extract current value and prepend new directory
430+
currentPath := env[idx][len(actualKey)+1:] // +1 for the "=" sign
431+
newPath := dir + string(os.PathListSeparator) + currentPath
432+
// Preserve original key casing (e.g., "Path" on Windows, "PATH" on Unix)
433+
env[idx] = actualKey + "=" + newPath
434+
return env
405435
}
406436
// PATH not found, add it
407-
return append(env, pathPrefix+dir)
437+
return append(env, "PATH="+dir)
408438
}

cmd/shims/exec_test.go

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,3 +330,246 @@ func TestExecWithShims(t *testing.T) {
330330
got := strings.TrimSpace(output.String())
331331
assert.Contains(t, got, "go1.21.5", "Expected output to contain version info %v", got)
332332
}
333+
334+
// TestFindEnvVar tests the case-insensitive environment variable finder
335+
func TestFindEnvVar(t *testing.T) {
336+
tests := []struct {
337+
name string
338+
env []string
339+
key string
340+
expectedIdx int
341+
expectedKey string
342+
onlyOnWindows bool
343+
}{
344+
{
345+
name: "exact match - PATH",
346+
env: []string{"HOME=/home/user", "PATH=/usr/bin:/bin", "USER=testuser"},
347+
key: "PATH",
348+
expectedIdx: 1,
349+
expectedKey: "PATH",
350+
},
351+
{
352+
name: "exact match - HOME",
353+
env: []string{"HOME=/home/user", "PATH=/usr/bin", "USER=testuser"},
354+
key: "HOME",
355+
expectedIdx: 0,
356+
expectedKey: "HOME",
357+
},
358+
{
359+
name: "not found",
360+
env: []string{"HOME=/home/user", "USER=testuser"},
361+
key: "PATH",
362+
expectedIdx: -1,
363+
expectedKey: "",
364+
},
365+
{
366+
name: "case-insensitive match - Path (Windows only)",
367+
env: []string{"Path=C:\\Windows\\System32", "HOME=/home/user"},
368+
key: "PATH",
369+
expectedIdx: 0,
370+
expectedKey: "Path",
371+
onlyOnWindows: true,
372+
},
373+
{
374+
name: "case-insensitive match - path (Windows only)",
375+
env: []string{"path=C:\\Windows\\System32", "HOME=/home/user"},
376+
key: "PATH",
377+
expectedIdx: 0,
378+
expectedKey: "path",
379+
onlyOnWindows: true,
380+
},
381+
{
382+
name: "case-insensitive match - PaTh (Windows only)",
383+
env: []string{"HOME=/home/user", "PaTh=C:\\Windows\\System32"},
384+
key: "PATH",
385+
expectedIdx: 1,
386+
expectedKey: "PaTh",
387+
onlyOnWindows: true,
388+
},
389+
}
390+
391+
for _, tt := range tests {
392+
t.Run(tt.name, func(t *testing.T) {
393+
if tt.onlyOnWindows && !utils.IsWindows() {
394+
t.Skip("Windows-only test")
395+
}
396+
397+
idx, key := findEnvVar(tt.env, tt.key)
398+
assert.Equal(t, tt.expectedIdx, idx, "Index mismatch")
399+
assert.Equal(t, tt.expectedKey, key, "Key mismatch")
400+
})
401+
}
402+
}
403+
404+
// TestSetEnvVar tests setting environment variables with case-insensitive handling on Windows
405+
func TestSetEnvVar(t *testing.T) {
406+
tests := []struct {
407+
name string
408+
env []string
409+
key string
410+
value string
411+
expected []string
412+
onlyOnWindows bool
413+
}{
414+
{
415+
name: "add new variable",
416+
env: []string{"HOME=/home/user", "USER=testuser"},
417+
key: "PATH",
418+
value: "/usr/bin:/bin",
419+
expected: []string{"HOME=/home/user", "USER=testuser", "PATH=/usr/bin:/bin"},
420+
},
421+
{
422+
name: "update existing variable - exact match",
423+
env: []string{"HOME=/home/user", "PATH=/usr/bin", "USER=testuser"},
424+
key: "PATH",
425+
value: "/new/path:/usr/bin",
426+
expected: []string{"HOME=/home/user", "PATH=/new/path:/usr/bin", "USER=testuser"},
427+
},
428+
{
429+
name: "update existing variable - case mismatch on Windows",
430+
env: []string{"Path=C:\\Windows\\System32", "HOME=/home/user"},
431+
key: "PATH",
432+
value: "C:\\goenv\\bin;C:\\Windows\\System32",
433+
expected: []string{"Path=C:\\goenv\\bin;C:\\Windows\\System32", "HOME=/home/user"},
434+
onlyOnWindows: true,
435+
},
436+
{
437+
name: "update existing variable - mixed case (Windows)",
438+
env: []string{"HOME=/home/user", "PaTh=C:\\Windows\\System32"},
439+
key: "PATH",
440+
value: "C:\\goenv\\bin;C:\\Windows\\System32",
441+
expected: []string{"HOME=/home/user", "PaTh=C:\\goenv\\bin;C:\\Windows\\System32"},
442+
onlyOnWindows: true,
443+
},
444+
}
445+
446+
for _, tt := range tests {
447+
t.Run(tt.name, func(t *testing.T) {
448+
if tt.onlyOnWindows && !utils.IsWindows() {
449+
t.Skip("Windows-only test")
450+
}
451+
452+
result := setEnvVar(tt.env, tt.key, tt.value)
453+
assert.Equal(t, tt.expected, result)
454+
})
455+
}
456+
}
457+
458+
// TestPrependToPath tests prepending directories to PATH with case-insensitive handling on Windows
459+
func TestPrependToPath(t *testing.T) {
460+
tests := []struct {
461+
name string
462+
env []string
463+
dir string
464+
expected []string
465+
onlyOnWindows bool
466+
}{
467+
{
468+
name: "prepend to existing PATH - Unix",
469+
env: []string{"HOME=/home/user", "PATH=/usr/bin:/bin", "USER=testuser"},
470+
dir: "/opt/goenv/bin",
471+
expected: []string{"HOME=/home/user", "PATH=/opt/goenv/bin:/usr/bin:/bin", "USER=testuser"},
472+
},
473+
{
474+
name: "add PATH when missing",
475+
env: []string{"HOME=/home/user", "USER=testuser"},
476+
dir: "/opt/goenv/bin",
477+
expected: []string{"HOME=/home/user", "USER=testuser", "PATH=/opt/goenv/bin"},
478+
},
479+
{
480+
name: "prepend to existing Path - Windows (case mismatch)",
481+
env: []string{"Path=C:\\Windows\\System32;C:\\Windows", "HOME=C:\\Users\\test"},
482+
dir: "C:\\goenv\\bin",
483+
expected: []string{"Path=C:\\goenv\\bin;C:\\Windows\\System32;C:\\Windows", "HOME=C:\\Users\\test"},
484+
onlyOnWindows: true,
485+
},
486+
{
487+
name: "prepend to existing path - Windows (lowercase)",
488+
env: []string{"HOME=C:\\Users\\test", "path=C:\\Windows\\System32"},
489+
dir: "C:\\goenv\\bin",
490+
expected: []string{"HOME=C:\\Users\\test", "path=C:\\goenv\\bin;C:\\Windows\\System32"},
491+
onlyOnWindows: true,
492+
},
493+
{
494+
name: "prepend to existing PaTh - Windows (mixed case)",
495+
env: []string{"PaTh=C:\\Windows\\System32", "USER=testuser"},
496+
dir: "C:\\goenv\\bin",
497+
expected: []string{"PaTh=C:\\goenv\\bin;C:\\Windows\\System32", "USER=testuser"},
498+
onlyOnWindows: true,
499+
},
500+
}
501+
502+
for _, tt := range tests {
503+
t.Run(tt.name, func(t *testing.T) {
504+
if tt.onlyOnWindows && !utils.IsWindows() {
505+
t.Skip("Windows-only test")
506+
}
507+
508+
result := prependToPath(tt.env, tt.dir)
509+
assert.Equal(t, tt.expected, result)
510+
})
511+
}
512+
}
513+
514+
// TestWindowsPathPreservation is a regression test for issue #557
515+
// It verifies that the original system PATH is preserved on Windows when goenv exec
516+
// prepends its own paths, even when PATH has different casing (Path, path, PATH, etc.)
517+
func TestWindowsPathPreservation(t *testing.T) {
518+
if !utils.IsWindows() {
519+
t.Skip("Windows-only test")
520+
}
521+
522+
tests := []struct {
523+
name string
524+
env []string
525+
dir string
526+
checkFor string // a path component that should still be present after prepending
527+
}{
528+
{
529+
name: "Path with C:\\TDM-GCC-64\\bin (issue #557 scenario)",
530+
env: []string{"Path=C:\\Windows\\System32;C:\\TDM-GCC-64\\bin;C:\\Windows"},
531+
dir: "C:\\Users\\test\\.goenv\\versions\\1.24.0\\bin",
532+
checkFor: "C:\\TDM-GCC-64\\bin",
533+
},
534+
{
535+
name: "PATH with system paths",
536+
env: []string{"PATH=C:\\Windows\\System32;C:\\Program Files\\Git\\cmd"},
537+
dir: "C:\\goenv\\bin",
538+
checkFor: "C:\\Program Files\\Git\\cmd",
539+
},
540+
{
541+
name: "path (lowercase) with MinGW",
542+
env: []string{"path=C:\\msys64\\mingw64\\bin;C:\\Windows\\System32"},
543+
dir: "C:\\goenv\\bin",
544+
checkFor: "C:\\msys64\\mingw64\\bin",
545+
},
546+
}
547+
548+
for _, tt := range tests {
549+
t.Run(tt.name, func(t *testing.T) {
550+
result := prependToPath(tt.env, tt.dir)
551+
552+
// Find the PATH variable in the result (case-insensitive)
553+
var pathValue string
554+
for _, envVar := range result {
555+
if idx := strings.Index(envVar, "="); idx != -1 {
556+
key := envVar[:idx]
557+
if strings.EqualFold(key, "PATH") {
558+
pathValue = envVar[idx+1:]
559+
break
560+
}
561+
}
562+
}
563+
564+
require.NotEmpty(t, pathValue, "PATH should be present in result")
565+
566+
// Verify the new directory was prepended
567+
assert.True(t, strings.HasPrefix(pathValue, tt.dir+";"),
568+
"PATH should start with new directory: got %s", pathValue)
569+
570+
// Verify the original path component is still present (regression test for #557)
571+
assert.Contains(t, pathValue, tt.checkFor,
572+
"Original path component %s should be preserved in PATH: got %s", tt.checkFor, pathValue)
573+
})
574+
}
575+
}

0 commit comments

Comments
 (0)