Files
act_runner/act/runner/command_test.go
silverwind fe2a66dc25 fix: align runner behaviours (#1338)
Contains a number of small bug fixes where jobs behaved differently from GitHub or failed silently.

1. Hooks fail on a refused `::set-env::` or `::add-path::` and no longer inherit the last step's identity
1. Docker build, exec and wait errors are returned instead of swallowed, token-only registry credentials work, logs of `--tty` services decode
1. Env files split lines like GitHub on each OS, so a carriage return is kept on Linux and CRLF ends a line on Windows, empty heredoc names or delimiters are rejected
1. Post steps run after their pre step even when main was skipped, composites see undeclared inputs, `runs.args` wins without `with.args` being parsed
1. Docker actions skip evaluating defaults of inputs given in `with` and read `entryPoint` and `args` in any case, a lowercase `dockerfile` is found, undeclared inputs get GitHub's warning
1. A user-set `CI` is kept, `GITHUB_REPOSITORY_ID` and the other missing `GITHUB_*` variables are exported
1. `timeout-minutes` can read the step's own `env`, a step-level `ACTIONS_ALLOW_UNSECURE_COMMANDS: false` beats a job-level `true` while the runner's own opt-in still wins, `pwsh` quotes the script path
1. A step `env:` given as one expression such as `${{ fromJSON(matrix.env) }}` is applied, `hashFiles` fails with GitHub's messages on script errors and after 120 seconds, a job image without node still gets an empty hash
1. A step whose command files cannot be reset fails instead of importing stale ones, a timeout between steps fails the job instead of reporting success
1. One named volume can be mounted at several paths, host-mode PTY steps keep their last output when they fail, host scratch directories are removed when job setup fails
1. Cancelling a step right after it starts kills its whole process tree
1. Cache prefix restores pick the newest entry created within the same second, also on S3, cache commits with a wrong size are rejected
1. Artifact chunks are written at their offset, download URLs are escaped, listing a run without artifacts no longer crashes
1. Poll backoff reaches `fetch_interval_max`, proxy passwords are masked in more forms, secrets are also masked in step names and in their quoted form in the runner log, a failed final report is logged
1. On Windows hosts a differently cased unsecure-commands opt-in or proxy variable counts, and an early cancel no longer leaks a job object handle
1. Interactive `register` prompts for a missing token and stops on SIGTERM, `exec --list --detect-event` no longer panics without events, `config set` works on `cache.s3` keys

Reviewed-on: https://gitea.com/gitea/runner/pulls/1338
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-23 09:18:24 +00:00

286 lines
13 KiB
Go

// Copyright 2022 The Gitea Authors. All rights reserved.
// Copyright 2020 The nektos/act Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package runner
import (
"io"
"strconv"
"strings"
"testing"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/common/logging"
"gitea.com/gitea/runner/act/common/logging/logtest"
"gitea.dev/actionslib/pkg/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseActionCommand(t *testing.T) {
for _, test := range []struct {
name, line, resumeCommand, command string
properties map[string]string
data string
ok bool
}{
{"plain", "plain output", "", "", nil, "", false},
{"unknown v2", "::unknown::v", "", "", nil, "", false},
{"missing v2 terminator", "::set-output name=x", "", "", nil, "", false},
{"empty command", "::::", "", "", nil, "", false},
{"text before v2", "debug ::set-output name=x::v", "", "", nil, "", false},
{"missing legacy terminator", "##[debug", "", "", nil, "", false},
{"tab is not a name separator", "::set-output\tname=x::v", "", "", nil, "", false},
{"v2 leading unicode whitespace and line endings", " \t\u2003::set-output name=tab::v\r\n\r\n", "", "set-output", map[string]string{"name": "tab"}, "v", true},
{"legacy prefix and casing", "prefix ##[SET-OUTPUT NAME=mid;]v\r\n", "", "set-output", map[string]string{"name": "mid"}, "v", true},
{"only first legacy prefix", "##[unknown]##[set-output name=x]v", "", "", nil, "", false},
{"v2 delimiter boundaries", "::set-output name=a:b,other=c:d:: a::b ", "", "set-output", map[string]string{"name": "a:b", "other": "c:d"}, " a::b ", true},
{"legacy first closing bracket", "##[set-output name=x]v]tail", "", "set-output", map[string]string{"name": "x"}, "v]tail", true},
{"v2 semicolons are literal", "::set-output name=x;y=z::v", "", "set-output", map[string]string{"name": "x;y=z"}, "v", true},
{"legacy commas are literal", "##[set-output name=x,y=z]v", "", "set-output", map[string]string{"name": "x,y=z"}, "v", true},
{"values keep their leading equals", "::set-output name==x=y,=key=v,empty=,=x,spaced= =x::v", "", "set-output", map[string]string{"name": "=x=y", "key": "v", "spaced": " =x"}, "v", true},
{"v2 trims whole list only", "::set-output \tfile=x, line=2,name = value \t::v", "", "set-output", map[string]string{"file": "x", " line": "2", "name ": " value"}, "v", true},
{"legacy preserves list whitespace", "##[set-output name=q ]v", "", "set-output", map[string]string{" name": "q "}, "v", true},
{"case insensitive duplicate keys", "::SET-OUTPUT NAME=first,Name=last::v", "", "set-output", map[string]string{"name": "last"}, "v", true},
{"keys stay escaped", "::set-output na%3Ame=x::v", "", "set-output", map[string]string{"na%3ame": "x"}, "v", true},
{"v2 decode table", "::set-output name=%0D%0A%3A%2C%25%253A%252C%250A%2525%3a%2c%0d%0a%3B%5D::%0D%0A%25%3A%2C%3B%5D%250A", "", "set-output", map[string]string{"name": "\r\n:,%%3A%2C%0A%25%3a%2c%0d%0a%3B%5D"}, "\r\n%%3A%2C%3B%5D%0A", true},
{"legacy decode table", "##[set-output name=%3B%0D%0A%5D%25%253B%255D%250A%2525%3b%5d%0d%0a%3A%2C]v%5Dw%3Bx", "", "set-output", map[string]string{"name": ";\r\n]%%3B%5D%0A%25%3b%5d%0d%0a%3A%2C"}, "v]w;x", true},
{"unknown v2 falls back to legacy", "::unknown::prefix ##[set-output name=x]v", "", "set-output", map[string]string{"name": "x"}, "v", true},
{"unterminated v2 falls back to legacy", "::debug ##[set-output name=x]v", "", "set-output", map[string]string{"name": "x"}, "v", true},
{"v2 wins over legacy", "::debug::##[set-output name=x]v", "", "debug", map[string]string{}, "##[set-output name=x]v", true},
{"internal line breaks are not commands", "::debug::hidden\rplain\r\n", "", "", nil, "", false},
{"v2 resume token", "::ReSuMe-ToKeN::", "RESUME-TOKEN", "resume-token", map[string]string{}, "", true},
{"legacy resume token", "prefix ##[RESUME-TOKEN]v", "resume-token", "resume-token", map[string]string{}, "v", true},
{"different resume token", "::other-token::", "resume-token", "", nil, "", false},
} {
t.Run(test.name, func(t *testing.T) {
command, properties, data, ok := parseActionCommand(test.line, test.resumeCommand)
assert.Equal(t, test.command, command)
assert.Equal(t, test.properties, properties)
assert.Equal(t, test.data, data)
assert.Equal(t, test.ok, ok)
})
}
}
// unsecureRC opts into ::set-env:: and ::add-path::, which are refused without it.
func unsecureRC() *RunContext {
return &RunContext{Config: &Config{}, Env: map[string]string{allowUnsecureCommandsVar: "true"}}
}
func TestSetEnv(t *testing.T) {
a := assert.New(t)
logger, hook := logtest.NewLogger()
ctx := common.WithLogger(t.Context(), logger)
rc := unsecureRC()
handler := rc.commandHandler(ctx)
handler("::set-env name=x::valz\n")
a.Equal("valz", rc.Env["x"])
handler("::SET-ENV name=NODE_OPTIONS::--require command.js\n")
rc.setEnvFile(ctx, map[string]string{"name": "node_options"}, "--require env.js")
a.NotContains(rc.Env, "NODE_OPTIONS")
a.NotContains(rc.Env, "node_options")
entries := hook.AllRecords()
require.Len(t, entries, 2)
a.Equal("##[error]Can't update NODE_OPTIONS environment variable using ::set-env:: command.", entries[0].Message)
a.Equal("##[error]Can't store NODE_OPTIONS output parameter using '$GITHUB_ENV' command.", entries[1].Message)
}
func TestSetOutput(t *testing.T) {
rc := &RunContext{Config: &Config{}, CurrentStep: "my-step", StepResults: map[string]*model.StepResult{"my-step": {Outputs: map[string]string{}}}}
handler := rc.commandHandler(t.Context())
handler("::set-output NAME=modern%3A::v%0D%0A%25%3B%5D%250A\n")
handler("##[set-output name=legacy%3B%5D]v%0D%0A%25%3B%5D%250A\n")
assert.Equal(t, map[string]string{"modern:": "v\r\n%%3B%5D%0A", "legacy;]": "v\r\n%;]%0A"}, rc.StepResults["my-step"].Outputs)
}
func TestAddpath(t *testing.T) {
for _, prefix := range []string{"::add-path::", "##[add-path]"} {
t.Run(prefix, func(t *testing.T) {
rc := unsecureRC()
handler := rc.commandHandler(t.Context())
handler(prefix + "/zoo\n")
handler(prefix + "/boo\n")
assert.Equal(t, []string{"/boo", "/zoo"}, rc.ExtraPath)
handler(prefix + "/zoo\n")
assert.Equal(t, []string{"/zoo", "/boo"}, rc.ExtraPath)
})
}
}
func TestSaveState(t *testing.T) {
rc := &RunContext{
Config: &Config{},
CurrentStep: "step",
StepResults: map[string]*model.StepResult{},
}
ctx := t.Context()
handler := rc.commandHandler(ctx)
handler("::save-state name=state-name::state-value\n")
assert.Equal(t, "state-value", rc.IntraActionState["step"]["state-name"])
}
func TestEscapeCommandData(t *testing.T) {
a := assert.New(t)
a.Equal("a%25b%0Dc%0Ad%250A", EscapeCommandData("a%b\rc\nd%0A"))
a.Equal("a%b\rc\nd%0A", UnescapeCommandData("a%25b%0Dc%0Ad%250A"))
}
func TestUnsecureCommands(t *testing.T) {
tests := []struct {
name string
jobEnv map[string]string
globalEnv map[string]string
stepEnv map[string]string
processEnv string
caseInsensitive bool
optedIn bool
}{
{name: "refused with no opt-in"},
{name: "refused for a value bool.TryParse rejects", jobEnv: map[string]string{allowUnsecureCommandsVar: "1"}},
{name: "opted in through the step environment", stepEnv: map[string]string{allowUnsecureCommandsVar: "true"}, optedIn: true},
{name: "opted in through the job environment", jobEnv: map[string]string{allowUnsecureCommandsVar: "TRUE"}, optedIn: true},
{name: "step opt-out overrides the job and global environment", jobEnv: map[string]string{allowUnsecureCommandsVar: "true"}, globalEnv: map[string]string{allowUnsecureCommandsVar: "true"}, stepEnv: map[string]string{allowUnsecureCommandsVar: "false"}},
{name: "the runner's own opt-in overrides a step opt-out", stepEnv: map[string]string{allowUnsecureCommandsVar: "false"}, processEnv: "true", optedIn: true},
{name: "a differently cased opt-in counts where env names are case-insensitive", jobEnv: map[string]string{"Actions_Allow_Unsecure_Commands": "true"}, caseInsensitive: true, optedIn: true},
{name: "a differently cased opt-in is ignored where env names are case-sensitive", jobEnv: map[string]string{"Actions_Allow_Unsecure_Commands": "true"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv(allowUnsecureCommandsVar, tt.processEnv)
a := assert.New(t)
rc := &RunContext{Config: &Config{}, Env: tt.jobEnv, GlobalEnv: tt.globalEnv, JobContainer: fakeContainer{caseInsensitive: tt.caseInsensitive}}
rc.setCurrentStepEnv(tt.stepEnv)
handler := rc.commandHandler(t.Context())
a.False(handler("::set-env name=x::valz\n"))
a.False(handler("::add-path::/opt/bin\n"))
if !tt.optedIn {
a.Empty(rc.Env["x"])
a.Empty(rc.ExtraPath)
require.ErrorContains(t, rc.takeUnsecureCommandError(), "set-env")
a.NoError(rc.takeUnsecureCommandError())
return
}
a.Equal("valz", rc.Env["x"])
a.Equal([]string{"/opt/bin"}, rc.ExtraPath)
a.NoError(rc.takeUnsecureCommandError())
})
}
}
type maskRecorder struct {
logging.Handler
masks []string
}
func (m *maskRecorder) AddMask(mask string) { m.masks = append(m.masks, mask) }
func TestCommandLogWriter(t *testing.T) {
for _, tt := range []struct {
name string
debug bool
steps [][]string
log []string
masks []string
}{
{
name: "applied commands are hidden unless echoed",
steps: [][]string{{
"::set-env name=x::y", "::add-path::/bin", "::set-output name=a::b", "##[save-state name=a]b", "::add-matcher::m.json", "::remove-matcher owner=m::",
"::echo::On%0A",
"::set-env name=x::y", "::add-path::/bin", "::set-output name=a::b", "##[save-state name=a]b", "::add-matcher::m.json", "::remove-matcher owner=m::",
"::ADD-MASK::my::secret", "::echo::bogus", "::echo::off", "::add-mask ::empty-properties", "::unknown::v", "plain",
}},
log: []string{
"::set-env name=x::y", "::add-path::/bin", "::set-output name=a::b", "##[save-state name=a]b", "::add-matcher::m.json", "::remove-matcher owner=m::",
"::add-mask::***", "::echo::bogus", "::echo::off", "::unknown::v", "plain",
},
masks: []string{"my::secret", "empty-properties"},
},
{
name: "step debug shows debug lines and echoes commands until echo is off",
debug: true,
steps: [][]string{{"::debug::first%0Asecond", "::set-output name=a::b", "::echo::off", "::debug::again", "::set-output name=a::b"}},
log: []string{"::debug::first%0Asecond", "::set-output name=a::b", "::echo::off", "::debug::again"},
},
{
name: "debug lines are hidden without step debug and an invalid echo changes nothing",
steps: [][]string{{"::echo::bogus", "::set-output name=a::b", "::echo::on", "::debug::d"}},
},
{
name: "annotations fold their location once whether echoed or not",
steps: [][]string{{
"::echo::on",
"::error file=main.go,line=12,col=5,title=vet::undefined: x",
"::warning File=a%3Ab.go,Line=1,column=2,title=100%252C::50%25%0Anext",
"::warning file=x,line=1,col=2,column=3::m",
"::notice line=9,col=4,title=title::m",
"::error file=x,col=2::m",
"::error file=x,line=1::m",
"::notice::std::vector",
"##[error file=x%3By]m%5D",
}},
log: []string{
"::error::main.go:12:5: vet: undefined: x", "::warning::a:b.go:1:2: 100%252C: 50%25%0Anext", "::warning::x:1:2: m",
"::notice::title: m", "::error::x: m", "::error::x:1: m", "::notice::std::vector", "::error::x;y: m]",
},
},
{
name: "stop-commands keeps lines verbatim until resumed",
steps: [][]string{{"::stop-commands::resume-token", "::add-mask::kept", "::set-output name=a::b", "::RESUME-TOKEN::", "::add-mask::masked"}},
log: []string{"::stop-commands::resume-token", "::add-mask::kept", "::set-output name=a::b", "::RESUME-TOKEN::"},
masks: []string{"resume-token", "masked"},
},
{
name: "a short stop token naming a command stays unmasked and resumes in any case",
steps: [][]string{{"::stop-commands::NOTICE", "::notice::", "::add-mask::masked"}},
log: []string{"::stop-commands::NOTICE", "::notice::"},
masks: []string{"masked"},
},
{
name: "state does not carry over to the next step",
steps: [][]string{{"::echo::on", "::stop-commands::token77"}, {"::set-output name=a::b", "::add-mask::masked"}},
log: []string{"::stop-commands::token77"},
masks: []string{"token77", "masked"},
},
} {
t.Run(tt.name, func(t *testing.T) {
logger, hook := logtest.NewLogger()
recorder := &maskRecorder{}
ctx := common.WithLoggerHandler(common.WithLogger(t.Context(), logger), recorder)
rc := &RunContext{
Config: &Config{},
Parent: &RunContext{Config: &Config{Secrets: map[string]string{"ACTIONS_STEP_DEBUG": strconv.FormatBool(tt.debug)}}},
Env: map[string]string{allowUnsecureCommandsVar: "true"},
CurrentStep: "step",
StepResults: map[string]*model.StepResult{"step": {Outputs: map[string]string{}}},
}
for _, lines := range tt.steps {
writer := rc.commandLogWriter(ctx)
for _, line := range lines {
_, err := io.WriteString(writer, line+"\n")
require.NoError(t, err)
}
}
var log []string
for _, record := range hook.AllRecords() {
log = append(log, strings.TrimSuffix(record.Message, "\n"))
}
assert.Equal(t, tt.log, log)
assert.Equal(t, tt.masks, recorder.masks)
assert.Equal(t, tt.masks, rc.Masks)
})
}
}