mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-09 16:50:28 +00:00
317 lines
12 KiB
Go
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")
|
|
})
|
|
}
|
|
}
|