[#6] Implement version constraints / downgrade protection

Signed-off-by: Knut Ahlers <knut@ahlers.me>
This commit is contained in:
Knut Ahlers 2022-08-07 16:15:36 +02:00
parent 718a32222f
commit 333a8ffe9b
Signed by: luzifer
GPG key ID: 0066F03ED215AD7D
9 changed files with 286 additions and 7 deletions

View file

@ -18,6 +18,11 @@ catalog:
name: 'Website' name: 'Website'
url: 'https://alpinelinux.org' url: 'https://alpinelinux.org'
version_constraint:
allow_downgrade: false
allow_prerelease: false
type: semver
check_interval: 1h check_interval: 1h
... ...
@ -29,6 +34,8 @@ Additionally you will configure a `fetcher` with its corresponding `fetcher_conf
You can provide your own `links` for each catalog entry which will be added to or override the links returned from the fetcher. If you provide the same `name` as the fetcher uses the link of the fetcher will be overridden. The `icon_class` should consist of `fas` or `fab` and an icon (for example `fa-globe`). You can use all **solid** (`fas`) or **breand** (`fab`) icons within [Font Awesome v5 Free](https://fontawesome.com/v5.15/icons?d=gallery&s=brands,solid&m=free). You can provide your own `links` for each catalog entry which will be added to or override the links returned from the fetcher. If you provide the same `name` as the fetcher uses the link of the fetcher will be overridden. The `icon_class` should consist of `fas` or `fab` and an icon (for example `fa-globe`). You can use all **solid** (`fas`) or **breand** (`fab`) icons within [Font Awesome v5 Free](https://fontawesome.com/v5.15/icons?d=gallery&s=brands,solid&m=free).
For the `version_constraint`s you must specify a `type` to parse the version returned by the fetcher (currently `semver` and `numeric_dot` (`104.0.5112.79`) are supported) and then can allow downgrades and pre-releases in the version. If no constraint is present, versions are neither parsed nor checked for downgrade / pre-release. If only the `type` is specified, downgrades and pre-releases are forbidden.
## Available Fetchers ## Available Fetchers
## Fetcher: `atlassian` ## Fetcher: `atlassian`

View file

@ -18,6 +18,11 @@ catalog:
name: 'Website' name: 'Website'
url: 'https://alpinelinux.org' url: 'https://alpinelinux.org'
version_constraint:
allow_downgrade: false
allow_prerelease: false
type: semver
check_interval: 1h check_interval: 1h
... ...
@ -29,6 +34,8 @@ Additionally you will configure a `fetcher` with its corresponding `fetcher_conf
You can provide your own `links` for each catalog entry which will be added to or override the links returned from the fetcher. If you provide the same `name` as the fetcher uses the link of the fetcher will be overridden. The `icon_class` should consist of `fas` or `fab` and an icon (for example `fa-globe`). You can use all **solid** (`fas`) or **breand** (`fab`) icons within [Font Awesome v5 Free](https://fontawesome.com/v5.15/icons?d=gallery&s=brands,solid&m=free). You can provide your own `links` for each catalog entry which will be added to or override the links returned from the fetcher. If you provide the same `name` as the fetcher uses the link of the fetcher will be overridden. The `icon_class` should consist of `fas` or `fab` and an icon (for example `fa-globe`). You can use all **solid** (`fas`) or **breand** (`fab`) icons within [Font Awesome v5 Free](https://fontawesome.com/v5.15/icons?d=gallery&s=brands,solid&m=free).
For the `version_constraint`s you must specify a `type` to parse the version returned by the fetcher (currently `semver` and `numeric_dot` (`104.0.5112.79`) are supported) and then can allow downgrades and pre-releases in the version. If no constraint is present, versions are neither parsed nor checked for downgrade / pre-release. If only the `type` is specified, downgrades and pre-releases are forbidden.
## Available Fetchers ## Available Fetchers
{% for module in modules -%} {% for module in modules -%}

View file

@ -8,6 +8,7 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
"github.com/Luzifer/go-latestver/internal/version"
"github.com/Luzifer/go_helpers/v2/fieldcollection" "github.com/Luzifer/go_helpers/v2/fieldcollection"
) )
@ -19,6 +20,8 @@ type (
Fetcher string `json:"-" yaml:"fetcher"` Fetcher string `json:"-" yaml:"fetcher"`
FetcherConfig *fieldcollection.FieldCollection `json:"-" yaml:"fetcher_config"` FetcherConfig *fieldcollection.FieldCollection `json:"-" yaml:"fetcher_config"`
VersionConstraint *version.Constraint `json:"-" yaml:"version_constraint"`
Links []CatalogLink `json:"links" yaml:"links"` Links []CatalogLink `json:"links" yaml:"links"`
} }

