fix/exec-umask 2cd66dc

extract.go RawHistoryFolder

1// SPDX-License-Identifier: MIT
2
3package externals
4
5import (
6	"archive/tar"
7	"compress/gzip"
8	"errors"
9	"fmt"
10	"io"
11	"io/fs"
12	"os"
13	"path"
14	"path/filepath"
15	"strings"
16)
17
18// Limits on what an archive may unpack to. They are far above a Node.js
19// release (about 6,500 entries and 220 MiB) and exist to bound a corrupt or
20// hostile archive that still has the right checksum.
21const (
22	maxEntries = 200_000
23	maxUnpack  = 2 << 30
24)
25
26// extractTarGz unpacks the .tar.gz at archive into the existing directory dest.
27// With stripTop the first path component is dropped (the nodejs.org archives
28// hold one top directory, node-vX-linux-x64); without it the names are taken as
29// they are (the actions/alpine_nodejs archives start at ./bin/node). It refuses what could write or point outside dest:
30//
31//   - absolute names and names with ".." components;
32//   - anything but directories, regular files, symbolic links and hard links;
33//   - symbolic links that are absolute or whose target climbs with ".." anywhere
34//     but at the start (so that the target cannot walk back out through another
35//     link), and entries below a symbolic link;
36//   - hard links to something that is not already unpacked.
37//
38// Writes go through os.Root, which also refuses to follow a link out of dest.
39// File modes are reduced to 0755 / 0644: the owner of the files is whoever runs
40// the runner, and the sandboxes only read and execute them.
41func extractTarGz(archive, dest string, stripTop bool) error {
42	f, err := os.Open(archive)
43	if err != nil {
44		return err
45	}
46	defer func() { _ = f.Close() }()
47	gz, err := gzip.NewReader(f)
48	if err != nil {
49		return fmt.Errorf("not a gzip file: %w", err)
50	}
51	defer func() { _ = gz.Close() }()
52	root, err := os.OpenRoot(dest)
53	if err != nil {
54		return err
55	}
56	defer func() { _ = root.Close() }()
57
58	tr := tar.NewReader(gz)
59	links := map[string]bool{} // unpacked symbolic links
60	var entries int
61	var total int64
62	for {
63		h, err := tr.Next()
64		if errors.Is(err, io.EOF) {
65			return openDirs(dest)
66		}
67		if err != nil {
68			return fmt.Errorf("read archive: %w", err)
69		}
70		if entries++; entries > maxEntries {
71			return fmt.Errorf("archive has more than %d entries", maxEntries)
72		}
73		if h.Typeflag == tar.TypeXGlobalHeader {
74			continue
75		}
76		name, err := cleanName(h.Name, stripTop)
77		if err != nil {
78			return err
79		}
80		if name == "" { // the top directory itself, or "./"
81			continue
82		}
83		if under(links, name) {
84			return fmt.Errorf("entry %q is below a symbolic link", h.Name)
85		}
86		switch h.Typeflag {
87		case tar.TypeDir:
88			err = root.MkdirAll(name, 0o755)
89		case tar.TypeReg:
90			if total += h.Size; total > maxUnpack {
91				return fmt.Errorf("archive unpacks to more than %d bytes", int64(maxUnpack))
92			}
93			err = writeFile(root, name, tr, h.Mode)
94		case tar.TypeSymlink:
95			if err = checkLinkTarget(name, h.Linkname); err == nil {
96				if err = root.MkdirAll(path.Dir(name), 0o755); err == nil {
97					if err = root.Symlink(h.Linkname, name); err == nil {
98						links[name] = true
99					}
100				}
101			}
102		case tar.TypeLink:
103			var old string
104			if old, err = cleanName(h.Linkname, stripTop); err == nil {
105				if old == "" || under(links, old) {
106					err = fmt.Errorf("hard link %q points at %q", h.Name, h.Linkname)
107				} else if err = root.MkdirAll(path.Dir(name), 0o755); err == nil {
108					err = root.Link(old, name)
109				}
110			}
111		default:
112			err = fmt.Errorf("entry %q has the unsupported type %q", h.Name, string(h.Typeflag))
113		}
114		if err != nil {
115			return fmt.Errorf("unpack %q: %w", h.Name, err)
116		}
117	}
118}
119
120func writeFile(root *os.Root, name string, r io.Reader, mode int64) error {
121	if err := root.MkdirAll(path.Dir(name), 0o755); err != nil {
122		return err
123	}
124	perm := os.FileMode(0o644)
125	if mode&0o111 != 0 {
126		perm = 0o755
127	}
128	f, err := root.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, perm)
129	if err != nil {
130		return err
131	}
132	if _, err := io.Copy(f, r); err != nil {
133		_ = f.Close()
134		return err
135	}
136	if err := f.Chmod(perm); err != nil { // the umask may have removed bits
137		_ = f.Close()
138		return err
139	}
140	return f.Close()
141}
142
143// cleanName validates an archive path and returns it relative to the tree: "./"
144// prefixes and empty or "." components are dropped, and with stripTop so is the
145// first component. It returns "" for the top of the tree itself.
146func cleanName(name string, stripTop bool) (string, error) {
147	if name == "" || strings.ContainsRune(name, 0) || strings.ContainsRune(name, '\\') {
148		return "", fmt.Errorf("invalid entry name %q", name)
149	}
150	if strings.HasPrefix(name, "/") {
151		return "", fmt.Errorf("entry %q has an absolute path", name)
152	}
153	var parts []string
154	for _, p := range strings.Split(name, "/") {
155		switch p {
156		case "", ".":
157		case "..":
158			return "", fmt.Errorf("entry %q has a .. component", name)
159		default:
160			parts = append(parts, p)
161		}
162	}
163	if stripTop {
164		if len(parts) <= 1 {
165			return "", nil
166		}
167		parts = parts[1:]
168	}
169	return strings.Join(parts, "/"), nil
170}
171
172// checkLinkTarget accepts a relative target whose ".." components all come
173// first and that stays inside the tree. name is the cleaned path of the link.
174// The lexical rule is exact because no entry is ever unpacked below a symbolic
175// link.
176func checkLinkTarget(name, target string) error {
177	if target == "" || strings.ContainsRune(target, 0) || strings.HasPrefix(target, "/") {
178		return fmt.Errorf("symbolic link %q has the target %q: absolute or empty", name, target)
179	}
180	// depth is how many directories lie between the tree's top and the link.
181	depth := 0
182	if dir := path.Dir(name); dir != "." {
183		depth = strings.Count(dir, "/") + 1
184	}
185	up, seenName := 0, false
186	for _, p := range strings.Split(target, "/") {
187		switch p {
188		case "", ".":
189		case "..":
190			if seenName {
191				return fmt.Errorf("symbolic link %q: target %q climbs after a name", name, target)
192			}
193			up++
194		default:
195			seenName = true
196		}
197	}
198	if up > depth {
199		return fmt.Errorf("symbolic link %q: target %q leaves the archive", name, target)
200	}
201	return nil
202}
203
204// under reports whether name, or one of its parents, is an unpacked symbolic link.
205func under(links map[string]bool, name string) bool {
206	for p := name; p != "." && p != "/" && p != ""; p = path.Dir(p) {
207		if links[p] {
208			return true
209		}
210	}
211	return false
212}
213
214// openDirs gives every directory the mode 0755, whatever the umask of the
215// runner was when it created them: the sandboxes read the tree as another user.
216func openDirs(dest string) error {
217	return filepath.WalkDir(dest, func(p string, d fs.DirEntry, err error) error {
218		if err != nil || !d.IsDir() {
219			return err
220		}
221		return os.Chmod(p, 0o755)
222	})
223}

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