diff --git a/docs/content/configuration/filter.md b/docs/content/configuration/filter.md index 9cdb20b7..f0708a97 100644 --- a/docs/content/configuration/filter.md +++ b/docs/content/configuration/filter.md @@ -68,3 +68,26 @@ D enterprise.github.com/company In the above example, any module not in the rules will be excluded. All modules from `enterprise.github.com/company` are fetched directly from the source. The `github.com/gomods/athens` module will be stored in the proxy storage, but only for version `v0.4.1` and any patch versions under `v0.1` and `v0.2` minor versions. + +### Versions Filter Modifiers + +Athens provides advanced filter modifiers to cover cases such as API compatibility or when a given dependency changes its license from a given versions. The modifiers are intended to be used in the pattern list of the filter file. + +
+-
+# external dependency approved list
++ github.com/gomods/athens 
+
+The currently supported modifiers are 
+
+* `~1.2.3` will enable all patch versions from 1.2.3 and above (e.g. 1.2.3, 1.2.4, 1.2.5)
+  * Formally, `1.2.x` where `x >= 3`
+
+* `^1.2.3` will enable all patch and minor versions from 1.2.3 and above (e.g. 1.2.4, 1.3.0 and 1.4.5)
+  * Formally, `1.x.y` where `x >= 2` and `y >= 3`
+
+* `<1.2.3` will enable all versions lower than 1.2.3 (e.g. 1.2.2, 1.0.0 and 0.58.9)
+  * Formally, `x.y.z` where `x <= 1`, `y < = 2` and `z < 3`
+
+This kind of modifiers will work only if a three parts semantic version is specified. For example, `~4.5.6` will work while `~4.5` won't.
\ No newline at end of file
diff --git a/pkg/module/filter.go b/pkg/module/filter.go
index 63005c09..5c02e817 100644
--- a/pkg/module/filter.go
+++ b/pkg/module/filter.go
@@ -10,7 +10,8 @@ import (
 )
 
 var (
-	pathSeparator = "/"
+	pathSeparator    = "/"
+	versionSeparator = "."
 )
 
 // Filter is a filter of modules
@@ -42,7 +43,7 @@ func NewFilter(filterFilePath string) (*Filter, error) {
 }
 
 // AddRule adds rule for specified path