View file

@ -0,0 +1,68 @@
package version
import "github.com/pkg/errors"
type (
Constraint struct {
AllowDowngrade bool `yaml:"allow_downgrade"`
AllowPrerelease bool `yaml:"allow_prerelease"`
Type string `yaml:"type"`
}
comparer interface {
Compare(oldVersion, newVersion string) (compareResult, error)
IsPrerelease(newVersion string) (bool, error)
}
compareResult uint
)
const (
compareResultInvalid compareResult = iota
compareResultEqual
compareResultDowngrade
compareResultUpgrade
)
func (c Constraint) ShouldApply(oldVersion, newVersion string) (bool, error) {
comp := c.getComparer()
if comp == nil {
return false, errors.New("invalid version type specified")
}
// Compare versions and check for UpgradeOnly flag
compResult, err := comp.Compare(oldVersion, newVersion)
if err != nil {
return false, errors.Wrap(err, "comparing versions")
}
if !c.AllowDowngrade && compResult != compareResultUpgrade {
return false, nil
}
// check for forbidden pre-releases
isPreR, err := comp.IsPrerelease(newVersion)
if err != nil {
return false, errors.Wrap(err, "checking pre-release")
}
if !c.AllowPrerelease && isPreR {
return false, nil
}
return true, nil
}
func (c Constraint) getComparer() comparer {
switch c.Type {
case "numeric_dot":
return numericDotSeparatedComparer{}
case "semver":
return semVerComparer{}
default:
return nil
}
}

View file

@ -0,0 +1,68 @@
package version
import (
"math"
"strconv"
"strings"
"github.com/pkg/errors"
)
type (
numericDotSeparatedComparer struct{}
)
var _ comparer = numericDotSeparatedComparer{}
func (n numericDotSeparatedComparer) Compare(oldVersion, newVersion string) (compareResult, error) {
oldV, err := n.parse(oldVersion)
if err != nil {
return compareResultInvalid, errors.Wrap(err, "parsing old version")
}
newV, err := n.parse(newVersion)
if err != nil {
return compareResultInvalid, errors.Wrap(err, "parsing old version")
}
getSeg := func(v []int, i int) int {
if i >= len(v) {
return 0
}
return v[i]
}
for i := 0; i < int(math.Max(float64(len(oldV)), float64(len(newV)))); i++ {
switch {
case getSeg(oldV, i) < getSeg(newV, i):
return compareResultUpgrade, nil
case getSeg(oldV, i) > getSeg(newV, i):
return compareResultDowngrade, nil
default:
continue
}
}
return compareResultEqual, nil
}
func (numericDotSeparatedComparer) IsPrerelease(string) (bool, error) {
// Numeric Dot has no marker for Pre-Releases
return false, nil
}
func (numericDotSeparatedComparer) parse(ver string) ([]int, error) {
var out []int
for _, seg := range strings.Split(ver, ".") {
segI, err := strconv.Atoi(seg)
if err != nil {
return nil, errors.Wrap(err, "parsing segment")
}
out = append(out, segI)
}
return out, nil
}

View file

@ -0,0 +1,27 @@
package version
import "testing"
func TestNumericDotSeparatedCompareFunc(t *testing.T) {
comp := numericDotSeparatedComparer{}
for _, tc := range []struct {
v1, v2 string
res compareResult
}{
{"2.1.0", "2.1.1", compareResultUpgrade},
{"2.1.1", "2.1.0", compareResultDowngrade},
{"2.1.1", "2.1.1", compareResultEqual},
{"103.0.5060.134", "104.0.5112.79", compareResultUpgrade},
{"2022.7.7", "2022.8.0", compareResultUpgrade},
} {
res, err := comp.Compare(tc.v1, tc.v2)
if err != nil {
t.Errorf("Comparing %q to %q: %s", tc.v1, tc.v2, err)
}
if res != tc.res {
t.Errorf("Comparing %q to %q: expected %v, got %v", tc.v1, tc.v2, tc.res, res)
}
}
}

