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 }