Skip to content

Commit c68950e

Browse files
authored
fix: treat empty on-disk JSON state files as empty state (#616)
1 parent e27054f commit c68950e

10 files changed

Lines changed: 227 additions & 0 deletions

File tree

internal/app/app_client.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"strings"
2323

2424
"github.com/slackapi/slack-cli/internal/config"
25+
"github.com/slackapi/slack-cli/internal/goutils"
2526
"github.com/slackapi/slack-cli/internal/shared/types"
2627
"github.com/slackapi/slack-cli/internal/slackerror"
2728
"github.com/slackapi/slack-cli/internal/style"
@@ -297,6 +298,22 @@ func (ac *AppClient) readDeployedApps() error {
297298
return err
298299
}
299300

301+
// Treat an empty (or whitespace-only) file as "no apps saved yet" rather
302+
// than a parse error. This state occurs when a prior write was truncated
303+
// but not completed (e.g. a process was interrupted between afero.WriteFile's
304+
// O_TRUNC and the actual write). Without this guard, every subsequent CLI
305+
// invocation logs ErrUnableToParseJSON on the same file.
306+
if goutils.IsEmptyJSON(f) {
307+
ac.apps = types.Apps{
308+
DeployedApps: map[string]types.App{},
309+
LocalApps: map[string]types.App{},
310+
}
311+
if err = ac.saveDeployedApps(); err != nil {
312+
return err
313+
}
314+
return nil
315+
}
316+
300317
if err = json.Unmarshal(f, &ac.apps); err != nil {
301318
return slackerror.New(slackerror.ErrUnableToParseJSON).
302319
WithMessage("Failed to parse contents of deployed apps file").
@@ -371,6 +388,17 @@ func (ac *AppClient) readLocalApps() error {
371388
return err
372389
}
373390

391+
// Treat an empty (or whitespace-only) file as "no local apps saved yet"
392+
// rather than a parse error. See readDeployedApps for the corresponding
393+
// note on why this state occurs.
394+
if goutils.IsEmptyJSON(f) {
395+
ac.apps.LocalApps = map[string]types.App{}
396+
if err = ac.saveLocalApps(); err != nil {
397+
return err
398+
}
399+
return nil
400+
}
401+
374402
err = json.Unmarshal(f, &ac.apps.LocalApps)
375403
if err != nil {
376404
return slackerror.New(slackerror.ErrUnableToParseJSON).

internal/app/app_client_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,30 @@ func Test_AppClient_ReadDeployedApps_BrokenAppsJSON(t *testing.T) {
165165
assert.Equal(t, err.(*slackerror.Error).Code, slackerror.ErrUnableToParseJSON)
166166
}
167167

168+
// Test that a zero-byte or whitespace-only apps.json (e.g. from a truncated
169+
// write after a prior process interrupt) is treated as empty state, not a
170+
// parse error. This prevents a hot loop of unable_to_parse_json events on
171+
// every CLI invocation while the file remains empty on disk.
172+
func Test_AppClient_ReadDeployedApps_EmptyAppsJSON(t *testing.T) {
173+
tests := map[string]string{
174+
"zero-byte file": "",
175+
"whitespace-only file": " \n\t\n",
176+
}
177+
for name, contents := range tests {
178+
t.Run(name, func(t *testing.T) {
179+
ac, _, _, pathToAppsJSON, _, teardown := setup(t)
180+
defer teardown(t)
181+
err := afero.WriteFile(ac.fs, pathToAppsJSON, []byte(contents), 0600)
182+
require.NoError(t, err)
183+
err = ac.readDeployedApps()
184+
require.NoError(t, err)
185+
// The empty file should be rewritten as a valid empty apps object.
186+
f, _ := afero.ReadFile(ac.fs, pathToAppsJSON)
187+
assert.Equal(t, "{}", string(f))
188+
})
189+
}
190+
}
191+
168192
// Test that pre-existing dev app details get read from apps.dev.json
169193
func Test_AppClient_ReadDevApps_ExistingAppsJSON(t *testing.T) {
170194
ac, _, _, _, pathToDevAppsJSON, teardown := setup(t)
@@ -207,6 +231,29 @@ func Test_AppClient_ReadDevApps_BrokenAppsJSON(t *testing.T) {
207231
assert.Equal(t, err.(*slackerror.Error).Code, slackerror.ErrUnableToParseJSON)
208232
}
209233

234+
// Test that a zero-byte or whitespace-only apps.dev.json (e.g. from a
235+
// truncated write after a prior process interrupt) is treated as empty
236+
// state, not a parse error. See Test_AppClient_ReadDeployedApps_EmptyAppsJSON.
237+
func Test_AppClient_ReadDevApps_EmptyAppsJSON(t *testing.T) {
238+
tests := map[string]string{
239+
"zero-byte file": "",
240+
"whitespace-only file": " \n\t\n",
241+
}
242+
for name, contents := range tests {
243+
t.Run(name, func(t *testing.T) {
244+
ac, _, _, _, pathToDevAppsJSON, teardown := setup(t)
245+
defer teardown(t)
246+
err := afero.WriteFile(ac.fs, pathToDevAppsJSON, []byte(contents), 0600)
247+
require.NoError(t, err)
248+
err = ac.readLocalApps()
249+
require.NoError(t, err)
250+
// The empty file should be rewritten as a valid empty apps object.
251+
f, _ := afero.ReadFile(ac.fs, pathToDevAppsJSON)
252+
assert.Equal(t, "{}", string(f))
253+
})
254+
}
255+
}
256+
210257
// Test that a team flag config defines the default app name in an empty AppClient
211258
func Test_AppClient_getDeployedAppTeamDomain_ViaCLIFlag(t *testing.T) {
212259
ac, _, _, _, _, teardown := setup(t)

internal/auth/auth.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,16 @@ func (c *Client) auths(ctx context.Context) (map[string]types.SlackAuth, error)
216216
return auths, err
217217
}
218218

219+
// Treat an empty (or whitespace-only) credentials file as "no credentials
220+
// stored" rather than a parse error. This state occurs when a prior write
221+
// was truncated but not completed (e.g. a process was interrupted between
222+
// afero.WriteFile's O_TRUNC and the actual write). Without this guard,
223+
// every subsequent CLI invocation reads the same 0-byte file and logs
224+
// ErrUnableToParseJSON in a hot loop.
225+
if goutils.IsEmptyJSON(raw) {
226+
return types.AuthByTeamID{}, nil
227+
}
228+
219229
err = json.Unmarshal(raw, &auths)
220230
if err != nil {
221231
return auths, slackerror.New(slackerror.ErrUnableToParseJSON).

internal/auth/auth_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,30 @@ func Test_AuthGettersAndSetters(t *testing.T) {
222222
require.Error(t, err)
223223
assert.Equal(t, err.(*slackerror.Error).Code, slackerror.ErrUnableToParseJSON)
224224
})
225+
226+
// A zero-byte or whitespace-only credentials.json (e.g. from a truncated
227+
// write after a prior process interrupt) should be treated as empty state
228+
// rather than a parse error, otherwise every CLI invocation would log
229+
// ErrUnableToParseJSON in a hot loop.
230+
t.Run("empty credentials file returns empty auth state, not a parse error", func(t *testing.T) {
231+
cases := map[string]string{
232+
"zero-byte file": "",
233+
"whitespace-only file": " \n\t\n",
234+
}
235+
for name, contents := range cases {
236+
t.Run(name, func(t *testing.T) {
237+
ctx, authClient := setup(t)
238+
dir, err := authClient.config.SystemConfig.SlackConfigDir(ctx)
239+
require.NoError(t, err)
240+
path := filepath.Join(dir, credentialsFileName)
241+
err = afero.WriteFile(authClient.fs, path, []byte(contents), 0o600)
242+
require.NoError(t, err)
243+
got, err := authClient.auths(ctx)
244+
require.NoError(t, err)
245+
require.Empty(t, got)
246+
})
247+
}
248+
})
225249
}
226250

227251
func Test_AuthsRotation(t *testing.T) {

internal/config/project.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"github.com/google/uuid"
2626
"github.com/opentracing/opentracing-go"
2727
"github.com/slackapi/slack-cli/internal/cache"
28+
"github.com/slackapi/slack-cli/internal/goutils"
2829
"github.com/slackapi/slack-cli/internal/shared/types"
2930
"github.com/slackapi/slack-cli/internal/slackerror"
3031
"github.com/slackapi/slack-cli/internal/style"
@@ -254,6 +255,17 @@ func ReadProjectConfigFile(ctx context.Context, fs afero.Fs, os types.Os) (Proje
254255
return projectConfig, err
255256
}
256257

258+
// Treat an empty (or whitespace-only) project config as "no project config
259+
// saved yet" rather than a parse error. This state occurs when a prior
260+
// write was truncated but not completed (e.g. a process was interrupted
261+
// between afero.WriteFile's O_TRUNC and the actual write). Without this
262+
// guard, every subsequent CLI invocation reads the same 0-byte file and
263+
// logs ErrUnableToParseJSON in a hot loop.
264+
if goutils.IsEmptyJSON(projectConfigFileBytes) {
265+
projectConfig.Surveys = map[string]SurveyConfig{}
266+
return projectConfig, nil
267+
}
268+
257269
err = json.Unmarshal(projectConfigFileBytes, &projectConfig)
258270
if err != nil {
259271
return projectConfig, slackerror.New(slackerror.ErrUnableToParseJSON).

internal/config/project_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,38 @@ func Test_ProjectConfig_ReadProjectConfigFile(t *testing.T) {
352352
assert.Equal(t, slackerror.ToSlackError(err).Code, slackerror.ErrUnableToParseJSON)
353353
assert.Equal(t, slackerror.ToSlackError(err).Message, "Failed to parse contents of project-level config file")
354354
})
355+
356+
// A zero-byte or whitespace-only .slack/config.json (e.g. from a truncated
357+
// write after a prior process interrupt) should be treated as empty state
358+
// rather than a parse error, otherwise every CLI invocation would log
359+
// ErrUnableToParseJSON in a hot loop.
360+
t.Run("empty project config file returns default config, not a parse error", func(t *testing.T) {
361+
cases := map[string]string{
362+
"zero-byte file": "",
363+
"whitespace-only file": " \n\t\n",
364+
}
365+
for name, contents := range cases {
366+
t.Run(name, func(t *testing.T) {
367+
ctx := slackcontext.MockContext(t.Context())
368+
fs := slackdeps.NewFsMock()
369+
os := slackdeps.NewOsMock()
370+
371+
os.AddDefaultMocks()
372+
addProjectMocks(t, fs)
373+
374+
projectDirPath, err := GetProjectDirPath(fs, os)
375+
require.NoError(t, err)
376+
projectConfigFilePath := GetProjectConfigJSONFilePath(projectDirPath)
377+
378+
err = afero.WriteFile(fs, projectConfigFilePath, []byte(contents), 0600)
379+
require.NoError(t, err)
380+
381+
got, err := ReadProjectConfigFile(ctx, fs, os)
382+
require.NoError(t, err)
383+
require.NotNil(t, got.Surveys)
384+
})
385+
}
386+
})
355387
}
356388

357389
func Test_ProjectConfig_WriteProjectConfigFile(t *testing.T) {

internal/config/system.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626

2727
"github.com/google/uuid"
2828
"github.com/opentracing/opentracing-go"
29+
"github.com/slackapi/slack-cli/internal/goutils"
2930
"github.com/slackapi/slack-cli/internal/shared/types"
3031
"github.com/slackapi/slack-cli/internal/slackerror"
3132
"github.com/slackapi/slack-cli/internal/style"
@@ -127,6 +128,17 @@ func (c *SystemConfig) UserConfig(ctx context.Context) (*SystemConfig, error) {
127128
return &config, err
128129
}
129130

131+
// Treat an empty (or whitespace-only) config file as "no user config saved
132+
// yet" rather than a parse error. This state occurs when a prior write was
133+
// truncated but not completed (e.g. a process was interrupted between
134+
// afero.WriteFile's O_TRUNC and the actual write). Without this guard,
135+
// every subsequent CLI invocation reads the same 0-byte file and logs
136+
// ErrUnableToParseJSON in a hot loop.
137+
if goutils.IsEmptyJSON(configFileBytes) {
138+
config.Surveys = map[string]SurveyConfig{}
139+
return &config, nil
140+
}
141+
130142
err = json.Unmarshal(configFileBytes, &config)
131143
if err != nil {
132144
return &config, slackerror.New(slackerror.ErrUnableToParseJSON).

internal/config/system_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,37 @@ func Test_SystemConfig_UserConfig(t *testing.T) {
107107
assert.Equal(t, slackerror.ToSlackError(err).Code, slackerror.ErrUnableToParseJSON)
108108
assert.Equal(t, slackerror.ToSlackError(err).Message, "Failed to parse contents of system-level config file")
109109
})
110+
111+
// A zero-byte or whitespace-only ~/.slack/config.json (e.g. from a truncated
112+
// write after a prior process interrupt) should be treated as empty state
113+
// rather than a parse error, otherwise every CLI invocation would log
114+
// ErrUnableToParseJSON in a hot loop.
115+
t.Run("empty configuration file returns default config, not a parse error", func(t *testing.T) {
116+
cases := map[string]string{
117+
"zero-byte file": "",
118+
"whitespace-only file": " \n\t\n",
119+
}
120+
for name, contents := range cases {
121+
t.Run(name, func(t *testing.T) {
122+
ctx := slackcontext.MockContext(t.Context())
123+
fs := slackdeps.NewFsMock()
124+
os := slackdeps.NewOsMock()
125+
126+
os.AddDefaultMocks()
127+
128+
configFilePath := filepath.Join(slackdeps.MockHomeDirectory, configFolderName, configFileName)
129+
err := afero.WriteFile(fs, configFilePath, []byte(contents), 0600)
130+
require.NoError(t, err)
131+
132+
systemConfig := NewSystemConfig(fs, os)
133+
got, err := systemConfig.UserConfig(ctx)
134+
135+
require.NoError(t, err)
136+
require.NotNil(t, got)
137+
require.NotNil(t, got.Surveys)
138+
})
139+
}
140+
})
110141
}
111142

112143
func Test_Config_SlackConfigDir(t *testing.T) {

internal/goutils/json.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,14 @@ func JSONUnmarshal(data []byte, v interface{}) error {
8484
}
8585
return nil
8686
}
87+
88+
// IsEmptyJSON returns true when the provided bytes are empty or contain only
89+
// whitespace. This matches the state left behind when a JSON state file was
90+
// truncated but not yet rewritten, e.g. after a process was interrupted between
91+
// afero.WriteFile's O_TRUNC and the actual write. Callers that read on-disk
92+
// config/auth/app state should treat this case as "empty state" rather than a
93+
// parse error, otherwise every subsequent CLI invocation logs
94+
// ErrUnableToParseJSON until the user manually clears the file.
95+
func IsEmptyJSON(data []byte) bool {
96+
return len(bytes.TrimSpace(data)) == 0
97+
}

internal/goutils/json_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,26 @@ func Test_JSONMarshalUnescapedIndent(t *testing.T) {
159159
}
160160
}
161161

162+
func Test_IsEmptyJSON(t *testing.T) {
163+
for name, tc := range map[string]struct {
164+
data string
165+
expected bool
166+
}{
167+
"nil bytes": {data: "", expected: true},
168+
"empty string": {data: "", expected: true},
169+
"single space": {data: " ", expected: true},
170+
"whitespace mix": {data: " \n\t\n", expected: true},
171+
"empty JSON object": {data: "{}", expected: false},
172+
"whitespace around JSON": {data: " {} ", expected: false},
173+
"JSON payload": {data: `{"one":"1"}`, expected: false},
174+
"invalid JSON is nonzero": {data: "{", expected: false},
175+
} {
176+
t.Run(name, func(t *testing.T) {
177+
assert.Equal(t, tc.expected, IsEmptyJSON([]byte(tc.data)))
178+
})
179+
}
180+
}
181+
162182
func Test_UnmarshalJSON(t *testing.T) {
163183
type testConfig struct {
164184
One string `json:"one,omitempty"`

0 commit comments

Comments
 (0)