mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-08-06 00:44:22 +02:00
feat: gate set-env/add-path and render annotation locations (#1109)
`::set-env::` and `::add-path::` let a step rewrite the environment of every later step from its own output, which the runner honoured silently. They are now refused, as GitHub has done since 2020, and `ACTIONS_ALLOW_UNSECURE_COMMANDS` opts back in per step or job. Support for that variable is new here too, and is the only opt-in, matching GitHub rather than adding a runner config key on top. Annotations keep their source location: Gitea has no annotation store and its web UI strips command properties, so `::error file=main.go,line=12::msg` is rendered as `::error::main.go:12: msg`. `DEVELOPMENT.md` writes down the log line encoding rules this relies on. --------- Co-authored-by: silverwind <me@silverwind.io> Reviewed-on: https://gitea.com/gitea/runner/pulls/1109 Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com> Co-authored-by: bircni <bircni@icloud.com>
This commit is contained in:
@@ -8,6 +8,7 @@
|
|||||||
- Never force-push, amend, or squash unless asked. Use new commits and normal push for pull request updates
|
- Never force-push, amend, or squash unless asked. Use new commits and normal push for pull request updates
|
||||||
- Comments: write almost none, short and preferably same-line, explaining why for a future reader. Never narrate code, the change or the prompt. Preserve existing ones that still apply
|
- Comments: write almost none, short and preferably same-line, explaining why for a future reader. Never narrate code, the change or the prompt. Preserve existing ones that still apply
|
||||||
- Add the current year into the copyright header of new `.go` files
|
- Add the current year into the copyright header of new `.go` files
|
||||||
|
- Read `DEVELOPMENT.md` for internals and conventions
|
||||||
- Ensure no trailing whitespace in edited files
|
- Ensure no trailing whitespace in edited files
|
||||||
- Run `make fmt` after `.go` edits, `make tidy` after `go.mod` edits, and `make checks` for the non-lint source checks
|
- Run `make fmt` after `.go` edits, `make tidy` after `go.mod` edits, and `make checks` for the non-lint source checks
|
||||||
- Lint what changed with `make lint-go`, and `make lint-go-windows` for Windows and platform-split files
|
- Lint what changed with `make lint-go`, and `make lint-go-windows` for Windows and platform-split files
|
||||||
|
|||||||
32
DEVELOPMENT.md
Normal file
32
DEVELOPMENT.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Development
|
||||||
|
|
||||||
|
## Job log line format
|
||||||
|
|
||||||
|
Gitea stores one log row per line and its web UI decodes the payload, so getting the encoding
|
||||||
|
wrong never fails a test here, it only shows up in the browser.
|
||||||
|
|
||||||
|
**A row cannot contain a real newline.** `FormatLog` rewrites `\n` to a literal backslash-n and
|
||||||
|
truncates at 64 KiB on a byte boundary.
|
||||||
|
|
||||||
|
**The payload of a line starting with a recognised prefix is decoded**, with the escape set
|
||||||
|
depending on the prefix:
|
||||||
|
|
||||||
|
| prefix | decodes |
|
||||||
|
| --- | --- |
|
||||||
|
| `##[error]` `##[warning]` `##[notice]` `##[debug]` `##[group]` `##[endgroup]` `##[add-matcher]` | `%25` `%0D` `%0A` `%3B` `%5D` |
|
||||||
|
| `::error::` `::warning::` `::notice::` `::debug::` (with or without ` key=value` properties), `::group::` `::endgroup::` `::add-matcher::` | `%25` `%0D` `%0A` |
|
||||||
|
| `##[command]` `[command]`, or no recognised prefix | nothing |
|
||||||
|
|
||||||
|
### Rules
|
||||||
|
|
||||||
|
- **Emitting a command line?** Escape the payload with `runner.EscapeCommandData`. One escaper
|
||||||
|
covers both forms: it escapes `%` first, so a literal `%3B` becomes `%253B` that the extra
|
||||||
|
`##[…]` rules cannot match, and a raw `;` or `]` is never decoded. It is also what makes
|
||||||
|
multi-line work, `\n` becomes `%0A` and the UI turns it back into a line break.
|
||||||
|
- **Forwarding a command from step output?** Leave the payload alone, it arrived escaped and is
|
||||||
|
decoded once. Decoding here double-decodes and destroys multi-line.
|
||||||
|
- **No prefix?** Do not escape, and split multi-line values into one row each.
|
||||||
|
- **Interpolating a secret?** Masking runs after escaping, so `AppendSecretMasker` registers the
|
||||||
|
encoded forms too.
|
||||||
|
- Command *properties* also escape `%3A` and `%2C`, which the UI never decodes, so the reporter
|
||||||
|
decodes exactly those two when folding a location into an annotation.
|
||||||
@@ -186,10 +186,10 @@ func (rc *RunContext) compositeExecutor(action *model.Action) *compositeSteps {
|
|||||||
err := rc.newCompositeCommandExecutor(step.main())(ctx)
|
err := rc.newCompositeCommandExecutor(step.main())(ctx)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("%v", err)
|
logger.Errorf("##[error]%s", EscapeCommandData(err.Error()))
|
||||||
common.SetJobError(ctx, err)
|
common.SetJobError(ctx, err)
|
||||||
} else if ctx.Err() != nil {
|
} else if ctx.Err() != nil {
|
||||||
logger.Errorf("%v", ctx.Err())
|
logger.Errorf("##[error]%s", EscapeCommandData(ctx.Err().Error()))
|
||||||
common.SetJobError(ctx, ctx.Err())
|
common.SetJobError(ctx, ctx.Err())
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -248,10 +248,10 @@ func newCompositeStepLogExecutor(runStep common.Executor, stepID string) common.
|
|||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
err := runStep(ctx)
|
err := runStep(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("%v", err)
|
logger.Errorf("##[error]%s", EscapeCommandData(err.Error()))
|
||||||
common.SetJobError(ctx, err)
|
common.SetJobError(ctx, err)
|
||||||
} else if ctx.Err() != nil {
|
} else if ctx.Err() != nil {
|
||||||
logger.Errorf("%v", ctx.Err())
|
logger.Errorf("##[error]%s", EscapeCommandData(ctx.Err().Error()))
|
||||||
common.SetJobError(ctx, ctx.Err())
|
common.SetJobError(ctx, ctx.Err())
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ package runner
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -45,17 +46,24 @@ func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if resumeCommand != "" && command != resumeCommand {
|
if resumeCommand != "" {
|
||||||
// There should not be any emojis in the log output for Gitea.
|
// There should not be any emojis in the log output for Gitea.
|
||||||
// The code in the switch statement is the same.
|
|
||||||
// Return true (not false) so the line still reaches the raw_output
|
// Return true (not false) so the line still reaches the raw_output
|
||||||
// log handler; otherwise everything between ::stop-commands:: and
|
// log handler; otherwise everything between ::stop-commands:: and
|
||||||
// its end token is silently dropped from the step log.
|
// its end token is silently dropped from the step log.
|
||||||
logger.Infof("%s", line)
|
logger.Infof("%s", line)
|
||||||
|
// Resumed here rather than from the switch, because the end token is arbitrary
|
||||||
|
// and a token naming a real command would otherwise never resume.
|
||||||
|
if command == resumeCommand {
|
||||||
|
resumeCommand = ""
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
arg = UnescapeCommandData(arg)
|
arg = UnescapeCommandData(arg)
|
||||||
kvPairs = unescapeKvPairs(kvPairs)
|
kvPairs = unescapeKvPairs(kvPairs)
|
||||||
|
if (command == "set-env" || command == "add-path") && rc.refuseUnsecureCommand(ctx, command) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
switch command {
|
switch command {
|
||||||
case "set-env":
|
case "set-env":
|
||||||
rc.setEnv(ctx, kvPairs, arg)
|
rc.setEnv(ctx, kvPairs, arg)
|
||||||
@@ -63,27 +71,20 @@ func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler {
|
|||||||
rc.setOutput(ctx, kvPairs, arg)
|
rc.setOutput(ctx, kvPairs, arg)
|
||||||
case "add-path":
|
case "add-path":
|
||||||
rc.addPath(ctx, arg)
|
rc.addPath(ctx, arg)
|
||||||
case "debug":
|
|
||||||
logger.Infof("%s", line)
|
|
||||||
case "warning":
|
|
||||||
logger.Infof("%s", line)
|
|
||||||
case "error":
|
|
||||||
logger.Infof("%s", line)
|
|
||||||
case "add-mask":
|
case "add-mask":
|
||||||
rc.AddMask(arg)
|
rc.AddMask(arg)
|
||||||
logger.Infof("%s", "***")
|
logger.Infof("%s", "***")
|
||||||
|
// The raw line is still forwarded, carrying the secret: that is how the reporter
|
||||||
|
// learns the mask, and it drops the row rather than writing it out.
|
||||||
case "stop-commands":
|
case "stop-commands":
|
||||||
resumeCommand = arg
|
resumeCommand = arg
|
||||||
logger.Infof("%s", line)
|
logger.Infof("%s", line)
|
||||||
case resumeCommand:
|
|
||||||
resumeCommand = ""
|
|
||||||
logger.Infof("%s", line)
|
|
||||||
case "save-state":
|
case "save-state":
|
||||||
logger.Infof("%s", line)
|
logger.Infof("%s", line)
|
||||||
rc.saveState(ctx, kvPairs, arg)
|
rc.saveState(ctx, kvPairs, arg)
|
||||||
case "add-matcher":
|
|
||||||
logger.Infof("%s", line)
|
|
||||||
default:
|
default:
|
||||||
|
// ::debug::, ::error::, ::warning::, ::add-matcher:: and anything unrecognised are
|
||||||
|
// passed through for the reporter and Gitea's web UI to render.
|
||||||
logger.Infof("%s", line)
|
logger.Infof("%s", line)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +93,52 @@ func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const allowUnsecureCommandsVar = "ACTIONS_ALLOW_UNSECURE_COMMANDS"
|
||||||
|
|
||||||
|
// refuseUnsecureCommand reports whether a deprecated ::set-env:: or ::add-path:: command must
|
||||||
|
// not run, recording the error that fails the step. GitHub disabled both because a step that
|
||||||
|
// echoes untrusted content can use them to set NODE_OPTIONS or PATH for every later step.
|
||||||
|
func (rc *RunContext) refuseUnsecureCommand(ctx context.Context, command string) bool {
|
||||||
|
if rc.allowUnsecureCommandsOptIn() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// The step executor logs the failure itself, so keep this line's wording distinct.
|
||||||
|
common.Logger(ctx).WithField(rawOutputField, true).Errorf("##[error]%s", EscapeCommandData(fmt.Sprintf(
|
||||||
|
"The `%s` command is disabled: it can set the environment of every later step from untrusted output. "+
|
||||||
|
"Write to $GITHUB_ENV or $GITHUB_PATH instead, or set ACTIONS_ALLOW_UNSECURE_COMMANDS to allow it",
|
||||||
|
command)))
|
||||||
|
|
||||||
|
rc.unsecureCommandMu.Lock()
|
||||||
|
defer rc.unsecureCommandMu.Unlock()
|
||||||
|
if rc.unsecureCommandErr == nil {
|
||||||
|
rc.unsecureCommandErr = fmt.Errorf("the `%s` workflow command is disabled", command)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// allowUnsecureCommandsOptIn reports whether the workflow itself asked for the deprecated
|
||||||
|
// commands, from any env scope, as it can on GitHub.
|
||||||
|
func (rc *RunContext) allowUnsecureCommandsOptIn() bool {
|
||||||
|
return isTruthyEnv(rc.currentStepEnv()[allowUnsecureCommandsVar]) ||
|
||||||
|
isTruthyEnv(rc.Env[allowUnsecureCommandsVar]) ||
|
||||||
|
isTruthyEnv(rc.GlobalEnv[allowUnsecureCommandsVar])
|
||||||
|
}
|
||||||
|
|
||||||
|
// isTruthyEnv mirrors GitHub's bool.TryParse: only "true", in any casing.
|
||||||
|
func isTruthyEnv(v string) bool {
|
||||||
|
return strings.EqualFold(strings.TrimSpace(v), "true")
|
||||||
|
}
|
||||||
|
|
||||||
|
// takeUnsecureCommandError returns and clears the error left by a refused command.
|
||||||
|
func (rc *RunContext) takeUnsecureCommandError() error {
|
||||||
|
rc.unsecureCommandMu.Lock()
|
||||||
|
defer rc.unsecureCommandMu.Unlock()
|
||||||
|
err := rc.unsecureCommandErr
|
||||||
|
rc.unsecureCommandErr = nil
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (rc *RunContext) setEnv(ctx context.Context, kvPairs map[string]string, arg string) {
|
func (rc *RunContext) setEnv(ctx context.Context, kvPairs map[string]string, arg string) {
|
||||||
name := kvPairs["name"]
|
name := kvPairs["name"]
|
||||||
common.Logger(ctx).Infof("::set-env:: %s=%s", name, arg)
|
common.Logger(ctx).Infof("::set-env:: %s=%s", name, arg)
|
||||||
@@ -161,9 +208,9 @@ var (
|
|||||||
commandPropertyUnescaper = strings.NewReplacer("%25", "%", "%0D", "\r", "%0A", "\n", "%3A", ":", "%2C", ",")
|
commandPropertyUnescaper = strings.NewReplacer("%25", "%", "%0D", "\r", "%0A", "\n", "%3A", ":", "%2C", ",")
|
||||||
)
|
)
|
||||||
|
|
||||||
// escapeCommandData encodes the data part of a "::cmd::" or "##[cmd]" line the runner writes itself,
|
// EscapeCommandData encodes the data part of a "::cmd::" or "##[cmd]" line the runner writes itself,
|
||||||
// so the log renderer decodes it back. Lines forwarded from step output are already escaped.
|
// so the log renderer decodes it back. Lines forwarded from step output are already escaped.
|
||||||
func escapeCommandData(arg string) string {
|
func EscapeCommandData(arg string) string {
|
||||||
return commandDataEscaper.Replace(arg)
|
return commandDataEscaper.Replace(arg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,12 +16,18 @@ import (
|
|||||||
|
|
||||||
"github.com/sirupsen/logrus/hooks/test"
|
"github.com/sirupsen/logrus/hooks/test"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// unsecureRC opts into ::set-env:: and ::add-path::, which are refused without it.
|
||||||
|
func unsecureRC() *RunContext {
|
||||||
|
return &RunContext{Env: map[string]string{allowUnsecureCommandsVar: "true"}}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSetEnv(t *testing.T) {
|
func TestSetEnv(t *testing.T) {
|
||||||
a := assert.New(t)
|
a := assert.New(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
rc := new(RunContext)
|
rc := unsecureRC()
|
||||||
handler := rc.commandHandler(ctx)
|
handler := rc.commandHandler(ctx)
|
||||||
|
|
||||||
handler("::set-env name=x::valz\n")
|
handler("::set-env name=x::valz\n")
|
||||||
@@ -31,7 +37,7 @@ func TestSetEnv(t *testing.T) {
|
|||||||
func TestStopCommandsKeepsSuppressedLinesInLog(t *testing.T) {
|
func TestStopCommandsKeepsSuppressedLinesInLog(t *testing.T) {
|
||||||
a := assert.New(t)
|
a := assert.New(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
rc := new(RunContext)
|
rc := unsecureRC()
|
||||||
handler := rc.commandHandler(ctx)
|
handler := rc.commandHandler(ctx)
|
||||||
|
|
||||||
// Stop command processing until the matching end token is seen.
|
// Stop command processing until the matching end token is seen.
|
||||||
@@ -84,7 +90,7 @@ func TestSetOutput(t *testing.T) {
|
|||||||
func TestAddpath(t *testing.T) {
|
func TestAddpath(t *testing.T) {
|
||||||
a := assert.New(t)
|
a := assert.New(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
rc := new(RunContext)
|
rc := unsecureRC()
|
||||||
handler := rc.commandHandler(ctx)
|
handler := rc.commandHandler(ctx)
|
||||||
|
|
||||||
handler("::add-path::/zoo\n")
|
handler("::add-path::/zoo\n")
|
||||||
@@ -99,7 +105,7 @@ func TestStopCommands(t *testing.T) {
|
|||||||
|
|
||||||
a := assert.New(t)
|
a := assert.New(t)
|
||||||
ctx := common.WithLogger(context.Background(), logger)
|
ctx := common.WithLogger(context.Background(), logger)
|
||||||
rc := new(RunContext)
|
rc := unsecureRC()
|
||||||
handler := rc.commandHandler(ctx)
|
handler := rc.commandHandler(ctx)
|
||||||
|
|
||||||
handler("::set-env name=x::valz\n")
|
handler("::set-env name=x::valz\n")
|
||||||
@@ -119,10 +125,26 @@ func TestStopCommands(t *testing.T) {
|
|||||||
a.Contains(messages, "::set-env name=x::abcd\n")
|
a.Contains(messages, "::set-env name=x::abcd\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The end token is arbitrary, so one that happens to name a real command must still resume
|
||||||
|
// rather than being swallowed by that command's case.
|
||||||
|
func TestStopCommandsResumesOnCommandNamedToken(t *testing.T) {
|
||||||
|
a := assert.New(t)
|
||||||
|
rc := unsecureRC()
|
||||||
|
handler := rc.commandHandler(context.Background())
|
||||||
|
|
||||||
|
handler("::stop-commands::add-mask\n")
|
||||||
|
handler("::set-env name=x::suppressed\n")
|
||||||
|
a.NotContains(rc.Env, "x")
|
||||||
|
|
||||||
|
handler("::add-mask::\n")
|
||||||
|
handler("::set-env name=x::resumed\n")
|
||||||
|
a.Equal("resumed", rc.Env["x"])
|
||||||
|
}
|
||||||
|
|
||||||
func TestAddpathADO(t *testing.T) {
|
func TestAddpathADO(t *testing.T) {
|
||||||
a := assert.New(t)
|
a := assert.New(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
rc := new(RunContext)
|
rc := unsecureRC()
|
||||||
handler := rc.commandHandler(ctx)
|
handler := rc.commandHandler(ctx)
|
||||||
|
|
||||||
handler("##[add-path]/zoo\n")
|
handler("##[add-path]/zoo\n")
|
||||||
@@ -218,6 +240,44 @@ func TestSaveState(t *testing.T) {
|
|||||||
func TestEscapeCommandData(t *testing.T) {
|
func TestEscapeCommandData(t *testing.T) {
|
||||||
a := assert.New(t)
|
a := assert.New(t)
|
||||||
|
|
||||||
a.Equal("a%25b%0Dc%0Ad%250A", escapeCommandData("a%b\rc\nd%0A"))
|
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"))
|
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
|
||||||
|
stepEnv map[string]string
|
||||||
|
optedIn bool
|
||||||
|
}{
|
||||||
|
{name: "refused with no opt-in"},
|
||||||
|
// GitHub reads the opt-in with bool.TryParse, so "1" is not one.
|
||||||
|
{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},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
a := assert.New(t)
|
||||||
|
rc := &RunContext{Env: tt.jobEnv}
|
||||||
|
rc.setCurrentStepEnv(tt.stepEnv)
|
||||||
|
handler := rc.commandHandler(context.Background())
|
||||||
|
|
||||||
|
handler("::set-env name=x::valz\n")
|
||||||
|
handler("::add-path::/opt/bin\n")
|
||||||
|
|
||||||
|
if !tt.optedIn {
|
||||||
|
a.Empty(rc.Env["x"])
|
||||||
|
a.Empty(rc.ExtraPath)
|
||||||
|
// The refusal fails the step that produced it, once.
|
||||||
|
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())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ func reportStepError(ctx context.Context, rc *RunContext, err error) {
|
|||||||
rc.markInterrupted(ctx.Err())
|
rc.markInterrupted(ctx.Err())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
common.Logger(ctx).Errorf("##[error]%s", escapeCommandData(err.Error()))
|
common.Logger(ctx).Errorf("##[error]%s", EscapeCommandData(err.Error()))
|
||||||
common.SetJobError(ctx, err)
|
common.SetJobError(ctx, err)
|
||||||
rc.markFailed()
|
rc.markFailed()
|
||||||
}
|
}
|
||||||
@@ -260,7 +260,7 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
|
|
||||||
logger.Infof("Cleaning up container for job %s", rc.JobName)
|
logger.Infof("Cleaning up container for job %s", rc.JobName)
|
||||||
if err = info.stopContainer()(ctx); err != nil {
|
if err = info.stopContainer()(ctx); err != nil {
|
||||||
logger.Errorf("Error while stop job container: %v", err)
|
logger.Errorf("##[error]%s", EscapeCommandData("Error while stop job container: "+err.Error()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// For Gitea
|
// For Gitea
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func (rc *RunContext) runJobHook(ctx context.Context, hookPath, name string) err
|
|||||||
cmd, shell := hookCommand(hookPath)
|
cmd, shell := hookCommand(hookPath)
|
||||||
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
||||||
defer rawLogger.Infof("::endgroup::")
|
defer rawLogger.Infof("::endgroup::")
|
||||||
rawLogger.Infof("::group::Run '%s'", escapeCommandData(hookPath))
|
rawLogger.Infof("::group::Run '%s'", EscapeCommandData(hookPath))
|
||||||
rawLogger.Infof("A %s hook has been configured by the runner administrator", name)
|
rawLogger.Infof("A %s hook has been configured by the runner administrator", name)
|
||||||
if shell != "" {
|
if shell != "" {
|
||||||
rawLogger.Infof("shell: %s", shell)
|
rawLogger.Infof("shell: %s", shell)
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ func AppendSecretMasker(oldnew []string, v string) []string {
|
|||||||
ret = append(ret, tm, "***")
|
ret = append(ret, tm, "***")
|
||||||
// command data reaches the log escaped, so "pass%word" also arrives as "pass%25word"
|
// command data reaches the log escaped, so "pass%word" also arrives as "pass%25word"
|
||||||
if strings.ContainsAny(tm, "%\r\n") {
|
if strings.ContainsAny(tm, "%\r\n") {
|
||||||
ret = append(ret, escapeCommandData(tm), "***")
|
ret = append(ret, EscapeCommandData(tm), "***")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
@@ -83,6 +84,25 @@ type RunContext struct {
|
|||||||
// failures. Those failures must still make success() false and failure() true for later
|
// failures. Those failures must still make success() false and failure() true for later
|
||||||
// main-step if evaluation.
|
// main-step if evaluation.
|
||||||
jobFailed bool
|
jobFailed bool
|
||||||
|
// stepEnv is a copy of the running step's environment, so that workflow commands parsed out
|
||||||
|
// of the container's output can be judged against it. Written by runStepExecutor and read on
|
||||||
|
// the log-writer goroutine, hence unsecureCommandMu, which also guards unsecureCommandErr.
|
||||||
|
stepEnv map[string]string
|
||||||
|
unsecureCommandErr error // refused ::set-env::/::add-path::, turned into a step failure
|
||||||
|
unsecureCommandMu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// setCurrentStepEnv records the environment of the step about to run.
|
||||||
|
func (rc *RunContext) setCurrentStepEnv(env map[string]string) {
|
||||||
|
rc.unsecureCommandMu.Lock()
|
||||||
|
defer rc.unsecureCommandMu.Unlock()
|
||||||
|
rc.stepEnv = env
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rc *RunContext) currentStepEnv() map[string]string {
|
||||||
|
rc.unsecureCommandMu.Lock()
|
||||||
|
defer rc.unsecureCommandMu.Unlock()
|
||||||
|
return rc.stepEnv
|
||||||
}
|
}
|
||||||
|
|
||||||
// markCancelled flags the job as cancelled so subsequent step `if` evaluations and the
|
// markCancelled flags the job as cancelled so subsequent step `if` evaluations and the
|
||||||
|
|||||||
@@ -165,9 +165,22 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
|
|||||||
}
|
}
|
||||||
_ = rc.JobContainer.Copy(actPath, files...)(ctx)
|
_ = rc.JobContainer.Copy(actPath, files...)(ctx)
|
||||||
|
|
||||||
|
// The command handler needs the step's env to judge ACTIONS_ALLOW_UNSECURE_COMMANDS.
|
||||||
|
// Cloned: the step executor keeps writing to its own env map after this point, on a
|
||||||
|
// different goroutine from the command handler that reads it.
|
||||||
|
rc.setCurrentStepEnv(maps0.Clone(*step.getEnv()))
|
||||||
|
defer rc.setCurrentStepEnv(nil)
|
||||||
|
_ = rc.takeUnsecureCommandError() // a refusal from before any step belongs to no step
|
||||||
|
|
||||||
timeoutctx, cancelTimeOut := evaluateStepTimeout(ctx, rc.ExprEval, stepModel)
|
timeoutctx, cancelTimeOut := evaluateStepTimeout(ctx, rc.ExprEval, stepModel)
|
||||||
defer cancelTimeOut()
|
defer cancelTimeOut()
|
||||||
err = executor(timeoutctx)
|
err = executor(timeoutctx)
|
||||||
|
// Always take it, so the job-scoped error cannot leak onto a later step. A refusal
|
||||||
|
// fails the step as it does on GitHub, but the executor's own error wins.
|
||||||
|
insecureErr := rc.takeUnsecureCommandError()
|
||||||
|
if err == nil {
|
||||||
|
err = insecureErr
|
||||||
|
}
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
logger.WithField("stepResult", stepResult.Outcome).Infof("Success - %s %s", stage, stepString)
|
logger.WithField("stepResult", stepResult.Outcome).Infof("Success - %s %s", stage, stepString)
|
||||||
@@ -181,7 +194,7 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
|
|||||||
}
|
}
|
||||||
|
|
||||||
if continueOnError {
|
if continueOnError {
|
||||||
logger.Errorf("##[error]%s", escapeCommandData(err.Error()))
|
logger.Errorf("##[error]%s", EscapeCommandData(err.Error()))
|
||||||
logger.Infof("Failed but continue next step")
|
logger.Infof("Failed but continue next step")
|
||||||
err = nil
|
err = nil
|
||||||
stepResult.Conclusion = model.StepStatusSuccess
|
stepResult.Conclusion = model.StepStatusSuccess
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
"github.com/kballard/go-shellquote"
|
"github.com/kballard/go-shellquote"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
yaml "go.yaml.in/yaml/v4"
|
yaml "go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -63,7 +64,7 @@ func (sr *stepRun) printRunScriptActionDetails(ctx context.Context) {
|
|||||||
|
|
||||||
normalized := strings.TrimRight(strings.ReplaceAll(sr.interpolatedScript, "\r\n", "\n"), "\n")
|
normalized := strings.TrimRight(strings.ReplaceAll(sr.interpolatedScript, "\r\n", "\n"), "\n")
|
||||||
|
|
||||||
rawLogger.Infof("::group::Run %s", escapeCommandData(sr.runScriptGroupTitle(normalized)))
|
rawLogger.Infof("::group::Run %s", EscapeCommandData(sr.runScriptGroupTitle(normalized)))
|
||||||
|
|
||||||
if normalized != "" {
|
if normalized != "" {
|
||||||
for line := range strings.SplitSeq(normalized, "\n") {
|
for line := range strings.SplitSeq(normalized, "\n") {
|
||||||
@@ -90,12 +91,12 @@ func printRunActionHeader(ctx context.Context, step *model.Step, env map[string]
|
|||||||
if step.Name != "" {
|
if step.Name != "" {
|
||||||
title = step.Name
|
title = step.Name
|
||||||
}
|
}
|
||||||
rawLogger.Infof("::group::Run %s", escapeCommandData(title))
|
rawLogger.Infof("::group::Run %s", EscapeCommandData(title))
|
||||||
|
|
||||||
if len(step.With) > 0 {
|
if len(step.With) > 0 {
|
||||||
rawLogger.Infof("with:")
|
rawLogger.Infof("with:")
|
||||||
for _, k := range slices.Sorted(maps.Keys(step.With)) {
|
for _, k := range slices.Sorted(maps.Keys(step.With)) {
|
||||||
rawLogger.Infof(" %s: %s", k, step.With[k])
|
logKeyedValue(rawLogger, k, step.With[k])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +130,17 @@ func printStepEnvBlock(ctx context.Context, step *model.Step, env map[string]str
|
|||||||
if caseInsensitive {
|
if caseInsensitive {
|
||||||
lookupKey = strings.ToUpper(k)
|
lookupKey = strings.ToUpper(k)
|
||||||
}
|
}
|
||||||
rawLogger.Infof(" %s: %s", k, envLookup[lookupKey])
|
logKeyedValue(rawLogger, k, envLookup[lookupKey])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// logKeyedValue prints one row per line of value: Gitea stores one log row per line, so an
|
||||||
|
// embedded newline would reach the user as a literal "\n".
|
||||||
|
func logKeyedValue(rawLogger *logrus.Entry, key, value string) {
|
||||||
|
lines := strings.Split(strings.ReplaceAll(value, "\r\n", "\n"), "\n")
|
||||||
|
rawLogger.Infof(" %s: %s", key, lines[0])
|
||||||
|
for _, line := range lines[1:] {
|
||||||
|
rawLogger.Infof(" %s", line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ package runner
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
@@ -14,6 +15,7 @@ import (
|
|||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
yaml "go.yaml.in/yaml/v4"
|
yaml "go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -354,3 +356,48 @@ func TestIsContinueOnError(t *testing.T) {
|
|||||||
assertObject.False(continueOnError)
|
assertObject.False(continueOnError)
|
||||||
assertObject.Error(err)
|
assertObject.Error(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A refused ::set-env::/::add-path:: records a job-scoped error. When the step that
|
||||||
|
// produced it also fails on its own, the refusal must be cleared at the step boundary, so
|
||||||
|
// it fails only that step and never leaks onto a later step that runs anyway (if: always()).
|
||||||
|
func TestRunStepExecutorDoesNotLeakRefusalToNextStep(t *testing.T) {
|
||||||
|
cm := &containerMock{}
|
||||||
|
noop := func(context.Context) error { return nil }
|
||||||
|
cm.On("Copy", mock.Anything, mock.Anything).Return(noop)
|
||||||
|
cm.On("UpdateFromEnv", mock.Anything, mock.Anything).Return(noop)
|
||||||
|
|
||||||
|
rc := &RunContext{
|
||||||
|
Config: &Config{Env: map[string]string{}},
|
||||||
|
Run: &model.Run{
|
||||||
|
JobID: "1",
|
||||||
|
Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}},
|
||||||
|
},
|
||||||
|
Env: map[string]string{},
|
||||||
|
StepResults: map[string]*model.StepResult{},
|
||||||
|
JobContainer: cm,
|
||||||
|
}
|
||||||
|
rc.ExprEval = rc.NewExpressionEvaluator(context.Background())
|
||||||
|
// Dryrun skips reading the path file back from the (mocked) container.
|
||||||
|
ctx := common.WithDryrun(context.Background(), true)
|
||||||
|
|
||||||
|
// A refusal parsed out of the job container's own output belongs to no step, so the
|
||||||
|
// first step must not be failed by it.
|
||||||
|
rc.commandHandler(ctx)("::set-env name=setup::y\n")
|
||||||
|
stepSetup := &stepRun{RunContext: rc, Step: &model.Step{ID: "setup"}, env: map[string]string{}}
|
||||||
|
require.NoError(t, runStepExecutor(stepSetup, stepStageMain, func(context.Context) error { return nil })(ctx))
|
||||||
|
|
||||||
|
// Step A refuses a ::set-env:: and then fails on its own.
|
||||||
|
stepA := &stepRun{RunContext: rc, Step: &model.Step{ID: "a"}, env: map[string]string{}}
|
||||||
|
errA := runStepExecutor(stepA, stepStageMain, func(context.Context) error {
|
||||||
|
rc.commandHandler(ctx)("::set-env name=x::y\n")
|
||||||
|
return errors.New("boom")
|
||||||
|
})(ctx)
|
||||||
|
// The step fails with its own error, not the refusal.
|
||||||
|
require.ErrorContains(t, errA, "boom")
|
||||||
|
|
||||||
|
// Step B runs despite step A's failure (if: always()) and issues no unsecure command;
|
||||||
|
// it must not inherit step A's refusal.
|
||||||
|
stepB := &stepRun{RunContext: rc, Step: &model.Step{ID: "b", If: yaml.Node{Value: "always()"}}, env: map[string]string{}}
|
||||||
|
errB := runStepExecutor(stepB, stepStageMain, func(context.Context) error { return nil })(ctx)
|
||||||
|
require.NoError(t, errB)
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ jobs:
|
|||||||
_:
|
_:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
|
ACTIONS_ALLOW_UNSECURE_COMMANDS: 'true'
|
||||||
MYGLOBALENV3: myglobalval3
|
MYGLOBALENV3: myglobalval3
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|||||||
2
act/runner/testdata/commands/push.yml
vendored
2
act/runner/testdata/commands/push.yml
vendored
@@ -4,6 +4,8 @@ on: push
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
ACTIONS_ALLOW_UNSECURE_COMMANDS: 'true'
|
||||||
steps:
|
steps:
|
||||||
- name: TEST set-env
|
- name: TEST set-env
|
||||||
run: echo "::set-env name=foo::bar"
|
run: echo "::set-env name=foo::bar"
|
||||||
|
|||||||
@@ -607,7 +607,8 @@ func (r *Runner) registerExternalCacheJob(token string, cred artifactcache.JobCr
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Warnf("cache external_server register failed (%s): %v", base, err)
|
log.Warnf("cache external_server register failed (%s): %v", base, err)
|
||||||
if reporter != nil {
|
if reporter != nil {
|
||||||
reporter.Logf("::warning::cache external_server register failed (%s): %v — cache requests from this job will be unauthenticated and likely return 401", base, err)
|
reporter.Logf("::warning::%s", runner.EscapeCommandData(fmt.Sprintf(
|
||||||
|
"cache external_server register failed (%s): %v — cache requests from this job will be unauthenticated and likely return 401", base, err)))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
resultsURL, _ = body["results_url"].(string) // absent from a server too old to forward
|
resultsURL, _ = body["results_url"].(string) // absent from a server too old to forward
|
||||||
@@ -617,7 +618,8 @@ func (r *Runner) registerExternalCacheJob(token string, cred artifactcache.JobCr
|
|||||||
map[string]any{"token": token}); err != nil {
|
map[string]any{"token": token}); err != nil {
|
||||||
log.Warnf("cache external_server revoke failed (%s): %v", base, err)
|
log.Warnf("cache external_server revoke failed (%s): %v", base, err)
|
||||||
if reporter != nil {
|
if reporter != nil {
|
||||||
reporter.Logf("::warning::cache external_server revoke failed (%s): %v", base, err)
|
reporter.Logf("::warning::%s", runner.EscapeCommandData(fmt.Sprintf(
|
||||||
|
"cache external_server revoke failed (%s): %v", base, err)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, resultsURL
|
}, resultsURL
|
||||||
|
|||||||
@@ -255,6 +255,9 @@ func (r *Reporter) Fire(entry *log.Entry) error {
|
|||||||
if step.StartedAt == nil {
|
if step.StartedAt == nil {
|
||||||
step.StartedAt = timestamppb.New(timestamp)
|
step.StartedAt = timestamppb.New(timestamp)
|
||||||
urgentState = true
|
urgentState = true
|
||||||
|
// The runner's own handler is per step, so an unresumed ::stop-commands:: must not
|
||||||
|
// leave the reporter suppressed, and no longer registering masks, for the whole job.
|
||||||
|
r.stopCommandEndToken = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force reporting log errors as raw output to prevent silent failures
|
// Force reporting log errors as raw output to prevent silent failures
|
||||||
@@ -396,10 +399,9 @@ func (r *Reporter) Logf(format string, a ...any) {
|
|||||||
|
|
||||||
func (r *Reporter) logf(format string, a ...any) {
|
func (r *Reporter) logf(format string, a ...any) {
|
||||||
if !r.duringSteps() {
|
if !r.duringSteps() {
|
||||||
r.logRows = append(r.logRows, &runnerv1.LogRow{
|
// Masked like any other row: these bypass parseLogRow, but a caller can still
|
||||||
Time: timestamppb.Now(),
|
// interpolate a secret, such as a configured URL carrying credentials.
|
||||||
Content: fmt.Sprintf(format, a...),
|
r.logRows = append(r.logRows, r.newLogRow(timestamppb.Now(), fmt.Sprintf(format, a...)))
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -700,66 +702,128 @@ func (r *Reporter) parseResult(result any) (runnerv1.Result, bool) {
|
|||||||
return ret, ok
|
return ret, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
var cmdRegex = regexp.MustCompile(`^::([^ :]+)( .*)?::(.*)$`)
|
// A property value never contains a raw ':' (GitHub escapes it as %3A), so excluding ':' ends
|
||||||
|
// the property list at the first '::' as GitHub does; greedily would swallow a '::' message.
|
||||||
|
var cmdRegex = regexp.MustCompile(`^::([^ :]+)( [^:]*)?::(.*)$`)
|
||||||
|
|
||||||
func (r *Reporter) handleCommand(originalContent, command, value string) *string {
|
// handleCommand takes value still escaped, so that the web UI decodes it exactly once. Only
|
||||||
if r.stopCommandEndToken != "" && command != r.stopCommandEndToken {
|
// the branches that consume the payload here decode it.
|
||||||
|
func (r *Reporter) handleCommand(originalContent, command, properties, value string) *string {
|
||||||
|
if r.stopCommandEndToken != "" {
|
||||||
|
if command != r.stopCommandEndToken {
|
||||||
return &originalContent
|
return &originalContent
|
||||||
}
|
}
|
||||||
|
// Resumed here rather than from the switch, because the end token is arbitrary and a
|
||||||
|
// token naming a real command would otherwise never resume.
|
||||||
|
r.stopCommandEndToken = ""
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
switch command {
|
switch command {
|
||||||
case "add-mask":
|
case "add-mask":
|
||||||
r.addMask(value)
|
r.addMask(runner.UnescapeCommandData(value))
|
||||||
return nil
|
return nil
|
||||||
case "debug":
|
case "debug":
|
||||||
if r.debugOutputEnabled {
|
if r.debugOutputEnabled {
|
||||||
return &value
|
return &originalContent // kept as ::debug::, so the web UI labels and decodes it
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
case "notice":
|
case "notice", "warning", "error":
|
||||||
// Not implemented yet, so just return the original content.
|
// Gitea has no annotation store, so the annotation is rendered into the log with
|
||||||
return &originalContent
|
// its source location instead of being dropped: that location is the whole point
|
||||||
case "warning":
|
// of the command for compiler and linter output.
|
||||||
// Not implemented yet, so just return the original content.
|
annotation := formatAnnotation(command, properties, value)
|
||||||
return &originalContent
|
return &annotation
|
||||||
case "error":
|
case "group", "endgroup":
|
||||||
// Not implemented yet, so just return the original content.
|
// Passed through: the web UI folds the log on these and decodes the payload itself.
|
||||||
return &originalContent
|
|
||||||
case "group":
|
|
||||||
// Returning the original content, because I think the frontend
|
|
||||||
// will use it when rendering the output.
|
|
||||||
return &originalContent
|
|
||||||
case "endgroup":
|
|
||||||
// Ditto
|
|
||||||
return &originalContent
|
return &originalContent
|
||||||
case "stop-commands":
|
case "stop-commands":
|
||||||
r.stopCommandEndToken = value
|
r.stopCommandEndToken = runner.UnescapeCommandData(value)
|
||||||
return nil
|
|
||||||
case r.stopCommandEndToken:
|
|
||||||
r.stopCommandEndToken = ""
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return &originalContent
|
return &originalContent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// formatAnnotation folds the file, line, column and title the command carries into its message,
|
||||||
|
// which the web UI otherwise drops along with the rest of the properties:
|
||||||
|
//
|
||||||
|
// ::error file=main.go,line=12,col=5,title=vet::undefined: x
|
||||||
|
// ::error::main.go:12:5: vet: undefined: x
|
||||||
|
//
|
||||||
|
// The ::-form prefix is deliberate, and value is not escaped here because it arrived escaped
|
||||||
|
// and must stay that way.
|
||||||
|
func formatAnnotation(level, properties, value string) string {
|
||||||
|
props := parseCommandProperties(properties)
|
||||||
|
|
||||||
|
prefix := props["file"]
|
||||||
|
if prefix != "" {
|
||||||
|
if props["line"] != "" {
|
||||||
|
prefix += ":" + props["line"]
|
||||||
|
if props["col"] != "" {
|
||||||
|
prefix += ":" + props["col"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prefix += ": "
|
||||||
|
}
|
||||||
|
if props["title"] != "" {
|
||||||
|
prefix += props["title"] + ": "
|
||||||
|
}
|
||||||
|
return "::" + level + "::" + prefix + value
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCommandProperties parses the `file=main.go,line=12` part of a workflow command.
|
||||||
|
func parseCommandProperties(properties string) map[string]string {
|
||||||
|
properties = strings.TrimSpace(properties)
|
||||||
|
if properties == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
props := map[string]string{}
|
||||||
|
for pair := range strings.SplitSeq(properties, ",") {
|
||||||
|
key, value, ok := strings.Cut(pair, "=")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Only the property-list separators are decoded, the web UI decodes the rest.
|
||||||
|
value = strings.ReplaceAll(strings.ReplaceAll(value, "%3A", ":"), "%2C", ",")
|
||||||
|
// GitHub keys its property dictionary case-insensitively, so `File=` works there too.
|
||||||
|
props[strings.ToLower(strings.TrimSpace(key))] = value
|
||||||
|
}
|
||||||
|
// GitHub's toolkit emits `col`; accept `column` as well, which some tools write instead.
|
||||||
|
if props["col"] == "" {
|
||||||
|
props["col"] = props["column"]
|
||||||
|
}
|
||||||
|
return props
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Reporter) parseLogRow(entry *log.Entry) *runnerv1.LogRow {
|
func (r *Reporter) parseLogRow(entry *log.Entry) *runnerv1.LogRow {
|
||||||
content := strings.TrimRight(entry.Message, "\r\n")
|
content := strings.TrimRight(entry.Message, "\r\n")
|
||||||
|
|
||||||
|
// cmdRegex only covers the ::cmd:: form, so the ##[add-mask] one would otherwise reach
|
||||||
|
// the log carrying its own secret. Registered and dropped like its ::add-mask:: twin.
|
||||||
|
if arg, ok := strings.CutPrefix(content, "##[add-mask]"); ok {
|
||||||
|
r.addMask(runner.UnescapeCommandData(arg))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
matches := cmdRegex.FindStringSubmatch(content)
|
matches := cmdRegex.FindStringSubmatch(content)
|
||||||
if matches != nil {
|
if matches != nil {
|
||||||
if output := r.handleCommand(content, matches[1], runner.UnescapeCommandData(matches[3])); output != nil {
|
if output := r.handleCommand(content, matches[1], matches[2], matches[3]); output != nil {
|
||||||
content = *output
|
content = *output
|
||||||
} else {
|
} else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
content = r.logReplacer.Replace(content)
|
return r.newLogRow(timestamppb.New(entry.Time), content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newLogRow applies the masking and validation every row must carry, whatever built it.
|
||||||
|
func (r *Reporter) newLogRow(t *timestamppb.Timestamp, content string) *runnerv1.LogRow {
|
||||||
return &runnerv1.LogRow{
|
return &runnerv1.LogRow{
|
||||||
Time: timestamppb.New(entry.Time),
|
Time: t,
|
||||||
Content: strings.ToValidUTF8(content, "?"),
|
Content: strings.ToValidUTF8(r.logReplacer.Replace(content), "?"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,9 +72,12 @@ func TestReporter_parseLogRow(t *testing.T) {
|
|||||||
"Debug enabled", true,
|
"Debug enabled", true,
|
||||||
[]string{
|
[]string{
|
||||||
"::debug::GitHub Actions runtime token access controls",
|
"::debug::GitHub Actions runtime token access controls",
|
||||||
|
// Left escaped: the web UI decodes it, and a real newline would not survive storage.
|
||||||
|
"::debug::first%0Asecond",
|
||||||
},
|
},
|
||||||
[]string{
|
[]string{
|
||||||
"GitHub Actions runtime token access controls",
|
"::debug::GitHub Actions runtime token access controls",
|
||||||
|
"::debug::first%0Asecond",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -86,31 +89,46 @@ func TestReporter_parseLogRow(t *testing.T) {
|
|||||||
"<nil>",
|
"<nil>",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// The three annotation levels share one code path, so the property shapes are only
|
||||||
|
// exercised under "error"; notice and warning just prove the level token round-trips.
|
||||||
{
|
{
|
||||||
"notice", false,
|
"notice", false,
|
||||||
[]string{
|
[]string{
|
||||||
"::notice file=file.name,line=42,endLine=48,title=Cool Title::Gosh, that's not going to work",
|
"::notice::Gosh, that's not going to work",
|
||||||
},
|
},
|
||||||
[]string{
|
[]string{
|
||||||
"::notice file=file.name,line=42,endLine=48,title=Cool Title::Gosh, that's not going to work",
|
"::notice::Gosh, that's not going to work",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"warning", false,
|
"warning", false,
|
||||||
[]string{
|
[]string{
|
||||||
"::warning file=file.name,line=42,endLine=48,title=Cool Title::Gosh, that's not going to work",
|
"::warning::Gosh, that's not going to work",
|
||||||
},
|
},
|
||||||
[]string{
|
[]string{
|
||||||
"::warning file=file.name,line=42,endLine=48,title=Cool Title::Gosh, that's not going to work",
|
"::warning::Gosh, that's not going to work",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"error", false,
|
"error", false,
|
||||||
[]string{
|
[]string{
|
||||||
"::error file=file.name,line=42,endLine=48,title=Cool Title::Gosh, that's not going to work",
|
"::error file=file.name,line=42,endLine=48,title=Cool Title::Gosh, that's not going to work",
|
||||||
|
"::error::Gosh, that's not going to work",
|
||||||
|
"::error file=file.name,line=42,col=7::Gosh, that's not going to work",
|
||||||
|
// The message keeps its own '::', the property list ends at the first one.
|
||||||
|
"::error file=main.cpp,line=12::no member named 'foo' in 'std::vector<int>'",
|
||||||
|
// GitHub matches property names case-insensitively.
|
||||||
|
"::error File=file.name,Line=42,Col=7::Gosh, that's not going to work",
|
||||||
|
// Only the property separators are decoded here, %25/%0A are left for the web UI.
|
||||||
|
"::error file=a%3Ab.go,title=100%252C::still %25 escaped%0Aand multi-line",
|
||||||
},
|
},
|
||||||
[]string{
|
[]string{
|
||||||
"::error file=file.name,line=42,endLine=48,title=Cool Title::Gosh, that's not going to work",
|
"::error::file.name:42: Cool Title: Gosh, that's not going to work",
|
||||||
|
"::error::Gosh, that's not going to work",
|
||||||
|
"::error::file.name:42:7: Gosh, that's not going to work",
|
||||||
|
"::error::main.cpp:12: no member named 'foo' in 'std::vector<int>'",
|
||||||
|
"::error::file.name:42:7: Gosh, that's not going to work",
|
||||||
|
"::error::a:b.go: 100%252C: still %25 escaped%0Aand multi-line",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -149,6 +167,24 @@ func TestReporter_parseLogRow(t *testing.T) {
|
|||||||
"*** bar baz ***",
|
"*** bar baz ***",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// a token naming a real command must still resume
|
||||||
|
"stop-commands with a command-named token", false,
|
||||||
|
[]string{
|
||||||
|
"::stop-commands::add-mask",
|
||||||
|
"::set-output name=x::suppressed",
|
||||||
|
"::add-mask::",
|
||||||
|
"::add-mask::masked",
|
||||||
|
"masked",
|
||||||
|
},
|
||||||
|
[]string{
|
||||||
|
"<nil>",
|
||||||
|
"::set-output name=x::suppressed",
|
||||||
|
"<nil>",
|
||||||
|
"<nil>",
|
||||||
|
"***",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"unknown command", false,
|
"unknown command", false,
|
||||||
[]string{
|
[]string{
|
||||||
@@ -179,6 +215,19 @@ func TestReporter_parseLogRow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Both add-mask forms must register the secret and drop their own row: the runner forwards
|
||||||
|
// the raw line, so failing to consume it writes the secret straight to the job log.
|
||||||
|
func TestReporter_parseLogRowAddMask(t *testing.T) {
|
||||||
|
for _, line := range []string{"::add-mask::supersecret", "##[add-mask]supersecret"} {
|
||||||
|
r := &Reporter{logReplacer: strings.NewReplacer()}
|
||||||
|
|
||||||
|
assert.Nil(t, r.parseLogRow(&log.Entry{Message: line}), line)
|
||||||
|
|
||||||
|
row := r.parseLogRow(&log.Entry{Message: "using supersecret now"})
|
||||||
|
assert.Equal(t, "using *** now", row.Content, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestReporter_Fire(t *testing.T) {
|
func TestReporter_Fire(t *testing.T) {
|
||||||
t.Run("ignore command lines", func(t *testing.T) {
|
t.Run("ignore command lines", func(t *testing.T) {
|
||||||
client := mocks.NewClient(t)
|
client := mocks.NewClient(t)
|
||||||
@@ -1013,7 +1062,7 @@ func TestReporter_Result(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestReporter_SetOutputs(t *testing.T) {
|
func TestReporter_SetOutputs(t *testing.T) {
|
||||||
r := &Reporter{state: &runnerv1.TaskState{}}
|
r := &Reporter{state: &runnerv1.TaskState{}, logReplacer: strings.NewReplacer()}
|
||||||
|
|
||||||
r.SetOutputs(map[string]string{"foo": "bar"})
|
r.SetOutputs(map[string]string{"foo": "bar"})
|
||||||
got, ok := r.outputs["foo"]
|
got, ok := r.outputs["foo"]
|
||||||
|
|||||||
Reference in New Issue
Block a user