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}