diff --git a/pkg/cmd/release/create/create_test.go b/pkg/cmd/release/create/create_test.go index 07044c83..864ae37e 100644 --- a/pkg/cmd/release/create/create_test.go +++ b/pkg/cmd/release/create/create_test.go @@ -2422,6 +2422,13 @@ 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`}, + // Literal backslashes pass through unescaped (wire compatibility with pre-FD-135 servers) + {name: "pkg-with-backslash", input: &packages.PackageVersionOverride{PackageID: `foo\bar`, Version: "1.0"}, expect: `foo\bar:1.0`}, } for _, test := range tests { @@ -2455,6 +2462,20 @@ 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"}}, + // Backslash before a non-escapable char must be preserved verbatim (no silent strip) + {input: `foo\bar:1.0`, expect: &packages.AmbiguousPackageVersionOverride{ActionNameOrPackageID: `foo\bar`, Version: "1.0"}}, + {input: `path\to\step:1.0`, expect: &packages.AmbiguousPackageVersionOverride{ActionNameOrPackageID: `path\to\step`, Version: "1.0"}}, + {input: `step:foo\bar:1.0`, expect: &packages.AmbiguousPackageVersionOverride{ActionNameOrPackageID: "step", PackageReferenceName: `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..3eff889a 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,65 @@ 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 (:, /, =). +// A backslash escapes the next char only when that char is one of : / = \ ; any other "\X" sequence is preserved +// literally so existing inputs containing backslashes are unaffected. +// 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 + + for i := 0; i < len(s); i++ { + ch := s[i] + if ch == '\\' && i+1 < len(s) && isEscapableDelimiter(s[i+1]) { + i++ + continue + } + if ch == ':' || ch == '/' || ch == '=' { + spans = append(spans, span{start, i}) + start = i + 1 + } + } + 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 { + var result strings.Builder + result.Grow(len(s)) + for i := 0; i < len(s); i++ { + if s[i] == '\\' && i+1 < len(s) && isEscapableDelimiter(s[i+1]) { + result.WriteByte(s[i+1]) + i++ + continue + } + result.WriteByte(s[i]) + } + return result.String() +} + +func escapePackageDelimiters(s string) string { + var result strings.Builder + for _, ch := range s { + if ch == ':' || ch == '/' || ch == '=' { + result.WriteRune('\\') + } + result.WriteRune(ch) + } + return result.String() +} + +func isEscapableDelimiter(b byte) bool { + return b == ':' || b == '/' || b == '=' || b == '\\' } // AmbiguousPackageVersionOverride tells us that we want to set the version of some package to `Version`