1package bootstrap
2
3import (
4 "bufio"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "net/url"
9 "os"
10 "path/filepath"
11 "regexp"
12 "runtime"
13 "sort"
14 "strings"
15 "time"
16
17 corelogging "github.com/bnema/dumber/internal/logging"
18)
19
20const (
21 crashReportsDirName = "crashes"
22 logTailLineCount = 120
23 maxCrashReportsKept = 20
24 scannerMaxTokenSize = 256 * 1024
25)
26
27type crashReport struct {
28 ReportVersion int `json:"report_version"`
29 GeneratedAt string `json:"generated_at"`
30 SessionID string `json:"session_id"`
31 CrashedPID int `json:"crashed_pid,omitempty"`
32 SessionLogFile string `json:"session_log_file,omitempty"`
33 SessionLogTail []string `json:"session_log_tail_redacted,omitempty"`
34 ReporterProcess crashReporter `json:"reporter_process"`
35 CoreDumpDiagnostics crashCoreDump `json:"core_dump_diagnostics"`
36 IssueTemplate crashIssueTemplate `json:"issue_template"`
37 GeneratedMarkdownRef string `json:"generated_markdown_ref,omitempty"`
38}
39
40type crashReporter struct {
41 GeneratedBy string `json:"generated_by"`
42 GoVersion string `json:"go_version"`
43 GOOS string `json:"goos"`
44 GOARCH string `json:"goarch"`
45 PID int `json:"pid"`
46 PPID int `json:"ppid"`
47}
48
49type crashCoreDump struct {
50 RLimitCoreSoft string `json:"rlimit_core_soft"`
51 RLimitCoreHard string `json:"rlimit_core_hard"`
52 Hint string `json:"hint"`
53}
54
55type crashIssueTemplate struct {
56 Title string `json:"title"`
57 Summary string `json:"summary"`
58}
59
60// writeCrashReport generates JSON + Markdown crash reports for a crashed session.
61// Returns the JSON report path, or empty string if the report already exists.
62func writeCrashReport(logDir, sessionID string, crashedPID int) (string, error) {
63 if logDir == "" || sessionID == "" {
64 return "", errors.New("logDir and sessionID are required")
65 }
66
67 reportsDir := filepath.Join(logDir, crashReportsDirName)
68 if err := os.MkdirAll(reportsDir, dirPerm); err != nil {
69 return "", err
70 }
71
72 pruneOldCrashReports(reportsDir, maxCrashReportsKept)
73
74 jsonPath := filepath.Join(reportsDir, fmt.Sprintf("session_%s.crash.json", sessionID))
75 markdownPath := filepath.Join(reportsDir, fmt.Sprintf("session_%s.crash.md", sessionID))
76
77 // Skip if report already exists.
78 if _, err := os.Stat(jsonPath); err == nil {
79 return jsonPath, nil
80 }
81
82 report := crashReport{
83 ReportVersion: 2,
84 GeneratedAt: time.Now().UTC().Format(time.RFC3339Nano),
85 SessionID: sessionID,
86 CrashedPID: crashedPID,
87 ReporterProcess: crashReporter{
88 GeneratedBy: "dumber",
89 GoVersion: runtime.Version(),
90 GOOS: runtime.GOOS,
91 GOARCH: runtime.GOARCH,
92 PID: os.Getpid(),
93 PPID: os.Getppid(),
94 },
95 CoreDumpDiagnostics: collectCoreDumpDiagnostics(),
96 IssueTemplate: crashIssueTemplate{
97 Title: fmt.Sprintf("Unexpected close in session %s", sessionID),
98 Summary: "Dumber closed unexpectedly and generated this report automatically.",
99 },
100 }
101
102 logPath := filepath.Join(logDir, corelogging.SessionFilename(sessionID))
103 if _, err := os.Stat(logPath); err == nil {
104 report.SessionLogFile = logPath
105 report.SessionLogTail = readRedactedLogTail(logPath, logTailLineCount)
106 }
107
108 report.GeneratedMarkdownRef = markdownPath
109
110 payload, err := json.MarshalIndent(report, "", " ")
111 if err != nil {
112 return "", err
113 }
114 payload = append(payload, '\n')
115 if err := os.WriteFile(jsonPath, payload, filePerm); err != nil {
116 return "", err
117 }
118
119 markdown := buildCrashMarkdown(report)
120 if err := os.WriteFile(markdownPath, []byte(markdown), filePerm); err != nil {
121 return "", err
122 }
123
124 return jsonPath, nil
125}
126
127func buildCrashMarkdown(report crashReport) string {
128 lines := []string{
129 "# Unexpected Close Report",
130 "",
131 fmt.Sprintf("Generated: `%s`", report.GeneratedAt),
132 fmt.Sprintf("Session: `%s`", report.SessionID),
133 fmt.Sprintf("Crashed PID: `%d`", report.CrashedPID),
134 "",
135 "## Reporter",
136 fmt.Sprintf("- pid: `%d`", report.ReporterProcess.PID),
137 fmt.Sprintf("- ppid: `%d`", report.ReporterProcess.PPID),
138 fmt.Sprintf("- go: `%s`", report.ReporterProcess.GoVersion),
139 "",
140 "## Core Dump Diagnostics",
141 fmt.Sprintf("- RLIMIT_CORE soft: `%s`", report.CoreDumpDiagnostics.RLimitCoreSoft),
142 fmt.Sprintf("- RLIMIT_CORE hard: `%s`", report.CoreDumpDiagnostics.RLimitCoreHard),
143 fmt.Sprintf("- hint: %s", report.CoreDumpDiagnostics.Hint),
144 }
145
146 if len(report.SessionLogTail) > 0 {
147 lines = append(lines,
148 "",
149 "## Redacted Log Tail",
150 "```text",
151 )
152 lines = append(lines, report.SessionLogTail...)
153 lines = append(lines, "```")
154 }
155
156 lines = append(lines,
157 "",
158 "## GitHub Issue Template",
159 fmt.Sprintf("Title: %s", report.IssueTemplate.Title),
160 "",
161 "```markdown",
162 "### What happened",
163 "Describe what you were doing just before Dumber closed unexpectedly.",
164 "",
165 "### Crash report",
166 fmt.Sprintf("- session id: `%s`", report.SessionID),
167 fmt.Sprintf("- crashed pid: `%d`", report.CrashedPID),
168 "",
169 "### Additional context",
170 "- distro:",
171 "- compositor/window manager:",
172 "- steps to reproduce:",
173 "```",
174 )
175
176 return strings.Join(lines, "\n") + "\n"
177}
178
179// ---------------------------------------------------------------------------
180// Redaction helpers (kept from previous implementation)
181// ---------------------------------------------------------------------------
182
183func readRedactedLogTail(path string, lines int) []string {
184 if lines <= 0 {
185 return nil
186 }
187
188 file, err := os.Open(path)
189 if err != nil {
190 return nil
191 }
192 defer func() { _ = file.Close() }()
193
194 ring := make([]string, lines)
195 count := 0
196 scanner := bufio.NewScanner(file)
197 scanner.Buffer(make([]byte, 0, scannerMaxTokenSize), scannerMaxTokenSize)
198 for scanner.Scan() {
199 ring[count%lines] = redactSensitiveContent(scanner.Text())
200 count++
201 }
202
203 var result []string
204 if count <= lines {
205 result = make([]string, count)
206 copy(result, ring[:count])
207 } else {
208 result = make([]string, lines)
209 start := count % lines
210 copy(result, ring[start:])
211 copy(result[lines-start:], ring[:start])
212 }
213
214 if scanner.Err() != nil {
215 result = append(result, fmt.Sprintf("[log tail truncated: %v]", scanner.Err()))
216 }
217 return result
218}
219
220var urlRegex = regexp.MustCompile(`(?i)\b(?:https?|wss?)://[^\s\]\)\"'<>]+`)
221
222var secretKeyRegex = regexp.MustCompile(`(?i)(token|access_token|id_token|code|password|passwd|secret|authorization)=([^&\s]+)`)
223
224var secretJSONRegex = regexp.MustCompile(`(?i)"(token|access_token|id_token|password|secret)"\s*:\s*"(.*?)"`)
225
226var secretHeaderRegex = regexp.MustCompile(`(?i)(authorization|auth):\s*(\S[^\n,;]*)`)
227
228func redactSensitiveContent(line string) string {
229 redacted := urlRegex.ReplaceAllStringFunc(line, redactURLString)
230 redacted = secretKeyRegex.ReplaceAllString(redacted, "$1=[REDACTED]")
231 redacted = secretJSONRegex.ReplaceAllString(redacted, `"$1":"[REDACTED]"`)
232 redacted = secretHeaderRegex.ReplaceAllString(redacted, "$1: [REDACTED]")
233 return redacted
234}
235
236func redactURLString(raw string) string {
237 trimmed := strings.TrimSpace(raw)
238 trimmed = strings.TrimRight(trimmed, ".,;:")
239 u, err := url.Parse(trimmed)
240 if err != nil {
241 return trimmed
242 }
243 u.RawQuery = ""
244 u.Fragment = ""
245 return u.String()
246}
247
248func pruneOldCrashReports(reportsDir string, maxKeep int) {
249 matches, err := filepath.Glob(filepath.Join(reportsDir, "session_*.crash.json"))
250 if err != nil || len(matches) <= maxKeep {
251 return
252 }
253
254 type reportEntry struct {
255 path string
256 modTime time.Time
257 }
258 entries := make([]reportEntry, 0, len(matches))
259 for _, p := range matches {
260 info, statErr := os.Stat(p)
261 if statErr != nil {
262 continue
263 }
264 entries = append(entries, reportEntry{path: p, modTime: info.ModTime()})
265 }
266
267 sort.Slice(entries, func(i, j int) bool {
268 return entries[i].modTime.After(entries[j].modTime)
269 })
270
271 for _, e := range entries[maxKeep:] {
272 _ = os.Remove(e.path)
273 mdPath := strings.TrimSuffix(e.path, ".json") + ".md"
274 _ = os.Remove(mdPath)
275 }
276}