mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-11 17:10:38 -10:00
* 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.
572 lines
15 KiB
Go
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
|
|
}
|