From 59c86b01bb39624650badd39f3acfd20be2b743b Mon Sep 17 00:00:00 2001 From: Yash Anil Date: Sun, 14 Jun 2026 18:05:27 -0700 Subject: [PATCH] completion: fix powershell completion corrupting non-ASCII names - fixes #9412 The Cobra generated PowerShell completion script captures rclone's output through a pipeline with Invoke-Expression. PowerShell decodes that output using [Console]::OutputEncoding, which on non-UTF-8 hosts (for example PowerShell 5.1 on a Windows install with an OEM code page such as CP852) misinterprets the UTF-8 bytes rclone emits and corrupts remote and path names containing non-ASCII characters, so tab completion produces a path that does not exist. Inject "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8" into the generated script immediately before the Invoke-Expression call. This is safe on PowerShell 7+, where UTF-8 is already the default. If the expected line is not present (for example after a Cobra template change) the script is emitted unmodified so we never produce a corrupted completion script. --- .../genautocomplete_powershell.go | 41 ++++++++++++-- cmd/genautocomplete/genautocomplete_test.go | 56 +++++++++++++++++++ 2 files changed, 93 insertions(+), 4 deletions(-) diff --git a/cmd/genautocomplete/genautocomplete_powershell.go b/cmd/genautocomplete/genautocomplete_powershell.go index 19b254b39..d42e4e55d 100644 --- a/cmd/genautocomplete/genautocomplete_powershell.go +++ b/cmd/genautocomplete/genautocomplete_powershell.go @@ -1,8 +1,10 @@ package genautocomplete import ( + "bytes" "fmt" "os" + "strings" "github.com/rclone/rclone/cmd" "github.com/rclone/rclone/fs" @@ -13,6 +15,34 @@ func init() { completionDefinition.AddCommand(powershellCommandDefinition) } +// powerShellInvokeLine is the line in the Cobra generated PowerShell completion +// script that captures rclone's output through a pipeline. +const powerShellInvokeLine = `Invoke-Expression -OutVariable out "$RequestComp" 2>&1 | Out-Null` + +// powerShellUTF8Fix forces the captured output to be decoded as UTF-8. When +// PowerShell captures a child process' stdout through a pipeline it decodes the +// bytes using [Console]::OutputEncoding, which on non-UTF-8 systems (for +// example PowerShell 5.1 on a Windows install with an OEM code page such as +// CP852) corrupts the UTF-8 that rclone emits. Setting the encoding to UTF-8 is +// safe on PowerShell 7+, where it is already the default. +const powerShellUTF8Fix = `[Console]::OutputEncoding = [System.Text.Encoding]::UTF8` + +// patchPowerShellCompletion injects the UTF-8 output encoding fix immediately +// before the Invoke-Expression call in the Cobra generated PowerShell +// completion script. If the expected line is not found (for example because the +// upstream Cobra template changed), the script is returned unmodified so we +// never emit a corrupted completion script. +func patchPowerShellCompletion(script string) string { + idx := strings.Index(script, powerShellInvokeLine) + if idx == -1 { + return script + } + // Reuse the indentation of the Invoke-Expression line for the inserted line. + lineStart := strings.LastIndex(script[:idx], "\n") + 1 + indent := script[lineStart:idx] + return script[:lineStart] + indent + powerShellUTF8Fix + "\n" + script[lineStart:] +} + var powershellCommandDefinition = &cobra.Command{ Use: "powershell [output_file]", Short: `Output powershell completion script for rclone.`, @@ -30,15 +60,18 @@ to your powershell profile. If output_file is "-" or missing, then the output will be written to stdout.`, Run: func(command *cobra.Command, args []string) { cmd.CheckArgs(0, 1, command, args) + var buf bytes.Buffer + if err := cmd.Root.GenPowerShellCompletion(&buf); err != nil { + fs.Fatal(nil, fmt.Sprint(err)) + } + script := patchPowerShellCompletion(buf.String()) if len(args) == 0 || (len(args) > 0 && args[0] == "-") { - err := cmd.Root.GenPowerShellCompletion(os.Stdout) - if err != nil { + if _, err := os.Stdout.WriteString(script); err != nil { fs.Fatal(nil, fmt.Sprint(err)) } return } - err := cmd.Root.GenPowerShellCompletionFile(args[0]) - if err != nil { + if err := os.WriteFile(args[0], []byte(script), 0644); err != nil { fs.Fatal(nil, fmt.Sprint(err)) } }, diff --git a/cmd/genautocomplete/genautocomplete_test.go b/cmd/genautocomplete/genautocomplete_test.go index fab32f3d1..55c6c9a66 100644 --- a/cmd/genautocomplete/genautocomplete_test.go +++ b/cmd/genautocomplete/genautocomplete_test.go @@ -2,6 +2,7 @@ package genautocomplete import ( "os" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -107,3 +108,58 @@ func TestCompletionFishStdout(t *testing.T) { assert.NoError(t, err) assert.NotEmpty(t, string(output)) } + +func TestCompletionPowershell(t *testing.T) { + tempFile, err := os.CreateTemp("", "completion_powershell") + assert.NoError(t, err) + defer func() { + _ = tempFile.Close() + _ = os.Remove(tempFile.Name()) + }() + + powershellCommandDefinition.Run(powershellCommandDefinition, []string{tempFile.Name()}) + + bs, err := os.ReadFile(tempFile.Name()) + assert.NoError(t, err) + assert.NotEmpty(t, string(bs)) + // The generated script must force UTF-8 output decoding so that non-ASCII + // remote names are not corrupted on non-UTF-8 PowerShell hosts. + assert.Contains(t, string(bs), powerShellUTF8Fix) +} + +func TestCompletionPowershellStdout(t *testing.T) { + originalStdout := os.Stdout + tempFile, err := os.CreateTemp("", "completion_powershell") + assert.NoError(t, err) + defer func() { + _ = tempFile.Close() + _ = os.Remove(tempFile.Name()) + }() + + os.Stdout = tempFile + defer func() { os.Stdout = originalStdout }() + + powershellCommandDefinition.Run(powershellCommandDefinition, []string{"-"}) + + output, err := os.ReadFile(tempFile.Name()) + assert.NoError(t, err) + assert.NotEmpty(t, string(output)) + assert.Contains(t, string(output), powerShellUTF8Fix) +} + +func TestPatchPowerShellCompletion(t *testing.T) { + t.Run("injects the encoding fix before the invoke line", func(t *testing.T) { + script := "before\n " + powerShellInvokeLine + "\nafter\n" + got := patchPowerShellCompletion(script) + // The fix is inserted on its own line, sharing the indentation of the + // invoke line, immediately before it. + want := "before\n " + powerShellUTF8Fix + "\n " + powerShellInvokeLine + "\nafter\n" + assert.Equal(t, want, got) + assert.Less(t, strings.Index(got, powerShellUTF8Fix), strings.Index(got, powerShellInvokeLine)) + }) + + t.Run("leaves the script unchanged when the invoke line is absent", func(t *testing.T) { + script := "some other script\nwithout the expected line\n" + assert.Equal(t, script, patchPowerShellCompletion(script)) + }) +}