exec.go (6073B)
1 // Copyright 2020 The Hugo Authors. All rights reserved.
2 //
3 // Licensed under the Apache License, Version 2.0 (the "License");
4 // you may not use this file except in compliance with the License.
5 // You may obtain a copy of the License at
6 // http://www.apache.org/licenses/LICENSE-2.0
7 //
8 // Unless required by applicable law or agreed to in writing, software
9 // distributed under the License is distributed on an "AS IS" BASIS,
10 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11 // See the License for the specific language governing permissions and
12 // limitations under the License.
13
14 package hexec
15
16 import (
17 "bytes"
18 "context"
19 "errors"
20 "fmt"
21 "io"
22 "regexp"
23 "strings"
24
25 "os"
26 "os/exec"
27
28 "github.com/cli/safeexec"
29 "github.com/gohugoio/hugo/config"
30 "github.com/gohugoio/hugo/config/security"
31 )
32
33 var WithDir = func(dir string) func(c *commandeer) {
34 return func(c *commandeer) {
35 c.dir = dir
36 }
37 }
38
39 var WithContext = func(ctx context.Context) func(c *commandeer) {
40 return func(c *commandeer) {
41 c.ctx = ctx
42 }
43 }
44
45 var WithStdout = func(w io.Writer) func(c *commandeer) {
46 return func(c *commandeer) {
47 c.stdout = w
48 }
49 }
50
51 var WithStderr = func(w io.Writer) func(c *commandeer) {
52 return func(c *commandeer) {
53 c.stderr = w
54 }
55 }
56
57 var WithStdin = func(r io.Reader) func(c *commandeer) {
58 return func(c *commandeer) {
59 c.stdin = r
60 }
61 }
62
63 var WithEnviron = func(env []string) func(c *commandeer) {
64 return func(c *commandeer) {
65 setOrAppend := func(s string) {
66 k1, _ := config.SplitEnvVar(s)
67 var found bool
68 for i, v := range c.env {
69 k2, _ := config.SplitEnvVar(v)
70 if k1 == k2 {
71 found = true
72 c.env[i] = s
73 }
74 }
75
76 if !found {
77 c.env = append(c.env, s)
78 }
79 }
80
81 for _, s := range env {
82 setOrAppend(s)
83 }
84 }
85 }
86
87 // New creates a new Exec using the provided security config.
88 func New(cfg security.Config) *Exec {
89 var baseEnviron []string
90 for _, v := range os.Environ() {
91 k, _ := config.SplitEnvVar(v)
92 if cfg.Exec.OsEnv.Accept(k) {
93 baseEnviron = append(baseEnviron, v)
94 }
95 }
96
97 return &Exec{
98 sc: cfg,
99 baseEnviron: baseEnviron,
100 }
101 }
102
103 // IsNotFound reports whether this is an error about a binary not found.
104 func IsNotFound(err error) bool {
105 var notFoundErr *NotFoundError
106 return errors.As(err, ¬FoundErr)
107 }
108
109 // SafeCommand is a wrapper around os/exec Command which uses a LookPath
110 // implementation that does not search in current directory before looking in PATH.
111 // See https://github.com/cli/safeexec and the linked issues.
112 func SafeCommand(name string, arg ...string) (*exec.Cmd, error) {
113 bin, err := safeexec.LookPath(name)
114 if err != nil {
115 return nil, err
116 }
117
118 return exec.Command(bin, arg...), nil
119 }
120
121 // Exec encorces a security policy for commands run via os/exec.
122 type Exec struct {
123 sc security.Config
124
125 // os.Environ filtered by the Exec.OsEnviron whitelist filter.
126 baseEnviron []string
127 }
128
129 // New will fail if name is not allowed according to the configured security policy.
130 // Else a configured Runner will be returned ready to be Run.
131 func (e *Exec) New(name string, arg ...any) (Runner, error) {
132 if err := e.sc.CheckAllowedExec(name); err != nil {
133 return nil, err
134 }
135
136 env := make([]string, len(e.baseEnviron))
137 copy(env, e.baseEnviron)
138
139 cm := &commandeer{
140 name: name,
141 env: env,
142 }
143
144 return cm.command(arg...)
145
146 }
147
148 // Npx is a convenience method to create a Runner running npx --no-install <name> <args.
149 func (e *Exec) Npx(name string, arg ...any) (Runner, error) {
150 arg = append(arg[:0], append([]any{"--no-install", name}, arg[0:]...)...)
151 return e.New("npx", arg...)
152 }
153
154 // Sec returns the security policies this Exec is configured with.
155 func (e *Exec) Sec() security.Config {
156 return e.sc
157 }
158
159 type NotFoundError struct {
160 name string
161 }
162
163 func (e *NotFoundError) Error() string {
164 return fmt.Sprintf("binary with name %q not found", e.name)
165 }
166
167 // Runner wraps a *os.Cmd.
168 type Runner interface {
169 Run() error
170 StdinPipe() (io.WriteCloser, error)
171 }
172
173 type cmdWrapper struct {
174 name string
175 c *exec.Cmd
176
177 outerr *bytes.Buffer
178 }
179
180 var notFoundRe = regexp.MustCompile(`(?s)not found:|could not determine executable`)
181
182 func (c *cmdWrapper) Run() error {
183 err := c.c.Run()
184 if err == nil {
185 return nil
186 }
187 if notFoundRe.MatchString(c.outerr.String()) {
188 return &NotFoundError{name: c.name}
189 }
190 return fmt.Errorf("failed to execute binary %q with args %v: %s", c.name, c.c.Args[1:], c.outerr.String())
191 }
192
193 func (c *cmdWrapper) StdinPipe() (io.WriteCloser, error) {
194 return c.c.StdinPipe()
195 }
196
197 type commandeer struct {
198 stdout io.Writer
199 stderr io.Writer
200 stdin io.Reader
201 dir string
202 ctx context.Context
203
204 name string
205 env []string
206 }
207
208 func (c *commandeer) command(arg ...any) (*cmdWrapper, error) {
209 if c == nil {
210 return nil, nil
211 }
212
213 var args []string
214 for _, a := range arg {
215 switch v := a.(type) {
216 case string:
217 args = append(args, v)
218 case func(*commandeer):
219 v(c)
220 default:
221 return nil, fmt.Errorf("invalid argument to command: %T", a)
222 }
223 }
224
225 bin, err := safeexec.LookPath(c.name)
226 if err != nil {
227 return nil, &NotFoundError{
228 name: c.name,
229 }
230 }
231
232 outerr := &bytes.Buffer{}
233 if c.stderr == nil {
234 c.stderr = outerr
235 } else {
236 c.stderr = io.MultiWriter(c.stderr, outerr)
237 }
238
239 var cmd *exec.Cmd
240
241 if c.ctx != nil {
242 cmd = exec.CommandContext(c.ctx, bin, args...)
243 } else {
244 cmd = exec.Command(bin, args...)
245 }
246
247 cmd.Stdin = c.stdin
248 cmd.Stderr = c.stderr
249 cmd.Stdout = c.stdout
250 cmd.Env = c.env
251 cmd.Dir = c.dir
252
253 return &cmdWrapper{outerr: outerr, c: cmd, name: c.name}, nil
254 }
255
256 // InPath reports whether binaryName is in $PATH.
257 func InPath(binaryName string) bool {
258 if strings.Contains(binaryName, "/") {
259 panic("binary name should not contain any slash")
260 }
261 _, err := safeexec.LookPath(binaryName)
262 return err == nil
263 }
264
265 // LookPath finds the path to binaryName in $PATH.
266 // Returns "" if not found.
267 func LookPath(binaryName string) string {
268 if strings.Contains(binaryName, "/") {
269 panic("binary name should not contain any slash")
270 }
271 s, err := safeexec.LookPath(binaryName)
272 if err != nil {
273 return ""
274 }
275 return s
276 }