Skip to content

Commit b8cad76

Browse files
committed
fix(teststate): stop after reporting a fatal error
Every t.Fatalf in this package fell through to the code after it. With *testing.T that is invisible, since FailNow calls runtime.Goexit, but TestingT exists so other harnesses can be plugged in and an implementation whose FailNow returns kept going. The harmful case was save: a marshal failure was reported and then os.WriteFile ran anyway with a nil byte slice, leaving a zero byte file for a later stage to load. IsPresent and IsEmptyJSON were quieter but wrong in the same way, reporting absent or empty when the real answer was an error they had just announced. Add an explicit return after each Fatalf, with a package comment explaining why they are not dead code.
1 parent 5eea79a commit b8cad76

2 files changed

Lines changed: 88 additions & 0 deletions

File tree

modules/core/teststate/teststate.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@
77
//
88
// This package lives in core rather than teststructure so that modules such as aws, k8s, packer, and ssh can provide
99
// their own helpers without teststructure having to import every one of them.
10+
// Every t.Fatalf in this package is followed by an explicit return. testing.TestingT documents FailNow as stopping
11+
// execution via runtime.Goexit, and *testing.T honours that, so those returns are unreachable in ordinary use. They
12+
// are not decorative: TestingT exists so other harnesses can be plugged in, and an implementation whose FailNow
13+
// returns would otherwise carry on past the failure. In save that meant writing a zero byte file after a marshal
14+
// error, and in IsPresent and IsEmptyJSON it meant masking the real error behind a plausible looking answer.
1015
package teststate
1116

