Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
244 changes: 244 additions & 0 deletions apk.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
package vers

import "strings"

// APK version comparison follows the tokenizer in apk-tools src/version.c
// at commit 900a3f5280bfad7ea22eda7de46d0d4c0c9ce8f2.

const (
apkTokenInitialDigit = iota
apkTokenDigit
apkTokenLetter
apkTokenSuffix
apkTokenSuffixNumber
apkTokenCommitHash
apkTokenRevisionNumber
apkTokenEnd
apkTokenInvalid
)

const apkSuffixNone = 5

var apkSuffixRank = map[string]int{
"alpha": 1,
"beta": 2,
"pre": 3,
"rc": 4,
"cvs": 6,
"svn": 7,
"git": 8,
"hg": 9,
"p": 10,
}

type apkToken struct {
kind int
number uint64
text string
}

func compareAPK(a, b string) int {
left := parseAPKVersion(a)
right := parseAPKVersion(b)

i := 0
for apkTokenType(left, i) == apkTokenType(right, i) && apkTokenType(left, i) < apkTokenEnd {
if c := compareAPKToken(left[i], right[i]); c != 0 {
return c
}
i++
}

lt, rt := apkTokenType(left, i), apkTokenType(right, i)
if lt == rt {
return 0
}
if lt == apkTokenSuffix && left[i].number < apkSuffixNone {
return -1
}
if rt == apkTokenSuffix && right[i].number < apkSuffixNone {
return 1
}
if lt > rt {
return -1
}
return 1
}

func compareAPKToken(a, b apkToken) int {
switch a.kind {
case apkTokenDigit:
if strings.HasPrefix(a.text, "0") || strings.HasPrefix(b.text, "0") {
return cmpString(a.text, b.text)
}
return cmpUint64(a.number, b.number)
case apkTokenInitialDigit, apkTokenSuffixNumber, apkTokenRevisionNumber, apkTokenLetter, apkTokenSuffix:
return cmpUint64(a.number, b.number)
default:
return cmpString(a.text, b.text)
}
}

func validAPKVersion(s string) bool {
tokens := parseAPKVersion(s)
for _, t := range tokens {
if t.kind == apkTokenInvalid {
return false
}
}
return len(tokens) > 0
}

func apkVersionIsPrerelease(s string) bool {
for _, t := range parseAPKVersion(s) {
if t.kind == apkTokenInvalid {
return false
}
if t.kind == apkTokenSuffix && t.number < apkSuffixNone {
return true
}
}
return false
}

func parseAPKVersion(s string) []apkToken {
s = strings.TrimSpace(s)
tokens := make([]apkToken, 0, 6) //nolint:mnd

first, i := scanAPKDigits(s, 0)
if first == "" {
return append(tokens, apkToken{kind: apkTokenInvalid})
}
tokens = append(tokens, apkToken{kind: apkTokenInitialDigit, number: apkUint64(first), text: first})
previous := apkTokenInitialDigit

for i < len(s) {
c := s[i]
switch {
case c >= 'a' && c <= 'z':
if previous > apkTokenDigit {
return append(tokens, apkToken{kind: apkTokenInvalid})
}
tokens = append(tokens, apkToken{kind: apkTokenLetter, number: uint64(c)})
previous = apkTokenLetter
i++
case c == '.':
if previous > apkTokenDigit {
return append(tokens, apkToken{kind: apkTokenInvalid})
}
digits, next := scanAPKDigits(s, i+1)
if digits == "" {
return append(tokens, apkToken{kind: apkTokenInvalid})
}
tokens = append(tokens, apkToken{kind: apkTokenDigit, number: apkUint64(digits), text: digits})
previous = apkTokenDigit
i = next
case c >= '0' && c <= '9':
var kind int
switch previous {
case apkTokenInitialDigit, apkTokenDigit:
kind = apkTokenDigit
case apkTokenSuffix:
kind = apkTokenSuffixNumber
default:
return append(tokens, apkToken{kind: apkTokenInvalid})
}
digits, next := scanAPKDigits(s, i)
tokens = append(tokens, apkToken{kind: kind, number: apkUint64(digits), text: digits})
previous = kind
i = next
case c == '_':
if previous > apkTokenSuffixNumber {
return append(tokens, apkToken{kind: apkTokenInvalid})
}
suffix, next := scanAPKLower(s, i+1)
rank, ok := apkSuffixRank[suffix]
if !ok {
return append(tokens, apkToken{kind: apkTokenInvalid})
}
tokens = append(tokens, apkToken{kind: apkTokenSuffix, number: uint64(rank)})
previous = apkTokenSuffix
i = next
case c == '~':
if previous >= apkTokenCommitHash {
return append(tokens, apkToken{kind: apkTokenInvalid})
}
hash, next := scanAPKHex(s, i+1)
if hash == "" {
return append(tokens, apkToken{kind: apkTokenInvalid})
}
tokens = append(tokens, apkToken{kind: apkTokenCommitHash, text: hash})
previous = apkTokenCommitHash
i = next
case c == '-':
if previous >= apkTokenRevisionNumber || i+1 >= len(s) || s[i+1] != 'r' {
return append(tokens, apkToken{kind: apkTokenInvalid})
}
digits, next := scanAPKDigits(s, i+2)
if digits == "" {
return append(tokens, apkToken{kind: apkTokenInvalid})
}
tokens = append(tokens, apkToken{kind: apkTokenRevisionNumber, number: apkUint64(digits), text: digits})
previous = apkTokenRevisionNumber
i = next
default:
return append(tokens, apkToken{kind: apkTokenInvalid})
}
}

return tokens
}

func apkTokenType(tokens []apkToken, i int) int {
if i < len(tokens) {
return tokens[i].kind
}
return apkTokenEnd
}

