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