Skip to content

Commit 04ae176

Browse files
authored
fix(external): fix Content-Disposition header parsing for filename ex… (#1132)
Because - The Content-Disposition header parsing logic had an incorrect condition that prevented filename extraction from HTTP responses - The original code checked `disposition == ""` but then tried to access the disposition string, causing filenames to never be extracted from HTTP headers - This affected file uploads where the filename should be preserved from the original HTTP response - The base64 encoding issue for uploaded filenames was related to this parsing problem This commit - Fixes the Content-Disposition header parsing logic by changing the condition from `disposition == ""` to `disposition != ""` - Removes the unnecessary check for "attachment" prefix, allowing filename extraction from both "attachment" and "inline" dispositions according to RFC standards - Adds comprehensive test coverage for filename extraction from various Content-Disposition header formats including: - Basic attachment and inline dispositions - RFC 5987 encoded filenames with special characters - Complex filenames with quotes and special characters - Data URI parsing with filename parameters - URL-encoded filename handling - Adds tests for both `binaryFetcher` and `artifactBinaryFetcher` implementations <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Fixes filename extraction from HTTP responses and adds extensive tests for URL/data URI handling and MinIO presigned patterns. > > - **Bug Fix** > - Correct `Content-Disposition` parsing in `pkg/external/external.go` to extract `filename` when header is present (handles both `attachment` and `inline`). > - **Tests** (`pkg/external/external_test.go`) > - Add coverage for `BinaryFetcher` and `ArtifactBinaryFetcher`: > - Filename extraction from `Content-Disposition` (attachment, inline, RFC 5987, complex names, absent header). > - Data URI parsing with optional `filename` parameter and error cases. > - Presigned URL decoding via `v1alpha/blob-urls/{base64}` and deprecated pattern matching. > - Regular URL fallback and network/error handling. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 35fbcfc. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 1153a09 commit 04ae176

File tree

2 files changed

+362
-5
lines changed

2 files changed

+362
-5
lines changed

pkg/external/external.go

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,9 @@ func (f *binaryFetcher) FetchFromURL(ctx context.Context, url string) (body []by
5050
body = resp.Body()
5151
contentType = strings.Split(mimetype.Detect(body).String(), ";")[0]
5252

53-
if disposition := resp.Header().Get("Content-Disposition"); disposition == "" {
54-
if strings.HasPrefix(disposition, "attachment") {
55-
if _, params, err := mime.ParseMediaType(disposition); err == nil {
56-
filename = params["filename"]
57-
}
53+
if disposition := resp.Header().Get("Content-Disposition"); disposition != "" {
54+
if _, params, err := mime.ParseMediaType(disposition); err == nil {
55+
filename = params["filename"]
5856
}
5957
}
6058

pkg/external/external_test.go

Lines changed: 359 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,359 @@
1+
package external
2+
3+
import (
4+
"context"
5+
"encoding/base64"
6+
"fmt"
7+
"net/http"
8+
"net/http/httptest"
9+
"testing"
10+
11+
"github.com/stretchr/testify/assert"
12+
)
13+
14+
// createTestServer creates a local HTTP server for testing filename extraction
15+
func createTestServer() *httptest.Server {
16+
mux := http.NewServeMux()
17+
18+
// Endpoint with attachment filename
19+
mux.HandleFunc("/file-attachment", func(w http.ResponseWriter, r *http.Request) {
20+
w.Header().Set("Content-Disposition", `attachment; filename="test-document.pdf"`)
21+
w.Header().Set("Content-Type", "application/pdf")
22+
_, _ = w.Write([]byte("PDF content"))
23+
})
24+
25+
// Endpoint with inline filename
26+
mux.HandleFunc("/file-inline", func(w http.ResponseWriter, r *http.Request) {
27+
w.Header().Set("Content-Disposition", `inline; filename="image.png"`)
28+
w.Header().Set("Content-Type", "image/png")
29+
_, _ = w.Write([]byte("PNG content"))
30+
})
31+
32+
// Endpoint with filename* (RFC 5987)
33+
mux.HandleFunc("/file-encoded", func(w http.ResponseWriter, r *http.Request) {
34+
w.Header().Set("Content-Disposition", `attachment; filename*=UTF-8''test%20file%20with%20spaces.txt`)
35+
w.Header().Set("Content-Type", "text/plain")
36+
_, _ = w.Write([]byte("Text content with encoded filename"))
37+
})
38+
39+
// Endpoint without Content-Disposition
40+
mux.HandleFunc("/file-no-disposition", func(w http.ResponseWriter, r *http.Request) {
41+
w.Header().Set("Content-Type", "text/plain")
42+
_, _ = w.Write([]byte("Plain text content"))
43+
})
44+
45+
// Endpoint with complex filename containing quotes and special chars
46+
mux.HandleFunc("/file-complex", func(w http.ResponseWriter, r *http.Request) {
47+
w.Header().Set("Content-Disposition", `attachment; filename="report (final).xlsx"`)
48+
w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
49+
_, _ = w.Write([]byte("Excel content"))
50+
})
51+
52+
return httptest.NewServer(mux)
53+
}
54+
55+
func TestNewBinaryFetcher(t *testing.T) {
56+
fetcher := NewBinaryFetcher()
57+
assert.NotNil(t, fetcher)
58+
assert.IsType(t, &binaryFetcher{}, fetcher)
59+
}
60+
61+
func TestNewArtifactBinaryFetcher(t *testing.T) {
62+
// Test that we can create the fetcher (without mocking the complex interface)
63+
// This test just verifies the constructor doesn't panic with nil inputs
64+
fetcher := NewArtifactBinaryFetcher(nil, nil)
65+
assert.NotNil(t, fetcher)
66+
assert.IsType(t, &artifactBinaryFetcher{}, fetcher)
67+
}
68+
69+
func TestBinaryFetcher_FetchFromURL_WithContentDisposition(t *testing.T) {
70+
server := createTestServer()
71+
defer server.Close()
72+
73+
tests := []struct {
74+
name string
75+
endpoint string
76+
expectedFilename string
77+
expectedContent string
78+
}{
79+
{
80+
name: "attachment with filename",
81+
endpoint: "/file-attachment",
82+
expectedFilename: "test-document.pdf",
83+
expectedContent: "PDF content",
84+
},
85+
{
86+
name: "inline with filename",
87+
endpoint: "/file-inline",
88+
expectedFilename: "image.png",
89+
expectedContent: "PNG content",
90+
},
91+
{
92+
name: "encoded filename (RFC 5987)",
93+
endpoint: "/file-encoded",
94+
expectedFilename: "test file with spaces.txt",
95+
expectedContent: "Text content with encoded filename",
96+
},
97+
{
98+
name: "no content disposition",
99+
endpoint: "/file-no-disposition",
100+
expectedFilename: "",
101+
expectedContent: "Plain text content",
102+
},
103+
{
104+
name: "complex filename with special chars",
105+
endpoint: "/file-complex",
106+
expectedFilename: "report (final).xlsx",
107+
expectedContent: "Excel content",
108+
},
109+
}
110+
111+
for _, tt := range tests {
112+
t.Run(tt.name, func(t *testing.T) {
113+
fetcher := NewBinaryFetcher()
114+
body, contentType, filename, err := fetcher.FetchFromURL(context.Background(), server.URL+tt.endpoint)
115+
116+
assert.NoError(t, err)
117+
assert.Equal(t, []byte(tt.expectedContent), body)
118+
assert.Equal(t, "text/plain", contentType) // mimetype detection
119+
assert.Equal(t, tt.expectedFilename, filename)
120+
})
121+
}
122+
}
123+
124+
func TestBinaryFetcher_FetchFromURL_DataURI(t *testing.T) {
125+
tests := []struct {
126+
name string
127+
dataURI string
128+
expectedContent string
129+
expectedType string
130+
expectedFilename string
131+
expectError bool
132+
}{
133+
{
134+
name: "simple base64",
135+
dataURI: "data:text/plain;base64,SGVsbG8gV29ybGQ=",
136+
expectedContent: "Hello World",
137+
expectedType: "text/plain",
138+
expectedFilename: "",
139+
expectError: false,
140+
},
141+
{
142+
name: "with filename parameter",
143+
dataURI: "data:text/plain;filename=test.txt;base64,SGVsbG8gV29ybGQ=",
144+
expectedContent: "Hello World",
145+
expectedType: "text/plain",
146+
expectedFilename: "test.txt",
147+
expectError: false,
148+
},
149+
{
150+
name: "with encoded filename",
151+
dataURI: "data:text/plain;filename=test%20file.txt;base64,SGVsbG8gV29ybGQ=",
152+
expectedContent: "Hello World",
153+
expectedType: "text/plain",
154+
expectedFilename: "test file.txt",
155+
expectError: false,
156+
},
157+
{
158+
name: "invalid format",
159+
dataURI: "invalid:data:uri",
160+
expectError: true,
161+
},
162+
}
163+
164+
for _, tt := range tests {
165+
t.Run(tt.name, func(t *testing.T) {
166+
fetcher := NewBinaryFetcher()
167+
body, contentType, filename, err := fetcher.FetchFromURL(context.Background(), tt.dataURI)
168+
169+
if tt.expectError {
170+
assert.Error(t, err)
171+
return
172+
}
173+
174+
assert.NoError(t, err)
175+
assert.Equal(t, tt.expectedContent, string(body))
176+
assert.Equal(t, tt.expectedType, contentType)
177+
assert.Equal(t, tt.expectedFilename, filename)
178+
})
179+
}
180+
}
181+
182+
func TestBinaryFetcher_ConvertDataURIToBytes(t *testing.T) {
183+
fetcher := &binaryFetcher{}
184+
185+
tests := []struct {
186+
name string
187+
dataURI string
188+
expectedContent string
189+
expectedType string
190+
expectedFilename string
191+
expectError bool
192+
}{
193+
{
194+
name: "basic data URI",
195+
dataURI: "data:text/plain;base64,SGVsbG8gV29ybGQ=",
196+
expectedContent: "Hello World",
197+
expectedType: "text/plain",
198+
expectedFilename: "",
199+
expectError: false,
200+
},
201+
{
202+
name: "with filename",
203+
dataURI: "data:image/png;filename=image.png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==",
204+
expectedType: "image/png",
205+
expectedFilename: "image.png",
206+
expectError: false,
207+
},
208+
{
209+
name: "invalid format - no data prefix",
210+
dataURI: "text/plain;base64,SGVsbG8gV29ybGQ=",
211+
expectError: true,
212+
},
213+
{
214+
name: "invalid base64",
215+
dataURI: "data:text/plain;base64,invalid_base64!@#$",
216+
expectError: true,
217+
},
218+
}
219+
220+
for _, tt := range tests {
221+
t.Run(tt.name, func(t *testing.T) {
222+
body, contentType, filename, err := fetcher.convertDataURIToBytes(tt.dataURI)
223+
224+
if tt.expectError {
225+
assert.Error(t, err)
226+
return
227+
}
228+
229+
assert.NoError(t, err)
230+
if tt.expectedContent != "" {
231+
assert.Equal(t, tt.expectedContent, string(body))
232+
}
233+
assert.Equal(t, tt.expectedType, contentType)
234+
assert.Equal(t, tt.expectedFilename, filename)
235+
})
236+
}
237+
}
238+
239+
func TestArtifactBinaryFetcher_FetchFromURL_DataURI(t *testing.T) {
240+
fetcher := NewArtifactBinaryFetcher(nil, nil)
241+
242+
dataURI := "data:text/plain;base64,SGVsbG8gV29ybGQ="
243+
body, contentType, filename, err := fetcher.FetchFromURL(context.Background(), dataURI)
244+
245+
assert.NoError(t, err)
246+
assert.Equal(t, "Hello World", string(body))
247+
assert.Equal(t, "text/plain", contentType)
248+
assert.Equal(t, "", filename)
249+
}
250+
251+
func TestArtifactBinaryFetcher_FetchFromURL_PresignedURL(t *testing.T) {
252+
server := createTestServer()
253+
defer server.Close()
254+
255+
fetcher := NewArtifactBinaryFetcher(nil, nil)
256+
257+
// Use our test server's attachment endpoint as the presigned URL
258+
presignedURL := server.URL + "/file-attachment"
259+
encodedTestURL := base64.URLEncoding.EncodeToString([]byte(presignedURL))
260+
testURL := fmt.Sprintf("https://example.com/v1alpha/blob-urls/%s", encodedTestURL)
261+
262+
body, contentType, filename, err := fetcher.FetchFromURL(context.Background(), testURL)
263+
264+
assert.NoError(t, err)
265+
assert.Equal(t, []byte("PDF content"), body)
266+
assert.Equal(t, "text/plain", contentType)
267+
assert.Equal(t, "test-document.pdf", filename)
268+
}
269+
270+
func TestArtifactBinaryFetcher_FetchFromURL_RegularURL(t *testing.T) {
271+
server := createTestServer()
272+
defer server.Close()
273+
274+
fetcher := NewArtifactBinaryFetcher(nil, nil)
275+
276+
body, contentType, filename, err := fetcher.FetchFromURL(context.Background(), server.URL+"/file-complex")
277+
278+
assert.NoError(t, err)
279+
assert.Equal(t, []byte("Excel content"), body)
280+
assert.Equal(t, "text/plain", contentType)
281+
assert.Equal(t, "report (final).xlsx", filename)
282+
}
283+
284+
func TestMinioURLPatterns(t *testing.T) {
285+
tests := []struct {
286+
name string
287+
url string
288+
pattern string
289+
shouldMatch bool
290+
expectedUID string
291+
}{
292+
{
293+
name: "deprecated pattern match",
294+
url: "https://example.com/v1alpha/namespaces/test/blob-urls/123e4567-e89b-12d3-a456-426614174000",
295+
pattern: "deprecated",
296+
shouldMatch: true,
297+
expectedUID: "123e4567-e89b-12d3-a456-426614174000",
298+
},
299+
{
300+
name: "presigned pattern match",
301+
url: "https://example.com/v1alpha/blob-urls/aHR0cHM6Ly9leGFtcGxlLmNvbS9maWxl",
302+
pattern: "presigned",
303+
shouldMatch: true,
304+
expectedUID: "aHR0cHM6Ly9leGFtcGxlLmNvbS9maWxl",
305+
},
306+
{
307+
name: "no match",
308+
url: "https://example.com/some/other/path",
309+
pattern: "deprecated",
310+
shouldMatch: false,
311+
},
312+
}
313+
314+
for _, tt := range tests {
315+
t.Run(tt.name, func(t *testing.T) {
316+
var matches []string
317+
if tt.pattern == "deprecated" {
318+
matches = minioURLPattern.FindStringSubmatch(tt.url)
319+
} else {
320+
matches = minioURLPresignedPattern.FindStringSubmatch(tt.url)
321+
}
322+
323+
if tt.shouldMatch {
324+
assert.NotNil(t, matches)
325+
if len(matches) > 1 {
326+
assert.Equal(t, tt.expectedUID, matches[1])
327+
}
328+
} else {
329+
assert.Nil(t, matches)
330+
}
331+
})
332+
}
333+
}
334+
335+
func TestBinaryFetcher_FetchFromURL_ErrorHandling(t *testing.T) {
336+
fetcher := NewBinaryFetcher()
337+
338+
// Test with invalid URL
339+
_, _, _, err := fetcher.FetchFromURL(context.Background(), "invalid-url")
340+
assert.Error(t, err)
341+
342+
// Test with unreachable URL
343+
_, _, _, err = fetcher.FetchFromURL(context.Background(), "http://localhost:99999/nonexistent")
344+
assert.Error(t, err)
345+
}
346+
347+
func TestArtifactBinaryFetcher_RegularURL_Fallback(t *testing.T) {
348+
server := createTestServer()
349+
defer server.Close()
350+
351+
fetcher := NewArtifactBinaryFetcher(nil, nil)
352+
353+
// Test regular URL fallback (should work with nil clients since it uses binaryFetcher)
354+
body, contentType, filename, err := fetcher.FetchFromURL(context.Background(), server.URL+"/file-inline")
355+
assert.NoError(t, err)
356+
assert.Equal(t, []byte("PNG content"), body)
357+
assert.Equal(t, "text/plain", contentType)
358+
assert.Equal(t, "image.png", filename)
359+
}

0 commit comments

Comments
 (0)