返回 DeepSeek-Reasonix
bash_redirect.go
根目录 / internal / shellsafe / bash_redirect.go
1 package shellsafe
2
3 import (
4 "sort"
5 "strings"
6
7 "mvdan.cc/sh/v3/syntax"
8
9 "reasonix/internal/shellparse"
10 )
11
12 // NormalizeBashSafeRedirectsForMatch returns a copy of subject with redirect
13 // syntax removed only when the redirect cannot write to a real file. It is used
14 // for shell safety matching, never for execution.
15 //
16 // Supported safe forms are fd duplication/close (`2>&1`, `>&2`, `2>&-`,
17 // `0<&1`) and output redirects to a null sink (`>/dev/null`, `>$null`,
18 // `>nul`, `2> /dev/null`, `>>/dev/null`, `&>/dev/null`, `&>>/dev/null`).
19 // The shell execution layer normalizes these null-sink spellings to the actual
20 // sink for the resolved shell. Other redirections are left unnormalized so the
21 // usual shell-syntax guard keeps prefix/read-only matching conservative.
22 func NormalizeBashSafeRedirectsForMatch(subject string) (string, bool) {
23 file, err := shellparse.ParseBash(subject)
24 if err != nil || shellparse.HasHereDoc(file) {
25 return "", false
26 }
27 spans, ok := safeRedirectSpans(subject, file.Stmts)
28 if !ok {
29 return "", false
30 }
31 if len(spans) == 0 {
32 return subject, true
33 }
34 sort.Slice(spans, func(i, j int) bool { return spans[i].start < spans[j].start })
35
36 var out strings.Builder
37 last := 0
38 for _, span := range spans {
39 if span.start < last || span.end > len(subject) {
40 return "", false
41 }
42 out.WriteString(subject[last:span.start])
43 last = span.end
44 }
45 out.WriteString(subject[last:])
46 return strings.TrimSpace(out.String()), true
47 }
48
49 type redirectSpan struct {
50 start int
51 end int
52 }
53
54 func safeRedirectSpans(source string, stmts []*syntax.Stmt) ([]redirectSpan, bool) {
55 var spans []redirectSpan
56 for _, stmt := range stmts {
57 if !appendSafeRedirectSpans(source, stmt, &spans) {
58 return nil, false
59 }
60 }
61 return spans, true
62 }
63
64 func appendSafeRedirectSpans(source string, stmt *syntax.Stmt, spans *[]redirectSpan) bool {
65 if stmt == nil {
66 return true
67 }
68 for _, redir := range stmt.Redirs {
69 span, ok := safeRedirectSpan(source, redir)
70 if !ok {
71 return false
72 }
73 *spans = append(*spans, span)
74 }
75 if binary, ok := stmt.Cmd.(*syntax.BinaryCmd); ok {
76 return appendSafeRedirectSpans(source, binary.X, spans) &&
77 appendSafeRedirectSpans(source, binary.Y, spans)
78 }
79 return true
80 }
81
82 func safeRedirectSpan(source string, redir *syntax.Redirect) (redirectSpan, bool) {
83 if redir == nil {
84 return redirectSpan{}, false
85 }
86 switch redir.Op {
87 case syntax.DplOut, syntax.DplIn:
88 if !isSafeFDDupWord(source, redir.Word) {
89 return redirectSpan{}, false
90 }
91 case syntax.RdrOut, syntax.AppOut, syntax.RdrClob, syntax.AppClob, syntax.RdrAll, syntax.AppAll, syntax.RdrAllClob, syntax.AppAllClob:
92 if !isNullRedirectWord(source, redir.Word) {
93 return redirectSpan{}, false
94 }
95 default:
96 return redirectSpan{}, false
97 }
98 start := int(redir.OpPos.Offset())
99 if redir.N != nil && redir.N.Pos().IsValid() {
100 start = int(redir.N.Pos().Offset())
101 }
102 end := int(redir.End().Offset())
103 if start < 0 || end < start || end > len(source) {
104 return redirectSpan{}, false
105 }
106 return redirectSpan{start: start, end: end}, true
107 }
108
109 func isSafeFDDupWord(source string, word *syntax.Word) bool {
110 value := redirectWordSource(source, word)
111 if value == "-" {
112 return true
113 }
114 if value == "" {
115 return false
116 }
117 for i := 0; i < len(value); i++ {
118 if value[i] < '0' || value[i] > '9' {
119 return false
120 }
121 }
122 return true
123 }
124
125 func isNullRedirectWord(source string, word *syntax.Word) bool {
126 value := redirectWordSource(source, word)
127 if value == "/dev/null" {
128 return true
129 }
130 return strings.EqualFold(value, "$null") || strings.EqualFold(value, "nul")
131 }
132
133 func redirectWordSource(source string, word *syntax.Word) string {
134 if word == nil || !word.Pos().IsValid() || !word.End().IsValid() {
135 return ""
136 }
137 start := int(word.Pos().Offset())
138 end := int(word.End().Offset())
139 if start < 0 || end < start || end > len(source) {
140 return ""
141 }
142 return strings.TrimSpace(source[start:end])
143 }
144
144 lines GO