detached 5278df2

cli_run_artifacts_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"archive/zip"
7	"bytes"
8	"compress/flate"
9	"context"
10	"fmt"
11	"net/http"
12	"net/http/httptest"
13	"os"
14	"path/filepath"
15	"testing"
16
17	"connectrpc.com/connect"
18	"github.com/stretchr/testify/assert"
19	"github.com/stretchr/testify/require"
20
21	"bnema.dev/ermite-api/gen/ermite"
22	"bnema.dev/ermite-api/gen/ermite/ermiteconnect"
23)
24
25// fakeArtifactAPI serves the artifacts of run 1 of proj.
26type fakeArtifactAPI struct {
27	ermiteconnect.UnimplementedArtifactServiceHandler
28	arts    []*ermite.Artifact
29	content map[int64][]byte
30}
31
32func (f *fakeArtifactAPI) ListArtifacts(_ context.Context, r *connect.Request[ermite.ListArtifactsRequest]) (*connect.Response[ermite.ListArtifactsResponse], error) {
33	if r.Msg.GetRepo() != "proj" || r.Msg.GetRunId() != 1 {
34		return nil, connect.NewError(connect.CodeNotFound, nil)
35	}
36	return connect.NewResponse(&ermite.ListArtifactsResponse{Artifacts: f.arts, UsedBytes: 3 << 20, QuotaBytes: 10 << 30}), nil
37}
38
39func (f *fakeArtifactAPI) DownloadArtifact(_ context.Context, r *connect.Request[ermite.DownloadArtifactRequest], st *connect.ServerStream[ermite.DownloadArtifactResponse]) error {
40	for _, a := range f.arts {
41		if a.GetId() != r.Msg.GetArtifactId() {
42			continue
43		}
44		if err := st.Send(&ermite.DownloadArtifactResponse{Artifact: a}); err != nil {
45			return err
46		}
47		data := f.content[a.GetId()]
48		for len(data) > 0 {
49			n := min(len(data), 7)
50			if err := st.Send(&ermite.DownloadArtifactResponse{Chunk: data[:n]}); err != nil {
51				return err
52			}
53			data = data[n:]
54		}
55		return nil
56	}
57	return connect.NewError(connect.CodeNotFound, nil)
58}
59
60func (f *fakeArtifactAPI) add(name, mediaType string, data []byte) {
61	if f.content == nil {
62		f.content = map[int64][]byte{}
63	}
64	id := int64(len(f.arts) + 1)
65	f.arts = append(f.arts, &ermite.Artifact{
66		Id: id, Repo: "proj", RunId: 1, Attempt: 1, JobId: "1-0-0", Name: name, MediaType: mediaType,
67		Size: int64(len(data)), ExpiresAt: ts("2027-01-08T12:00:00Z"),
68	})
69	f.content[id] = data
70}
71
72func (f *fakeArtifactAPI) serve(t *testing.T) string {
73	t.Helper()
74	mux := http.NewServeMux()
75	p, h := ermiteconnect.NewArtifactServiceHandler(f)
76	mux.Handle("/-/api"+p, http.StripPrefix("/-/api", h))
77	srv := httptest.NewServer(mux)
78	t.Cleanup(srv.Close)
79	return srv.URL
80}
81
82type zipEntry struct {
83	name, body string
84	mode       os.FileMode
85}
86
87func zipOf(t *testing.T, entries ...zipEntry) []byte {
88	t.Helper()
89	var b bytes.Buffer
90	zw := zip.NewWriter(&b)
91	for _, e := range entries {
92		h := &zip.FileHeader{Name: e.name, Method: zip.Deflate}
93		if e.mode != 0 {
94			h.SetMode(e.mode)
95		}
96		w, err := zw.CreateHeader(h)
97		require.NoError(t, err)
98		_, err = w.Write([]byte(e.body))
99		require.NoError(t, err)
100	}
101	require.NoError(t, zw.Close())
102	return b.Bytes()
103}
104
105func readFile(t *testing.T, p string) string {
106	t.Helper()
107	b, err := os.ReadFile(p)
108	require.NoError(t, err)
109	return string(b)
110}
111
112// noTemp checks that no temporary file of a download is left in dir.
113func noTemp(t *testing.T, dir string) {
114	t.Helper()
115	left, err := filepath.Glob(filepath.Join(dir, ".ermite-download-*"))
116	require.NoError(t, err)
117	assert.Empty(t, left)
118}
119
120func TestRunArtifactsList(t *testing.T) {
121	f := &fakeArtifactAPI{}
122	f.add("dist", "application/zip", []byte("PK"))
123	f.add("report.txt", "text/plain", bytes.Repeat([]byte("x"), 1536))
124	url := f.serve(t)
125	out := runCLI(t, envFor(url), "", "run", "artifacts", "proj", "1").ok(t)
126	assert.Contains(t, out.stdout, "ID")
127	assert.Contains(t, out.stdout, "EXPIRES")
128	assert.Regexp(t, `1\s+dist\s+2 B\s+2027-01-08 12:00`, out.stdout)
129	assert.Regexp(t, `2\s+report\.txt\s+1\.5 KiB`, out.stdout)
130	assert.Contains(t, out.stdout, "3.0 MiB of 10.0 GiB used")
131
132	js := decode[jsonArtifactList](t, runCLI(t, envFor(url), "", "run", "artifacts", "proj", "1", "--json").ok(t).stdout)
133	require.Len(t, js.Artifacts, 2)
134	assert.Equal(t, "report.txt", js.Artifacts[1].Name)
135	assert.Equal(t, int64(10<<30), js.QuotaBytes)
136
137	assert.Error(t, runCLI(t, envFor(url), "", "run", "artifacts", "proj", "2").err)
138}
139
140func TestRunDownloadOneZip(t *testing.T) {
141	f := &fakeArtifactAPI{}
142	f.add("dist", "application/zip", zipOf(t,
143		zipEntry{name: "bin/"}, zipEntry{name: "bin/app", body: "binary", mode: 0o755}, zipEntry{name: "README", body: "read me"}))
144	dir := filepath.Join(t.TempDir(), "out")
145	runCLI(t, envFor(f.serve(t)), "", "run", "download", "proj", "1", "--dir", dir).ok(t)
146	assert.Equal(t, "binary", readFile(t, filepath.Join(dir, "bin", "app")))
147	assert.Equal(t, "read me", readFile(t, filepath.Join(dir, "README")))
148	st, err := os.Stat(filepath.Join(dir, "bin", "app"))
149	require.NoError(t, err)
150	assert.NotZero(t, st.Mode()&0o100, "the executable bit")
151	noTemp(t, dir)
152}
153
154func TestRunDownloadSeveral(t *testing.T) {
155	f := &fakeArtifactAPI{}
156	f.add("linux", "application/zip", zipOf(t, zipEntry{name: "app", body: "linux"}))
157	f.add("darwin", "application/zip", zipOf(t, zipEntry{name: "app", body: "darwin"}))
158	f.add("notes.txt", "text/plain", []byte("plain file"))
159	url := f.serve(t)
160
161	dir := t.TempDir()
162	runCLI(t, envFor(url), "", "run", "download", "proj", "1", "--dir", dir).ok(t)
163	assert.Equal(t, "linux", readFile(t, filepath.Join(dir, "linux", "app")))
164	assert.Equal(t, "darwin", readFile(t, filepath.Join(dir, "darwin", "app")))
165	assert.Equal(t, "plain file", readFile(t, filepath.Join(dir, "notes.txt")), "saved as is")
166	noTemp(t, dir)
167
168	// Again into the same directory: nothing is overwritten.
169	assert.Error(t, runCLI(t, envFor(url), "", "run", "download", "proj", "1", "--dir", dir).err)
170	noTemp(t, dir)
171
172	// --name chooses; two names are several, one is extracted into the dir.
173	two := t.TempDir()
174	runCLI(t, envFor(url), "", "run", "download", "proj", "1", "--dir", two, "--name", "darwin", "-n", "notes.txt").ok(t)
175	assert.Equal(t, "darwin", readFile(t, filepath.Join(two, "darwin", "app")))
176	assert.Equal(t, "plain file", readFile(t, filepath.Join(two, "notes.txt")))
177	assert.NoDirExists(t, filepath.Join(two, "linux"))
178
179	one := t.TempDir()
180	runCLI(t, envFor(url), "", "run", "download", "proj", "1", "--dir", one, "--name", "linux").ok(t)
181	assert.Equal(t, "linux", readFile(t, filepath.Join(one, "app")))
182
183	unknown := runCLI(t, envFor(url), "", "run", "download", "proj", "1", "--dir", t.TempDir(), "--name", "nope")
184	require.Error(t, unknown.err)
185	assert.Contains(t, unknown.err.Error(), `no artifact "nope"`)
186}
187
188func TestRunDownloadRefusesUnsafeArchives(t *testing.T) {
189	for name, entries := range map[string][]zipEntry{
190		"dot-dot":   {{name: "ok", body: "x"}, {name: "../escape", body: "x"}},
191		"deep":      {{name: "a/../../escape", body: "x"}},
192		"absolute":  {{name: "/tmp/escape", body: "x"}},
193		"symlink":   {{name: "link", body: "/etc/passwd", mode: os.ModeSymlink | 0o777}},
194		"backslash": {{name: `a\..\..\escape`, body: "x"}},
195		"dir-link":  {{name: "d", body: "/", mode: os.ModeSymlink | 0o777}, {name: "d/escape", body: "x"}},
196		"device":    {{name: "dev", body: "", mode: os.ModeDevice | 0o666}},
197	} {
198		t.Run(name, func(t *testing.T) {
199			f := &fakeArtifactAPI{}
200			f.add("evil", "application/zip", zipOf(t, entries...))
201			parent := t.TempDir()
202			dir := filepath.Join(parent, "out")
203			res := runCLI(t, envFor(f.serve(t)), "", "run", "download", "proj", "1", "--dir", dir)
204			require.Error(t, res.err)
205			assert.NoFileExists(t, filepath.Join(parent, "escape"))
206			assert.NoFileExists(t, filepath.Join(dir, "link"))
207			assert.NoFileExists(t, filepath.Join(dir, "ok"), "nothing is written from a refused archive")
208			noTemp(t, dir)
209		})
210	}
211}
212
213func TestExtractZipBoundsTheExpansion(t *testing.T) {
214	data := zipOf(t, zipEntry{name: "big", body: string(bytes.Repeat([]byte("0"), 1<<20))})
215	dir := t.TempDir()
216	root, err := os.OpenRoot(dir)
217	require.NoError(t, err)
218	defer func() { _ = root.Close() }()
219	err = extractZip(bytes.NewReader(data), int64(len(data)), root, 1<<10)
220	require.Error(t, err)
221	assert.Contains(t, err.Error(), "expands to more than")
222	assert.NoFileExists(t, filepath.Join(dir, "big"))
223}
224
225// A setuid or setgid bit and other mode bits of an entry are dropped: files
226// are 0644, or 0755 when the owner could execute them.
227func TestExtractZipKeepsOnlyPlainModes(t *testing.T) {
228	data := zipOf(t, zipEntry{name: "suid", body: "x", mode: os.ModeSetuid | os.ModeSetgid | 0o4777}, zipEntry{name: "plain", body: "y", mode: 0o666})
229	dir := t.TempDir()
230	root, err := os.OpenRoot(dir)
231	require.NoError(t, err)
232	defer func() { _ = root.Close() }()
233	require.NoError(t, extractZip(bytes.NewReader(data), int64(len(data)), root, 1<<20))
234	for name, want := range map[string]os.FileMode{"suid": 0o755, "plain": 0o644} {
235		info, err := os.Stat(filepath.Join(dir, name))
236		require.NoError(t, err)
237		assert.Zero(t, info.Mode()&(os.ModeSetuid|os.ModeSetgid|os.ModeSticky), name)
238		assert.Equal(t, want, info.Mode().Perm()&want, name)
239		assert.Zero(t, info.Mode().Perm()&^0o755, name)
240	}
241}
242
243// An entry whose header understates its size (a zip bomb that lies) is cut at
244// the limit: the declared sizes are not trusted alone.
245func TestExtractZipStopsALyingEntry(t *testing.T) {
246	var deflated bytes.Buffer
247	fw, err := flate.NewWriter(&deflated, flate.BestCompression)
248	require.NoError(t, err)
249	_, err = fw.Write(bytes.Repeat([]byte{0}, 4<<20))
250	require.NoError(t, err)
251	require.NoError(t, fw.Close())
252	var b bytes.Buffer
253	zw := zip.NewWriter(&b)
254	w, err := zw.CreateRaw(&zip.FileHeader{Name: "bomb", Method: zip.Deflate, CompressedSize64: uint64(deflated.Len()), UncompressedSize64: 10})
255	require.NoError(t, err)
256	_, err = w.Write(deflated.Bytes())
257	require.NoError(t, err)
258	require.NoError(t, zw.Close())
259
260	dir := t.TempDir()
261	root, err := os.OpenRoot(dir)
262	require.NoError(t, err)
263	defer func() { _ = root.Close() }()
264	require.Error(t, extractZip(bytes.NewReader(b.Bytes()), int64(b.Len()), root, 1<<20))
265	if info, err := os.Stat(filepath.Join(dir, "bomb")); err == nil {
266		assert.LessOrEqual(t, info.Size(), int64(1<<20)+1, "never more than the limit written")
267	}
268}
269
270func TestExtractZipBoundsTheEntries(t *testing.T) {
271	entries := make([]zipEntry, maxZipEntries+1)
272	for i := range entries {
273		entries[i] = zipEntry{name: fmt.Sprint("f", i)}
274	}
275	data := zipOf(t, entries...)
276	dir := t.TempDir()
277	root, err := os.OpenRoot(dir)
278	require.NoError(t, err)
279	defer func() { _ = root.Close() }()
280	err = extractZip(bytes.NewReader(data), int64(len(data)), root, 1<<30)
281	require.ErrorContains(t, err, "entries")
282	assert.NoFileExists(t, filepath.Join(dir, "f0"))
283}
284
285func TestCheckArtifactName(t *testing.T) {
286	for _, n := range []string{"", ".", "..", "a/b", `a\b`, "/etc/passwd", "../x", "a\x00b"} {
287		assert.Error(t, checkArtifactName(n), n)
288	}
289	assert.NoError(t, checkArtifactName("dist-linux.tar"))
290	assert.Equal(t, "1.5 KiB", humanBytes(1536))
291	assert.Equal(t, "10.0 GiB", humanBytes(10<<30))
292}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close