Files
bash-for-windows/internal/shell/exec.go
2026-05-31 21:49:13 +02:00

525 lines
12 KiB
Go

package shell
import (
"bytes"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
)
type redirect struct {
fd int // 0=stdin 1=stdout 2=stderr -1=both stdout+stderr
mode string // ">" ">>" "<"
dest string // filename or "&1" "&2"
}
// executePipeline handles background jobs (&) and pipelines (|).
func (s *Shell) executePipeline(input string) error {
input = strings.TrimSpace(input)
if input == "" {
return nil
}
// Background job: trailing & (not &&)
bg := false
if strings.HasSuffix(input, "&") && !strings.HasSuffix(input, "&&") {
bg = true
input = strings.TrimSuffix(strings.TrimSuffix(input, "&"), " ")
}
parts := splitPipe(input)
if len(parts) == 1 {
if bg {
return s.executeCommandBg(strings.TrimSpace(parts[0]))
}
return s.executeCommand(strings.TrimSpace(parts[0]))
}
return s.doPipe(parts, bg)
}
// splitPipe splits by | but not ||, respecting quotes.
func splitPipe(input string) []string {
var parts []string
current := strings.Builder{}
inSingle := false
inDouble := false
parenDepth := 0
pendingDollar := false
for i := 0; i < len(input); i++ {
c := input[i]
switch {
case c == '\'' && !inDouble && parenDepth == 0:
inSingle = !inSingle
current.WriteByte(c)
case c == '"' && !inSingle && parenDepth == 0:
inDouble = !inDouble
current.WriteByte(c)
// Process substitution <(...): don't split | inside.
case c == '<' && !inSingle && !inDouble && i+1 < len(input) && input[i+1] == '(':
parenDepth++
current.WriteByte('<')
current.WriteByte('(')
i++
case c == '$' && !inSingle && !inDouble && i+1 < len(input) && input[i+1] == '(':
pendingDollar = true
current.WriteByte(c)
case c == '(' && !inSingle && !inDouble && (parenDepth > 0 || pendingDollar):
parenDepth++
pendingDollar = false
current.WriteByte(c)
case c == ')' && !inSingle && !inDouble && parenDepth > 0:
parenDepth--
current.WriteByte(c)
case c == '|' && !inSingle && !inDouble && parenDepth == 0:
if i+1 < len(input) && input[i+1] == '|' {
// || operator — pass both chars through
current.WriteByte(c)
current.WriteByte(input[i+1])
i++
} else {
parts = append(parts, strings.TrimSpace(current.String()))
current.Reset()
pendingDollar = false
}
default:
pendingDollar = false
current.WriteByte(c)
}
}
if current.Len() > 0 {
parts = append(parts, strings.TrimSpace(current.String()))
}
return parts
}
// parseArrayAssign detects NAME=(...) or NAME+=(...) at start of input.
func (s *Shell) parseArrayAssign(input string) (name string, appendMode bool, elements []string, ok bool) {
input = strings.TrimSpace(input)
// Read identifier
i := 0
for i < len(input) && isVarChar(input[i]) {
i++
}
if i == 0 {
return
}
name = input[:i]
if !isValidIdentifier(name) {
return
}
// Optional +
if i < len(input) && input[i] == '+' {
appendMode = true
i++
}
// Require =
if i >= len(input) || input[i] != '=' {
return
}
i++
// Require (
if i >= len(input) || input[i] != '(' {
return
}
i++
// Find matching )
depth := 1
j := i
inSingle := false
inDouble := false
for j < len(input) && depth > 0 {
c := input[j]
switch {
case c == '\'' && !inDouble:
inSingle = !inSingle
case c == '"' && !inSingle:
inDouble = !inDouble
case c == '(' && !inSingle && !inDouble:
depth++
case c == ')' && !inSingle && !inDouble:
depth--
}
if depth > 0 {
j++
}
}
content := input[i:j]
elements = s.tokenize(content)
ok = true
return
}
// executeCommand executes a single command (no pipes, no &&/||).
func (s *Shell) executeCommand(input string) error {
input = strings.TrimSpace(input)
if input == "" {
return nil
}
// { cmd; cmd; } command group
if strings.HasPrefix(input, "{") {
end := strings.LastIndex(input, "}")
if end > 0 {
inner := strings.TrimSpace(input[1:end])
return s.Execute(inner)
}
}
// Detect array assignment NAME=(...) or NAME+=(...)
if name, appendMode, elems, ok := s.parseArrayAssign(input); ok {
if appendMode {
s.appendArray(name, elems)
} else {
s.setArray(name, elems)
}
return nil
}
tokens := s.tokenize(input)
if len(tokens) == 0 {
return nil
}
cmdArgs, redirects := s.extractRedirects(tokens)
if len(cmdArgs) == 0 {
// Pure redirection, e.g. "> file" creates/truncates file
return s.withRedirects(redirects, func() error { return nil })
}
cmdName := cmdArgs[0]
args := cmdArgs[1:]
// Alias expansion
if alias, ok := aliases[cmdName]; ok {
full := alias
if len(args) > 0 {
full += " " + strings.Join(args, " ")
}
return s.withRedirects(redirects, func() error {
return s.Execute(full)
})
}
// Builtin
if builtin, ok := s.builtins[cmdName]; ok {
return s.withRedirects(redirects, func() error {
return builtin(args)
})
}
// External
return s.withRedirects(redirects, func() error {
return s.executeExternal(cmdName, args)
})
}
func (s *Shell) extractRedirects(tokens []string) ([]string, []redirect) {
var args []string
var redirects []redirect
i := 0
for i < len(tokens) {
tok := tokens[i]
switch {
// 2>&1
case tok == "2>&1":
redirects = append(redirects, redirect{2, ">", "&1"})
i++
// 1>&2
case tok == "1>&2":
redirects = append(redirects, redirect{1, ">", "&2"})
i++
// &> or &>> (both stdout+stderr)
case tok == "&>" || tok == "&>>":
if i+1 < len(tokens) {
redirects = append(redirects, redirect{-1, strings.TrimPrefix(tok, "&"), tokens[i+1]})
i += 2
} else {
i++
}
case strings.HasPrefix(tok, "&>"):
mode := ">"
dest := tok[2:]
if strings.HasPrefix(dest, ">") {
mode = ">>"
dest = dest[1:]
}
redirects = append(redirects, redirect{-1, mode, dest})
i++
// 2> 2>> 2>file
case tok == "2>" || tok == "2>>":
if i+1 < len(tokens) {
redirects = append(redirects, redirect{2, tok[1:], tokens[i+1]})
i += 2
} else {
i++
}
case strings.HasPrefix(tok, "2>>"):
redirects = append(redirects, redirect{2, ">>", tok[3:]})
i++
case strings.HasPrefix(tok, "2>"):
redirects = append(redirects, redirect{2, ">", tok[2:]})
i++
// > >>
case tok == ">" || tok == ">>":
if i+1 < len(tokens) {
redirects = append(redirects, redirect{1, tok, tokens[i+1]})
i += 2
} else {
i++
}
case strings.HasPrefix(tok, ">>") && len(tok) > 2:
redirects = append(redirects, redirect{1, ">>", tok[2:]})
i++
case strings.HasPrefix(tok, ">") && len(tok) > 1:
redirects = append(redirects, redirect{1, ">", tok[1:]})
i++
// <<< here-string
case tok == "<<<":
if i+1 < len(tokens) {
val := tokens[i+1] + "\n"
tmpf, err := os.CreateTemp("", "herestr*")
if err == nil {
tmpf.WriteString(val)
tmpf.Close()
redirects = append(redirects, redirect{0, "<", tmpf.Name()})
}
i += 2
} else {
i++
}
// < (stdin) — also handle process substitution <(...)
case tok == "<":
if i+1 < len(tokens) {
next := tokens[i+1]
// Process substitution: < <(cmd)
if strings.HasPrefix(next, "<(") && strings.HasSuffix(next, ")") {
cmd := next[2 : len(next)-1]
tmpf, err := os.CreateTemp("", "procsub*")
if err == nil {
s.withIO(nil, tmpf, nil, func() error { return s.Execute(cmd) })
tmpf.Close()
redirects = append(redirects, redirect{0, "<", tmpf.Name()})
}
i += 2
} else {
redirects = append(redirects, redirect{0, "<", next})
i += 2
}
} else {
i++
}
case strings.HasPrefix(tok, "<") && len(tok) > 1 && tok[1] != '<':
redirects = append(redirects, redirect{0, "<", tok[1:]})
i++
default:
args = append(args, tok)
i++
}
}
return args, redirects
}
func (s *Shell) withRedirects(redirects []redirect, fn func() error) error {
if len(redirects) == 0 {
return fn()
}
oldIn, oldOut, oldErr := s.Stdin, s.Stdout, s.Stderr
var toClose []io.Closer
defer func() {
s.Stdin, s.Stdout, s.Stderr = oldIn, oldOut, oldErr
for _, c := range toClose {
c.Close()
}
}()
for i, r := range redirects {
if r.dest == "/dev/null" {
redirects[i].dest = os.DevNull
r = redirects[i]
}
switch r.mode {
case ">":
if r.dest == "&1" {
s.Stderr = s.Stdout
} else if r.dest == "&2" {
s.Stdout = s.Stderr
} else {
f, err := os.Create(r.dest)
if err != nil {
return fmt.Errorf("cannot open %s: %v", r.dest, err)
}
toClose = append(toClose, f)
if r.fd == 1 || r.fd == -1 {
s.Stdout = f
}
if r.fd == 2 || r.fd == -1 {
s.Stderr = f
}
}
case ">>":
f, err := os.OpenFile(r.dest, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return fmt.Errorf("cannot open %s: %v", r.dest, err)
}
toClose = append(toClose, f)
if r.fd == 1 || r.fd == -1 {
s.Stdout = f
}
if r.fd == 2 || r.fd == -1 {
s.Stderr = f
}
case "<":
f, err := os.Open(r.dest)
if err != nil {
return fmt.Errorf("cannot open %s: %v", r.dest, err)
}
toClose = append(toClose, f)
s.Stdin = f
}
}
return fn()
}
func (s *Shell) executeExternal(cmdName string, args []string) error {
path := findExecutable(cmdName)
if path == "" {
fmt.Fprintf(s.Stderr, "%s: command not found\n", cmdName)
return exitCodeErr{127}
}
// If the file is a bash/sh script, run it through our own interpreter.
// This avoids the CRLF shebang problem (#!/usr/bin/env bash\r) and lets
// us execute scripts that have no .exe extension on Windows.
if data, err := os.ReadFile(path); err == nil && isShellScript(data) {
sh := New()
sh.Stdin = s.Stdin
sh.Stdout = s.Stdout
sh.Stderr = s.Stderr
sh.SetArgs(args)
sh.SetVar("0", path)
return sh.Execute(string(data))
}
cmd := exec.Command(path, args...)
cmd.Stdin = s.Stdin
cmd.Stdout = s.Stdout
cmd.Stderr = s.Stderr
err := cmd.Run()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
return exitCodeErr{exitErr.ExitCode()}
}
}
return err
}
// isShellScript returns true when data begins with a #!/…bash or #!/…sh shebang.
func isShellScript(data []byte) bool {
if len(data) < 2 || data[0] != '#' || data[1] != '!' {
return false
}
nl := bytes.IndexByte(data, '\n')
if nl < 0 {
nl = len(data)
}
line := strings.TrimRight(string(data[:nl]), "\r")
for _, word := range strings.Fields(line[2:]) {
base := filepath.Base(word)
if base == "bash" || base == "sh" {
return true
}
}
return false
}
func (s *Shell) executeCommandBg(input string) error {
tokens := s.tokenize(input)
if len(tokens) == 0 {
return nil
}
cmdArgs, _ := s.extractRedirects(tokens)
if len(cmdArgs) == 0 {
return nil
}
path := findExecutable(cmdArgs[0])
if path == "" {
return fmt.Errorf("%s: command not found", cmdArgs[0])
}
cmd := exec.Command(path, cmdArgs[1:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
return err
}
pid := cmd.Process.Pid
s.vars["!"] = fmt.Sprintf("%d", pid)
fmt.Fprintf(s.Stderr, "[1] %d\n", pid)
go func() { cmd.Wait() }()
return nil
}
func findExecutable(name string) string {
// Direct path
if strings.ContainsAny(name, "/\\") {
if info, err := os.Stat(name); err == nil && !info.IsDir() {
abs, _ := filepath.Abs(name)
return abs
}
return ""
}
path := os.Getenv("PATH")
for _, dir := range filepath.SplitList(path) {
for _, candidate := range []string{
filepath.Join(dir, name),
filepath.Join(dir, name+".exe"),
filepath.Join(dir, name+".cmd"),
filepath.Join(dir, name+".bat"),
} {
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
return candidate
}
}
}
return ""
}
// doPipe executes a pipeline where each stage feeds into the next.
func (s *Shell) doPipe(commands []string, bg bool) error {
_ = bg // background pipe support would require goroutines; skip for now
var prevBuf []byte
for i, cmd := range commands {
isLast := i == len(commands)-1
var stdinReader io.Reader
if i == 0 {
stdinReader = s.Stdin
} else {
stdinReader = bytes.NewReader(prevBuf)
}
if isLast {
return s.withIO(stdinReader, nil, nil, func() error {
return s.executeCommand(cmd)
})
}
var buf bytes.Buffer
s.withIO(stdinReader, &buf, nil, func() error {
return s.executeCommand(cmd)
})
prevBuf = buf.Bytes()
}
return nil
}