v0.2.0 43088fd

actions_extract.go RawHistoryFolder

1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6	"archive/tar"
7	"compress/gzip"
8	"context"
9	"errors"
10	"fmt"
11	"io"
12	"io/fs"
13	"os"
14	"path"
15	"path/filepath"
16	"strings"
17)
18
19// Limits of an action archive. The runner has none (it runs tar); these keep a
20// hostile archive from filling the sandbox. They are variables for the tests.
21var (
22	maxArchiveEntries = 200_000
23	// maxArchiveBytes bounds the sizes of the regular files; maxStreamBytes the
24	// whole decompressed tar stream, headers and PAX/GNU long-name records
25	// included, which the entry and size counters do not see.
26	maxArchiveBytes = int64(2 << 30)
27	maxStreamBytes  = maxArchiveBytes + 64<<20
28)
29
30// errArchive is returned for an archive that is not safe or not valid. Its text
31// is ermite's: GitHub's runner runs tar, which accepts absolute paths and links
32// out of the tree (Runner.Worker/ActionManager.cs:1358-1394).
33type errArchive string
34
35func (e errArchive) Error() string { return string(e) }
36
37// capReader fails once more than n bytes were read.
38type capReader struct {
39	r io.Reader
40	n int64
41}
42
43func (c *capReader) Read(p []byte) (int, error) {
44	if c.n <= 0 {
45		return 0, errArchive("the archive is too large once unpacked")
46	}
47	if int64(len(p)) > c.n {
48		p = p[:c.n]
49	}
50	n, err := c.r.Read(p)
51	c.n -= int64(n)
52	return n, err
53}
54
55// extractTarball unpacks a gzip-compressed tar into dir, which must exist and
56// be empty, and strips the single top directory of the archive (GitHub's
57// archives have exactly one, ActionManager.cs:1380-1393): the content lands in
58// dir itself, so no file moves afterwards and a link that is valid here stays
59// valid. Every name is checked: no absolute path, no "..", one top directory,
60// no link whose target leaves dir, and everything is written through an
61// os.Root, so that a link planted by an earlier entry cannot redirect a later
62// one. The decompressed stream is capped, and ctx is checked between entries.
63// Links are validated at install time: a running step can change the action
64// tree like any file of its own sandbox, so this is not a confinement guarantee
65// for what happens later.
66// owner, when set, is the uid and gid of the files.
67func extractTarball(ctx context.Context, r io.Reader, dir string, owner *[2]int) error {
68	zr, err := gzip.NewReader(r)
69	if err != nil {
70		return errArchive("the archive is not a gzip file: " + err.Error())
71	}
72	defer func() { _ = zr.Close() }()
73	root, err := os.OpenRoot(dir)
74	if err != nil {
75		return err
76	}
77	defer func() { _ = root.Close() }()
78	tr := tar.NewReader(&capReader{r: zr, n: maxStreamBytes})
79	entries, total, top := 0, int64(0), ""
80	for {
81		if err := ctx.Err(); err != nil {
82			return err
83		}
84		h, err := tr.Next()
85		if errors.Is(err, io.EOF) {
86			return verifyLinks(ctx, root, dir)
87		}
88		if err != nil {
89			var ea errArchive
90			if errors.As(err, &ea) {
91				return ea
92			}
93			return errArchive("the archive is not a valid tar file: " + err.Error())
94		}
95		if entries++; entries > maxArchiveEntries {
96			return errArchive("the archive has too many entries")
97		}
98		if total += h.Size; total > maxArchiveBytes {
99			return errArchive("the archive is too large once unpacked")
100		}
101		if err := extractEntry(root, tr, h, owner, &top); err != nil {
102			return err
103		}
104	}
105}
106
107// stripTop validates the name of an entry, drops its top directory and returns
108// the rest, clean and relative to the final root ("" for the top directory
109// itself). All entries must share one top directory.
110func stripTop(name string, top *string) (string, error) {
111	c, err := cleanEntry(name)
112	if err != nil {
113		return "", err
114	}
115	if c == "" {
116		return "", nil
117	}
118	first, rest, hasRest := strings.Cut(c, "/")
119	if *top == "" {
120		*top = first
121	} else if first != *top {
122		return "", errArchive(fmt.Sprintf("the archive has more than one top-level directory: %q and %q", *top, first))
123	}
124	if !hasRest {
125		return "", nil
126	}
127	return rest, nil
128}
129
130// cleanEntry validates the name of an entry and returns it clean and relative.
131func cleanEntry(name string) (string, error) {
132	if strings.ContainsRune(name, 0) || strings.HasPrefix(name, "/") {
133		return "", errArchive(fmt.Sprintf("the archive has an absolute or invalid path: %q", name))
134	}
135	for _, seg := range strings.Split(name, "/") {
136		if seg == ".." {
137			return "", errArchive(fmt.Sprintf("the archive has a path that leaves its directory: %q", name))
138		}
139	}
140	c := path.Clean(name)
141	if c == "." {
142		return "", nil
143	}
144	return c, nil
145}
146
147// linkInside reports whether a link at name pointing to target stays in the
148// tree: target is relative to the directory of name for a symlink, and to the
149// root for a hard link.
150func linkInside(name, target string, hard bool) bool {
151	if target == "" || strings.HasPrefix(target, "/") || strings.ContainsRune(target, 0) {
152		return false
153	}
154	base := path.Dir(name)
155	if hard {
156		base = "."
157	}
158	t := path.Join(base, target)
159	return t != ".." && !strings.HasPrefix(t, "../")
160}
161
162func extractEntry(root *os.Root, tr *tar.Reader, h *tar.Header, owner *[2]int, top *string) error {
163	switch h.Typeflag {
164	case tar.TypeXGlobalHeader, tar.TypeXHeader:
165		return nil // git archive's pax_global_header: the commit id
166	}
167	name, err := stripTop(h.Name, top)
168	if err != nil {
169		return err
170	}
171	if name == "" {
172		if h.Typeflag != tar.TypeDir {
173			return errArchive(fmt.Sprintf("the archive has a file next to its top-level directory: %q", h.Name))
174		}
175		return nil
176	}
177	perm := os.FileMode(h.Mode) & 0o777
178	chown := func() error {
179		if owner == nil {
180			return nil
181		}
182		return root.Lchown(name, owner[0], owner[1])
183	}
184	if dir := path.Dir(name); dir != "." {
185		if err := root.MkdirAll(dir, 0o755); err != nil {
186			return errArchive(fmt.Sprintf("cannot create the directory of %q: %v", name, err))
187		}
188	}
189	switch h.Typeflag {
190	case tar.TypeDir:
191		if err := root.MkdirAll(name, 0o755); err != nil {
192			return err
193		}
194		if err := root.Chmod(name, perm|0o700); err != nil {
195			return err
196		}
197		return chown()
198	case tar.TypeReg:
199		_ = root.Remove(name)
200		f, err := root.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, perm|0o600)
201		if err != nil {
202			return errArchive(fmt.Sprintf("cannot create %q: %v", name, err))
203		}
204		if _, err := io.Copy(f, tr); err != nil {
205			_ = f.Close()
206			return errArchive("the archive is not a valid tar file: " + err.Error())
207		}
208		if err := f.Close(); err != nil {
209			return err
210		}
211		if err := root.Chmod(name, perm|0o600); err != nil {
212			return err
213		}
214		return chown()
215	case tar.TypeSymlink:
216		// Only the shape is checked here: where the link leads depends on the links
217		// that come before and after it, so verifyLinks resolves every symlink of
218		// the finished tree.
219		if h.Linkname == "" || strings.HasPrefix(h.Linkname, "/") || strings.ContainsRune(h.Linkname, 0) {
220			return errArchive(fmt.Sprintf("the archive has a link that points outside of it: %q -> %q", name, h.Linkname))
221		}
222		_ = root.Remove(name)
223		if err := root.Symlink(h.Linkname, name); err != nil {
224			return err
225		}
226		return chown()
227	case tar.TypeLink:
228		tgt, err := stripTop(h.Linkname, top)
229		if err != nil || tgt == "" || !linkInside(name, tgt, true) {
230			return errArchive(fmt.Sprintf("the archive has a link that points outside of it: %q -> %q", name, h.Linkname))
231		}
232		_ = root.Remove(name)
233		return root.Link(tgt, name)
234	}
235	return nil // devices, fifos: skipped
236}
237
238// maxLinkHops bounds the symbolic links followed to resolve one link (Linux
239// stops at 40).
240const maxLinkHops = 40
241
242// verifyLinks resolves every symbolic link of the extracted tree component by
243// component, following the links met on the way, and rejects the archive when
244// one leads out of the tree or loops. A lexical check is not enough: with
245// "a -> ." a link "link -> a/../outside" looks like it stays in the tree, and
246// leaves it. A link to something that does not exist is allowed (git archives
247// have such links); the rest of its target is still resolved.
248func verifyLinks(ctx context.Context, root *os.Root, dir string) error {
249	return filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
250		if err != nil {
251			return err
252		}
253		if err := ctx.Err(); err != nil {
254			return err
255		}
256		if d.Type()&fs.ModeSymlink == 0 {
257			return nil
258		}
259		rel, err := filepath.Rel(dir, p)
260		if err != nil {
261			return err
262		}
263		target, err := root.Readlink(filepath.ToSlash(rel))
264		if err != nil {
265			return err
266		}
267		if err := resolveInside(root, path.Dir(filepath.ToSlash(rel)), target); err != nil {
268			return errArchive(fmt.Sprintf("the archive has a link that points outside of it: %q -> %q (%v)", filepath.ToSlash(rel), target, err))
269		}
270		return nil
271	})
272}
273
274// resolveInside resolves target, relative to the directory dirRel of the tree
275// (slash separated, "." for the root), the way the kernel would, and fails when
276// the path leaves the root, is absolute, or needs more than maxLinkHops links.
277func resolveInside(root *os.Root, dirRel, target string) error {
278	var cur []string // resolved directory components, none of them a link
279	if dirRel != "." && dirRel != "" {
280		cur = strings.Split(dirRel, "/")
281	}
282	pending := strings.Split(target, "/")
283	hops := 0
284	for len(pending) > 0 {
285		c := pending[0]
286		pending = pending[1:]
287		switch c {
288		case "", ".":
289			continue
290		case "..":
291			if len(cur) == 0 {
292				return errors.New("leaves the tree")
293			}
294			cur = cur[:len(cur)-1]
295			continue
296		}
297		p := strings.Join(append(append([]string(nil), cur...), c), "/")
298		fi, err := root.Lstat(p)
299		switch {
300		case errors.Is(err, os.ErrNotExist):
301			cur = append(cur, c)
302			continue
303		case err != nil:
304			return err
305		case fi.Mode()&fs.ModeSymlink == 0:
306			cur = append(cur, c)
307			continue
308		}
309		if hops++; hops > maxLinkHops {
310			return errors.New("too many levels of links")
311		}
312		next, err := root.Readlink(p)
313		if err != nil {
314			return err
315		}
316		if strings.HasPrefix(next, "/") || next == "" {
317			return errors.New("an absolute or empty link")
318		}
319		pending = append(strings.Split(next, "/"), pending...)
320	}
321	return nil
322}

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