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
14package hexec
15
16import (
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
33var WithDir = func(dir string) func(c *commandeer) {
34	return func(c *commandeer) {
35		c.dir = dir
36	}
37}
38
39var WithContext = func(ctx context.Context) func(c *commandeer) {
40	return func(c *commandeer) {
41		c.ctx = ctx
42	}
43}
44
45var WithStdout = func(w io.Writer) func(c *commandeer) {
46	return func(c *commandeer) {
47		c.stdout = w
48	}
49}
50
51var WithStderr = func(w io.Writer) func(c *commandeer) {
52	return func(c *commandeer) {
53		c.stderr = w
54	}
55}
56
57var WithStdin = func(r io.Reader) func(c *commandeer) {
58	return func(c *commandeer) {
59		c.stdin = r
60	}
61}
62
63var 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.
88func 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.
104func IsNotFound(err error) bool {
105	var notFoundErr *NotFoundError
106	return errors.As(err, &notFoundErr)
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.
112func 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.
122type 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.
131func (e *Exec) New(name string, arg ...interface{}) (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.
149func (e *Exec) Npx(name string, arg ...interface{}) (Runner, error) {
150	arg = append(arg[:0], append([]interface{}{"--no-install", name}, arg[0:]...)...)
151	return e.New("npx", arg...)
152}
153
154// Sec returns the security policies this Exec is configured with.
155func (e *Exec) Sec() security.Config {
156	return e.sc
157}
158
159type NotFoundError struct {
160	name string
161}
162
163func (e *NotFoundError) Error() string {
164	return fmt.Sprintf("binary with name %q not found", e.name)
165}
166
167// Runner wraps a *os.Cmd.
168type Runner interface {
169	Run() error
170	StdinPipe() (io.WriteCloser, error)
171}
172
173type cmdWrapper struct {
174	name string
175	c    *exec.Cmd
176
177	outerr *bytes.Buffer
178}
179
180var notFoundRe = regexp.MustCompile(`(?s)not found:|could not determine executable`)
181
182func (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
193func (c *cmdWrapper) StdinPipe() (io.WriteCloser, error) {
194	return c.c.StdinPipe()
195}
196
197type 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
208func (c *commandeer) command(arg ...interface{}) (*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.
257func 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.
267func 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}
277