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  }