Skip to content

Commit 85325d4

Browse files
authored
Fix Fatal CLI Restore (#345)
* restore cli state on failures (Fatal)
1 parent c657739 commit 85325d4

3 files changed

Lines changed: 174 additions & 6 deletions

File tree

src/pipeleak/cmd/root.go

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ import (
1919
"github.com/spf13/cobra"
2020
)
2121

22+
// TerminalRestorer is a function that can be called to restore terminal state
23+
var TerminalRestorer func()
24+
2225
var (
2326
rootCmd = &cobra.Command{
2427
Use: "pipeleak",
@@ -70,6 +73,7 @@ type CustomWriter struct {
7073

7174
func (cw *CustomWriter) Write(p []byte) (n int, err error) {
7275
originalLen := len(p)
76+
7377
if bytes.HasSuffix(p, []byte("\n")) {
7478
p = bytes.TrimSuffix(p, []byte("\n"))
7579
}
@@ -94,6 +98,17 @@ func (cw *CustomWriter) Write(p []byte) (n int, err error) {
9498
return originalLen, nil
9599
}
96100

101+
// FatalHook is a zerolog hook that restores terminal state before fatal exits
102+
type FatalHook struct{}
103+
104+
func (h FatalHook) Run(e *zerolog.Event, level zerolog.Level, msg string) {
105+
if level == zerolog.FatalLevel {
106+
if TerminalRestorer != nil {
107+
TerminalRestorer()
108+
}
109+
}
110+
}
111+
97112
func initLogger(cmd *cobra.Command) {
98113
defaultOut := &CustomWriter{Writer: os.Stdout}
99114
colorEnabled := LogColor
@@ -115,12 +130,15 @@ func initLogger(cmd *cobra.Command) {
115130
}
116131
}
117132

133+
// Create the fatal hook to restore terminal state
134+
fatalHook := FatalHook{}
135+
118136
if JsonLogoutput {
119137
// For JSON output, wrap with HitLevelWriter to transform level field
120138
hitWriter := &logging.HitLevelWriter{}
121139
hitWriter.SetOutput(defaultOut)
122140
logging.SetGlobalHitWriter(hitWriter)
123-
log.Logger = zerolog.New(hitWriter).With().Timestamp().Logger()
141+
log.Logger = zerolog.New(hitWriter).With().Timestamp().Logger().Hook(fatalHook)
124142
} else {
125143
// For console output, use custom FormatLevel to color the hit level
126144
output := zerolog.ConsoleWriter{
@@ -133,7 +151,7 @@ func initLogger(cmd *cobra.Command) {
133151
hitWriter := &logging.HitLevelWriter{}
134152
hitWriter.SetOutput(&output)
135153
logging.SetGlobalHitWriter(hitWriter)
136-
log.Logger = zerolog.New(hitWriter).With().Timestamp().Logger()
154+
log.Logger = zerolog.New(hitWriter).With().Timestamp().Logger().Hook(fatalHook)
137155
}
138156
}
139157

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package cmd
2+
3+
import (
4+
"bytes"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
9+
"github.com/rs/zerolog"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
func TestTerminalRestorer(t *testing.T) {
15+
t.Run("TerminalRestorer_can_be_set", func(t *testing.T) {
16+
originalRestorer := TerminalRestorer
17+
defer func() { TerminalRestorer = originalRestorer }()
18+
19+
called := false
20+
TerminalRestorer = func() { called = true }
21+
22+
TerminalRestorer()
23+
assert.True(t, called, "TerminalRestorer should be callable")
24+
})
25+
26+
t.Run("TerminalRestorer_nil_safe", func(t *testing.T) {
27+
originalRestorer := TerminalRestorer
28+
defer func() { TerminalRestorer = originalRestorer }()
29+
30+
TerminalRestorer = nil
31+
assert.NotPanics(t, func() {
32+
if TerminalRestorer != nil {
33+
TerminalRestorer()
34+
}
35+
})
36+
})
37+
}
38+
39+
func TestFatalHook(t *testing.T) {
40+
t.Run("fatal_level_calls_TerminalRestorer", func(t *testing.T) {
41+
originalRestorer := TerminalRestorer
42+
defer func() { TerminalRestorer = originalRestorer }()
43+
44+
called := false
45+
TerminalRestorer = func() { called = true }
46+
47+
hook := FatalHook{}
48+
49+
// Create a dummy event and run the hook with fatal level
50+
var buf bytes.Buffer
51+
logger := zerolog.New(&buf)
52+
event := logger.Fatal()
53+
54+
hook.Run(event, zerolog.FatalLevel, "test")
55+
56+
assert.True(t, called, "TerminalRestorer should be called for fatal level")
57+
})
58+
59+
t.Run("non_fatal_level_does_not_call_TerminalRestorer", func(t *testing.T) {
60+
originalRestorer := TerminalRestorer
61+
defer func() { TerminalRestorer = originalRestorer }()
62+
63+
called := false
64+
TerminalRestorer = func() { called = true }
65+
66+
hook := FatalHook{}
67+
68+
levels := []zerolog.Level{
69+
zerolog.TraceLevel,
70+
zerolog.DebugLevel,
71+
zerolog.InfoLevel,
72+
zerolog.WarnLevel,
73+
zerolog.ErrorLevel,
74+
}
75+
76+
var buf bytes.Buffer
77+
logger := zerolog.New(&buf)
78+
for _, lvl := range levels {
79+
event := logger.WithLevel(lvl)
80+
hook.Run(event, lvl, "test")
81+
assert.False(t, called, "TerminalRestorer should not be called for non-fatal levels")
82+
}
83+
})
84+
85+
t.Run("nil_TerminalRestorer_does_not_panic", func(t *testing.T) {
86+
originalRestorer := TerminalRestorer
87+
defer func() { TerminalRestorer = originalRestorer }()
88+
89+
TerminalRestorer = nil
90+
hook := FatalHook{}
91+
92+
assert.NotPanics(t, func() {
93+
var buf bytes.Buffer
94+
logger := zerolog.New(&buf)
95+
event := logger.Fatal()
96+
hook.Run(event, zerolog.FatalLevel, "test")
97+
})
98+
})
99+
}
100+
101+
func TestCustomWriter_WritesCorrectly(t *testing.T) {
102+
t.Run("Writes_log_to_file", func(t *testing.T) {
103+
tmpDir := t.TempDir()
104+
logFile := filepath.Join(tmpDir, "test.log")
105+
106+
f, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY, 0644)
107+
require.NoError(t, err)
108+
defer func() { _ = f.Close() }()
109+
110+
writer := &CustomWriter{Writer: f}
111+
112+
testLog := []byte(`{"level":"info","message":"test"}` + "\n")
113+
n, err := writer.Write(testLog)
114+
115+
assert.NoError(t, err)
116+
assert.Equal(t, len(testLog), n, "Should return original length")
117+
118+
// Read back and verify
119+
_ = f.Close()
120+
content, err := os.ReadFile(logFile)
121+
require.NoError(t, err)
122+
assert.Contains(t, string(content), "test", "Log content should be written")
123+
})
124+
}

src/pipeleak/main.go

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,39 @@
11
package main
22

33
import (
4+
"encoding/json"
5+
"io"
46
_ "net/http/pprof"
57
"os"
68

79
"github.com/CompassSecurity/pipeleak/cmd"
810
"golang.org/x/term"
911
)
1012

11-
var originalTerminalState *term.State
13+
var originalTermState *term.State
14+
15+
type TerminalRestoringWriter struct {
16+
underlying io.Writer
17+
}
18+
19+
func (w *TerminalRestoringWriter) Write(p []byte) (n int, err error) {
20+
var logEntry map[string]interface{}
21+
if err := json.Unmarshal(p, &logEntry); err == nil {
22+
if level, ok := logEntry["level"].(string); ok && level == "fatal" {
23+
_, _ = w.underlying.Write(p)
24+
restoreTerminalState()
25+
os.Exit(1)
26+
}
27+
}
28+
return w.underlying.Write(p)
29+
}
1230

1331
func main() {
1432
saveTerminalState()
1533
defer restoreTerminalState()
34+
35+
cmd.TerminalRestorer = restoreTerminalState
36+
1637
err := cmd.Execute()
1738
if err != nil {
1839
os.Exit(1)
@@ -23,13 +44,18 @@ func saveTerminalState() {
2344
if term.IsTerminal(int(os.Stdin.Fd())) {
2445
state, err := term.GetState(int(os.Stdin.Fd()))
2546
if err == nil {
26-
originalTerminalState = state
47+
originalTermState = state
2748
}
2849
}
2950
}
3051

3152
func restoreTerminalState() {
32-
if originalTerminalState != nil {
33-
_ = term.Restore(int(os.Stdin.Fd()), originalTerminalState)
53+
if originalTermState != nil {
54+
_ = term.Restore(int(os.Stdin.Fd()), originalTermState)
3455
}
3556
}
57+
58+
// GetTerminalRestoringWriter wraps the given writer to restore terminal state on fatal logs
59+
func GetTerminalRestoringWriter(w io.Writer) io.Writer {
60+
return &TerminalRestoringWriter{underlying: w}
61+
}

0 commit comments

Comments
 (0)