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
4 changes: 4 additions & 0 deletions go.work.sum
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,16 @@ github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zU
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4=
Expand Down
156 changes: 2 additions & 154 deletions go/exec/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"

core "dappco.re/go"
"dappco.re/go/process/internal/lookpath"
goio "io"
)

Expand Down Expand Up @@ -298,7 +299,7 @@ func (c *Cmd) logError(msg string, failure core.Result) {
// "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)
resolved := lookpath.Look(name)
if !resolved.OK {
return resolved
}
Expand All @@ -307,156 +308,3 @@ func commandContext(ctx context.Context, name string, arg ...string) core.Result
Args: append([]string{name}, arg...),
})
}

// 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 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))
}

for _, dir := range core.Split(core.Getenv("PATH"), string(core.PathListSeparator)) {
if dir == "" {
dir = "."
}
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}
}
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
}
info, ok := stat.Value.(core.FsFileInfo)
if !ok || info.IsDir() {
return false
}
if len(extensions) > 0 {
return hasExecutableExtension(path, extensions)
}
return info.Mode()&0111 != 0
}
Loading
Loading