Skip to content

Commit 71812c4

Browse files
Fix DeepSource review issues in self-updater
- Handle os.UserHomeDir() error to prevent state file in relative paths - Restrict config dir permissions from 0o755 to 0o750 - Limit download size with io.LimitReader (50MB cap) - Replace unused parameter `r` with `_` in test handler - Refactor replaceBinary into testable replaceBinaryAt function - Rewrite tests to call actual functions instead of reimplementing logic Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 92b4181 commit 71812c4

2 files changed

Lines changed: 88 additions & 91 deletions

File tree

internal/update/updater.go

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ import (
2323
"github.com/deepsourcelabs/cli/internal/debug"
2424
)
2525

26+
// executablePath returns the current executable path. Overridable in tests.
27+
var executablePath = os.Executable
28+
2629
// UpdateState is the on-disk state written by CheckForUpdate and consumed by ApplyUpdate.
2730
type UpdateState struct {
2831
Version string `json:"version"`
@@ -32,14 +35,21 @@ type UpdateState struct {
3235
}
3336

3437
// updateStatePath returns the path to the update state file (~/.deepsource/update.json).
35-
func updateStatePath() string {
36-
home, _ := os.UserHomeDir()
37-
return filepath.Join(home, buildinfo.ConfigDirName, "update.json")
38+
func updateStatePath() (string, error) {
39+
home, err := os.UserHomeDir()
40+
if err != nil {
41+
return "", fmt.Errorf("getting home directory: %w", err)
42+
}
43+
return filepath.Join(home, buildinfo.ConfigDirName, "update.json"), nil
3844
}
3945

4046
// ReadUpdateState reads the update state file. Returns nil if the file does not exist.
4147
func ReadUpdateState() (*UpdateState, error) {
42-
data, err := os.ReadFile(updateStatePath())
48+
p, err := updateStatePath()
49+
if err != nil {
50+
return nil, err
51+
}
52+
data, err := os.ReadFile(p)
4353
if err != nil {
4454
if errors.Is(err, os.ErrNotExist) {
4555
return nil, nil
@@ -58,8 +68,11 @@ func writeUpdateState(s *UpdateState) error {
5868
if err != nil {
5969
return fmt.Errorf("marshaling update state: %w", err)
6070
}
61-
p := updateStatePath()
62-
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
71+
p, err := updateStatePath()
72+
if err != nil {
73+
return err
74+
}
75+
if err := os.MkdirAll(filepath.Dir(p), 0o750); err != nil {
6376
return fmt.Errorf("creating config dir: %w", err)
6477
}
6578
if err := os.WriteFile(p, data, 0o644); err != nil {
@@ -69,7 +82,11 @@ func writeUpdateState(s *UpdateState) error {
6982
}
7083

7184
func clearUpdateState() {
72-
_ = os.Remove(updateStatePath())
85+
p, err := updateStatePath()
86+
if err != nil {
87+
return
88+
}
89+
_ = os.Remove(p)
7390
}
7491

7592
// CheckForUpdate fetches the manifest, compares versions, and writes a state
@@ -208,7 +225,8 @@ func downloadFile(client *http.Client, url string) ([]byte, error) {
208225
return nil, fmt.Errorf("download returned HTTP %d", resp.StatusCode)
209226
}
210227

211-
data, err := io.ReadAll(resp.Body)
228+
const maxUpdateSize = 50 * 1024 * 1024 // 50MB
229+
data, err := io.ReadAll(io.LimitReader(resp.Body, maxUpdateSize))
212230
if err != nil {
213231
return nil, fmt.Errorf("reading download body: %w", err)
214232
}
@@ -276,17 +294,21 @@ func extractFromZip(data []byte, binaryName string) ([]byte, error) {
276294

277295
// replaceBinary atomically replaces the current executable with newBinary.
278296
func replaceBinary(newBinary []byte) error {
279-
exe, err := os.Executable()
297+
exe, err := executablePath()
280298
if err != nil {
281299
return fmt.Errorf("finding current executable: %w", err)
282300
}
283301
exe, err = filepath.EvalSymlinks(exe)
284302
if err != nil {
285303
return fmt.Errorf("resolving symlinks: %w", err)
286304
}
305+
return replaceBinaryAt(newBinary, exe)
306+
}
287307

288-
dir := filepath.Dir(exe)
289-
base := filepath.Base(exe)
308+
// replaceBinaryAt atomically replaces the binary at destPath with newBinary.
309+
func replaceBinaryAt(newBinary []byte, destPath string) error {
310+
dir := filepath.Dir(destPath)
311+
base := filepath.Base(destPath)
290312

291313
// Write new binary to a temp file in the same directory (same filesystem for rename)
292314
tmp, err := os.CreateTemp(dir, base+".new.*")
@@ -311,17 +333,17 @@ func replaceBinary(newBinary []byte) error {
311333
}
312334

313335
// Rename current binary to .bak, then new to current
314-
bakPath := exe + ".bak"
336+
bakPath := destPath + ".bak"
315337
_ = os.Remove(bakPath) // clean up any leftover .bak
316338

317-
if err := os.Rename(exe, bakPath); err != nil {
339+
if err := os.Rename(destPath, bakPath); err != nil {
318340
os.Remove(tmpPath)
319341
return fmt.Errorf("backing up current binary: %w", err)
320342
}
321343

322-
if err := os.Rename(tmpPath, exe); err != nil {
344+
if err := os.Rename(tmpPath, destPath); err != nil {
323345
// Try to restore the backup
324-
_ = os.Rename(bakPath, exe)
346+
_ = os.Rename(bakPath, destPath)
325347
os.Remove(tmpPath)
326348
return fmt.Errorf("replacing binary: %w", err)
327349
}

internal/update/updater_test.go

Lines changed: 51 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -86,32 +86,10 @@ func TestReplaceBinary(t *testing.T) {
8686
t.Fatal(err)
8787
}
8888

89-
// Point os.Executable to our fake binary by using a symlink
90-
// Since we can't override os.Executable, test replaceBinary directly
91-
// by calling the internal logic with a known path.
9289
newContent := []byte("new binary content")
93-
94-
// Write new binary to temp, rename
95-
tmp, err := os.CreateTemp(dir, "deepsource.new.*")
96-
if err != nil {
97-
t.Fatal(err)
98-
}
99-
if _, err := tmp.Write(newContent); err != nil {
100-
t.Fatal(err)
101-
}
102-
if err := tmp.Chmod(0o755); err != nil {
103-
t.Fatal(err)
104-
}
105-
tmp.Close()
106-
107-
bakPath := fakeBin + ".bak"
108-
if err := os.Rename(fakeBin, bakPath); err != nil {
109-
t.Fatal(err)
110-
}
111-
if err := os.Rename(tmp.Name(), fakeBin); err != nil {
112-
t.Fatal(err)
90+
if err := replaceBinaryAt(newContent, fakeBin); err != nil {
91+
t.Fatalf("replaceBinaryAt: %v", err)
11392
}
114-
os.Remove(bakPath)
11593

11694
got, err := os.ReadFile(fakeBin)
11795
if err != nil {
@@ -230,40 +208,41 @@ func TestCheckForUpdate_NewerVersion(t *testing.T) {
230208
buildinfo.SetBuildInfo("2.0.30", "", "prod")
231209

232210
key := runtime.GOOS + "_" + runtime.GOARCH
233-
manifest := Manifest{
234-
Version: "2.0.40",
235-
Platforms: map[string]PlatformInfo{
236-
key: {
237-
Archive: "deepsource_2.0.40_" + key + ".tar.gz",
238-
SHA256: "abc123",
239-
},
240-
},
241-
}
242-
// Simulate what CheckForUpdate does: fetch manifest, compare, write state
243-
newer, _ := IsNewer("2.0.30", manifest.Version)
244-
if !newer {
245-
t.Fatal("expected newer=true")
246-
}
247-
248-
platform := manifest.Platforms[key]
249-
state := &UpdateState{
250-
Version: manifest.Version,
251-
ArchiveURL: buildinfo.BaseURL + "/build/" + platform.Archive,
252-
SHA256: platform.SHA256,
253-
CheckedAt: time.Now().UTC(),
254-
}
255-
if err := writeUpdateState(state); err != nil {
256-
t.Fatalf("writeUpdateState: %v", err)
211+
manifestJSON := fmt.Sprintf(`{
212+
"version": "2.0.40",
213+
"platforms": {
214+
%q: {
215+
"archive": "deepsource_2.0.40_%s.tar.gz",
216+
"sha256": "abc123"
217+
}
218+
}
219+
}`, key, key)
220+
221+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
222+
w.Write([]byte(manifestJSON))
223+
}))
224+
defer srv.Close()
225+
226+
// Point manifest fetch at our test server
227+
origBaseURL := buildinfo.BaseURL
228+
buildinfo.BaseURL = srv.URL
229+
defer func() { buildinfo.BaseURL = origBaseURL }()
230+
231+
if err := CheckForUpdate(srv.Client()); err != nil {
232+
t.Fatalf("CheckForUpdate: %v", err)
257233
}
258234

259235
got, err := ReadUpdateState()
260236
if err != nil {
261237
t.Fatalf("ReadUpdateState: %v", err)
262238
}
239+
if got == nil {
240+
t.Fatal("expected non-nil state")
241+
}
263242
if got.Version != "2.0.40" {
264243
t.Errorf("expected version 2.0.40, got %s", got.Version)
265244
}
266-
expectedURL := fmt.Sprintf("%s/build/deepsource_2.0.40_%s.tar.gz", buildinfo.BaseURL, key)
245+
expectedURL := fmt.Sprintf("%s/build/deepsource_2.0.40_%s.tar.gz", srv.URL, key)
267246
if got.ArchiveURL != expectedURL {
268247
t.Errorf("expected archive URL %s, got %s", expectedURL, got.ArchiveURL)
269248
}
@@ -299,7 +278,7 @@ func TestApplyUpdate_WithStateFile(t *testing.T) {
299278
checksum := sha256.Sum256(archive)
300279
checksumHex := hex.EncodeToString(checksum[:])
301280

302-
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
281+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
303282
w.Write(archive)
304283
}))
305284
defer srv.Close()
@@ -314,42 +293,38 @@ func TestApplyUpdate_WithStateFile(t *testing.T) {
314293
t.Fatalf("writeUpdateState: %v", err)
315294
}
316295

317-
client := srv.Client()
318-
319-
// ApplyUpdate reads the state file internally, so we test it reads correctly.
320-
// However, replaceBinary will use os.Executable() which we can't easily mock.
321-
// So we test the pieces: state file reading, download, checksum verification.
322-
323-
readState, err := ReadUpdateState()
324-
if err != nil {
325-
t.Fatalf("ReadUpdateState: %v", err)
326-
}
327-
if readState.Version != "2.0.40" {
328-
t.Fatalf("expected version 2.0.40, got %s", readState.Version)
296+
// Create a fake binary so replaceBinary can replace it
297+
fakeBin := filepath.Join(t.TempDir(), buildinfo.AppName)
298+
if err := os.WriteFile(fakeBin, []byte("old binary"), 0o755); err != nil {
299+
t.Fatal(err)
329300
}
330301

331-
data, err := downloadFile(client, readState.ArchiveURL)
302+
// Override executablePath to point to our fake binary
303+
origExecPath := executablePath
304+
executablePath = func() (string, error) { return fakeBin, nil }
305+
defer func() { executablePath = origExecPath }()
306+
307+
ver, err := ApplyUpdate(srv.Client())
332308
if err != nil {
333-
t.Fatalf("downloadFile: %v", err)
309+
t.Fatalf("ApplyUpdate: %v", err)
334310
}
335-
336-
if err := verifyChecksum(data, readState.SHA256); err != nil {
337-
t.Fatalf("verifyChecksum: %v", err)
311+
if ver != "2.0.40" {
312+
t.Errorf("expected version 2.0.40, got %s", ver)
338313
}
339314

340-
extracted, err := extractFromTarGz(data, buildinfo.AppName)
315+
// Verify the binary was actually replaced
316+
got, err := os.ReadFile(fakeBin)
341317
if err != nil {
342-
t.Fatalf("extractFromTarGz: %v", err)
318+
t.Fatal(err)
343319
}
344-
if !bytes.Equal(extracted, binaryContent) {
345-
t.Errorf("extracted binary mismatch: got %q, want %q", extracted, binaryContent)
320+
if !bytes.Equal(got, binaryContent) {
321+
t.Errorf("binary content mismatch: got %q, want %q", got, binaryContent)
346322
}
347323

348-
// Verify clearUpdateState removes the file
349-
clearUpdateState()
350-
afterClear, _ := ReadUpdateState()
351-
if afterClear != nil {
352-
t.Error("state file should be removed after clear")
324+
// Verify state file was cleared
325+
afterApply, _ := ReadUpdateState()
326+
if afterApply != nil {
327+
t.Error("state file should be removed after apply")
353328
}
354329
}
355330

0 commit comments

Comments
 (0)