Skip to content

Commit e458c6e

Browse files
claudeJanDeDobbeleer
authored andcommitted
fix(pwsh): emit ASCII-safe init output for non-ASCII paths
PowerShell decodes native command stdout using [Console]::OutputEncoding, which defaults to the legacy OEM code page on Windows. When a path in the init bootstrap contains non-ASCII characters (e.g. a ² in the username), the UTF-8 bytes are mangled before the init script can run, breaking initialization with 'term is not recognized' errors. Quote pwsh strings containing non-ASCII runes as expandable string expressions built from [char] casts so everything written to stdout, and to the on-disk init script, is pure ASCII, which survives every code page. Elvish keeps the previous plain single-quote behavior via its own quoting function. Validated against pwsh 7.4.6 in every injection context: env var assignment, the call operator, Invoke-Expression, argument-mode --config= tokens, and the omp.ps1 executable assignment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BCNz9G4sSmwRixkjVodcWo
1 parent 3c2976d commit e458c6e

5 files changed

Lines changed: 117 additions & 13 deletions

File tree

src/shell/elvish.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,21 @@ package shell
22

33
import (
44
_ "embed"
5+
"fmt"
6+
"strings"
57
)
68

79
//go:embed scripts/omp.elv
810
var elvishInit string
911

12+
func quoteElvishStr(str string) string {
13+
if str == "" {
14+
return "''"
15+
}
16+
17+
return fmt.Sprintf("'%s'", strings.ReplaceAll(str, "'", "''"))
18+
}
19+
1020
func (f Features) Elvish() Code {
1121
switch f {
1222
case Upgrade:

src/shell/elvish_test.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package shell
22

33
import (
4+
"fmt"
45
"testing"
56

67
"github.com/stretchr/testify/assert"
@@ -15,3 +16,17 @@ $_omp_executable notice`
1516

1617
assert.Equal(t, want, got)
1718
}
19+
20+
func TestQuoteElvishStr(t *testing.T) {
21+
tests := []struct {
22+
str string
23+
expected string
24+
}{
25+
{str: "", expected: "''"},
26+
{str: `/tmp/"omp's dir"/oh-my-posh`, expected: `'/tmp/"omp''s dir"/oh-my-posh'`},
27+
{str: `C:/tmp\omp's dir/oh-my-posh.exe`, expected: `'C:/tmp\omp''s dir/oh-my-posh.exe'`},
28+
}
29+
for _, tc := range tests {
30+
assert.Equal(t, tc.expected, quoteElvishStr(tc.str), fmt.Sprintf("quoteElvishStr: %s", tc.str))
31+
}
32+
}

src/shell/init.go

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -98,16 +98,19 @@ func recurseInitCommand(env runtime.Environment) string {
9898
additionalParams += " --eval"
9999
}
100100

101-
config := quotePwshOrElvishStr(env.Flags().ConfigPath)
102-
executable = quotePwshOrElvishStr(executable)
101+
config := env.Flags().ConfigPath
103102

104103
var command string
105104

106105
switch env.Flags().Shell {
107106
case PWSH:
108107
command = "(@(& %s init %s --config=%s --print%s) -join \"`n\") | Invoke-Expression"
108+
config = quotePwshStr(config)
109+
executable = quotePwshStr(executable)
109110
case ELVISH:
110111
command = "eval ((external %s) init %s --config=%s --print%s | slurp)"
112+
config = quoteElvishStr(config)
113+
executable = quoteElvishStr(executable)
111114
}
112115

113116
return fmt.Sprintf(command, executable, env.Flags().Shell, config, additionalParams)
@@ -163,7 +166,7 @@ func generateScript(env runtime.Environment, feats Features) string {
163166

164167
switch env.Flags().Shell {
165168
case PWSH:
166-
executable = quotePwshOrElvishStr(executable)
169+
executable = quotePwshStr(executable)
167170
script = pwshInit
168171
case ZSH:
169172
executable = QuotePosixStr(executable)
@@ -182,7 +185,7 @@ func generateScript(env runtime.Environment, feats Features) string {
182185
config = quoteNuStr(env.Flags().ConfigPath)
183186
script = nuInit
184187
case ELVISH:
185-
executable = quotePwshOrElvishStr(executable)
188+
executable = quoteElvishStr(executable)
186189
script = elvishInit
187190
case XONSH:
188191
executable = quotePythonStr(executable)
@@ -241,7 +244,7 @@ func sourceCommand(env runtime.Environment, scriptPath string, async bool) strin
241244

242245
switch env.Flags().Shell {
243246
case PWSH:
244-
script += fmt.Sprintf("& %s", quotePwshOrElvishStr(scriptPath))
247+
script += fmt.Sprintf("& %s", quotePwshStr(scriptPath))
245248
case ZSH, BASH:
246249
script += fmt.Sprintf("source %s", QuotePosixStr(scriptPath))
247250
case XONSH:
@@ -252,7 +255,7 @@ func sourceCommand(env runtime.Environment, scriptPath string, async bool) strin
252255
// yash has no source builtin, use the dot command instead
253256
script += fmt.Sprintf(". %s", quoteYashStr(scriptPath))
254257
case ELVISH:
255-
script += fmt.Sprintf("eval (slurp < %s)", quotePwshOrElvishStr(scriptPath))
258+
script += fmt.Sprintf("eval (slurp < %s)", quoteElvishStr(scriptPath))
256259
case CMD:
257260
// dofile closes the file handle when done, io.open would leak it
258261
// until the Lua GC kicks in, blocking script updates on Windows
@@ -276,7 +279,7 @@ func sourceCommandAsync(shell, scriptPath string) string {
276279
"$global:_ompPromptFunction = $null; "+
277280
"$global:_ompInitialized = $false; "+
278281
"function prompt() { if (-not $global:_ompInitialized) { $global:_ompAsyncInit = $true; & %s; return }; if ($global:_ompPromptFunction) { & $global:_ompPromptFunction } }",
279-
quotePwshOrElvishStr(scriptPath),
282+
quotePwshStr(scriptPath),
280283
)
281284
case ZSH:
282285
return fmt.Sprintf("precmd() { source %s }", QuotePosixStr(scriptPath))
@@ -312,7 +315,7 @@ func sessionScript(env runtime.Environment) string {
312315

313316
switch env.Flags().Shell {
314317
case PWSH:
315-
return fmt.Sprintf("$env:POSH_SESSION_ID = \"%s\"; $env:POSH_CONFIG = %s;", sessionID, quotePwshOrElvishStr(config))
318+
return fmt.Sprintf("$env:POSH_SESSION_ID = \"%s\"; $env:POSH_CONFIG = %s;", sessionID, quotePwshStr(config))
316319
case ZSH, BASH:
317320
return fmt.Sprintf("export POSH_SESSION_ID=\"%s\"; export POSH_CONFIG=%s;", sessionID, QuotePosixStr(config))
318321
case YASH:
@@ -322,7 +325,7 @@ func sessionScript(env runtime.Environment) string {
322325
case FISH:
323326
return fmt.Sprintf("set --export --global POSH_SESSION_ID \"%s\"; set --export --global POSH_CONFIG %s;", sessionID, quoteFishStr(config))
324327
case ELVISH:
325-
return fmt.Sprintf("set-env POSH_SESSION_ID \"%s\"; set-env POSH_CONFIG %s;", sessionID, quotePwshOrElvishStr(config))
328+
return fmt.Sprintf("set-env POSH_SESSION_ID \"%s\"; set-env POSH_CONFIG %s;", sessionID, quoteElvishStr(config))
326329
case CMD:
327330
return fmt.Sprintf(`os.setenv('POSH_SESSION_ID', '%s'); os.setenv('POSH_CONFIG', '%s');`, sessionID, escapeLuaStr(config))
328331
}

src/shell/pwsh.go

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
_ "embed"
55
"fmt"
66
"strings"
7+
"unicode/utf8"
78
)
89

910
//go:embed scripts/omp.ps1
@@ -42,10 +43,59 @@ func (f Features) Pwsh() Code {
4243
}
4344
}
4445

45-
func quotePwshOrElvishStr(str string) string {
46+
func quotePwshStr(str string) string {
4647
if str == "" {
4748
return "''"
4849
}
4950

50-
return fmt.Sprintf("'%s'", strings.ReplaceAll(str, "'", "''"))
51+
ascii := strings.IndexFunc(str, func(r rune) bool { return r >= utf8.RuneSelf }) == -1
52+
if ascii {
53+
return fmt.Sprintf("'%s'", strings.ReplaceAll(str, "'", "''"))
54+
}
55+
56+
// PowerShell decodes native command output using [Console]::OutputEncoding,
57+
// which defaults to the legacy OEM code page on Windows. Multi-byte UTF-8
58+
// sequences in stdout are mangled before the init script gets a chance to
59+
// run, so non-ASCII runes must be spelled out as [char] expressions to keep
60+
// the emitted code pure ASCII, which survives every code page.
61+
// The expandable string form "$( )" stays a single token in argument mode,
62+
// so the result can be used anywhere a quoted string can.
63+
var parts []string
64+
65+
var segment strings.Builder
66+
67+
flush := func() {
68+
if segment.Len() == 0 {
69+
return
70+
}
71+
72+
parts = append(parts, fmt.Sprintf("'%s'", strings.ReplaceAll(segment.String(), "'", "''")))
73+
segment.Reset()
74+
}
75+
76+
for _, r := range str {
77+
if r < utf8.RuneSelf {
78+
segment.WriteRune(r)
79+
continue
80+
}
81+
82+
flush()
83+
84+
if r > 0xFFFF {
85+
parts = append(parts, fmt.Sprintf("[char]::ConvertFromUtf32(0x%X)", r))
86+
continue
87+
}
88+
89+
parts = append(parts, fmt.Sprintf("[char]0x%X", r))
90+
}
91+
92+
flush()
93+
94+
// a leading [char] would make + do character arithmetic instead of
95+
// string concatenation, an empty string first forces string semantics
96+
if !strings.HasPrefix(parts[0], "'") {
97+
parts = append([]string{"''"}, parts...)
98+
}
99+
100+
return fmt.Sprintf(`"$(%s)"`, strings.Join(parts, " + "))
51101
}

src/shell/pwsh_test.go

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import (
44
"fmt"
55
"testing"
66

7+
"github.com/jandedobbeleer/oh-my-posh/src/runtime"
8+
"github.com/jandedobbeleer/oh-my-posh/src/runtime/mock"
9+
710
"github.com/stretchr/testify/assert"
811
)
912

@@ -42,16 +45,39 @@ func TestSourceCommandAsyncPwsh(t *testing.T) {
4245
assert.Equal(t, want, got)
4346
}
4447

45-
func TestQuotePwshOrElvishStr(t *testing.T) {
48+
func TestQuotePwshStr(t *testing.T) {
4649
tests := []struct {
4750
str string
4851
expected string
4952
}{
5053
{str: "", expected: "''"},
5154
{str: `/tmp/"omp's dir"/oh-my-posh`, expected: `'/tmp/"omp''s dir"/oh-my-posh'`},
5255
{str: `C:/tmp\omp's dir/oh-my-posh.exe`, expected: `'C:/tmp\omp''s dir/oh-my-posh.exe'`},
56+
// non-ASCII runes are emitted as [char] expressions so the output
57+
// survives PowerShell's OEM code page decoding of native stdout
58+
{str: `C:/Users/MyNameE²/init.ps1`, expected: `"$('C:/Users/MyNameE' + [char]0xB2 + '/init.ps1')"`},
59+
{str: `C:/Users/ز/omp's.ps1`, expected: `"$('C:/Users/' + [char]0xD8 + [char]0xB2 + '/omp''s.ps1')"`},
60+
{str: `²init.ps1`, expected: `"$('' + [char]0xB2 + 'init.ps1')"`},
61+
{str: `C:/Users/📁/init.ps1`, expected: `"$('C:/Users/' + [char]::ConvertFromUtf32(0x1F4C1) + '/init.ps1')"`},
5362
}
5463
for _, tc := range tests {
55-
assert.Equal(t, tc.expected, quotePwshOrElvishStr(tc.str), fmt.Sprintf("quotePwshOrElvishStr: %s", tc.str))
64+
assert.Equal(t, tc.expected, quotePwshStr(tc.str), fmt.Sprintf("quotePwshStr: %s", tc.str))
65+
}
66+
}
67+
68+
// PowerShell decodes native command output using [Console]::OutputEncoding,
69+
// which defaults to the legacy OEM code page on Windows. Everything written
70+
// to stdout for pwsh must therefore be pure ASCII, no matter which characters
71+
// the injected paths contain.
72+
func TestSessionScriptPwshIsPureASCII(t *testing.T) {
73+
env := new(mock.Environment)
74+
env.On("Flags").Return(&runtime.Flags{Shell: PWSH, ConfigPath: "C:/Users/MyNameE²/omp.json"})
75+
76+
got := sessionScript(env)
77+
78+
assert.Contains(t, got, `$env:POSH_CONFIG = "$('C:/Users/MyNameE' + [char]0xB2 + '/omp.json')";`)
79+
80+
for i, b := range []byte(got) {
81+
assert.Less(t, b, uint8(0x80), fmt.Sprintf("non-ASCII byte at index %d in: %s", i, got))
5682
}
5783
}

0 commit comments

Comments
 (0)