You've already forked woodpecker
mirror of
https://github.com/woodpecker-ci/woodpecker.git
synced 2025-11-29 21:48:14 +02:00
local backend: fix windows cmd.exe command escaping (#5569)
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -24,18 +25,38 @@ import (
|
||||
"al.essio.dev/pkg/shellescape"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNoShellSet = errors.New("no shell was set")
|
||||
ErrNoCmdSet = errors.New("no commands where set")
|
||||
)
|
||||
|
||||
func (e *local) genCmdByShell(shell string, cmdList []string) (args []string, err error) {
|
||||
if len(cmdList) == 0 {
|
||||
return nil, ErrNoCmdSet
|
||||
}
|
||||
|
||||
script := ""
|
||||
for _, cmd := range cmdList {
|
||||
script += fmt.Sprintf("echo %s\n%s\n", strings.TrimSpace(shellescape.Quote("+ "+cmd)), cmd)
|
||||
}
|
||||
script = strings.TrimSpace(script)
|
||||
|
||||
switch strings.TrimSuffix(strings.ToLower(shell), ".exe") {
|
||||
shell = strings.TrimSuffix(strings.ToLower(shell), ".exe")
|
||||
switch shell {
|
||||
case "":
|
||||
return nil, ErrNoShellSet
|
||||
case "cmd":
|
||||
script := "@SET PROMPT=$\n"
|
||||
for _, cmd := range cmdList {
|
||||
script += fmt.Sprintf("@echo + %s\n", strings.TrimSpace(shellescape.Quote(cmd)))
|
||||
quotedCmd := strings.TrimSpace(shellescape.Quote(cmd))
|
||||
// As cmd echo does not allow strings with newlines we need to replace them ...
|
||||
quotedCmd = strings.ReplaceAll(quotedCmd, "\n", "\\n")
|
||||
// Also the shellescape.Quote fail with any | or & char and wrapping them in quotes again can be bypassed
|
||||
// by just leaving an string halve quoted we just replace them with symbolic representations
|
||||
quotedCmd = strings.ReplaceAll(quotedCmd, "&", "\\AND")
|
||||
quotedCmd = strings.ReplaceAll(quotedCmd, "|", "\\OR")
|
||||
|
||||
script += fmt.Sprintf("@echo + %s\n", quotedCmd)
|
||||
script += fmt.Sprintf("@%s\n", cmd)
|
||||
script += "@IF NOT %ERRORLEVEL% == 0 exit %ERRORLEVEL%\n"
|
||||
}
|
||||
@@ -60,7 +81,7 @@ func (e *local) genCmdByShell(shell string, cmdList []string) (args []string, er
|
||||
// cspell:disable-next-line
|
||||
return []string{"-noprofile", "-noninteractive", "-c", "$ErrorActionPreference = \"Stop\"; " + script}, nil
|
||||
default:
|
||||
// normal posix shells
|
||||
// assume posix shell
|
||||
return []string{"-e", "-c", script}, nil
|
||||
}
|
||||
}
|
||||
|
||||
139
pipeline/backend/local/command_test.go
Normal file
139
pipeline/backend/local/command_test.go
Normal file
@@ -0,0 +1,139 @@
|
||||
// Copyright 2025 Woodpecker Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package local
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGenCmdByShell(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
e := local{tempDir: tmpDir}
|
||||
|
||||
t.Run("error cases", func(t *testing.T) {
|
||||
args, err := e.genCmdByShell("", []string{"echo hi"})
|
||||
assert.Nil(t, args)
|
||||
assert.ErrorIs(t, err, ErrNoShellSet)
|
||||
|
||||
args, err = e.genCmdByShell("sh", []string{})
|
||||
assert.Nil(t, args)
|
||||
assert.ErrorIs(t, err, ErrNoCmdSet)
|
||||
})
|
||||
|
||||
t.Run("windows shells", func(t *testing.T) {
|
||||
t.Run("cmd", func(t *testing.T) {
|
||||
args, err := e.genCmdByShell("cmd.exe", []string{"echo hi", "call build.bat"})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, args, 2)
|
||||
assert.Equal(t, "/c", args[0])
|
||||
assert.True(t, strings.HasSuffix(args[1], ".cmd"))
|
||||
|
||||
// Verify the temp file was created and contains expected content
|
||||
content, err := os.ReadFile(args[1])
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, `@SET PROMPT=$
|
||||
@echo + 'echo hi'
|
||||
@echo hi
|
||||
@IF NOT %ERRORLEVEL% == 0 exit %ERRORLEVEL%
|
||||
@echo + 'call build.bat'
|
||||
@call build.bat
|
||||
@IF NOT %ERRORLEVEL% == 0 exit %ERRORLEVEL%
|
||||
`, string(content))
|
||||
})
|
||||
|
||||
t.Run("powershell", func(t *testing.T) {
|
||||
args, err := e.genCmdByShell("powershell", []string{"Write-Host 'test'", "echo test"})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, args, 4)
|
||||
assert.EqualValues(t, []string{"-noprofile", "-noninteractive", "-c"}, []string{args[0], args[1], args[2]})
|
||||
assert.EqualValues(t, `$ErrorActionPreference = "Stop"; echo '+ Write-Host '"'"'test'"'"''
|
||||
Write-Host 'test'
|
||||
echo '+ echo test'
|
||||
echo test`, args[3])
|
||||
|
||||
args, err = e.genCmdByShell("pwsh", []string{"Get-Process"})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, args, 4)
|
||||
assert.Equal(t, "-noprofile", args[0])
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("unix shells", func(t *testing.T) {
|
||||
args, err := e.genCmdByShell("sh", []string{"echo hello", "pwd"})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, args, 3)
|
||||
assert.Equal(t, "-e", args[0])
|
||||
assert.Equal(t, "-c", args[1])
|
||||
assert.Contains(t, args[2], "echo hello")
|
||||
assert.Contains(t, args[2], "pwd")
|
||||
|
||||
args, err = e.genCmdByShell("bash", []string{"ls -la"})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, args, 3)
|
||||
assert.Equal(t, "-e", args[0])
|
||||
assert.Equal(t, "-c", args[1])
|
||||
|
||||
args, err = e.genCmdByShell("zsh", []string{"echo test"})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, args, 3)
|
||||
assert.Equal(t, "-e", args[0])
|
||||
})
|
||||
|
||||
t.Run("fish shell", func(t *testing.T) {
|
||||
args, err := e.genCmdByShell("fish", []string{"echo test", "ls"})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, args, 2)
|
||||
assert.Equal(t, "-c", args[0])
|
||||
assert.Contains(t, args[1], "echo test")
|
||||
assert.Contains(t, args[1], "|| exit $status")
|
||||
})
|
||||
|
||||
t.Run("nu shell", func(t *testing.T) {
|
||||
args, err := e.genCmdByShell("nu", []string{"echo test"})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, args, 2)
|
||||
assert.Equal(t, "--commands", args[0])
|
||||
assert.Contains(t, args[1], "echo test")
|
||||
})
|
||||
|
||||
t.Run("command escaping", func(t *testing.T) {
|
||||
args, err := e.genCmdByShell("cmd", []string{"echo 'test with | pipe'", "echo 'test & ampersand'\n\necho new line"})
|
||||
require.NoError(t, err)
|
||||
content, err := os.ReadFile(args[1])
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, `@SET PROMPT=$
|
||||
@echo + 'echo '"'"'test with \OR pipe'"'"''
|
||||
@echo 'test with | pipe'
|
||||
@IF NOT %ERRORLEVEL% == 0 exit %ERRORLEVEL%
|
||||
@echo + 'echo '"'"'test \AND ampersand'"'"'\n\necho new line'
|
||||
@echo 'test & ampersand'
|
||||
|
||||
echo new line
|
||||
@IF NOT %ERRORLEVEL% == 0 exit %ERRORLEVEL%
|
||||
`, string(content))
|
||||
})
|
||||
|
||||
t.Run("shell with .exe suffix", func(t *testing.T) {
|
||||
args, err := e.genCmdByShell("bash.exe", []string{"echo test"})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, args, 3)
|
||||
assert.Equal(t, "-e", args[0])
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user