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 }