diff --git a/internal/common/utils.go b/internal/common/utils.go index 0ffc21b..bd24440 100644 --- a/internal/common/utils.go +++ b/internal/common/utils.go @@ -1,8 +1,10 @@ package common import ( + "net/url" "path" "reflect" + "strings" "time" ) @@ -18,7 +20,14 @@ func PrettyTime(t time.Time) string { // CanonicalURL returns a canonical URL for the given path. func CanonicalURL(isDir bool, p ...string) string { - s := path.Join(p...) + // URL-encode each path segment so that special characters like '#' + // are properly escaped (e.g. '#' becomes '%23'). + joined := path.Join(p...) + segments := strings.Split(joined, "/") + for i, seg := range segments { + segments[i] = url.PathEscape(seg) + } + s := strings.Join(segments, "/") if isDir { s += "/" diff --git a/internal/common/utils_test.go b/internal/common/utils_test.go new file mode 100644 index 0000000..2fa4226 --- /dev/null +++ b/internal/common/utils_test.go @@ -0,0 +1,96 @@ +package common + +import ( + "testing" +) + +func TestCanonicalURL(t *testing.T) { + tests := []struct { + name string + isDir bool + parts []string + expect string + }{ + { + name: "simple file", + isDir: false, + parts: []string{"/files", "readme.txt"}, + expect: "/files/readme.txt", + }, + { + name: "simple directory", + isDir: true, + parts: []string{"/files", "subdir"}, + expect: "/files/subdir/", + }, + { + name: "pound sign in filename", + isDir: false, + parts: []string{"/files", "track#1.mp3"}, + expect: "/files/track%231.mp3", + }, + { + name: "pound sign in directory", + isDir: true, + parts: []string{"/files", "C# Projects"}, + expect: "/files/C%23%20Projects/", + }, + { + name: "space in filename", + isDir: false, + parts: []string{"/files", "my file.txt"}, + expect: "/files/my%20file.txt", + }, + { + name: "question mark in filename", + isDir: false, + parts: []string{"/files", "what?.txt"}, + expect: "/files/what%3F.txt", + }, + { + name: "percent in filename", + isDir: false, + parts: []string{"/files", "100%.txt"}, + expect: "/files/100%25.txt", + }, + { + name: "multiple special chars", + isDir: false, + parts: []string{"/docs", "file #2 (copy).txt"}, + expect: "/docs/file%20%232%20%28copy%29.txt", + }, + { + name: "nested path with pound", + isDir: false, + parts: []string{"/music", "artist#name", "track#1.mp3"}, + expect: "/music/artist%23name/track%231.mp3", + }, + { + name: "root directory file", + isDir: false, + parts: []string{"/", "file.txt"}, + expect: "/file.txt", + }, + { + name: "single segment file", + isDir: false, + parts: []string{"file.txt"}, + expect: "file.txt", + }, + { + name: "no special chars", + isDir: false, + parts: []string{"/path", "to", "file.txt"}, + expect: "/path/to/file.txt", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := CanonicalURL(tt.isDir, tt.parts...) + if got != tt.expect { + t.Errorf("CanonicalURL(%v, %v) = %q, want %q", tt.isDir, tt.parts, got, tt.expect) + } + }) + } +}