From cbc31a6c371a58c8480e1b918fb1c79a2afcb913 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Eriksson?= Date: Fri, 18 Oct 2024 10:41:45 +0200 Subject: [PATCH] cli: look up binaries using client PATH Instead of using the daemon's PATH, use the PATH from the client's environment. That way we don't need to restart the daemon on PATH changes, while still resolving e.g. the correct node binary when using nvm. --- cli/daemon/run/exec_script.go | 9 +- cli/daemon/run/proc_groups.go | 31 ++++- internal/lookpath/lookpath.go | 242 ++++++++++++++++++++++++++++++++++ v2/tsbuilder/tsbuilder.go | 11 +- 4 files changed, 284 insertions(+), 9 deletions(-) create mode 100644 internal/lookpath/lookpath.go diff --git a/cli/daemon/run/exec_script.go b/cli/daemon/run/exec_script.go index cf945a9849..4f56e91d63 100644 --- a/cli/daemon/run/exec_script.go +++ b/cli/daemon/run/exec_script.go @@ -17,6 +17,7 @@ import ( "encr.dev/cli/daemon/namespace" "encr.dev/cli/daemon/run/infra" encoreEnv "encr.dev/internal/env" + "encr.dev/internal/lookpath" "encr.dev/internal/optracker" "encr.dev/pkg/builder" "encr.dev/pkg/builder/builderimpl" @@ -215,9 +216,15 @@ func (mgr *Manager) ExecScript(ctx context.Context, p ExecScriptParams) (err err tracker.AllDone() + cwd := filepath.Join(p.App.Root(), p.WorkingDir) + binary, err := lookpath.InDir(cwd, env, proc.Command[0]) + if err != nil { + return err + } + args := append(slices.Clone(proc.Command[1:]), p.ScriptArgs...) // nosemgrep: go.lang.security.audit.dangerous-exec-command.dangerous-exec-command - cmd := exec.CommandContext(ctx, proc.Command[0], args...) + cmd := exec.CommandContext(ctx, binary, args...) cmd.Dir = filepath.Join(p.App.Root(), p.WorkingDir) cmd.Stdout = p.Stdout cmd.Stderr = p.Stderr diff --git a/cli/daemon/run/proc_groups.go b/cli/daemon/run/proc_groups.go index 5b0d862f30..2c6f1c3cf9 100644 --- a/cli/daemon/run/proc_groups.go +++ b/cli/daemon/run/proc_groups.go @@ -24,6 +24,7 @@ import ( "encore.dev/appruntime/exported/config" "encore.dev/appruntime/exported/experiments" "encr.dev/cli/daemon/internal/sym" + "encr.dev/internal/lookpath" "encr.dev/pkg/builder" "encr.dev/pkg/fns" "encr.dev/pkg/noopgateway" @@ -245,11 +246,17 @@ func (pg *ProcGroup) NewAllInOneProc(spec builder.Cmd, listenAddr netip.AddrPort // Append both the command-specific env and the base environment. env = append(env, spec.Env...) + cwd := filepath.Join(pg.Run.App.Root(), pg.workingDir) + binary, err := lookpath.InDir(cwd, env, spec.Command[0]) + if err != nil { + return err + } + // This is safe since the command comes from our build. // nosemgrep go.lang.security.audit.dangerous-exec-command.dangerous-exec-command - cmd := exec.CommandContext(pg.ctx, spec.Command[0], spec.Command[1:]...) + cmd := exec.CommandContext(pg.ctx, binary, spec.Command[1:]...) cmd.Env = env - cmd.Dir = filepath.Join(pg.Run.App.Root(), pg.workingDir) + cmd.Dir = cwd // Proxy stdout and stderr to the given app logger, if any. if l := pg.logger; l != nil { @@ -281,11 +288,17 @@ func (pg *ProcGroup) NewProcForService(serviceName string, listenAddr netip.Addr // Append both the command-specific env and the base environment. env = append(env, spec.Env...) + cwd := filepath.Join(pg.Run.App.Root(), pg.workingDir) + binary, err := lookpath.InDir(cwd, env, spec.Command[0]) + if err != nil { + return err + } + // This is safe since the command comes from our build. // nosemgrep go.lang.security.audit.dangerous-exec-command.dangerous-exec-command - cmd := exec.CommandContext(pg.ctx, spec.Command[0], spec.Command[1:]...) + cmd := exec.CommandContext(pg.ctx, binary, spec.Command[1:]...) cmd.Env = env - cmd.Dir = filepath.Join(pg.Run.App.Root(), pg.workingDir) + cmd.Dir = cwd // Proxy stdout and stderr to the given app logger, if any. if l := pg.logger; l != nil { @@ -312,11 +325,17 @@ func (pg *ProcGroup) NewProcForGateway(gatewayName string, listenAddr netip.Addr // Append both the command-specific env and the base environment. env = append(env, spec.Env...) + cwd := filepath.Join(pg.Run.App.Root(), pg.workingDir) + binary, err := lookpath.InDir(cwd, env, spec.Command[0]) + if err != nil { + return err + } + // This is safe since the command comes from our build. // nosemgrep go.lang.security.audit.dangerous-exec-command.dangerous-exec-command - cmd := exec.CommandContext(pg.ctx, spec.Command[0], spec.Command[1:]...) + cmd := exec.CommandContext(pg.ctx, binary, spec.Command[1:]...) cmd.Env = env - cmd.Dir = filepath.Join(pg.Run.App.Root(), pg.workingDir) + cmd.Dir = cwd // Bound the wait time to esure prompt live reload if something goes wrong // with IO copying. diff --git a/internal/lookpath/lookpath.go b/internal/lookpath/lookpath.go new file mode 100644 index 0000000000..85f5e181b5 --- /dev/null +++ b/internal/lookpath/lookpath.go @@ -0,0 +1,242 @@ +/* +This package contains code from https://github.com/mvdan/sh. + +Copyright (c) 2016, Daniel Martí. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of the copyright holder nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ + +package lookpath + +import ( + "cmp" + "fmt" + "os" + "path/filepath" + "runtime" + "slices" + "strings" +) + +func checkStat(dir, file string, checkExec bool) (string, error) { + if !filepath.IsAbs(file) { + file = filepath.Join(dir, file) + } + info, err := os.Stat(file) + if err != nil { + return "", err + } + m := info.Mode() + if m.IsDir() { + return "", fmt.Errorf("is a directory") + } + if checkExec && runtime.GOOS != "windows" && m&0o111 == 0 { + return "", fmt.Errorf("permission denied") + } + return file, nil +} + +func winHasExt(file string) bool { + i := strings.LastIndex(file, ".") + if i < 0 { + return false + } + return strings.LastIndexAny(file, `:\/`) < i +} + +// findExecutable returns the path to an existing executable file. +func findExecutable(dir, file string, exts []string) (string, error) { + if len(exts) == 0 { + // non-windows + return checkStat(dir, file, true) + } + if winHasExt(file) { + if file, err := checkStat(dir, file, true); err == nil { + return file, nil + } + } + for _, e := range exts { + f := file + e + if f, err := checkStat(dir, f, true); err == nil { + return f, nil + } + } + return "", fmt.Errorf("not found") +} + +// findFile returns the path to an existing file. +func findFile(dir, file string, _ []string) (string, error) { + return checkStat(dir, file, false) +} + +// InDir is similar to [os/exec.LookPath], with the difference that it uses the +// provided environment. env is used to fetch relevant environment variables +// such as PWD and PATH. +// +// If no error is returned, the returned path must be valid. +func InDir(cwd string, env []string, file string) (string, error) { + if filepath.IsAbs(file) { + return file, nil + } + + upper := runtime.GOOS == "windows" + envs := listEnvironWithUpper(upper, env...) + return lookPathDir(cwd, envs, file, findExecutable) +} + +// findAny defines a function to pass to lookPathDir. +type findAny = func(dir string, file string, exts []string) (string, error) + +func lookPathDir(cwd string, env listEnviron, file string, find findAny) (string, error) { + if find == nil { + panic("no find function found") + } + + pathList := filepath.SplitList(env.Get("PATH")) + if len(pathList) == 0 { + pathList = []string{""} + } + chars := `/` + if runtime.GOOS == "windows" { + chars = `:\/` + } + exts := pathExts(env) + if strings.ContainsAny(file, chars) { + return find(cwd, file, exts) + } + for _, elem := range pathList { + var path string + switch elem { + case "", ".": + // otherwise "foo" won't be "./foo" + path = "." + string(filepath.Separator) + file + default: + path = filepath.Join(elem, file) + } + if f, err := find(cwd, path, exts); err == nil { + return f, nil + } + } + return "", fmt.Errorf("%q: executable file not found in $PATH", file) +} + +func pathExts(env listEnviron) []string { + if runtime.GOOS != "windows" { + return nil + } + pathext := env.Get("PATHEXT") + if pathext == "" { + return []string{".com", ".exe", ".bat", ".cmd"} + } + var exts []string + for _, e := range strings.Split(strings.ToLower(pathext), `;`) { + if e == "" { + continue + } + if e[0] != '.' { + e = "." + e + } + exts = append(exts, e) + } + return exts +} + +// listEnvironWithUpper implements ListEnviron, but letting the tests specify +// whether to uppercase all names or not. +func listEnvironWithUpper(upper bool, pairs ...string) listEnviron { + list := slices.Clone(pairs) + if upper { + // Uppercase before sorting, so that we can remove duplicates + // without the need for linear search nor a map. + for i, s := range list { + if sep := strings.IndexByte(s, '='); sep > 0 { + list[i] = strings.ToUpper(s[:sep]) + s[sep:] + } + } + } + + slices.SortStableFunc(list, func(a, b string) int { + isep := strings.IndexByte(a, '=') + jsep := strings.IndexByte(b, '=') + if isep < 0 { + isep = 0 + } else { + isep += 1 + } + if jsep < 0 { + jsep = 0 + } else { + jsep += 1 + } + return strings.Compare(a[:isep], b[:jsep]) + }) + + last := "" + for i := 0; i < len(list); { + s := list[i] + sep := strings.IndexByte(s, '=') + if sep <= 0 { + // invalid element; remove it + list = append(list[:i], list[i+1:]...) + continue + } + name := s[:sep] + if last == name { + // duplicate; the last one wins + list = append(list[:i-1], list[i:]...) + continue + } + last = name + i++ + } + return listEnviron(list) +} + +// listEnviron is a sorted list of "name=value" strings. +type listEnviron []string + +func (l listEnviron) Get(name string) string { + eqpos := len(name) + endpos := len(name) + 1 + i, ok := slices.BinarySearchFunc(l, name, func(l, name string) int { + if len(l) < endpos { + // Too short; see if we are before or after the name. + return strings.Compare(l, name) + } + // Compare the name prefix, then the equal character. + c := strings.Compare(l[:eqpos], name) + eq := l[eqpos] + if c == 0 { + return cmp.Compare(eq, '=') + } + return c + }) + if ok { + return l[i][endpos:] + } + return "" +} diff --git a/v2/tsbuilder/tsbuilder.go b/v2/tsbuilder/tsbuilder.go index c3191298d3..bd3e074fce 100644 --- a/v2/tsbuilder/tsbuilder.go +++ b/v2/tsbuilder/tsbuilder.go @@ -17,6 +17,7 @@ import ( "google.golang.org/protobuf/proto" "encr.dev/internal/env" + "encr.dev/internal/lookpath" "encr.dev/internal/version" "encr.dev/pkg/builder" "encr.dev/pkg/fns" @@ -256,9 +257,15 @@ type testInput struct { } func (i *BuilderImpl) RunTests(ctx context.Context, p builder.RunTestsParams) error { - cmd := exec.CommandContext(ctx, p.Spec.Command, p.Spec.Args...) + cwd := p.WorkingDir.ToIO() + binary, err := lookpath.InDir(cwd, p.Spec.Environ, p.Spec.Command) + if err != nil { + return err + } + + cmd := exec.CommandContext(ctx, binary, p.Spec.Args...) cmd.Env = p.Spec.Environ - cmd.Dir = p.WorkingDir.ToIO() + cmd.Dir = cwd cmd.Stdout = p.Stdout cmd.Stderr = p.Stderr return cmd.Run()