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