detached 8b3e747

fix(crash): platform-split unix code, harden test assertions, add key-value redaction test

Move collectCoreDumpDiagnostics/formatRlimitCore and
enableCrashForensics/logCoreDumpLimits/formatRlimit into
build-constrained _unix.go files with stubs for non-unix platforms.
Guard type assertions in TestWriteUnexpectedCloseReport to produce
clear failures instead of panics. Add key_value_params subtest for
secretKeyRegex coverage.
commit
8b3e7477c16bfd730d181aa751fc4f88d9ad519a
author
brice · · 2026-02-08 09:57 UTC
parent
01565b7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +119 -66
M cmd/dumber/main.go +0 -38
@@ -7,8 +7,6 @@ import (
77 "os"
88 "os/signal"
99 "runtime"
10- "runtime/debug"
11- "strconv"
1210 "syscall"
1311
1412 "github.com/bnema/dumber/internal/application/port"
@@ -33,7 +31,6 @@ import (
3331 "github.com/bnema/dumber/internal/ui/component"
3432 "github.com/bnema/dumber/internal/ui/theme"
3533 "github.com/rs/zerolog"
36- "golang.org/x/sys/unix"
3734 )
3835
3936 // Build-time variables (set via ldflags).
@@ -394,41 +391,6 @@ func setupSignalHandler(ctx context.Context, app *ui.App) {
394391 }()
395392 }
396393
397-func enableCrashForensics() {
398- debug.SetTraceback("crash")
399-
400- var limit unix.Rlimit
401- if err := unix.Getrlimit(unix.RLIMIT_CORE, &limit); err != nil {
402- return
403- }
404- if limit.Cur >= limit.Max {
405- return
406- }
407- limit.Cur = limit.Max
408- _ = unix.Setrlimit(unix.RLIMIT_CORE, &limit)
409-}
410-
411-func logCoreDumpLimits(ctx context.Context) {
412- log := logging.FromContext(ctx)
413- var limit unix.Rlimit
414- if err := unix.Getrlimit(unix.RLIMIT_CORE, &limit); err != nil {
415- log.Debug().Err(err).Msg("failed to read RLIMIT_CORE")
416- return
417- }
418-
419- log.Debug().
420- Str("soft", formatRlimit(limit.Cur)).
421- Str("hard", formatRlimit(limit.Max)).
422- Msg("core dump limits")
423-}
424-
425-func formatRlimit(value uint64) string {
426- if value == unix.RLIM_INFINITY {
427- return "infinity"
428- }
429- return strconv.FormatUint(value, 10)
430-}
431-
432394 // repositories groups infrastructure layer repository implementations.
433395 type repositories struct {
434396 history repository.HistoryRepository
@@ -7,8 +7,6 @@ import (
7 "os"7 "os"
8 "os/signal"8 "os/signal"
9 "runtime"9 "runtime"
10- "runtime/debug"
11- "strconv"
12 "syscall"10 "syscall"
13 11
14 "github.com/bnema/dumber/internal/application/port"12 "github.com/bnema/dumber/internal/application/port"
@@ -33,7 +31,6 @@ import (
33 "github.com/bnema/dumber/internal/ui/component"31 "github.com/bnema/dumber/internal/ui/component"
34 "github.com/bnema/dumber/internal/ui/theme"32 "github.com/bnema/dumber/internal/ui/theme"
35 "github.com/rs/zerolog"33 "github.com/rs/zerolog"
36- "golang.org/x/sys/unix"
37 )34 )
38 35
39 // Build-time variables (set via ldflags).36 // Build-time variables (set via ldflags).
@@ -394,41 +391,6 @@ func setupSignalHandler(ctx context.Context, app *ui.App) {
394 }()391 }()
395 }392 }
396 393
397-func enableCrashForensics() {
398- debug.SetTraceback("crash")
399-
400- var limit unix.Rlimit
401- if err := unix.Getrlimit(unix.RLIMIT_CORE, &limit); err != nil {
402- return
403- }
404- if limit.Cur >= limit.Max {
405- return
406- }
407- limit.Cur = limit.Max
408- _ = unix.Setrlimit(unix.RLIMIT_CORE, &limit)
409-}
410-
411-func logCoreDumpLimits(ctx context.Context) {
412- log := logging.FromContext(ctx)
413- var limit unix.Rlimit
414- if err := unix.Getrlimit(unix.RLIMIT_CORE, &limit); err != nil {
415- log.Debug().Err(err).Msg("failed to read RLIMIT_CORE")
416- return
417- }
418-
419- log.Debug().
420- Str("soft", formatRlimit(limit.Cur)).
421- Str("hard", formatRlimit(limit.Max)).
422- Msg("core dump limits")
423-}
424-
425-func formatRlimit(value uint64) string {
426- if value == unix.RLIM_INFINITY {
427- return "infinity"
428- }
429- return strconv.FormatUint(value, 10)
430-}
431-
432 // repositories groups infrastructure layer repository implementations.394 // repositories groups infrastructure layer repository implementations.
433 type repositories struct {395 type repositories struct {
434 history repository.HistoryRepository396 history repository.HistoryRepository
A cmd/dumber/main_other.go +9 -0
@@ -0,0 +1,9 @@
1+//go:build !linux && !darwin
2+
3+package main
4+
5+import "context"
6+
7+func enableCrashForensics() {}
8+
9+func logCoreDumpLimits(_ context.Context) {}
@@ -0,0 +1,9 @@
1+//go:build !linux && !darwin
2+
3+package main
4+
5+import "context"
6+
7+func enableCrashForensics() {}
8+
9+func logCoreDumpLimits(_ context.Context) {}
A cmd/dumber/main_unix.go +47 -0
@@ -0,0 +1,47 @@
1+//go:build linux || darwin
2+
3+package main
4+
5+import (
6+ "context"
7+ "runtime/debug"
8+ "strconv"
9+
10+ "github.com/bnema/dumber/internal/logging"
11+ "golang.org/x/sys/unix"
12+)
13+
14+func enableCrashForensics() {
15+ debug.SetTraceback("crash")
16+
17+ var limit unix.Rlimit
18+ if err := unix.Getrlimit(unix.RLIMIT_CORE, &limit); err != nil {
19+ return
20+ }
21+ if limit.Cur >= limit.Max {
22+ return
23+ }
24+ limit.Cur = limit.Max
25+ _ = unix.Setrlimit(unix.RLIMIT_CORE, &limit)
26+}
27+
28+func logCoreDumpLimits(ctx context.Context) {
29+ log := logging.FromContext(ctx)
30+ var limit unix.Rlimit
31+ if err := unix.Getrlimit(unix.RLIMIT_CORE, &limit); err != nil {
32+ log.Debug().Err(err).Msg("failed to read RLIMIT_CORE")
33+ return
34+ }
35+
36+ log.Debug().
37+ Str("soft", formatRlimit(limit.Cur)).
38+ Str("hard", formatRlimit(limit.Max)).
39+ Msg("core dump limits")
40+}
41+
42+func formatRlimit(value uint64) string {
43+ if value == unix.RLIM_INFINITY {
44+ return "infinity"
45+ }
46+ return strconv.FormatUint(value, 10)
47+}
@@ -0,0 +1,47 @@
1+//go:build linux || darwin
2+
3+package main
4+
5+import (
6+ "context"
7+ "runtime/debug"
8+ "strconv"
9+
10+ "github.com/bnema/dumber/internal/logging"
11+ "golang.org/x/sys/unix"
12+)
13+
14+func enableCrashForensics() {
15+ debug.SetTraceback("crash")
16+
17+ var limit unix.Rlimit
18+ if err := unix.Getrlimit(unix.RLIMIT_CORE, &limit); err != nil {
19+ return
20+ }
21+ if limit.Cur >= limit.Max {
22+ return
23+ }
24+ limit.Cur = limit.Max
25+ _ = unix.Setrlimit(unix.RLIMIT_CORE, &limit)
26+}
27+
28+func logCoreDumpLimits(ctx context.Context) {
29+ log := logging.FromContext(ctx)
30+ var limit unix.Rlimit
31+ if err := unix.Getrlimit(unix.RLIMIT_CORE, &limit); err != nil {
32+ log.Debug().Err(err).Msg("failed to read RLIMIT_CORE")
33+ return
34+ }
35+
36+ log.Debug().
37+ Str("soft", formatRlimit(limit.Cur)).
38+ Str("hard", formatRlimit(limit.Max)).
39+ Msg("core dump limits")
40+}
41+
42+func formatRlimit(value uint64) string {
43+ if value == unix.RLIM_INFINITY {
44+ return "infinity"
45+ }
46+ return strconv.FormatUint(value, 10)
47+}
M internal/bootstrap/crash_report.go +0 -23
@@ -16,7 +16,6 @@ import (
1616 "time"
1717
1818 corelogging "github.com/bnema/dumber/internal/logging"
19- "golang.org/x/sys/unix"
2019 )
2120
2221 const (
@@ -168,28 +167,6 @@ func currentReporter() unexpectedCloseReporter {
168167 }
169168 }
170169
171-func collectCoreDumpDiagnostics() unexpectedCloseCoreDump {
172- d := unexpectedCloseCoreDump{
173- Hint: "Run `coredumpctl list | rg -i \"dumber\"` and include matching entries in the issue.",
174- }
175- var limit unix.Rlimit
176- if err := unix.Getrlimit(unix.RLIMIT_CORE, &limit); err != nil {
177- d.RLimitCoreSoft = "unknown"
178- d.RLimitCoreHard = "unknown"
179- return d
180- }
181- d.RLimitCoreSoft = formatRlimitCore(limit.Cur)
182- d.RLimitCoreHard = formatRlimitCore(limit.Max)
183- return d
184-}
185-
186-func formatRlimitCore(value uint64) string {
187- if value == unix.RLIM_INFINITY {
188- return "infinity"
189- }
190- return strconv.FormatUint(value, 10)
191-}
192-
193170 func readStartupProcessIDs(lockDir, sessionID string) (int, int) {
194171 raw, err := os.ReadFile(startupMarkerPath(lockDir, sessionID))
195172 if err != nil {
@@ -16,7 +16,6 @@ import (
16 "time"16 "time"
17 17
18 corelogging "github.com/bnema/dumber/internal/logging"18 corelogging "github.com/bnema/dumber/internal/logging"
19- "golang.org/x/sys/unix"
20 )19 )
21 20
22 const (21 const (
@@ -168,28 +167,6 @@ func currentReporter() unexpectedCloseReporter {
168 }167 }
169 }168 }
170 169
171-func collectCoreDumpDiagnostics() unexpectedCloseCoreDump {
172- d := unexpectedCloseCoreDump{
173- Hint: "Run `coredumpctl list | rg -i \"dumber\"` and include matching entries in the issue.",
174- }
175- var limit unix.Rlimit
176- if err := unix.Getrlimit(unix.RLIMIT_CORE, &limit); err != nil {
177- d.RLimitCoreSoft = "unknown"
178- d.RLimitCoreHard = "unknown"
179- return d
180- }
181- d.RLimitCoreSoft = formatRlimitCore(limit.Cur)
182- d.RLimitCoreHard = formatRlimitCore(limit.Max)
183- return d
184-}
185-
186-func formatRlimitCore(value uint64) string {
187- if value == unix.RLIM_INFINITY {
188- return "infinity"
189- }
190- return strconv.FormatUint(value, 10)
191-}
192-
193 func readStartupProcessIDs(lockDir, sessionID string) (int, int) {170 func readStartupProcessIDs(lockDir, sessionID string) (int, int) {
194 raw, err := os.ReadFile(startupMarkerPath(lockDir, sessionID))171 raw, err := os.ReadFile(startupMarkerPath(lockDir, sessionID))
195 if err != nil {172 if err != nil {
A internal/bootstrap/crash_report_other.go +11 -0
@@ -0,0 +1,11 @@
1+//go:build !linux && !darwin
2+
3+package bootstrap
4+
5+func collectCoreDumpDiagnostics() unexpectedCloseCoreDump {
6+ return unexpectedCloseCoreDump{
7+ RLimitCoreSoft: "unsupported",
8+ RLimitCoreHard: "unsupported",
9+ Hint: "Core dump diagnostics are only available on Linux and macOS.",
10+ }
11+}
@@ -0,0 +1,11 @@
1+//go:build !linux && !darwin
2+
3+package bootstrap
4+
5+func collectCoreDumpDiagnostics() unexpectedCloseCoreDump {
6+ return unexpectedCloseCoreDump{
7+ RLimitCoreSoft: "unsupported",
8+ RLimitCoreHard: "unsupported",
9+ Hint: "Core dump diagnostics are only available on Linux and macOS.",
10+ }
11+}
M internal/bootstrap/crash_report_test.go +21 -5
@@ -35,6 +35,15 @@ func TestRedactSensitiveContent(t *testing.T) {
3535 assert.Contains(t, redacted, `"password":"[REDACTED]"`)
3636 })
3737
38+ t.Run("key_value_params", func(t *testing.T) {
39+ line := "callback received password=secret123 token=abc456"
40+ redacted := redactSensitiveContent(line)
41+ assert.NotContains(t, redacted, "secret123")
42+ assert.NotContains(t, redacted, "abc456")
43+ assert.Contains(t, redacted, "password=[REDACTED]")
44+ assert.Contains(t, redacted, "token=[REDACTED]")
45+ })
46+
3847 t.Run("authorization_headers", func(t *testing.T) {
3948 line := "Authorization: Bearer eyJhbGciOiJSUz"
4049 redacted := redactSensitiveContent(line)
@@ -105,13 +114,20 @@ func TestWriteUnexpectedCloseReport(t *testing.T) {
105114 require.NoError(t, json.Unmarshal(raw, &payload))
106115 assert.Equal(t, sessionID, payload["session_id"])
107116
108- classification := payload["classification"].(map[string]any)
109- classValue, _ := classification["class"].(string)
117+ classificationRaw, ok := payload["classification"].(map[string]any)
118+ require.True(t, ok, "classification should be a map[string]any")
119+ classValue, ok := classificationRaw["class"].(string)
120+ require.True(t, ok, "classification.class should be a string")
110121 assert.Equal(t, string(SessionExitMainProcessCrashOrAbrupt), classValue)
111122
112- tail := payload["session_log_tail_redacted"].([]any)
113- require.NotEmpty(t, tail)
114- text := tail[0].(string) + tail[len(tail)-1].(string)
123+ tail, ok := payload["session_log_tail_redacted"].([]any)
124+ require.True(t, ok, "session_log_tail_redacted should be a []any")
125+ require.GreaterOrEqual(t, len(tail), 2, "expected at least 2 log tail entries")
126+ first, ok := tail[0].(string)
127+ require.True(t, ok, "first tail entry should be a string")
128+ last, ok := tail[len(tail)-1].(string)
129+ require.True(t, ok, "last tail entry should be a string")
130+ text := first + last
115131 assert.NotContains(t, text, "?a=1&b=2")
116132 assert.NotContains(t, text, "code=abc")
117133 assert.NotContains(t, text, "token=def")
@@ -35,6 +35,15 @@ func TestRedactSensitiveContent(t *testing.T) {
35 assert.Contains(t, redacted, `"password":"[REDACTED]"`)35 assert.Contains(t, redacted, `"password":"[REDACTED]"`)
36 })36 })
37 37
38+ t.Run("key_value_params", func(t *testing.T) {
39+ line := "callback received password=secret123 token=abc456"
40+ redacted := redactSensitiveContent(line)
41+ assert.NotContains(t, redacted, "secret123")
42+ assert.NotContains(t, redacted, "abc456")
43+ assert.Contains(t, redacted, "password=[REDACTED]")
44+ assert.Contains(t, redacted, "token=[REDACTED]")
45+ })
46+
38 t.Run("authorization_headers", func(t *testing.T) {47 t.Run("authorization_headers", func(t *testing.T) {
39 line := "Authorization: Bearer eyJhbGciOiJSUz"48 line := "Authorization: Bearer eyJhbGciOiJSUz"
40 redacted := redactSensitiveContent(line)49 redacted := redactSensitiveContent(line)
@@ -105,13 +114,20 @@ func TestWriteUnexpectedCloseReport(t *testing.T) {
105 require.NoError(t, json.Unmarshal(raw, &payload))114 require.NoError(t, json.Unmarshal(raw, &payload))
106 assert.Equal(t, sessionID, payload["session_id"])115 assert.Equal(t, sessionID, payload["session_id"])
107 116
108- classification := payload["classification"].(map[string]any)117+ classificationRaw, ok := payload["classification"].(map[string]any)
109- classValue, _ := classification["class"].(string)118+ require.True(t, ok, "classification should be a map[string]any")
119+ classValue, ok := classificationRaw["class"].(string)
120+ require.True(t, ok, "classification.class should be a string")
110 assert.Equal(t, string(SessionExitMainProcessCrashOrAbrupt), classValue)121 assert.Equal(t, string(SessionExitMainProcessCrashOrAbrupt), classValue)
111 122
112- tail := payload["session_log_tail_redacted"].([]any)123+ tail, ok := payload["session_log_tail_redacted"].([]any)
113- require.NotEmpty(t, tail)124+ require.True(t, ok, "session_log_tail_redacted should be a []any")
114- text := tail[0].(string) + tail[len(tail)-1].(string)125+ require.GreaterOrEqual(t, len(tail), 2, "expected at least 2 log tail entries")
126+ first, ok := tail[0].(string)
127+ require.True(t, ok, "first tail entry should be a string")
128+ last, ok := tail[len(tail)-1].(string)
129+ require.True(t, ok, "last tail entry should be a string")
130+ text := first + last
115 assert.NotContains(t, text, "?a=1&b=2")131 assert.NotContains(t, text, "?a=1&b=2")
116 assert.NotContains(t, text, "code=abc")132 assert.NotContains(t, text, "code=abc")
117 assert.NotContains(t, text, "token=def")133 assert.NotContains(t, text, "token=def")
A internal/bootstrap/crash_report_unix.go +31 -0
@@ -0,0 +1,31 @@
1+//go:build linux || darwin
2+
3+package bootstrap
4+
5+import (
6+ "strconv"
7+
8+ "golang.org/x/sys/unix"
9+)
10+
11+func collectCoreDumpDiagnostics() unexpectedCloseCoreDump {
12+ d := unexpectedCloseCoreDump{
13+ Hint: "Run `coredumpctl list | rg -i \"dumber\"` and include matching entries in the issue.",
14+ }
15+ var limit unix.Rlimit
16+ if err := unix.Getrlimit(unix.RLIMIT_CORE, &limit); err != nil {
17+ d.RLimitCoreSoft = "unknown"
18+ d.RLimitCoreHard = "unknown"
19+ return d
20+ }
21+ d.RLimitCoreSoft = formatRlimitCore(limit.Cur)
22+ d.RLimitCoreHard = formatRlimitCore(limit.Max)
23+ return d
24+}
25+
26+func formatRlimitCore(value uint64) string {
27+ if value == unix.RLIM_INFINITY {
28+ return "infinity"
29+ }
30+ return strconv.FormatUint(value, 10)
31+}
@@ -0,0 +1,31 @@
1+//go:build linux || darwin
2+
3+package bootstrap
4+
5+import (
6+ "strconv"
7+
8+ "golang.org/x/sys/unix"
9+)
10+
11+func collectCoreDumpDiagnostics() unexpectedCloseCoreDump {
12+ d := unexpectedCloseCoreDump{
13+ Hint: "Run `coredumpctl list | rg -i \"dumber\"` and include matching entries in the issue.",
14+ }
15+ var limit unix.Rlimit
16+ if err := unix.Getrlimit(unix.RLIMIT_CORE, &limit); err != nil {
17+ d.RLimitCoreSoft = "unknown"
18+ d.RLimitCoreHard = "unknown"
19+ return d
20+ }
21+ d.RLimitCoreSoft = formatRlimitCore(limit.Cur)
22+ d.RLimitCoreHard = formatRlimitCore(limit.Max)
23+ return d
24+}
25+
26+func formatRlimitCore(value uint64) string {
27+ if value == unix.RLIM_INFINITY {
28+ return "infinity"
29+ }
30+ return strconv.FormatUint(value, 10)
31+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close