Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion components/execd/pkg/web/controller/filesystem_download.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
Expand Down Expand Up @@ -55,7 +56,7 @@ func (c *FilesystemController) DownloadFile() {
}

c.ctx.Header("Content-Type", "application/octet-stream")
c.ctx.Header("Content-Disposition", "attachment; filename="+filepath.Base(filePath))
c.ctx.Header("Content-Disposition", formatContentDisposition(filepath.Base(filePath)))
c.ctx.Header("Content-Length", strconv.FormatInt(fileInfo.Size(), 10))

if rangeHeader := c.ctx.GetHeader("Range"); rangeHeader != "" {
Expand All @@ -81,3 +82,25 @@ func (c *FilesystemController) DownloadFile() {

http.ServeContent(c.ctx.Writer, c.ctx.Request, filepath.Base(filePath), fileInfo.ModTime(), file)
}

// formatContentDisposition formats the Content-Disposition header value with proper
// encoding for non-ASCII filenames according to RFC 6266 and RFC 5987.
func formatContentDisposition(filename string) string {
// Check if filename contains non-ASCII characters
needsEncoding := false
for _, r := range filename {
if r > 127 {
needsEncoding = true
break
}
}

if !needsEncoding {
return "attachment; filename=\"" + filename + "\""
}

// Use RFC 5987 encoding for non-ASCII filenames
// Format: attachment; filename="fallback"; filename*=UTF-8''encoded_name
encodedFilename := url.PathEscape(filename)
return "attachment; filename=\"" + encodedFilename + "\"; filename*=UTF-8''" + encodedFilename
}
41 changes: 41 additions & 0 deletions components/execd/pkg/web/controller/filesystem_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,44 @@ func TestReplaceContentFailsUnknownFile(t *testing.T) {

require.Contains(t, []int{http.StatusNotFound, http.StatusInternalServerError}, rec.Code, "expected failure status")
}

func TestFormatContentDisposition(t *testing.T) {
tests := []struct {
name string
filename string
want string
}{
{
name: "ASCII filename",
filename: "test.txt",
want: "attachment; filename=\"test.txt\"",
},
{
name: "Chinese filename",
filename: "测试文件.txt",
want: "attachment; filename=\"%E6%B5%8B%E8%AF%95%E6%96%87%E4%BB%B6.txt\"; filename*=UTF-8''%E6%B5%8B%E8%AF%95%E6%96%87%E4%BB%B6.txt",
},
{
name: "Japanese filename",
filename: "テスト.txt",
want: "attachment; filename=\"%E3%83%86%E3%82%B9%E3%83%88.txt\"; filename*=UTF-8''%E3%83%86%E3%82%B9%E3%83%88.txt",
},
{
name: "Special characters in filename",
filename: "file with spaces.txt",
want: "attachment; filename=\"file with spaces.txt\"",
},
{
name: "Mixed ASCII and non-ASCII",
filename: "report-报告.pdf",
want: "attachment; filename=\"report-%E6%8A%A5%E5%91%8A.pdf\"; filename*=UTF-8''report-%E6%8A%A5%E5%91%8A.pdf",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := formatContentDisposition(tt.filename)
require.Equal(t, tt.want, got)
})
}
}
Loading