github.com/github/skeema@v1.2.6/util/shellout.go (about)

     1  package util
     2  
     3  import (
     4  	"context"
     5  	"errors"
     6  	"fmt"
     7  	"os"
     8  	"os/exec"
     9  	"regexp"
    10  	"strings"
    11  	"time"
    12  )
    13  
    14  // ShellOut represents a command-line for an external command, executed via sh -c
    15  type ShellOut struct {
    16  	Command          string
    17  	PrintableCommand string        // Used in String() if non-empty; useful for hiding passwords in output
    18  	Dir              string        // Initial working dir for the command if non-empty
    19  	Timeout          time.Duration // If > 0, kill process after this amount of time
    20  	CombineOutput    bool          // If true, combine stdout and stderr into a single stream
    21  	cancelFunc       context.CancelFunc
    22  }
    23  
    24  func (s *ShellOut) String() string {
    25  	if s.PrintableCommand != "" {
    26  		return s.PrintableCommand
    27  	}
    28  	return s.Command
    29  }
    30  
    31  func (s *ShellOut) cmd() *exec.Cmd {
    32  	if s.Timeout > 0 {
    33  		ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
    34  		s.cancelFunc = cancel
    35  		return exec.CommandContext(ctx, "/bin/sh", "-c", s.Command)
    36  	}
    37  	return exec.Command("/bin/sh", "-c", s.Command)
    38  }
    39  
    40  // Run shells out to the external command and blocks until it completes. It
    41  // returns an error if one occurred. STDIN, STDOUT, and STDERR will be
    42  // redirected to those of the parent process.
    43  func (s *ShellOut) Run() error {
    44  	if s.Command == "" {
    45  		return errors.New("Attempted to shell out to an empty command string")
    46  	}
    47  	cmd := s.cmd()
    48  	if s.cancelFunc != nil {
    49  		defer s.cancelFunc()
    50  	}
    51  	cmd.Dir = s.Dir
    52  	cmd.Stdin = os.Stdin
    53  	cmd.Stdout = os.Stdout
    54  	if s.CombineOutput {
    55  		cmd.Stderr = os.Stdout
    56  	} else {
    57  		cmd.Stderr = os.Stderr
    58  	}
    59  	return cmd.Run()
    60  }
    61  
    62  // RunCapture shells out to the external command and blocks until it completes.
    63  // It returns the command's STDOUT output as a single string, optionally with
    64  // STDERR if CombineOutput is true; otherwise STDERR is redirected to that of
    65  // the parent process. STDIN is always redirected from the parent process.
    66  func (s *ShellOut) RunCapture() (string, error) {
    67  	if s.Command == "" {
    68  		return "", errors.New("Attempted to shell out to an empty command string")
    69  	}
    70  	cmd := s.cmd()
    71  	if s.cancelFunc != nil {
    72  		defer s.cancelFunc()
    73  	}
    74  	cmd.Dir = s.Dir
    75  	cmd.Stdin = os.Stdin
    76  
    77  	var out []byte
    78  	var err error
    79  	if s.CombineOutput {
    80  		out, err = cmd.CombinedOutput()
    81  	} else {
    82  		cmd.Stderr = os.Stderr
    83  		out, err = cmd.Output()
    84  	}
    85  	return string(out), err
    86  }
    87  
    88  // RunCaptureSplit behaves like RunCapture, except the output will be tokenized.
    89  // If newlines are present in the output, it will be split on newlines; else if
    90  // commas are present, it will be split on commas; else ditto for tabs; else
    91  // ditto for spaces. Blank tokens will be ignored (i.e. 2 delimiters in a row
    92  // get treated as a single delimiter; leading or trailing delimiter is ignored).
    93  // Does NOT provide any special treatment for quoted fields in the output.
    94  func (s *ShellOut) RunCaptureSplit() ([]string, error) {
    95  	raw, err := s.RunCapture()
    96  	raw = strings.TrimSpace(raw) // in case output ends in newline despite using a different delimiter
    97  	var delimiter rune
    98  	for _, candidate := range []rune{'\n', ',', '\t', ' '} {
    99  		if strings.ContainsRune(raw, candidate) {
   100  			delimiter = candidate
   101  			break
   102  		}
   103  	}
   104  	if delimiter == 0 {
   105  		// No delimiter found: just return the full output as a slice with 1 element,
   106  		// or 0 elements if it was a blank string
   107  		if raw == "" {
   108  			return []string{}, err
   109  		}
   110  		return []string{raw}, err
   111  	}
   112  	tokens := strings.Split(raw, string(delimiter))
   113  	result := make([]string, 0, len(tokens))
   114  	for _, token := range tokens {
   115  		token = strings.TrimSpace(token)
   116  		if token != "" {
   117  			result = append(result, token)
   118  		}
   119  	}
   120  	return result, err
   121  }
   122  
   123  // varPlaceholder is a regexp for detecting placeholders of format "{VARNAME}"
   124  // in NewInterpolatedShellOut()
   125  var varPlaceholder = regexp.MustCompile(`{([^}]*)}`)
   126  
   127  // NewInterpolatedShellOut takes a shell command-line containing variables of
   128  // format {VARNAME}, and performs substitution on them based on the supplied
   129  // map of variable names to values.
   130  //
   131  // Variable names should be supplied in all-caps in the variables map. Inside
   132  // of command, they are case-insensitive. If any unknown variable is contained
   133  // in the command string, a non-nil error will be returned and the unknown
   134  // variable will not be interpolated.
   135  //
   136  // As a special case, any variable name may appear with an X suffix. This will
   137  // still be replaced as normal in the generated ShellOut.Command, but will
   138  // appear as all X's in ShellOut.PrintableCommand. For example, if the command
   139  // string contains "{PASSWORDX}" and variables has a key "PASSWORD", it will be
   140  // replaced in a manner that obfuscates the actual password in PrintableCommand.
   141  func NewInterpolatedShellOut(command string, variables map[string]string) (*ShellOut, error) {
   142  	var forDisplay bool // affects behavior of replacer closure
   143  	var err error       // may be mutated by replacer closure
   144  	replacer := func(input string) string {
   145  		varName := strings.ToUpper(input[1 : len(input)-1])
   146  		value, ok := variables[varName]
   147  		if !ok && varName[len(varName)-1] == 'X' {
   148  			value, ok = variables[varName[:len(varName)-1]]
   149  			if ok && forDisplay {
   150  				return "XXXXX"
   151  			}
   152  		}
   153  		if !ok {
   154  			err = fmt.Errorf("Unknown variable %s", input)
   155  			return input
   156  		}
   157  		return escapeVarValue(value)
   158  	}
   159  
   160  	s := &ShellOut{}
   161  	s.Command = varPlaceholder.ReplaceAllStringFunc(command, replacer)
   162  	if strings.Contains(strings.ToUpper(command), "X}") {
   163  		forDisplay = true
   164  		s.PrintableCommand = varPlaceholder.ReplaceAllStringFunc(command, replacer)
   165  	}
   166  	return s, err
   167  }
   168  
   169  // noQuotesNeeded is a regexp for detecting which variable values do not require
   170  // escaping and quote-wrapping in escapeVarValue()
   171  var noQuotesNeeded = regexp.MustCompile(`^[\w/@%=:.,+-]*$`)
   172  
   173  // escapeVarValue takes a string, and wraps it in single-quotes so that it will
   174  // be interpretted as a single arg in a shell-out command line. If the value
   175  // already contained any single-quotes, they will be escaped in a way that will
   176  // cause /bin/sh -c to still interpret them as part of a single arg.
   177  func escapeVarValue(value string) string {
   178  	if noQuotesNeeded.MatchString(value) {
   179  		return value
   180  	}
   181  	return fmt.Sprintf("'%s'", strings.Replace(value, "'", `'"'"'`, -1))
   182  }