Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 143 additions & 15 deletions go/exec/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,11 @@ func (c *Cmd) prepare() core.Result {
return core.Fail(core.E("Cmd.prepare", "exec: command context is required", ErrCommandContextRequired))
}

c.cmd = commandContext(c.ctx, c.name, c.args...)
resolved := commandContext(c.ctx, c.name, c.args...)
if !resolved.OK {
return resolved
}
c.cmd = resolved.Value.(*core.Cmd)
Comment on lines +222 to +226

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate exec.go and relevant files =="
fd -a 'exec\.go$' . | sed 's#^\./##'
echo

echo "== git diff stat =="
git diff --stat || true
echo

echo "== inspect go/exec/exec.go around commandContext and prepare =="
file="go/exec/exec.go"
if [ -f "$file" ]; then
  wc -l "$file"
  sed -n '1,320p' "$file" | cat -n
fi
echo

echo "== search commandContext and prepare usages/definitions =="
rg -n "func commandContext|commandContext\\(|func .*prepare|\\.prepare\\(|\\.Dir|core\\.Cmd|New\\(" go/exec/go.mod go -S || true

Repository: dAppCore/go-process

Length of output: 15409


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== go/exec/exec.go remaining core/lookPath symbols =="
sed -n '320,462p' go/exec/exec.go | cat -n
echo

echo "== core.Cmd type/Dir handling in go package =="
rg -n "type Cmd|func .*Cmd\\(" go -S | head -100
echo

echo "== os/exec link commandContext implementation =="
sed -n '1,80p' go/os_exec_link.go | cat -n
echo

echo "== related commandContext patterns with Dir ordering =="
for f in go/program.go go/service.go go/exec/exec.go; do
  echo "-- $f --"
  sed -n '70,130p' "$f" 2>/dev/null | cat -n || true
  sed -n '150,185p' "$f" 2>/dev/null | cat -n || true
done

echo "== inspect existing Windows test section around Cmd Dir behavior =="
sed -n '340,390p' go/exec/exec_internal_test.go | cat -n

Repository: dAppCore/go-process

Length of output: 17789


🌐 Web query:

Go os/exec Cmd Windows relative Path Dir behavior "Path relative to Dir"

💡 Result:

In Go's os/exec package, the Cmd.Dir field specifies the working directory in which the command will execute [1][2]. It is important to note that Cmd.Dir does not influence the resolution of the command executable path itself [3]. When you provide a command name to exec.Command, Go's os/exec package resolves that executable independently of the directory specified in Cmd.Dir [3]. Specifically: 1. Executable Resolution: If the command name contains path separators (e.g.,./myprog or C:\bin\myprog), the package uses the name directly [3]. If the name does not contain path separators, the package uses LookPath to resolve the executable by searching the system PATH [3]. 2. Security and Current Directory: As of Go 1.19, os/exec will not resolve a program using an implicit or explicit path entry relative to the current directory [1][4]. Even if the system PATH configuration includes the current directory (or dot), LookPath will return an error (satisfying errors.Is(err, exec.ErrDot)) instead of resolving to a file in the current working directory [1][4]. 3. Relative Paths: To explicitly run a program located in a specific directory (such as the directory set in Cmd.Dir), you should construct the full path before calling exec.Command (e.g., using filepath.Join) or provide an explicit relative path like "./myprog" [3][5]. While "./myprog" works, it is evaluated relative to the calling process's current working directory, not the directory specified in Cmd.Dir [3]. In summary, Cmd.Dir only sets the environment for the child process once it starts; it does not change the search path for finding the executable binary [1][3]. If you intend to run an executable located inside the folder assigned to Cmd.Dir, you must provide the full path to that executable when creating the Cmd instance [3].

Citations:


🌐 Web query:

Go "When Path is set relative to Dir" os/exec Windows Cmd.Dir Path

💡 Result:

