From 6756d82ffa9a17c20266baa686ba3af2ca609a1b Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 5 Jan 2026 21:46:50 +0800 Subject: [PATCH] fix: use PathUnescape for filename URL decoding (#1214) --- internal/protocol/http/fetcher.go | 14 +++++++------ internal/protocol/http/fetcher_test.go | 26 ++++++++++++++++++++++++ internal/test/httptest.go | 23 +++++++++++++++++++++ pkg/util/url.go | 13 ++++++++++++ pkg/util/url_test.go | 28 ++++++++++++++++++++++++++ 5 files changed, 98 insertions(+), 6 deletions(-) diff --git a/internal/protocol/http/fetcher.go b/internal/protocol/http/fetcher.go index 3a408a93..8f552d5b 100644 --- a/internal/protocol/http/fetcher.go +++ b/internal/protocol/http/fetcher.go @@ -171,9 +171,9 @@ func (f *Fetcher) Resolve(req *base.Request) error { // get file filePath by URL if file.Name == "" { file.Name = path.Base(httpReq.URL.Path) - // Url decode + // Url decode - use PathUnescape for paths to handle %2B correctly if file.Name != "" { - file.Name, _ = url.QueryUnescape(file.Name) + file.Name, _ = url.PathUnescape(file.Name) } } // unknown file filePath @@ -656,7 +656,8 @@ func parseFilenameExtended(cd string) string { if len(parts) == 2 { // parts[0] is charset (e.g., "UTF-8") // parts[1] is percent-encoded value - decoded, err := url.QueryUnescape(parts[1]) + // Use PathUnescape to handle %2B correctly (should decode to +, not space) + decoded, err := url.PathUnescape(parts[1]) if err == nil { return decoded } @@ -665,7 +666,8 @@ func parseFilenameExtended(cd string) string { // Try with single quote delimiter as well (some servers use this) parts = strings.SplitN(value, "'", 3) if len(parts) >= 3 { - decoded, err := url.QueryUnescape(parts[2]) + // Use PathUnescape to handle %2B correctly (should decode to +, not space) + decoded, err := url.PathUnescape(parts[2]) if err == nil { return decoded } @@ -687,8 +689,8 @@ func decodeFilenameParam(filename string) string { } } - // Try URL decoding - decoded := util.TryUrlQueryUnescape(filename) + // Try URL decoding - use PathUnescape for filenames to handle %2B correctly + decoded := util.TryUrlPathUnescape(filename) // Check if the result is valid UTF-8. If not, try GBK decoding. // This handles the case where Chinese Windows servers send GBK-encoded filenames diff --git a/internal/protocol/http/fetcher_test.go b/internal/protocol/http/fetcher_test.go index cf6575cd..d6bb59ed 100644 --- a/internal/protocol/http/fetcher_test.go +++ b/internal/protocol/http/fetcher_test.go @@ -113,6 +113,32 @@ func TestFetcher_Resolve(t *testing.T) { }, }, }, t) + // Test filename with plus signs (e.g., C++ Primer) + // Before fix: %2B decoded to space -> "C++ Primer" became "C Primer" + // After fix: %2B correctly decoded to + -> "C++ Primer Plus.mobi" + testResolve(test.StartTestCustomServer, "plus-sign-encoded", &base.Resource{ + Size: test.BuildSize, + Range: false, + Files: []*base.FileInfo{ + { + Name: "C++ Primer Plus.mobi", + Size: test.BuildSize, + }, + }, + }, t) + // Test filename with plus sign in URL path + // Before fix: %2B decoded to space + // After fix: %2B correctly decoded to + + testResolve(test.StartTestCustomServer, "C%2B%2B%20Primer.txt", &base.Resource{ + Size: 0, + Range: false, + Files: []*base.FileInfo{ + { + Name: "C++ Primer.txt", + Size: 0, + }, + }, + }, t) // Test URL without file path - should use domain/host as filename listener := test.StartTestRootServer() diff --git a/internal/test/httptest.go b/internal/test/httptest.go index 4c5c90d8..2432d8c5 100644 --- a/internal/test/httptest.go +++ b/internal/test/httptest.go @@ -169,6 +169,29 @@ func StartTestCustomServer() net.Listener { defer file.Close() io.Copy(writer, file) }) + // Test endpoint for filenames with plus signs (C++ files, etc.) + // This tests that %2B decodes to + not space + mux.HandleFunc("/plus-sign-encoded", func(writer http.ResponseWriter, request *http.Request) { + // Use filename*= format with %2B encoding for plus signs + writer.Header().Set("Content-Disposition", `attachment; filename*=UTF-8''C%2B%2B%20%20Primer%20%20Plus.mobi`) + writer.Header().Set("Content-Type", "application/octet-stream") + writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize)) + file, err := os.Open(BuildFile) + if err != nil { + panic(err) + } + defer file.Close() + io.Copy(writer, file) + }) + // Test endpoint for plus sign in URL path + mux.HandleFunc("/C%2B%2B%20Primer.txt", func(writer http.ResponseWriter, request *http.Request) { + file, err := os.Open(BuildFile) + if err != nil { + panic(err) + } + defer file.Close() + io.Copy(writer, file) + }) return mux }) } diff --git a/pkg/util/url.go b/pkg/util/url.go index 6007469b..bdd9894b 100644 --- a/pkg/util/url.go +++ b/pkg/util/url.go @@ -66,3 +66,16 @@ func TryUrlQueryUnescape(s string) string { } return s } + +// TryUrlPathUnescape tries to unescape a URL path-encoded string. +// Unlike QueryUnescape, PathUnescape does not treat '+' as a space. +// This is the correct function to use for decoding URL paths and filenames +// where %2B should decode to '+', not to a space. +// +// If unescaping fails, it returns the original string. +func TryUrlPathUnescape(s string) string { + if decoded, err := url.PathUnescape(s); err == nil { + return decoded + } + return s +} diff --git a/pkg/util/url_test.go b/pkg/util/url_test.go index 74ee75d4..f5f2e2f2 100644 --- a/pkg/util/url_test.go +++ b/pkg/util/url_test.go @@ -149,3 +149,31 @@ func TestTryURLDecode(t *testing.T) { }) } } + +func TestTryUrlPathUnescape(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"normal.txt", "normal.txt"}, + {"%E7%8F%80%E5%B0%94%E8%AF%BA.zip", "珀尔诺.zip"}, + {"hello%20world.txt", "hello world.txt"}, + {"bad%2-text", "bad%2-text"}, + // The key difference: %2B should decode to + (not space) + {"C%2B%2B%20Primer.txt", "C++ Primer.txt"}, + {"test%2Bfile.txt", "test+file.txt"}, + // Plus sign in path should remain as-is (not decoded to space) + {"test+plus.txt", "test+plus.txt"}, + // Mixed encoding + {"C%2B%2B%20%20Primer%20%20Plus.mobi", "C++ Primer Plus.mobi"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := TryUrlPathUnescape(tt.input) + if got != tt.expected { + t.Errorf("TryUrlPathUnescape(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +}