From 5e13f2c2c732740b4f5ff1b5e5b5fa6dd93c1b01 Mon Sep 17 00:00:00 2001 From: cheyang Date: Fri, 19 Jan 2024 11:44:32 +0800 Subject: [PATCH] Use utils.command to replace exec.command (#3686) * Use simpleCommand to enhance validation, To #53506158 Signed-off-by: cheyang Remove unused method, To #53506158 Signed-off-by: cheyang * Remove unused method, To #53506158 Signed-off-by: cheyang * Remove unused method, To #53506158 Signed-off-by: cheyang --------- Signed-off-by: cheyang --- pkg/csi/plugins/nodeserver.go | 10 +- pkg/utils/exec.go | 86 +++++++++++++++ pkg/utils/exec_test.go | 188 +++++++++++++++++++++++++++++++++ pkg/utils/helm/helm.go | 58 ---------- pkg/utils/helm/helm_test.go | 90 ---------------- pkg/utils/helm/utils.go | 30 ++++-- pkg/utils/helm/utils_test.go | 33 ++++++ pkg/utils/home.go | 25 +++-- pkg/utils/kubectl/configmap.go | 6 +- pkg/utils/mount.go | 7 +- pkg/utils/mount_test.go | 4 + 11 files changed, 368 insertions(+), 169 deletions(-) create mode 100644 pkg/utils/exec.go create mode 100644 pkg/utils/exec_test.go diff --git a/pkg/csi/plugins/nodeserver.go b/pkg/csi/plugins/nodeserver.go index 7b871571797..3363a0ecf53 100644 --- a/pkg/csi/plugins/nodeserver.go +++ b/pkg/csi/plugins/nodeserver.go @@ -161,7 +161,10 @@ func (ns *nodeServer) NodePublishVolume(ctx context.Context, req *csi.NodePublis } else { args = append(args, mountPath, targetPath) } - command := exec.Command("mount", args...) + command, err := utils.SimpleCommand("mount", args...) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } glog.V(3).Infof("NodePublishVolume: exec command %v", command) stdoutStderr, err := command.CombinedOutput() @@ -508,7 +511,10 @@ func checkMountInUse(volumeName string) (bool, error) { // TODO: refer to https://github.com/kubernetes-sigs/alibaba-cloud-csi-driver/blob/4fcb743220371de82d556ab0b67b08440b04a218/pkg/oss/utils.go#L72 // for a better implementation - command := exec.Command("/usr/local/bin/check_bind_mounts.sh", volumeName) + command, err := utils.SimpleCommand("/usr/local/bin/check_bind_mounts.sh", volumeName) + if err != nil { + return inUse, err + } glog.Infoln(command) stdoutStderr, err := command.CombinedOutput() diff --git a/pkg/utils/exec.go b/pkg/utils/exec.go new file mode 100644 index 00000000000..ae05eec1d0e --- /dev/null +++ b/pkg/utils/exec.go @@ -0,0 +1,86 @@ +/* +Copyright 2024 The Fluid Authors. + +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 utils + +import ( + "fmt" + "os/exec" + "path/filepath" + "strings" +) + +func init() { + allowPathlist = buildPathList(allowPathlist) +} + +// allowPathlist of safe commands +var allowPathlist = map[string]bool{ + // "helm": true, + "kubectl": true, + "ddc-helm": true, + // add other commands as needed +} + +// illegalChars to check +// var illegalChars = []string{"&", "|", ";", "$", "'", "`", "(", ")", ">>"} +var illegalChars = []rune{'&', '|', ';', '$', '\'', '`', '(', ')', '>'} + +// buildPathList is a function that builds a map of paths for the given pathList. +func buildPathList(pathList map[string]bool) (targetPath map[string]bool) { + targetPath = make(map[string]bool) + for name, enabled := range pathList { + if filepath.Base(name) == name { + path, err := exec.LookPath(name) + if err != nil { + log.Info("Failed to find path %s due to %v", path, err) + + } else { + targetPath[path] = enabled + } + } + targetPath[name] = enabled + } + return +} + +// SimpleCommand checks the args before creating *exec.Cmd +func SimpleCommand(name string, arg ...string) (cmd *exec.Cmd, err error) { + if allowPathlist[name] { + cmd = exec.Command(name, arg...) + } else { + err = checkCommandArgs(arg...) + if err != nil { + return + } + cmd = exec.Command(name, arg...) + } + + return +} + +// CheckCommandArgs is check string is valid in args +func checkCommandArgs(arg ...string) (err error) { + for _, value := range arg { + for _, illegalChar := range illegalChars { + if strings.ContainsRune(value, illegalChar) { + return fmt.Errorf("args %s has illegal access with illegalChar %c", value, illegalChar) + } + } + } + + return +} diff --git a/pkg/utils/exec_test.go b/pkg/utils/exec_test.go new file mode 100644 index 00000000000..036cb4a6eec --- /dev/null +++ b/pkg/utils/exec_test.go @@ -0,0 +1,188 @@ +/* +Copyright 2024 The Fluid Authors. + +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 utils + +import ( + "fmt" + "os/exec" + "reflect" + "testing" + + "github.com/brahma-adshonor/gohook" +) + +func TestCheckCommandArgs(t *testing.T) { + type args struct { + arg []string + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "Test with illegal arguments", + args: args{ + arg: []string{"ls", "|", "grep go"}, + }, + wantErr: true, + }, + { + name: "Test with legal arguments", + args: args{ + arg: []string{"ls"}, + }, + wantErr: false, + }, { + name: "Test with legal arguments2", + args: args{ + arg: []string{"echo test > /dev/null"}, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := checkCommandArgs(tt.args.arg...); (err != nil) != tt.wantErr { + t.Errorf("checkCommandArgs() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestSimpleCommand(t *testing.T) { + type args struct { + name string + arg []string + } + tests := []struct { + name string + args args + wantCmd *exec.Cmd + wantErr bool + }{ + { + name: "Allow path list", + args: args{ + name: "kubectl", + arg: []string{"Hello", "World"}, + }, + wantCmd: &exec.Cmd{ + Path: "echo", + Args: []string{"Hello", "World"}, + }, + wantErr: false, + }, { + name: "Valid command arguments", + args: args{ + name: "echo", + arg: []string{"Hello", "World"}, + }, + wantCmd: &exec.Cmd{ + Path: "echo", + Args: []string{"Hello", "World"}, + }, + wantErr: false, + }, + { + name: "Invalid command arguments", + args: args{ + name: "echo", + arg: []string{"Hello", "World&"}, + }, + wantCmd: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd, err := SimpleCommand(tt.args.name, tt.args.arg...) + if (err != nil) != tt.wantErr { + t.Errorf("SimpleCommand() error = %v, wantErr %v", err, tt.wantErr) + return + } + if err != nil { + return + } + tt.wantCmd = exec.Command(tt.args.name, tt.args.arg...) + if !reflect.DeepEqual(tt.wantCmd.Args, cmd.Args) { + t.Errorf("SimpleCommand() = %v, want %v", tt.args.arg, cmd.Args) + } + if !reflect.DeepEqual(tt.wantCmd.Path, cmd.Path) { + t.Errorf("SimpleCommand() = %v, want %v", tt.args.arg, cmd.Args) + } + }) + } +} + +func Test_buildPathList(t *testing.T) { + type args struct { + pathList map[string]bool + } + tests := []struct { + name string + args args + mockLookpathFunc func(file string) (string, error) + want map[string]bool + }{ + { + name: "Test with command 'kubectl'", + args: args{ + pathList: map[string]bool{"kubectl": true}, + }, + mockLookpathFunc: func(file string) (string, error) { + return "/path/to/" + file, nil // Mocked path + }, + want: map[string]bool{"kubectl": true, "/path/to/kubectl": true}, // assuming '/path/to/kubectl' is the path of the 'kubectl' command + }, + { + name: "Test with nonexistent command", + args: args{ + pathList: map[string]bool{"nonexistent": true}, + }, mockLookpathFunc: func(file string) (string, error) { + return "", fmt.Errorf("Failed to find path") + }, + want: map[string]bool{"nonexistent": true}, // as 'nonexistent' command does not exist, so the result should be same as initial + }, + { + name: "Test with full path command", + args: args{ + pathList: map[string]bool{"/usr/local/bin/kubectl": true}, + }, + mockLookpathFunc: func(file string) (string, error) { + return "/path/to/" + file, nil // Mocked path + }, + want: map[string]bool{"/usr/local/bin/kubectl": true}, // since '/usr/local/bin/kubectl' command already has full path, so the result should be same as initial + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := gohook.Hook(exec.LookPath, tt.mockLookpathFunc, nil) + if err != nil { + t.Fatalf("failed to hook function: %v", err) + } + got := buildPathList(tt.args.pathList) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("buildPathList() = %v, want %v", got, tt.want) + } + _ = gohook.UnHook(tt.mockLookpathFunc) + + }) + } +} diff --git a/pkg/utils/helm/helm.go b/pkg/utils/helm/helm.go index f07870eace7..3be00a45448 100644 --- a/pkg/utils/helm/helm.go +++ b/pkg/utils/helm/helm.go @@ -54,64 +54,6 @@ func GenerateValueFile(values interface{}) (valueFileName string, err error) { return valueFileName, err } -// GenerateHelmTemplate generates helm template without tiller: helm template -f values.yaml chart_name -// Exec /usr/local/bin/helm, [template -f /tmp/values313606961 --namespace default --name hj /charts/tf-horovod] -// returns generated template file: templateFileName -func GenerateHelmTemplate(name string, namespace string, valueFileName string, chartName string, options ...string) (templateFileName string, err error) { - tempName := fmt.Sprintf("%s.yaml", name) - templateFile, err := os.CreateTemp("", tempName) - if err != nil { - return templateFileName, err - } - templateFileName = templateFile.Name() - - binary, err := exec.LookPath(helmCmd[0]) - if err != nil { - return templateFileName, err - } - - // 3. check if the chart file exists - // if strings.HasPrefix(chartName, "/") { - if _, err = os.Stat(chartName); err != nil { - return templateFileName, err - } - // } - - // 4. prepare the arguments - args := []string{binary, "template", "-f", valueFileName, - "--namespace", namespace, - "--name", name, - } - if len(options) != 0 { - args = append(args, options...) - } - - args = append(args, []string{chartName, ">", templateFileName}...) - - log.V(1).Info("Exec bash -c ", "cmd", args) - - // return syscall.Exec(cmd, args, env) - // 5. execute the command - log.V(1).Info("Generating template", "args", args) - cmd := exec.Command("bash", "-c", strings.Join(args, " ")) - // cmd.Env = env - out, err := cmd.CombinedOutput() - fmt.Printf("%s", string(out)) - if err != nil { - return templateFileName, fmt.Errorf("failed to execute %s, %v with %v", binary, args, err) - } - - // // 6. clean up the value file if needed - // if log.GetLevel() != log.DebugLevel { - // err = os.Remove(valueFileName) - // if err != nil { - // log.Warnf("Failed to delete %s due to %v", valueFileName, err) - // } - // } - - return templateFileName, nil -} - // GetChartVersion checks the chart version by given the chart directory // helm inspect chart /charts/tf-horovod func GetChartVersion(chart string) (version string, err error) { diff --git a/pkg/utils/helm/helm_test.go b/pkg/utils/helm/helm_test.go index 30d82630d3d..35e9dc8a1c5 100644 --- a/pkg/utils/helm/helm_test.go +++ b/pkg/utils/helm/helm_test.go @@ -17,96 +17,6 @@ func TestGenerateValueFile(t *testing.T) { } } -func TestGenerateHelmTemplate(t *testing.T) { - CombinedOutputCommon := func(cmd *exec.Cmd) ([]byte, error) { - return []byte("test-output"), nil - } - CombinedOutputErr := func(cmd *exec.Cmd) ([]byte, error) { - return nil, errors.New("fail to run the command") - } - StatCommon := func(name string) (os.FileInfo, error) { - return nil, nil - } - StatErr := func(name string) (os.FileInfo, error) { - return nil, errors.New("fail to run the command") - } - LookPathCommon := func(file string) (string, error) { - return "test-path", nil - } - LookPathErr := func(file string) (string, error) { - return "", errors.New("fail to run the command") - } - - wrappedUnhookCombinedOutput := func() { - err := gohook.UnHook((*exec.Cmd).CombinedOutput) - if err != nil { - t.Fatal(err.Error()) - } - } - wrappedUnhookStat := func() { - err := gohook.UnHook(os.Stat) - if err != nil { - t.Fatal(err.Error()) - } - } - wrappedUnhookLookPath := func() { - err := gohook.UnHook(exec.LookPath) - if err != nil { - t.Fatal(err.Error()) - } - } - - err := gohook.Hook(exec.LookPath, LookPathErr, nil) - if err != nil { - t.Fatal(err.Error()) - } - _, err = GenerateHelmTemplate("fluid", "default", "testValueFile", "testChartName") - if err == nil { - t.Errorf("fail to catch the error") - } - wrappedUnhookLookPath() - - err = gohook.Hook(exec.LookPath, LookPathCommon, nil) - if err != nil { - t.Fatal(err.Error()) - } - err = gohook.Hook(os.Stat, StatErr, nil) - if err != nil { - t.Fatal(err.Error()) - } - _, err = GenerateHelmTemplate("fluid", "default", "testValueFile", "testChartName") - if err == nil { - t.Errorf("fail to catch the error") - } - wrappedUnhookStat() - - err = gohook.Hook(os.Stat, StatCommon, nil) - if err != nil { - t.Fatal(err.Error()) - } - err = gohook.Hook((*exec.Cmd).CombinedOutput, CombinedOutputErr, nil) - if err != nil { - t.Fatal(err.Error()) - } - _, err = GenerateHelmTemplate("fluid", "default", "testValueFile", "testChartName") - if err == nil { - t.Errorf("fail to catch the error") - } - wrappedUnhookCombinedOutput() - - err = gohook.Hook((*exec.Cmd).CombinedOutput, CombinedOutputCommon, nil) - if err != nil { - t.Fatal(err.Error()) - } - _, err = GenerateHelmTemplate("fluid", "default", "testValueFile", "testChartName") - if err != nil { - t.Errorf("fail to exec the function") - } - wrappedUnhookCombinedOutput() - wrappedUnhookStat() - wrappedUnhookLookPath() -} - func TestGetChartVersion(t *testing.T) { LookPathCommon := func(file string) (string, error) { return "helm", nil diff --git a/pkg/utils/helm/utils.go b/pkg/utils/helm/utils.go index ae25b393be2..2e853554b8b 100644 --- a/pkg/utils/helm/utils.go +++ b/pkg/utils/helm/utils.go @@ -55,7 +55,10 @@ func InstallRelease(name string, namespace string, valueFile string, chartName s // return syscall.Exec(cmd, args, env) // 5. execute the command - cmd := exec.Command(binary, args...) + cmd, err := utils.SimpleCommand(binary, args...) + if err != nil { + return err + } log.Info("Exec", "command", cmd.String()) // cmd.Env = env out, err := cmd.CombinedOutput() @@ -82,7 +85,10 @@ func CheckRelease(name, namespace string) (exist bool, err error) { return exist, err } - cmd := exec.Command(helmCmd[0], "status", name, "-n", namespace) + cmd, err := utils.SimpleCommand(helmCmd[0], "status", name, "-n", namespace) + if err != nil { + return exist, err + } // support multiple cluster management // if types.KubeConfig != "" { // cmd.Env = append(cmd.Env, fmt.Sprintf("KUBECONFIG=%s", types.KubeConfig)) @@ -147,7 +153,10 @@ func DeleteRelease(name, namespace string) error { } args := []string{"uninstall", name, "-n", namespace} - cmd := exec.Command(binary, args...) + cmd, err := utils.SimpleCommand(binary, args...) + if err != nil { + return err + } log.Info("Exec", "command", cmd.String()) // env := os.Environ() // if types.KubeConfig != "" { @@ -171,7 +180,10 @@ func ListReleases(namespace string) (releases []string, err error) { return releases, err } - cmd := exec.Command(helmCmd[0], "list", "-q", "-n", namespace) + cmd, err := utils.SimpleCommand(helmCmd[0], "list", "-q", "-n", namespace) + if err != nil { + return releases, err + } // support multiple cluster management // if types.KubeConfig != "" { // cmd.Env = append(cmd.Env, fmt.Sprintf("KUBECONFIG=%s", types.KubeConfig)) @@ -191,7 +203,10 @@ func ListReleaseMap(namespace string) (releaseMap map[string]string, err error) return releaseMap, err } - cmd := exec.Command(helmCmd[0], "list", "-n", namespace) + cmd, err := utils.SimpleCommand(helmCmd[0], "list", "-n", namespace) + if err != nil { + return releaseMap, err + } // // support multiple cluster management // if types.KubeConfig != "" { // cmd.Env = append(cmd.Env, fmt.Sprintf("KUBECONFIG=%s", types.KubeConfig)) @@ -225,7 +240,10 @@ func ListAllReleasesWithDetail(namespace string) (releaseMap map[string][]string return releaseMap, err } - cmd := exec.Command(helmCmd[0], "list", "--all", "-n", namespace) + cmd, err := utils.SimpleCommand(helmCmd[0], "list", "--all", "-n", namespace) + if err != nil { + return releaseMap, err + } // support multiple cluster management // if types.KubeConfig != "" { // cmd.Env = append(cmd.Env, fmt.Sprintf("KUBECONFIG=%s", types.KubeConfig)) diff --git a/pkg/utils/helm/utils_test.go b/pkg/utils/helm/utils_test.go index 136e35d075d..8a8b7164052 100644 --- a/pkg/utils/helm/utils_test.go +++ b/pkg/utils/helm/utils_test.go @@ -102,6 +102,12 @@ func TestInstallRelease(t *testing.T) { } wrappedUnhookCombinedOutput() + badValue := "test$bad" + err = InstallRelease("fluid", badValue, "testValueFile", "/chart/fluid") + if err == nil { + t.Errorf("fail to catch the error of %s", badValue) + } + err = gohook.Hook((*exec.Cmd).CombinedOutput, CombinedOutputCommon, nil) if err != nil { t.Fatal(err.Error()) @@ -175,6 +181,12 @@ func TestCheckRelease(t *testing.T) { } wrappedUnhookStart() + badValue := "test$bad" + _, err = CheckRelease("fluid", badValue) + if err == nil { + t.Errorf("fail to catch the error of %s", badValue) + } + err = gohook.Hook((*exec.Cmd).Start, StartCommon, nil) if err != nil { t.Fatal(err.Error()) @@ -242,6 +254,12 @@ func TestDeleteRelease(t *testing.T) { t.Errorf("fail to catch the error") } wrappedUnhookOutput() + // test check illegal arguements + badValue := "test$bad" + err = DeleteRelease("fluid", badValue) + if err == nil { + t.Errorf("fail to catch the error of %s", badValue) + } err = gohook.Hook((*exec.Cmd).Output, OutputCommon, nil) if err != nil { @@ -319,6 +337,11 @@ func TestListReleases(t *testing.T) { } wrappedUnhookOutput() wrappedUnhookLookPath() + + _, err = ListReleases("def$ault") + if err == nil { + t.Errorf("fail to catch the error") + } } func TestListReleaseMap(t *testing.T) { @@ -385,6 +408,11 @@ func TestListReleaseMap(t *testing.T) { } wrappedUnhookOutput() wrappedUnhookLookPath() + + _, err = ListReleaseMap("def$ault") + if err == nil { + t.Errorf("fail to catch the error") + } } func TestListAllReleasesWithDetail(t *testing.T) { @@ -451,6 +479,11 @@ func TestListAllReleasesWithDetail(t *testing.T) { } wrappedUnhookOutput() wrappedUnhookLookPath() + + _, err = ListAllReleasesWithDetail("def$ault") + if err == nil { + t.Errorf("fail to catch the error") + } } func TestDeleteReleaseIfExists(t *testing.T) { diff --git a/pkg/utils/home.go b/pkg/utils/home.go index f346dc877d2..0c557e52a40 100644 --- a/pkg/utils/home.go +++ b/pkg/utils/home.go @@ -20,7 +20,6 @@ import ( "bytes" "errors" "os" - "os/exec" "os/user" "runtime" "strings" @@ -45,26 +44,32 @@ func Home() (string, error) { return homeUnix() } -func homeUnix() (string, error) { +func homeUnix() (home string, err error) { // First prefer the HOME environmental variable - if home := os.Getenv("HOME"); home != "" { + home = os.Getenv("HOME") + if home != "" { return home, nil } // If that fails, try the shell var stdout bytes.Buffer - cmd := exec.Command("sh", "-c", "eval echo ~$USER") + cmd, err := SimpleCommand("sh", "-c", "eval echo ~$USER") + if err != nil { + return + } cmd.Stdout = &stdout - if err := cmd.Run(); err != nil { - return "", err + err = cmd.Run() + if err != nil { + return } - result := strings.TrimSpace(stdout.String()) - if result == "" { - return "", errors.New("blank output when reading home directory") + home = strings.TrimSpace(stdout.String()) + if home == "" { + err = errors.New("blank output when reading home directory") + return } - return result, nil + return } func homeWindows() (string, error) { diff --git a/pkg/utils/kubectl/configmap.go b/pkg/utils/kubectl/configmap.go index 58de69b231e..5038649943c 100644 --- a/pkg/utils/kubectl/configmap.go +++ b/pkg/utils/kubectl/configmap.go @@ -21,6 +21,7 @@ import ( "os/exec" "strings" + "github.com/fluid-cloudnative/fluid/pkg/utils" "github.com/go-logr/logr" ctrl "sigs.k8s.io/controller-runtime" ) @@ -68,7 +69,10 @@ func kubectl(args []string) ([]byte, error) { // return syscall.Exec(cmd, args, env) // 2. execute the command - cmd := exec.Command(binary, args...) + cmd, err := utils.SimpleCommand(binary, args...) + if err != nil { + return nil, err + } // cmd.Env = env return cmd.CombinedOutput() } diff --git a/pkg/utils/mount.go b/pkg/utils/mount.go index 51a3338795d..480a8300d8a 100644 --- a/pkg/utils/mount.go +++ b/pkg/utils/mount.go @@ -41,13 +41,16 @@ func GetMountRoot() (string, error) { return mountRoot, nil } -func CheckMountReadyAndSubPathExist(fluidPath string, mountType string, subPath string) error { +func CheckMountReadyAndSubPathExist(fluidPath string, mountType string, subPath string) (err error) { glog.Infof("Try to check if the mount target %s is ready", fluidPath) if fluidPath == "" { return errors.New("target is not specified for checking the mount") } args := []string{fluidPath, mountType, subPath} - command := exec.Command("/usr/local/bin/check_mount.sh", args...) + command, err := SimpleCommand("/usr/local/bin/check_mount.sh", args...) + if err != nil { + return + } glog.Infoln(command) stdoutStderr, err := command.CombinedOutput() glog.Infoln(string(stdoutStderr)) diff --git a/pkg/utils/mount_test.go b/pkg/utils/mount_test.go index 97e59b52883..bba791eb5b8 100644 --- a/pkg/utils/mount_test.go +++ b/pkg/utils/mount_test.go @@ -101,6 +101,10 @@ func TestCheckMountReady(t *testing.T) { err := CheckMountReadyAndSubPathExist("", "test", "") So(err, ShouldNotBeNil) }) + Convey("illegal subpath", func() { + err := CheckMountReadyAndSubPathExist("/test", "test", "$(echo)") + So(err, ShouldNotBeNil) + }) }) }