In Go's os/exec package, the Cmd.Path field is explicitly documented to be evaluated relative to the Cmd.Dir directory if the path is relative [1][2]. If you specify a relative path for an executable in Cmd.Path (e.g., "bin/prog.exe") and set Cmd.Dir to a specific directory (e.g., "C:\app"), the Go runtime will attempt to locate and execute the program at the resulting joined path ("C:\app\bin\prog.exe") [1][2]. It is important to note how Go handles executable resolution to avoid common pitfalls: 1. Executable Lookup: If you use exec.Command("name") without any path separators, Go uses LookPath to find the executable [1][3]. This process is separate from setting Cmd.Dir [3][4]. If LookPath resolves a command to a relative path (e.g., "./prog.exe") based on the current working directory of the calling process, that relative path remains relative when Cmd.Start is called [4]. If you subsequently set Cmd.Dir to a different directory, the command will fail because it will look for that relative path inside the new Cmd.Dir [4]. 2. Security (ErrDot): Since Go 1.19, os/exec will not resolve programs using implicit or explicit path entries relative to the current directory (the "dot" entry) during LookPath, specifically to prevent security vulnerabilities [1][2]. If a lookup would result in a path relative to the current directory, LookPath will return an error that satisfies errors.Is(err, ErrDot) [1][2]. To ensure robust behavior: - Always use absolute paths or explicitly constructed paths when setting Cmd.Path if you intend to execute a file relative to a specific directory. - Avoid relying on LookPath to resolve relative paths if you plan to change the working directory via Cmd.Dir [3]. - If you need to run a program located in a specific directory relative to your application, construct the path using filepath.Join with your target directory and the executable name before passing it to exec.Command [3].

Citations:


Resolve relative direct paths against Options.Dir before assigning c.cmd.Dir.

prepare assigns Path with commandContext before setting c.cmd.Dir = c.opts.Dir. For a direct relative path such as ./tool, Go evaluates Path relative to Cmd.Dir, so it can resolve to the caller's directory instead of the configured directory. Resolve ./tool from c.opts.Dir, or resolve after c.cmd.Dir is known.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@go/exec/exec.go` around lines 222 - 226, Update the command preparation flow
around commandContext and c.cmd assignment so relative direct paths such as
./tool are resolved against c.opts.Dir before Path is finalized. Ensure
c.cmd.Dir is established before resolving the command, or explicitly resolve the
path from c.opts.Dir, while preserving existing handling for non-relative
commands.


