From bfe32e4e3d78af4768cb3bae96b79eaf2b9d2324 Mon Sep 17 00:00:00 2001 From: Torsten Long Date: Wed, 27 Mar 2024 16:52:06 +0100 Subject: [PATCH] Minor release 0.8.0 Signed-off-by: Torsten Long --- .github/workflows/test-lint-deploy.yml | 5 ++ README.md | 9 +++ docs/usage.md | 90 +++++++++++++++++++++++++- generate_deployable.sh | 19 ++++++ go/.gitignore | 2 + go/go.mod | 24 +++++++ go/main.go | 75 +++++++++++++++++++++ lib/command_commands.bash | 87 +++++++++++++++++++++++++ lib/shellmock.bash | 10 ++- tests/extended.bats | 80 +++++++++++++++++++++++ 10 files changed, 399 insertions(+), 2 deletions(-) create mode 100644 go/.gitignore create mode 100644 go/go.mod create mode 100644 go/main.go create mode 100644 lib/command_commands.bash diff --git a/.github/workflows/test-lint-deploy.yml b/.github/workflows/test-lint-deploy.yml index 2ba3a3e..ae953a8 100644 --- a/.github/workflows/test-lint-deploy.yml +++ b/.github/workflows/test-lint-deploy.yml @@ -100,6 +100,11 @@ jobs: with: fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version-file: "go/go.mod" + cache: true + - name: Build Package run: make build diff --git a/README.md b/README.md index 489e6c6..fb8ef13 100644 --- a/README.md +++ b/README.md @@ -50,12 +50,17 @@ The following tools are needed to use `shellmock`: - `base64` - `bash` (at least version 4.4) - `cat` +- `chmod` - `env` - `find` - `gawk` - `grep` +- `mkdir` +- `mktemp` +- `rm` - `sed` - `sort` +- `touch` - `tr` - `xargs` @@ -71,7 +76,11 @@ We recommend an installation via `npm` instead of an installation via `apt`. The reason is that many system packages provide comparatively old versions while the version installable via `npm` is up to date. +To run the [`commands` command](./docs/usage.md#commands), you also need a +[Golang][golang] toolchain. + [bats-npm-install]: https://bats-core.readthedocs.io/en/stable/installation.html#any-os-npm +[golang]: https://go.dev/doc/install ## Documentation Overview diff --git a/docs/usage.md b/docs/usage.md index 1370def..862b5d2 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -51,6 +51,8 @@ It is implemented as a shell function with the following sub-commands: Configure a previously-created mock by defining expectations. - `assert`: Assert based on previously-configured expectations. +- `commands`: + Retrieve a list of all executables and shell functions called by shell code. - `global-config`: Configure global behaviour of Shellmock itself. - `calls`: @@ -74,8 +76,12 @@ You can jump to the respective section via the following links. - [Flexible Position-Independent argspec](#flexible-position-independent-argspec) - [Regex-Based argspec](#regex-based-argspec) - [Multi-Line Mock Output](#multi-line-mock-output) + - [Forwarding Calls](#forwarding-calls) - [assert](#assert) - [Assertion Types](#assertion-types) +- [commands](#commands) + - [Dependencies](#dependencies) + - [Examples](#examples) - [global-config](#global-config) - [checkpath](#checkpath) - [killparent](#killparent) @@ -430,7 +436,7 @@ EOF shellmock config git 0 1:tag 2:--list <<< $'first\nsecond\n' ``` -### Forwarding Calls +#### Forwarding Calls It can be desirable to mock only some calls to an executable. For example, you may want to mock only `POST` request sent via `curl` but `GET` @@ -510,6 +516,88 @@ There are currently the following types of assertions. `only-expected-calls`, and `call-correspondence`. It is a convenience assertion type combining all other assertions. +### commands + + + +Syntax: +`shellmock commands [-f] [-c]` + +The `commands` command builds a list of executables used by some shell code. +The shell code that shall be checked is read from stdin. + + + +The `commands` command can be used to create a test to make sure that you know +exactly which executables your shell script uses. +Shell-builtins will not be reported. +Furthermore, by default, known shell functions will not be reported unless the +`-f` flag is provided. +The `-c` flag will modify the output by also providing the number of times the +executable or function is used. +Executable/function and count will be separated by a colon. + +#### Dependencies + +The `commands` command is not fully implemented in `bash` and with default shell +utilities. +Instead, it uses some bundled [Golang code](../go) to perform the extraction of +used commands. +Thus, in order to use the `commands` command, you have to have a +[Golang][golang] toolchain installed on your system. + +[golang]: https://go.dev/doc/install + +#### Examples + +**Example**: +Finding executables + +```bash +# Some shell code that uses "git" to pull a repository and mercurial otherwise. +shellmock commands <<< "if command -v git; then git pull; else hg pull; fi" +# Output will be: +# git +# hg +``` + +**Example**: +Some cases that cannot be found + +```bash +# Define two functions first. +func1() { + echo "Running func1." +} +func2() { + local ls=ls + func1 + # Output all files in a directory using `cat`. + find . -type f | xargs cat + # Calling "ls" but with its name stored in a variable. + "${ls}" + func1 +} + +shellmock commands -f -c <<< "$(type func2 | tail -n+2)" +# Output will be: +# find:1 +# func1:2 +# xargs:1 +``` + +Note how the `-c` flag causes the number of occurrences to be reported. +Furthermore, the `-f` flag causes `func1` to be reported, too, even though it is +a known shell function. + +Note how `cat` is not detected because it is not called directly by the script. +Instead, it is called indirectly via `xargs`. +Also note how `ls` is not detected even though it is called directly by the +script. +However, its name is stored in a shell variable. +To be able to detect such cases, the values of all shell variables would have to +be known, which is not possible without executing the script. + ### global-config diff --git a/generate_deployable.sh b/generate_deployable.sh index d6f8aa5..b6daf01 100755 --- a/generate_deployable.sh +++ b/generate_deployable.sh @@ -71,6 +71,25 @@ EOF ENDOFFILE } +# Internal Go code used to check used commands in shell code. +__shellmock_internal_init_command_search() { + local path=$1 +EOF + + echo "cat > \"\${path}/go.mod\" << 'ENDOFFILE'" + cat ./go/go.mod + + cat << 'EOF' +ENDOFFILE +EOF + + echo "cat > \"\${path}/main.go\" << 'ENDOFFILE'" + cat ./go/main.go + + cat << 'EOF' +ENDOFFILE +} + # Run initialisation steps. __shellmock_internal_init EOF diff --git a/go/.gitignore b/go/.gitignore new file mode 100644 index 0000000..6c2c294 --- /dev/null +++ b/go/.gitignore @@ -0,0 +1,2 @@ +# Always ignore the sumfile because users won't have one either. +go.sum diff --git a/go/go.mod b/go/go.mod new file mode 100644 index 0000000..8e20742 --- /dev/null +++ b/go/go.mod @@ -0,0 +1,24 @@ +// Copyright (c) 2022 - for information on the respective copyright owner +// see the NOTICE file or the repository +// https://github.com/boschresearch/shellmock +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not +// use this file except in compliance with the License. You may obtain a copy of +// the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations under +// the License. + +module main + +go 1.22.1 + +require ( + golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 + mvdan.cc/sh/v3 v3.8.0 +) diff --git a/go/main.go b/go/main.go new file mode 100644 index 0000000..5e5dca7 --- /dev/null +++ b/go/main.go @@ -0,0 +1,75 @@ +// Copyright (c) 2022 - for information on the respective copyright owner +// see the NOTICE file or the repository +// https://github.com/boschresearch/shellmock +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not +// use this file except in compliance with the License. You may obtain a copy of +// the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations under +// the License. + +package main + +import ( + "bufio" + "bytes" + "fmt" + "io" + "log" + "os" + "slices" + + "golang.org/x/exp/maps" + shell "mvdan.cc/sh/v3/syntax" +) + +// Determine all commands executed by a script. +func findCommands(shellCode shell.Node) map[string]int { + result := map[string]int{} + + shell.Walk( + shellCode, func(node shell.Node) bool { + // Simple commands. + if expr, ok := node.(*shell.CallExpr); ok { + if len(expr.Args) == 0 || len(expr.Args[0].Parts) == 0 { + // Ignore empty commands and continue searching. + return true + } + // We do not detect cases where a command is an argument. We also do not detect + // cases where the command we seek is hidden in a command substitution or shell + // expansion. + if cmd, ok := expr.Args[0].Parts[0].(*shell.Lit); ok { + result[cmd.Value]++ + } + } + // Continue searching. + return true + }, + ) + + return result +} + +func main() { + content, err := io.ReadAll(bufio.NewReader(os.Stdin)) + if err != nil { + log.Fatalf("failed to read from stdin: %s", err.Error()) + } + parsed, err := shell.NewParser().Parse(bytes.NewReader(content), "") + if err != nil { + log.Fatalf("failed to parse shell code: %s", err.Error()) + } + commands := findCommands(parsed) + keys := maps.Keys(commands) + slices.Sort(keys) + for _, cmd := range keys { + count := commands[cmd] + fmt.Printf("%s:%d\n", cmd, count) + } +} diff --git a/lib/command_commands.bash b/lib/command_commands.bash new file mode 100644 index 0000000..4cc4aca --- /dev/null +++ b/lib/command_commands.bash @@ -0,0 +1,87 @@ +#!/bin/bash + +# Copyright (c) 2022 - for information on the respective copyright owner +# see the NOTICE file or the repository +# https://github.com/boschresearch/shellmock +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +# This file contains functionality needed for the command used to check which +# executables a script is executing. That makes it easier to determine which +# ones to mock. +__shellmock__commands() { + __shellmock_internal_pathcheck + __shellmock_internal_trapcheck + + local check_functions=0 + local usage_counts=0 + while [[ $# -gt 0 ]]; do + case $1 in + --check-functions | -f) + local check_functions=1 + ;; + --usage-counts | -c) + local usage_counts=1 + ;; + *) + echo >&2 "Unknown argument '$1'." + return 1 + ;; + esac + shift + done + + if ! command -v go &> /dev/null; then + echo >&2 "The 'commands' command requires a Go toolchain." \ + "Get it from here: https://go.dev/doc/install" + return 1 + fi + + if [[ -t 0 ]]; then + echo >&2 "Shell code is read from stdin but stdin is a terminal, aborting." + return 1 + fi + local code + code="$(cat -)" + + # Build the binary used to analyse the shell code. + local bin="${__SHELLMOCK_GO_MOD}/main" + if ! [[ -x ${bin} ]]; then + __shellmock_internal_init_command_search "${__SHELLMOCK_GO_MOD}" + (cd "${__SHELLMOCK_GO_MOD}" && go get && go build) 1>&2 + fi + + declare -A builtins + local tmp + while read -r tmp; do + builtins["${tmp}"]=1 + done < <(compgen -b) && wait $! || return 1 + + local cmd + while read -r tmp; do + cmd="${tmp%:*}" + # Only output if it is neither a currently defined function or a built-in. + if + [[ -z ${builtins["${cmd}"]-} ]] \ + && [[ ${check_functions} -eq 1 || + $(type -t "${cmd}" || :) != function ]] + then + # Adjust output format as requested. + if [[ ${usage_counts} -eq 1 ]]; then + echo "${tmp}" + else + echo "${cmd}" + fi + fi + done < <("${bin}" <<< "${code}") && wait $! || return 1 +} diff --git a/lib/shellmock.bash b/lib/shellmock.bash index 44e0956..bdeb906 100644 --- a/lib/shellmock.bash +++ b/lib/shellmock.bash @@ -20,7 +20,10 @@ __shellmock_mktemp() { local has_bats=$1 local what=$2 local dir - dir=$(mktemp -d -p "${BATS_TEST_TMPDIR-${TMPDIR-/tmp}}") + local base="${BATS_TEST_TMPDIR-${TMPDIR-/tmp}}/shellmock" + local template="${what// /_}.XXXXXXXXXX" + mkdir -p "${base}" + dir=$(mktemp -d -p "${base}" "${template}") if [[ ${has_bats} -eq 0 ]]; then echo >&2 "Keeping ${what} in: ${dir}" fi @@ -63,6 +66,11 @@ __shellmock_internal_init() { __shellmock_mktemp "${has_bats}" "call records" )" + declare -gx __SHELLMOCK_GO_MOD + __SHELLMOCK_GO_MOD="$( + __shellmock_mktemp "${has_bats}" "go code" + )" + declare -gx __SHELLMOCK_PATH # Remember the value of "${PATH}" when shellmock was loaded, including the # prepended mockbin dir. diff --git a/tests/extended.bats b/tests/extended.bats index 586fb0a..3028779 100644 --- a/tests/extended.bats +++ b/tests/extended.bats @@ -23,6 +23,7 @@ setup_file() { } setup() { + root="$(git rev-parse --show-toplevel)" load ../shellmock # shellcheck disable=SC2086 # We want to perform word splitting here. set ${TEST_OPTS-"--"} @@ -91,3 +92,82 @@ setup() { TMPDIR="${tmpdir}" BATS_TEST_TMPDIR="" __shellmock_internal_init [[ -z $(trap -p -- RETURN) ]] } + +_join() { + local sep=$1 + shift + ( + IFS="${sep}" + echo "$*" + ) +} + +@test "finding executables in shell code" { + run -0 --separate-stderr shellmock commands << 'EOF' +echo "Built-ins are not detected." +if which bash; then + echo "Arguments that happen to be commands are not detected." +fi +if which bash; then + echo "Using an executable multiple times is OK." +fi +echo "Shell functions are not reported." +shellmock new git +shellmock new curl +shellmock new wget +echo "Normal executables are reported." +ls +ls +find +awk +EOF + + [[ ${output} == $(_join $'\n' awk find ls which) ]] +} + +@test "counting executables and functions in shell code" { + run -0 --separate-stderr shellmock commands -c -f << 'EOF' +echo "Built-ins are not detected." +if which bash; then + echo "Arguments that happen to be commands are not detected." +fi +if which bash; then + echo "Using an executable multiple times is OK." +fi +echo "Shell functions are also reported." +shellmock new git +shellmock new curl +shellmock new wget +echo "Normal executables are reported." +ls +ls +find +awk +EOF + + [[ ${output} == $(_join $'\n' awk:1 find:1 ls:2 shellmock:3 which:2) ]] +} + +@test "that we know which executables shellmock uses" { + run -0 --separate-stderr shellmock commands < "${root}/shellmock.bash" + + exes=( + base32 + base64 + cat + chmod + env + find + go + grep + mkdir + mktemp + rm + sed + sort + touch + tr + xargs + ) + [[ ${output} == $(_join $'\n' "${exes[@]}") ]] +}