1package daemon
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "fmt"
8 "strconv"
9 "strings"
10 "time"
11 "unicode/utf8"
12
13 "github.com/bnema/vev/internal/ports"
14)
15
16const (
17 barScriptOutputLimit = 1024
18 barScriptStderrLimit = 512
19 barScriptDisplayLimit = 256
20 barScriptTimeout = time.Second
21)
22
23type barScriptContext struct {
24 Anchor string
25 Session string
26 Tab string
27 Pane string
28 PaneCWD string
29 Cols int
30}
31
32func (c barScriptContext) env(base []string) []string {
33 vars := map[string]string{
34 "VEV_ANCHOR": c.Anchor,
35 "VEV_SESSION": c.Session,
36 "VEV_TAB": c.Tab,
37 "VEV_PANE": c.Pane,
38 "VEV_PANE_CWD": c.PaneCWD,
39 "VEV_COLS": strconv.Itoa(c.Cols),
40 }
41 out := make([]string, 0, len(base)+len(vars))
42 for _, entry := range base {
43 key, _, ok := strings.Cut(entry, "=")
44 if ok {
45 if _, replace := vars[key]; replace {
46 continue
47 }
48 }
49 out = append(out, entry)
50 }
51 for _, key := range []string{"VEV_ANCHOR", "VEV_SESSION", "VEV_TAB", "VEV_PANE", "VEV_PANE_CWD", "VEV_COLS"} {
52 out = append(out, key+"="+vars[key])
53 }
54 return out
55}
56
57type barScriptRunner struct {
58 runner ports.ShellCommandRunner
59 timeout time.Duration
60}
61
62func (r barScriptRunner) run(ctx context.Context, command string, env []string, scriptCtx barScriptContext) (string, error) {
63 if strings.TrimSpace(command) == "" {
64 return "", nil
65 }
66 if r.runner == nil {
67 return "", errors.New("bar script command runner is nil")
68 }
69 timeout := r.timeout
70 if timeout <= 0 {
71 timeout = barScriptTimeout
72 }
73 ctx, cancel := context.WithTimeout(ctx, timeout)
74 defer cancel()
75
76 res, err := r.runner.Run(ctx, ports.CommandSpec{
77 Command: command,
78 Env: scriptCtx.env(env),
79 Timeout: timeout,
80 StdoutLimit: barScriptOutputLimit,
81 StderrLimit: barScriptStderrLimit,
82 })
83 if errors.Is(ctx.Err(), context.DeadlineExceeded) {
84 return "", context.DeadlineExceeded
85 }
86 text := sanitizeBarScriptOutput(res.Stdout, barScriptOutputLimit)
87 if err != nil {
88 return text, &barScriptError{
89 exitCode: res.ExitCode,
90 stderr: sanitizeBarScriptOutput(res.Stderr, barScriptStderrLimit),
91 err: err,
92 }
93 }
94 return text, nil
95}
96
97func sanitizeBarScriptOutput(raw []byte, limit int) string {
98 if limit <= 0 || limit > len(raw) {
99 limit = len(raw)
100 }
101 raw = raw[:limit]
102 if i := bytes.IndexAny(raw, "\r\n"); i >= 0 {
103 raw = raw[:i]
104 }
105 text := strings.ToValidUTF8(string(raw), "")
106 text = stripANSIAndControls(text)
107 if len(text) <= barScriptDisplayLimit {
108 return text
109 }
110 return trimUTF8Bytes(text, barScriptDisplayLimit)
111}
112
113func stripANSIAndControls(s string) string {
114 var out strings.Builder
115 for i := 0; i < len(s); {
116 r, size := utf8.DecodeRuneInString(s[i:])
117 switch {
118 case r == '\x1b':
119 i = skipEscapeSequence(s, i+size)
120 continue
121 case r == 0x9b:
122 i = skipCSI(s, i+size)
123 continue
124 case r == 0x90 || r == 0x98 || r == 0x9d || r == 0x9e || r == 0x9f:
125 i = skipStringControl(s, i+size)
126 continue
127 case r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f):
128 i += size
129 continue
130 }
131 out.WriteRune(r)
132 i += size
133 }
134 return out.String()
135}
136
137func skipEscapeSequence(s string, i int) int {
138 if i >= len(s) {
139 return i
140 }
141 switch s[i] {
142 case '[':
143 return skipCSI(s, i+1)
144 case ']', 'P', '^', '_', 'X':
145 return skipStringControl(s, i+1)
146 }
147 for i < len(s) && s[i] >= 0x20 && s[i] <= 0x2f {
148 i++
149 }
150 if i < len(s) && s[i] >= 0x30 && s[i] <= 0x7e {
151 return i + 1
152 }
153 return i
154}
155
156func skipCSI(s string, i int) int {
157 for i < len(s) {
158 c := s[i]
159 i++
160 if c >= 0x40 && c <= 0x7e {
161 break
162 }
163 }
164 return i
165}
166
167func skipStringControl(s string, i int) int {
168 for i < len(s) {
169 if s[i] == '\a' {
170 return i + 1
171 }
172 if s[i] == '\x1b' && i+1 < len(s) && s[i+1] == '\\' {
173 return i + 2
174 }
175 _, size := utf8.DecodeRuneInString(s[i:])
176 i += size
177 }
178 return i
179}
180
181func trimUTF8Bytes(s string, limit int) string {
182 if len(s) <= limit {
183 return s
184 }
185 for limit > 0 && !utf8.ValidString(s[:limit]) {
186 limit--
187 }
188 return s[:limit]
189}
190
191// barScriptError carries the diagnostic detail needed to explain why a bar
192// script failed: the process exit code and whatever it wrote to stderr.
193type barScriptError struct {
194 exitCode int
195 stderr string
196 err error
197}
198
199func (e *barScriptError) Error() string {
200 if e.stderr != "" {
201 return fmt.Sprintf("exit %d: %s", e.exitCode, e.stderr)
202 }
203 return fmt.Sprintf("exit %d", e.exitCode)
204}
205
206func (e *barScriptError) Unwrap() error { return e.err }
207
208// pathFromEnv returns the PATH entry's value, or "" when unset.
209func pathFromEnv(env []string) string {
210 for _, entry := range env {
211 if name, value, ok := environmentEntry(entry); ok && name == "PATH" {
212 return value
213 }
214 }
215 return ""
216}