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 "strconv"
15 "strings"
16 "time"
17
18 corelogging "github.com/bnema/dumber/internal/logging"
19)
20
21const (
22 crashReportsDirName = "crashes"
23 logTailLineCount = 120
24 maxCrashReportsKept = 20
25 scannerMaxTokenSize = 256 * 1024
26)
27
28type unexpectedCloseReport struct {
29 ReportVersion int `json:"report_version"`
30 GeneratedAt string `json:"generated_at"`
31 SessionID string `json:"session_id"`
32 Classification SessionExitClassification `json:"classification"`
33 StartupPID int `json:"startup_pid,omitempty"`
34 StartupPPID int `json:"startup_ppid,omitempty"`
35 MarkerFiles map[string]string `json:"marker_files"`
36 SessionLogFile string `json:"session_log_file,omitempty"`
37 SessionLogTail []string `json:"session_log_tail_redacted,omitempty"`
38 ReporterProcess unexpectedCloseReporter `json:"reporter_process"`
39 CoreDumpDiagnostics unexpectedCloseCoreDump `json:"core_dump_diagnostics"`
40 IssueTemplate unexpectedCloseIssueTemplate `json:"issue_template"`
41 GeneratedMarkdownRef string `json:"generated_markdown_ref,omitempty"`
42}
43
44type unexpectedCloseReporter struct {
45 GeneratedBy string `json:"generated_by"`
46 GoVersion string `json:"go_version"`
47 GOOS string `json:"goos"`
48 GOARCH string `json:"goarch"`
49 PID int `json:"pid"`
50 PPID int `json:"ppid"`
51}
52
53type unexpectedCloseCoreDump struct {
54 RLimitCoreSoft string `json:"rlimit_core_soft"`
55 RLimitCoreHard string `json:"rlimit_core_hard"`
56 Hint string `json:"hint"`
57}
58
59type unexpectedCloseIssueTemplate struct {
60 Title string `json:"title"`
61 Summary string `json:"summary"`
62}
63
64func writeUnexpectedCloseReport(lockDir, sessionID string) (string, error) {
65 if lockDir == "" || sessionID == "" {
66 return "", errors.New("lockDir and sessionID are required")
67 }
68
69 classification, classifyErr := ClassifySessionExitFromMarkers(lockDir, sessionID)
70 if classifyErr != nil {
71 return "", classifyErr
72 }
73
74 if classification.Class == SessionExitCleanExit {
75 return "", nil
76 }
77
78 reportsDir := filepath.Join(lockDir, crashReportsDirName)
79 if err := os.MkdirAll(reportsDir, lockDirPerm); err != nil {
80 return "", err
81 }
82
83 pruneOldCrashReports(reportsDir, maxCrashReportsKept)
84
85 jsonPath := filepath.Join(reportsDir, fmt.Sprintf("session_%s.crash.json", sessionID))
86 markdownPath := filepath.Join(reportsDir, fmt.Sprintf("session_%s.crash.md", sessionID))
87 if _, statErr := os.Stat(jsonPath); statErr == nil {
88 if _, mdErr := os.Stat(markdownPath); mdErr == nil {
89 return jsonPath, nil
90 }
91 raw, readErr := os.ReadFile(jsonPath)
92 if readErr != nil {
93 return "", readErr
94 }
95 var existing unexpectedCloseReport
96 if err := json.Unmarshal(raw, &existing); err != nil {
97 return "", err
98 }
99 markdown := buildUnexpectedCloseMarkdown(existing)
100 if err := os.WriteFile(markdownPath, []byte(markdown), markerFilePerm); err != nil {
101 return "", err
102 }
103 return jsonPath, nil
104 } else if !os.IsNotExist(statErr) {
105 return "", statErr
106 }
107
108 report := unexpectedCloseReport{
109 ReportVersion: 1,
110 GeneratedAt: time.Now().UTC().Format(time.RFC3339Nano),
111 SessionID: sessionID,
112 Classification: classification,
113 MarkerFiles: reportMarkerFiles(lockDir, sessionID),
114 ReporterProcess: currentReporter(),
115 CoreDumpDiagnostics: collectCoreDumpDiagnostics(),
116 IssueTemplate: unexpectedCloseIssueTemplate{
117 Title: fmt.Sprintf("Unexpected close in session %s", sessionID),
118 Summary: "Dumber closed unexpectedly and generated this report automatically.",
119 },
120 }
121
122 startupPID, startupPPID := readStartupProcessIDs(lockDir, sessionID)
123 report.StartupPID = startupPID
124 report.StartupPPID = startupPPID
125
126 logPath := filepath.Join(lockDir, corelogging.SessionFilename(sessionID))
127 if _, statErr := os.Stat(logPath); statErr == nil {
128 report.SessionLogFile = logPath
129 report.SessionLogTail = readRedactedLogTail(logPath, logTailLineCount)
130 }
131
132 report.GeneratedMarkdownRef = markdownPath
133
134 payload, err := json.MarshalIndent(report, "", " ")
135 if err != nil {
136 return "", err
137 }
138 payload = append(payload, '\n')
139 if err := os.WriteFile(jsonPath, payload, markerFilePerm); err != nil {
140 return "", err
141 }
142
143 markdown := buildUnexpectedCloseMarkdown(report)
144 if err := os.WriteFile(markdownPath, []byte(markdown), markerFilePerm); err != nil {
145 return "", err
146 }
147
148 return jsonPath, nil
149}
150
151func reportMarkerFiles(lockDir, sessionID string) map[string]string {
152 return map[string]string{
153 "startup": startupMarkerPath(lockDir, sessionID),
154 "shutdown": shutdownMarkerPath(lockDir, sessionID),
155 "abrupt": abruptMarkerPath(lockDir, sessionID),
156 }
157}
158
159func currentReporter() unexpectedCloseReporter {
160 return unexpectedCloseReporter{
161 GeneratedBy: "dumber",
162 GoVersion: runtime.Version(),
163 GOOS: runtime.GOOS,
164 GOARCH: runtime.GOARCH,
165 PID: os.Getpid(),
166 PPID: os.Getppid(),
167 }
168}
169
170func readStartupProcessIDs(lockDir, sessionID string) (int, int) {
171 raw, err := os.ReadFile(startupMarkerPath(lockDir, sessionID))
172 if err != nil {
173 raw, err = os.ReadFile(abruptMarkerPath(lockDir, sessionID))
174 if err != nil {
175 return 0, 0
176 }
177 }
178
179 pid, _ := strconv.Atoi(markerValue(raw, "pid="))
180 ppid, _ := strconv.Atoi(markerValue(raw, "ppid="))
181 return pid, ppid
182}
183
184func readRedactedLogTail(path string, lines int) []string {
185 if lines <= 0 {
186 return nil
187 }
188
189 file, err := os.Open(path)
190 if err != nil {
191 return nil
192 }
193 defer func() { _ = file.Close() }()
194
195 ring := make([]string, lines)
196 count := 0
197 scanner := bufio.NewScanner(file)
198 scanner.Buffer(make([]byte, 0, scannerMaxTokenSize), scannerMaxTokenSize)
199 for scanner.Scan() {
200 ring[count%lines] = redactSensitiveContent(scanner.Text())
201 count++
202 }
203
204 var result []string
205 if count <= lines {
206 result = make([]string, count)
207 copy(result, ring[:count])
208 } else {
209 result = make([]string, lines)
210 start := count % lines
211 copy(result, ring[start:])
212 copy(result[lines-start:], ring[:start])
213 }
214
215 if scanner.Err() != nil {
216 result = append(result, fmt.Sprintf("[log tail truncated: %v]", scanner.Err()))
217 }
218 return result
219}
220
221func buildUnexpectedCloseMarkdown(report unexpectedCloseReport) string {
222 lines := []string{
223 "# Unexpected Close Report",
224 "",
225 fmt.Sprintf("Generated: `%s`", report.GeneratedAt),
226 fmt.Sprintf("Session: `%s`", report.SessionID),
227 fmt.Sprintf("Class: `%s`", report.Classification.Class),
228 fmt.Sprintf("Inference: `%s`", report.Classification.Inference),
229 fmt.Sprintf("Reason: `%s`", report.Classification.Reason),
230 "",
231 "## Process Context",
232 fmt.Sprintf("- startup pid: `%d`", report.StartupPID),
233 fmt.Sprintf("- startup ppid: `%d`", report.StartupPPID),
234 fmt.Sprintf("- reporter pid: `%d`", report.ReporterProcess.PID),
235 fmt.Sprintf("- reporter ppid: `%d`", report.ReporterProcess.PPID),
236 "",
237 "## Core Dump Diagnostics",
238 fmt.Sprintf("- RLIMIT_CORE soft: `%s`", report.CoreDumpDiagnostics.RLimitCoreSoft),
239 fmt.Sprintf("- RLIMIT_CORE hard: `%s`", report.CoreDumpDiagnostics.RLimitCoreHard),
240 fmt.Sprintf("- hint: %s", report.CoreDumpDiagnostics.Hint),
241 "",
242 "## Marker Files",
243 fmt.Sprintf("- startup: `%s`", report.MarkerFiles["startup"]),
244 fmt.Sprintf("- shutdown: `%s`", report.MarkerFiles["shutdown"]),
245 fmt.Sprintf("- abrupt: `%s`", report.MarkerFiles["abrupt"]),
246 }
247
248 if len(report.SessionLogTail) > 0 {
249 lines = append(lines,
250 "",
251 "## Redacted Log Tail",
252 "```text",
253 )
254 lines = append(lines, report.SessionLogTail...)
255 lines = append(lines, "```")
256 }
257
258 lines = append(lines,
259 "",
260 "## GitHub Issue Template",
261 fmt.Sprintf("Title: %s", report.IssueTemplate.Title),
262 "",
263 "```markdown",
264 "### What happened",
265 "Describe what you were doing just before Dumber closed unexpectedly.",
266 "",
267 "### Crash report",
268 fmt.Sprintf("- session id: `%s`", report.SessionID),
269 fmt.Sprintf("- class: `%s`", report.Classification.Class),
270 fmt.Sprintf("- reason: `%s`", report.Classification.Reason),
271 "",
272 "### Additional context",
273 "- distro:",
274 "- compositor/window manager:",
275 "- steps to reproduce:",
276 "```",
277 )
278
279 return strings.Join(lines, "\n") + "\n"
280}
281
282var urlRegex = regexp.MustCompile(`(?i)\b(?:https?|wss?)://[^\s\]\)\"'<>]+`)
283
284var secretKeyRegex = regexp.MustCompile(`(?i)(token|access_token|id_token|code|password|passwd|secret|authorization)=([^&\s]+)`)
285
286var secretJSONRegex = regexp.MustCompile(`(?i)"(token|access_token|id_token|password|secret)"\s*:\s*"(.*?)"`)
287
288var secretHeaderRegex = regexp.MustCompile(`(?i)(authorization|auth):\s*(\S[^\n,;]*)`)
289
290func redactSensitiveContent(line string) string {
291 redacted := urlRegex.ReplaceAllStringFunc(line, redactURLString)
292 redacted = secretKeyRegex.ReplaceAllString(redacted, "$1=[REDACTED]")
293 redacted = secretJSONRegex.ReplaceAllString(redacted, `"$1":"[REDACTED]"`)
294 redacted = secretHeaderRegex.ReplaceAllString(redacted, "$1: [REDACTED]")
295 return redacted
296}
297
298func redactURLString(raw string) string {
299 trimmed := strings.TrimSpace(raw)
300 trimmed = strings.TrimRight(trimmed, ".,;:")
301 u, err := url.Parse(trimmed)
302 if err != nil {
303 return trimmed
304 }
305 u.RawQuery = ""
306 u.Fragment = ""
307 return u.String()
308}
309
310func pruneOldCrashReports(reportsDir string, maxKeep int) {
311 matches, err := filepath.Glob(filepath.Join(reportsDir, "session_*.crash.json"))
312 if err != nil || len(matches) <= maxKeep {
313 return
314 }
315
316 type reportEntry struct {
317 path string
318 modTime time.Time
319 }
320 entries := make([]reportEntry, 0, len(matches))
321 for _, p := range matches {
322 info, statErr := os.Stat(p)
323 if statErr != nil {
324 continue
325 }
326 entries = append(entries, reportEntry{path: p, modTime: info.ModTime()})
327 }
328
329 sort.Slice(entries, func(i, j int) bool {
330 return entries[i].modTime.After(entries[j].modTime)
331 })
332
333 for _, e := range entries[maxKeep:] {
334 _ = os.Remove(e.path)
335 mdPath := strings.TrimSuffix(e.path, ".json") + ".md"
336 _ = os.Remove(mdPath)
337 }
338}