Skip to content

Commit

Permalink
base-profiles: supports profiles with limited/no command usage
Browse files Browse the repository at this point in the history
  • Loading branch information
jkellerer committed Nov 12, 2023
1 parent 3d72803 commit 26edf1e
Show file tree
Hide file tree
Showing 8 changed files with 265 additions and 7 deletions.
44 changes: 44 additions & 0 deletions commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"github.com/creativeprojects/resticprofile/restic"
"github.com/creativeprojects/resticprofile/schedule"
"github.com/creativeprojects/resticprofile/term"
"github.com/creativeprojects/resticprofile/util"
"github.com/creativeprojects/resticprofile/util/templates"
"github.com/creativeprojects/resticprofile/win"
)
Expand Down Expand Up @@ -625,3 +626,46 @@ func elevated(flags commandLineFlags) error {

return nil
}

func commandFilter(profile *config.Profile, global *config.Global) func(command string) bool {
var baseProfile, allowed, denied util.MultiPatternMatcher

if global == nil || global.BaseProfiles == nil {
baseProfile = util.GlobMultiMatcher(constants.DefaultBaseProfile)
} else {
baseProfile = util.GlobMultiMatcher(global.BaseProfiles...)
}

if profile != nil {
if match, _ := baseProfile(profile.Name); match {
allowed = util.GlobMultiMatcher()
denied = util.GlobMultiMatcher("*")
} else {
if len(profile.AllowedCommands) == 1 && profile.AllowedCommands[0] == "" {
profile.AllowedCommands = nil
}
allowed = util.GlobMultiMatcher(profile.AllowedCommands...)
denied = util.GlobMultiMatcher(profile.DeniedCommands...)
}
}

if allowed != nil && denied != nil {
return func(command string) bool {
if match, pattern := allowed(command); match {
if pattern != command {
if match, pattern = denied(command); match && pattern == command {
return false // allowed by wildcard and denied by direct match
}
}
return true
}
if match, _ := denied(command); match {
return false
}
// default true if no allowed commands are set
return len(profile.AllowedCommands) == 0
}
}

return func(command string) bool { return true }
}
83 changes: 83 additions & 0 deletions commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -334,3 +334,86 @@ func TestGenerateCommand(t *testing.T) {
}
})
}

