detached 08eefbd

fix(download): address Copilot review - security, tests, cleanup

commit
08eefbd5d5ce88e0c44045d54bb5feb8281bbd22
author
bnema · · 2026-01-07 18:19 UTC
parent
d3e73b1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +258 -7
M internal/infrastructure/webkit/download_handler.go +46 -5
@@ -2,6 +2,8 @@ package webkit
22
33 import (
44 "context"
5+ "fmt"
6+ "os"
57 "path/filepath"
68 "strings"
79 "sync"
@@ -11,6 +13,11 @@ import (
1113 "github.com/bnema/puregotk-webkit/webkit"
1214 )
1315
16+const (
17+ // dirPerm is the permission mode for creating download directories.
18+ dirPerm = 0755
19+)
20+
1421 // DownloadHandler manages WebKit downloads and notifies the UI layer.
1522 type DownloadHandler struct {
1623 downloadPath string
@@ -40,22 +47,38 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D
4047 var failedMu sync.Mutex
4148
4249 // Handle decide-destination signal to set download path.
50+ //nolint:unparam // bool return is required by WebKit's signal signature (false = handled synchronously)
4351 decideDestCb := func(d webkit.Download, suggestedFilename string) bool {
44- destPath := filepath.Join(downloadPath, suggestedFilename)
45- // WebKit expects a file:// URI for the destination.
46- d.SetDestination("file://" + destPath)
52+ // Ensure download directory exists.
53+ if err := os.MkdirAll(downloadPath, dirPerm); err != nil {
54+ log.Error().Err(err).Str("path", downloadPath).Msg("failed to create download directory")
55+ return false
56+ }
57+
58+ // Sanitize filename to prevent path traversal attacks.
59+ safeName := sanitizeFilename(suggestedFilename)
60+ destPath := filepath.Join(downloadPath, safeName)
61+
62+ log.Debug().
63+ Str("suggested", suggestedFilename).
64+ Str("sanitized", safeName).
65+ Str("destPath", destPath).
66+ Msg("setting download destination")
67+
68+ // WebKit expects an absolute file path for the destination.
69+ d.SetDestination(destPath)
4770
4871 // Notify: download started.
4972 if eventHandler != nil {
5073 eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
5174 Type: port.DownloadEventStarted,
52- Filename: suggestedFilename,
75+ Filename: safeName,
5376 Destination: destPath,
5477 })
5578 }
5679
5780 log.Info().
58- Str("filename", suggestedFilename).
81+ Str("filename", safeName).
5982 Str("destination", destPath).
6083 Msg("download started")
6184
@@ -72,11 +95,15 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D
7295 dest := d.GetDestination()
7396 filename := extractFilename(dest)
7497
98+ // Create error for the failed download.
99+ downloadErr := fmt.Errorf("download failed: %s", filename)
100+
75101 if eventHandler != nil {
76102 eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
77103 Type: port.DownloadEventFailed,
78104 Filename: filename,
79105 Destination: dest,
106+ Error: downloadErr,
80107 })
81108 }
82109
@@ -124,3 +151,17 @@ func extractFilename(dest string) string {
124151 path := strings.TrimPrefix(dest, "file://")
125152 return filepath.Base(path)
126153 }
154+
155+// sanitizeFilename sanitizes a suggested filename to prevent path traversal attacks.
156+// It extracts only the base name and handles edge cases like "." or "..".
157+func sanitizeFilename(name string) string {
158+ // Get only the base name (removes any directory components).
159+ clean := filepath.Base(name)
160+
161+ // If Base returns "." or ".." (edge cases), use fallback.
162+ if clean == "." || clean == ".." || clean == "" {
163+ return "download"
164+ }
165+
166+ return clean
167+}
@@ -2,6 +2,8 @@ package webkit
2 2
3 import (3 import (
4 "context"4 "context"
5+ "fmt"
6+ "os"
5 "path/filepath"7 "path/filepath"
6 "strings"8 "strings"
7 "sync"9 "sync"
@@ -11,6 +13,11 @@ import (
11 "github.com/bnema/puregotk-webkit/webkit"13 "github.com/bnema/puregotk-webkit/webkit"
12 )14 )
13 15
16+const (
17+ // dirPerm is the permission mode for creating download directories.
18+ dirPerm = 0755
19+)
20+
14 // DownloadHandler manages WebKit downloads and notifies the UI layer.21 // DownloadHandler manages WebKit downloads and notifies the UI layer.
15 type DownloadHandler struct {22 type DownloadHandler struct {
16 downloadPath string23 downloadPath string
@@ -40,22 +47,38 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D
40 var failedMu sync.Mutex47 var failedMu sync.Mutex
41 48
42 // Handle decide-destination signal to set download path.49 // Handle decide-destination signal to set download path.
50+ //nolint:unparam // bool return is required by WebKit's signal signature (false = handled synchronously)
43 decideDestCb := func(d webkit.Download, suggestedFilename string) bool {51 decideDestCb := func(d webkit.Download, suggestedFilename string) bool {
44- destPath := filepath.Join(downloadPath, suggestedFilename)52+ // Ensure download directory exists.
45- // WebKit expects a file:// URI for the destination.53+ if err := os.MkdirAll(downloadPath, dirPerm); err != nil {
46- d.SetDestination("file://" + destPath)54+ log.Error().Err(err).Str("path", downloadPath).Msg("failed to create download directory")
55+ return false
56+ }
57+
58+ // Sanitize filename to prevent path traversal attacks.
59+ safeName := sanitizeFilename(suggestedFilename)
60+ destPath := filepath.Join(downloadPath, safeName)
61+
62+ log.Debug().
63+ Str("suggested", suggestedFilename).
64+ Str("sanitized", safeName).
65+ Str("destPath", destPath).
66+ Msg("setting download destination")
67+
68+ // WebKit expects an absolute file path for the destination.
69+ d.SetDestination(destPath)
47 70
48 // Notify: download started.71 // Notify: download started.
49 if eventHandler != nil {72 if eventHandler != nil {
50 eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{73 eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
51 Type: port.DownloadEventStarted,74 Type: port.DownloadEventStarted,
52- Filename: suggestedFilename,75+ Filename: safeName,
53 Destination: destPath,76 Destination: destPath,
54 })77 })
55 }78 }
56 79
57 log.Info().80 log.Info().
58- Str("filename", suggestedFilename).81+ Str("filename", safeName).
59 Str("destination", destPath).82 Str("destination", destPath).
60 Msg("download started")83 Msg("download started")
61 84
@@ -72,11 +95,15 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D
72 dest := d.GetDestination()95 dest := d.GetDestination()
73 filename := extractFilename(dest)96 filename := extractFilename(dest)
74 97
98+ // Create error for the failed download.
99+ downloadErr := fmt.Errorf("download failed: %s", filename)
100+
75 if eventHandler != nil {101 if eventHandler != nil {
76 eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{102 eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
77 Type: port.DownloadEventFailed,103 Type: port.DownloadEventFailed,
78 Filename: filename,104 Filename: filename,
79 Destination: dest,105 Destination: dest,
106+ Error: downloadErr,
80 })107 })
81 }108 }
82 109
@@ -124,3 +151,17 @@ func extractFilename(dest string) string {
124 path := strings.TrimPrefix(dest, "file://")151 path := strings.TrimPrefix(dest, "file://")
125 return filepath.Base(path)152 return filepath.Base(path)
126 }153 }
154+
155+// sanitizeFilename sanitizes a suggested filename to prevent path traversal attacks.
156+// It extracts only the base name and handles edge cases like "." or "..".
157+func sanitizeFilename(name string) string {
158+ // Get only the base name (removes any directory components).
159+ clean := filepath.Base(name)
160+
161+ // If Base returns "." or ".." (edge cases), use fallback.
162+ if clean == "." || clean == ".." || clean == "" {
163+ return "download"
164+ }
165+
166+ return clean
167+}
A internal/infrastructure/webkit/download_handler_test.go +148 -0
@@ -0,0 +1,148 @@
1+package webkit
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestSanitizeFilename(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ input string
13+ expected string
14+ }{
15+ {
16+ name: "normal filename",
17+ input: "document.pdf",
18+ expected: "document.pdf",
19+ },
20+ {
21+ name: "filename with spaces",
22+ input: "my document.pdf",
23+ expected: "my document.pdf",
24+ },
25+ {
26+ name: "path traversal with parent dirs",
27+ input: "../../../etc/passwd",
28+ expected: "passwd",
29+ },
30+ {
31+ name: "path traversal hidden file",
32+ input: "../.ssh/id_rsa",
33+ expected: "id_rsa",
34+ },
35+ {
36+ name: "nested path",
37+ input: "foo/bar/baz.txt",
38+ expected: "baz.txt",
39+ },
40+ {
41+ name: "absolute path",
42+ input: "/etc/passwd",
43+ expected: "passwd",
44+ },
45+ {
46+ name: "dot only",
47+ input: ".",
48+ expected: "download",
49+ },
50+ {
51+ name: "double dot only",
52+ input: "..",
53+ expected: "download",
54+ },
55+ {
56+ name: "empty string",
57+ input: "",
58+ expected: "download",
59+ },
60+ {
61+ name: "hidden file",
62+ input: ".bashrc",
63+ expected: ".bashrc",
64+ },
65+ {
66+ name: "windows style path",
67+ input: "..\\..\\Windows\\System32\\config",
68+ expected: "..\\..\\Windows\\System32\\config",
69+ },
70+ }
71+
72+ for _, tt := range tests {
73+ t.Run(tt.name, func(t *testing.T) {
74+ result := sanitizeFilename(tt.input)
75+ assert.Equal(t, tt.expected, result)
76+ })
77+ }
78+}
79+
80+func TestExtractFilename(t *testing.T) {
81+ tests := []struct {
82+ name string
83+ input string
84+ expected string
85+ }{
86+ {
87+ name: "file URI with path",
88+ input: "file:///home/user/Downloads/document.pdf",
89+ expected: "document.pdf",
90+ },
91+ {
92+ name: "file URI root",
93+ input: "file:///document.pdf",
94+ expected: "document.pdf",
95+ },
96+ {
97+ name: "plain path",
98+ input: "/home/user/Downloads/image.png",
99+ expected: "image.png",
100+ },
101+ {
102+ name: "filename only",
103+ input: "video.mp4",
104+ expected: "video.mp4",
105+ },
106+ {
107+ name: "empty string",
108+ input: "",
109+ expected: ".",
110+ },
111+ {
112+ name: "file URI with spaces",
113+ input: "file:///home/user/My Documents/file.txt",
114+ expected: "file.txt",
115+ },
116+ }
117+
118+ for _, tt := range tests {
119+ t.Run(tt.name, func(t *testing.T) {
120+ result := extractFilename(tt.input)
121+ assert.Equal(t, tt.expected, result)
122+ })
123+ }
124+}
125+
126+func TestNewDownloadHandler(t *testing.T) {
127+ t.Run("creates handler with path and nil event handler", func(t *testing.T) {
128+ handler := NewDownloadHandler("/tmp/downloads", nil)
129+
130+ assert.NotNil(t, handler)
131+ assert.Equal(t, "/tmp/downloads", handler.downloadPath)
132+ assert.Nil(t, handler.eventHandler)
133+ })
134+
135+ t.Run("creates handler with custom path", func(t *testing.T) {
136+ handler := NewDownloadHandler("/custom/path", nil)
137+
138+ assert.Equal(t, "/custom/path", handler.downloadPath)
139+ })
140+}
141+
142+func TestDownloadHandler_SetDownloadPath(t *testing.T) {
143+ handler := NewDownloadHandler("/initial/path", nil)
144+
145+ handler.SetDownloadPath("/new/path")
146+
147+ assert.Equal(t, "/new/path", handler.downloadPath)
148+}
@@ -0,0 +1,148 @@
1+package webkit
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestSanitizeFilename(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ input string
13+ expected string
14+ }{
15+ {
16+ name: "normal filename",
17+ input: "document.pdf",
18+ expected: "document.pdf",
19+ },
20+ {
21+ name: "filename with spaces",
22+ input: "my document.pdf",
23+ expected: "my document.pdf",
24+ },
25+ {
26+ name: "path traversal with parent dirs",
27+ input: "../../../etc/passwd",
28+ expected: "passwd",
29+ },
30+ {
31+ name: "path traversal hidden file",
32+ input: "../.ssh/id_rsa",
33+ expected: "id_rsa",
34+ },
35+ {
36+ name: "nested path",
37+ input: "foo/bar/baz.txt",
38+ expected: "baz.txt",
39+ },
40+ {
41+ name: "absolute path",
42+ input: "/etc/passwd",
43+ expected: "passwd",
44+ },
45+ {
46+ name: "dot only",
47+ input: ".",
48+ expected: "download",
49+ },
50+ {
51+ name: "double dot only",
52+ input: "..",
53+ expected: "download",
54+ },
55+ {
56+ name: "empty string",
57+ input: "",
58+ expected: "download",
59+ },
60+ {
61+ name: "hidden file",
62+ input: ".bashrc",
63+ expected: ".bashrc",
64+ },
65+ {
66+ name: "windows style path",
67+ input: "..\\..\\Windows\\System32\\config",
68+ expected: "..\\..\\Windows\\System32\\config",
69+ },
70+ }
71+
72+ for _, tt := range tests {
73+ t.Run(tt.name, func(t *testing.T) {
74+ result := sanitizeFilename(tt.input)
75+ assert.Equal(t, tt.expected, result)
76+ })
77+ }
78+}
79+
80+func TestExtractFilename(t *testing.T) {
81+ tests := []struct {
82+ name string
83+ input string
84+ expected string
85+ }{
86+ {
87+ name: "file URI with path",
88+ input: "file:///home/user/Downloads/document.pdf",
89+ expected: "document.pdf",
90+ },
91+ {
92+ name: "file URI root",
93+ input: "file:///document.pdf",
94+ expected: "document.pdf",
95+ },
96+ {
97+ name: "plain path",
98+ input: "/home/user/Downloads/image.png",
99+ expected: "image.png",
100+ },
101+ {
102+ name: "filename only",
103+ input: "video.mp4",
104+ expected: "video.mp4",
105+ },
106+ {
107+ name: "empty string",
108+ input: "",
109+ expected: ".",
110+ },
111+ {
112+ name: "file URI with spaces",
113+ input: "file:///home/user/My Documents/file.txt",
114+ expected: "file.txt",
115+ },
116+ }
117+
118+ for _, tt := range tests {
119+ t.Run(tt.name, func(t *testing.T) {
120+ result := extractFilename(tt.input)
121+ assert.Equal(t, tt.expected, result)
122+ })
123+ }
124+}
125+
126+func TestNewDownloadHandler(t *testing.T) {
127+ t.Run("creates handler with path and nil event handler", func(t *testing.T) {
128+ handler := NewDownloadHandler("/tmp/downloads", nil)
129+
130+ assert.NotNil(t, handler)
131+ assert.Equal(t, "/tmp/downloads", handler.downloadPath)
132+ assert.Nil(t, handler.eventHandler)
133+ })
134+
135+ t.Run("creates handler with custom path", func(t *testing.T) {
136+ handler := NewDownloadHandler("/custom/path", nil)
137+
138+ assert.Equal(t, "/custom/path", handler.downloadPath)
139+ })
140+}
141+
142+func TestDownloadHandler_SetDownloadPath(t *testing.T) {
143+ handler := NewDownloadHandler("/initial/path", nil)
144+
145+ handler.SetDownloadPath("/new/path")
146+
147+ assert.Equal(t, "/new/path", handler.downloadPath)
148+}
A internal/infrastructure/xdg/adapter_test.go +63 -0
@@ -0,0 +1,63 @@
1+package xdg
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestAdapter_DownloadDir(t *testing.T) {
13+ adapter := New()
14+
15+ t.Run("returns XDG_DOWNLOAD_DIR when set", func(t *testing.T) {
16+ // Save and restore original env.
17+ original := os.Getenv("XDG_DOWNLOAD_DIR")
18+ defer func() {
19+ if original == "" {
20+ os.Unsetenv("XDG_DOWNLOAD_DIR")
21+ } else {
22+ os.Setenv("XDG_DOWNLOAD_DIR", original)
23+ }
24+ }()
25+
26+ os.Setenv("XDG_DOWNLOAD_DIR", "/custom/downloads")
27+
28+ dir, err := adapter.DownloadDir()
29+
30+ require.NoError(t, err)
31+ assert.Equal(t, "/custom/downloads", dir)
32+ })
33+
34+ t.Run("falls back to ~/Downloads when XDG_DOWNLOAD_DIR not set", func(t *testing.T) {
35+ // Save and restore original env.
36+ original := os.Getenv("XDG_DOWNLOAD_DIR")
37+ defer func() {
38+ if original == "" {
39+ os.Unsetenv("XDG_DOWNLOAD_DIR")
40+ } else {
41+ os.Setenv("XDG_DOWNLOAD_DIR", original)
42+ }
43+ }()
44+
45+ os.Unsetenv("XDG_DOWNLOAD_DIR")
46+
47+ dir, err := adapter.DownloadDir()
48+
49+ require.NoError(t, err)
50+
51+ home, err := os.UserHomeDir()
52+ require.NoError(t, err)
53+
54+ expected := filepath.Join(home, "Downloads")
55+ assert.Equal(t, expected, dir)
56+ })
57+}
58+
59+func TestNew(t *testing.T) {
60+ adapter := New()
61+
62+ assert.NotNil(t, adapter)
63+}
@@ -0,0 +1,63 @@
1+package xdg
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestAdapter_DownloadDir(t *testing.T) {
13+ adapter := New()
14+
15+ t.Run("returns XDG_DOWNLOAD_DIR when set", func(t *testing.T) {
16+ // Save and restore original env.
17+ original := os.Getenv("XDG_DOWNLOAD_DIR")
18+ defer func() {
19+ if original == "" {
20+ os.Unsetenv("XDG_DOWNLOAD_DIR")
21+ } else {
22+ os.Setenv("XDG_DOWNLOAD_DIR", original)
23+ }
24+ }()
25+
26+ os.Setenv("XDG_DOWNLOAD_DIR", "/custom/downloads")
27+
28+ dir, err := adapter.DownloadDir()
29+
30+ require.NoError(t, err)
31+ assert.Equal(t, "/custom/downloads", dir)
32+ })
33+
34+ t.Run("falls back to ~/Downloads when XDG_DOWNLOAD_DIR not set", func(t *testing.T) {
35+ // Save and restore original env.
36+ original := os.Getenv("XDG_DOWNLOAD_DIR")
37+ defer func() {
38+ if original == "" {
39+ os.Unsetenv("XDG_DOWNLOAD_DIR")
40+ } else {
41+ os.Setenv("XDG_DOWNLOAD_DIR", original)
42+ }
43+ }()
44+
45+ os.Unsetenv("XDG_DOWNLOAD_DIR")
46+
47+ dir, err := adapter.DownloadDir()
48+
49+ require.NoError(t, err)
50+
51+ home, err := os.UserHomeDir()
52+ require.NoError(t, err)
53+
54+ expected := filepath.Join(home, "Downloads")
55+ assert.Equal(t, expected, dir)
56+ })
57+}
58+
59+func TestNew(t *testing.T) {
60+ adapter := New()
61+
62+ assert.NotNil(t, adapter)
63+}
M internal/ui/app.go +1 -2
@@ -482,7 +482,7 @@ func (a *App) initDownloadHandler(ctx context.Context) {
482482 }
483483
484484 // Create download event adapter to show toasts.
485- eventAdapter := &downloadEventAdapter{app: a, ctx: ctx}
485+ eventAdapter := &downloadEventAdapter{app: a}
486486
487487 // Create and wire the download handler.
488488 handler := webkit.NewDownloadHandler(downloadPath, eventAdapter)
@@ -494,7 +494,6 @@ func (a *App) initDownloadHandler(ctx context.Context) {
494494 // downloadEventAdapter implements port.DownloadEventHandler and shows toasts.
495495 type downloadEventAdapter struct {
496496 app *App
497- ctx context.Context
498497 }
499498
500499 func (d *downloadEventAdapter) OnDownloadEvent(ctx context.Context, event port.DownloadEvent) {
@@ -482,7 +482,7 @@ func (a *App) initDownloadHandler(ctx context.Context) {
482 }482 }
483 483
484 // Create download event adapter to show toasts.484 // Create download event adapter to show toasts.
485- eventAdapter := &downloadEventAdapter{app: a, ctx: ctx}485+ eventAdapter := &downloadEventAdapter{app: a}
486 486
487 // Create and wire the download handler.487 // Create and wire the download handler.
488 handler := webkit.NewDownloadHandler(downloadPath, eventAdapter)488 handler := webkit.NewDownloadHandler(downloadPath, eventAdapter)
@@ -494,7 +494,6 @@ func (a *App) initDownloadHandler(ctx context.Context) {
494 // downloadEventAdapter implements port.DownloadEventHandler and shows toasts.494 // downloadEventAdapter implements port.DownloadEventHandler and shows toasts.
495 type downloadEventAdapter struct {495 type downloadEventAdapter struct {
496 app *App496 app *App
497- ctx context.Context
498 }497 }
499 498
500 func (d *downloadEventAdapter) OnDownloadEvent(ctx context.Context, event port.DownloadEvent) {499 func (d *downloadEventAdapter) OnDownloadEvent(ctx context.Context, event port.DownloadEvent) {

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