detached a696807

fix(update): address copilot review feedback

commit
a69680764b782c5825f521d7c59538daeb390637
author
brice · · 2025-12-25 16:47 UTC
parent
2221f72
signature
unsigned
3 files · +44 -35
M internal/infrastructure/updater/github.go +37 -15
@@ -297,6 +297,27 @@ func verifyChecksum(filePath, expectedHash string) error {
297297 return nil
298298 }
299299
300+// getExpectedChecksum fetches checksums and returns the hash for the given URL.
301+func (g *GitHubDownloader) getExpectedChecksum(ctx context.Context, downloadURL string) (string, error) {
302+ checksums, err := g.fetchChecksums(ctx)
303+ if err != nil {
304+ return "", fmt.Errorf("failed to fetch checksums: %w", err)
305+ }
306+
307+ parsedURL, err := url.Parse(downloadURL)
308+ if err != nil {
309+ return "", fmt.Errorf("failed to parse download URL: %w", err)
310+ }
311+
312+ archiveFilename := filepath.Base(parsedURL.Path)
313+ expectedHash, ok := checksums[archiveFilename]
314+ if !ok {
315+ return "", fmt.Errorf("no checksum found for %s", archiveFilename)
316+ }
317+
318+ return expectedHash, nil
319+}
320+
300321 // Download fetches the update archive from the given URL.
301322 func (g *GitHubDownloader) Download(ctx context.Context, downloadURL, destDir string) (string, error) {
302323 log := logging.FromContext(ctx)
@@ -311,18 +332,10 @@ func (g *GitHubDownloader) Download(ctx context.Context, downloadURL, destDir st
311332 return "", fmt.Errorf("failed to create download directory: %w", err)
312333 }
313334
314- // Fetch checksums first for verification.
315- checksums, err := g.fetchChecksums(ctx)
335+ // Fetch and look up expected checksum for this archive.
336+ expectedHash, err := g.getExpectedChecksum(ctx, downloadURL)
316337 if err != nil {
317- return "", fmt.Errorf("failed to fetch checksums: %w", err)
318- }
319-
320- // Extract expected filename from URL to look up checksum.
321- parsedURL, _ := url.Parse(downloadURL)
322- archiveFilename := filepath.Base(parsedURL.Path)
323- expectedHash, ok := checksums[archiveFilename]
324- if !ok {
325- return "", fmt.Errorf("no checksum found for %s", archiveFilename)
338+ return "", err
326339 }
327340
328341 // Create the request.
@@ -380,10 +393,16 @@ func (g *GitHubDownloader) Download(ctx context.Context, downloadURL, destDir st
380393 return "", fmt.Errorf("checksum verification failed: %w", err)
381394 }
382395
396+ // Safely truncate hash for logging (SHA256 is 64 chars, but be defensive).
397+ displayHash := expectedHash
398+ if len(displayHash) > 16 {
399+ displayHash = displayHash[:16] + "..."
400+ }
401+
383402 log.Debug().
384403 Int64("bytes", written).
385404 Str("path", archivePath).
386- Str("checksum", expectedHash[:16]+"...").
405+ Str("checksum", displayHash).
387406 Msg("download completed and verified")
388407
389408 return archivePath, nil
@@ -399,9 +418,12 @@ func sanitizeTarPath(name, destDir string) (string, error) {
399418 return "", fmt.Errorf("absolute path not allowed: %s", name)
400419 }
401420
402- // Reject paths that try to escape using ..
403- if strings.HasPrefix(cleaned, "..") || strings.Contains(cleaned, string(filepath.Separator)+"..") {
404- return "", fmt.Errorf("path traversal detected: %s", name)
421+ // Reject paths that contain ".." as a path component (not as part of filename).
422+ // Split by separator and check each component.
423+ for _, part := range strings.Split(cleaned, string(filepath.Separator)) {
424+ if part == ".." {
425+ return "", fmt.Errorf("path traversal detected: %s", name)
426+ }
405427 }
406428
407429 // Join with destination and verify result is within destDir.
@@ -297,6 +297,27 @@ func verifyChecksum(filePath, expectedHash string) error {
297 return nil297 return nil
298 }298 }
299 299
300+// getExpectedChecksum fetches checksums and returns the hash for the given URL.
301+func (g *GitHubDownloader) getExpectedChecksum(ctx context.Context, downloadURL string) (string, error) {
302+ checksums, err := g.fetchChecksums(ctx)
303+ if err != nil {
304+ return "", fmt.Errorf("failed to fetch checksums: %w", err)
305+ }
306+
307+ parsedURL, err := url.Parse(downloadURL)
308+ if err != nil {
309+ return "", fmt.Errorf("failed to parse download URL: %w", err)
310+ }
311+
312+ archiveFilename := filepath.Base(parsedURL.Path)
313+ expectedHash, ok := checksums[archiveFilename]
314+ if !ok {
315+ return "", fmt.Errorf("no checksum found for %s", archiveFilename)
316+ }
317+
318+ return expectedHash, nil
319+}
320+
300 // Download fetches the update archive from the given URL.321 // Download fetches the update archive from the given URL.
301 func (g *GitHubDownloader) Download(ctx context.Context, downloadURL, destDir string) (string, error) {322 func (g *GitHubDownloader) Download(ctx context.Context, downloadURL, destDir string) (string, error) {
302 log := logging.FromContext(ctx)323 log := logging.FromContext(ctx)
@@ -311,18 +332,10 @@ func (g *GitHubDownloader) Download(ctx context.Context, downloadURL, destDir st
311 return "", fmt.Errorf("failed to create download directory: %w", err)332 return "", fmt.Errorf("failed to create download directory: %w", err)
312 }333 }
313 334
314- // Fetch checksums first for verification.335+ // Fetch and look up expected checksum for this archive.
315- checksums, err := g.fetchChecksums(ctx)336+ expectedHash, err := g.getExpectedChecksum(ctx, downloadURL)
316 if err != nil {337 if err != nil {
317- return "", fmt.Errorf("failed to fetch checksums: %w", err)338+ return "", err
318- }
319-
320- // Extract expected filename from URL to look up checksum.
321- parsedURL, _ := url.Parse(downloadURL)
322- archiveFilename := filepath.Base(parsedURL.Path)
323- expectedHash, ok := checksums[archiveFilename]
324- if !ok {
325- return "", fmt.Errorf("no checksum found for %s", archiveFilename)
326 }339 }
327 340
328 // Create the request.341 // Create the request.
@@ -380,10 +393,16 @@ func (g *GitHubDownloader) Download(ctx context.Context, downloadURL, destDir st
380 return "", fmt.Errorf("checksum verification failed: %w", err)393 return "", fmt.Errorf("checksum verification failed: %w", err)
381 }394 }
382 395
396+ // Safely truncate hash for logging (SHA256 is 64 chars, but be defensive).
397+ displayHash := expectedHash
398+ if len(displayHash) > 16 {
399+ displayHash = displayHash[:16] + "..."
400+ }
401+
383 log.Debug().402 log.Debug().
384 Int64("bytes", written).403 Int64("bytes", written).
385 Str("path", archivePath).404 Str("path", archivePath).
386- Str("checksum", expectedHash[:16]+"...").405+ Str("checksum", displayHash).
387 Msg("download completed and verified")406 Msg("download completed and verified")
388 407
389 return archivePath, nil408 return archivePath, nil
@@ -399,9 +418,12 @@ func sanitizeTarPath(name, destDir string) (string, error) {
399 return "", fmt.Errorf("absolute path not allowed: %s", name)418 return "", fmt.Errorf("absolute path not allowed: %s", name)
400 }419 }
401 420
402- // Reject paths that try to escape using ..421+ // Reject paths that contain ".." as a path component (not as part of filename).
403- if strings.HasPrefix(cleaned, "..") || strings.Contains(cleaned, string(filepath.Separator)+"..") {422+ // Split by separator and check each component.
404- return "", fmt.Errorf("path traversal detected: %s", name)423+ for _, part := range strings.Split(cleaned, string(filepath.Separator)) {
424+ if part == ".." {
425+ return "", fmt.Errorf("path traversal detected: %s", name)
426+ }
405 }427 }
406 428
407 // Join with destination and verify result is within destDir.429 // Join with destination and verify result is within destDir.
M internal/infrastructure/updater/github_test.go +6 -15
@@ -5,6 +5,7 @@ import (
55 "encoding/hex"
66 "os"
77 "path/filepath"
8+ "strings"
89 "testing"
910 )
1011
@@ -208,10 +209,10 @@ func TestSanitizeTarPath(t *testing.T) {
208209 errMsg: "path traversal",
209210 },
210211 {
211- name: "double dot at start",
212- path: "..dumber",
213- wantErr: true, // Starts with ".." so rejected as path traversal
214- errMsg: "path traversal",
212+ name: "double dot at start of filename",
213+ path: "..dumber",
214+ wantErr: false, // Valid filename, not a path traversal (.. is not a path component)
215+ wantPath: "/tmp/extract/..dumber",
215216 },
216217 {
217218 name: "absolute path unix",
@@ -356,15 +357,5 @@ func TestSizeLimits(t *testing.T) {
356357
357358 // contains checks if substr is in s (helper for error message checks).
358359 func contains(s, substr string) bool {
359- return len(s) >= len(substr) && (s == substr || substr == "" ||
360- (s != "" && substr != "" && findSubstring(s, substr)))
361-}
362-
363-func findSubstring(s, substr string) bool {
364- for i := 0; i <= len(s)-len(substr); i++ {
365- if s[i:i+len(substr)] == substr {
366- return true
367- }
368- }
369- return false
360+ return strings.Contains(s, substr)
370361 }
@@ -5,6 +5,7 @@ import (
5 "encoding/hex"5 "encoding/hex"
6 "os"6 "os"
7 "path/filepath"7 "path/filepath"
8+ "strings"
8 "testing"9 "testing"
9 )10 )
10 11
@@ -208,10 +209,10 @@ func TestSanitizeTarPath(t *testing.T) {
208 errMsg: "path traversal",209 errMsg: "path traversal",
209 },210 },
210 {211 {
211- name: "double dot at start",212+ name: "double dot at start of filename",
212- path: "..dumber",213+ path: "..dumber",
213- wantErr: true, // Starts with ".." so rejected as path traversal214+ wantErr: false, // Valid filename, not a path traversal (.. is not a path component)
214- errMsg: "path traversal",215+ wantPath: "/tmp/extract/..dumber",
215 },216 },
216 {217 {
217 name: "absolute path unix",218 name: "absolute path unix",
@@ -356,15 +357,5 @@ func TestSizeLimits(t *testing.T) {
356 357
357 // contains checks if substr is in s (helper for error message checks).358 // contains checks if substr is in s (helper for error message checks).
358 func contains(s, substr string) bool {359 func contains(s, substr string) bool {
359- return len(s) >= len(substr) && (s == substr || substr == "" ||360+ return strings.Contains(s, substr)
360- (s != "" && substr != "" && findSubstring(s, substr)))
361-}
362-
363-func findSubstring(s, substr string) bool {
364- for i := 0; i <= len(s)-len(substr); i++ {
365- if s[i:i+len(substr)] == substr {
366- return true
367- }
368- }
369- return false
370 }361 }
M internal/ui/coordinator/update.go +1 -5
@@ -67,12 +67,10 @@ func (c *UpdateCoordinator) checkAsync(ctx context.Context) {
6767 return
6868 }
6969
70+ // Update both lastInfo and status atomically to avoid inconsistent reads.
7071 c.mu.Lock()
7172 c.lastInfo = result
72- c.mu.Unlock()
73-
7473 if !result.UpdateAvailable {
75- c.mu.Lock()
7674 c.status = entity.UpdateStatusUpToDate
7775 c.mu.Unlock()
7876 log.Debug().
@@ -80,8 +78,6 @@ func (c *UpdateCoordinator) checkAsync(ctx context.Context) {
8078 Msg("already on latest version")
8179 return
8280 }
83-
84- c.mu.Lock()
8581 c.status = entity.UpdateStatusAvailable
8682 c.mu.Unlock()
8783 log.Info().
@@ -67,12 +67,10 @@ func (c *UpdateCoordinator) checkAsync(ctx context.Context) {
67 return67 return
68 }68 }
69 69
70+ // Update both lastInfo and status atomically to avoid inconsistent reads.
70 c.mu.Lock()71 c.mu.Lock()
71 c.lastInfo = result72 c.lastInfo = result
72- c.mu.Unlock()
73-
74 if !result.UpdateAvailable {73 if !result.UpdateAvailable {
75- c.mu.Lock()
76 c.status = entity.UpdateStatusUpToDate74 c.status = entity.UpdateStatusUpToDate
77 c.mu.Unlock()75 c.mu.Unlock()
78 log.Debug().76 log.Debug().
@@ -80,8 +78,6 @@ func (c *UpdateCoordinator) checkAsync(ctx context.Context) {
80 Msg("already on latest version")78 Msg("already on latest version")
81 return79 return
82 }80 }
83-
84- c.mu.Lock()
85 c.status = entity.UpdateStatusAvailable81 c.status = entity.UpdateStatusAvailable
86 c.mu.Unlock()82 c.mu.Unlock()
87 log.Info().83 log.Info().

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