diff --git a/pkg/cmd/release/create/create_test.go b/pkg/cmd/release/create/create_test.go index 07044c83..b6a78454 100644 --- a/pkg/cmd/release/create/create_test.go +++ b/pkg/cmd/release/create/create_test.go @@ -2422,6 +2422,11 @@ func TestReleaseCreate_ToPackageOverrideString(t *testing.T) { {name: "action-ref-ver", input: &packages.PackageVersionOverride{PackageReferenceName: "pterm-on-install", ActionName: "Install", Version: "6.1.2"}, expect: "Install:pterm-on-install:6.1.2"}, {name: "star-ref-ver", input: &packages.PackageVersionOverride{PackageReferenceName: "pterm-on-install", Version: "6.1.2"}, expect: "*:pterm-on-install:6.1.2"}, {name: "pkg-action-ref-ver", input: &packages.PackageVersionOverride{PackageReferenceName: "pterm", PackageID: "pterm", ActionName: "Install", Version: "1.2.3"}, expect: "pterm:pterm:1.2.3"}, + // Maven package IDs with colons get escaped (FD-135) + {name: "maven-pkg-ver", input: &packages.PackageVersionOverride{PackageID: "com.yourcompany:project-name", Version: "1.0"}, expect: `com.yourcompany\:project-name:1.0`}, + {name: "maven-pkg-ref-ver", input: &packages.PackageVersionOverride{PackageID: "com.juliusbaer.fi-master:deployment", PackageReferenceName: "ref", Version: "25.2026.04.1"}, expect: `com.juliusbaer.fi-master\:deployment:ref:25.2026.04.1`}, + // Step name with slash gets escaped + {name: "step-slash-ver", input: &packages.PackageVersionOverride{ActionName: "Deploy Templates/templates", Version: "1.0"}, expect: `Deploy Templates\/templates:1.0`}, } for _, test := range tests { @@ -2455,6 +2460,16 @@ func TestReleaseCreate_ParsePackageOverrideString(t *testing.T) { {input: "pterm/Push Package=9.7-pre-xyz", expect: &packages.AmbiguousPackageVersionOverride{PackageReferenceName: "Push Package", ActionNameOrPackageID: "pterm", Version: "9.7-pre-xyz"}}, {input: "pterm=Push Package/9.7-pre-xyz", expect: &packages.AmbiguousPackageVersionOverride{PackageReferenceName: "Push Package", ActionNameOrPackageID: "pterm", Version: "9.7-pre-xyz"}}, + // Maven packages with escaped colons (FD-135) + {input: `com.yourcompany\:project-name:1.0-SNAPSHOT`, expect: &packages.AmbiguousPackageVersionOverride{ActionNameOrPackageID: "com.yourcompany:project-name", Version: "1.0-SNAPSHOT"}}, + {input: `com.juliusbaer.fi-master\:deployment:25.2026.04.1`, expect: &packages.AmbiguousPackageVersionOverride{ActionNameOrPackageID: "com.juliusbaer.fi-master:deployment", Version: "25.2026.04.1"}}, + // Maven package with escaped colon and package reference name + {input: `com.yourcompany\:project-name:ref:1.0`, expect: &packages.AmbiguousPackageVersionOverride{ActionNameOrPackageID: "com.yourcompany:project-name", PackageReferenceName: "ref", Version: "1.0"}}, + // Step name with escaped slash (additional packages) + {input: `Deploy Templates\/templates:1.0`, expect: &packages.AmbiguousPackageVersionOverride{ActionNameOrPackageID: "Deploy Templates/templates", Version: "1.0"}}, + // Escaped backslash + {input: `foo\\bar:1.0`, expect: &packages.AmbiguousPackageVersionOverride{ActionNameOrPackageID: `foo\bar`, Version: "1.0"}}, + {input: "", expectErr: errors.New("empty package version specification")}, // bare identifiers aren't valid diff --git a/pkg/packages/packages.go b/pkg/packages/packages.go index 4b8b4767..6f453ebf 100644 --- a/pkg/packages/packages.go +++ b/pkg/packages/packages.go @@ -12,7 +12,6 @@ import ( "github.com/MakeNowJust/heredoc/v2" "github.com/OctopusDeploy/cli/pkg/output" "github.com/OctopusDeploy/cli/pkg/question" - "github.com/OctopusDeploy/cli/pkg/util" octopusApiClient "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/feeds" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/releases" @@ -193,11 +192,11 @@ type PackageVersionOverride struct { func (p *PackageVersionOverride) ToPackageOverrideString() string { components := make([]string, 0, 3) - // stepNameOrPackageID always comes first if we have it + // stepNameOrPackageID always comes first if we have it; escape delimiter chars so the server can parse correctly if p.PackageID != "" { - components = append(components, p.PackageID) + components = append(components, escapePackageDelimiters(p.PackageID)) } else if p.ActionName != "" { // can't have both PackageID and ActionName; PackageID wins - components = append(components, p.ActionName) + components = append(components, escapePackageDelimiters(p.ActionName)) } // followed by package reference name if we have it @@ -205,7 +204,7 @@ func (p *PackageVersionOverride) ToPackageOverrideString() string { if len(components) == 0 { // if we have an explicit packagereference but no packageId or action, we need to express it with *:ref:version components = append(components, "*") } - components = append(components, p.PackageReferenceName) + components = append(components, escapePackageDelimiters(p.PackageReferenceName)) } if len(components) == 0 { // the server can't deal with just a number by itself; if we want to override everything we must pass *:Version @@ -216,12 +215,62 @@ func (p *PackageVersionOverride) ToPackageOverrideString() string { return strings.Join(components, ":") } -// splitPackageOverrideString splits the input string into components based on delimiter characters. -// we want to pick up empty entries here; so "::5" and ":pterm:5" should both return THREE components, rather than one or two -// and we want to allow for multiple different delimeters. -// neither the builtin golang strings.Split or strings.FieldsFunc support this. Logic borrowed from strings.FieldsFunc with heavy modifications +// splitPackageOverrideString splits the input string into components based on delimiter characters (:, /, =). +// Supports escaping delimiters with a backslash (e.g. \: for a literal colon in Maven package IDs). +// Empty entries are preserved; "::5" and ":pterm:5" both return THREE components. func splitPackageOverrideString(s string) []string { - return util.SplitString(s, []int32{':', '/', '='}) + type span struct { + start int + end int + } + spans := make([]span, 0, 3) + start := 0 + escaped := false + + for idx, ch := range s { + if ch == '\\' && !escaped { + escaped = true + continue + } + if (ch == ':' || ch == '/' || ch == '=') && !escaped { + spans = append(spans, span{start, idx}) + start = idx + 1 + } else { + escaped = false + } + } + spans = append(spans, span{start, len(s)}) + + a := make([]string, len(spans)) + for i, span := range spans { + a[i] = unescapePackageString(s[span.start:span.end]) + } + return a +} + +func unescapePackageString(s string) string { + result := make([]rune, 0, len(s)) + escaped := false + for _, ch := range s { + if ch == '\\' && !escaped { + escaped = true + continue + } + result = append(result, ch) + escaped = false + } + return string(result) +} + +func escapePackageDelimiters(s string) string { + var result strings.Builder + for _, ch := range s { + if ch == ':' || ch == '/' || ch == '=' || ch == '\\' { + result.WriteRune('\\') + } + result.WriteRune(ch) + } + return result.String() } // AmbiguousPackageVersionOverride tells us that we want to set the version of some package to `Version`