Files
athens/pkg/module/filter.go
Chad Kunde 7811524c22 Version filtering (#1050)
* extend filtering logic and configuration to include version lists

Module filtering is very useful, but many deployments will need to
satisfy even more granular constrainsts.  Enterprises may need
approved lists specific down to the minor (or patch) version element.

Version filtering logic is similar to the module filtering, in that
it's a prefix match of the version requested against each entry in the
version filter list.

Closes #1045

* include version filtering in documentation with example

* allow filtering when version is missing

Endpoints that do not specify a version, such as "@v/list", only need
to be filtered by module rules.
2019-02-05 09:04:09 +01:00

224 lines
4.5 KiB
Go

package module
import (
"bufio"
"os"
"strconv"
"strings"
"github.com/gomods/athens/pkg/errors"
)
var (
pathSeparator = "/"
)
// Filter is a filter of modules
type Filter struct {
root ruleNode
filePath string
}
// NewFilter creates new filter based on rules defined in a configuration file
// WARNING: this is not concurrently safe
// Configuration consists of two operations: + for include and - for exclude
// e.g.
// - github.com/a
// + github.com/a/b
// will communicate all modules except github.com/a and its children, but github.com/a/b will be communicated
// example 2:
// -
// + github.com/a
// will exclude all items from communication except github.com/a
func NewFilter(filterFilePath string) (*Filter, error) {
// Do not return an error if the file path is empty
// Do not attempt to parse it as well.
if filterFilePath == "" {
return nil, nil
}
return initFromConfig(filterFilePath)
}
// AddRule adds rule for specified path
func (f *Filter) AddRule(path string, versions []string, rule FilterRule) {
f.ensurePath(path)
segments := getPathSegments(path)
if len(segments) == 0 {
f.root.rule = rule
return
}
// look for latest node in a path
latest := f.root
for _, p := range segments[:len(segments)-1] {
latest = latest.next[p]
}
// replace with updated node
last := segments[len(segments)-1]
rn := latest.next[last]
rn.rule = rule
rn.vers = versions
latest.next[last] = rn
}
// Rule returns the filter rule to be applied to the given path
func (f *Filter) Rule(path, version string) FilterRule {
segs := getPathSegments(path)
rule := f.getAssociatedRule(version, segs...)
if rule == Default {
rule = Include
}
return rule
}
func (f *Filter) ensurePath(path string) {
latest := f.root.next
pathSegments := getPathSegments(path)
for _, p := range pathSegments {
if _, ok := latest[p]; !ok {
latest[p] = newRule(Default)
}
latest = latest[p].next
}
}
func (f *Filter) getAssociatedRule(version string, path ...string) FilterRule {
if len(path) == 0 {
return f.root.rule
}
rules := make([]FilterRule, 0, len(path))
rn := f.root
for _, p := range path {
if _, ok := rn.next[p]; !ok {
break
}
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 = true
break
}
}
if match || version == "" {
rules = append(rules, rn.rule)
}
}
if len(rules) == 0 {
return f.root.rule
}
for i := len(rules) - 1; i >= 0; i-- {
if rules[i] != Default {
return rules[i]
}
}
return f.root.rule
}
func initFromConfig(filePath string) (*Filter, error) {
const op errors.Op = "module.initFromConfig"
lines, err := getConfigLines(filePath)
if err != nil {
return nil, err
}
rn := newRule(Default)
f := &Filter{
filePath: filePath,
}
f.root = rn
for idx, line := range lines {
// Ignore newline
if len(line) == 0 {
continue
}
if len(line) > 0 && line[0] == '#' {
continue
}
split := strings.Split(line, " ")
if len(split) > 3 {
return nil, errors.E(op, "Invalid configuration found in filter file at the line "+strconv.Itoa(idx+1))
}
ruleSign := strings.TrimSpace(split[0])
rule := Default
switch ruleSign {
case "+":
rule = Include
case "-":
rule = Exclude
case "D":
rule = Direct
default:
return nil, errors.E(op, "Invalid configuration found in filter file at the line "+strconv.Itoa(idx+1))
}
// is root config
if len(split) == 1 {
f.AddRule("", nil, rule)
continue
}
var vers []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] += "."
}
}
}
path := strings.TrimSpace(split[1])
f.AddRule(path, vers, rule)
}
return f, nil
}
func getPathSegments(path string) []string {
path = strings.TrimSpace(path)
path = strings.Trim(path, pathSeparator)
if path == "" {
return []string{}
}
return strings.Split(path, pathSeparator)
}
func newRule(r FilterRule) ruleNode {
rn := ruleNode{}
rn.next = make(map[string]ruleNode)
rn.rule = r
return rn
}
func getConfigLines(filterFile string) ([]string, error) {
const op errors.Op = "module.getConfigLines"
f, err := os.Open(filterFile)
if err != nil {
return nil, errors.E(op, err)
}
scanner := bufio.NewScanner(f)
var lines []string
for scanner.Scan() {
lines = append(lines, strings.TrimSpace(scanner.Text()))
}
return lines, f.Close()
}