Skip to content

Commit 6afe4f9

Browse files
stainless-app[bot]batuhan
authored andcommitted
feat: add support for file downloads from binary response endpoints
1 parent 5a8537c commit 6afe4f9

2 files changed

Lines changed: 214 additions & 0 deletions

File tree

pkg/cmd/cmdutil.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ import (
66
"fmt"
77
"io"
88
"log"
9+
"mime"
910
"net/http"
1011
"net/http/httputil"
1112
"os"
1213
"os/exec"
1314
"os/signal"
15+
"path/filepath"
1416
"strings"
1517
"syscall"
1618

@@ -154,6 +156,108 @@ func streamToStdout(generateOutput func(w *os.File) error) error {
154156
return err
155157
}
156158

159+
func writeBinaryResponse(response *http.Response, outfile string) (string, error) {
160+
defer response.Body.Close()
161+
body, err := io.ReadAll(response.Body)
162+
if err != nil {
163+
return "", err
164+
}
165+
switch outfile {
166+
case "-", "/dev/stdout":
167+
_, err := os.Stdout.Write(body)
168+
return "", err
169+
case "":
170+
// If output file is unspecified, then print to stdout for plain text or
171+
// if stdout is not a terminal:
172+
if !isTerminal(os.Stdout) || isUTF8TextFile(body) {
173+
_, err := os.Stdout.Write(body)
174+
return "", err
175+
}
176+
177+
// If response has a suggested filename in the content-disposition
178+
// header, then use that (with an optional suffix to ensure uniqueness):
179+
file, err := createDownloadFile(response, body)
180+
if err != nil {
181+
return "", err
182+
}
183+
defer file.Close()
184+
if _, err := file.Write(body); err != nil {
185+
return "", err
186+
}
187+
return fmt.Sprintf("Wrote output to: %s", file.Name()), nil
188+
default:
189+
if err := os.WriteFile(outfile, body, 0644); err != nil {
190+
return "", err
191+
}
192+
return fmt.Sprintf("Wrote output to: %s", outfile), nil
193+
}
194+
}
195+
196+
// Return a writable file handle to a new file, which attempts to choose a good filename
197+
// based on the Content-Disposition header or sniffing the MIME filetype of the response.
198+
func createDownloadFile(response *http.Response, data []byte) (*os.File, error) {
199+
filename := "file"
200+
// If the header provided an output filename, use that
201+
disp := response.Header.Get("Content-Disposition")
202+
_, params, err := mime.ParseMediaType(disp)
203+
if err == nil {
204+
if dispFilename, ok := params["filename"]; ok {
205+
// Only use the last path component to prevent directory traversal
206+
filename = filepath.Base(dispFilename)
207+
// Try to create the file with exclusive flag to avoid race conditions
208+
file, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644)
209+
if err == nil {
210+
return file, nil
211+
}
212+
}
213+
}
214+
215+
// If file already exists, create a unique filename using CreateTemp
216+
ext := filepath.Ext(filename)
217+
if ext == "" {
218+
ext = guessExtension(data)
219+
}
220+
base := strings.TrimSuffix(filename, ext)
221+
return os.CreateTemp(".", base+"-*"+ext)
222+
}
223+
224+
func guessExtension(data []byte) string {
225+
ct := http.DetectContentType(data)
226+
227+
// Prefer common extensions over obscure ones
228+
switch ct {
229+
case "application/gzip":
230+
return ".gz"
231+
case "application/pdf":
232+
return ".pdf"
233+
case "application/zip":
234+
return ".zip"
235+
case "audio/mpeg":
236+
return ".mp3"
237+
case "image/bmp":
238+
return ".bmp"
239+
case "image/gif":
240+
return ".gif"
241+
case "image/jpeg":
242+
return ".jpg"
243+
case "image/png":
244+
return ".png"
245+
case "image/webp":
246+
return ".webp"
247+
case "video/mp4":
248+
return ".mp4"
249+
}
250+
251+
exts, err := mime.ExtensionsByType(ct)
252+
if err == nil && len(exts) > 0 {
253+
return exts[0]
254+
} else if isUTF8TextFile(data) {
255+
return ".txt"
256+
} else {
257+
return ".bin"
258+
}
259+
}
260+
157261
func shouldUseColors(w io.Writer) bool {
158262
force, ok := os.LookupEnv("FORCE_COLOR")
159263
if ok {

pkg/cmd/cmdutil_test.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
package cmd
22

33
import (
4+
"bytes"
5+
"io"
6+
"net/http"
47
"os"
8+
"path/filepath"
59
"testing"
10+
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
613
)
714

815
func TestStreamOutput(t *testing.T) {
@@ -15,3 +22,106 @@ func TestStreamOutput(t *testing.T) {
1522
t.Errorf("streamOutput failed: %v", err)
1623
}
1724
}
25+
26+
func TestWriteBinaryResponse(t *testing.T) {
27+
t.Run("write to explicit file", func(t *testing.T) {
28+
tmpDir := t.TempDir()
29+
outfile := tmpDir + "/output.txt"
30+
body := []byte("test content")
31+
resp := &http.Response{
32+
Body: io.NopCloser(bytes.NewReader(body)),
33+
}
34+
35+
msg, err := writeBinaryResponse(resp, outfile)
36+
37+
require.NoError(t, err)
38+
assert.Contains(t, msg, outfile)
39+
40+
content, err := os.ReadFile(outfile)
41+
require.NoError(t, err)
42+
assert.Equal(t, body, content)
43+
})
44+
45+
t.Run("write to stdout", func(t *testing.T) {
46+
oldStdout := os.Stdout
47+
r, w, _ := os.Pipe()
48+
os.Stdout = w
49+
50+
body := []byte("stdout content")
51+
resp := &http.Response{
52+
Body: io.NopCloser(bytes.NewReader(body)),
53+
}
54+
msg, err := writeBinaryResponse(resp, "-")
55+
56+
w.Close()
57+
os.Stdout = oldStdout
58+
59+
require.NoError(t, err)
60+
assert.Empty(t, msg)
61+
62+
var buf bytes.Buffer
63+
_, _ = buf.ReadFrom(r)
64+
assert.Equal(t, body, buf.Bytes())
65+
})
66+
}
67+
68+
func TestCreateDownloadFile(t *testing.T) {
69+
t.Run("creates file with filename from header", func(t *testing.T) {
70+
tmpDir := t.TempDir()
71+
oldWd, _ := os.Getwd()
72+
os.Chdir(tmpDir)
73+
defer os.Chdir(oldWd)
74+
75+
resp := &http.Response{
76+
Header: http.Header{
77+
"Content-Disposition": []string{`attachment; filename="test.txt"`},
78+
},
79+
}
80+
file, err := createDownloadFile(resp, []byte("test content"))
81+
require.NoError(t, err)
82+
defer file.Close()
83+
assert.Equal(t, "test.txt", filepath.Base(file.Name()))
84+
85+
// Create a second file with the same name to ensure it doesn't clobber the first
86+
resp2 := &http.Response{
87+
Header: http.Header{
88+
"Content-Disposition": []string{`attachment; filename="test.txt"`},
89+
},
90+
}
91+
file2, err := createDownloadFile(resp2, []byte("second content"))
92+
require.NoError(t, err)
93+
defer file2.Close()
94+
assert.NotEqual(t, file.Name(), file2.Name(), "second file should have a different name")
95+
assert.Contains(t, filepath.Base(file2.Name()), "test")
96+
})
97+
98+
t.Run("creates temp file when no header", func(t *testing.T) {
99+
tmpDir := t.TempDir()
100+
oldWd, _ := os.Getwd()
101+
os.Chdir(tmpDir)
102+
defer os.Chdir(oldWd)
103+
104+
resp := &http.Response{Header: http.Header{}}
105+
file, err := createDownloadFile(resp, []byte("test content"))
106+
require.NoError(t, err)
107+
defer file.Close()
108+
assert.Contains(t, filepath.Base(file.Name()), "file-")
109+
})
110+
111+
t.Run("prevents directory traversal", func(t *testing.T) {
112+
tmpDir := t.TempDir()
113+
oldWd, _ := os.Getwd()
114+
os.Chdir(tmpDir)
115+
defer os.Chdir(oldWd)
116+
117+
resp := &http.Response{
118+
Header: http.Header{
119+
"Content-Disposition": []string{`attachment; filename="../../../etc/passwd"`},
120+
},
121+
}
122+
file, err := createDownloadFile(resp, []byte("test content"))
123+
require.NoError(t, err)
124+
defer file.Close()
125+
assert.Equal(t, "passwd", filepath.Base(file.Name()))
126+
})
127+
}

0 commit comments

Comments
 (0)