func apkUint64(s string) uint64 {
var n uint64
for i := 0; i < len(s); i++ {
n = n*10 + uint64(s[i]-'0')
}
return n
}

func scanAPKDigits(s string, i int) (string, int) {
start := i
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
i++
}
return s[start:i], i
}

func scanAPKLower(s string, i int) (string, int) {
start := i
for i < len(s) && s[i] >= 'a' && s[i] <= 'z' {
i++
}
return s[start:i], i
}

func scanAPKHex(s string, i int) (string, int) {
start := i
for i < len(s) {
c := s[i]
if (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') {
i++
} else {
break
}
}
return s[start:i], i
}

func cmpUint64(a, b uint64) int {
if a < b {
return -1
}
if a > b {
return 1
}
return 0
}
138 changes: 138 additions & 0 deletions apk_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package vers

import (
"bufio"
"os"
"path/filepath"
"strings"
"testing"
)

func TestAPKVersionData(t *testing.T) {
f, err := os.Open(filepath.Join("testdata", "local", "data", "apk_version.data"))
if err != nil {
t.Fatal(err)
}
defer func() { _ = f.Close() }()

var comparisons, validity int
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if i := strings.Index(line, "#"); i >= 0 {
line = line[:i]
}
line = strings.TrimSpace(line)
if line == "" {
continue
}

if a, op, b, ok := splitAPKComparison(line); ok {
comparisons++
got := CompareWithScheme(a, b, "apk")
if got != op {
t.Errorf("CompareWithScheme(%q, %q, apk) = %d, want %d", a, b, got, op)
}
continue
}

if strings.ContainsRune(line, ' ') {
continue
}

validity++
version := line
want := true
if strings.HasPrefix(line, "!") {
version = line[1:]
want = false
}
if got := ValidWithScheme(version, "apk"); got != want {
t.Errorf("ValidWithScheme(%q, apk) = %v, want %v", version, got, want)
}
}
if err := scanner.Err(); err != nil {
t.Fatal(err)
}

t.Logf("apk_version.data: %d comparisons, %d validity checks", comparisons, validity)
if comparisons < 730 || validity < 30 {
t.Fatalf("apk_version.data yielded %d comparisons and %d validity checks", comparisons, validity)
}
}

func splitAPKComparison(line string) (a string, op int, b string, ok bool) {
fields := strings.Fields(line)
if len(fields) != 3 {
return "", 0, "", false
}
switch fields[1] {
case "<":
return fields[0], -1, fields[2], true
case ">":
return fields[0], 1, fields[2], true
case "=":
return fields[0], 0, fields[2], true
}
return "", 0, "", false
}

func TestAPKComparisonThroughPublicAPI(t *testing.T) {
tests := []struct {
left, right string
want int
}{
{"1.0", "1.0_alpha", 1},
{"1.0", "1.0_p1", -1},
{"1.0_cvs", "1.0", 1},
{"1.0_git20240101", "1.0", 1},
{"1.0_svn", "1.0_git", -1},
{"1.0~1234", "1.0~1235", -1},
{"1.0~1234-r1", "1.0~1234-r0", 1},
{"1.0", "1.0bc", -1},
{"1.06", "1.6", -1},
{"1.006", "1.06", -1},
}
for _, tt := range tests {
if got := CompareWithScheme(tt.left, tt.right, "apk"); got != tt.want {
t.Errorf("CompareWithScheme(%q, %q, apk) = %d, want %d", tt.left, tt.right, got, tt.want)
}
if got := CompareWithScheme(tt.right, tt.left, "apk"); got != -tt.want {
t.Errorf("CompareWithScheme(%q, %q, apk) = %d, want %d", tt.right, tt.left, got, -tt.want)
}
}
}

func TestAPKClassificationThroughPublicAPI(t *testing.T) {
if !IsPrereleaseWithScheme("1.0_alpha", "apk") || !IsPrereleaseWithScheme("1.0_rc1", "apk") {
t.Error("apk pre-release suffixes should classify as prerelease")
}
for _, stable := range []string{"1.0", "1.0_p1", "1.0_git20240101", "1.0-r1", "1.0~abcd"} {
if IsPrereleaseWithScheme(stable, "apk") {
t.Errorf("IsPrereleaseWithScheme(%q, apk) = true", stable)
}
if !IsStableWithScheme(stable, "apk") {
t.Errorf("IsStableWithScheme(%q, apk) = false", stable)
}
}
if IsStableWithScheme("0.1bc", "apk") || IsPrereleaseWithScheme("0.1bc", "apk") {
t.Error("invalid apk version should not classify")
}
}

func TestAPKRangesThroughPublicAPI(t *testing.T) {
r, err := Parse("vers:apk/>=1.0|<2.0")
if err != nil {
t.Fatal(err)
}
if !r.Contains("1.0_git20240101") {
t.Error("apk range should contain a git snapshot above the lower bound")
}
if r.Contains("1.0_alpha") {
t.Error("apk range should not contain a pre-release below the lower bound")
}

if !ValidWithScheme("1.0_hg1", "alpine") {
t.Error("alpine alias should validate apk versions")
}
}
4 changes: 3 additions & 1 deletion normalization.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ func validVersionForScheme(version, scheme string) bool { //nolint:gocyclo
case schemeOpenSSL:
_, ok := parseOpenSSLVersion(version)
return ok
case schemeMaven, schemeLexicographic, schemeDatetime, schemeAPK, schemeAlpine, schemeGentoo, schemeALPM, schemeConan:
case schemeAPK, schemeAlpine:
return validAPKVersion(version)
case schemeMaven, schemeLexicographic, schemeDatetime, schemeGentoo, schemeALPM, schemeConan:
return !strings.ContainsAny(version, " \t\r\n")
default:
return Valid(version)
Expand Down
Loading