Repository: JanDeDobbeleer/oh-my-posh
Pull request #2630 (2022-08-07 17:02:27)

fix(shell): quote paths properly in init scripts
### Prerequisites

- [x] I have read and understood the [contributing guide][CONTRIBUTING.md]
- [x] The commit message follows the [conventional commits][cc] guidelines
- [x] Tests for the changes have been added (for bug fixes/features)
- [ ] Docs have been added/updated (for bug fixes/features)

### Description

Inspired by [Starship](https://github.com/starship/starship/blob/9220df70d965ff43f7baa75be4c622c6963a7652/src/init/mod.rs#L38-L87), I implemented functions to quote the OMP executable path and the config file path (if necessary) and escape any special characters (if necessary) in the syntax of specific shell/script so that we can safely replace placeholders like `::OMP::` in a script with the quoted strings. This resolves a problem that happens especially when simply using single quotes to quote a path including any single quotes.

BTW, I also fixed the path separator inconsistency (Windows only) in the output of PowerShell function `Get-PoshThemes`.

[CONTRIBUTING.md]: https://github.com/JanDeDobbeleer/oh-my-posh/blob/main/CONTRIBUTING.md
[cc]: https://www.conventionalcommits.org/en/v1.0.0/#summary



Patch:
diff --git a/src/shell/init.go b/src/shell/init.go
index 61fcd6af..eee16943 100644
--- a/src/shell/init.go
+++ b/src/shell/init.go
@@ -3,6 +3,7 @@ package shell
 import (
 	_ "embed"
 	"path/filepath"
+	"runtime"
 	"strconv"
 
 	"fmt"
@@ -51,25 +52,111 @@ func getExecutablePath(env environment.Environment) (string, error) {
 	// On Windows, it fails when the excutable is called in MSYS2 for example
 	// which uses unix style paths to resolve the executable's location.
 	// PowerShell knows how to resolve both, so we can swap this without any issue.
-	executable = strings.ReplaceAll(executable, "\\", "/")
-	switch env.Flags().Shell {
-	case BASH, ZSH:
-		executable = strings.ReplaceAll(executable, " ", "\\ ")
-		executable = strings.ReplaceAll(executable, "(", "\\(")
-		executable = strings.ReplaceAll(executable, ")", "\\)")
+	if runtime.GOOS == environment.WINDOWS {
+		executable = strings.ReplaceAll(executable, "\\", "/")
 	}
 	return executable, nil
 }
 
-func Init(env environment.Environment) string {
-	executable, err := getExecutablePath(env)
-	if err != nil {
-		return noExe
+func quotePwshStr(str string) string {
+	return fmt.Sprintf("'%s'", strings.ReplaceAll(str, "'", "''"))
+}
+
+func quotePosixStr(str string) string {
+	if len(str) == 0 {
+		return "''"
+	}
+	needQuoting := false
+	var b strings.Builder
+	for _, r := range str {
+		normal := false
+		switch r {
+		case '!', ';', '"', '(', ')', '[', ']', '{', '}', '$', '|', '&', '>', '<', '`', ' ', '#', '~', '*', '?', '=':
+			b.WriteRune(r)
+		case '\\', '\'':
+			b.WriteByte('\\')
+			b.WriteRune(r)
+		case '\a':
+			b.WriteString(`\a`)
+		case '\b':
+			b.WriteString(`\b`)
+		case '\f':
+			b.WriteString(`\f`)
+		case '\n':
+			b.WriteString(`\n`)
+		case '\r':
+			b.WriteString(`\r`)
+		case '\t':
+			b.WriteString(`\t`)
+		case '\v':
+			b.WriteString(`\v`)
+		default:
+			b.WriteRune(r)
+			normal = true
+		}
+		if !normal {
+			needQuoting = true
+		}
+	}
+	// the quoting form $'...' is used for a string includes any special characters
+	if needQuoting {
+		return fmt.Sprintf("$'%s'", b.String())
+	}
+	return b.String()
+}
+
+func quoteFishStr(str string) string {
+	if len(str) == 0 {
+		return "''"
+	}
+	needQuoting := false
+	var b strings.Builder
+	for _, r := range str {
+		normal := false
+		switch r {
+		case ';', '"', '(', ')', '[', ']', '{', '}', '$', '|', '&', '>', '<', ' ', '#', '~', '*', '?', '=':
+			b.WriteRune(r)
+		case '\\', '\'':
+			b.WriteByte('\\')
+			b.WriteRune(r)
+		default:
+			b.WriteRune(r)
+			normal = true
+		}
+		if !normal {
+			needQuoting = true
+		}
 	}
+	// single quotes are used when the string includes any special characters
+	if needQuoting {
+		return fmt.Sprintf("'%s'", b.String())
+	}
+	return b.String()
+}
+
+func quoteLuaStr(str string) string {
+	if len(str) == 0 {
+		return "''"
+	}
+	return fmt.Sprintf("'%s'", strings.NewReplacer(`\`, `\\`, `'`, `\'`).Replace(str))
+}
+
+func quoteNuStr(str string) string {
+	if len(str) == 0 {
+		return "''"
+	}
+	return fmt.Sprintf(`"%s"`, strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(str))
+}
+
+func Init(env environment.Environment) string {
 	shell := env.Flags().Shell
 	switch shell {
 	case PWSH, PWSH5:
-		return fmt.Sprintf("(@(&\"%s\" init %s --config=\"%s\" --print) -join \"`n\") | Invoke-Expression", executable, shell, env.Flags().Config)
+		executable, err := getExecutablePath(env)
+		if err != nil {
+			return noExe
+		}
+		return fmt.Sprintf("(@(& %s init %s --config=%s --print) -join \"`n\") | Invoke-Expression", quotePwshStr(executable), shell, quotePwshStr(env.Flags().Config))
 	case ZSH, BASH, FISH, CMD:
 		return PrintInit(env)
 	case NU:
@@ -87,32 +174,43 @@ func PrintInit(env environment.Environment) string {
 	}
 	shell := env.Flags().Shell
 	configFile := env.Flags().Config
+	var script string
 	switch shell {
 	case PWSH, PWSH5:
-		script := getShellInitScript(executable, configFile, pwshInit)
-		return strings.ReplaceAll(script, "::SHELL::", shell)
+		executable = quotePwshStr(executable)
+		configFile = quotePwshStr(configFile)
+		script = pwshInit
 	case ZSH:
-		return getShellInitScript(executable, configFile, zshInit)
+		executable = quotePosixStr(executable)
+		configFile = quotePosixStr(configFile)
+		script = zshInit
 	case BASH:
-		return getShellInitScript(executable, configFile, bashInit)
+		executable = quotePosixStr(executable)
+		configFile = quotePosixStr(configFile)
+		script = bashInit
 	case FISH:
-		return getShellInitScript(executable, configFile, fishInit)
+		executable = quoteFishStr(executable)
+		configFile = quoteFishStr(configFile)
+		script = fishInit
 	case CMD:
-		return getShellInitScript(executable, configFile, cmdInit)
+		executable = quoteLuaStr(executable)
+		configFile = quoteLuaStr(configFile)
+		script = cmdInit
 	case NU:
-		return getShellInitScript(executable, configFile, nuInit)
+		executable = quoteNuStr(executable)
+		configFile = quoteNuStr(configFile)
+		script = nuInit
 	default:
 		return fmt.Sprintf("echo \"No initialization script available for %s\"", shell)
 	}
-}
-
-func getShellInitScript(executable, configFile, script string) string {
-	script = strings.ReplaceAll(script, "::OMP::", executable)
-	script = strings.ReplaceAll(script, "::CONFIG::", configFile)
-	script = strings.ReplaceAll(script, "::TRANSIENT::", strconv.FormatBool(Transient))
-	script = strings.ReplaceAll(script, "::ERROR_LINE::", strconv.FormatBool(ErrorLine))
-	script = strings.ReplaceAll(script, "::TOOLTIPS::", strconv.FormatBool(Tooltips))
-	return script
+	return strings.NewReplacer(
+		"::OMP::", executable,
+		"::CONFIG::", configFile,
+		"::SHELL::", shell,
+		"::TRANSIENT::", strconv.FormatBool(Transient),
+		"::ERROR_LINE::", strconv.FormatBool(ErrorLine),
+		"::TOOLTIPS::", strconv.FormatBool(Tooltips),
+	).Replace(script)
 }
 
 func createNuInit(env environment.Environment) {
diff --git a/src/shell/scripts/omp.bash b/src/shell/scripts/omp.bash
index eac4bda7..645610b6 100644
--- a/src/shell/scripts/omp.bash
+++ b/src/shell/scripts/omp.bash
@@ -1,4 +1,4 @@
-export POSH_THEME='::CONFIG::'
+export POSH_THEME=::CONFIG::
 export POWERLINE_COMMAND="oh-my-posh"
 export CONDA_PROMPT_MODIFIER=false
 
@@ -10,15 +10,18 @@ if [[ ! -d "/tmp" ]]; then
 fi
 
 # start timer on command start
-PS0='$(::OMP:: get millis > "$TIMER_START")'
+PS0='$(_omp_start_timer)'
 # set secondary prompt
 PS2="$(::OMP:: print secondary --config="$POSH_THEME" --shell=bash --shell-version="$BASH_VERSION")"
 
+function _omp_start_timer() {
+    ::OMP:: get millis > "$TIMER_START"
+}
+
 function _omp_hook() {
     local ret=$?
-
-    omp_stack_count=$((${#DIRSTACK[@]} - 1))
-    omp_elapsed=-1
+    local omp_stack_count=$((${#DIRSTACK[@]} - 1))
+    local omp_elapsed=-1
     if [[ -f "$TIMER_START" ]]; then
         omp_now=$(::OMP:: get millis)
         omp_start_time=$(cat "$TIMER_START")
diff --git a/src/shell/scripts/omp.fish b/src/shell/scripts/omp.fish
index dd1886dd..ed50e228 100644
--- a/src/shell/scripts/omp.fish
+++ b/src/shell/scripts/omp.fish
@@ -1,4 +1,4 @@
-set --export POSH_THEME '::CONFIG::'
+set --export POSH_THEME ::CONFIG::
 set --global POWERLINE_COMMAND "oh-my-posh"
 set --global CONDA_PROMPT_MODIFIER false
 set --global omp_tooltip_command ""
@@ -11,7 +11,7 @@ function fish_prompt
     # see https://github.com/fish-shell/fish-shell/issues/8418
     printf \e\[0J
     if test "$omp_transient" = "1"
-      '::OMP::' print transient --config $POSH_THEME --shell fish --error $omp_status_cache --execution-time $omp_duration --stack-count $omp_stack_count --shell-version $FISH_VERSION
+      ::OMP:: print transient --config $POSH_THEME --shell fish --error $omp_status_cache --execution-time $omp_duration --stack-count $omp_stack_count --shell-version $FISH_VERSION
       return
     end
     set --global omp_status_cache $omp_status_cache_temp
@@ -29,7 +29,7 @@ function fish_prompt
       set --global --export omp_last_status_generation $status_generation
     end
 
-    '::OMP::' print primary --config $POSH_THEME --shell fish --error $omp_status_cache --execution-time $omp_duration --stack-count $omp_stack_count --shell-version $FISH_VERSION
+    ::OMP:: print primary --config $POSH_THEME --shell fish --error $omp_status_cache --execution-time $omp_duration --stack-count $omp_stack_count --shell-version $FISH_VERSION
 end
 
 function fish_right_prompt
@@ -39,14 +39,14 @@ function fish_right_prompt
       return
     end
     if test -n "$omp_tooltip_command"
-      set omp_tooltip_prompt ('::OMP::' print tooltip --config $POSH_THEME --shell fish --error $omp_status_cache --shell-version $FISH_VERSION --command $omp_tooltip_command)
+      set omp_tooltip_prompt (::OMP:: print tooltip --config $POSH_THEME --shell fish --error $omp_status_cache --shell-version $FISH_VERSION --command $omp_tooltip_command)
       if test -n "$omp_tooltip_prompt"
         echo -n $omp_tooltip_prompt
         set omp_tooltip_command ""
         return
       end
     end
-    '::OMP::' print right --config $POSH_THEME --shell fish --error $omp_status_cache --execution-time $omp_duration --stack-count $omp_stack_count --shell-version $FISH_VERSION
+    ::OMP:: print right --config $POSH_THEME --shell fish --error $omp_status_cache --execution-time $omp_duration --stack-count $omp_stack_count --shell-version $FISH_VERSION
 end
 
 function postexec_omp --on-event fish_postexec
diff --git a/src/shell/scripts/omp.lua b/src/shell/scripts/omp.lua
index 63b60340..9df35b64 100644
--- a/src/shell/scripts/omp.lua
+++ b/src/shell/scripts/omp.lua
@@ -27,14 +27,14 @@ local tooltip_active = false
 local cached_prompt = {}
 
 local function omp_exe()
-    return [["::OMP::"]]
+    return '"'..::OMP::..'"'
 end
 
 local function omp_config()
-    return [["::CONFIG::"]]
+    return '"'..::CONFIG::..'"'
 end
 
-os.setenv("POSH_THEME", omp_config())
+os.setenv("POSH_THEME", ::CONFIG::)
 
 local function can_async()
     if (clink.version_encoded or 0) >= 10030001 then
diff --git a/src/shell/scripts/omp.nu b/src/shell/scripts/omp.nu
index bad7cfa9..b30d22a4 100644
--- a/src/shell/scripts/omp.nu
+++ b/src/shell/scripts/omp.nu
@@ -1,5 +1,5 @@
 let-env POWERLINE_COMMAND = 'oh-my-posh'
-let-env POSH_THEME = '::CONFIG::'
+let-env POSH_THEME = ::CONFIG::
 let-env PROMPT_INDICATOR = ""
 # By default displays the right prompt on the first line
 # making it annoying when you have a multiline prompt
@@ -8,9 +8,9 @@ let-env PROMPT_COMMAND_RIGHT = {''}
 let-env NU_VERSION = (version | get version)
 
 # PROMPTS
-let-env PROMPT_MULTILINE_INDICATOR = (^'::OMP::' print secondary $"--config=($env.POSH_THEME)" --shell=nu $"--shell-version=($env.NU_VERSION)")
+let-env PROMPT_MULTILINE_INDICATOR = (^::OMP:: print secondary $"--config=($env.POSH_THEME)" --shell=nu $"--shell-version=($env.NU_VERSION)")
 
 let-env PROMPT_COMMAND = {
     let width = (term size -c | get columns | into string)
-    ^'::OMP::' print primary $"--config=($env.POSH_THEME)" --shell=nu $"--shell-version=($env.NU_VERSION)" $"--execution-time=($env.CMD_DURATION_MS)" $"--error=($env.LAST_EXIT_CODE)" $"--terminal-width=($width)"
+    ^::OMP:: print primary $"--config=($env.POSH_THEME)" --shell=nu $"--shell-version=($env.NU_VERSION)" $"--execution-time=($env.CMD_DURATION_MS)" $"--error=($env.LAST_EXIT_CODE)" $"--terminal-width=($width)"
 }
diff --git a/src/shell/scripts/omp.ps1 b/src/shell/scripts/omp.ps1
index 9d6f555b..4db68517 100644
--- a/src/shell/scripts/omp.ps1
+++ b/src/shell/scripts/omp.ps1
@@ -15,14 +15,14 @@ function global:Get-PoshStackCount {
 
 New-Module -Name "oh-my-posh-core" -ScriptBlock {
     $script:ErrorCode = 0
-    $script:OMPExecutable = "::OMP::"
+    $script:OMPExecutable = ::OMP::
     $script:ShellName = "::SHELL::"
     $script:PSVersion = $PSVersionTable.PSVersion.ToString()
     $script:TransientPrompt = $false
     $env:POWERLINE_COMMAND = "oh-my-posh"
     $env:CONDA_PROMPT_MODIFIER = $false
-    if (("::CONFIG::" -ne '') -and (Test-Path "::CONFIG::")) {
-        $env:POSH_THEME = (Resolve-Path -Path "::CONFIG::").ProviderPath
+    if ((::CONFIG:: -ne '') -and (Test-Path ::CONFIG::)) {
+        $env:POSH_THEME = (Resolve-Path -Path ::CONFIG::).ProviderPath
     }
     # specific module support (disabled by default)
     if ($null -eq $env:POSH_GIT_ENABLED) {
@@ -247,7 +247,7 @@ New-Module -Name "oh-my-posh-core" -ScriptBlock {
                             |___/
 '@
         Write-Host $logo
-        $themes = Get-ChildItem -Path "$Path\*" -Include '*.omp.json' | Sort-Object Name
+        $themes = Get-ChildItem -Path "$Path/*" -Include '*.omp.json' | Sort-Object Name
         if ($List -eq $true) {
             $themes | Select-Object @{ Name = 'hyperlink'; Expression = { Get-FileHyperlink -uri $_.FullName } } | Format-Table -HideTableHeaders
         } else {
@@ -263,7 +263,7 @@ Themes location: $(Get-FileHyperlink -uri "$Path")
 
 To change your theme, adjust the init script in $PROFILE.
 Example:
-  oh-my-posh init pwsh --config $Path/jandedobbeleer.omp.json | Invoke-Expression
+  oh-my-posh init pwsh --config '$((Join-Path $Path "jandedobbeleer.omp.json") -replace "'", "''")' | Invoke-Expression
 
 "@
     }
@@ -286,27 +286,34 @@ Example:
     }
 
     function Update-PoshErrorCode {
-        $script:ExecutionTime = -1
         $lastHistory = Get-History -ErrorAction Ignore -Count 1
+
+        # default values
+        $script:ErrorCode = 0
+        $script:ExecutionTime = -1
+
         # error code should be updated only when a non-empty command is run
         if (($null -eq $lastHistory) -or ($script:LastHistoryId -eq $lastHistory.Id)) {
             return
         }
+
         $script:LastHistoryId = $lastHistory.Id
         $script:ExecutionTime = ($lastHistory.EndExecutionTime - $lastHistory.StartExecutionTime).TotalMilliseconds
         if ($script:OriginalLastExecutionStatus) {
-            $script:ErrorCode = 0
             return
         }
+
         $invocationInfo = try {
             # retrieve info of the most recent error
             $global:Error[0] | Where-Object { $_ -ne $null } | Select-Object -ExpandProperty InvocationInfo
         } catch { $null }
+
         # check if the last command caused the last error
         if ($null -ne $invocationInfo -and $lastHistory.CommandLine -eq $invocationInfo.Line) {
             $script:ErrorCode = 1
             return
         }
+
         if ($script:OriginalLastExitCode -is [int] -and $script:OriginalLastExitCode -ne 0) {
             # native app exit code
             $script:ErrorCode = $script:OriginalLastExitCode
diff --git a/src/shell/scripts/omp.zsh b/src/shell/scripts/omp.zsh
index 0151775f..86f44f70 100644
--- a/src/shell/scripts/omp.zsh
+++ b/src/shell/scripts/omp.zsh
@@ -1,4 +1,4 @@
-export POSH_THEME='::CONFIG::'
+export POSH_THEME=::CONFIG::
 export POWERLINE_COMMAND="oh-my-posh"
 export CONDA_PROMPT_MODIFIER=false
 
@@ -64,7 +64,7 @@ if [[ "::TOOLTIPS::" = "true" ]]; then
   zle -N self-insert
 fi
 
-_posh-zle-line-init() {
+function _posh-zle-line-init() {
     [[ $CONTEXT == start ]] || return 0
 
     # Start regular line editor


Test patch:
diff --git a/src/shell/init_test.go b/src/shell/init_test.go
index 6eae0d5c..5679878b 100644
--- a/src/shell/init_test.go
+++ b/src/shell/init_test.go
@@ -1,6 +1,7 @@
 package shell
 
 import (
+	"fmt"
 	"oh-my-posh/environment"
 	"oh-my-posh/mock"
 	"testing"
@@ -29,3 +30,83 @@ func TestConsoleBackgroundColorTemplate(t *testing.T) {
 		assert.Equal(t, tc.Expected, color, tc.Case)
 	}
 }
+
+func TestQuotePwshStr(t *testing.T) {
+	tests := []struct {
+		str      string
+		expected string
+	}{
+		{str: ``, expected: `''`},
+		{str: `/tmp/oh-my-posh`, expected: `'/tmp/oh-my-posh'`},
+		{str: `/tmp/omp's dir/oh-my-posh`, expected: `'/tmp/omp''s dir/oh-my-posh'`},
+		{str: `C:\tmp\oh-my-posh.exe`, expected: `'C:\tmp\oh-my-posh.exe'`},
+		{str: `C:\tmp\omp's dir\oh-my-posh.exe`, expected: `'C:\tmp\omp''s dir\oh-my-posh.exe'`},
+	}
+	for _, tc := range tests {
+		assert.Equal(t, tc.expected, quotePwshStr(tc.str), fmt.Sprintf("quotePwshStr: %s", tc.str))
+	}
+}
+
+func TestQuotePosixStr(t *testing.T) {
+	tests := []struct {
+		str      string
+		expected string
+	}{
+		{str: ``, expected: `''`},
+		{str: `/tmp/oh-my-posh`, expected: `/tmp/oh-my-posh`},
+		{str: `/tmp/omp's dir/oh-my-posh`, expected: `$'/tmp/omp\'s dir/oh-my-posh'`},
+		{str: `C:/tmp/oh-my-posh.exe`, expected: `C:/tmp/oh-my-posh.exe`},
+		{str: `C:/tmp/omp's dir/oh-my-posh.exe`, expected: `$'C:/tmp/omp\'s dir/oh-my-posh.exe'`},
+	}
+	for _, tc := range tests {
+		assert.Equal(t, tc.expected, quotePosixStr(tc.str), fmt.Sprintf("quotePosixStr: %s", tc.str))
+	}
+}
+
+func TestQuoteFishStr(t *testing.T) {
+	tests := []struct {
+		str      string
+		expected string
+	}{
+		{str: ``, expected: `''`},
+		{str: `/tmp/oh-my-posh`, expected: `/tmp/oh-my-posh`},
+		{str: `/tmp/omp's dir/oh-my-posh`, expected: `'/tmp/omp\'s dir/oh-my-posh'`},
+		{str: `C:/tmp/oh-my-posh.exe`, expected: `C:/tmp/oh-my-posh.exe`},
+		{str: `C:/tmp/omp's dir/oh-my-posh.exe`, expected: `'C:/tmp/omp\'s dir/oh-my-posh.exe'`},
+	}
+	for _, tc := range tests {
+		assert.Equal(t, tc.expected, quoteFishStr(tc.str), fmt.Sprintf("quoteFishStr: %s", tc.str))
+	}
+}
+
+func TestQuoteLuaStr(t *testing.T) {
+	tests := []struct {
+		str      string
+		expected string
+	}{
+		{str: ``, expected: `''`},
+		{str: `/tmp/oh-my-posh`, expected: `'/tmp/oh-my-posh'`},
+		{str: `/tmp/omp's dir/oh-my-posh`, expected: `'/tmp/omp\'s dir/oh-my-posh'`},
+		{str: `C:/tmp/oh-my-posh.exe`, expected: `'C:/tmp/oh-my-posh.exe'`},
+		{str: `C:/tmp/omp's dir/oh-my-posh.exe`, expected: `'C:/tmp/omp\'s dir/oh-my-posh.exe'`},
+	}
+	for _, tc := range tests {
+		assert.Equal(t, tc.expected, quoteLuaStr(tc.str), fmt.Sprintf("quoteLuaStr: %s", tc.str))
+	}
+}
+
+func TestQuoteNuStr(t *testing.T) {
+	tests := []struct {
+		str      string
+		expected string
+	}{
+		{str: ``, expected: `''`},
+		{str: `/tmp/oh-my-posh`, expected: `"/tmp/oh-my-posh"`},
+		{str: `/tmp/omp's dir/oh-my-posh`, expected: `"/tmp/omp's dir/oh-my-posh"`},
+		{str: `C:/tmp/oh-my-posh.exe`, expected: `"C:/tmp/oh-my-posh.exe"`},
+		{str: `C:/tmp/omp's dir/oh-my-posh.exe`, expected: `"C:/tmp/omp's dir/oh-my-posh.exe"`},
+	}
+	for _, tc := range tests {
+		assert.Equal(t, tc.expected, quoteNuStr(tc.str), fmt.Sprintf("quoteNuStr: %s", tc.str))
+	}
+}