View file

@ -0,0 +1,51 @@
package version
import (
"github.com/blang/semver/v4"
"github.com/pkg/errors"
)
type (
semVerComparer struct{}
)
var _ comparer = semVerComparer{}
func (semVerComparer) Compare(oldVersion, newVersion string) (compareResult, error) {
oldS, err := semver.Make(oldVersion)
if err != nil {
return compareResultInvalid, errors.Wrap(err, "parsing old version")
}
newS, err := semver.Make(newVersion)
if err != nil {
return compareResultInvalid, errors.Wrap(err, "parsing new version")
}
switch oldS.Compare(newS) {
case -1:
// oldS < newS
return compareResultUpgrade, nil
case 0:
// oldS == newS
return compareResultEqual, nil
case 1:
// oldS > newS
return compareResultDowngrade, nil
default:
// WTF, that does not exist according to lib docs
return compareResultInvalid, errors.New("invalid compare result")
}
}
func (semVerComparer) IsPrerelease(newVersion string) (bool, error) {
newS, err := semver.Make(newVersion)
if err != nil {
return false, errors.Wrap(err, "parsing version")
}
return newS.Pre != nil, nil
}

View file

@ -0,0 +1,27 @@
package version
import "testing"
func TestSemVerCompareFunc(t *testing.T) {
comp := semVerComparer{}
for _, tc := range []struct {
v1, v2 string
res compareResult
}{
{"1.0.0", "2.0.0", compareResultUpgrade},
{"2.0.0", "2.1.0", compareResultUpgrade},
{"2.1.0", "2.1.1", compareResultUpgrade},
{"2.1.1", "2.1.0", compareResultDowngrade},
{"2.1.1", "2.1.1", compareResultEqual},
} {
res, err := comp.Compare(tc.v1, tc.v2)
if err != nil {
t.Errorf("Comparing %q to %q: %s", tc.v1, tc.v2, err)
}
if res != tc.res {
t.Errorf("Comparing %q to %q: expected %v, got %v", tc.v1, tc.v2, tc.res, res)
}
}
}

View file

@ -59,18 +59,35 @@ func checkForUpdates(ce *database.CatalogEntry) error {
ver = strings.TrimPrefix(ver, "v") ver = strings.TrimPrefix(ver, "v")
vertime = vertime.Truncate(time.Second).UTC() vertime = vertime.Truncate(time.Second).UTC()
logger = logger.WithFields(log.Fields{
"from": cm.CurrentVersion,
"to": ver,
})
var (
compareErr error
shouldUpdate = true
)
if ce.VersionConstraint != nil {
shouldUpdate, compareErr = ce.VersionConstraint.ShouldApply(cm.CurrentVersion, ver)
}
switch { switch {
case err != nil: case err != nil:
log.WithField("entry", ce.Key()).WithError(err).Error("Fetcher caused error, error is stored in entry") logger.WithError(err).Error("Fetcher caused error, error is stored in entry")
cm.Error = err.Error() cm.Error = err.Error()
case cm.CurrentVersion != ver: case compareErr != nil:
logger.WithError(err).Error("Version compare caused error, error is stored in entry")
cm.Error = compareErr.Error()
logger.WithFields(log.Fields{ case cm.CurrentVersion != ver && !shouldUpdate:
"from": cm.CurrentVersion, logger.Info("Version-updated prevented by constraints")
"to": ver, cm.Error = ""
}).Info("Entry had version update")
case cm.CurrentVersion != ver && shouldUpdate:
logger.Info("Entry had version update")
if err = storage.Logs.Add(&database.LogEntry{ if err = storage.Logs.Add(&database.LogEntry{
CatalogName: ce.Name, CatalogName: ce.Name,
@ -84,7 +101,11 @@ func checkForUpdates(ce *database.CatalogEntry) error {
cm.VersionTime = ptrTime(vertime) cm.VersionTime = ptrTime(vertime)
cm.CurrentVersion = ver cm.CurrentVersion = ver
fallthrough cm.Error = ""
case cm.CurrentVersion == ver:
logger.Debug("Version did not change")
cm.Error = ""
default: default:
cm.Error = "" cm.Error = ""