返回 DeepSeek-Reasonix
cli.go
1 // Package crashreport owns local, user-reviewed CLI crash reports.
2 package crashreport
3
4 import (
5 "bytes"
6 "context"
7 "crypto/rand"
8 "encoding/hex"
9 "encoding/json"
10 "errors"
11 "fmt"
12 "io"
13 "net/http"
14 "os"
15 "path/filepath"
16 "regexp"
17 "runtime"
18 "sort"
19 "strconv"
20 "strings"
21 "sync"
22 "time"
23 "unicode/utf8"
24
25 "reasonix/internal/fileutil"
26 "reasonix/internal/netclient"
27 )
28
29 const (
30 dirName = "cli-crash-reports"
31 currentSchemaVersion = 2
32 maxReports = 10
33 maxMessageBytes = 16 << 10
34 maxStackBytes = 8 << 10
35 maxFieldBytes = 4 << 10
36 )
37
38 var reportEndpoint = "https://crash.reasonix.io/v1/report"
39
40 var queueMu sync.Mutex
41
42 var (
43 userPathSegment = regexp.MustCompile(`(?i)([A-Z]:\\Users\\|/(?:home|Users)/)[^/\\:\s"']+`)
44 emailPattern = regexp.MustCompile(`\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b`)
45 secretKeyValuePattern = regexp.MustCompile(`(?i)\b(api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|authorization|secret|password|passwd|pwd|token)\b\s*[:=]\s*(?:Bearer\s+)?['"]?[^'"\s,;]+['"]?`)
46 bearerTokenPattern = regexp.MustCompile(`(?i)\bBearer\s+[A-Za-z0-9._~+/=-]{16,}`)
47 explicitKeyPattern = regexp.MustCompile(`\b(?:sk|rk)-(?:proj-)?[A-Za-z0-9_-]{16,}\b`)
48 envIdentifierPattern = regexp.MustCompile(`\b[A-Z][A-Z0-9_]*(?:API[_-]?KEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|SECRET|TOKEN|PASSWORD|PASSWD|PWD)[A-Z0-9_]*\b`)
49 jwtPattern = regexp.MustCompile(`\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b`)
50 longHexPattern = regexp.MustCompile(`\b[0-9a-fA-F]{32,}\b`)
51 longBase64Pattern = regexp.MustCompile(`[A-Za-z0-9+/]{40,}={0,2}`)
52 longBase64URLPattern = regexp.MustCompile(`\b[A-Za-z0-9_-]{48,}\b`)
53 goLocationPattern = regexp.MustCompile(`[^\s]+\.go:\d+`)
54 reportFilenamePattern = regexp.MustCompile(`^[0-9]{20}-[0-9]+-[0-9a-f]{16}\.json$`)
55 )
56
57 // Report is the subset of the shared crash ingest protocol emitted by the CLI.
58 type Report struct {
59 Kind string `json:"kind"`
60 Version string `json:"version"`
61 OS string `json:"os"`
62 Arch string `json:"arch"`
63 Message string `json:"message"`
64 SchemaVersion int `json:"schemaVersion,omitempty"`
65 Source string `json:"source,omitempty"`
66 Label string `json:"label,omitempty"`
67 ErrorType string `json:"errorType,omitempty"`
68 ErrorMessage string `json:"errorMessage,omitempty"`
69 Stack string `json:"stack,omitempty"`
70 TopFrame string `json:"topFrame,omitempty"`
71 OccurredAt string `json:"occurredAt,omitempty"`
72 }
73
74 // Pending is a locally stored report. ID is safe to pass back to Load or Remove.
75 type Pending struct {
76 ID string
77 Report Report
78 }
79
80 var ErrNoReports = errors.New("no pending CLI crash reports")
81
82 // CapturePanic records a sanitized panic report locally. The panic value itself
83 // is deliberately never serialized because it can contain prompts, commands,
84 // paths, or provider response content.
85 func CapturePanic(home, version string, recovered any, stack []byte) error {
86 if strings.TrimSpace(home) == "" {
87 return errors.New("crash report: empty Reasonix home")
88 }
89 cleanStack := sanitizeStack(string(stack))
90 report := Report{
91 Kind: "crash",
92 Version: sanitizeField(defaultString(version, "unknown"), 64),
93 OS: runtime.GOOS,
94 Arch: runtime.GOARCH,
95 Message: "[cli panic]\n\nUnhandled CLI panic.",
96 SchemaVersion: currentSchemaVersion,
97 Source: "cli.go",
98 Label: "panic",
99 ErrorType: sanitizeField(fmt.Sprintf("%T", recovered), 128),
100 ErrorMessage: "Unhandled CLI panic.",
101 Stack: cleanStack,
102 TopFrame: topFrame(cleanStack),
103 OccurredAt: time.Now().UTC().Format(time.RFC3339Nano),
104 }
105 return write(home, report)
106 }
107
108 // List returns valid local reports newest first. Unknown or malformed files are
109 // ignored but retained so an older binary never destroys a newer report format.
110 func List(home string) ([]Pending, error) {
111 dir := filepath.Join(home, dirName)
112 entries, err := os.ReadDir(dir)
113 if errors.Is(err, os.ErrNotExist) {
114 return nil, nil
115 }
116 if err != nil {
117 return nil, err
118 }
119 out := make([]Pending, 0, len(entries))
120 for _, entry := range entries {
121 if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
122 continue
123 }
124 info, err := entry.Info()
125 if err != nil || !info.Mode().IsRegular() {
126 continue
127 }
128 body, err := os.ReadFile(filepath.Join(dir, entry.Name()))
129 if err != nil {
130 continue
131 }
132 var report Report
133 if json.Unmarshal(body, &report) != nil || !valid(report) {
134 continue
135 }
136 out = append(out, Pending{ID: strings.TrimSuffix(entry.Name(), ".json"), Report: sanitizeReport(report)})
137 }
138 sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
139 return out, nil
140 }
141
142 // Load returns one report by ID, or the newest report when ID is empty.
143 func Load(home, id string) (Pending, error) {
144 reports, err := List(home)
145 if err != nil {
146 return Pending{}, err
147 }
148 if len(reports) == 0 {
149 return Pending{}, ErrNoReports
150 }
151 id = strings.TrimSpace(id)
152 if id == "" {
153 return reports[0], nil
154 }
155 for _, report := range reports {
156 if report.ID == id {
157 return report, nil
158 }
159 }
160 return Pending{}, fmt.Errorf("CLI crash report %q not found", id)
161 }
162
163 // Preview returns the sanitized report in readable JSON form.
164 func Preview(report Report) ([]byte, error) {
165 var out bytes.Buffer
166 encoder := json.NewEncoder(&out)
167 encoder.SetIndent("", " ")
168 encoder.SetEscapeHTML(false)
169 if err := encoder.Encode(sanitizeReport(report)); err != nil {
170 return nil, err
171 }
172 return bytes.TrimSuffix(out.Bytes(), []byte("\n")), nil
173 }
174
175 // Send uploads a single user-reviewed report. It does not remove local state;
176 // callers remove the report only after a successful response.
177 func Send(ctx context.Context, report Report, proxy netclient.ProxySpec) error {
178 client, err := netclient.NewHTTPClient(proxy, netclient.TransportOptions{
179 DialTimeout: 3 * time.Second,
180 TLSHandshakeTimeout: 3 * time.Second,
181 ResponseHeaderTimeout: 5 * time.Second,
182 })
183 if err != nil {
184 return err
185 }
186 client.Timeout = 10 * time.Second
187 return sendWithClient(ctx, client, reportEndpoint, report)
188 }
189
190 func sendWithClient(ctx context.Context, client *http.Client, endpoint string, report Report) error {
191 body, err := json.Marshal(sanitizeReport(report))
192 if err != nil {
193 return err
194 }
195 req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
196 if err != nil {
197 return err
198 }
199 req.Header.Set("Content-Type", "application/json")
200 resp, err := client.Do(req)
201 if err != nil {
202 return err
203 }
204 defer resp.Body.Close()
205 _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1024))
206 if resp.StatusCode < 200 || resp.StatusCode >= 300 {
207 return fmt.Errorf("crash endpoint returned %s", resp.Status)
208 }
209 return nil
210 }
211
212 // Remove deletes one report previously returned by List or Load.
213 func Remove(home, id string) error {
214 report, err := Load(home, id)
215 if err != nil {
216 return err
217 }
218 return os.Remove(filepath.Join(home, dirName, report.ID+".json"))
219 }
220
221 func write(home string, report Report) error {
222 dir := filepath.Join(home, dirName)
223 if err := os.MkdirAll(dir, 0o700); err != nil {
224 return err
225 }
226 body, err := json.Marshal(sanitizeReport(report))
227 if err != nil {
228 return err
229 }
230 nonce := make([]byte, 8)
231 if _, err := rand.Read(nonce); err != nil {
232 return err
233 }
234 id := fmt.Sprintf("%020d-%d-%s", time.Now().UTC().UnixNano(), os.Getpid(), hex.EncodeToString(nonce))
235 queueMu.Lock()
236 defer queueMu.Unlock()
237 if err := fileutil.AtomicWriteFile(filepath.Join(dir, id+".json"), body, 0o600); err != nil {
238 return err
239 }
240 prune(dir)
241 return nil
242 }
243
244 func prune(dir string) {
245 entries, err := os.ReadDir(dir)
246 if err != nil {
247 return
248 }
249 paths := make([]string, 0, len(entries))
250 for _, entry := range entries {
251 if entry.IsDir() || !reportFilenamePattern.MatchString(entry.Name()) {
252 continue
253 }
254 path := filepath.Join(dir, entry.Name())
255 info, err := entry.Info()
256 if err != nil || !info.Mode().IsRegular() {
257 continue
258 }
259 body, err := os.ReadFile(path)
260 if err != nil {
261 continue
262 }
263 var report Report
264 if json.Unmarshal(body, &report) != nil || !valid(report) {
265 continue
266 }
267 paths = append(paths, path)
268 }
269 sort.Strings(paths)
270 for len(paths) > maxReports {
271 _ = os.Remove(paths[0])
272 paths = paths[1:]
273 }
274 }
275
276 func valid(report Report) bool {
277 return report.SchemaVersion >= 0 && report.SchemaVersion <= currentSchemaVersion &&
278 report.Kind == "crash" && strings.TrimSpace(report.Version) != "" &&
279 strings.TrimSpace(report.OS) != "" && strings.TrimSpace(report.Arch) != "" &&
280 strings.TrimSpace(report.Message) != ""
281 }
282
283 func sanitizeReport(report Report) Report {
284 report.Kind = "crash"
285 report.Version = sanitizeField(defaultString(report.Version, "unknown"), 64)
286 report.OS = sanitizeField(report.OS, 32)
287 report.Arch = sanitizeField(report.Arch, 32)
288 report.Message = sanitizeText(report.Message, maxMessageBytes)
289 report.SchemaVersion = currentSchemaVersion
290 report.Source = "cli.go"
291 report.Label = "panic"
292 report.ErrorType = sanitizeField(report.ErrorType, 128)
293 report.ErrorMessage = sanitizeText(report.ErrorMessage, maxFieldBytes)
294 report.Stack = sanitizeStack(report.Stack)
295 report.TopFrame = topFrame(report.Stack)
296 report.OccurredAt = sanitizeField(report.OccurredAt, 64)
297 return report
298 }
299
300 func sanitizeStack(stack string) string {
301 stack = sanitizeText(stack, maxStackBytes*2)
302 lines := strings.Split(stack, "\n")
303 for i, line := range lines {
304 trimmed := strings.TrimSpace(line)
305 if trimmed == "" {
306 continue
307 }
308 if location := goLocationPattern.FindString(trimmed); location != "" {
309 normalized := strings.ReplaceAll(location, `\`, "/")
310 fileAndLine := normalized[strings.LastIndex(normalized, "/")+1:]
311 lines[i] = "\t<path>/" + fileAndLine
312 continue
313 }
314 if open := strings.Index(trimmed, "("); open > 0 {
315 lines[i] = strings.TrimSpace(trimmed[:open]) + "(...)"
316 }
317 }
318 return clip(strings.TrimSpace(strings.Join(lines, "\n")), maxStackBytes)
319 }
320
321 func topFrame(stack string) string {
322 fallback := ""
323 functionName := ""
324 for _, line := range strings.Split(stack, "\n") {
325 line = strings.TrimSpace(line)
326 if strings.HasPrefix(line, "<path>/") && strings.Contains(line, ".go:") {
327 frame := line
328 if functionName != "" {
329 frame = functionName + " " + line
330 }
331 if fallback == "" {
332 fallback = frame
333 }
334 if !isCrashCaptureFrame(functionName) {
335 return clip(frame, 300)
336 }
337 functionName = ""
338 continue
339 }
340 if strings.HasSuffix(line, "(...)") {
341 functionName = strings.TrimSpace(strings.TrimSuffix(line, "(...)"))
342 }
343 }
344 return clip(fallback, 300)
345 }
346
347 func isCrashCaptureFrame(functionName string) bool {
348 return functionName == "runtime/debug.Stack" || functionName == "panic" ||
349 strings.HasPrefix(functionName, "runtime.") ||
350 strings.Contains(functionName, ".runWithCrashCapture.func")
351 }
352
353 func sanitizeField(value string, max int) string {
354 return sanitizeText(value, max)
355 }
356
357 func sanitizeText(value string, max int) string {
358 value = userPathSegment.ReplaceAllString(value, "${1}_")
359 value = emailPattern.ReplaceAllString(value, "[redacted-email]")
360 value = bearerTokenPattern.ReplaceAllString(value, "Bearer [redacted]")
361 value = secretKeyValuePattern.ReplaceAllString(value, "${1}=[redacted]")
362 value = envIdentifierPattern.ReplaceAllString(value, "[redacted-env]")
363 value = jwtPattern.ReplaceAllString(value, "[redacted-jwt]")
364 value = explicitKeyPattern.ReplaceAllString(value, "[redacted-key]")
365 value = longHexPattern.ReplaceAllString(value, "[redacted-hex]")
366 value = longBase64Pattern.ReplaceAllString(value, "[redacted-token]")
367 value = longBase64URLPattern.ReplaceAllString(value, "[redacted-token]")
368 return clip(strings.TrimSpace(value), max)
369 }
370
371 func clip(value string, max int) string {
372 if len(value) <= max {
373 return value
374 }
375 value = value[:max]
376 for !utf8.ValidString(value) {
377 value = value[:len(value)-1]
378 }
379 return value
380 }
381
382 func defaultString(value, fallback string) string {
383 if strings.TrimSpace(value) == "" {
384 return fallback
385 }
386 return value
387 }
388
389 func parseIDTime(id string) time.Time {
390 part, _, _ := strings.Cut(id, "-")
391 ns, _ := strconv.ParseInt(part, 10, 64)
392 return time.Unix(0, ns).UTC()
393 }
394
395 // CapturedAt returns the local report timestamp encoded in its ID.
396 func (p Pending) CapturedAt() time.Time { return parseIDTime(p.ID) }
397
397 lines GO