1217
import (
@@ -73,6 +78,8 @@ func save(t testing.TestingT, path string, overwrite bool, value any, loggedVal
7378
bytes, err := json.Marshal(value)
7479
if err != nil {
7580
t.Fatalf("Failed to convert value %s to JSON: %v", path, err)
81+
82+
return
7683
}
7784

7885
if loggedVal {
@@ -83,6 +90,8 @@ func save(t testing.TestingT, path string, overwrite bool, value any, loggedVal
8390

8491
if err := os.MkdirAll(parentDir, 0o755); err != nil {
8592
t.Fatalf("Failed to create folder %s: %v", parentDir, err)
93+
94+
return
8695
}
8796

8897
// 0o600: this file can hold secrets, such as the private key in an aws.Ec2Keypair or an ssh.KeyPair.
@@ -100,6 +109,8 @@ func Load(t testing.TestingT, path string, value any) {
100109
bytes, err := os.ReadFile(path)
101110
if err != nil {
102111
t.Fatalf("Failed to load value from %s: %v", path, err)
112+
113+
return
103114
}
104115

105116
if err := json.Unmarshal(bytes, value); err != nil {
@@ -112,6 +123,8 @@ func IsPresent(t testing.TestingT, path string) bool {
112123
exists, err := files.FileExistsE(path)
113124
if err != nil {
114125
t.Fatalf("Failed to load test data from %s due to unexpected error: %v", path, err)
126+
127+
return false
115128
}
116129

117130
if !exists {
@@ -121,6 +134,8 @@ func IsPresent(t testing.TestingT, path string) bool {
121134
bytes, err := os.ReadFile(path)
122135
if err != nil {
123136
t.Fatalf("Failed to load test data from %s due to unexpected error: %v", path, err)
137+
138+
return false
124139
}
125140

126141
if IsEmptyJSON(t, bytes) {
@@ -141,6 +156,8 @@ func IsEmptyJSON(t testing.TestingT, bytes []byte) bool {
141156

142157
if err := json.Unmarshal(bytes, &value); err != nil {
143158
t.Fatalf("Failed to parse JSON while testing whether it is empty: %v", err)
159+
160+
return false
144161
}
145162

146163
if value == nil {

modules/core/teststate/teststate_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,3 +204,74 @@ func TestSaveWritesOwnerOnlyPermissions(t *testing.T) {
204204
require.NoError(t, err)
205205
assert.Equal(t, os.FileMode(0o600), info.Mode().Perm(), "saved test data must be owner read/write only")
206206
}
207+
208+
// nonStoppingT is a testing.TestingT whose FailNow returns instead of calling runtime.Goexit. The interface
209+
// documents Goexit semantics, but TestingT exists so other harnesses can be plugged in, and this pins that a
210+
// harness which does not stop cannot make this package do damage after it has reported a failure.
211+
type nonStoppingT struct {
212+
failed bool
213+
msgs []string
214+
}
215+
216+
func (r *nonStoppingT) Fail() { r.failed = true }
217+
func (r *nonStoppingT) FailNow() { r.failed = true }
218+
func (r *nonStoppingT) Error(args ...any) { r.failed = true }
219+
func (r *nonStoppingT) Errorf(string, ...any) { r.failed = true }
220+
func (r *nonStoppingT) Fatal(args ...any) { r.msgs = append(r.msgs, fmt.Sprint(args...)); r.FailNow() }
221+
func (r *nonStoppingT) Name() string { return "nonStoppingT" }
222+
func (r *nonStoppingT) Helper() {}
223+
func (r *nonStoppingT) Fatalf(f string, a ...any) {
224+
r.msgs = append(r.msgs, fmt.Sprintf(f, a...))
225+
r.FailNow()
226+
}
227+
228+
// unmarshalable has a func field, which encoding/json always rejects, so Save fails at the marshal step.
229+
type unmarshalable struct {
230+
Fn func()
231+
}
232+
233+
// TestSaveWritesNothingAfterAMarshalFailure is the regression test. Before the explicit return, save reported the
234+
// marshal failure and then carried on to os.WriteFile with a nil byte slice, leaving a zero byte file that a later
235+
// stage would try to load.
236+
func TestSaveWritesNothingAfterAMarshalFailure(t *testing.T) {
237+
t.Parallel()
238+
239+
folder := t.TempDir()
240+
path := teststate.FormatPath(folder, "Broken.json")
241+
242+
recorder := &nonStoppingT{}
243+
teststate.Save(recorder, path, true, unmarshalable{})
244+
245+
assert.True(t, recorder.failed, "the marshal failure must be reported")
246+
assert.NoFileExists(t, path, "no file may be written after a marshal failure")
247+
}
248+
249+
// TestIsPresentDoesNotMaskAnUnreadableFile pins that a read failure reports the failure rather than quietly
250+
// answering "absent", which would invite a caller to overwrite state it could not read.
251+
func TestIsPresentDoesNotMaskAnUnreadableFile(t *testing.T) {
252+
t.Parallel()
253+
254+
// A directory where a file is expected: FileExistsE succeeds, os.ReadFile then fails with EISDIR.
255+
folder := t.TempDir()
256+
path := teststate.FormatPath(folder, "IsADirectory.json")
257+
require.NoError(t, os.MkdirAll(path, 0o755))
258+
259+
recorder := &nonStoppingT{}
260+
present := teststate.IsPresent(recorder, path)
261+
262+
assert.True(t, recorder.failed, "the read failure must be reported")
263+
assert.False(t, present)
264+
require.NotEmpty(t, recorder.msgs)
265+
assert.Contains(t, recorder.msgs[0], "unexpected error")
266+
}
267+
268+
// TestIsEmptyJSONReportsAParseFailure pins that invalid JSON is reported rather than being called empty.
269+
func TestIsEmptyJSONReportsAParseFailure(t *testing.T) {
270+
t.Parallel()
271+
272+
recorder := &nonStoppingT{}
273+
empty := teststate.IsEmptyJSON(recorder, []byte("{not json"))
274+
275+
assert.True(t, recorder.failed, "the parse failure must be reported")
276+
assert.False(t, empty, "invalid JSON is not the same as empty JSON")
277+
}

0 commit comments

Comments
 (0)