From f7a600e81c43d3061b5aa3fd6d72d907ada7e8b8 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Sat, 2 May 2026 14:25:44 +0100 Subject: [PATCH] Percent-encode VERS metacharacters in version strings in ToVersString Version strings containing | or operator characters (>, <, =, !) are output verbatim by ToVersString, causing Parse(ToVersString(r)) to split or interpret them as constraint boundaries. Encodes these characters in the output and decodes them when parsing constraints, so roundtripping preserves the original range. --- constraint.go | 7 +++++++ parser.go | 23 +++++++++++++++++++---- parser_test.go | 20 ++++++++++++++++++++ 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/constraint.go b/constraint.go index 408d11d..130e074 100644 --- a/constraint.go +++ b/constraint.go @@ -2,6 +2,7 @@ package vers import ( "fmt" + "net/url" "regexp" "strings" ) @@ -40,6 +41,9 @@ func parseConstraintWithScheme(s, scheme string) (*Constraint, error) { if version == "" { return nil, fmt.Errorf("invalid constraint format: %s", s) } + if decoded, err := url.PathUnescape(version); err == nil { + version = decoded + } if !preserveVPrefix { version = stripVPrefix(version) } @@ -48,6 +52,9 @@ func parseConstraintWithScheme(s, scheme string) (*Constraint, error) { // No operator found, treat as exact match version := s + if decoded, err := url.PathUnescape(version); err == nil { + version = decoded + } if !preserveVPrefix { version = stripVPrefix(s) } diff --git a/parser.go b/parser.go index 7f5bc9f..2f58aed 100644 --- a/parser.go +++ b/parser.go @@ -83,7 +83,7 @@ func (p *Parser) ToVersString(r *Range, scheme string) string { if interval.Min == interval.Max && interval.MinInclusive && interval.MaxInclusive && interval.Min != "" { // Exact version - no operator needed per VERS spec constraints = append(constraints, constraintWithVersion{ - str: normalizeVersion(interval.Min, scheme), + str: encodeVersVersion(normalizeVersion(interval.Min, scheme)), sortKey: interval.Min, }) } else { @@ -93,7 +93,7 @@ func (p *Parser) ToVersString(r *Range, scheme string) string { op = ">=" } constraints = append(constraints, constraintWithVersion{ - str: op + normalizeVersion(interval.Min, scheme), + str: op + encodeVersVersion(normalizeVersion(interval.Min, scheme)), sortKey: interval.Min, }) } @@ -103,7 +103,7 @@ func (p *Parser) ToVersString(r *Range, scheme string) string { op = "<=" } constraints = append(constraints, constraintWithVersion{ - str: op + normalizeVersion(interval.Max, scheme), + str: op + encodeVersVersion(normalizeVersion(interval.Max, scheme)), sortKey: interval.Max, }) } @@ -113,7 +113,7 @@ func (p *Parser) ToVersString(r *Range, scheme string) string { // Add exclusions for _, exc := range r.Exclusions { constraints = append(constraints, constraintWithVersion{ - str: "!=" + normalizeVersion(exc, scheme), + str: "!=" + encodeVersVersion(normalizeVersion(exc, scheme)), sortKey: exc, }) } @@ -147,6 +147,21 @@ func sortConstraintsByVersion(constraints []constraintWithVersion) { } } +var versMetaEncoder = strings.NewReplacer( + "|", "%7C", + ">", "%3E", + "<", "%3C", + "=", "%3D", + "!", "%21", + "/", "%2F", + "*", "%2A", + " ", "%20", +) + +func encodeVersVersion(v string) string { + return versMetaEncoder.Replace(v) +} + // normalizeVersion normalizes a version string for output. // For semver-based schemes, this ensures 3-part versions (1.1 -> 1.1.0). func normalizeVersion(version, scheme string) string { diff --git a/parser_test.go b/parser_test.go index 7eaed8e..24c5080 100644 --- a/parser_test.go +++ b/parser_test.go @@ -509,6 +509,26 @@ func TestToVersString(t *testing.T) { } } +func TestToVersStringEncodesMetacharacters(t *testing.T) { + parser := NewParser() + + // A version string containing a VERS separator should be percent-encoded + r := Exact("1.0|2.0") + got := parser.ToVersString(r, "deb") + if strings.Contains(got, "|2.0") && !strings.Contains(got, "%7C") { + t.Errorf("ToVersString should encode | in version, got %q", got) + } + + // Roundtrip: parse the encoded output and check containment + parsed, err := parser.Parse(got) + if err != nil { + t.Fatalf("Parse(ToVersString()) failed: %v", err) + } + if !parsed.Contains("1.0|2.0") { + t.Error("roundtrip should preserve exact version with metacharacter") + } +} + func TestPublicAPISatisfies(t *testing.T) { tests := []struct { name string