diff --git a/internal/templater/funcs.go b/internal/templater/funcs.go index 2fe85b96b0..0ffd233dc0 100644 --- a/internal/templater/funcs.go +++ b/internal/templater/funcs.go @@ -3,8 +3,8 @@ package templater import ( "maps" "math/rand/v2" + "net/url" "os" - "path" "path/filepath" "runtime" "strings" @@ -103,8 +103,13 @@ func joinEnv(elem ...string) string { return strings.Join(elem, string(os.PathListSeparator)) } -func joinUrl(elem ...string) string { - return path.Join(elem...) +func joinUrl(elem ...string) (string, error) { + if len(elem) == 0 { + return "", nil + } + // Use net/url.JoinPath rather than path.Join: the latter runs path.Clean, + // which collapses the "//" in a URL scheme (e.g. "http://" -> "http:/"). + return url.JoinPath(elem[0], elem[1:]...) } func merge(base map[string]any, v ...map[string]any) map[string]any { diff --git a/internal/templater/funcs_test.go b/internal/templater/funcs_test.go new file mode 100644 index 0000000000..62c288b702 --- /dev/null +++ b/internal/templater/funcs_test.go @@ -0,0 +1,27 @@ +package templater + +import "testing" + +func TestJoinUrl(t *testing.T) { + for _, tt := range []struct { + name string + elem []string + want string + }{ + // The URL scheme's "//" must be preserved, not collapsed by path.Clean. + {"scheme preserved", []string{"http://localhost", "path1", "path2"}, "http://localhost/path1/path2"}, + {"https with base path", []string{"https://example.com/api", "v1", "users"}, "https://example.com/api/v1/users"}, + {"trailing slash on base", []string{"http://localhost/", "path"}, "http://localhost/path"}, + {"single element", []string{"http://localhost"}, "http://localhost/"}, + } { + t.Run(tt.name, func(t *testing.T) { + got, err := joinUrl(tt.elem...) + if err != nil { + t.Fatalf("joinUrl(%q) unexpected error: %v", tt.elem, err) + } + if got != tt.want { + t.Errorf("joinUrl(%q) = %q; want %q", tt.elem, got, tt.want) + } + }) + } +}