fix: escape command data the runner writes itself (#1120)

Command data is percent-escaped on the wire because Gitea's job log cannot carry a newline, and the renderer decodes it.

1. Unescaping iterated a map, so order was random per process: `%250A` decoded to a newline instead of a literal `%0A` about two runs in three. Now a precompiled `strings.NewReplacer`.
1. Escape the data of command lines the runner writes itself (`##[error]`, `::group::Run …`), so decoding returns the original text instead of mangling a `%`. Multi-line runner errors also get real line breaks instead of a literal `\n`.
1. Register secrets in escaped form too — one containing `%` reaches the log as `%25…` and was never masked.
1. Decode in `jobLogFormatter`, so `gitea-runner exec` matches the web view.

## Relation to the Gitea PR

https://github.com/go-gitea/gitea/pull/38659 makes the renderer decode command data. Point 2 is required by it; the rest stand alone. Either order works — until both land, a new runner on old Gitea shows `%25`, an old runner on new Gitea shows the mangling point 2 fixes.
Co-authored-by: bircni <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1120
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
silverwind
2026-07-27 15:16:49 +00:00
committed by bircni
parent 78a74f78f8
commit c3b39e0d99
7 changed files with 57 additions and 24 deletions

View File

@@ -154,30 +154,25 @@ func parseKeyValuePairs(kvPairs, separator string) map[string]string {
return rtn
}
// A Replacer never rescans what it wrote, so "%250A" stays a literal "%0A".
var (
commandDataEscaper = strings.NewReplacer("%", "%25", "\r", "%0D", "\n", "%0A")
commandDataUnescaper = strings.NewReplacer("%25", "%", "%0D", "\r", "%0A", "\n")
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,
// so the log renderer decodes it back. Lines forwarded from step output are already escaped.
func escapeCommandData(arg string) string {
return commandDataEscaper.Replace(arg)
}
func UnescapeCommandData(arg string) string {
escapeMap := map[string]string{
"%25": "%",
"%0D": "\r",
"%0A": "\n",
}
for k, v := range escapeMap {
arg = strings.ReplaceAll(arg, k, v)
}
return arg
return commandDataUnescaper.Replace(arg)
}
func unescapeCommandProperty(arg string) string {
escapeMap := map[string]string{
"%25": "%",
"%0D": "\r",
"%0A": "\n",
"%3A": ":",
"%2C": ",",
}
for k, v := range escapeMap {
arg = strings.ReplaceAll(arg, k, v)
}
return arg
return commandPropertyUnescaper.Replace(arg)
}
func unescapeKvPairs(kvPairs map[string]string) map[string]string {