Files
runner/internal/pkg/action/downloadartifact/download_test.go
T
2026-11-06 03:32:59 +00:00

317 lines
12 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package downloadartifact
import (
"archive/zip"
"bytes"
"context"
"errors"
"fmt"
"io"
"maps"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strconv"
"sync"
"sync/atomic"
"testing"
"testing/synctest"
"time"
"gitea.com/gitea/runner/act/artifacts"
"gitea.com/gitea/runner/act/container"
"gitea.com/gitea/runner/internal/pkg/action"
"gitea.com/gitea/runner/internal/pkg/action/artifact"
"gitea.dev/actionslib/pkg/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type zipEntry struct {
name, body string
mode os.FileMode
}
func zipOf(t *testing.T, entries ...zipEntry) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for _, e := range entries {
header := &zip.FileHeader{Name: e.name, Method: zip.Deflate}
header.SetMode(e.mode)
w, err := zw.CreateHeader(header)
require.NoError(t, err)
_, err = io.WriteString(w, e.body)
require.NoError(t, err)
}
require.NoError(t, zw.Close())
return buf.Bytes()
}
type harness struct {
t *testing.T
server, store string
client *artifact.Client
workspace string
env map[string]string
mu sync.Mutex
logs, annotation []string
}
func newHarness(t *testing.T) *harness {
t.Helper()
store := t.TempDir()
router := http.NewServeMux()
artifacts.RegisterV4(router, store)
server := httptest.NewServer(router)
t.Cleanup(server.Close)
client, err := artifact.New(server.URL, artifacts.LocalRuntimeToken("7", time.Hour), nil)
require.NoError(t, err)
return &harness{t: t, server: server.URL, store: store, client: client, workspace: t.TempDir()}
}
func (h *harness) upload(name, mimeType string, content []byte) int64 {
h.t.Helper()
ctx := h.t.Context()
signed, err := h.client.Create(ctx, name, mimeType, nil)
require.NoError(h.t, err)
uploaded, err := h.client.Upload(ctx, signed, mimeType, bytes.NewReader(content), 2, time.Minute)
require.NoError(h.t, err)
id, err := h.client.Finalize(ctx, name, uploaded.Size, uploaded.Digest)
require.NoError(h.t, err)
return id
}
func (h *harness) run(inputs map[string]string) error {
h.logs, h.annotation = nil, nil
env := map[string]string{"GITHUB_OUTPUT": filepath.Join(h.workspace, "output")}
maps.Copy(env, h.env)
return Main(h.t.Context(), &action.Context{
Container: &container.HostEnvironment{},
Github: &model.GithubContext{Repository: "owner/repo", RunID: "7"},
Inputs: inputs,
Workspace: h.workspace,
Env: env,
ResultsURL: h.server,
RuntimeToken: artifacts.LocalRuntimeToken("7", time.Hour),
Infof: func(format string, args ...any) {
h.mu.Lock()
defer h.mu.Unlock()
h.logs = append(h.logs, fmt.Sprintf(format, args...))
},
Warning: func(format string, args ...any) {
h.mu.Lock()
defer h.mu.Unlock()
h.annotation = append(h.annotation, "warning: "+fmt.Sprintf(format, args...))
},
})
}
func (h *harness) read(rel string) string {
h.t.Helper()
content, err := os.ReadFile(filepath.Join(h.workspace, filepath.FromSlash(rel)))
require.NoError(h.t, err)
return string(content)
}
func TestDownload(t *testing.T) {
h := newHarness(t)
first := h.upload("alpha", "", zipOf(t, zipEntry{name: "a.txt", body: "a1"}))
_, err := h.client.Delete(t.Context(), "alpha")
require.NoError(t, err)
archive := zipOf(t,
zipEntry{name: "run.sh", body: "#!/bin/sh\n", mode: 0o755},
zipEntry{name: "empty/", mode: os.ModeDir | 0o755},
zipEntry{name: "nested/../a.txt", body: "a2"},
zipEntry{name: "empty.txt"},
)
second := h.upload("alpha", "", archive)
h.upload("beta ü.txt", "text/plain", []byte("plain text"))
h.upload(".hidden", "", zipOf(t, zipEntry{name: "h.txt", body: "h"}))
for _, test := range []struct {
inputs map[string]string
files map[string]string
}{
{map[string]string{"name": "alpha", "path": "named"}, map[string]string{"named/a.txt": "a2", "named/run.sh": "#!/bin/sh\n", "named/empty.txt": ""}},
{map[string]string{"path": "all"}, map[string]string{"all/alpha/a.txt": "a2", "all/beta ü.txt/beta ü.txt": "plain text", "all/.hidden/h.txt": "h"}},
{map[string]string{"path": "merged", "merge-multiple": "true"}, map[string]string{"merged/a.txt": "a2", "merged/beta ü.txt": "plain text", "merged/h.txt": "h"}},
{map[string]string{"path": "pattern", "pattern": "*"}, map[string]string{"pattern/alpha/a.txt": "a2", "pattern/beta ü.txt/beta ü.txt": "plain text"}},
{map[string]string{"path": "ids", "artifact-ids": fmt.Sprintf("%dtrailing, %d", second, first), "pattern": "ignored"}, map[string]string{"ids/a.txt": "a2"}},
{map[string]string{"name": "alpha", "path": "kept", "skip-decompress": "true"}, map[string]string{"kept/alpha.zip": string(archive)}},
{map[string]string{"path": "none", "pattern": "nope-*", "repository": "ignored", "run-id": "invalid"}, nil},
} {
require.NoError(t, h.run(test.inputs))
assert.Contains(t, h.read("output"), "download-path="+filepath.Join(h.workspace, test.inputs["path"])+"\n")
for name, body := range test.files {
assert.Equal(t, body, h.read(name))
}
}
assert.Contains(t, h.logs, "Total of 0 artifact(s) downloaded")
assert.NoDirExists(t, filepath.Join(h.workspace, "pattern", ".hidden"))
assert.DirExists(t, filepath.Join(h.workspace, "named", "empty"))
if runtime.GOOS != "windows" {
info, err := os.Stat(filepath.Join(h.workspace, "named", "run.sh"))
require.NoError(t, err)
assert.Equal(t, os.FileMode(0o644), info.Mode())
assert.WithinDuration(t, time.Now(), info.ModTime(), time.Minute)
}
for _, test := range []struct {
inputs map[string]string
error string
}{
{map[string]string{"artifact-ids": strconv.FormatInt(first, 10)}, "None of the provided artifact IDs were found"},
{map[string]string{"name": "gone"}, "Artifact not found for name: gone"},
{map[string]string{"name": "alpha", "artifact-ids": "1"}, "cannot be used together"},
{map[string]string{"artifact-ids": "x"}, "Invalid artifact ID: 'x'"},
{map[string]string{"artifact-ids": " , "}, "No valid artifact IDs provided"},
{map[string]string{"digest-mismatch": "loud"}, "Valid options are: ignore, info, warn, error"},
{map[string]string{"merge-multiple": "yes"}, "Core Schema"},
} {
require.ErrorContains(t, h.run(test.inputs), test.error)
}
h.env = map[string]string{"GITHUB_WORKSPACE": filepath.Join(h.workspace, "overridden")}
require.NoError(t, h.run(map[string]string{"name": "beta ü.txt"}))
assert.Equal(t, "plain text", h.read("overridden/beta ü.txt"))
h.env = map[string]string{"HOME": filepath.Join(h.workspace, "home")}
require.NoError(t, h.run(map[string]string{"name": "beta ü.txt", "path": "~/tilde", "retention-days": "1"}))
assert.Equal(t, "plain text", h.read("home/tilde/beta ü.txt"))
assert.Equal(t, []string{"warning: Unexpected input(s) 'retention-days', valid inputs are ['name', 'artifact-ids', 'path', 'pattern', 'merge-multiple', 'github-token', 'repository', 'run-id', 'skip-decompress', 'digest-mismatch']"}, h.annotation)
h.upload("..", "", archive)
require.ErrorContains(t, h.run(map[string]string{}), "Artifact name is not valid: ..")
}
func TestDownloadFormats(t *testing.T) {
for _, test := range []struct {
contentType, url string
zip bool
}{
{"application/zip", "raw", true},
{"Application/X-Zip-Compressed; charset=binary", "raw", true},
{"application/zip-compressed", "raw", true},
{"", "FILE.ZIP?sig=x", true},
{"", "raw?name=file.zip", false},
} {
assert.Equal(t, test.zip, isZip(&http.Response{Header: http.Header{"Content-Type": {test.contentType}}}, "https://example.test/"+test.url))
}
for _, test := range []struct {
pattern, name string
want bool
}{
{"a*", "alpha", true},
{"*", ".hidden", false},
{"!alpha", "beta", true},
{"#alpha", "alpha", false},
} {
assert.Equal(t, test.want, matchName(test.pattern, test.name))
}
}
func TestDownloadDigestMismatch(t *testing.T) {
h := newHarness(t)
h.upload("art", "", zipOf(t, zipEntry{name: "f.txt", body: "original"}))
blobs, err := filepath.Glob(filepath.Join(h.store, "v4", "*", "*", "blob"))
require.NoError(t, err)
require.Len(t, blobs, 1)
tampered := zipOf(t, zipEntry{name: "f.txt", body: "tampered"})
require.NoError(t, os.WriteFile(blobs[0], tampered, 0o644))
for _, behavior := range []string{"", "ignore", "info", "warn", "error"} {
err = h.run(map[string]string{"name": "art", "path": "digest", "digest-mismatch": behavior})
if behavior == "" || behavior == "error" {
require.ErrorContains(t, err, "Digest validation failed for artifact(s): art. Use 'digest-mismatch: warn' to continue on mismatch.")
} else {
require.NoError(t, err)
}
assert.Equal(t, "tampered", h.read("digest/f.txt"))
if behavior == "warn" {
assert.Equal(t, []string{"warning: Artifact 'art' digest validation failed. Please verify the integrity of the artifact."}, h.annotation)
} else {
assert.Empty(t, h.annotation)
}
if behavior == "info" {
assert.Contains(t, h.logs, "Artifact 'art' digest validation failed. Please verify the integrity of the artifact.")
}
}
}
func TestDownloadFromAnotherRun(t *testing.T) {
h := &harness{t: t, workspace: t.TempDir()}
archive := zipOf(t, zipEntry{name: "other.txt", body: "from run 12"})
var expired atomic.Bool
var zipCalls atomic.Int32
blob := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Empty(t, r.Header.Get("Authorization"))
w.Header().Set("Content-Type", "application/zip")
_, _ = w.Write(archive)
}))
defer blob.Close()
gitea := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/repos/someone/else/actions/runs/12/artifacts":
assert.Equal(t, "token pat", r.Header.Get("Authorization"))
_, _ = io.WriteString(w, `{"total_count":3,"artifacts":[
{"id":10,"name":"dist","size_in_bytes":1,"expired":false},
{"id":30,"name":"dist","size_in_bytes":1,"expired":true},
{"id":20,"name":"dist","size_in_bytes":1,"expired":false}]}`)
case "/api/v1/repos/someone/else/actions/artifacts/20/zip":
zipCalls.Add(1)
if expired.Load() {
http.Error(w, "Artifact has expired", http.StatusNotFound)
return
}
http.Redirect(w, r, blob.URL+"/raw/20?sig=abc", http.StatusFound)
default:
http.NotFound(w, r)
}
}))
defer gitea.Close()
h.server = gitea.URL
inputs := map[string]string{"github-token": "pat", "repository": "someone/else/ignored", "run-id": "12", "path": "other"}
require.NoError(t, h.run(inputs))
assert.Equal(t, "from run 12", h.read("other/other.txt"))
assert.Contains(t, h.logs, "Found 1 artifact(s)")
expired.Store(true)
require.ErrorContains(t, h.run(inputs), "Artifact has expired")
assert.Equal(t, int32(2), zipCalls.Load(), "an API error is not retried")
require.ErrorContains(t, h.run(map[string]string{"github-token": "pat", "repository": "nobody"}), "Invalid repository: 'nobody'. Must be in format owner/repo")
require.ErrorContains(t, h.run(map[string]string{"github-token": "pat", "run-id": "0"}), "Invalid run-id: '0'")
}
func TestDownloadRetries(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
attempts := 0
target := filepath.Join(t.TempDir(), "download")
started := time.Now()
_, err := fetch(t.Context(), &action.Context{Container: &container.HostEnvironment{}}, &source{
signedURL: func(context.Context, artifact.Artifact) (string, error) {
assert.DirExists(t, target)
return "https://example.test/artifact.zip", nil
},
download: func(context.Context, string, time.Duration) (*http.Response, error) {
attempts++
return nil, errors.New("connection failed")
},
}, artifact.Artifact{}, target, false)
require.EqualError(t, err, "Unable to download and extract artifact: Artifact download failed after 5 retries.")
assert.Equal(t, 5, attempts)
assert.Equal(t, 25*time.Second, time.Since(started))
})
}
func TestDownloadRejectsEscapingZipPaths(t *testing.T) {
for _, name := range []string{"../escape.txt", "a/../../escape.txt", "/absolute.txt", `..\escape.txt`} {
t.Run(name, func(t *testing.T) {
archive := zipOf(t, zipEntry{name: name, body: "escape"})
require.ErrorContains(t, extractZip(t.Context(), &action.Context{Container: &container.HostEnvironment{}}, bytes.NewReader(archive), int64(len(archive)), t.TempDir()), "Artifact entry path is not valid")
})
}
}