Compare commits

..
Author SHA1 Message Date
silverwind 0732cacaac fix: prevent masking gaps in workflow command logs
Normalize displayed command payloads so partial escaping cannot bypass masking,
and retain complete one-character masks.

Assisted-by: Codex:GPT-6
2026-11-05 06:56:31 +01:00
33 changed files with 412 additions and 279 deletions

No files matched your search

+7 -4
View File
@@ -23,11 +23,14 @@ depending on the prefix:
covers both forms: it escapes `%` first, so a literal `%3B` becomes `%253B` that the extra 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 `##[…]` 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. 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 - **Forwarding a command from step output?** Preserve its escaping unless masking changes it.
decoded once. Decoding here double-decodes and destroys multi-line. Decoding for display happens in the UI.
- **No prefix?** Do not escape, and split multi-line values into one row each. - **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 - **Interpolating a secret?** Masking runs after escaping. Recognised command payloads are
encoded forms too. normalised before masking to cover partially escaped values. Their original escaping is
preserved when no replacement occurs. `AppendSecretMasker` also registers encoded forms for
other output. Complete nonblank one-character values are masked, while one-byte fragments
of multiline values are skipped.
- Command *properties* also escape `%3A` and `%2C`, which the UI never decodes, so the command - Command *properties* also escape `%3A` and `%2C`, which the UI never decodes, so the command
handler folds an annotation's location into its decoded message and escapes the result again. handler folds an annotation's location into its decoded message and escapes the result again.
+7 -10
View File
@@ -851,9 +851,6 @@ func TestHandler_touchCacheSkipsFreshCompletedEntries(t *testing.T) {
func newTestHandler(t *testing.T, policy Policy, entries ...*Cache) *Handler { func newTestHandler(t *testing.T, policy Policy, entries ...*Cache) *Handler {
t.Helper() t.Helper()
handler := startTestHandler(t, Options{Policy: policy}) handler := startTestHandler(t, Options{Policy: policy})
if len(entries) == 0 {
return handler
}
db, err := handler.openDB() db, err := handler.openDB()
require.NoError(t, err) require.NoError(t, err)
@@ -943,10 +940,6 @@ func TestHandler_RejectsBearer(t *testing.T) {
func TestHandler_UnregisterRevokes(t *testing.T) { func TestHandler_UnregisterRevokes(t *testing.T) {
t.Parallel() t.Parallel()
handler, err := StartHandler(Options{Dir: filepath.Join(t.TempDir(), "artifactcache")})
require.NoError(t, err)
defer handler.Close()
for _, tc := range []struct { for _, tc := range []struct {
name string name string
token string token string
@@ -957,6 +950,9 @@ func TestHandler_UnregisterRevokes(t *testing.T) {
{"reference counted", "shared", "v", 2}, {"reference counted", "shared", "v", 2},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
handler, err := StartHandler(Options{Dir: filepath.Join(t.TempDir(), "artifactcache")})
require.NoError(t, err)
defer handler.Close()
revokers := make([]func(), tc.registrations) revokers := make([]func(), tc.registrations)
for i := range revokers { for i := range revokers {
revokers[i] = handler.RegisterJob(tc.token, JobCredential{Repo: testRepo}) revokers[i] = handler.RegisterJob(tc.token, JobCredential{Repo: testRepo})
@@ -1053,8 +1049,9 @@ func TestHandler_ArtifactSignature(t *testing.T) {
base := handler.ExternalURL() + apiPath base := handler.ExternalURL() + apiPath
good := signArtifactURL(handler, 1) good := signArtifactURL(handler, 1)
secret := bytes.Clone(handler.secret) other, err := StartHandler(Options{Dir: filepath.Join(t.TempDir(), "artifactcache2")})
secret[0] ^= 1 require.NoError(t, err)
defer other.Close()
for _, tc := range []struct { for _, tc := range []struct {
name string name string
@@ -1063,7 +1060,7 @@ func TestHandler_ArtifactSignature(t *testing.T) {
{"missing signature", fmt.Sprintf("%s/artifacts/%d", base, 1)}, {"missing signature", fmt.Sprintf("%s/artifacts/%d", base, 1)},
{"tampered signature", good[:len(good)-4] + "dead"}, {"tampered signature", good[:len(good)-4] + "dead"},
{"expired signature", handler.signedArtifactURL(JobCredential{}, 1, time.Now().Add(-time.Second))}, {"expired signature", handler.signedArtifactURL(JobCredential{}, 1, time.Now().Add(-time.Second))},
{"signature from a different server", base + strings.SplitN(signArtifactURL(&Handler{secret: secret}, 1), apiPath, 2)[1]}, {"signature from a different server", base + strings.SplitN(signArtifactURL(other, 1), apiPath, 2)[1]},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
resp, err := testClient.Get(tc.url) resp, err := testClient.Get(tc.url)
+3 -14
View File
@@ -36,7 +36,7 @@ func TestFetchAction(t *testing.T) {
root := t.TempDir() root := t.TempDir()
remote := filepath.Join(root, "org", "action") remote := filepath.Join(root, "org", "action")
require.NoError(t, os.CopyFS(remote, os.DirFS(filepath.Join(gitFixtureDir, "work")))) require.NoError(t, gitCmd("init", "--initial-branch=main", remote))
require.NoError(t, os.WriteFile(filepath.Join(remote, "action.yml"), []byte("name: action\n"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(remote, "action.yml"), []byte("name: action\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(remote, "run.sh"), []byte("#!/bin/sh\n"), 0o755)) require.NoError(t, os.WriteFile(filepath.Join(remote, "run.sh"), []byte("#!/bin/sh\n"), 0o755))
require.NoError(t, os.Symlink("action.yml", filepath.Join(remote, "link"))) require.NoError(t, os.Symlink("action.yml", filepath.Join(remote, "link")))
@@ -53,7 +53,6 @@ func TestFetchAction(t *testing.T) {
var archiveRequests []http.Header var archiveRequests []http.Header
var mirror *httptest.Server var mirror *httptest.Server
archiveCache := map[string][]byte{} archiveCache := map[string][]byte{}
revisions := map[string]string{}
requests, archives, failures := 0, "", 0 requests, archives, failures := 0, "", 0
handler := http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { handler := http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
mu.Lock() mu.Lock()
@@ -80,14 +79,7 @@ func TestFetchAction(t *testing.T) {
http.Redirect(writer, request, mirror.URL+request.URL.Path, http.StatusFound) http.Redirect(writer, request, mirror.URL+request.URL.Path, http.StatusFound)
return return
} }
sha, ok := revisions[ref] sha, _ := gitOutput(request.Context(), remote, "rev-parse", strings.TrimSuffix(ref, ".tar.gz"))
if !ok {
var err error
sha, err = gitOutput(request.Context(), remote, "rev-parse", strings.TrimSuffix(ref, ".tar.gz"))
if err == nil {
revisions[ref] = sha
}
}
etag := fmt.Sprintf(`W/"%s-tar.gz-%t"`, sha, archives != "unprefixed") etag := fmt.Sprintf(`W/"%s-tar.gz-%t"`, sha, archives != "unprefixed")
if request.Header.Get("If-None-Match") == etag { if request.Header.Get("If-None-Match") == etag {
writer.WriteHeader(http.StatusNotModified) writer.WriteHeader(http.StatusNotModified)
@@ -216,9 +208,6 @@ func TestFetchAction(t *testing.T) {
require.NoError(t, gitCmd("-C", remote, "commit", "--allow-empty", "-m", "moved")) require.NoError(t, gitCmd("-C", remote, "commit", "--allow-empty", "-m", "moved"))
require.NoError(t, gitCmd("-C", remote, "tag", "--force", "v1")) require.NoError(t, gitCmd("-C", remote, "tag", "--force", "v1"))
expectedSha = gitRevParse(t, remote, "v1") expectedSha = gitRevParse(t, remote, "v1")
mu.Lock()
clear(revisions)
mu.Unlock()
moved, movedSha, err := FetchAction(t.Context(), input) moved, movedSha, err := FetchAction(t.Context(), input)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, expectedSha, movedSha) assert.Equal(t, expectedSha, movedSha)
@@ -264,7 +253,7 @@ func TestFetchAction(t *testing.T) {
}) })
t.Run("downloads again over a tree whose files changed size or that has no record, not under a job copying it", func(t *testing.T) { t.Run("downloads again over a tree whose files changed size or that has no record, not under a job copying it", func(t *testing.T) {
sha := expectedSha sha := gitRevParse(t, remote, "main")
for _, mode := range []string{"prefixed", "none"} { for _, mode := range []string{"prefixed", "none"} {
serve(mode, 0) serve(mode, 0)
input := newInput(sha) input := newInput(sha)
+31 -25
View File
@@ -112,7 +112,8 @@ func TestFindGitMetadataOfSHA256RepositoryUsesOrigin(t *testing.T) {
t.Parallel() t.Parallel()
basedir := t.TempDir() basedir := t.TempDir()
const remoteURL = "https://github.com/owner/repo.git" const remoteURL = "https://github.com/owner/repo.git"
require.NoError(t, gitCmd("init", "--template=", "--object-format=sha256", "--initial-branch=main", basedir)) require.NoError(t, gitCmd("init", "--object-format=sha256", "--initial-branch=main", basedir))
require.NoError(t, os.RemoveAll(filepath.Join(basedir, ".git", "hooks")))
require.NoError(t, gitCmd("-C", basedir, "commit", "--allow-empty", "-m", "init")) require.NoError(t, gitCmd("-C", basedir, "commit", "--allow-empty", "-m", "init"))
require.NoError(t, gitCmd("-C", basedir, "remote", "add", "origin", remoteURL)) require.NoError(t, gitCmd("-C", basedir, "remote", "add", "origin", remoteURL))
require.NoError(t, gitCmd("-C", basedir, "config", "--add", "remote.origin.url", "https://github.com/other/repo.git")) require.NoError(t, gitCmd("-C", basedir, "config", "--add", "remote.origin.url", "https://github.com/other/repo.git"))
@@ -204,13 +205,16 @@ func TestGitCloneExecutor(t *testing.T) {
require.NoError(t, gitCmd("-C", workDir, "add", "action.sh", ".gitattributes")) require.NoError(t, gitCmd("-C", workDir, "add", "action.sh", ".gitattributes"))
require.NoError(t, gitCmd("-C", workDir, "commit", "-m", "initial")) require.NoError(t, gitCmd("-C", workDir, "commit", "-m", "initial"))
require.NoError(t, gitCmd("-C", workDir, "tag", "v2")) require.NoError(t, gitCmd("-C", workDir, "tag", "v2"))
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v2"))
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "act-fails")) require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "act-fails"))
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "branch-commit")) require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "branch-commit"))
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "act-fails"))
fullSha := gitRevParse(t, workDir, "main") fullSha := gitRevParse(t, workDir, "main")
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main", "v2", "act-fails", "main:refs/heads/"+fullSha[:4])) require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main:refs/heads/"+fullSha[:4]))
hostConfig := filepath.Join(t.TempDir(), "gitconfig") hostConfig := filepath.Join(t.TempDir(), "gitconfig")
require.NoError(t, os.WriteFile(hostConfig, []byte("[core]\n\tautocrlf = true\n\teol = crlf\n[url "+strconv.Quote(remoteDir)+"]\n\tinsteadOf = https://example.invalid/action\n"), 0o644)) require.NoError(t, os.WriteFile(hostConfig, []byte("[core]\n\tautocrlf = true\n\teol = crlf\n[url "+strconv.Quote(remoteDir)+"]\n\tinsteadOf = https://example.invalid/action\n"), 0o644))
@@ -278,10 +282,10 @@ func TestGitCloneExecutor(t *testing.T) {
func TestGitCloneExecutorReclonesWhenOriginURLChanges(t *testing.T) { func TestGitCloneExecutorReclonesWhenOriginURLChanges(t *testing.T) {
t.Parallel() t.Parallel()
createRemote := func(message string) string { createRemote := func(message string) string {
dir := t.TempDir() remoteDir, workDir := newGitRemote(t, message)
require.NoError(t, os.CopyFS(dir, os.DirFS(filepath.Join(gitFixtureDir, "work")))) require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", message))
return dir return remoteDir
} }
oldRemoteDir := createRemote("old-action") oldRemoteDir := createRemote("old-action")
@@ -401,30 +405,31 @@ func TestGitCloneExecutorShallow(t *testing.T) {
remoteDir, workDir := newGitRemote(t, "c1", "c2", "c3") remoteDir, workDir := newGitRemote(t, "c1", "c2", "c3")
require.NoError(t, gitCmd("-C", workDir, "tag", "v1")) require.NoError(t, gitCmd("-C", workDir, "tag", "v1"))
unadvertisedSHA := gitRevParse(t, workDir, "HEAD~1") unadvertisedSHA := gitRevParse(t, workDir, "HEAD~1")
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main", "v1")) require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v1"))
shallowMarker := func(dir string) string { return filepath.Join(dir, ".git", "shallow") } shallowMarker := func(dir string) string { return filepath.Join(dir, ".git", "shallow") }
for name, ref := range map[string]string{"branch is cloned shallowly": "main", "tag is cloned shallowly": "v1"} { for name, ref := range map[string]string{"branch is cloned shallowly": "main", "tag is cloned shallowly": "v1"} {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
t.Parallel()
dir := t.TempDir() dir := t.TempDir()
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{ require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
URL: remoteDir, Ref: ref, Dir: dir, Depth: 1, URL: remoteDir, Ref: ref, Dir: dir, Depth: 1,
})(t.Context())) })(t.Context()))
assert.FileExists(t, shallowMarker(dir), "clone should be shallow") assert.FileExists(t, shallowMarker(dir), "clone should be shallow")
assert.Equal(t, []string{"c3"}, gitSubjects(t, dir), "only the tip commit should be present") assert.Equal(t, 1, gitRevCount(t, dir), "only the tip commit should be present")
assert.Equal(t, "c3", gitHeadSubject(t, dir))
}) })
} }
unadvertisedRemote := func(t *testing.T, allowUnadvertised bool) string { unadvertisedRemote := func(t *testing.T, allowUnadvertised bool) string {
remote := filepath.Join(t.TempDir(), "remote.git") remote := filepath.Join(t.TempDir(), "remote.git")
require.NoError(t, gitCmd("clone", "--bare", "--template=", "--config", "uploadpack.allowAnySHA1InWant="+strconv.FormatBool(allowUnadvertised), remoteDir, remote)) require.NoError(t, gitCmd("clone", "--bare", remoteDir, remote))
require.NoError(t, gitCmd("-C", remote, "config", "uploadpack.allowAnySHA1InWant", strconv.FormatBool(allowUnadvertised)))
return remote return remote
} }
t.Run("commit hash resolves when the remote accepts a direct shallow fetch", func(t *testing.T) { t.Run("commit hash resolves when the remote accepts a direct shallow fetch", func(t *testing.T) {
t.Parallel()
dir := t.TempDir() dir := t.TempDir()
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{ require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
URL: unadvertisedRemote(t, false), Ref: unadvertisedSHA, Dir: dir, Depth: 1, URL: unadvertisedRemote(t, false), Ref: unadvertisedSHA, Dir: dir, Depth: 1,
@@ -433,7 +438,6 @@ func TestGitCloneExecutorShallow(t *testing.T) {
}) })
t.Run("commit hash is fetched shallowly, moved to another hash, then reused without reaching the remote", func(t *testing.T) { t.Run("commit hash is fetched shallowly, moved to another hash, then reused without reaching the remote", func(t *testing.T) {
t.Parallel()
remote := unadvertisedRemote(t, true) remote := unadvertisedRemote(t, true)
dir := t.TempDir() dir := t.TempDir()
cloneAt := func(ref string) error { cloneAt := func(ref string) error {
@@ -444,7 +448,7 @@ func TestGitCloneExecutorShallow(t *testing.T) {
require.NoError(t, cloneAt(unadvertisedSHA)) require.NoError(t, cloneAt(unadvertisedSHA))
assert.FileExists(t, shallowMarker(dir)) assert.FileExists(t, shallowMarker(dir))
assert.Len(t, gitSubjects(t, dir), 1) assert.Equal(t, 1, gitRevCount(t, dir))
assert.Equal(t, unadvertisedSHA, gitRevParse(t, dir, "HEAD")) assert.Equal(t, unadvertisedSHA, gitRevParse(t, dir, "HEAD"))
olderSHA := gitRevParse(t, workDir, "HEAD~2") olderSHA := gitRevParse(t, workDir, "HEAD~2")
@@ -457,8 +461,6 @@ func TestGitCloneExecutorShallow(t *testing.T) {
}) })
t.Run("moving hexadecimal-named branch updates while staying shallow", func(t *testing.T) { t.Run("moving hexadecimal-named branch updates while staying shallow", func(t *testing.T) {
t.Parallel()
remoteDir, workDir := newGitRemote(t, "c2", "c3")
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main:deadbeef")) require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main:deadbeef"))
dir := t.TempDir() dir := t.TempDir()
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{ clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
@@ -471,17 +473,19 @@ func TestGitCloneExecutorShallow(t *testing.T) {
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main:deadbeef")) require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main:deadbeef"))
require.NoError(t, clone(t.Context())) require.NoError(t, clone(t.Context()))
assert.Equal(t, []string{"c4"}, gitSubjects(t, dir), "reused shallow clone should update to the new tip") assert.Equal(t, "c4", gitHeadSubject(t, dir), "reused shallow clone should update to the new tip")
assert.FileExists(t, shallowMarker(dir), "repo should remain shallow after update") assert.FileExists(t, shallowMarker(dir), "repo should remain shallow after update")
assert.Equal(t, 1, gitRevCount(t, dir))
}) })
t.Run("sha256 full hash is cloned shallowly, reused without the remote, and its 40-character prefix is short", func(t *testing.T) { t.Run("sha256 full hash is cloned shallowly, reused without the remote, and its 40-character prefix is short", func(t *testing.T) {
t.Parallel()
remote := filepath.Join(t.TempDir(), "remote.git") remote := filepath.Join(t.TempDir(), "remote.git")
require.NoError(t, gitCmd("init", "--bare", "--object-format=sha256", "--initial-branch=main", remote))
work := t.TempDir() work := t.TempDir()
require.NoError(t, gitCmd("init", "--template=", "--object-format=sha256", "--initial-branch=main", work)) require.NoError(t, gitCmd("init", "--object-format=sha256", "--initial-branch=main", work))
require.NoError(t, gitCmd("-C", work, "remote", "add", "origin", remote))
require.NoError(t, gitCmd("-C", work, "commit", "--allow-empty", "-m", "pinned")) require.NoError(t, gitCmd("-C", work, "commit", "--allow-empty", "-m", "pinned"))
require.NoError(t, gitCmd("clone", "--bare", "--template=", work, remote)) require.NoError(t, gitCmd("-C", work, "push", "origin", "main"))
sha := gitRevParse(t, work, "HEAD") sha := gitRevParse(t, work, "HEAD")
dir := t.TempDir() dir := t.TempDir()
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{URL: remote, Ref: sha, Dir: dir, Depth: 1}) clone := NewGitCloneExecutor(NewGitCloneExecutorInput{URL: remote, Ref: sha, Dir: dir, Depth: 1})
@@ -500,11 +504,11 @@ func TestGitCloneExecutorPinnedHashIgnoresShadowingRef(t *testing.T) {
pinned := gitRevParse(t, workDir, "HEAD") pinned := gitRevParse(t, workDir, "HEAD")
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "decoy")) require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "decoy"))
require.NoError(t, gitCmd("-C", workDir, "tag", pinned)) require.NoError(t, gitCmd("-C", workDir, "tag", pinned))
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main", pinned)) require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
require.NoError(t, gitCmd("-C", workDir, "push", "origin", pinned))
for name, depth := range map[string]int{"shallow": 1, "full clone": 0} { for name, depth := range map[string]int{"shallow": 1, "full clone": 0} {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
t.Parallel()
dir := t.TempDir() dir := t.TempDir()
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{ require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
URL: remoteDir, Ref: pinned, Dir: dir, Depth: depth, URL: remoteDir, Ref: pinned, Dir: dir, Depth: depth,
@@ -536,11 +540,13 @@ func gitRevParse(t *testing.T, dir, rev string) string {
return strings.TrimSpace(string(out)) return strings.TrimSpace(string(out))
} }
func gitSubjects(t *testing.T, dir string) []string { func gitRevCount(t *testing.T, dir string) int {
t.Helper() t.Helper()
out, err := exec.Command("git", "-C", dir, "log", "--format=%s").Output() out, err := exec.Command("git", "-C", dir, "rev-list", "--count", "HEAD").Output()
require.NoError(t, err) require.NoError(t, err)
return strings.Split(strings.TrimSpace(string(out)), "\n") n, err := strconv.Atoi(strings.TrimSpace(string(out)))
require.NoError(t, err)
return n
} }
func gitHeadSubject(t *testing.T, dir string) string { func gitHeadSubject(t *testing.T, dir string) string {
@@ -674,7 +680,7 @@ func TestNewGitCloneExecutorFetchSendsTokenAndHonoursContext(t *testing.T) {
}) })
dir := filepath.Join(t.TempDir(), "cached-action") dir := filepath.Join(t.TempDir(), "cached-action")
require.NoError(t, os.CopyFS(dir, os.DirFS(filepath.Join(gitFixtureDir, "work")))) require.NoError(t, gitCmd("init", dir))
require.NoError(t, gitCmd("-C", dir, "remote", "add", "origin", server.URL)) require.NoError(t, gitCmd("-C", dir, "remote", "add", "origin", server.URL))
ctx, cancel := context.WithCancel(t.Context()) ctx, cancel := context.WithCancel(t.Context())
+1 -1
View File
@@ -42,11 +42,11 @@ func TestDockerBuildLabelsImageWithItsTagAndReportsDaemonError(t *testing.T) {
func TestCreateBuildContextFollowsDockerignoreAndSymlinkedRootButKeepsDockerfile(t *testing.T) { func TestCreateBuildContextFollowsDockerignoreAndSymlinkedRootButKeepsDockerfile(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
require.NoError(t, os.Mkdir(filepath.Join(dir, "build"), 0o755))
for name, content := range map[string]string{ for name, content := range map[string]string{
".dockerignore": "*.log\nbuild\n!build/keep.txt\nDockerfile", "Dockerfile": "FROM scratch", "a.txt": "a", ".dockerignore": "*.log\nbuild\n!build/keep.txt\nDockerfile", "Dockerfile": "FROM scratch", "a.txt": "a",
"a.log": "log", "build/drop.txt": "drop", "build/keep.txt": "keep", "a.log": "log", "build/drop.txt": "drop", "build/keep.txt": "keep",
} { } {
require.NoError(t, os.MkdirAll(filepath.Join(dir, filepath.Dir(name)), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644))
} }
require.NoError(t, os.Mkdir(filepath.Join(dir, "empty"), 0o755)) require.NoError(t, os.Mkdir(filepath.Join(dir, "empty"), 0o755))
+5 -7
View File
@@ -540,9 +540,6 @@ func TestRejectsMissingContainer(t *testing.T) {
} }
func TestPublicCopyPipelineHandlesStaleID(t *testing.T) { func TestPublicCopyPipelineHandlesStaleID(t *testing.T) {
source := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(source, "x"), nil, 0o644))
for _, testcase := range []struct { for _, testcase := range []struct {
name, destination, extractionRoot, entry string name, destination, extractionRoot, entry string
directory bool directory bool
@@ -570,6 +567,8 @@ func TestPublicCopyPipelineHandlesStaleID(t *testing.T) {
cr := &containerReference{id: "stale", cli: client, input: &NewContainerInput{Name: "job-1"}} cr := &containerReference{id: "stale", cli: client, input: &NewContainerInput{Name: "job-1"}}
if testcase.directory { if testcase.directory {
source := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(source, "x"), nil, 0o644))
require.NoError(t, cr.CopyDir(testcase.destination, source+string(filepath.Separator), false, true)(ctx)) require.NoError(t, cr.CopyDir(testcase.destination, source+string(filepath.Separator), false, true)(ctx))
} else { } else {
require.NoError(t, cr.Copy(testcase.destination, &FileEntry{Name: "x", Mode: 0o644})(ctx)) require.NoError(t, cr.Copy(testcase.destination, &FileEntry{Name: "x", Mode: 0o644})(ctx))
@@ -1109,10 +1108,6 @@ func TestConnectTakesDaemonOSFromPing(t *testing.T) {
} }
func TestContainerCopyModes(t *testing.T) { func TestContainerCopyModes(t *testing.T) {
source := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(source, "run.sh"), []byte("#!/bin/sh\n"), 0o666))
require.NoError(t, os.Chmod(filepath.Join(source, "run.sh"), 0o666))
for _, testcase := range []struct { for _, testcase := range []struct {
hostOS, serverOS string hostOS, serverOS string
expected int64 expected int64
@@ -1122,6 +1117,9 @@ func TestContainerCopyModes(t *testing.T) {
{"linux", "linux", 0o666}, {"linux", "linux", 0o666},
} { } {
t.Run(testcase.hostOS+"-"+testcase.serverOS, func(t *testing.T) { t.Run(testcase.hostOS+"-"+testcase.serverOS, func(t *testing.T) {
source := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(source, "run.sh"), []byte("#!/bin/sh\n"), 0o666))
require.NoError(t, os.Chmod(filepath.Join(source, "run.sh"), 0o666))
file := &FileEntry{Name: "run.sh", Mode: 0o600, Body: "#!/bin/sh\n"} file := &FileEntry{Name: "run.sh", Mode: 0o600, Body: "#!/bin/sh\n"}
original := hostOS original := hostOS
hostOS = testcase.hostOS hostOS = testcase.hostOS
+5 -5
View File
@@ -32,11 +32,6 @@ func TestActionPlatformNames(t *testing.T) {
} }
func TestCopyDir(t *testing.T) { func TestCopyDir(t *testing.T) {
src := filepath.Join(t.TempDir(), "action")
require.NoError(t, os.MkdirAll(filepath.Join(src, ".git", "objects"), 0o700))
require.NoError(t, os.WriteFile(filepath.Join(src, ".git", "objects", "pack"), []byte("x"), 0o600))
require.NoError(t, os.WriteFile(filepath.Join(src, "action.yml"), []byte("name: x\n"), 0o600))
for _, test := range []struct { for _, test := range []struct {
name string name string
useGitIgnore, skipGitDir bool useGitIgnore, skipGitDir bool
@@ -46,6 +41,11 @@ func TestCopyDir(t *testing.T) {
{name: "unfiltered outside a git repository", useGitIgnore: true}, {name: "unfiltered outside a git repository", useGitIgnore: true},
} { } {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
src := filepath.Join(t.TempDir(), "action")
require.NoError(t, os.MkdirAll(filepath.Join(src, ".git", "objects"), 0o700))
require.NoError(t, os.WriteFile(filepath.Join(src, ".git", "objects", "pack"), []byte("x"), 0o600))
require.NoError(t, os.WriteFile(filepath.Join(src, "action.yml"), []byte("name: x\n"), 0o600))
dest := filepath.Join(t.TempDir(), "dest") dest := filepath.Join(t.TempDir(), "dest")
require.NoError(t, (&HostEnvironment{}).CopyDir(dest, src+string(filepath.Separator), test.useGitIgnore, test.skipGitDir)(t.Context())) require.NoError(t, (&HostEnvironment{}).CopyDir(dest, src+string(filepath.Separator), test.useGitIgnore, test.skipGitDir)(t.Context()))
+15 -17
View File
@@ -5,7 +5,6 @@ package container
import ( import (
"bufio" "bufio"
"bytes"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -16,9 +15,14 @@ import (
"golang.org/x/text/encoding/unicode" "golang.org/x/text/encoding/unicode"
) )
func newTestHostEnv(t *testing.T) (*HostEnvironment, string) {
t.Helper()
e := &HostEnvironment{Path: t.TempDir()}
return e, filepath.Join(e.Path, "envfile")
}
func TestParseEnvFile(t *testing.T) { func TestParseEnvFile(t *testing.T) {
t.Parallel() t.Parallel()
hostEnv := &HostEnvironment{Path: t.TempDir()}
largeValue := strings.Repeat("x", 2*1024*1024) largeValue := strings.Repeat("x", 2*1024*1024)
encodeUTF16 := func(endianness unicode.Endianness) string { encodeUTF16 := func(endianness unicode.Endianness) string {
content, err := unicode.UTF16(endianness, unicode.UseBOM).NewEncoder().Bytes([]byte("FOO=bar\r\nMULTI<<EOF\r\nline1\r\nEOF\r\n")) content, err := unicode.UTF16(endianness, unicode.UseBOM).NewEncoder().Bytes([]byte("FOO=bar\r\nMULTI<<EOF\r\nline1\r\nEOF\r\n"))
@@ -47,15 +51,15 @@ func TestParseEnvFile(t *testing.T) {
{"UTF16 big endian", encodeUTF16(unicode.BigEndian), map[string]string{"FOO": "bar\r", "MULTI": "line1\r"}}, {"UTF16 big endian", encodeUTF16(unicode.BigEndian), map[string]string{"FOO": "bar\r", "MULTI": "line1\r"}},
} { } {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
envPath := filepath.Join(hostEnv.Path, test.name) e, envPath := newTestHostEnv(t)
require.NoError(t, os.WriteFile(envPath, []byte(test.content), 0o600)) require.NoError(t, os.WriteFile(envPath, []byte(test.content), 0o600))
env := map[string]string{} env := map[string]string{}
require.NoError(t, parseEnvFile(hostEnv, envPath, &env, false)(t.Context())) require.NoError(t, parseEnvFile(e, envPath, &env, false)(t.Context()))
assert.Equal(t, test.want, env) assert.Equal(t, test.want, env)
}) })
} }
t.Run("ordered entries preserve repeated names", func(t *testing.T) { t.Run("ordered entries preserve repeated names", func(t *testing.T) {
envPath := filepath.Join(hostEnv.Path, "ordered") hostEnv, envPath := newTestHostEnv(t)
require.NoError(t, os.WriteFile(envPath, []byte("FOO=first\r\nfoo<<EOF\r\nmiddle\r\nEOF\r\nFOO=last\r\n"), 0o600)) require.NoError(t, os.WriteFile(envPath, []byte("FOO=first\r\nfoo<<EOF\r\nmiddle\r\nEOF\r\nFOO=last\r\n"), 0o600))
var entries [][2]string var entries [][2]string
require.NoError(t, ParseEnvFile(hostEnv, envPath, func(name, value string) { require.NoError(t, ParseEnvFile(hostEnv, envPath, func(name, value string) {
@@ -66,7 +70,7 @@ func TestParseEnvFile(t *testing.T) {
}, entries) }, entries)
}) })
t.Run("Windows line endings", func(t *testing.T) { t.Run("Windows line endings", func(t *testing.T) {
envPath := filepath.Join(hostEnv.Path, "windows") hostEnv, envPath := newTestHostEnv(t)
require.NoError(t, os.WriteFile(envPath, []byte("FOO=bar\r\nMULTI<<EOF\r\nline1\r\nline2\nEOF\r\n"), 0o600)) require.NoError(t, os.WriteFile(envPath, []byte("FOO=bar\r\nMULTI<<EOF\r\nline1\r\nline2\nEOF\r\n"), 0o600))
env := map[string]string{} env := map[string]string{}
require.NoError(t, parseEnvFile(hostEnv, envPath, &env, true)(t.Context())) require.NoError(t, parseEnvFile(hostEnv, envPath, &env, true)(t.Context()))
@@ -76,22 +80,16 @@ func TestParseEnvFile(t *testing.T) {
func TestParseEnvFileLineExceedsBufferReportsScannerError(t *testing.T) { func TestParseEnvFileLineExceedsBufferReportsScannerError(t *testing.T) {
t.Parallel() t.Parallel()
hostEnv := &HostEnvironment{Path: t.TempDir()} e, envPath := newTestHostEnv(t)
envPath := filepath.Join(hostEnv.Path, "envfile") require.NoError(t, os.WriteFile(envPath, []byte("FOO<<EOF\n"+strings.Repeat("x", 17*1024*1024)+"\nEOF\n"), 0o600))
const header, footer = "FOO<<EOF\n", "\nEOF\n"
content := bytes.Repeat([]byte("x"), len(header)+17*1024*1024+len(footer))
copy(content, header)
copy(content[len(content)-len(footer):], footer)
require.NoError(t, os.WriteFile(envPath, content, 0o600))
env := map[string]string{} env := map[string]string{}
err := parseEnvFile(hostEnv, envPath, &env, false)(t.Context()) err := parseEnvFile(e, envPath, &env, false)(t.Context())
require.ErrorIs(t, err, bufio.ErrTooLong) require.ErrorIs(t, err, bufio.ErrTooLong)
assert.Contains(t, err.Error(), "reading env file") assert.Contains(t, err.Error(), "reading env file")
} }
func TestParseEnvFileInvalidHeredoc(t *testing.T) { func TestParseEnvFileInvalidHeredoc(t *testing.T) {
hostEnv := &HostEnvironment{Path: t.TempDir()}
for name, content := range map[string]string{ for name, content := range map[string]string{
"missing delimiter": "FOO<<EOF\nline1\nline2\n", "missing delimiter": "FOO<<EOF\nline1\nline2\n",
"empty name": "<<EOF\nline1\nEOF\n", "empty name": "<<EOF\nline1\nEOF\n",
@@ -99,11 +97,11 @@ func TestParseEnvFileInvalidHeredoc(t *testing.T) {
"delimiter with carriage return": "FOO<<EOF\nline1\nEOF\r\n", "delimiter with carriage return": "FOO<<EOF\nline1\nEOF\r\n",
} { } {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
envPath := filepath.Join(hostEnv.Path, name) e, envPath := newTestHostEnv(t)
require.NoError(t, os.WriteFile(envPath, []byte("APPLIED=before-error\n"+content), 0o600)) require.NoError(t, os.WriteFile(envPath, []byte("APPLIED=before-error\n"+content), 0o600))
env := map[string]string{} env := map[string]string{}
err := parseEnvFile(hostEnv, envPath, &env, false)(t.Context()) err := parseEnvFile(e, envPath, &env, false)(t.Context())
require.Error(t, err) require.Error(t, err)
assert.Contains(t, err.Error(), "delimiter") assert.Contains(t, err.Error(), "delimiter")
assert.Equal(t, map[string]string{"APPLIED": "before-error"}, env) assert.Equal(t, map[string]string{"APPLIED": "before-error"}, env)
+7 -9
View File
@@ -28,12 +28,10 @@ func gitCmd(t *testing.T, args ...string) {
} }
func TestCollectFilesKeepsWhatGitTracksOrDoesNotIgnore(t *testing.T) { func TestCollectFilesKeepsWhatGitTracksOrDoesNotIgnore(t *testing.T) {
repoDir := t.TempDir() repoDir := filepath.Join(t.TempDir(), "mygitrepo")
gitCmd(t, "init", "-q", "--template=", repoDir) for _, dir := range []string{repoDir, filepath.Join(repoDir, "sub"), filepath.Join(repoDir, "nested")} {
for _, dir := range []string{"sub", "nested"} { gitCmd(t, "init", "-q", dir)
require.NoError(t, os.CopyFS(filepath.Join(repoDir, dir, ".git"), os.DirFS(filepath.Join(repoDir, ".git"))))
} }
require.NoError(t, os.Mkdir(filepath.Join(repoDir, "ign"), 0o755))
for name, body := range map[string]string{ for name, body := range map[string]string{
".gitignore": ".*\nign/\n", ".gitignore": ".*\nign/\n",
".env": "", ".env": "",
@@ -45,15 +43,15 @@ func TestCollectFilesKeepsWhatGitTracksOrDoesNotIgnore(t *testing.T) {
"sub/kept": "", "sub/kept": "",
"nested/kept": "", "nested/kept": "",
} { } {
require.NoError(t, os.MkdirAll(filepath.Dir(filepath.Join(repoDir, name)), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(repoDir, name), []byte(body), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(repoDir, name), []byte(body), 0o644))
} }
require.NoError(t, os.Mkdir(filepath.Join(repoDir, "uninitialized"), 0o755)) require.NoError(t, os.Mkdir(filepath.Join(repoDir, "uninitialized"), 0o755))
gitCmd(t, "-C", repoDir, "add", "-f", ".gitignore", "Renamed", "ign/tracked") gitCmd(t, "-C", repoDir, "add", "-f", ".gitignore", "Renamed", "ign/tracked")
require.NoError(t, os.Rename(filepath.Join(repoDir, "Renamed"), filepath.Join(repoDir, "renamed"))) require.NoError(t, os.Rename(filepath.Join(repoDir, "Renamed"), filepath.Join(repoDir, "renamed")))
gitCmd(t, "-C", repoDir, "update-index", "--add", for _, gitlink := range []string{"sub", "uninitialized"} {
"--cacheinfo", "160000,4b825dc642cb6eb9a060e54bf8d69288fbee4904,sub", gitCmd(t, "-C", repoDir, "update-index", "--add", "--cacheinfo", "160000,4b825dc642cb6eb9a060e54bf8d69288fbee4904,"+gitlink)
"--cacheinfo", "160000,4b825dc642cb6eb9a060e54bf8d69288fbee4904,uninitialized", }
)
expected := map[string]string{".gitignore": "", "ign/tracked": "", "nested/kept": "", "renamed": "", "sub/.gitignore": "", "sub/kept": ""} expected := map[string]string{".gitignore": "", "ign/tracked": "", "nested/kept": "", "renamed": "", "sub/.gitignore": "", "sub/kept": ""}
if runtime.GOOS != "windows" { if runtime.GOOS != "windows" {
require.NoError(t, os.Chmod(filepath.Join(repoDir, "renamed"), 0o777|os.ModeSticky)) require.NoError(t, os.Chmod(filepath.Join(repoDir, "renamed"), 0o777|os.ModeSticky))
+1 -1
View File
@@ -92,7 +92,7 @@ func TestCompositeActionParity(t *testing.T) {
return "", "", assert.AnError return "", "", assert.AnError
}) })
rc := &RunContext{ rc := &RunContext{
Config: &Config{GitHubInstance: "github.com", PresetGitHubContext: &model.GithubContext{}}, Config: &Config{GitHubInstance: "github.com", ActionCacheDir: t.TempDir(), PresetGitHubContext: &model.GithubContext{}},
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {}}}}, Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {}}}},
JobContainer: &jobContainerMock{}, JobContainer: &jobContainerMock{},
} }
+13 -17
View File
@@ -249,7 +249,6 @@ func TestActionRunner(t *testing.T) {
} }
func TestGoActionUsesLinuxExecutablePaths(t *testing.T) { func TestGoActionUsesLinuxExecutablePaths(t *testing.T) {
actionRoot := t.TempDir()
for _, stage := range []string{"main", "pre", "post"} { for _, stage := range []string{"main", "pre", "post"} {
t.Run(stage, func(t *testing.T) { t.Run(stage, func(t *testing.T) {
cm := &containerMock{} cm := &containerMock{}
@@ -265,7 +264,7 @@ func TestGoActionUsesLinuxExecutablePaths(t *testing.T) {
JobContainer: cm, JobContainer: cm,
}, },
action: &model.Action{Runs: model.ActionRuns{Using: model.ActionRunsUsingGo, Main: "main.go", Pre: "pre.go", Post: "post.go"}}, action: &model.Action{Runs: model.ActionRuns{Using: model.ActionRunsUsingGo, Main: "main.go", Pre: "pre.go", Post: "post.go"}},
actionDir: filepath.Join(actionRoot, stage), actionDir: t.TempDir(),
env: map[string]string{}, env: map[string]string{},
remoteAction: mustNewRemoteAction(t, "org/repo@ref"), remoteAction: mustNewRemoteAction(t, "org/repo@ref"),
} }
@@ -505,21 +504,11 @@ func TestExecAsDockerHoldsCloneLockForRemoteUncached(t *testing.T) {
} }
func TestExecAsDockerImageChecks(t *testing.T) { func TestExecAsDockerImageChecks(t *testing.T) {
actionRoot := t.TempDir()
originalExists, originalBuild, originalContainer := ContainerImageExistsLocally, ContainerNewDockerBuildExecutor, ContainerNewContainer originalExists, originalBuild, originalContainer := ContainerImageExistsLocally, ContainerNewDockerBuildExecutor, ContainerNewContainer
t.Cleanup(func() { t.Cleanup(func() {
ContainerImageExistsLocally, ContainerNewDockerBuildExecutor, ContainerNewContainer = originalExists, originalBuild, originalContainer ContainerImageExistsLocally, ContainerNewDockerBuildExecutor, ContainerNewContainer = originalExists, originalBuild, originalContainer
}) })
ContainerNewContainer = func(*container.NewContainerInput) container.ExecutionsEnvironment { return fakeContainer{} } ContainerNewContainer = func(*container.NewContainerInput) container.ExecutionsEnvironment { return fakeContainer{} }
var removed bool
fakeDockerDaemon(t, func(writer http.ResponseWriter, request *http.Request) {
if request.Method == http.MethodDelete {
removed = true
_, _ = io.WriteString(writer, `[]`)
} else {
_, _ = io.WriteString(writer, `{"Architecture":"aarch64"}`)
}
})
for _, testcase := range []struct { for _, testcase := range []struct {
name, platform string name, platform string
@@ -540,11 +529,18 @@ func TestExecAsDockerImageChecks(t *testing.T) {
checks = append(checks, platform) checks = append(checks, platform)
return testcase.exists && (platform == "any" || testcase.matches), nil return testcase.exists && (platform == "any" || testcase.matches), nil
} }
var built bool var built, removed bool
removed = false
ContainerNewDockerBuildExecutor = func(container.NewDockerBuildExecutorInput) common.Executor { ContainerNewDockerBuildExecutor = func(container.NewDockerBuildExecutorInput) common.Executor {
return func(context.Context) error { built = true; return nil } return func(context.Context) error { built = true; return nil }
} }
fakeDockerDaemon(t, func(writer http.ResponseWriter, request *http.Request) {
if request.Method == http.MethodDelete {
removed = true
_, _ = io.WriteString(writer, `[]`)
} else {
_, _ = io.WriteString(writer, `{"Architecture":"aarch64"}`)
}
})
step := &stepActionRemote{ step := &stepActionRemote{
Step: &model.Step{ID: "action"}, Step: &model.Step{ID: "action"},
RunContext: &RunContext{ RunContext: &RunContext{
@@ -559,7 +555,7 @@ func TestExecAsDockerImageChecks(t *testing.T) {
action: &model.Action{Runs: model.ActionRuns{Using: "docker", Image: "Dockerfile"}}, action: &model.Action{Runs: model.ActionRuns{Using: "docker", Image: "Dockerfile"}},
env: map[string]string{}, env: map[string]string{},
} }
dir := filepath.Join(actionRoot, testcase.name) dir := t.TempDir()
require.NoError(t, execAsDocker(t.Context(), step, "action", dir, dir, false, stepStageMain)) require.NoError(t, execAsDocker(t.Context(), step, "action", dir, dir, false, stepStageMain))
assert.Equal(t, testcase.wantChecks, checks) assert.Equal(t, testcase.wantChecks, checks)
assert.Equal(t, testcase.wantBuild, built) assert.Equal(t, testcase.wantBuild, built)
@@ -731,9 +727,9 @@ func TestExecAsDockerStageEntrypoint(t *testing.T) {
cm.On("Close").Return(func(context.Context) error { return nil }) cm.On("Close").Return(func(context.Context) error { return nil })
if tc.stage == stepStageMain { if tc.stage == stepStageMain {
require.NoError(t, runActionImpl(step, "", step.remoteAction)(ctx)) require.NoError(t, runActionImpl(step, t.TempDir(), step.remoteAction)(ctx))
} else { } else {
require.NoError(t, execAsDocker(ctx, step, "action", "", "", false, tc.stage)) require.NoError(t, execAsDocker(ctx, step, "action", t.TempDir(), t.TempDir(), false, tc.stage))
} }
if tc.warning == "" { if tc.warning == "" {
assert.Empty(t, hook.AllRecords()) assert.Empty(t, hook.AllRecords())
+5
View File
@@ -193,6 +193,11 @@ func TestCommandLogWriter(t *testing.T) {
log []string log []string
masks []string masks []string
}{ }{
{
name: "mask commands decode modern and legacy escapes once",
steps: [][]string{{"::add-mask::%25", "##[add-mask]alpha beta%3Bgamma%5Ddelta%250A"}},
masks: []string{"%", "alpha beta;gamma]delta%0A"},
},
{ {
name: "applied commands are hidden unless echoed", name: "applied commands are hidden unless echoed",
steps: [][]string{{ steps: [][]string{{
+3 -2
View File
@@ -412,10 +412,11 @@ func TestGetEvaluatorInputsBoolean(t *testing.T) {
} }
for _, eventName := range []string{"workflow_call", "workflow_dispatch"} { for _, eventName := range []string{"workflow_call", "workflow_dispatch"} {
wf, err := model.ReadWorkflow(strings.NewReader(workflow(eventName)))
require.NoError(t, err)
for _, table := range tables { for _, table := range tables {
t.Run(eventName+"/"+table.name, func(t *testing.T) { t.Run(eventName+"/"+table.name, func(t *testing.T) {
wf, err := model.ReadWorkflow(strings.NewReader(workflow(eventName)))
require.NoError(t, err)
rc := &RunContext{ rc := &RunContext{
Config: &Config{Workdir: "."}, Config: &Config{Workdir: "."},
Run: &model.Run{JobID: "job1", Workflow: wf}, Run: &model.Run{JobID: "job1", Workflow: wf},
+3 -4
View File
@@ -993,7 +993,6 @@ func tarArchive(t *testing.T, entries ...tarEntry) []byte {
buf := &bytes.Buffer{} buf := &bytes.Buffer{}
tw := tar.NewWriter(buf) tw := tar.NewWriter(buf)
for _, entry := range entries { for _, entry := range entries {
buf.Grow(len(entry.body) + 3*512)
typeflag := entry.typeflag typeflag := entry.typeflag
if typeflag == 0 { if typeflag == 0 {
typeflag = tar.TypeReg typeflag = tar.TypeReg
@@ -1010,7 +1009,7 @@ func tarArchive(t *testing.T, entries ...tarEntry) []byte {
} }
require.NoError(t, tw.WriteHeader(header)) require.NoError(t, tw.WriteHeader(header))
if typeflag == tar.TypeReg { if typeflag == tar.TypeReg {
_, err := io.CopyN(tw, strings.NewReader(entry.body), int64(len(entry.body))) _, err := tw.Write([]byte(entry.body))
require.NoError(t, err) require.NoError(t, err)
} }
} }
@@ -1150,12 +1149,12 @@ func TestTryUploadJobSummaryMasksSecrets(t *testing.T) {
})) }))
defer server.Close() defer server.Close()
content, err := testcase.encoding.NewEncoder().String(testcase.content) content, err := testcase.encoding.NewEncoder().Bytes([]byte(testcase.content))
require.NoError(t, err) require.NoError(t, err)
cm := &containerMock{} cm := &containerMock{}
cm.On("GetContainerArchive", mock.Anything, "/var/run/act/workflow/step-summary-0.md").Return( cm.On("GetContainerArchive", mock.Anything, "/var/run/act/workflow/step-summary-0.md").Return(
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{ io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{
name: "step-summary-0.md", body: content, name: "step-summary-0.md", body: string(content),
}))), }))),
nil, nil,
).Once() ).Once()
+37 -9
View File
@@ -263,29 +263,27 @@ func AppendSecretMaskers(oldnew []string, secrets map[string]string) []string {
return oldnew return oldnew
} }
// AppendSecretMasker registers v and each of its lines, as GitHub does. // AppendSecretMasker skips one-byte line fragments to avoid masking punctuation from structured secrets.
func AppendSecretMasker(oldnew []string, v string) []string { func AppendSecretMasker(oldnew []string, v string) []string {
ret := appendMaskedValue(oldnew, v) ret := appendMaskedValue(oldnew, v)
for l := range strings.FieldsFuncSeq(v, func(r rune) bool { return r == '\r' || r == '\n' }) { for l := range strings.FieldsFuncSeq(v, func(r rune) bool { return r == '\r' || r == '\n' }) {
ret = appendMaskedValue(ret, strings.TrimSpace(l)) if line := strings.TrimSpace(l); len(line) > 1 {
ret = appendMaskedValue(ret, line)
}
} }
return ret return ret
} }
// appendMaskedValue registers one value and every shape it takes on its way into a log.
func appendMaskedValue(ret []string, v string) []string { func appendMaskedValue(ret []string, v string) []string {
// formatted JSON secrets could otherwise mask {,[,],} everywhere if strings.TrimSpace(v) == "" || slices.Contains(ret, v) {
if len(strings.TrimSpace(v)) <= 1 || slices.Contains(ret, v) {
return ret return ret
} }
ret = append(ret, v, "***") ret = append(ret, v, "***")
// command data reaches the log escaped, so "pass%word" also arrives as "pass%25word"
if strings.ContainsAny(v, "%\r\n") { if strings.ContainsAny(v, "%\r\n") {
ret = append(ret, EscapeCommandData(v), "***") ret = append(ret, EscapeCommandData(v), "***")
} }
for _, encode := range secretValueEncoders { for _, encode := range secretValueEncoders {
encoded := encode(v) encoded := encode(v)
// An encoding that leaves the value unchanged is already masked above.
if encoded == v || len(encoded) <= 1 || slices.Contains(ret, encoded) { if encoded == v || len(encoded) <= 1 || slices.Contains(ret, encoded) {
continue continue
} }
@@ -319,7 +317,7 @@ func valueMasker(insecureSecrets bool, oldnew []string) entryProcessor {
masks := Masks(ctx) masks := Masks(ctx)
if len(*masks) == 0 { if len(*masks) == 0 {
r.Message = defReplacer.Replace(r.Message) r.Message = MaskLog(r.Message, defReplacer)
return r return r
} }
@@ -339,12 +337,42 @@ func valueMasker(insecureSecrets bool, oldnew []string) entryProcessor {
cmasker := replacer cmasker := replacer
mu.Unlock() mu.Unlock()
r.Message = cmasker.Replace(r.Message) r.Message = MaskLog(r.Message, cmasker)
return r return r
} }
} }
// MaskLog decodes command data before masking to cover partially escaped values.
func MaskLog(message string, replacer *strings.Replacer) string {
var command, data string
var found bool
unescape := UnescapeCommandData
if strings.HasPrefix(message, "##[") {
command, data, found = strings.Cut(message[3:], "]")
unescape = UnescapeLegacyCommand
} else if strings.HasPrefix(message, "::") {
command, data, found = strings.Cut(message[2:], "::")
if name, _, properties := strings.Cut(command, " "); properties {
switch name {
case "error", "warning", "notice", "debug":
command = name
}
}
}
switch command {
case "error", "warning", "notice", "debug", "group", "endgroup", "add-matcher":
if found {
trimmedData := strings.TrimRight(data, "\r\n")
normalized := message[:len(message)-len(data)] + EscapeCommandData(unescape(trimmedData)) + data[len(trimmedData):]
if masked := replacer.Replace(normalized); masked != normalized {
return masked
}
}
}
return replacer.Replace(message)
}
type maskedHandler struct { type maskedHandler struct {
logging.Handler logging.Handler
masker entryProcessor masker entryProcessor
+61 -4
View File
@@ -28,6 +28,7 @@ func TestValueMasker(t *testing.T) {
secrets map[string]string secrets map[string]string
masks []string masks []string
disallowed []string disallowed []string
want string
}{ }{
{ {
name: "Multiline Private Key", name: "Multiline Private Key",
@@ -49,6 +50,60 @@ func TestValueMasker(t *testing.T) {
secrets: map[string]string{"TOKEN": "pass%word"}, secrets: map[string]string{"TOKEN": "pass%word"},
disallowed: []string{"pass%25word"}, disallowed: []string{"pass%25word"},
}, },
{
name: "one-character mask",
lines: "%",
masks: []string{"%"},
want: "***",
},
{
name: "fully escaped legacy secret",
lines: "##[group]alpha beta%3Bgamma%5Ddelta",
secrets: map[string]string{"TOKEN": "alpha beta;gamma]delta"},
want: "##[group]***",
},
{
name: "partially escaped legacy mask",
lines: "##[group]alpha beta%3Bgamma]delta",
masks: []string{"alpha beta;gamma]delta"},
want: "##[group]***",
},
{
name: "partially escaped modern mask preserves unrelated escapes",
lines: "::group::alpha%25beta%gamma tail%250A%0A",
masks: []string{"alpha%beta%gamma"},
want: "::group::*** tail%250A%0A",
},
{
name: "command escapes are decoded once",
lines: "##[group]alpha%253Bbeta%5Dgamma",
masks: []string{"alpha;beta]gamma"},
want: "##[group]alpha%253Bbeta%5Dgamma",
},
{
name: "plain output preserves escapes",
lines: "alpha beta%3Bgamma%5Ddelta",
masks: []string{"alpha beta;gamma]delta"},
want: "alpha beta%3Bgamma%5Ddelta",
},
{
name: "command display preserves literal escapes around a mask",
lines: "##[command]alpha tail%3Bvalue%5D",
masks: []string{"alpha"},
want: "##[command]*** tail%3Bvalue%5D",
},
{
name: "unknown modern commands preserve literal escapes",
lines: "::unknown::alpha tail%25value",
masks: []string{"alpha"},
want: "::unknown::*** tail%25value",
},
{
name: "masks can span command headers and data",
lines: "::group::hidden other",
masks: []string{"group::hidden", "other"},
want: "::*** ***",
},
} }
for _, entry := range table { for _, entry := range table {
t.Run(entry.name, func(t *testing.T) { t.Run(entry.name, func(t *testing.T) {
@@ -56,6 +111,9 @@ func TestValueMasker(t *testing.T) {
masker := valueMasker(false, AppendSecretMaskers(nil, entry.secrets)) masker := valueMasker(false, AppendSecretMaskers(nil, entry.secrets))
for line := range strings.SplitSeq(entry.lines, "\n") { for line := range strings.SplitSeq(entry.lines, "\n") {
lentry := masker(ctx, logging.Record{Message: line}) lentry := masker(ctx, logging.Record{Message: line})
if entry.want != "" {
assert.Equal(t, entry.want, lentry.Message)
}
for _, line := range entry.disallowed { for _, line := range entry.disallowed {
assert.NotContains(t, lentry.Message, line) assert.NotContains(t, lentry.Message, line)
} }
@@ -139,6 +197,7 @@ func TestValueMaskerEncodedMasks(t *testing.T) {
{"encoded: " + base64.StdEncoding.EncodeToString([]byte(" s3cr3t ")), "encoded: ***"}, {"encoded: " + base64.StdEncoding.EncodeToString([]byte(" s3cr3t ")), "encoded: ***"},
{"encoded: " + base64.StdEncoding.EncodeToString([]byte("s3cr3t")), "encoded: ***"}, {"encoded: " + base64.StdEncoding.EncodeToString([]byte("s3cr3t")), "encoded: ***"},
{"encoded: " + base64.StdEncoding.EncodeToString([]byte("first")), "encoded: ***"}, {"encoded: " + base64.StdEncoding.EncodeToString([]byte("first")), "encoded: ***"},
{"##[group]s3cr3t value\r\n", "##[group]***\r\n"},
} { } {
entry := masker(WithMasks(t.Context(), &masks), logging.Record{Message: tc.line}) entry := masker(WithMasks(t.Context(), &masks), logging.Record{Message: tc.line})
assert.Equal(t, tc.want, entry.Message, tc.line) assert.Equal(t, tc.want, entry.Message, tc.line)
@@ -194,17 +253,15 @@ func TestValueMaskerCachedReplacerSeesNewMasks(t *testing.T) {
} }
func TestAppendSecretMaskerSkipsUselessEncodings(t *testing.T) { func TestAppendSecretMaskerSkipsUselessEncodings(t *testing.T) {
// A token with no character an escape would touch only gains its base64 forms:
// JSON, query and path escaping all leave it unchanged.
pairs := AppendSecretMasker(nil, "plaintoken") pairs := AppendSecretMasker(nil, "plaintoken")
assert.Equal(t, []string{ assert.Equal(t, []string{
"plaintoken", "***", "cGxhaW50b2tlbg==", "***", "bGFpbnRva2Vu", "***", "YWludG9rZW4=", "***", "plaintoken", "***", "cGxhaW50b2tlbg==", "***", "bGFpbnRva2Vu", "***", "YWludG9rZW4=", "***",
"aW50b2tl", "***", "YWludG9r", "***", "bGFpbnRv", "***", "aW50b2tl", "***", "YWludG9r", "***", "bGFpbnRv", "***",
}, pairs) }, pairs)
// Too short to mask. assert.Contains(t, AppendSecretMasker(nil, "x"), "x")
assert.Empty(t, AppendSecretMasker(nil, "x"))
assert.Empty(t, AppendSecretMasker(nil, " \t")) assert.Empty(t, AppendSecretMasker(nil, " \t"))
assert.NotContains(t, AppendSecretMasker(nil, "{\nvalue\n}"), "{")
assert.NotContains(t, AppendSecretMasker(nil, `"123456"`), "123456") assert.NotContains(t, AppendSecretMasker(nil, `"123456"`), "123456")
} }
+6 -7
View File
@@ -4,7 +4,6 @@
package runner package runner
import ( import (
"bytes"
"context" "context"
"encoding/base64" "encoding/base64"
"io" "io"
@@ -210,14 +209,14 @@ func TestPatchedBundleAcrossActions(t *testing.T) {
assert.Equal(t, data, out, "an untouched bundle must come back byte for byte") assert.Equal(t, data, out, "an untouched bundle must come back byte for byte")
return return
} }
assert.False(t, bytes.Contains(out, []byte(".LOCALHOST")), "a copy of the gate was missed") assert.NotContains(t, string(out), ".LOCALHOST", "a copy of the gate was missed")
if !bytes.Contains(data, []byte(CacheServiceV2Env)) { if !strings.Contains(string(data), CacheServiceV2Env) {
return return
} }
assert.NotZero(t, bytes.Count(out, []byte("(process.env."+cacheURLEnv+"||process.env")), assert.NotZero(t, strings.Count(string(out), "(process.env."+cacheURLEnv+"||process.env"),
"the cache service URL was not redirected") "the cache service URL was not redirected")
assert.Equal(t, bytes.Count(data, []byte(resultsURLEnv)), bytes.Count(out, []byte(resultsURLEnv)), assert.Equal(t, strings.Count(string(data), resultsURLEnv), strings.Count(string(out), resultsURLEnv),
"a read of the results URL was lost, it must stay as the fallback") "a read of the results URL was lost, it must stay as the fallback")
}) })
} }
@@ -312,18 +311,18 @@ func TestCodeSplitActionFindsTheCacheService(t *testing.T) {
requireHostTools(t, "node", "rustc") requireHostTools(t, "node", "rustc")
dir := tempDirPath(t) dir := tempDirPath(t)
require.NoError(t, os.Mkdir(filepath.Join(dir, "dist"), 0o755))
for _, name := range []string{ for _, name := range []string{
"action.yml", "dist/restore.js", "dist/save.js", "dist/cleanup-BWEbZ6YT.js", "action.yml", "dist/restore.js", "dist/save.js", "dist/cleanup-BWEbZ6YT.js",
"dist/cache-CTfQoCTL.js", "dist/cache-DC26rUaF.js", "dist/state-cjs-CkvFUaNy.js", "dist/cache-CTfQoCTL.js", "dist/cache-DC26rUaF.js", "dist/state-cjs-CkvFUaNy.js",
} { } {
body, err := os.ReadFile(bundleFromGitHub(t, "Swatinem/rust-cache", "v2.9.2", name)) body, err := os.ReadFile(bundleFromGitHub(t, "Swatinem/rust-cache", "v2.9.2", name))
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, name), body, 0o600)) require.NoError(t, os.WriteFile(filepath.Join(dir, name), body, 0o600))
} }
body, err := os.ReadFile(filepath.Join(dir, "action.yml")) body, err := os.ReadFile(filepath.Join(dir, "action.yml"))
require.NoError(t, err) require.NoError(t, err)
action, err := model.ReadAction(bytes.NewReader(body)) action, err := model.ReadAction(strings.NewReader(string(body)))
require.NoError(t, err) require.NoError(t, err)
inputs := map[string]string{"lookup-only": "true", "add-rust-environment-hash-key": "false", "cache-bin": "false"} inputs := map[string]string{"lookup-only": "true", "add-rust-environment-hash-key": "false", "cache-bin": "false"}
for name, input := range action.Inputs { for name, input := range action.Inputs {
+12 -14
View File
@@ -4,7 +4,6 @@
package runner package runner
import ( import (
"bytes"
"context" "context"
"os" "os"
"os/exec" "os/exec"
@@ -183,9 +182,9 @@ func TestPatchedBundleSurvivesInsideAStringLiteral(t *testing.T) {
out, patched := patchedBundle([]byte(embedded)) out, patched := patchedBundle([]byte(embedded))
require.True(t, patched) require.True(t, patched)
cmd := exec.CommandContext(t.Context(), "node", "--check", "-") file := filepath.Join(t.TempDir(), "bundle.js")
cmd.Stdin = bytes.NewReader(out) require.NoError(t, os.WriteFile(file, out, 0o600))
checked, err := cmd.CombinedOutput() checked, err := exec.CommandContext(t.Context(), "node", "--check", file).CombinedOutput()
require.NoError(t, err, "%s", checked) require.NoError(t, err, "%s", checked)
} }
@@ -257,14 +256,6 @@ func TestActionScriptPaths(t *testing.T) {
} }
func TestPatchActionsAtTheContainerCopy(t *testing.T) { func TestPatchActionsAtTheContainerCopy(t *testing.T) {
actionDir := t.TempDir()
script := filepath.Join(actionDir, "dist", "cache.js")
require.NoError(t, os.MkdirAll(filepath.Dir(script), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(filepath.Dir(script), "index.js"), []byte("require('./cache.js')"), 0o600))
require.NoError(t, os.WriteFile(script, []byte(gateTSC), 0o600))
info, err := os.Stat(script)
require.NoError(t, err)
copiedBundles := func(t *testing.T, noPatch bool) ([]string, []*container.FileEntry, int64) { copiedBundles := func(t *testing.T, noPatch bool) ([]string, []*container.FileEntry, int64) {
t.Helper() t.Helper()
@@ -273,12 +264,19 @@ func TestPatchActionsAtTheContainerCopy(t *testing.T) {
Step: &model.Step{Uses: "owner/repo/sub@v1"}, Step: &model.Step{Uses: "owner/repo/sub@v1"},
remoteAction: &remoteAction{Org: "owner", Repo: "repo", Path: "sub", Ref: "v1"}, remoteAction: &remoteAction{Org: "owner", Repo: "repo", Path: "sub", Ref: "v1"},
action: &model.Action{Runs: model.ActionRuns{Using: "node20", Main: "../dist/index.js"}}, action: &model.Action{Runs: model.ActionRuns{Using: "node20", Main: "../dist/index.js"}},
actionDir: actionDir, actionDir: t.TempDir(),
RunContext: &RunContext{ RunContext: &RunContext{
Config: &Config{NoActionPatch: noPatch}, Config: &Config{ActionCacheDir: t.TempDir(), NoActionPatch: noPatch},
JobContainer: cm, JobContainer: cm,
}, },
} }
script := filepath.Join(sar.actionDir, "dist", "cache.js")
require.NoError(t, os.MkdirAll(filepath.Dir(script), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(filepath.Dir(script), "index.js"), []byte("require('./cache.js')"), 0o600))
require.NoError(t, os.WriteFile(script, []byte(gateTSC), 0o600))
info, err := os.Stat(script)
require.NoError(t, err)
var calls []string var calls []string
var copied []*container.FileEntry var copied []*container.FileEntry
cm.On("CopyDir", "/var/run/act/actions/repo/", sar.actionDir+"/", false, true).Return(func(context.Context) error { cm.On("CopyDir", "/var/run/act/actions/repo/", sar.actionDir+"/", false, true).Return(func(context.Context) error {
+16 -8
View File
@@ -33,17 +33,24 @@ func TestReusableWorkflowCachedBranchRefRefreshes(t *testing.T) {
} }
remoteDir := t.TempDir() remoteDir := t.TempDir()
gitMust(t, "", "init", "--template=", "--initial-branch=master", remoteDir) gitMust(t, "", "init", "--bare", "--initial-branch=master", remoteDir)
workDir := t.TempDir()
gitMust(t, "", "clone", remoteDir, workDir)
gitMust(t, workDir, "config", "user.email", "test@test")
gitMust(t, workDir, "config", "user.name", "test")
gitMust(t, workDir, "checkout", "-b", "master")
const workflowPath = ".gitea/workflows/reusable.yml" const workflowPath = ".gitea/workflows/reusable.yml"
tmpl := func(tag string) string { tmpl := func(tag string) string {
return "name: reusable\non:\n workflow_call:\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo " + tag + "\n" return "name: reusable\non:\n workflow_call:\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo " + tag + "\n"
} }
require.NoError(t, os.MkdirAll(filepath.Join(remoteDir, ".gitea/workflows"), 0o755)) require.NoError(t, os.MkdirAll(filepath.Join(workDir, ".gitea/workflows"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(remoteDir, workflowPath), []byte(tmpl("v1")), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(workDir, workflowPath), []byte(tmpl("v1")), 0o644))
gitMust(t, remoteDir, "add", workflowPath) gitMust(t, workDir, "add", workflowPath)
gitMust(t, remoteDir, "commit", "-m", "v1") gitMust(t, workDir, "commit", "-m", "v1")
gitMust(t, workDir, "push", "-u", "origin", "master")
rc := &RunContext{ rc := &RunContext{
Config: &Config{Env: testCheckoutEnv()}, Config: &Config{Env: testCheckoutEnv()},
@@ -63,8 +70,9 @@ func TestReusableWorkflowCachedBranchRefRefreshes(t *testing.T) {
require.Equal(t, tmpl("v1"), string(got)) require.Equal(t, tmpl("v1"), string(got))
// Branch tip moves; cache key (cacheDir) does not. // Branch tip moves; cache key (cacheDir) does not.
require.NoError(t, os.WriteFile(filepath.Join(remoteDir, workflowPath), []byte(tmpl("v2")), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(workDir, workflowPath), []byte(tmpl("v2")), 0o644))
gitMust(t, remoteDir, "commit", "-am", "v2") gitMust(t, workDir, "commit", "-am", "v2")
gitMust(t, workDir, "push", "origin", "master")
require.NoError(t, cloneRemoteReusableWorkflow(rc, remoteDir, "master", cacheDir)(t.Context())) require.NoError(t, cloneRemoteReusableWorkflow(rc, remoteDir, "master", cacheDir)(t.Context()))
got, err = os.ReadFile(filepath.Join(cacheDir, workflowPath)) got, err = os.ReadFile(filepath.Join(cacheDir, workflowPath))
@@ -206,7 +214,7 @@ func TestInstanceAuthAndTransportOnlyApplyToTheInstance(t *testing.T) {
func gitMust(t *testing.T, dir string, args ...string) { func gitMust(t *testing.T, dir string, args ...string) {
t.Helper() t.Helper()
cmd := exec.Command("git", append([]string{"-c", "user.name=test", "-c", "user.email=test@test"}, args...)...) cmd := exec.Command("git", args...)
if dir != "" { if dir != "" {
cmd.Dir = dir cmd.Dir = dir
} }
+6 -6
View File
@@ -1666,21 +1666,21 @@ func TestRunContextUpdateExtraPath(t *testing.T) {
longPath := strings.Repeat("x", 64*1024+1) longPath := strings.Repeat("x", 64*1024+1)
for _, testcase := range []struct { for _, testcase := range []struct {
name string name string
content string content []byte
want string want string
wantErr bool wantErr bool
}{ }{
{name: "UTF-8 BOM", content: "\xef\xbb\xbf/utf8/tools\n", want: "/utf8/tools"}, {name: "UTF-8 BOM", content: []byte("\xef\xbb\xbf/utf8/tools\n"), want: "/utf8/tools"},
{name: "UTF-16 BOM", content: "\xff\xfeC\x00:\x00\\\x00t\x00o\x00o\x00l\x00s\x00\n\x00", want: `C:\tools`}, {name: "UTF-16 BOM", content: []byte{0xff, 0xfe, 'C', 0, ':', 0, '\\', 0, 't', 0, 'o', 0, 'o', 0, 'l', 0, 's', 0, '\n', 0}, want: `C:\tools`},
{name: "over 64 KiB", content: longPath + "\n", want: longPath}, {name: "over 64 KiB", content: []byte(longPath + "\n"), want: longPath},
{name: "over 16 MiB", content: strings.Repeat("x", 16*1024*1024+1), wantErr: true}, {name: "over 16 MiB", content: []byte(strings.Repeat("x", 16*1024*1024+1)), wantErr: true},
} { } {
t.Run(testcase.name, func(t *testing.T) { t.Run(testcase.name, func(t *testing.T) {
logger := logging.New(logging.DiscardHandler) logger := logging.New(logging.DiscardHandler)
ctx := common.WithLogger(t.Context(), logger) ctx := common.WithLogger(t.Context(), logger)
jobContainer := &containerMock{} jobContainer := &containerMock{}
jobContainer.On("GetContainerArchive", mock.Anything, "/github/path"). jobContainer.On("GetContainerArchive", mock.Anything, "/github/path").
Return(io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "path", body: testcase.content}))), nil).Once() Return(io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "path", body: string(testcase.content)}))), nil).Once()
defer jobContainer.AssertExpectations(t) defer jobContainer.AssertExpectations(t)
rc := &RunContext{JobContainer: jobContainer} rc := &RunContext{JobContainer: jobContainer}
+9 -3
View File
@@ -35,8 +35,14 @@ func setFetch(t *testing.T, fetch func(context.Context, git.NewGitCloneExecutorI
func TestShortSHAActionRejected(t *testing.T) { func TestShortSHAActionRejected(t *testing.T) {
actionRoot := t.TempDir() actionRoot := t.TempDir()
repo := filepath.Join(actionRoot, "actions", "hello-world-docker-action") repo := filepath.Join(actionRoot, "actions", "hello-world-docker-action")
gitMust(t, "", "init", "--template=", "--initial-branch=main", repo) require.NoError(t, os.MkdirAll(repo, 0o755))
gitMust(t, repo, "commit", "--allow-empty", "-m", "initial") gitMust(t, "", "init", "--initial-branch=main", repo)
gitMust(t, repo, "config", "user.email", "test@test")
gitMust(t, repo, "config", "user.name", "test")
require.NoError(t, os.WriteFile(filepath.Join(repo, "action.yml"),
[]byte("name: hello\nruns:\n using: node24\n main: index.js\n"), 0o644))
gitMust(t, repo, "add", ".")
gitMust(t, repo, "commit", "-m", "initial")
output, err := exec.Command("git", "-C", repo, "rev-parse", "--short=7", "HEAD").Output() output, err := exec.Command("git", "-C", repo, "rev-parse", "--short=7", "HEAD").Output()
require.NoError(t, err) require.NoError(t, err)
@@ -387,7 +393,7 @@ func TestStepActionRemotePreResolvesDownloadedCommit(t *testing.T) {
[]byte("name: setup-go\nruns:\n using: node20\n main: index.js\n"), 0o600)) []byte("name: setup-go\nruns:\n using: node20\n main: index.js\n"), 0o600))
for _, args := range [][]string{ for _, args := range [][]string{
{"init", "--template=", "--initial-branch=main", actionDir}, {"init", "--initial-branch=main", actionDir},
{"-C", actionDir, "add", "action.yml"}, {"-C", actionDir, "add", "action.yml"},
{"-C", actionDir, "-c", "user.name=runner", "-c", "[email protected]", "-c", "commit.gpgsign=false", "commit", "-m", "action"}, {"-C", actionDir, "-c", "user.name=runner", "-c", "[email protected]", "-c", "commit.gpgsign=false", "commit", "-m", "action"},
} { } {
+4 -5
View File
@@ -128,11 +128,6 @@ func (*daemonTestService) UpdateLog(_ context.Context, req *runnerv1.UpdateLogRe
func TestDaemonRetriesDeclareOutagesAndRemovesOnlyConsumedEphemeralRegistration(t *testing.T) { func TestDaemonRetriesDeclareOutagesAndRemovesOnlyConsumedEphemeralRegistration(t *testing.T) {
defer func(delay time.Duration) { declareRetryDelay = delay }(declareRetryDelay) defer func(delay time.Duration) { declareRetryDelay = delay }(declareRetryDelay)
declareRetryDelay = 0 declareRetryDelay = 0
dir := t.TempDir()
regFile := filepath.Join(dir, "registration.json")
configFile := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(configFile, []byte("runner:\n file: "+regFile+"\n idle_cleanup_interval: 0s\ncache:\n enabled: false\n"), 0o600))
for _, tc := range []struct { for _, tc := range []struct {
name string name string
ephemeral, beforeTask bool ephemeral, beforeTask bool
@@ -159,6 +154,10 @@ func TestDaemonRetriesDeclareOutagesAndRemovesOnlyConsumedEphemeralRegistration(
connecthttp.Mount(mux, rpcServer, connecthttp.WithReadMaxBytes(0)) connecthttp.Mount(mux, rpcServer, connecthttp.WithReadMaxBytes(0))
server := httptest.NewServer(http.StripPrefix("/api/actions", mux)) server := httptest.NewServer(http.StripPrefix("/api/actions", mux))
defer server.Close() defer server.Close()
dir := t.TempDir()
regFile := filepath.Join(dir, "registration.json")
configFile := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(configFile, []byte("runner:\n file: "+regFile+"\n idle_cleanup_interval: 0s\ncache:\n enabled: false\n"), 0o600))
require.NoError(t, config.SaveRegistration(regFile, &config.Registration{Address: server.URL, UUID: "test", Name: "test", Token: "test", Labels: []string{"host:host"}, Ephemeral: tc.ephemeral})) require.NoError(t, config.SaveRegistration(regFile, &config.Registration{Address: server.URL, UUID: "test", Name: "test", Token: "test", Labels: []string{"host:host"}, Ephemeral: tc.ephemeral}))
err := runDaemon(ctx, &daemonArgs{Once: true}, &configFile)(nil, nil) err := runDaemon(ctx, &daemonArgs{Once: true}, &configFile)(nil, nil)
if tc.declareErr == nil && tc.fetchErr == nil || tc.beforeTask { if tc.declareErr == nil && tc.fetchErr == nil || tc.beforeTask {
+35 -27
View File
@@ -75,35 +75,40 @@ func TestExecuteArgsLoadInputs(t *testing.T) {
} }
func TestExecuteArgsEventJSON(t *testing.T) { func TestExecuteArgsEventJSON(t *testing.T) {
dir := t.TempDir() payload := func(content string) string {
path := filepath.Join(t.TempDir(), "event.json")
require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
return path
}
tests := []struct { tests := []struct {
name string name string
payload string eventpath string
inputs []string inputs []string
expected string expected string
}{ }{
{ {
name: "keeps a payload without inputs to merge", name: "keeps a payload without inputs to merge",
payload: `{"inputs": {"kept": "from_event"}}`, eventpath: payload(`{"inputs": {"kept": "from_event"}}`),
expected: `{"inputs": {"kept": "from_event"}}`, expected: `{"inputs": {"kept": "from_event"}}`,
}, },
{ {
name: "overrides the event inputs it names, keeping the rest", name: "overrides the event inputs it names, keeping the rest",
payload: `{"inputs": {"kept": "from_event", "named": "from_event"}}`, eventpath: payload(`{"inputs": {"kept": "from_event", "named": "from_event"}}`),
inputs: []string{"named=from_cli"}, inputs: []string{"named=from_cli"},
expected: `{"inputs": {"kept": "from_event", "named": "from_cli"}}`, expected: `{"inputs": {"kept": "from_event", "named": "from_cli"}}`,
}, },
{ {
name: "adds inputs to a payload carrying none", name: "adds inputs to a payload carrying none",
payload: `{"repository": {"name": "test-repo"}}`, eventpath: payload(`{"repository": {"name": "test-repo"}}`),
inputs: []string{"flag=val"}, inputs: []string{"flag=val"},
expected: `{"repository": {"name": "test-repo"}, "inputs": {"flag": "val"}}`, expected: `{"repository": {"name": "test-repo"}, "inputs": {"flag": "val"}}`,
}, },
{ {
name: "handles a null payload", name: "handles a null payload",
payload: "null", eventpath: payload("null"),
inputs: []string{"flag=val"}, inputs: []string{"flag=val"},
expected: `{"inputs": {"flag": "val"}}`, expected: `{"inputs": {"flag": "val"}}`,
}, },
{ {
name: "builds a payload without an event file", name: "builds a payload without an event file",
@@ -114,12 +119,7 @@ func TestExecuteArgsEventJSON(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
var eventpath string eventJSON, err := (&executeArgs{eventpath: tt.eventpath, inputs: tt.inputs}).eventJSON()
if tt.payload != "" {
eventpath = filepath.Join(dir, tt.name+".json")
require.NoError(t, os.WriteFile(eventpath, []byte(tt.payload), 0o600))
}
eventJSON, err := (&executeArgs{eventpath: eventpath, inputs: tt.inputs}).eventJSON()
require.NoError(t, err) require.NoError(t, err)
require.JSONEq(t, tt.expected, eventJSON) require.JSONEq(t, tt.expected, eventJSON)
}) })
@@ -275,13 +275,21 @@ func workflowForList(name, file, event, jobID, jobName string) *model.Workflow {
return &model.Workflow{ return &model.Workflow{
Name: name, Name: name,
File: file, File: file,
RawOn: yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: event}, RawOn: rawOnNode(event),
Jobs: map[string]*model.Job{ Jobs: map[string]*model.Job{
jobID: {Name: jobName}, jobID: {Name: jobName},
}, },
} }
} }
func rawOnNode(event string) yaml.Node {
var node yaml.Node
if err := yaml.Unmarshal([]byte(event), &node); err != nil {
panic(err)
}
return *node.Content[0]
}
func TestExecuteArgsLoadEnvsResultsOrigin(t *testing.T) { func TestExecuteArgsLoadEnvsResultsOrigin(t *testing.T) {
t.Setenv("ACTIONS_RESULTS_URL", "") // the default is only supplied when nothing else does t.Setenv("ACTIONS_RESULTS_URL", "") // the default is only supplied when nothing else does
handler, err := artifactcache.StartHandler(artifactcache.Options{Dir: t.TempDir(), OutboundIP: "127.0.0.1"}) handler, err := artifactcache.StartHandler(artifactcache.Options{Dir: t.TempDir(), OutboundIP: "127.0.0.1"})
+14 -3
View File
@@ -27,9 +27,16 @@ func TestRunPostTaskScriptSkippedWhenEmpty(t *testing.T) {
r := &Runner{ r := &Runner{
cfg: &config.Config{}, cfg: &config.Config{},
} }
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
taskCtx, err := structpb.NewStruct(map[string]any{})
require.NoError(t, err)
task := &runnerv1.Task{Id: 1, Context: taskCtx}
reporter := report.NewReporter(ctx, cancel, nil, task, r.cfg)
require.NotPanics(t, func() { require.NotPanics(t, func() {
r.runPostTaskScript(t.Context(), nil, nil, "/workspace/owner/repo") r.runPostTaskScript(ctx, reporter, task, "/workspace/owner/repo")
}) })
} }
@@ -52,7 +59,9 @@ func TestRunPostTaskScriptNonZeroExitDoesNotPanic(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context()) ctx, cancel := context.WithCancel(t.Context())
defer cancel() defer cancel()
task := &runnerv1.Task{Id: 1, Context: &structpb.Struct{}} taskCtx, err := structpb.NewStruct(map[string]any{})
require.NoError(t, err)
task := &runnerv1.Task{Id: 1, Context: taskCtx}
reporter := report.NewReporter(ctx, cancel, nil, task, cfg) reporter := report.NewReporter(ctx, cancel, nil, task, cfg)
require.NotPanics(t, func() { require.NotPanics(t, func() {
@@ -137,7 +146,9 @@ func TestRunPostTaskScriptIntegration(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context()) ctx, cancel := context.WithCancel(t.Context())
defer cancel() defer cancel()
task := &runnerv1.Task{Id: 11, Context: &structpb.Struct{}} taskCtx, err := structpb.NewStruct(map[string]any{})
require.NoError(t, err)
task := &runnerv1.Task{Id: 11, Context: taskCtx}
reporter := report.NewReporter(ctx, cancel, nil, task, cfg) reporter := report.NewReporter(ctx, cancel, nil, task, cfg)
setReporterJobResult(t, reporter, runnerv1.Result_RESULT_SUCCESS) setReporterJobResult(t, reporter, runnerv1.Result_RESULT_SUCCESS)
+6 -3
View File
@@ -21,7 +21,8 @@ import (
func TestRunnerCleanupStaleTaskDirs(t *testing.T) { func TestRunnerCleanupStaleTaskDirs(t *testing.T) {
now := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC) now := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC)
workdirRoot := t.TempDir() workdirRoot := filepath.Join(t.TempDir(), "workspace")
require.NoError(t, os.MkdirAll(workdirRoot, 0o700))
oldTask := filepath.Join(workdirRoot, "1001") oldTask := filepath.Join(workdirRoot, "1001")
freshTask := filepath.Join(workdirRoot, "1002") freshTask := filepath.Join(workdirRoot, "1002")
@@ -58,7 +59,8 @@ func TestRunnerCleanupStaleTaskDirs(t *testing.T) {
// be reclaimed, while the shared tool_cache and operator data are preserved. // be reclaimed, while the shared tool_cache and operator data are preserved.
func TestRunnerOnIdleCleansStaleHostScratchDirs(t *testing.T) { func TestRunnerOnIdleCleansStaleHostScratchDirs(t *testing.T) {
now := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC) now := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC)
hostRoot := t.TempDir() hostRoot := filepath.Join(t.TempDir(), "act")
require.NoError(t, os.MkdirAll(hostRoot, 0o700))
staleScratch := filepath.Join(hostRoot, "0123456789abcdef") // 16 hex staleScratch := filepath.Join(hostRoot, "0123456789abcdef") // 16 hex
freshScratch := filepath.Join(hostRoot, "fedcba9876543210") freshScratch := filepath.Join(hostRoot, "fedcba9876543210")
@@ -116,7 +118,8 @@ func TestRunnerCleanupStaleTaskDirsMissingRoot(t *testing.T) {
func TestRunnerCleanupStaleTaskDirsHonorsContext(t *testing.T) { func TestRunnerCleanupStaleTaskDirsHonorsContext(t *testing.T) {
now := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC) now := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC)
workdirRoot := t.TempDir() workdirRoot := filepath.Join(t.TempDir(), "workspace")
require.NoError(t, os.MkdirAll(workdirRoot, 0o700))
for i := 1001; i <= 1003; i++ { for i := 1001; i <= 1003; i++ {
dir := filepath.Join(workdirRoot, strconv.Itoa(i)) dir := filepath.Join(workdirRoot, strconv.Itoa(i))
+1 -2
View File
@@ -96,7 +96,6 @@ func TestSetupLinesInputs(t *testing.T) {
} }
func TestPrettyOSName(t *testing.T) { func TestPrettyOSName(t *testing.T) {
dir := t.TempDir()
for _, tt := range []struct { for _, tt := range []struct {
name string name string
osRelease string osRelease string
@@ -110,7 +109,7 @@ func TestPrettyOSName(t *testing.T) {
{"missing file", "", "", true}, {"missing file", "", "", true},
} { } {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
path := filepath.Join(dir, tt.name) path := filepath.Join(t.TempDir(), "os-release")
if !tt.missing { if !tt.missing {
require.NoError(t, os.WriteFile(path, []byte(tt.osRelease), 0o600)) require.NoError(t, os.WriteFile(path, []byte(tt.osRelease), 0o600))
} }
+11 -32
View File
@@ -59,7 +59,7 @@ func TestCheckout(t *testing.T) {
t.Run("history", func(t *testing.T) { t.Run("history", func(t *testing.T) {
t.Parallel() t.Parallel()
work := t.TempDir() work := t.TempDir()
runGit(t, "init", "--template=", "--initial-branch=main", work) runGit(t, "init", "--initial-branch=main", work)
runGit(t, "-C", work, "commit", "--allow-empty", "-m", "c1") runGit(t, "-C", work, "commit", "--allow-empty", "-m", "c1")
sha = runGit(t, "-C", work, "rev-parse", "HEAD") sha = runGit(t, "-C", work, "rev-parse", "HEAD")
runGit(t, "-C", work, "tag", "v1") runGit(t, "-C", work, "tag", "v1")
@@ -77,7 +77,7 @@ func TestCheckout(t *testing.T) {
t.Run("sha256", func(t *testing.T) { t.Run("sha256", func(t *testing.T) {
t.Parallel() t.Parallel()
sha256Repo := filepath.Join(server, "org", "sha256") sha256Repo := filepath.Join(server, "org", "sha256")
runGit(t, "init", "--template=", "--object-format=sha256", "--initial-branch=main", sha256Repo) runGit(t, "init", "--object-format=sha256", "--initial-branch=main", sha256Repo)
runGit(t, "-C", sha256Repo, "commit", "--allow-empty", "-m", "s1") runGit(t, "-C", sha256Repo, "commit", "--allow-empty", "-m", "s1")
runGit(t, "-C", sha256Repo, "checkout", "--detach") runGit(t, "-C", sha256Repo, "checkout", "--detach")
}) })
@@ -267,41 +267,21 @@ exec "$LFS" "$@"
assert.Contains(t, string(outputs), "ref="+cmp.Or(tc.eventRef, "refs/heads/main")+"\n") assert.Contains(t, string(outputs), "ref="+cmp.Or(tc.eventRef, "refs/heads/main")+"\n")
return return
} }
headArgs := []string{"-C", dst, "rev-parse", "HEAD", "--symbolic-full-name", "HEAD"}
if tc.head != "" {
headArgs = append(headArgs, "--abbrev-ref", "@{u}")
}
head := strings.Fields(runGit(t, headArgs...))
if tc.head == "" { if tc.head == "" {
require.Len(t, head, 2) assert.Empty(t, gitOutput(dst, "symbolic-ref", "-q", "HEAD"))
assert.Equal(t, "HEAD", head[1])
} else { } else {
require.Len(t, head, 3) assert.Equal(t, "refs/heads/"+tc.head, gitOutput(dst, "symbolic-ref", "-q", "HEAD"))
assert.Equal(t, "refs/heads/"+tc.head, head[1]) assert.Equal(t, "origin/"+tc.head, gitOutput(dst, "rev-parse", "--abbrev-ref", "@{u}"))
assert.Equal(t, "origin/"+tc.head, head[2])
} }
assert.Contains(t, string(outputs), "commit="+head[0]+"\n") assert.Contains(t, string(outputs), "commit="+gitOutput(dst, "rev-parse", "HEAD")+"\n")
if inputRef := tc.inputs["ref"]; inputRef != "" && inputRef != sha { if inputRef := tc.inputs["ref"]; inputRef != "" && inputRef != sha {
assert.Contains(t, string(outputs), "ref="+inputRef+"\n") assert.Contains(t, string(outputs), "ref="+inputRef+"\n")
} }
assert.Equal(t, tc.inputs["sparse-checkout"] != "", gitOutput(dst, "config", "--local", "extensions.worktreeConfig") == "true")
persisted := tc.inputs["persist-credentials"] != "false" persisted := tc.inputs["persist-credentials"] != "false"
for _, repo := range repos { for _, repo := range repos {
var credentialIncludes []string
var worktreeConfig string
for entry := range strings.SplitSeq(runGit(t, "-C", repo, "config", "--local", "--no-includes", "--null", "--list"), "\x00") {
key, value, _ := strings.Cut(entry, "\n")
switch key {
case "extensions.worktreeconfig":
worktreeConfig = value
case strings.ToLower(credentialsInclude):
credentialIncludes = append(credentialIncludes, value)
}
}
if repo == dst {
assert.Equal(t, tc.inputs["sparse-checkout"] != "", strings.TrimSpace(worktreeConfig) == "true")
}
assert.Equal(t, tc.inputs["clean"] == "false", fileExists(filepath.Join(repo, "untracked")), repo) assert.Equal(t, tc.inputs["clean"] == "false", fileExists(filepath.Join(repo, "untracked")), repo)
assert.Len(t, strings.Fields(strings.Join(credentialIncludes, "\n")), 1, repo) assert.Len(t, strings.Fields(gitOutput(repo, "config", "--local", "--get-all", credentialsInclude)), 1, repo)
headers, _ := exec.Command("git", "-C", repo, "config", "--get-all", "http."+serverURL+"/.extraheader").Output() headers, _ := exec.Command("git", "-C", repo, "config", "--get-all", "http."+serverURL+"/.extraheader").Output()
assert.Equal(t, persisted, string(headers) == "\nAuthorization: Basic dG9rZW46dG9r\n", repo) assert.Equal(t, persisted, string(headers) == "\nAuthorization: Basic dG9rZW46dG9r\n", repo)
assert.Equal(t, persisted, gitOutput(repo, "config", "http."+serverURL+"/.sslcainfo") != "", repo) assert.Equal(t, persisted, gitOutput(repo, "config", "http."+serverURL+"/.sslcainfo") != "", repo)
@@ -365,16 +345,15 @@ func TestCheckoutTrustsTheRunnerCAAndOffersClientCertOnlyDuringTheStep(t *testin
func publish(t *testing.T, server, name string, files, submodules map[string]string) { func publish(t *testing.T, server, name string, files, submodules map[string]string) {
work := t.TempDir() work := t.TempDir()
runGit(t, "init", "--template=", "--initial-branch=main", work) runGit(t, "init", "--initial-branch=main", work)
for file, content := range files { for file, content := range files {
require.NoError(t, os.MkdirAll(filepath.Dir(filepath.Join(work, file)), 0o755)) require.NoError(t, os.MkdirAll(filepath.Dir(filepath.Join(work, file)), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(work, file), []byte(content), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(work, file), []byte(content), 0o644))
} }
runGit(t, "-C", work, "add", ".") runGit(t, "-C", work, "add", ".")
var moduleConfig []byte
for path, url := range submodules { for path, url := range submodules {
moduleConfig = fmt.Appendf(moduleConfig, "[submodule %q]\n\tpath = %q\n\turl = %q\n", path, path, url) runGit(t, "-C", work, "config", "-f", ".gitmodules", "submodule."+path+".path", path)
require.NoError(t, os.WriteFile(filepath.Join(work, ".gitmodules"), moduleConfig, 0o644)) runGit(t, "-C", work, "config", "-f", ".gitmodules", "submodule."+path+".url", url)
commit := runGit(t, "--git-dir", filepath.Join(server, name, url), "rev-parse", "main") commit := runGit(t, "--git-dir", filepath.Join(server, name, url), "rev-parse", "main")
runGit(t, "-C", work, "update-index", "--add", "--cacheinfo", "160000,"+commit+","+path) runGit(t, "-C", work, "update-index", "--add", "--cacheinfo", "160000,"+commit+","+path)
runGit(t, "-C", work, "add", ".gitmodules") runGit(t, "-C", work, "add", ".gitmodules")
+10 -5
View File
@@ -6,6 +6,7 @@ package process
import ( import (
"os/exec" "os/exec"
"testing" "testing"
"time"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -22,13 +23,16 @@ func TestGroupClosePropagatesToOrphan(t *testing.T) {
childPID := ready() childPID := ready()
require.NoError(t, cmd.Wait()) require.NoError(t, cmd.Wait())
require.True(t, cmd.ProcessState.Exited(), "the step process should have exited on its own") require.Eventually(t, func() bool {
return !processAlive(cmd.Process.Pid)
}, 20*time.Second, 200*time.Millisecond, "the step process should have exited on its own")
require.True(t, processAlive(childPID), "orphan should outlive its parent") require.True(t, processAlive(childPID), "orphan should outlive its parent")
waitForExit := newProcessExitWaiter(t, childPID)
require.NoError(t, group.Close()) require.NoError(t, group.Close())
waitForExit() require.Eventually(t, func() bool {
return !processAlive(childPID)
}, 20*time.Second, 200*time.Millisecond, "closing the job must terminate the orphan")
} }
// TestGroupNestsWithKiller: assigned to the group first and the step's Killer // TestGroupNestsWithKiller: assigned to the group first and the step's Killer
@@ -49,9 +53,10 @@ func TestGroupNestsWithKiller(t *testing.T) {
require.NoError(t, err, "the step's job object must nest inside the group's") require.NoError(t, err, "the step's job object must nest inside the group's")
defer killer.Close() defer killer.Close()
waitForExit := newProcessExitWaiter(t, cmd.Process.Pid)
require.NoError(t, killer.Kill()) require.NoError(t, killer.Kill())
waitForExit() require.Eventually(t, func() bool {
return !processAlive(cmd.Process.Pid)
}, 20*time.Second, 200*time.Millisecond, "cancelling the step should still kill its tree")
} }
// TestGroupNilSafe covers the fallback path: when the job object cannot be // TestGroupNilSafe covers the fallback path: when the job object cannot be
+35 -3
View File
@@ -9,6 +9,8 @@ import (
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"strconv"
"strings" "strings"
"syscall" "syscall"
"testing" "testing"
@@ -44,8 +46,23 @@ func processAlive(pid int) bool {
return true return true
} }
// TestKillerKillsTree verifies that a process group captured by the killer is
// terminated together with a child the process spawns afterwards. This mirrors
// a step or post-task script that launches a child which spawns further
// processes, where cancelling must take down the whole tree, not just the
// direct child.
func TestKillerKillsTree(t *testing.T) { func TestKillerKillsTree(t *testing.T) {
cmd, ready := newTestProcess(t, "parent", false) dir := t.TempDir()
pidFile := filepath.Join(dir, "child.pid")
// Parent shell backgrounds a long-lived child (writing its PID to a file)
// and then sleeps. With job control off (non-interactive sh) the backgrounded
// child stays in the parent's process group, so the group kill must reach it.
script := fmt.Sprintf(`sleep 600 & echo $! > %q; sleep 600`, pidFile)
cmd := exec.Command("/bin/sh", "-c", script)
// Launch as its own process-group leader, exactly like a real process does
// (see SysProcAttr), so the killer's PGID == the process PID.
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
require.NoError(t, cmd.Start()) require.NoError(t, cmd.Start())
t.Cleanup(func() { t.Cleanup(func() {
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
@@ -56,10 +73,25 @@ func TestKillerKillsTree(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
defer killer.Close() defer killer.Close()
childPID := ready() // Wait for the backgrounded child PID to be reported.
require.True(t, processAlive(childPID), "child process should start") var childPID int
require.Eventually(t, func() bool {
b, e := os.ReadFile(pidFile)
if e != nil {
return false
}
s := strings.TrimSpace(string(b))
if s == "" {
return false
}
childPID, _ = strconv.Atoi(s)
return childPID > 0 && processAlive(childPID)
}, 20*time.Second, 100*time.Millisecond, "child process should start")
// Killing the group must terminate both the parent and the backgrounded child.
require.NoError(t, killer.Kill()) require.NoError(t, killer.Kill())
// Reap the parent so it does not linger as a zombie (which would still report
// as alive); SIGKILL makes Wait return promptly.
_ = cmd.Wait() _ = cmd.Wait()
require.Eventually(t, func() bool { require.Eventually(t, func() bool {
+5 -19
View File
@@ -5,6 +5,7 @@ package process
import ( import (
"testing" "testing"
"time"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"golang.org/x/sys/windows" "golang.org/x/sys/windows"
@@ -24,23 +25,6 @@ func processAlive(pid int) bool {
return code == stillActive return code == stillActive
} }
func newProcessExitWaiter(t *testing.T, pids ...int) func() {
t.Helper()
handles := make([]windows.Handle, 0, len(pids))
for _, pid := range pids {
handle, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid))
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, windows.CloseHandle(handle)) })
handles = append(handles, handle)
}
return func() {
t.Helper()
result, err := windows.WaitForMultipleObjects(handles, true, 20000)
require.NoError(t, err)
require.EqualValues(t, windows.WAIT_OBJECT_0, result, "processes should exit: %v", pids)
}
}
func TestKillerKillsTree(t *testing.T) { func TestKillerKillsTree(t *testing.T) {
cmd, ready := newTestProcess(t, "parent", false) cmd, ready := newTestProcess(t, "parent", false)
require.NoError(t, cmd.Start()) require.NoError(t, cmd.Start())
@@ -55,7 +39,9 @@ func TestKillerKillsTree(t *testing.T) {
childPID := ready() childPID := ready()
require.True(t, processAlive(childPID), "child process should start") require.True(t, processAlive(childPID), "child process should start")
waitForExit := newProcessExitWaiter(t, cmd.Process.Pid, childPID)
require.NoError(t, killer.Kill()) require.NoError(t, killer.Kill())
waitForExit()
require.Eventually(t, func() bool {
return !processAlive(cmd.Process.Pid) && !processAlive(childPID)
}, 20*time.Second, 200*time.Millisecond, "parent and child should both be terminated")
} }
+1 -1
View File
@@ -960,7 +960,7 @@ func logRow(entry logging.Record) *runnerv1.LogRow {
// mask repairs the content first, so a secret the repair itself spells out is still caught. // mask repairs the content first, so a secret the repair itself spells out is still caught.
func (r *Reporter) mask(content string) string { func (r *Reporter) mask(content string) string {
return r.logReplacer.Replace(strings.ToValidUTF8(content, "?")) return runner.MaskLog(strings.ToValidUTF8(content, "?"), r.logReplacer)
} }
// AddMask deliberately leaves the process-wide masker alone. Its entries come from every live // AddMask deliberately leaves the process-wide masker alone. Its entries come from every live
+36 -10
View File
@@ -173,9 +173,11 @@ func TestReporter_RunDaemonClose_Race(t *testing.T) {
) )
ctx, cancel := context.WithCancel(t.Context()) ctx, cancel := context.WithCancel(t.Context())
taskCtx, err := structpb.NewStruct(map[string]any{})
require.NoError(t, err)
cfg, _ := config.LoadDefault("") cfg, _ := config.LoadDefault("")
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{ reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{
Context: &structpb.Struct{}, Context: taskCtx,
}, cfg) }, cfg)
reporter.ResetSteps(newTestSteps(1)) reporter.ResetSteps(newTestSteps(1))
@@ -472,8 +474,10 @@ func TestReporter_ServerCancelStillFlushesFinal(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context()) ctx, cancel := context.WithCancel(t.Context())
defer cancel() defer cancel()
taskCtx, err := structpb.NewStruct(map[string]any{})
require.NoError(t, err)
cfg, _ := config.LoadDefault("") cfg, _ := config.LoadDefault("")
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: &structpb.Struct{}}, cfg) reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
reporter.ResetSteps(newTestSteps(1)) reporter.ResetSteps(newTestSteps(1))
reporter.stateMu.Lock() reporter.stateMu.Lock()
@@ -523,8 +527,10 @@ func TestReporter_CloseReportsCancelledOnCanceledCtx(t *testing.T) {
) )
ctx, cancel := context.WithCancel(t.Context()) ctx, cancel := context.WithCancel(t.Context())
taskCtx, err := structpb.NewStruct(map[string]any{})
require.NoError(t, err)
cfg, _ := config.LoadDefault("") cfg, _ := config.LoadDefault("")
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: &structpb.Struct{}}, cfg) reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
reporter.ResetSteps(newTestSteps(1)) reporter.ResetSteps(newTestSteps(1))
cancel() cancel()
@@ -665,15 +671,19 @@ func TestReporter_SetOutputs(t *testing.T) {
require.True(t, ok) require.True(t, ok)
assert.Equal(t, "caf\xff", got.value) assert.Equal(t, "caf\xff", got.value)
r.SetOutputs(map[string]string{"atlimit": longValue[:maxOutputValueLen]}) maxValue := strings.Repeat("v", maxOutputValueLen)
r.SetOutputs(map[string]string{"atlimit": maxValue})
got, ok = r.outputs["atlimit"] got, ok = r.outputs["atlimit"]
require.True(t, ok) require.True(t, ok)
assert.Len(t, got.value, maxOutputValueLen) assert.Len(t, got.value, maxOutputValueLen)
} }
func TestReporter_DebugSettingsAreNotMasked(t *testing.T) { func TestReporter_DebugSettingsAreNotMasked(t *testing.T) {
taskCtx, err := structpb.NewStruct(map[string]any{})
require.NoError(t, err)
reporter := NewReporter(t.Context(), nil, nil, &runnerv1.Task{ reporter := NewReporter(t.Context(), nil, nil, &runnerv1.Task{
Context: &structpb.Struct{}, Context: taskCtx,
Secrets: map[string]string{"ACTIONS_STEP_DEBUG": "true", "ACTIONS_RUNNER_DEBUG": "true", "TOKEN": "s3cr3t"}, Secrets: map[string]string{"ACTIONS_STEP_DEBUG": "true", "ACTIONS_RUNNER_DEBUG": "true", "TOKEN": "s3cr3t"},
}, &config.Config{}) }, &config.Config{})
defer deregisterGlobalMasks(reporter) defer deregisterGlobalMasks(reporter)
@@ -750,8 +760,7 @@ func TestReporter_ParseResult(t *testing.T) {
func TestReporter_masksEncodedSecrets(t *testing.T) { func TestReporter_masksEncodedSecrets(t *testing.T) {
secret := `p@ss w"rd/1` secret := `p@ss w"rd/1`
r := &Reporter{logReplacer: strings.NewReplacer()} r := &Reporter{logReplacer: strings.NewReplacer()}
r.oldnew = runner.AppendSecretMasker(r.oldnew, secret) r.AddMask(secret)
r.logReplacer = strings.NewReplacer(r.oldnew...)
for _, line := range []string{ for _, line := range []string{
"token: " + secret, "token: " + secret,
@@ -763,6 +772,17 @@ func TestReporter_masksEncodedSecrets(t *testing.T) {
assert.NotContains(t, masked, secret) assert.NotContains(t, masked, secret)
assert.NotContains(t, masked, base64.StdEncoding.EncodeToString([]byte(secret))) assert.NotContains(t, masked, base64.StdEncoding.EncodeToString([]byte(secret)))
} }
r.AddMask("alpha beta;gamma]delta")
r.AddMask("%")
for _, test := range []struct{ line, want string }{
{"##[group]alpha beta%3Bgamma%5Ddelta", "##[group]***"},
{"##[group]alpha beta%3Bgamma]delta", "##[group]***"},
{"%", "***"},
} {
r.appendLogRow(logRow(logging.Record{Message: test.line}))
assert.Equal(t, test.want, r.logRows[len(r.logRows)-1].Content)
}
} }
func TestReporter_AckIndexBeyondBuffer(t *testing.T) { func TestReporter_AckIndexBeyondBuffer(t *testing.T) {
@@ -851,8 +871,10 @@ func TestReporter_ServerFailureResultIsReportedAsFailure(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context()) ctx, cancel := context.WithCancel(t.Context())
defer cancel() defer cancel()
taskCtx, err := structpb.NewStruct(map[string]any{})
require.NoError(t, err)
cfg, _ := config.LoadDefault("") cfg, _ := config.LoadDefault("")
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: &structpb.Struct{}}, cfg) reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
close(reporter.daemon) close(reporter.daemon)
require.NoError(t, reporter.ReportState(false)) require.NoError(t, reporter.ReportState(false))
@@ -1019,7 +1041,9 @@ func newTestReporter(t *testing.T, client *mocks.Client, cfg *config.Config) *Re
t.Helper() t.Helper()
ctx, cancel := context.WithCancel(t.Context()) ctx, cancel := context.WithCancel(t.Context())
t.Cleanup(cancel) t.Cleanup(cancel)
return NewReporter(ctx, cancel, client, &runnerv1.Task{Context: &structpb.Struct{}}, cfg) taskCtx, err := structpb.NewStruct(map[string]any{})
require.NoError(t, err)
return NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
} }
func newTestSteps(n int) []*model.Step { func newTestSteps(n int) []*model.Step {
@@ -1037,9 +1061,11 @@ func TestReporter_ReportedSteps(t *testing.T) {
return &runnerv1.UpdateLogResponse{AckIndex: req.Index + int64(len(req.Rows))}, nil return &runnerv1.UpdateLogResponse{AckIndex: req.Index + int64(len(req.Rows))}, nil
}) })
client.On("UpdateTask", mock.Anything, mock.Anything).Maybe().Return(&runnerv1.UpdateTaskResponse{}, nil) client.On("UpdateTask", mock.Anything, mock.Anything).Maybe().Return(&runnerv1.UpdateTaskResponse{}, nil)
taskCtx, err := structpb.NewStruct(map[string]any{})
require.NoError(t, err)
cfg, _ := config.LoadDefault("") cfg, _ := config.LoadDefault("")
ctx, cancel := context.WithCancel(t.Context()) ctx, cancel := context.WithCancel(t.Context())
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: &structpb.Struct{}, Secrets: map[string]string{"TOKEN": "s3cr3t"}}, cfg) reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx, Secrets: map[string]string{"TOKEN": "s3cr3t"}}, cfg)
reporter.RunDaemon() reporter.RunDaemon()
reporter.Logf("runner version") reporter.Logf("runner version")
reporter.ResetSteps(steps) reporter.ResetSteps(steps)
+1 -2
View File
@@ -117,8 +117,7 @@ func TestExportRetriesAndMatchesOTLP(t *testing.T) {
certificate := server.TLS.Certificates[0] certificate := server.TLS.Certificates[0]
key, err := x509.MarshalPKCS8PrivateKey(certificate.PrivateKey) key, err := x509.MarshalPKCS8PrivateKey(certificate.PrivateKey)
require.NoError(t, err) require.NoError(t, err)
dir := t.TempDir() certificatePath, keyPath := filepath.Join(t.TempDir(), "cert.pem"), filepath.Join(t.TempDir(), "key.pem")
certificatePath, keyPath := filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")
require.NoError(t, os.WriteFile(certificatePath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certificate.Certificate[0]}), 0o600)) require.NoError(t, os.WriteFile(certificatePath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certificate.Certificate[0]}), 0o600))
require.NoError(t, os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: key}), 0o600)) require.NoError(t, os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: key}), 0o600))
setEnv(t, map[string]string{ setEnv(t, map[string]string{