v0.2.0 43088fd

actions_extract_test.go RawHistoryFolder

1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6	"archive/tar"
7	"bytes"
8	"compress/gzip"
9	"context"
10	"os"
11	"path/filepath"
12	"strings"
13	"testing"
14)
15
16type tent struct {
17	name, body, link string
18	typ              byte
19}
20
21func tarball(t *testing.T, ents ...tent) *bytes.Reader {
22	t.Helper()
23	var buf bytes.Buffer
24	zw := gzip.NewWriter(&buf)
25	tw := tar.NewWriter(zw)
26	for _, e := range ents {
27		typ := e.typ
28		if typ == 0 {
29			typ = tar.TypeReg
30		}
31		h := &tar.Header{Name: e.name, Typeflag: typ, Mode: 0o644, Linkname: e.link}
32		if typ == tar.TypeReg {
33			h.Size = int64(len(e.body))
34		}
35		if typ == tar.TypeDir {
36			h.Mode = 0o755
37		}
38		if err := tw.WriteHeader(h); err != nil {
39			t.Fatal(err)
40		}
41		if typ == tar.TypeReg {
42			_, _ = tw.Write([]byte(e.body))
43		}
44	}
45	_ = tw.Close()
46	_ = zw.Close()
47	return bytes.NewReader(buf.Bytes())
48}
49
50func TestExtractTarball(t *testing.T) {
51	dir := t.TempDir()
52	err := extractTarball(context.Background(), tarball(t,
53		tent{name: "o-r-abc/", typ: tar.TypeDir},
54		tent{name: "o-r-abc/action.yml", body: "name: x"},
55		tent{name: "o-r-abc/sub/main.js", body: "1"},
56		tent{name: "o-r-abc/link", typ: tar.TypeSymlink, link: "sub/main.js"},
57		tent{name: "o-r-abc/hard", typ: tar.TypeLink, link: "o-r-abc/sub/main.js"},
58	), dir, nil)
59	if err != nil {
60		t.Fatal(err)
61	}
62	// The top directory is stripped: the content is in dir itself.
63	for _, f := range []string{"action.yml", "link", "hard"} {
64		if _, err := os.Stat(filepath.Join(dir, f)); err != nil {
65			t.Errorf("%s: %v", f, err)
66		}
67	}
68	if b, _ := os.ReadFile(filepath.Join(dir, "link")); string(b) != "1" {
69		t.Errorf("link: %q", b)
70	}
71	if _, err := os.Stat(filepath.Join(dir, "o-r-abc")); err == nil {
72		t.Error("the top directory must be stripped")
73	}
74}
75
76func TestExtractTarballRejects(t *testing.T) {
77	cases := map[string][]tent{
78		"absolute":       {{name: "/etc/passwd", body: "x"}},
79		"dotdot":         {{name: "t/a/../../x", body: "x"}},
80		"symlink out":    {{name: "t/a/l", typ: tar.TypeSymlink, link: "../../x"}},
81		"symlink abs":    {{name: "t/l", typ: tar.TypeSymlink, link: "/etc"}},
82		"hardlink out":   {{name: "t/l", typ: tar.TypeLink, link: "../x"}},
83		"hardlink abs":   {{name: "t/l", typ: tar.TypeLink, link: "/etc/passwd"}},
84		"through link":   {{name: "t/d", typ: tar.TypeSymlink, link: "."}, {name: "t/d/../../x", body: "x"}},
85		"link then dir":  {{name: "t/l", typ: tar.TypeSymlink, link: "a/.."}, {name: "t/l/../x", body: "x"}},
86		"two tops":       {{name: "t/a", body: "1"}, {name: "u/b", body: "2"}},
87		"file beside":    {{name: "t/", typ: tar.TypeDir}, {name: "loose", body: "x"}},
88		"hardlink other": {{name: "t/a", body: "1"}, {name: "t/l", typ: tar.TypeLink, link: "u/a"}},
89	}
90	for name, ents := range cases {
91		t.Run(name, func(t *testing.T) {
92			parent := t.TempDir()
93			dir := filepath.Join(parent, "dest")
94			if err := os.Mkdir(dir, 0o755); err != nil {
95				t.Fatal(err)
96			}
97			err := extractTarball(context.Background(), tarball(t, ents...), dir, nil)
98			if err == nil {
99				t.Fatal("accepted")
100			}
101			if _, statErr := os.Lstat(filepath.Join(parent, "x")); statErr == nil {
102				t.Fatal("wrote outside")
103			}
104		})
105	}
106}
107
108// GO-001: "top/link -> ../outside" stays inside the staging directory of the
109// extraction but escapes once the top directory is stripped. The target is
110// resolved against the final root, whatever the location the files are
111// extracted to, and the relocated tree has no link that leaves it.
112func TestExtractTarballLinkEscapesStrippedRoot(t *testing.T) {
113	// From top/sub/l, i.e. sub/l once the top directory is stripped.
114	for link, ok := range map[string]bool{"../outside": true, "..": true, "../x": true, "../../outside": false, "../../top/x": false, "../../../x": false} {
115		dir := filepath.Join(t.TempDir(), "stage")
116		_ = os.Mkdir(dir, 0o755)
117		err := extractTarball(context.Background(), tarball(t,
118			tent{name: "top/x", body: "x"},
119			tent{name: "top/sub/l", typ: tar.TypeSymlink, link: link},
120		), dir, nil)
121		if (err == nil) != ok {
122			t.Errorf("link %q: accepted=%v, want %v (%v)", link, err == nil, ok, err)
123		}
124	}
125	// The direct case of the finding.
126	dir := filepath.Join(t.TempDir(), "stage")
127	_ = os.Mkdir(dir, 0o755)
128	if err := extractTarball(context.Background(), tarball(t,
129		tent{name: "top/link", typ: tar.TypeSymlink, link: "../outside"},
130	), dir, nil); err == nil {
131		t.Fatal("top/link -> ../outside was accepted")
132	}
133	// A tree that was accepted keeps all its links inside after relocation.
134	stage := filepath.Join(t.TempDir(), "stage")
135	_ = os.Mkdir(stage, 0o755)
136	if err := extractTarball(context.Background(), tarball(t,
137		tent{name: "top/real", body: "r"},
138		tent{name: "top/a/b/l", typ: tar.TypeSymlink, link: "../../real"},
139	), stage, nil); err != nil {
140		t.Fatal(err)
141	}
142	final := filepath.Join(t.TempDir(), "final")
143	if err := os.Rename(stage, final); err != nil {
144		t.Fatal(err)
145	}
146	if b, err := os.ReadFile(filepath.Join(final, "a", "b", "l")); err != nil || string(b) != "r" {
147		t.Fatalf("relocated link: %q %v", b, err)
148	}
149}
150
151func TestExtractTarballNotGzip(t *testing.T) {
152	err := extractTarball(context.Background(), strings.NewReader("not gzip"), t.TempDir(), nil)
153	if err == nil || !strings.Contains(err.Error(), "not a gzip") {
154		t.Fatalf("%v", err)
155	}
156}
157
158// A symlink to a directory inside the tree must not let a later entry write
159// outside it, even through a chain of links.
160func TestExtractTarballLinkChain(t *testing.T) {
161	parent := t.TempDir()
162	dir := filepath.Join(parent, "dest")
163	_ = os.Mkdir(dir, 0o755)
164	err := extractTarball(context.Background(), tarball(t,
165		tent{name: "t/a", typ: tar.TypeSymlink, link: "b"},
166		tent{name: "t/b", typ: tar.TypeSymlink, link: "."},
167		tent{name: "t/a/a/a/f", body: "x"},
168	), dir, nil)
169	if err != nil {
170		t.Fatal(err)
171	}
172	if b, _ := os.ReadFile(filepath.Join(dir, "f")); string(b) != "x" {
173		t.Fatalf("f: %q", b)
174	}
175}
176
177// GO-004: PAX and GNU long-name records are not entries and have no Size in the
178// header Next returns, so the counters do not see them. The decompressed stream
179// is capped.
180func TestExtractTarballMetadataFlood(t *testing.T) {
181	old := maxStreamBytes
182	maxStreamBytes = 256 << 10
183	defer func() { maxStreamBytes = old }()
184	var buf bytes.Buffer
185	zw := gzip.NewWriter(&buf)
186	tw := tar.NewWriter(zw)
187	for i := 0; i < 400; i++ {
188		// A PAX record per file with a 4 KiB value; the entries stay below
189		// every counter.
190		h := &tar.Header{Name: "t/f", Typeflag: tar.TypeReg, Mode: 0o644, Size: 0, Format: tar.FormatPAX,
191			PAXRecords: map[string]string{"comment": strings.Repeat("x", 4096)}}
192		if err := tw.WriteHeader(h); err != nil {
193			t.Fatal(err)
194		}
195	}
196	_ = tw.Close()
197	_ = zw.Close()
198	err := extractTarball(context.Background(), bytes.NewReader(buf.Bytes()), t.TempDir(), nil)
199	if err == nil || !strings.Contains(err.Error(), "too large once unpacked") {
200		t.Fatalf("%v", err)
201	}
202}
203
204func TestExtractTarballCancelled(t *testing.T) {
205	ctx, cancel := context.WithCancel(context.Background())
206	cancel()
207	err := extractTarball(ctx, tarball(t, tent{name: "t/a", body: "1"}), t.TempDir(), nil)
208	if err != context.Canceled {
209		t.Fatalf("%v", err)
210	}
211}
212
213// GO-007: where a link leads depends on the other links. "a -> ." makes
214// "link -> a/../outside" leave the tree, which a lexical check accepts.
215func TestExtractTarballLinkChainsAreResolved(t *testing.T) {
216	sym := func(name, to string) tent { return tent{name: "top/" + name, typ: tar.TypeSymlink, link: to} }
217	for name, tc := range map[string]struct {
218		ents []tent
219		ok   bool
220	}{
221		"two links escape":         {[]tent{sym("a", "."), sym("link", "a/../outside")}, false},
222		"two links, reverse order": {[]tent{sym("link", "a/../outside"), sym("a", ".")}, false}, // forward reference
223		"three hops":               {[]tent{sym("a", "b"), sym("b", "."), sym("link", "a/../../x")}, false},
224		"through a directory link": {[]tent{{name: "top/d/e/f", body: "x"}, sym("d/up", "../.."), sym("link", "d/up/..")}, false},
225		"absolute inside a hop":    {[]tent{sym("a", "/etc"), sym("link", "a/passwd")}, false},
226		"loop":                     {[]tent{sym("a", "b"), sym("b", "a"), sym("link", "a/x")}, false},
227		"long chain":               {chain(45), false},
228		"stays inside":             {[]tent{{name: "top/d/f", body: "1"}, sym("a", "."), sym("link", "a/d/f"), sym("up", "d/..")}, true},
229		"dangling is fine":         {[]tent{sym("link", "nowhere/at/all")}, true},
230		"forward ref inside":       {[]tent{sym("link", "a/b"), sym("a", "d"), {name: "top/d/b", body: "1"}}, true},
231		"dotdot after real dir":    {[]tent{{name: "top/d/f", body: "1"}, sym("link", "d/../d/f")}, true},
232	} {
233		t.Run(name, func(t *testing.T) {
234			parent := t.TempDir()
235			dir := filepath.Join(parent, "stage")
236			_ = os.Mkdir(dir, 0o755)
237			err := extractTarball(context.Background(), tarball(t, tc.ents...), dir, nil)
238			if (err == nil) != tc.ok {
239				t.Fatalf("accepted=%v, want %v (%v)", err == nil, tc.ok, err)
240			}
241		})
242	}
243}
244
245// chain makes n links, each one to the next, the last to ".": more hops than
246// the kernel follows.
247func chain(n int) []tent {
248	var out []tent
249	for i := 0; i < n; i++ {
250		to := "l" + string(rune('A'+(i+1)%26)) + string(rune('a'+(i+1)/26))
251		if i == n-1 {
252			to = "."
253		}
254		out = append(out, tent{name: "top/l" + string(rune('A'+i%26)) + string(rune('a'+i/26)), typ: tar.TypeSymlink, link: to})
255	}
256	return append(out, tent{name: "top/start", typ: tar.TypeSymlink, link: "lAa/x"})
257}

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 4

Tags 11

↑↓ moveenter openesc close