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