返回 DeepSeek-Reasonix
bash.go
根目录 / internal / shellparse / bash.go
1 package shellparse
2
3 import (
4 "errors"
5 "strings"
6
7 "mvdan.cc/sh/v3/syntax"
8 )
9
10 // ParseBash parses command using Bash syntax.
11 func ParseBash(command string) (*syntax.File, error) {
12 return syntax.NewParser(syntax.Variant(syntax.LangBash)).Parse(strings.NewReader(command), "")
13 }
14
15 // StaticCommandPolicy controls which static shell features may be modeled
16 // without invoking a shell.
17 type StaticCommandPolicy struct {
18 AllowEnvAssignments bool
19 AllowStderrToStdout bool
20 }
21
22 // StaticCommand is a shell command reduced to exec.Command inputs.
23 type StaticCommand struct {
24 Argv []string
25 Env []string
26 MergeStderr bool
27 }
28
29 // StaticRejectReason names why a command cannot be reduced to StaticCommand.
30 type StaticRejectReason string
31
32 const (
33 StaticRejectParse StaticRejectReason = "parse error"
34 StaticRejectHereDoc StaticRejectReason = "here document"
35 StaticRejectControl StaticRejectReason = "shell control syntax"
36 StaticRejectRedirection StaticRejectReason = "shell redirection"
37 StaticRejectAssignment StaticRejectReason = "shell assignment"
38 StaticRejectExpansion StaticRejectReason = "shell expansion"
39 )
40
41 // StaticRejectError carries a machine-readable rejection reason plus optional
42 // parser detail.
43 type StaticRejectError struct {
44 Reason StaticRejectReason
45 Detail string
46 }
47
48 func (e *StaticRejectError) Error() string {
49 if e == nil {
50 return ""
51 }
52 if e.Detail != "" {
53 return e.Detail
54 }
55 return string(e.Reason)
56 }
57
58 func staticReject(reason StaticRejectReason, detail string) *StaticRejectError {
59 return &StaticRejectError{Reason: reason, Detail: detail}
60 }
61
62 // StaticFields returns the fields of a single static Bash command. It rejects
63 // shell syntax that can alter command shape, such as control operators,
64 // redirects, assignments, backgrounding, and runtime expansions.
65 func StaticFields(command string) ([]string, string) {
66 cmd, err := ParseStaticCommand(command, StaticCommandPolicy{})
67 if err != nil {
68 return nil, staticFieldsMessage(err)
69 }
70 return cmd.Argv, ""
71 }
72
73 // ParseStaticCommand parses a single static Bash command into argv and optional
74 // environment assignments. It never evaluates shell expansion or runs a shell.
75 func ParseStaticCommand(command string, policy StaticCommandPolicy) (StaticCommand, error) {
76 var out StaticCommand
77 if strings.TrimSpace(command) == "" {
78 return out, nil
79 }
80 file, err := ParseBash(command)
81 if err != nil {
82 return out, staticReject(StaticRejectParse, err.Error())
83 }
84 if HasHereDoc(file) {
85 return out, staticReject(StaticRejectHereDoc, "")
86 }
87 if len(file.Stmts) != 1 {
88 return out, staticReject(StaticRejectControl, "")
89 }
90 stmt := file.Stmts[0]
91 if stmt == nil || stmt.Negated || stmt.Background || stmt.Coprocess || stmt.Disown {
92 return out, staticReject(StaticRejectControl, "")
93 }
94 call, ok := stmt.Cmd.(*syntax.CallExpr)
95 if !ok {
96 return out, staticReject(StaticRejectControl, "")
97 }
98 if len(stmt.Redirs) > 0 {
99 mergeStderr, err := staticRedirections(stmt.Redirs, policy)
100 if err != nil {
101 return out, err
102 }
103 out.MergeStderr = mergeStderr
104 }
105 if len(call.Assigns) > 0 {
106 env, err := staticAssignments(call.Assigns, policy)
107 if err != nil {
108 return out, err
109 }
110 out.Env = env
111 }
112
113 out.Argv = make([]string, 0, len(call.Args))
114 for _, arg := range call.Args {
115 field, ok := StaticWord(arg)
116 if !ok {
117 return out, staticReject(StaticRejectExpansion, "")
118 }
119 out.Argv = append(out.Argv, field)
120 }
121 if len(out.Argv) == 0 && len(out.Env) > 0 {
122 return StaticCommand{}, staticReject(StaticRejectAssignment, "shell assignment without command")
123 }
124 return out, nil
125 }
126
127 func staticFieldsMessage(err error) string {
128 var reject *StaticRejectError
129 if !errors.As(err, &reject) {
130 return err.Error()
131 }
132 switch reject.Reason {
133 case StaticRejectParse:
134 return reject.Error()
135 case StaticRejectHereDoc:
136 return "here document"
137 case StaticRejectExpansion:
138 return "shell expansion"
139 default:
140 return "shell control syntax"
141 }
142 }
143
144 func staticAssignments(assigns []*syntax.Assign, policy StaticCommandPolicy) ([]string, error) {
145 if !policy.AllowEnvAssignments {
146 return nil, staticReject(StaticRejectAssignment, "")
147 }
148 env := make([]string, 0, len(assigns))
149 for _, assign := range assigns {
150 if assign == nil || assign.Append || assign.Naked || assign.Name == nil || assign.Index != nil || assign.Array != nil {
151 return nil, staticReject(StaticRejectAssignment, "")
152 }
153 value := ""
154 if assign.Value != nil {
155 var ok bool
156 value, ok = StaticWord(assign.Value)
157 if !ok {
158 return nil, staticReject(StaticRejectExpansion, "")
159 }
160 }
161 env = append(env, assign.Name.Value+"="+value)
162 }
163 return env, nil
164 }
165
166 func staticRedirections(redirs []*syntax.Redirect, policy StaticCommandPolicy) (bool, error) {
167 mergeStderr := false
168 for _, redir := range redirs {
169 if !policy.AllowStderrToStdout || !isStderrToStdout(redir) || mergeStderr {
170 return false, staticReject(StaticRejectRedirection, "")
171 }
172 mergeStderr = true
173 }
174 return mergeStderr, nil
175 }
176
177 func isStderrToStdout(redir *syntax.Redirect) bool {
178 if redir == nil || redir.Op != syntax.DplOut || redir.N == nil || redir.N.Value != "2" {
179 return false
180 }
181 word, ok := StaticWord(redir.Word)
182 return ok && word == "1"
183 }
184
185 // ContainsShellSyntax reports whether command is anything other than a single
186 // static Bash command. Parse failures are treated as syntax to keep callers
187 // conservative.
188 func ContainsShellSyntax(command string) bool {
189 if strings.TrimSpace(command) == "" {
190 return false
191 }
192 _, malformed := StaticFields(command)
193 return malformed != ""
194 }
195
196 // ApprovalFeatures describes Bash syntax that affects whether a permission can
197 // be reused. CommandPrefix contains the leading static argv fields up to the
198 // first runtime expansion; it lets callers recognize a static eval/-c command
199 // even when its payload is dynamic.
200 type ApprovalFeatures struct {
201 CommandPrefix []string
202 DynamicCommandName bool
203 NestedExecution bool
204 Expansion bool
205 Assignment bool
206 Redirection bool
207 }
208
209 // AnalyzeApprovalFeatures inspects one simple Bash command without evaluating
210 // expansions. ok is false for compound or otherwise unsupported statements.
211 func AnalyzeApprovalFeatures(command string) (features ApprovalFeatures, ok bool) {
212 file, err := ParseBash(command)
213 if err != nil || len(file.Stmts) != 1 {
214 return features, false
215 }
216 stmt := file.Stmts[0]
217 if stmt == nil || stmt.Negated || stmt.Background || stmt.Coprocess || stmt.Disown {
218 return features, false
219 }
220 call, ok := stmt.Cmd.(*syntax.CallExpr)
221 if !ok {
222 return features, false
223 }
224 syntax.Walk(file, func(node syntax.Node) bool {
225 switch node.(type) {
226 case *syntax.CmdSubst, *syntax.ProcSubst:
227 features.NestedExecution = true
228 case *syntax.ParamExp, *syntax.ArithmExp, *syntax.ExtGlob:
229 features.Expansion = true
230 case *syntax.Assign:
231 features.Assignment = true
232 case *syntax.Redirect:
233 features.Redirection = true
234 }
235 return true
236 })
237 for _, arg := range call.Args {
238 if wordHasUnescapedBrace(arg) {
239 syntax.SplitBraces(arg)
240 }
241 }
242 for i, arg := range call.Args {
243 field, static := StaticWord(arg)
244 if !static {
245 features.Expansion = true
246 if i == 0 {
247 features.DynamicCommandName = true
248 }
249 break
250 }
251 features.CommandPrefix = append(features.CommandPrefix, field)
252 }
253 return features, true
254 }
255
256 // ContainsUnquotedGlob reports whether command contains an unquoted shell glob
257 // token. StaticFields deliberately returns argv without expanding globs, so
258 // permission callers use this additional check before reusing broad rules.
259 func ContainsUnquotedGlob(command string) bool {
260 file, err := ParseBash(command)
261 if err != nil {
262 return true
263 }
264 found := false
265 syntax.Walk(file, func(node syntax.Node) bool {
266 word, ok := node.(*syntax.Word)
267 if !ok {
268 return !found
269 }
270 for _, part := range word.Parts {
271 lit, ok := part.(*syntax.Lit)
272 if ok && hasUnescapedGlobMeta(lit.Value) {
273 found = true
274 return false
275 }
276 }
277 return !found
278 })
279 return found
280 }
281
282 func hasUnescapedGlobMeta(value string) bool {
283 return hasUnescapedMeta(value, "*?[")
284 }
285
286 func wordHasUnescapedBrace(word *syntax.Word) bool {
287 if word == nil {
288 return false
289 }
290 for _, part := range word.Parts {
291 if lit, ok := part.(*syntax.Lit); ok && hasUnescapedMeta(lit.Value, "{") {
292 return true
293 }
294 }
295 return false
296 }
297
298 func hasUnescapedMeta(value, meta string) bool {
299 escaped := false
300 for i := 0; i < len(value); i++ {
301 if escaped {
302 escaped = false
303 continue
304 }
305 if value[i] == '\\' {
306 escaped = true
307 continue
308 }
309 if strings.ContainsRune(meta, rune(value[i])) {
310 return true
311 }
312 }
313 return false
314 }
315
316 // CanMaskEarlierFailure reports whether a later part of command can hide the
317 // failure of an earlier part, so the shell's final exit status is not evidence
318 // that every step succeeded.
319 //
320 // Only `&&` chains are exempt: bash short-circuits them and reports the first
321 // failing command's status, so `build && test` already surfaces a failed build.
322 // Everything else can mask — `;` and newlines run the next command regardless,
323 // `||` runs it precisely when the previous one failed, `|` reports only the
324 // last stage, and `&` detaches the status entirely.
325 //
326 // ok is false when the command cannot be analyzed statically (parse failure,
327 // here-documents, unsupported control syntax); callers must not read canMask
328 // as proven-safe in that case.
329 func CanMaskEarlierFailure(command string) (canMask bool, ok bool) {
330 if strings.TrimSpace(command) == "" {
331 return false, true
332 }
333 file, err := ParseBash(command)
334 if err != nil || HasHereDoc(file) {
335 return false, false
336 }
337 // Two or more top-level statements are `;`/newline separated.
338 if len(file.Stmts) > 1 {
339 return true, true
340 }
341 for _, stmt := range file.Stmts {
342 masks, stmtOK := stmtCanMaskEarlierFailure(stmt)
343 if !stmtOK {
344 return false, false
345 }
346 if masks {
347 return true, true
348 }
349 }
350 return false, true
351 }
352
353 func stmtCanMaskEarlierFailure(stmt *syntax.Stmt) (bool, bool) {
354 if stmt == nil || stmt.Negated || stmt.Coprocess || stmt.Disown {
355 return false, false
356 }
357 if stmt.Background {
358 return true, true
359 }
360 switch cmd := stmt.Cmd.(type) {
361 case *syntax.BinaryCmd:
362 if cmd.Op != syntax.AndStmt {
363 // `||`, `|`, and `|&` all let a later stage decide the status.
364 return true, true
365 }
366 xMasks, xOK := stmtCanMaskEarlierFailure(cmd.X)
367 if !xOK {
368 return false, false
369 }
370 yMasks, yOK := stmtCanMaskEarlierFailure(cmd.Y)
371 if !yOK {
372 return false, false
373 }
374 return xMasks || yMasks, true
375 case *syntax.CallExpr:
376 return false, true
377 default:
378 return false, false
379 }
380 }
381
382 // SplitTopLevel returns simple command segments split at top-level shell
383 // control operators. It preserves each segment's original source text. ok is
384 // false when the command cannot be decomposed without losing safety.
385 func SplitTopLevel(command string) (segments []string, split bool, ok bool) {
386 if strings.TrimSpace(command) == "" {
387 return nil, false, true
388 }
389 file, err := ParseBash(command)
390 if err != nil || HasHereDoc(file) {
391 return nil, false, false
392 }
393
394 for _, stmt := range file.Stmts {
395 if len(file.Stmts) > 1 {
396 split = true
397 }
398 if !appendTopLevelSegments(command, stmt, &segments, &split) {
399 return nil, false, false
400 }
401 }
402 segments = compactSegments(segments)
403 return segments, split, true
404 }
405
406 func appendTopLevelSegments(source string, stmt *syntax.Stmt, segments *[]string, split *bool) bool {
407 if stmt == nil || stmt.Negated || stmt.Coprocess || stmt.Disown {
408 return false
409 }
410 switch cmd := stmt.Cmd.(type) {
411 case *syntax.BinaryCmd:
412 if stmt.Background || len(stmt.Redirs) > 0 {
413 return false
414 }
415 *split = true
416 return appendTopLevelSegments(source, cmd.X, segments, split) &&
417 appendTopLevelSegments(source, cmd.Y, segments, split)
418 case *syntax.CallExpr:
419 segment := sourceForStmt(source, stmt)
420 if segment != "" {
421 *segments = append(*segments, segment)
422 }
423 if stmt.Background {
424 *split = true
425 }
426 return true
427 default:
428 return false
429 }
430 }
431
432 func sourceForStmt(source string, stmt *syntax.Stmt) string {
433 start := int(stmt.Pos().Offset())
434 end := int(stmt.End().Offset())
435 if stmt.Semicolon.IsValid() {
436 semi := int(stmt.Semicolon.Offset())
437 if start <= semi && semi <= end {
438 end = semi
439 }
440 }
441 if start < 0 || end < start || end > len(source) {
442 return ""
443 }
444 return strings.TrimSpace(source[start:end])
445 }
446
447 func compactSegments(in []string) []string {
448 out := in[:0]
449 for _, segment := range in {
450 segment = strings.TrimSpace(segment)
451 if segment == "" || strings.HasPrefix(segment, "#") {
452 continue
453 }
454 out = append(out, segment)
455 }
456 return out
457 }
458
459 // HasHereDoc reports whether file contains a here-document. Here-doc bodies are
460 // arbitrary text, so callers that analyze shell syntax should usually fail
461 // closed when this returns true.
462 func HasHereDoc(file *syntax.File) bool {
463 if file == nil {
464 return false
465 }
466 has := false
467 syntax.Walk(file, func(node syntax.Node) bool {
468 if node == nil || has {
469 return false
470 }
471 if redir, ok := node.(*syntax.Redirect); ok && redir.Hdoc != nil {
472 has = true
473 return false
474 }
475 return true
476 })
477 return has
478 }
479
480 // StaticWord returns word's static value, accepting literal and quoted literal
481 // parts while rejecting runtime expansions.
482 func StaticWord(word *syntax.Word) (string, bool) {
483 if word == nil {
484 return "", false
485 }
486 var b strings.Builder
487 for _, part := range word.Parts {
488 value, ok := staticWordPart(part, false)
489 if !ok {
490 return "", false
491 }
492 b.WriteString(value)
493 }
494 return b.String(), true
495 }
496
497 func staticWordPart(part syntax.WordPart, inDoubleQuotes bool) (string, bool) {
498 switch p := part.(type) {
499 case *syntax.Lit:
500 return unescapeLit(p.Value, inDoubleQuotes), true
501 case *syntax.SglQuoted:
502 return p.Value, true
503 case *syntax.DblQuoted:
504 var b strings.Builder
505 for _, nested := range p.Parts {
506 value, ok := staticWordPart(nested, true)
507 if !ok {
508 return "", false
509 }
510 b.WriteString(value)
511 }
512 return b.String(), true
513 default:
514 return "", false
515 }
516 }
517
518 func unescapeLit(s string, inDoubleQuotes bool) string {
519 if !strings.Contains(s, "\\") {
520 return s
521 }
522 var b strings.Builder
523 for i := 0; i < len(s); i++ {
524 c := s[i]
525 if c != '\\' || i+1 >= len(s) {
526 b.WriteByte(c)
527 continue
528 }
529 next := s[i+1]
530 if next == '\n' {
531 i++
532 continue
533 }
534 if !inDoubleQuotes || next == '$' || next == '`' || next == '"' || next == '\\' {
535 b.WriteByte(next)
536 i++
537 continue
538 }
539 b.WriteByte(c)
540 }
541 return b.String()
542 }
543
544 // IsAssignment reports whether word has Bash assignment syntax.
545 func IsAssignment(word string) bool {
546 name, _, ok := strings.Cut(word, "=")
547 if !ok || name == "" {
548 return false
549 }
550 for i := 0; i < len(name); i++ {
551 c := name[i]
552 if i == 0 {
553 if c != '_' && (c < 'A' || c > 'Z') && (c < 'a' || c > 'z') {
554 return false
555 }
556 continue
557 }
558 if c != '_' && (c < 'A' || c > 'Z') && (c < 'a' || c > 'z') && (c < '0' || c > '9') {
559 return false
560 }
561 }
562 return true
563 }
564
565 // WordBase returns the basename of a shell command word.
566 func WordBase(word string) string {
567 if i := strings.LastIndexByte(word, '/'); i >= 0 {
568 return word[i+1:]
569 }
570 return word
571 }
572
572 lines GO