c.cmd.Dir = c.opts.Dir
if len(c.opts.Env) > 0 {
Expand Down Expand Up @@ -285,26 +289,51 @@ func (c *Cmd) logError(msg string, failure core.Result) {
c.getLogger().Error(msg, "cmd", c.name, "args", core.Join(" ", c.args...), "err", failure.Error())
}

func commandContext(ctx context.Context, name string, arg ...string) *core.Cmd {
path := name
if result := lookPath(name); result.OK {
path = result.Value.(string)
// commandContext resolves name to a concrete executable and builds the handle
// for it.
//
// Resolution failure is returned, never swallowed. Handing the bare name to
// os/exec instead is not a harmless fallback on Windows: when Dir is set,
// Cmd.Start resolves a separator-free Path relative to Dir, so an unresolved
// "git" is reported as `exec: "C:\...\some work dir\git"` — a confusing error
// naming a path nobody asked for, in place of the honest "not found on PATH".
func commandContext(ctx context.Context, name string, arg ...string) core.Result {
resolved := lookPath(name)
if !resolved.OK {
return resolved
}

cmd := &core.Cmd{
Path: path,
return core.Ok(&core.Cmd{
Path: resolved.Value.(string),
Args: append([]string{name}, arg...),
}
return cmd
})
}

// defaultPathExt is the extension list Windows itself assumes when %PATHEXT%
// is unset.
const defaultPathExt = ".COM;.EXE;.BAT;.CMD"

// lookPath resolves file to a runnable path, searching PATH when file carries
// no directory component.
//
// On Windows a command is named without its extension — "git", not "git.exe" —
// so every candidate is also tried with each %PATHEXT% suffix. Without that,
// no Windows executable is ever found by its bare name.
func lookPath(file string) core.Result {
return lookPathWith(file, executableExtensions())
}

// lookPathWith is lookPath with the extension list supplied rather than read
// from the environment. Taking it as an argument is what lets the Windows
// resolution rules be pinned on a POSIX runner — the tests drive it with a
// fixture PATH and a fake %PATHEXT%, so no Windows box is needed to prove the
// logic and the CI lane is left to prove only the wiring.
func lookPathWith(file string, extensions []string) core.Result {
if file == "" {
return core.Fail(core.E("lookPath", "executable file not found in PATH", nil))
}
if core.Contains(file, string(core.PathSeparator)) {
if isExecutable(file) {
return core.Ok(file)
if containsSeparator(file) {
if path, ok := firstExecutable(file, extensions); ok {
return core.Ok(path)
}
return core.Fail(core.E("lookPath", core.Sprintf("executable file %q not found", file), nil))
}
Expand All @@ -313,15 +342,111 @@ func lookPath(file string) core.Result {
if dir == "" {
dir = "."
}
path := core.PathJoin(dir, file)
if isExecutable(path) {
if path, ok := firstExecutable(core.PathJoin(dir, file), extensions); ok {
return core.Ok(path)
}
}
return core.Fail(core.E("lookPath", core.Sprintf("executable file %q not found in PATH", file), nil))
}

// firstExecutable returns the first of base's candidate spellings that names a
// runnable file.
func firstExecutable(base string, extensions []string) (string, bool) {
for _, candidate := range executableCandidates(base, extensions) {
if isExecutableWith(candidate, extensions) {
return candidate, true
}
}
return "", false
}

// executableCandidates returns the spellings of base to try, in order. With no
// extensions in play — every POSIX case — base stands alone. On Windows a base
// that already ends in a listed extension also stands alone; anything else is
// tried once per extension, so "git" becomes "git.com", "git.exe" and so on.
func executableCandidates(base string, extensions []string) []string {
if len(extensions) == 0 || hasExecutableExtension(base, extensions) {
return []string{base}
Comment on lines +368 to +369

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and inspect the relevant Go file and function ranges without executing repository code.
if [ -f go/exec/exec.go ]; then
  echo "FOUND go/exec/exec.go"
  wc -l go/exec/exec.go
  echo "--- outline around exec.go ---"
  ast-grep outline go/exec/exec.go || true
  echo "--- relevant lines 330-390 ---"
  sed -n '330,390p' go/exec/exec.go
  echo "--- relevant lines 430-475 ---"
  sed -n '430,475p' go/exec/exec.go
else
  echo "go/exec/exec.go not found"
  git ls-files | rg '(^|/)exec\.go$|exec/' || true
fi

echo "--- search for related symbols ---"
rg -n "hasExecutableExtension|PATHEXT|extensions|PATHEXT|Executable|ExpandPath|FindExecutable|isExecutable" go/exec -S || true

Repository: dAppCore/go-process

Length of output: 10575


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "--- full test functions around executable lookup / PATHEXT ---"
sed -n '180,290p' go/exec/exec_internal_test.go

echo "--- deterministic probe of lookup logic as implemented ---"
python3 - <<'PY'
from pathlib import Path

defaultPathExt = ".COM;EXE;.BAT;.CMD"

def parsePathExt(value):
    extensions = []
    for part in value.split(';'):
        part = part.strip().lower()
        if part:
            if not part.startswith('.'):
                part = '.' + part
            if part not in extensions:
                extensions.append(part)
    return extensions or None if value == defaultPathExt else []

def hasExecutableExtension(base, extensions):
    lowered = base.lower()
    for extension in extensions:
        if lowered.endswith(extension):
            return True
    return False

def executableExtensions(value=None):
    if Path("/dev/null").exists() and False:
        return None
    return parsePathExt(value if value is not None else "")

def executableCandidates(base, extensions):
    if len(extensions) == 0 or hasExecutableExtension(base, extensions):
        return [base]
    candidates = []
    for extension in extensions:
        candidates.append(base + extension)
    return candidates

def candidateExists(c, filesystem):
    for existing in filesystem:
        if existing.lower() == c.lower():
            return True
    return False

def firstExecutable(base, extensions, filesystem):
    for candidate in executableCandidates(base, extensions):
        if candidateExists(candidate, filesystem):
            return candidate
    return ""

filesystem = {"tmp/tool.exe"}
pathextWithoutExe = ".COM;.BAT"

print("filesystem:", filesystem)
print("PATHEXT used:", pathextWithoutExe)
print("resolution_order:", executableCandidates("tool.exe", executableExtensions(pathextWithoutExe)))
print("resolved:", firstExecutable("tool.exe", executableExtensions(pathextWithoutExe), filesystem))
print("expected_go_windows_order_behavior:", ["tool.exe", "tool.exe.com", "tool.exe.bat"])

filesystem = {"tmp/tool.exe"}
pathextDefault = defaultPathExt
print("\nfilesystem:", filesystem)
print("PATHEXT used:", pathextDefault)
print("resolution_order:", executableCandidates("tool", executableExtensions(pathextDefault)))
print("resolved:", firstExecutable("tool", executableExtensions(pathextDefault), filesystem))

filesystem = {"tmp/tool.exe"}
pathextBadCaseNoExe = ".COM;.exe;.BAT;.CMD"
print("\nfilesystem:", filesystem)
print("PATHEXT used:", pathextBadCaseNoExe)
print("resolution_order:", executableCandidates("tool.exe", executableExtensions(pathextBadCaseNoExe)))
print("resolved:", firstExecutable("tool.exe", executableExtensions(pathextBadCaseNoExe), filesystem))
print("expected_go_windows_order_behavior:", ["tool.exe", "tool.exe.com", "tool.exe.exe", "tool.exe.bat", "tool.exe.cmd"])
PY

Repository: dAppCore/go-process

Length of output: 5474


🌐 Web query:

Go os/exec Windows LookPath executable filename with extension before PATHEXT

💡 Result:

On Windows, Go’s os/exec.LookPath only treats a candidate as executable if it has an extension that matches PATHEXT; specifically, “the file must have an extension named by the PATHEXT environment variable”. If PATHEXT is unset, it falls back to requiring one of “.com”, “.exe”, “.bat”, or “.cmd”. [1][2] Therefore, when using os/exec on Windows, if you call LookPath with an executable filename that already includes an extension, Go will still apply its PATHEXT-based extension matching when searching PATH directories. If your provided extension is not in PATHEXT, LookPath will not treat it as a valid match via the PATH search. [1][3] Concretely, in the Windows implementation, LookPath builds the list of allowable extensions from PATHEXT (lowercased, ensuring each begins with “.”). When PATHEXT is empty, it uses the default extension list {“.com”, “.exe”, “.bat”, “.cmd”}. It then tries to find an executable by checking those extensions during PATH lookup. [3] Note: If the input contains a path separator (e.g., “C:...\tool.exe”), LookPath treats it differently: it tries the provided path directly (but still via findExecutable with the allowable extensions list). [3]

Citations:


🌐 Web query:

site:go.dev/src/os/exec/lp_windows.go func findExecutable os/exec lp_windows.go

💡 Result:

The Go source file is here: os/exec/lp_windows.go.

findExecutable is defined at line 37 and searches for an executable by checking the provided filename and Windows PATHEXT extensions. (go.dev)

Citations:


🌐 Web query:

"go/src/os/exec/lp_windows.go" "findExecutable" "try" "PATHEXT"

💡 Result:

In the Go source file src/os/exec/lp_windows.go, the findExecutable function is responsible for verifying if a file exists and is an executable by checking it directly and by appending extensions defined in the PATHEXT environment variable [1][2][3]. The logic inside findExecutable works as follows: 1. Handling Extensions: If no extensions (exts) are provided, it checks if the file exists using chkStat [1][3]. 2. Direct Check: If the file already has an extension (checked via hasExt), it first attempts to verify if that file exists using chkStat [1][3]. 3. PATHEXT Iteration: It iterates through the provided list of extensions (derived from PATHEXT in LookPath). For each extension, it appends it to the filename and checks if the resulting file exists using chkStat [1][2]. 4. Result: If a match is found during any of these checks, the absolute path to the file is returned [1][2]. If no match is found after checking all extensions, it returns an error (typically fs.ErrNotExist or ErrNotFound) [1][2][3]. The PATHEXT environment variable is processed in the LookPath function [1][2]. If PATHEXT is set, Go splits it by semicolons (;) and ensures each extension starts with a dot (.), creating the list of extensions used by findExecutable [1][2]. If PATHEXT is not set or is empty, Go defaults to [".com", ".exe", ".bat", ".cmd"] [1][2].

Citations:


🌐 Web query:

"github.com/golang/go/src/os/exec/lp_windows.go" "findExecutable" "extensions"

💡 Result:

In the Go programming language, specifically within the implementation for Windows (found in lp_windows.go), the findExecutable function uses file extensions to locate an executable file [1][2]. The specific extensions considered depend on the PATHEXT environment variable [1][3]. The logic works as follows: 1. Environment Variable Check: The code retrieves the PATHEXT environment variable [1][4]. 2. If PATHEXT is set: The implementation splits the variable's value by semicolons (;) [1][5]. It converts these extensions to lowercase and ensures each starts with a dot (.) if it does not already [1][2]. 3. Default Extensions: If PATHEXT is not set or is empty, the implementation defaults to the following list: .com, .exe, .bat, and .cmd [1][4]. When searching for a file, findExecutable iterates through these determined extensions and appends them to the base filename to check for the existence of the file on the filesystem using chkStat [1][3]. This process is central to how os/exec.LookPath functions on Windows, ensuring that it respects the system's configured executable search behavior [2][6].

Citations:


🌐 Web query:

github.com/golang/go/src/os/exec/lp_windows.go "findExecutable" "hasExt" "ex" "PATHEXT"

💡 Result:

In the Go standard library, specifically within src/os/exec/lp_windows.go, the findExecutable function is responsible for determining if a file path is a valid executable by checking it against a list of allowed extensions [1][2]. The function operates as follows: 1. It first checks if the provided file path already includes an extension by using the hasExt helper function [2]. hasExt determines if a file has an extension by checking if there is a dot (.) in the filename that appears after any directory separators (:\/) [2]. 2. If hasExt returns true, it verifies if the file exists at that exact path using chkStat [1][2]. 3. If the file does not have an extension or the previous check fails, it iterates through the provided slice of extensions (derived from the PATHEXT environment variable or defaulting to .com, .exe, .bat, .cmd) and attempts to locate the file by appending each extension to the original path [1][3][2]. The PATHEXT environment variable is processed in LookPath by splitting the string by semicolons, converting entries to lowercase, and ensuring they start with a dot [1][2]. If PATHEXT is unset, the library defaults to [".com", ".exe", ".bat", ".cmd"] [1][2]. As of Go 1.19, LookPath includes security measures that prevent it from implicitly resolving programs in the current directory, returning an error satisfying errors.Is(err, ErrDot) if the only executable found is in the current directory [4][5][6].

Citations:


Do not validate explicit filenames against a restricted PATHEXT list.

When an explicit filename has an extension, try that filename before applying remaining PATHEXT suffixes. With tool.exe present and %PATHEXT% as .com;.bat, the current lookup only checks .exe.com and .exe.bat, so the caller misses an executable. Keep suffix expansion for unresolved names. Add coverage for an existing tool.exe with a PATHEXT value that excludes .exe.

Also applies to lines 458-459.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@go/exec/exec.go` around lines 368 - 369, Update the executable lookup logic
around hasExecutableExtension and the corresponding flow at the later PATHEXT
handling site so explicit filenames with extensions are attempted as-is before
any PATHEXT suffix expansion, without requiring the extension to appear in the
restricted list. Preserve suffix expansion for unresolved names, and add
coverage for an existing tool.exe when PATHEXT excludes .exe.

}
candidates := make([]string, 0, len(extensions))
for _, extension := range extensions {
candidates = append(candidates, base+extension)
}
return candidates
}

// hasExecutableExtension reports whether base already ends in one of the
// listed extensions. Windows filenames are case-insensitive, so the comparison
// is too.
func hasExecutableExtension(base string, extensions []string) bool {
lowered := core.Lower(base)
for _, extension := range extensions {
if core.HasSuffix(lowered, extension) {
return true
}
}
return false
}

// executableExtensions returns the %PATHEXT% list, or nil off Windows where a
// command name is used exactly as written.
func executableExtensions() []string {
if string(core.PathSeparator) != `\` {
return nil
}
return parsePathExt(core.Getenv("PATHEXT"))
}

// parsePathExt normalises a %PATHEXT% value into lower-cased, dot-prefixed
// extensions, dropping blanks and duplicates. An unset or unusable value falls
// back to the set Windows assumes, so a stripped environment still resolves
// the common executables.
func parsePathExt(value string) []string {
extensions := make([]string, 0, 8)
seen := make(map[string]bool, 8)
for _, field := range core.Split(value, ";") {
extension := core.Lower(core.Trim(field))
if extension == "" || extension == "." {
continue
}
if !core.HasPrefix(extension, ".") {
extension = "." + extension
}
if seen[extension] {
continue
}
seen[extension] = true
extensions = append(extensions, extension)
}
if len(extensions) == 0 && value != defaultPathExt {
return parsePathExt(defaultPathExt)
}
return extensions
}

// containsSeparator reports whether file carries a directory component under
// either convention. Windows accepts '/' as well as '\', so a name spelled
// "bin/tool" there is a path to check directly, not a name to hunt on PATH.
func containsSeparator(file string) bool {
if core.Contains(file, "/") {
return true
}
separator := string(core.PathSeparator)
return separator != "/" && core.Contains(file, separator)
}

func isExecutable(path string) bool {
return isExecutableWith(path, executableExtensions())
}

// isExecutableWith applies the platform's own rule for "this can be run".
//
// POSIX asks the mode bits. Windows has no execute bit — os.Stat synthesises
// 0666, or 0444 for a read-only file — so mode&0111 is never set there and a
// mode test rejects every file, git.exe included. Under a non-empty extension
// list the question becomes whether the suffix is one %PATHEXT% names, which
// is what Windows itself keys on.
func isExecutableWith(path string, extensions []string) bool {
stat := core.Stat(path)
if !stat.OK {
return false
Expand All @@ -330,5 +455,8 @@ func isExecutable(path string) bool {
if !ok || info.IsDir() {
return false
}
if len(extensions) > 0 {
return hasExecutableExtension(path, extensions)
}
return info.Mode()&0111 != 0
}
Loading
Loading