github.com/linchen2chris/hugo@v0.0.0-20230307053224-cec209389705/common/hexec/exec.go (about)

     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, &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.
   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  }