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}