From bb74d0e7f1a0a574d58c097d74b8fc7d0512c021 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Wed, 16 Sep 2026 09:20:45 +0100 Subject: [PATCH] Bump vers-spec submodule and track upstream spec changes Strict parse now rejects empty constraints, explicit '=' and encoded non-space whitespace; intdot compare ignores non-numeric suffixes. The lenient literal space is confined to the constraints, so a scheme with a space stays an error in both modes. Add gitsubmodule to dependabot so future bumps arrive as PRs. --- .github/dependabot.yml | 8 ++++++++ parser.go | 29 +++++++++++++++++++++++++++- parser_test.go | 44 ++++++++++++++++++++++++++++++++++++++++++ schemes.go | 10 ++++++++++ schemes_test.go | 3 +++ testdata/vers-spec | 2 +- 6 files changed, 94 insertions(+), 2 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 8a19728..180d94d 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -15,3 +15,11 @@ updates: open-pull-requests-limit: 5 cooldown: default-days: 7 + + - package-ecosystem: gitsubmodule + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + cooldown: + default-days: 7 diff --git a/parser.go b/parser.go index af0ecf1..b10fcdc 100644 --- a/parser.go +++ b/parser.go @@ -28,7 +28,7 @@ func (p *Parser) parseVersURI(versURI string, requireCanonicalOrder bool) (*Rang if !strings.HasPrefix(versURI, prefix) { return nil, fmt.Errorf("invalid vers URI format: %s", versURI) } - if strings.ContainsAny(versURI, " \t\r\n") { + if strings.ContainsAny(versURI, "\t\r\n") { return nil, fmt.Errorf("non-canonical VERS: whitespace is not permitted") } remainder := versURI[len(prefix):] @@ -40,6 +40,12 @@ func (p *Parser) parseVersURI(versURI string, requireCanonicalOrder bool) (*Rang scheme := remainder[:slash] constraintsStr := remainder[slash+1:] + if strings.ContainsRune(scheme, ' ') || (requireCanonicalOrder && strings.ContainsRune(constraintsStr, ' ')) { + return nil, fmt.Errorf("non-canonical VERS: whitespace is not permitted") + } + if constraintsStr == "" && requireCanonicalOrder { + return nil, fmt.Errorf("non-canonical VERS: constraints must not be empty") + } // Handle wildcard for unbounded range if constraintsStr == "*" || constraintsStr == "" { r := Unbounded() @@ -67,6 +73,9 @@ func validateVersConstraints(constraints, scheme string, requireCanonicalOrder b var previous *Constraint previousRaw := "" for _, raw := range strings.Split(constraints, "|") { + if requireCanonicalOrder && len(raw) > 0 && raw[0] == '=' { + return fmt.Errorf("non-canonical VERS: explicit equality comparator is not permitted") + } operator := constraintOperator(raw) version := raw[len(operator):] if err := validateVersVersion(version, scheme); err != nil { @@ -98,6 +107,9 @@ func validateVersVersion(version, scheme string) error { if isLowerASCIIHex(version[i+1]) || isLowerASCIIHex(version[i+2]) { return fmt.Errorf("non-canonical VERS: percent-encoding in version is not canonical") } + if b := hexByte(version[i+1], version[i+2]); b == '\t' || b == '\r' || b == '\n' { + return fmt.Errorf("non-canonical VERS: whitespace is not permitted") + } i += 2 } @@ -121,6 +133,21 @@ func isASCIIHex(c byte) bool { return isASCIIDigit(c) || c >= 'A' && c <= 'F' || isLowerASCIIHex(c) } +func hexByte(hi, lo byte) byte { + return hexNibble(hi)<<4 | hexNibble(lo) +} + +func hexNibble(c byte) byte { + switch { + case isASCIIDigit(c): + return c - '0' + case c >= 'A' && c <= 'F': + return c - 'A' + 10 + default: + return c - 'a' + 10 + } +} + func isLowerASCIIHex(c byte) bool { return c >= 'a' && c <= 'f' } diff --git a/parser_test.go b/parser_test.go index 3923688..28ad237 100644 --- a/parser_test.go +++ b/parser_test.go @@ -60,6 +60,50 @@ func TestParseVersURI(t *testing.T) { } } +func TestParseVersURIStrict(t *testing.T) { + tests := []struct { + input string + wantErr string + }{ + {"vers:npm/", "constraints must not be empty"}, + {"vers:npm/=1.0.0", "explicit equality comparator"}, + {"vers:npm/==1.0.0", "explicit equality comparator"}, + {"vers:lexicographic/1%092", "whitespace is not permitted"}, + {"vers:lexicographic/1 2", "whitespace is not permitted"}, + {"vers:np m/1.0.0", "whitespace is not permitted"}, + {"vers:lexicographic/1%202", ""}, + {"vers:npm/1.0.0", ""}, + } + for _, tt := range tests { + _, err := defaultParser.parseVersURI(tt.input, true) + if tt.wantErr == "" { + if err != nil { + t.Errorf("parseVersURI(%q, strict) error = %v, want nil", tt.input, err) + } + } else if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("parseVersURI(%q, strict) error = %v, want substring %q", tt.input, err, tt.wantErr) + } + } +} + +func TestParseVersURILenientSpace(t *testing.T) { + r, err := defaultParser.parseVersURI("vers:lexicographic/1 2", false) + if err != nil { + t.Fatalf("lenient parse of literal space failed: %v", err) + } + if got := ToVersString(r, r.Scheme); got != "vers:lexicographic/1%202" { + t.Errorf("roundtrip = %q, want vers:lexicographic/1%%202", got) + } + + // Only the constraints may carry a literal space; a scheme with one has no + // canonical form to re-emit. + for _, input := range []string{"vers:np m/1.0.0", "vers: npm/1.0.0"} { + if _, err := defaultParser.parseVersURI(input, false); err == nil { + t.Errorf("lenient parse of %q succeeded, want error", input) + } + } +} + func TestParseNpmRange(t *testing.T) { tests := []struct { name string diff --git a/schemes.go b/schemes.go index 82836eb..cd29648 100644 --- a/schemes.go +++ b/schemes.go @@ -611,7 +611,17 @@ func compareRPMPart(a, b string) int { //nolint:gocyclo,gocognit return 0 } +func intDotPrefix(s string) string { + for i := 0; i < len(s); i++ { + if s[i] != '.' && !isASCIIDigit(s[i]) { + return s[:i] + } + } + return s +} + func compareIntDot(a, b string) int { + a, b = intDotPrefix(a), intDotPrefix(b) pa, pb := strings.Split(a, "."), strings.Split(b, ".") for i := 0; i < len(pa) || i < len(pb); i++ { var va, vb string diff --git a/schemes_test.go b/schemes_test.go index 92938f5..ccc88a7 100644 --- a/schemes_test.go +++ b/schemes_test.go @@ -244,6 +244,9 @@ func TestExistingSchemeComparatorEdges(t *testing.T) { {"maven", "1.999999999999999999999999", "1.2", 1}, {"lexicographic", "10", "2", -1}, {"intdot", "1.0.0.1", "1.0.0", 1}, + {"intdot", "1.2beta", "1.2rc1", 0}, + {"intdot", "1.10alpha", "1.3rc1", 1}, + {"intdot", "1.2", "1.2.0", 0}, {"gentoo", "01", "1", 0}, } for _, tt := range tests { diff --git a/testdata/vers-spec b/testdata/vers-spec index 55c47a5..599b53d 160000 --- a/testdata/vers-spec +++ b/testdata/vers-spec @@ -1 +1 @@ -Subproject commit 55c47a5cf69d2e96275d9a3089a98918ec978008 +Subproject commit 599b53de29e0271314e82347516845f18928237a