Cybersecurity-Projects/PROJECTS/beginner/simple-vulnerability-scanner/internal/pypi/version.go

334 lines
6.5 KiB
Go

/*
©AngelaMos | 2026
version.go
PEP 440 version parsing, comparison, and bump classification
Implements the full PEP 440 spec: epoch, release segments, pre-release
(alpha/beta/rc), post-release, dev releases, and local identifiers. The
Compare method follows PEP 440 ordering rules, including edge cases around
dev-only suffixes, implicit trailing zeros, and epoch precedence.
Key exports:
Version - parsed version struct with all optional components
ChangeKind - Patch, Minor, or Major bump classification
ParseVersion - parses a raw PEP 440 string into Version
LatestStable - finds the highest stable version from a list of strings
ClassifyChange - determines the bump magnitude between two versions
Connects to:
update.go - calls ParseVersion, LatestStable, ClassifyChange
output.go - uses ChangeKind constants for update color selection
*/
package pypi
import (
"cmp"
"errors"
"fmt"
"math"
"regexp"
"strconv"
"strings"
)
// ErrInvalidVersion indicates a string that does not conform to PEP 440
var ErrInvalidVersion = errors.New("invalid PEP 440 version")
// ChangeKind classifies a version bump by semver magnitude
type ChangeKind int
const (
Patch ChangeKind = iota + 1
Minor
Major
)
// String returns the lowercase name of the change kind
func (c ChangeKind) String() string {
switch c {
case Patch:
return "patch"
case Minor:
return "minor"
case Major:
return "major"
default:
return "unknown"
}
}
// Version represents a parsed PEP 440 version with all optional components
type Version struct {
Raw string
Epoch int
Release []int
PreKind string
PreNum int
Post int
Dev int
Local string
}
var versionRe = regexp.MustCompile(
`(?i)^v?` +
`(?:(\d+)!)?` +
`(\d+(?:\.\d+)*)` +
`(?:[-_.]?(alpha|a|beta|b|preview|pre|c|rc)[-_.]?(\d*))?` +
`(?:[-_.]?(post|rev|r)[-_.]?(\d*)|-(\d+))?` +
`(?:[-_.]?(dev)[-_.]?(\d*))?` +
`(?:\+([a-z0-9](?:[a-z0-9._-]*[a-z0-9])?))?$`,
)
// ParseVersion parses a PEP 440 version string into its structured components
func ParseVersion(s string) (Version, error) {
normalized := strings.ToLower(strings.TrimSpace(s))
m := versionRe.FindStringSubmatch(normalized)
if m == nil {
return Version{}, fmt.Errorf("%w: %q", ErrInvalidVersion, s)
}
v := Version{
Raw: s,
Post: -1,
Dev: -1,
}
if m[1] != "" {
v.Epoch = mustAtoi(m[1])
}
for _, seg := range strings.Split(m[2], ".") {
v.Release = append(v.Release, mustAtoi(seg))
}
if m[3] != "" {
v.PreKind = normalizePreKind(m[3])
v.PreNum = optionalAtoi(m[4])
}
switch {
case m[5] != "":
v.Post = optionalAtoi(m[6])
case m[7] != "":
v.Post = mustAtoi(m[7])
}
if m[8] != "" {
v.Dev = optionalAtoi(m[9])
}
v.Local = m[10]
return v, nil
}
// String returns the canonical PEP 440 representation
func (v Version) String() string {
var b strings.Builder
if v.Epoch != 0 {
fmt.Fprintf(&b, "%d!", v.Epoch)
}
for i, n := range v.Release {
if i > 0 {
b.WriteByte('.')
}
fmt.Fprintf(&b, "%d", n)
}
if v.PreKind != "" {
fmt.Fprintf(&b, "%s%d", v.PreKind, v.PreNum)
}
if v.Post >= 0 {
fmt.Fprintf(&b, ".post%d", v.Post)
}
if v.Dev >= 0 {
fmt.Fprintf(&b, ".dev%d", v.Dev)
}
if v.Local != "" {
fmt.Fprintf(&b, "+%s", v.Local)
}
return b.String()
}
// IsStable reports whether the version is a stable (non-pre, non-dev) release
func (v Version) IsStable() bool {
return v.PreKind == "" && v.Dev < 0
}
// Compare returns -1, 0, or 1 following PEP 440 ordering rules.
//
// The ordering within a given release segment is:
//
// 1.0.dev1 < 1.0a1 < 1.0b1 < 1.0rc1 < 1.0 < 1.0.post1
func (v Version) Compare(other Version) int {
if c := cmp.Compare(v.Epoch, other.Epoch); c != 0 {
return c
}
if c := compareRelease(v.Release, other.Release); c != 0 {
return c
}
vpt, vpn := preKey(v)
opt, opn := preKey(other)
if c := cmp.Compare(vpt, opt); c != 0 {
return c
}
if c := cmp.Compare(vpn, opn); c != 0 {
return c
}
if c := cmp.Compare(postKey(v), postKey(other)); c != 0 {
return c
}
return cmp.Compare(devKey(v), devKey(other))
}
// ClassifyChange determines whether moving from v to other is a major, minor,
// or patch bump based on their release segments
func ClassifyChange(from, to Version) ChangeKind {
fromR := padRelease(from.Release, 3)
toR := padRelease(to.Release, 3)
if fromR[0] != toR[0] {
return Major
}
if fromR[1] != toR[1] {
return Minor
}
return Patch
}
// LatestStable finds the highest stable version from a list of version strings
func LatestStable(versions []string) (Version, error) {
var latest Version
var found bool
for _, raw := range versions {
v, err := ParseVersion(raw)
if err != nil {
continue
}
if !v.IsStable() {
continue
}
if !found || v.Compare(latest) > 0 {
latest = v
found = true
}
}
if !found {
return Version{}, errors.New("no stable version found")
}
return latest, nil
}
func compareRelease(a, b []int) int {
maxLen := max(len(a), len(b))
for i := range maxLen {
av, bv := 0, 0
if i < len(a) {
av = a[i]
}
if i < len(b) {
bv = b[i]
}
if c := cmp.Compare(av, bv); c != 0 {
return c
}
}
return 0
}
// preKey produces a sortable tuple for the pre-release component.
//
// Versions with only a dev suffix (no pre, no post) sort before all
// pre-releases. Pre-releases sort by kind (a < b < rc) then number.
// Final and post-releases sort after all pre-releases.
func preKey(v Version) (int, int) {
hasPre := v.PreKind != ""
hasDev := v.Dev >= 0
hasPost := v.Post >= 0
switch {
case !hasPre && !hasPost && hasDev:
return math.MinInt, math.MinInt
case hasPre:
return preKindRank(v.PreKind), v.PreNum
default:
return math.MaxInt, math.MaxInt
}
}
func postKey(v Version) int {
if v.Post < 0 {
return math.MinInt
}
return v.Post
}
func devKey(v Version) int {
if v.Dev < 0 {
return math.MaxInt
}
return v.Dev
}
func preKindRank(kind string) int {
switch kind {
case "a":
return 0
case "b":
return 1
case "rc":
return 2
default:
return -1
}
}
func normalizePreKind(s string) string {
switch strings.ToLower(s) {
case "a", "alpha":
return "a"
case "b", "beta":
return "b"
case "rc", "c", "pre", "preview":
return "rc"
default:
return s
}
}
func padRelease(r []int, n int) []int {
if len(r) >= n {
return r[:n]
}
padded := make([]int, n)
copy(padded, r)
return padded
}
func mustAtoi(s string) int {
n, _ := strconv.Atoi(s) //nolint:errcheck
return n
}
func optionalAtoi(s string) int {
if s == "" {
return 0
}
n, _ := strconv.Atoi(s) //nolint:errcheck
return n
}