Files
DeepSeek-Reasonix/internal/shellparse/bash.go
T
YHH 4fbbca6a99 refactor(go): adopt modern stdlib idioms and gate them (#7831)
* refactor(go): adopt modern stdlib idioms across kernel and desktop

Mechanical sweep with golangci-lint's modernize/intrange/copyloopvar/
usestdlibvars fixers plus hand-fixes for what they could not rewrite:
slices.Contains, min/max, slices.Backward, WaitGroup.Go, reflect.TypeFor,
maps.Copy, strings.Cut/CutPrefix/SplitSeq, strings.Builder, range-over-int.
Net -549 lines with no behavior change.

Three loops in session.go stay index-based: slices.Backward yields element
copies, so the fixer's rewrite silently dropped writes to DecisionReceipts
and ToolCalls (caught by TestDelivery* and TestDependentSameBatch*). All 49
slices.Backward sites were audited for that shape; only these three write
through the index, and they carry a nolint with the reason.

CaptureAfter's scan loop had an empty body and never ran any logic, so the
fixer could not express it. Drop it and the accessor it was the only caller
of. omitzero is deliberately untouched: turning omitempty into omitzero
changes the JSON written for nested structs, which is a wire-format change
to session files and the extension protocol.

* build(lint): gate the modern-idiom linters

Nothing stopped the codebase from drifting back to pre-1.22 idioms. Both
modules are now clean for modernize, intrange, copyloopvar, and
usestdlibvars, so enable them rather than leaving the sweep as a one-off.

omitzero stays disabled with the reason inline; usetesting, perfsprint, and
errorlint still have residue and land separately.
2026-08-07 15:30:33 +08:00

572 lines
15 KiB
Go

package shellparse
import (
"errors"
"strings"
"mvdan.cc/sh/v3/syntax"
)
// ParseBash parses command using Bash syntax.
func ParseBash(command string) (*syntax.File, error) {
return syntax.NewParser(syntax.Variant(syntax.LangBash)).Parse(strings.NewReader(command), "")
}
// StaticCommandPolicy controls which static shell features may be modeled
// without invoking a shell.
type StaticCommandPolicy struct {
AllowEnvAssignments bool
AllowStderrToStdout bool
}
// StaticCommand is a shell command reduced to exec.Command inputs.
type StaticCommand struct {
Argv []string
Env []string
MergeStderr bool
}
// StaticRejectReason names why a command cannot be reduced to StaticCommand.
type StaticRejectReason string
const (
StaticRejectParse StaticRejectReason = "parse error"
StaticRejectHereDoc StaticRejectReason = "here document"
StaticRejectControl StaticRejectReason = "shell control syntax"
StaticRejectRedirection StaticRejectReason = "shell redirection"
StaticRejectAssignment StaticRejectReason = "shell assignment"
StaticRejectExpansion StaticRejectReason = "shell expansion"
)
// StaticRejectError carries a machine-readable rejection reason plus optional
// parser detail.
type StaticRejectError struct {
Reason StaticRejectReason
Detail string
}
func (e *StaticRejectError) Error() string {
if e == nil {
return ""
}
if e.Detail != "" {
return e.Detail
}
return string(e.Reason)
}
func staticReject(reason StaticRejectReason, detail string) *StaticRejectError {
return &StaticRejectError{Reason: reason, Detail: detail}
}
// StaticFields returns the fields of a single static Bash command. It rejects
// shell syntax that can alter command shape, such as control operators,
// redirects, assignments, backgrounding, and runtime expansions.
func StaticFields(command string) ([]string, string) {
cmd, err := ParseStaticCommand(command, StaticCommandPolicy{})
if err != nil {
return nil, staticFieldsMessage(err)
}
return cmd.Argv, ""
}
// ParseStaticCommand parses a single static Bash command into argv and optional
// environment assignments. It never evaluates shell expansion or runs a shell.
func ParseStaticCommand(command string, policy StaticCommandPolicy) (StaticCommand, error) {
var out StaticCommand
if strings.TrimSpace(command) == "" {
return out, nil
}
file, err := ParseBash(command)
if err != nil {
return out, staticReject(StaticRejectParse, err.Error())
}
if HasHereDoc(file) {
return out, staticReject(StaticRejectHereDoc, "")
}
if len(file.Stmts) != 1 {
return out, staticReject(StaticRejectControl, "")
}
stmt := file.Stmts[0]
if stmt == nil || stmt.Negated || stmt.Background || stmt.Coprocess || stmt.Disown {
return out, staticReject(StaticRejectControl, "")
}
call, ok := stmt.Cmd.(*syntax.CallExpr)
if !ok {
return out, staticReject(StaticRejectControl, "")
}
if len(stmt.Redirs) > 0 {
mergeStderr, err := staticRedirections(stmt.Redirs, policy)
if err != nil {
return out, err
}
out.MergeStderr = mergeStderr
}
if len(call.Assigns) > 0 {
env, err := staticAssignments(call.Assigns, policy)
if err != nil {
return out, err
}
out.Env = env
}
out.Argv = make([]string, 0, len(call.Args))
for _, arg := range call.Args {
field, ok := StaticWord(arg)
if !ok {
return out, staticReject(StaticRejectExpansion, "")
}
out.Argv = append(out.Argv, field)
}
if len(out.Argv) == 0 && len(out.Env) > 0 {
return StaticCommand{}, staticReject(StaticRejectAssignment, "shell assignment without command")
}
return out, nil
}
func staticFieldsMessage(err error) string {
var reject *StaticRejectError
if !errors.As(err, &reject) {
return err.Error()
}
switch reject.Reason {
case StaticRejectParse:
return reject.Error()
case StaticRejectHereDoc:
return "here document"
case StaticRejectExpansion:
return "shell expansion"
default:
return "shell control syntax"
}
}
func staticAssignments(assigns []*syntax.Assign, policy StaticCommandPolicy) ([]string, error) {
if !policy.AllowEnvAssignments {
return nil, staticReject(StaticRejectAssignment, "")
}
env := make([]string, 0, len(assigns))
for _, assign := range assigns {
if assign == nil || assign.Append || assign.Naked || assign.Name == nil || assign.Index != nil || assign.Array != nil {
return nil, staticReject(StaticRejectAssignment, "")
}
value := ""
if assign.Value != nil {
var ok bool
value, ok = StaticWord(assign.Value)
if !ok {
return nil, staticReject(StaticRejectExpansion, "")
}
}
env = append(env, assign.Name.Value+"="+value)
}
return env, nil
}
func staticRedirections(redirs []*syntax.Redirect, policy StaticCommandPolicy) (bool, error) {
mergeStderr := false
for _, redir := range redirs {
if !policy.AllowStderrToStdout || !isStderrToStdout(redir) || mergeStderr {
return false, staticReject(StaticRejectRedirection, "")
}
mergeStderr = true
}
return mergeStderr, nil
}
func isStderrToStdout(redir *syntax.Redirect) bool {
if redir == nil || redir.Op != syntax.DplOut || redir.N == nil || redir.N.Value != "2" {
return false
}
word, ok := StaticWord(redir.Word)
return ok && word == "1"
}
// ContainsShellSyntax reports whether command is anything other than a single
// static Bash command. Parse failures are treated as syntax to keep callers
// conservative.
func ContainsShellSyntax(command string) bool {
if strings.TrimSpace(command) == "" {
return false
}
_, malformed := StaticFields(command)
return malformed != ""
}
// ApprovalFeatures describes Bash syntax that affects whether a permission can
// be reused. CommandPrefix contains the leading static argv fields up to the
// first runtime expansion; it lets callers recognize a static eval/-c command
// even when its payload is dynamic.
type ApprovalFeatures struct {
CommandPrefix []string
DynamicCommandName bool
NestedExecution bool
Expansion bool
Assignment bool
Redirection bool
}
// AnalyzeApprovalFeatures inspects one simple Bash command without evaluating
// expansions. ok is false for compound or otherwise unsupported statements.
func AnalyzeApprovalFeatures(command string) (features ApprovalFeatures, ok bool) {
file, err := ParseBash(command)
if err != nil || len(file.Stmts) != 1 {
return features, false
}
stmt := file.Stmts[0]
if stmt == nil || stmt.Negated || stmt.Background || stmt.Coprocess || stmt.Disown {
return features, false
}
call, ok := stmt.Cmd.(*syntax.CallExpr)
if !ok {
return features, false
}
syntax.Walk(file, func(node syntax.Node) bool {
switch node.(type) {
case *syntax.CmdSubst, *syntax.ProcSubst:
features.NestedExecution = true
case *syntax.ParamExp, *syntax.ArithmExp, *syntax.ExtGlob:
features.Expansion = true
case *syntax.Assign:
features.Assignment = true
case *syntax.Redirect:
features.Redirection = true
}
return true
})
for _, arg := range call.Args {
if wordHasUnescapedBrace(arg) {
syntax.SplitBraces(arg)
}
}
for i, arg := range call.Args {
field, static := StaticWord(arg)
if !static {
features.Expansion = true
if i == 0 {
features.DynamicCommandName = true
}
break
}
features.CommandPrefix = append(features.CommandPrefix, field)
}
return features, true
}
// ContainsUnquotedGlob reports whether command contains an unquoted shell glob
// token. StaticFields deliberately returns argv without expanding globs, so
// permission callers use this additional check before reusing broad rules.
func ContainsUnquotedGlob(command string) bool {
file, err := ParseBash(command)
if err != nil {
return true
}
found := false
syntax.Walk(file, func(node syntax.Node) bool {
word, ok := node.(*syntax.Word)
if !ok {
return !found
}
for _, part := range word.Parts {
lit, ok := part.(*syntax.Lit)
if ok && hasUnescapedGlobMeta(lit.Value) {
found = true
return false
}
}
return !found
})
return found
}
func hasUnescapedGlobMeta(value string) bool {
return hasUnescapedMeta(value, "*?[")
}
func wordHasUnescapedBrace(word *syntax.Word) bool {
if word == nil {
return false
}
for _, part := range word.Parts {
if lit, ok := part.(*syntax.Lit); ok && hasUnescapedMeta(lit.Value, "{") {
return true
}
}
return false
}
func hasUnescapedMeta(value, meta string) bool {
escaped := false
for i := range len(value) {
if escaped {
escaped = false
continue
}
if value[i] == '\\' {
escaped = true
continue
}
if strings.ContainsRune(meta, rune(value[i])) {
return true
}
}
return false
}
// CanMaskEarlierFailure reports whether a later part of command can hide the
// failure of an earlier part, so the shell's final exit status is not evidence
// that every step succeeded.
//
// Only `&&` chains are exempt: bash short-circuits them and reports the first
// failing command's status, so `build && test` already surfaces a failed build.
// Everything else can mask — `;` and newlines run the next command regardless,
// `||` runs it precisely when the previous one failed, `|` reports only the
// last stage, and `&` detaches the status entirely.
//
// ok is false when the command cannot be analyzed statically (parse failure,
// here-documents, unsupported control syntax); callers must not read canMask
// as proven-safe in that case.
func CanMaskEarlierFailure(command string) (canMask bool, ok bool) {
if strings.TrimSpace(command) == "" {
return false, true
}
file, err := ParseBash(command)
if err != nil || HasHereDoc(file) {
return false, false
}
// Two or more top-level statements are `;`/newline separated.
if len(file.Stmts) > 1 {
return true, true
}
for _, stmt := range file.Stmts {
masks, stmtOK := stmtCanMaskEarlierFailure(stmt)
if !stmtOK {
return false, false
}
if masks {
return true, true
}
}
return false, true
}
func stmtCanMaskEarlierFailure(stmt *syntax.Stmt) (bool, bool) {
if stmt == nil || stmt.Negated || stmt.Coprocess || stmt.Disown {
return false, false
}
if stmt.Background {
return true, true
}
switch cmd := stmt.Cmd.(type) {
case *syntax.BinaryCmd:
if cmd.Op != syntax.AndStmt {
// `||`, `|`, and `|&` all let a later stage decide the status.
return true, true
}
xMasks, xOK := stmtCanMaskEarlierFailure(cmd.X)
if !xOK {
return false, false
}
yMasks, yOK := stmtCanMaskEarlierFailure(cmd.Y)
if !yOK {
return false, false
}
return xMasks || yMasks, true
case *syntax.CallExpr:
return false, true
default:
return false, false
}
}
// SplitTopLevel returns simple command segments split at top-level shell
// control operators. It preserves each segment's original source text. ok is
// false when the command cannot be decomposed without losing safety.
func SplitTopLevel(command string) (segments []string, split bool, ok bool) {
if strings.TrimSpace(command) == "" {
return nil, false, true
}
file, err := ParseBash(command)
if err != nil || HasHereDoc(file) {
return nil, false, false
}
for _, stmt := range file.Stmts {
if len(file.Stmts) > 1 {
split = true
}
if !appendTopLevelSegments(command, stmt, &segments, &split) {
return nil, false, false
}
}
segments = compactSegments(segments)
return segments, split, true
}
func appendTopLevelSegments(source string, stmt *syntax.Stmt, segments *[]string, split *bool) bool {
if stmt == nil || stmt.Negated || stmt.Coprocess || stmt.Disown {
return false
}
switch cmd := stmt.Cmd.(type) {
case *syntax.BinaryCmd:
if stmt.Background || len(stmt.Redirs) > 0 {
return false
}
*split = true
return appendTopLevelSegments(source, cmd.X, segments, split) &&
appendTopLevelSegments(source, cmd.Y, segments, split)
case *syntax.CallExpr:
segment := sourceForStmt(source, stmt)
if segment != "" {
*segments = append(*segments, segment)
}
if stmt.Background {
*split = true
}
return true
default:
return false
}
}
func sourceForStmt(source string, stmt *syntax.Stmt) string {
start := int(stmt.Pos().Offset())
end := int(stmt.End().Offset())
if stmt.Semicolon.IsValid() {
semi := int(stmt.Semicolon.Offset())
if start <= semi && semi <= end {
end = semi
}
}
if start < 0 || end < start || end > len(source) {
return ""
}
return strings.TrimSpace(source[start:end])
}
func compactSegments(in []string) []string {
out := in[:0]
for _, segment := range in {
segment = strings.TrimSpace(segment)
if segment == "" || strings.HasPrefix(segment, "#") {
continue
}
out = append(out, segment)
}
return out
}
// HasHereDoc reports whether file contains a here-document. Here-doc bodies are
// arbitrary text, so callers that analyze shell syntax should usually fail
// closed when this returns true.
func HasHereDoc(file *syntax.File) bool {
if file == nil {
return false
}
has := false
syntax.Walk(file, func(node syntax.Node) bool {
if node == nil || has {
return false
}
if redir, ok := node.(*syntax.Redirect); ok && redir.Hdoc != nil {
has = true
return false
}
return true
})
return has
}
// StaticWord returns word's static value, accepting literal and quoted literal
// parts while rejecting runtime expansions.
func StaticWord(word *syntax.Word) (string, bool) {
if word == nil {
return "", false
}
var b strings.Builder
for _, part := range word.Parts {
value, ok := staticWordPart(part, false)
if !ok {
return "", false
}
b.WriteString(value)
}
return b.String(), true
}
func staticWordPart(part syntax.WordPart, inDoubleQuotes bool) (string, bool) {
switch p := part.(type) {
case *syntax.Lit:
return unescapeLit(p.Value, inDoubleQuotes), true
case *syntax.SglQuoted:
return p.Value, true
case *syntax.DblQuoted:
var b strings.Builder
for _, nested := range p.Parts {
value, ok := staticWordPart(nested, true)
if !ok {
return "", false
}
b.WriteString(value)
}
return b.String(), true
default:
return "", false
}
}
func unescapeLit(s string, inDoubleQuotes bool) string {
if !strings.Contains(s, "\\") {
return s
}
var b strings.Builder
for i := 0; i < len(s); i++ {
c := s[i]
if c != '\\' || i+1 >= len(s) {
b.WriteByte(c)
continue
}
next := s[i+1]
if next == '\n' {
i++
continue
}
if !inDoubleQuotes || next == '$' || next == '`' || next == '"' || next == '\\' {
b.WriteByte(next)
i++
continue
}
b.WriteByte(c)
}
return b.String()
}
// IsAssignment reports whether word has Bash assignment syntax.
func IsAssignment(word string) bool {
name, _, ok := strings.Cut(word, "=")
if !ok || name == "" {
return false
}
for i := range len(name) {
c := name[i]
if i == 0 {
if c != '_' && (c < 'A' || c > 'Z') && (c < 'a' || c > 'z') {
return false
}
continue
}
if c != '_' && (c < 'A' || c > 'Z') && (c < 'a' || c > 'z') && (c < '0' || c > '9') {
return false
}
}
return true
}
// WordBase returns the basename of a shell command word.
func WordBase(word string) string {
if i := strings.LastIndexByte(word, '/'); i >= 0 {
return word[i+1:]
}
return word
}