-func (f *Filter) AddRule(path string, versions []string, rule FilterRule) {
+func (f *Filter) AddRule(path string, qualifiers []string, rule FilterRule) {
 	f.ensurePath(path)
 
 	segments := getPathSegments(path)
@@ -62,7 +63,7 @@ func (f *Filter) AddRule(path string, versions []string, rule FilterRule) {
 	last := segments[len(segments)-1]
 	rn := latest.next[last]
 	rn.rule = rule
-	rn.vers = versions
+	rn.qualifiers = qualifiers
 	latest.next[last] = rn
 }
 
@@ -102,9 +103,9 @@ func (f *Filter) getAssociatedRule(version string, path ...string) FilterRule {
 		}
 		rn = rn.next[p]
 		// default to true if no version filter, false otherwise
-		match := len(rn.vers) == 0
-		for _, ver := range rn.vers {
-			if strings.HasPrefix(version, ver) {
+		match := len(rn.qualifiers) == 0
+		for _, q := range rn.qualifiers {
+			if matches(version, q) {
 				match = true
 				break
 			}
@@ -172,30 +173,115 @@ func initFromConfig(filePath string) (*Filter, error) {
 			f.AddRule("", nil, rule)
 			continue
 		}
-		var vers []string
+		var qual []string
 		if len(split) == 3 {
-			vers = strings.Split(split[2], ",")
-			for i := range vers {
-				vers[i] = strings.TrimRight(vers[i], "*")
-				if vers[i][len(vers[i])-1] != '.' && strings.Count(vers[i], ".") < 2 {
-					vers[i] += "."
+			qual = strings.Split(split[2], ",")
+			for i := range qual {
+				qual[i] = strings.TrimRight(qual[i], "*")
+				if qual[i][len(qual[i])-1] != '.' && strings.Count(qual[i], ".") < 2 {
+					qual[i] += "."
 				}
 			}
 		}
 
 		path := strings.TrimSpace(split[1])
-		f.AddRule(path, vers, rule)
+		f.AddRule(path, qual, rule)
 	}
 	return f, nil
 }
 
+// matches checks if the given version matches the given qualifier.
+// Qualifiers can be:
+// - plain versions
+// - v1.2.3 enables v1.2.3
+// - ~1.2.3: enables 1.2.x  which are at least 1.2.3
+// - ^1.2.3: enables 1.x.x which are at least 1.2.3
+// - <1.2.3: enables everything lower than 1.2.3 includes 1.2.2 and 0.58.9 as well
+func matches(version, qualifier string) bool {
+	if len(qualifier) < 2 || len(version) < 1 {
+		return false
+	}
+
+	prefix := qualifier[0]
+	first := qualifier[1]
+
+	// v1.2.3 means we accept every version starting with v1.2.3
+	// handle this special case first, then go for ~v1.2.3 and similar
+	if prefix == 'v' && first >= '0' && first <= '9' { // a number
+		return strings.HasPrefix(version, qualifier)
+	}
+
+	v, err := getVersionSegments(version[1:])
+	if err != nil {
+		return false
+	}
+
+	q, err := getVersionSegments(qualifier[2:])
+	if err != nil {
+		return false
+	}
+
+	if len(v) != len(q) {
+		return false
+	}
+	// no semver
+	if len(v) != 3 || len(q) != 3 {
+		return false
+	}
+
+	switch prefix {
+	case '~':
+		if v[0] == q[0] && v[1] == q[1] && v[2] >= q[2] {
+			return true
+		}
+		return false
+	case '^':
+		if v[0] == q[0] && v[1] > q[1] {
+			return true
+		}
+		if v[0] == q[0] && v[1] == q[1] && v[2] >= q[2] {
+			return true
+		}
+		return false
+	case '<':
+		if v[0] < q[0] {
+			return true
+		}
+		if v[0] == q[0] && v[1] < q[1] {
+			return true
+		}
+		if v[0] == q[0] && v[1] == q[1] && v[2] <= q[2] {
+			return true
+		}
+		return false
+	}
+	return false
+}
+
 func getPathSegments(path string) []string {
+	return getSegments(path, pathSeparator)
+}
+
+func getVersionSegments(path string) ([]int, error) {
+	vv := getSegments(path, versionSeparator)
+	res := make([]int, len(vv))
+	for i, v := range vv {
+		n, err := strconv.Atoi(v)
+		if err != nil {
+			return nil, err
+		}
+		res[i] = n
+	}
+	return res, nil
+}
+
+func getSegments(path, separator string) []string {
 	path = strings.TrimSpace(path)
-	path = strings.Trim(path, pathSeparator)
+	path = strings.Trim(path, separator)
 	if path == "" {
 		return []string{}
 	}
-	return strings.Split(path, pathSeparator)
+	return strings.Split(path, separator)
 }
 
 func newRule(r FilterRule) ruleNode {
diff --git a/pkg/module/filter_rule.go b/pkg/module/filter_rule.go
index b2210f1c..89732b94 100644
--- a/pkg/module/filter_rule.go
+++ b/pkg/module/filter_rule.go
@@ -1,7 +1,7 @@
 package module
 
 type ruleNode struct {
-	next map[string]ruleNode
-	rule FilterRule
-	vers []string
+	next       map[string]ruleNode
+	rule       FilterRule
+	qualifiers []string
 }
diff --git a/pkg/module/filter_test.go b/pkg/module/filter_test.go
index 26d363aa..d44360a9 100644
--- a/pkg/module/filter_test.go
+++ b/pkg/module/filter_test.go
@@ -133,6 +133,75 @@ func (t *FilterTests) Test_versionFilter() {
 	r.Equal(Include, f.Rule("github.com/a/b/c/d", "v1.3.4"))
 }
 
+func (t *FilterTests) Test_versionFilterMinor() {
+	r := t.Require()
+	filter := tempFilterFile(t.T())
+	defer os.Remove(filter)
+
+	f, err := NewFilter(filter)
+	r.NoError(err)
+	f.AddRule("", nil, Exclude)
+	f.AddRule("github.com/a/b", []string{"~v1.2.3", "~v2.3.40"}, Include)
+	r.Equal(Include, f.Rule("github.com/a/b", "v1.2.3"))
+	r.Equal(Include, f.Rule("github.com/a/b", "v1.2.5"))
+	r.Equal(Exclude, f.Rule("github.com/a/b", "v1.2.2"))
+	r.Equal(Exclude, f.Rule("github.com/a/b", "v1.3.3"))
+	r.Equal(Include, f.Rule("github.com/a/b", "v2.3.45"))
+	r.Equal(Exclude, f.Rule("github.com/a/b", "v2.2.45"))
+	r.Equal(Exclude, f.Rule("github.com/a/b", "v2.3.20"))
+}
+
+func (t *FilterTests) Test_versionFilterMiddle() {
+	r := t.Require()
+	filter := tempFilterFile(t.T())
+	defer os.Remove(filter)
+
+	f, err := NewFilter(filter)
+	r.NoError(err)
+	f.AddRule("", nil, Exclude)
+	f.AddRule("github.com/a/b", []string{"^v1.2.3", "^v2.3.40"}, Include)
+	r.Equal(Include, f.Rule("github.com/a/b", "v1.2.3"))
+	r.Equal(Include, f.Rule("github.com/a/b", "v1.2.5"))
+	r.Equal(Include, f.Rule("github.com/a/b", "v1.4.2"))
+	r.Equal(Exclude, f.Rule("github.com/a/b", "v1.2.1"))
+	r.Equal(Include, f.Rule("github.com/a/b", "v2.3.45"))
+	r.Equal(Include, f.Rule("github.com/a/b", "v2.4.1"))
+	r.Equal(Exclude, f.Rule("github.com/a/b", "v2.2.45"))
+}
+
+func (t *FilterTests) Test_versionFilterLess() {
+	r := t.Require()
+	filter := tempFilterFile(t.T())
+	defer os.Remove(filter)
+
+	f, err := NewFilter(filter)
+	r.NoError(err)
+	f.AddRule("", nil, Exclude)
+	f.AddRule("github.com/a/b", []string{"