-
Notifications
You must be signed in to change notification settings - Fork 0
fix(exec): resolve executables on Windows — PATHEXT, mode bits, and a swallowed failure #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
|
||
| c.cmd.Dir = c.opts.Dir | ||
| if len(c.opts.Env) > 0 { | ||
|
|
@@ -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)) | ||
| } | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 || trueRepository: 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"])
PYRepository: dAppCore/go-process Length of output: 5474 🌐 Web query:
💡 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:
💡 Result: The Go source file is here:
Citations: 🌐 Web query:
💡 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:
💡 Result: In the Go programming language, specifically within the implementation for Windows (found in lp_windows.go), the Citations:
🌐 Web query:
💡 Result: In the Go standard library, specifically within 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 Also applies to lines 458-459. 🤖 Prompt for AI Agents |
||
| } | ||
| 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 | ||
|
|
@@ -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 | ||
| } | ||
There was a problem hiding this comment.
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:
Repository: dAppCore/go-process
Length of output: 15409
🏁 Script executed:
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.Dirbefore assigningc.cmd.Dir.prepareassignsPathwithcommandContextbefore settingc.cmd.Dir = c.opts.Dir. For a direct relative path such as./tool, Go evaluatesPathrelative toCmd.Dir, so it can resolve to the caller's directory instead of the configured directory. Resolve./toolfromc.opts.Dir, or resolve afterc.cmd.Diris known.🤖 Prompt for AI Agents