github.com/Lephar/snapd@v0.0.0-20210825215435-c7fba9cef4d2/testutil/exec.go (about) 1 // -*- Mode: Go; indent-tabs-mode: t -*- 2 3 /* 4 * Copyright (C) 2016 Canonical Ltd 5 * 6 * This program is free software: you can redistribute it and/or modify 7 * it under the terms of the GNU General Public License version 3 as 8 * published by the Free Software Foundation. 9 * 10 * This program is distributed in the hope that it will be useful, 11 * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 * GNU General Public License for more details. 14 * 15 * You should have received a copy of the GNU General Public License 16 * along with this program. If not, see <http://www.gnu.org/licenses/>. 17 * 18 */ 19 20 package testutil 21 22 import ( 23 "bytes" 24 "fmt" 25 "io" 26 "io/ioutil" 27 "os" 28 "os/exec" 29 "path" 30 "path/filepath" 31 "strings" 32 "sync" 33 34 "gopkg.in/check.v1" 35 ) 36 37 var shellcheckPath string 38 39 func init() { 40 if p, err := exec.LookPath("shellcheck"); err == nil { 41 shellcheckPath = p 42 } 43 } 44 45 var ( 46 shellchecked = make(map[string]bool, 16) 47 shellcheckedMu sync.Mutex 48 ) 49 50 func shellcheckSeenAlready(script string) bool { 51 shellcheckedMu.Lock() 52 defer shellcheckedMu.Unlock() 53 if shellchecked[script] { 54 return true 55 } 56 shellchecked[script] = true 57 return false 58 } 59 60 var pristineEnv = os.Environ() 61 62 func maybeShellcheck(c *check.C, script string, wholeScript io.Reader) { 63 // MockCommand is used sometimes in SetUptTest, so it adds up 64 // even for the empty script, don't recheck the essentially same 65 // thing again and again! 66 if shellcheckSeenAlready(script) { 67 return 68 } 69 c.Logf("using shellcheck: %q", shellcheckPath) 70 if shellcheckPath == "" { 71 // no shellcheck, nothing to do 72 return 73 } 74 cmd := exec.Command(shellcheckPath, "-s", "bash", "-") 75 cmd.Env = pristineEnv 76 cmd.Stdin = wholeScript 77 out, err := cmd.CombinedOutput() 78 c.Check(err, check.IsNil, check.Commentf("shellcheck failed:\n%s", string(out))) 79 } 80 81 // MockCmd allows mocking commands for testing. 82 type MockCmd struct { 83 binDir string 84 exeFile string 85 logFile string 86 } 87 88 // The top of the script generate the output to capture the 89 // command that was run and the arguments used. To support 90 // mocking commands that need "\n" in their args (like zenity) 91 // we use the following convention: 92 // - generate \0 to separate args 93 // - generate \0\0 to separate commands 94 var scriptTpl = `#!/bin/bash 95 ###LOCK### 96 printf "%%s" "$(basename "$0")" >> %[1]q 97 printf '\0' >> %[1]q 98 99 for arg in "$@"; do 100 printf "%%s" "$arg" >> %[1]q 101 printf '\0' >> %[1]q 102 done 103 104 printf '\0' >> %[1]q 105 %s 106 ` 107 108 // Wrap the script in flock to serialize the calls to the script and prevent the 109 // call log from getting corrupted. Workaround 14.04 flock(1) weirdness, that 110 // keeps the script file open for writing and execve() fails with ETXTBSY. 111 var selfLock = `if [ "${FLOCKER}" != "$0" ]; then exec env FLOCKER="$0" flock -e "$(dirname "$0")" "$0" "$@" ; fi` 112 113 func mockCommand(c *check.C, basename, script, template string) *MockCmd { 114 var wholeScript bytes.Buffer 115 var binDir, exeFile, logFile string 116 var newpath string 117 if filepath.IsAbs(basename) { 118 binDir = filepath.Dir(basename) 119 err := os.MkdirAll(binDir, 0755) 120 if err != nil { 121 panic(fmt.Sprintf("cannot create the directory for mocked command %q: %v", basename, err)) 122 } 123 exeFile = basename 124 logFile = basename + ".log" 125 } else { 126 binDir = c.MkDir() 127 exeFile = path.Join(binDir, basename) 128 logFile = path.Join(binDir, basename+".log") 129 newpath = binDir + ":" + os.Getenv("PATH") 130 } 131 fmt.Fprintf(&wholeScript, template, logFile, script) 132 err := ioutil.WriteFile(exeFile, wholeScript.Bytes(), 0700) 133 if err != nil { 134 panic(err) 135 } 136 137 maybeShellcheck(c, script, &wholeScript) 138 139 if newpath != "" { 140 os.Setenv("PATH", binDir+":"+os.Getenv("PATH")) 141 } 142 143 return &MockCmd{binDir: binDir, exeFile: exeFile, logFile: logFile} 144 } 145 146 // MockCommand adds a mocked command. If the basename argument is a command it 147 // is added to PATH. If it is an absolute path it is just created there, along 148 // with the full prefix. The caller is responsible for the cleanup in this case. 149 // 150 // The command logs all invocations to a dedicated log file. If script is 151 // non-empty then it is used as is and the caller is responsible for how the 152 // script behaves (exit code and any extra behavior). If script is empty then 153 // the command exits successfully without any other side-effect. 154 func MockCommand(c *check.C, basename, script string) *MockCmd { 155 return mockCommand(c, basename, script, strings.Replace(scriptTpl, "###LOCK###", "", 1)) 156 } 157 158 // MockLockedCommand is the same as MockCommand(), but the script uses flock to 159 // enforce exclusive locking, preventing the call tracking from being corrupted. 160 // Thus it is safe to be called in parallel. 161 func MockLockedCommand(c *check.C, basename, script string) *MockCmd { 162 return mockCommand(c, basename, script, strings.Replace(scriptTpl, "###LOCK###", selfLock, 1)) 163 } 164 165 // Also mock this command, using the same bindir and log. 166 // Useful when you want to check the ordering of things. 167 func (cmd *MockCmd) Also(basename, script string) *MockCmd { 168 exeFile := path.Join(cmd.binDir, basename) 169 err := ioutil.WriteFile(exeFile, []byte(fmt.Sprintf(scriptTpl, cmd.logFile, script)), 0700) 170 if err != nil { 171 panic(err) 172 } 173 return &MockCmd{binDir: cmd.binDir, exeFile: exeFile, logFile: cmd.logFile} 174 } 175 176 // Restore removes the mocked command from PATH 177 func (cmd *MockCmd) Restore() { 178 entries := strings.Split(os.Getenv("PATH"), ":") 179 for i, entry := range entries { 180 if entry == cmd.binDir { 181 entries = append(entries[:i], entries[i+1:]...) 182 break 183 } 184 } 185 os.Setenv("PATH", strings.Join(entries, ":")) 186 } 187 188 // Calls returns a list of calls that were made to the mock command. 189 // of the form: 190 // [][]string{ 191 // {"cmd", "arg1", "arg2"}, // first invocation of "cmd" 192 // {"cmd", "arg1", "arg2"}, // second invocation of "cmd" 193 // } 194 func (cmd *MockCmd) Calls() [][]string { 195 raw, err := ioutil.ReadFile(cmd.logFile) 196 if os.IsNotExist(err) { 197 return nil 198 } 199 if err != nil { 200 panic(err) 201 } 202 logContent := strings.TrimSuffix(string(raw), "\000") 203 204 allCalls := [][]string{} 205 calls := strings.Split(logContent, "\000\000") 206 for _, call := range calls { 207 call = strings.TrimSuffix(call, "\000") 208 allCalls = append(allCalls, strings.Split(call, "\000")) 209 } 210 return allCalls 211 } 212 213 // ForgetCalls purges the list of calls made so far 214 func (cmd *MockCmd) ForgetCalls() { 215 err := os.Remove(cmd.logFile) 216 if os.IsNotExist(err) { 217 return 218 } 219 if err != nil { 220 panic(err) 221 } 222 } 223 224 // BinDir returns the location of the directory holding overridden commands. 225 func (cmd *MockCmd) BinDir() string { 226 return cmd.binDir 227 } 228 229 // Exe return the full path of the mock binary 230 func (cmd *MockCmd) Exe() string { 231 return filepath.Join(cmd.exeFile) 232 }