func TestCommandFilter(t *testing.T) {
p, g := new(config.Profile), new(config.Global)

reset := func() { p.AllowedCommands, p.DeniedCommands, g.BaseProfiles = nil, nil, nil }

expect := func(t *testing.T, expected bool, command string) {
assert.Equal(t, expected, commandFilter(p, g)(command), "command %s", command)
}

t.Run("nil-tolerant", func(t *testing.T) {
assert.True(t, commandFilter(nil, nil)("backup"))
})

t.Run("default-base", func(t *testing.T) {
reset()
p.Name = "default"
assert.True(t, commandFilter(p, nil)("backup"))
expect(t, true, "backup")
p.Name = "__default"
assert.False(t, commandFilter(p, nil)("backup"))
expect(t, false, "backup")
})

t.Run("configured-base", func(t *testing.T) {
reset()
p.Name = "default"
g.BaseProfiles = []string{"*"}
expect(t, false, "backup")
g.BaseProfiles = []string{"default"}
expect(t, false, "backup")
p.Name = "other"
expect(t, true, "backup")
})

t.Run("default-all-allowed", func(t *testing.T) {
reset()
for run := 0; run < 3; run++ {
expect(t, true, "backup")
expect(t, true, "restore")
expect(t, true, "another")

switch run {
case 0:
p.AllowedCommands, p.DeniedCommands = []string{}, []string{}
case 1:
p.AllowedCommands, p.DeniedCommands = []string{""}, []string{""}
}
}
})

t.Run("allowed", func(t *testing.T) {
reset()
p.AllowedCommands = []string{"*"}
expect(t, true, "backup")
expect(t, true, "restore")
p.AllowedCommands = []string{"backup"}
expect(t, true, "backup")
expect(t, false, "restore")
})

t.Run("denied", func(t *testing.T) {
reset()
p.DeniedCommands = []string{"*"}
expect(t, false, "backup")
expect(t, false, "restore")
p.DeniedCommands = []string{"backup"}
expect(t, false, "backup")
expect(t, true, "restore")
expect(t, true, "another")
})

t.Run("allowed-is-not-denied", func(t *testing.T) {
reset()
p.AllowedCommands = []string{"backup", "restore", "repair*"}
p.DeniedCommands = []string{"backup", "repair-snapshot"}
expect(t, true, "backup")
expect(t, true, "restore")
expect(t, true, "repair-index")
expect(t, false, "repair-snapshot") // direct denied match, wildcard allowed results in denied
expect(t, false, "another")
})
}
2 changes: 2 additions & 0 deletions config/global.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type Global struct {
Nice int `mapstructure:"nice" default:"0" range:"[-20:19]" description:"Sets the unix \"nice\" value for resticprofile and child processes (on any OS)"`
Priority string `mapstructure:"priority" default:"normal" enum:"idle;background;low;normal;high;highest" description:"Sets process priority class for resticprofile and child processes (on any OS)"`
DefaultCommand string `mapstructure:"default-command" default:"snapshots" description:"The restic or resticprofile command to use when no command was specified"`
BaseProfiles []string `mapstructure:"base-profiles" default:"__*" description:"One or more glob expression matching names of profiles that are meant for inheritance only. Profile names matching these expression cannot be executed directly - see https://creativeprojects.github.io/resticprofile/configuration/inheritance/"`
Initialize bool `mapstructure:"initialize" default:"false" description:"Initialize a repository if missing"`
ResticBinary string `mapstructure:"restic-binary" description:"Full path of the restic executable (detected if not set)"`
ResticVersion string // not configurable at the moment. To be set after ResticBinary is known.
Expand All @@ -40,6 +41,7 @@ func NewGlobal() *Global {
IONiceClass: constants.DefaultIONiceClass,
Nice: constants.DefaultStandardNiceFlag,
DefaultCommand: constants.DefaultCommand,
BaseProfiles: []string{constants.DefaultBaseProfile},
FilterResticFlags: constants.DefaultFilterResticFlags,
ResticLockRetryAfter: constants.DefaultResticLockRetryAfter,
ResticStaleLockAge: constants.DefaultResticStaleLockAge,
Expand Down
8 changes: 5 additions & 3 deletions config/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ type Profile struct {
resticVersion *semver.Version
Name string
Description string `mapstructure:"description" description:"Describes the profile"`
AllowedCommands []string `mapstructure:"allowed-commands" description:"A list of commands that may be started on this profile, defaults to empty (= all). Wildcards (glob expressions) are supported"`
DeniedCommands []string `mapstructure:"denied-commands" description:"A list of commands that cannot be started on this profile, defaults to empty (= none). What was explicitly allowed cannot be denied"`
BaseDir string `mapstructure:"base-dir" description:"Sets the working directory for this profile. The profile will fail when the working directory cannot be changed. Leave empty to use the current directory instead"`
Quiet bool `mapstructure:"quiet" argument:"quiet"`
Verbose int `mapstructure:"verbose" argument:"verbose"`
Expand Down Expand Up @@ -480,9 +482,9 @@ func (o *OtherFlagsSection) SetOtherFlag(name string, value any) {
// NewProfile instantiates a new blank profile
func NewProfile(c *Config, name string) (p *Profile) {
p = &Profile{
Name: name,
config: c,
OtherSections: make(map[string]*GenericSection),
Name: name,
config: c,
OtherSections: make(map[string]*GenericSection),
PrometheusPushFormat: constants.DefaultPrometheusPushFormat,
}

Expand Down
1 change: 1 addition & 0 deletions constants/default.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const (
DefaultConfigurationFile = "profiles"
DefaultProfileName = "default"
DefaultCommand = "snapshots"
DefaultBaseProfile = "__*"
DefaultFilterResticFlags = true
DefaultResticLockRetryAfter = 60 * time.Second
DefaultResticStaleLockAge = 1 * time.Hour
Expand Down
9 changes: 5 additions & 4 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -475,11 +475,12 @@ func runProfile(
wrapper.addProgress(prom.NewProgress(profile, prom.NewMetrics(group, version, profile.PrometheusLabels)))
}

err = wrapper.runProfile()
if err != nil {
return err
if accept := commandFilter(profile, global); accept(resticCommand) {
err = wrapper.runProfile()
} else {
err = fmt.Errorf("profile %q does not allow running %q", profile.Name, resticCommand)
}
return nil
return err
}

// randomBool returns true for Heads and false for Tails
Expand Down
39 changes: 39 additions & 0 deletions util/matcher.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package util

import (
"path"
"slices"
"sync"

"github.com/creativeprojects/clog"
)

// MultiPatternMatcher is a matcher for values using multiple patterns. The first matched pattern is returned
type MultiPatternMatcher func(value string) (match bool, pattern string)

// GlobMultiMatcher returns a function that matches path like values using a set of glob expressions
func GlobMultiMatcher(patterns ...string) MultiPatternMatcher {
patterns = slices.Clone(patterns)
slices.Sort(patterns)
slices.Compact(patterns) // unique
slices.SortFunc(patterns, func(a, b string) int { return len(a) - len(b) }) // smallest first

once := sync.Once{}

return func(value string) (match bool, pattern string) {
var err error
for _, pattern = range patterns {
match, err = path.Match(pattern, value)
if err != nil {
once.Do(func() {
clog.Warningf("glob matcher (first error is logged): failed matching with %s: %s", pattern, err.Error())
})
}
if match {
return
}
}
pattern = ""
return
}
}
86 changes: 86 additions & 0 deletions util/matcher_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package util

import (
"fmt"
"math/rand"
"slices"
"testing"

"github.com/creativeprojects/clog"
"github.com/stretchr/testify/assert"
)

func TestGlobMultiMatcher(t *testing.T) {
t.Run("no-pattern-no-match", func(t *testing.T) {
match, ptn := GlobMultiMatcher()("any")
assert.False(t, match)
assert.Empty(t, ptn)
match, ptn = GlobMultiMatcher(nil...)("any")
assert.False(t, match)
assert.Empty(t, ptn)
})

t.Run("matches-glob", func(t *testing.T) {
tests := []struct {
pattern, value string
matches bool
}{
{pattern: "", value: "", matches: true},
{pattern: "*", value: "", matches: true},
{pattern: "*", value: "value", matches: true},
{pattern: "", value: "value", matches: false},
{pattern: "[0-9]", value: "5", matches: true},
{pattern: "[0-9]", value: "10", matches: false},
{pattern: "direct", value: "direct", matches: true},
{pattern: "prefix", value: "prefix-suffix", matches: false},
{pattern: "suffix", value: "prefix-suffix", matches: false},
{pattern: "prefix*", value: "prefix-suffix", matches: true},
{pattern: "*suffix", value: "prefix-suffix", matches: true},
}
for i, test := range tests {
t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
match, ptn := GlobMultiMatcher(test.pattern)(test.value)
assert.Equal(t, match, test.matches, "%q / %q", test.pattern, test.value)
if match {
assert.Equal(t, test.pattern, ptn)
} else {
assert.Empty(t, ptn)
}
})
}
})

t.Run("logs-first-error", func(t *testing.T) {
defaultLogger := clog.GetDefaultLogger()
defer clog.SetDefaultLogger(defaultLogger)

log := clog.NewMemoryHandler()
clog.SetDefaultLogger(clog.NewLogger(log))

invalidMatcher := GlobMultiMatcher("longer[", "*[")
for run := 0; run < 10; run++ {
invalidMatcher("longer-value")
}

assert.Equal(t, "glob matcher (first error is logged): failed matching with *[: syntax error in pattern", log.Pop())
assert.True(t, log.Empty())
})

t.Run("matches-shortest-first", func(t *testing.T) {
patterns := []string{"-----*", "----*", "---*", "--*", "-*"}
shuffle := func() {
rand.Shuffle(len(patterns), func(i, j int) { patterns[i], patterns[j] = patterns[j], patterns[i] })
}
for run := 0; run < 1000; run++ {
shuffle()
input := slices.Clone(patterns)
matcher := GlobMultiMatcher(input...)
for _, pattern := range patterns {
match, ptn := matcher(pattern)
assert.True(t, match)
assert.Equal(t, "-*", ptn)
}
assert.Equal(t, input, patterns, "input must not change")
}
})
}

0 comments on commit 26edf1e

Please sign in to comment.