diff --git a/CHANGELOG.md b/CHANGELOG.md index cccc745a7..2765f90c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,9 +28,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add versions to backups so that we can understand/handle older backup formats ### Fixed -- Backing up a calendar that has the same name as the default calendar + - Added additional backoff-retry to all OneDrive queries. - Users with `null` userType values are no longer excluded from user queries. +- Fix bug when backing up a calendar that has the same name as the default calendar ### Known Issues diff --git a/src/pkg/filters/filters.go b/src/pkg/filters/filters.go index cc08fd780..4998c6684 100644 --- a/src/pkg/filters/filters.go +++ b/src/pkg/filters/filters.go @@ -36,6 +36,8 @@ const ( TargetPathContains // "baz" equals any complete element suffix of "foo/bar/baz" TargetPathSuffix + // "foo/bar/baz" equals the complete path "foo/bar/baz" + TargetPathEquals ) func norm(s string) string { @@ -295,6 +297,44 @@ func NotPathSuffix(targets []string) Filter { return newSliceFilter(TargetPathSuffix, targets, tgts, true) } +// PathEquals creates a filter where Compare(v) is true if +// target.Equals(v) && +// split(target)[i].Equals(split(v)[i]) for _all_ i in 0..len(target)-1 +// ex: target "foo" returns true for inputs "/foo/", "/foo", and "foo/" +// but false for "/foo/bar", "bar/foo/", and "/foobar/" +// +// Unlike single-target filters, this filter accepts a +// slice of targets, will compare an input against each target +// independently, and returns true if one or more of the +// comparisons succeed. +func PathEquals(targets []string) Filter { + tgts := make([]string, len(targets)) + for i := range targets { + tgts[i] = normPathElem(targets[i]) + } + + return newSliceFilter(TargetPathEquals, targets, tgts, false) +} + +// NotPathEquals creates a filter where Compare(v) is true if +// !target.Equals(v) || +// !split(target)[i].Equals(split(v)[i]) for _all_ i in 0..len(target)-1 +// ex: target "foo" returns true "/foo/bar", "bar/foo/", and "/foobar/" +// but false for for inputs "/foo/", "/foo", and "foo/" +// +// Unlike single-target filters, this filter accepts a +// slice of targets, will compare an input against each target +// independently, and returns true if one or more of the +// comparisons succeed. +func NotPathEquals(targets []string) Filter { + tgts := make([]string, len(targets)) + for i := range targets { + tgts[i] = normPathElem(targets[i]) + } + + return newSliceFilter(TargetPathEquals, targets, tgts, true) +} + // newFilter is the standard filter constructor. func newFilter(c comparator, target string, negate bool) Filter { return Filter{ @@ -367,6 +407,9 @@ func (f Filter) Compare(input string) bool { case TargetPathSuffix: cmp = pathSuffix hasSlice = true + case TargetPathEquals: + cmp = pathEquals + hasSlice = true case Passes: return true case Fails: @@ -471,6 +514,20 @@ func pathSuffix(target, input string) bool { return strings.HasSuffix(normPathElem(input), target) } +// true if target is an _exact_ match on the input, excluding +// path delmiters. Element complete means we do not succeed +// on partial element matches (ex: "/bar" does not match +// "/foobar"). +// +// As a precondition, assumes the target value has been +// passed through normPathElem(). +// +// The input is assumed to be the complete path that may +// match the target. +func pathEquals(target, input string) bool { + return normPathElem(input) == target +} + // ---------------------------------------------------------------------------------------------------- // Helpers // ---------------------------------------------------------------------------------------------------- @@ -487,6 +544,7 @@ var prefixString = map[comparator]string{ TargetPathPrefix: "pathPfx:", TargetPathContains: "pathCont:", TargetPathSuffix: "pathSfx:", + TargetPathEquals: "pathEq:", } func (f Filter) String() string { diff --git a/src/pkg/filters/filters_test.go b/src/pkg/filters/filters_test.go index d6b361db4..0a1585a42 100644 --- a/src/pkg/filters/filters_test.go +++ b/src/pkg/filters/filters_test.go @@ -461,3 +461,70 @@ func (suite *FiltersSuite) TestPathSuffix_NormalizedTargets() { }) } } + +func (suite *FiltersSuite) TestPathEquals() { + table := []struct { + name string + targets []string + input string + expectF assert.BoolAssertionFunc + expectNF assert.BoolAssertionFunc + }{ + {"Exact - same case", []string{"fA"}, "/fA", assert.True, assert.False}, + {"Exact - different case", []string{"fa"}, "/fA", assert.True, assert.False}, + {"Exact - multiple folders", []string{"fA/fB"}, "/fA/fB", assert.True, assert.False}, + {"Exact - target variations - none", []string{"fA"}, "/fA", assert.True, assert.False}, + {"Exact - target variations - prefix", []string{"/fA"}, "/fA", assert.True, assert.False}, + {"Exact - target variations - suffix", []string{"fA/"}, "/fA", assert.True, assert.False}, + {"Exact - target variations - both", []string{"/fA/"}, "/fA", assert.True, assert.False}, + {"Exact - input variations - none", []string{"fA"}, "fA", assert.True, assert.False}, + {"Exact - input variations - prefix", []string{"fA"}, "/fA", assert.True, assert.False}, + {"Exact - input variations - suffix", []string{"fA"}, "fA/", assert.True, assert.False}, + {"Exact - input variations - both", []string{"fA"}, "/fA/", assert.True, assert.False}, + {"Partial match", []string{"f"}, "/fA/", assert.False, assert.True}, + {"Suffix - same case", []string{"fB"}, "/fA/fB", assert.False, assert.True}, + {"Suffix - different case", []string{"fb"}, "/fA/fB", assert.False, assert.True}, + {"Prefix - same case", []string{"fA"}, "/fA/fB", assert.False, assert.True}, + {"Prefix - different case", []string{"fa"}, "/fA/fB", assert.False, assert.True}, + {"Contains - same case", []string{"fB"}, "/fA/fB/fC", assert.False, assert.True}, + {"Contains - different case", []string{"fb"}, "/fA/fB/fC", assert.False, assert.True}, + {"Slice - one matches", []string{"foo", "/fA/fb", "fb"}, "/fA/fb", assert.True, assert.True}, + {"Slice - none match", []string{"foo", "fa/f", "f"}, "/fA/fb", assert.False, assert.True}, + } + for _, test := range table { + suite.T().Run(test.name, func(t *testing.T) { + f := filters.PathEquals(test.targets) + nf := filters.NotPathEquals(test.targets) + + test.expectF(t, f.Compare(test.input), "filter") + test.expectNF(t, nf.Compare(test.input), "negated filter") + }) + } +} + +func (suite *FiltersSuite) TestPathEquals_NormalizedTargets() { + table := []struct { + name string + targets []string + expect []string + }{ + {"Single - no slash", []string{"fA"}, []string{"/fA/"}}, + {"Single - pre slash", []string{"/fA"}, []string{"/fA/"}}, + {"Single - suff slash", []string{"fA/"}, []string{"/fA/"}}, + {"Single - both slashes", []string{"/fA/"}, []string{"/fA/"}}, + {"Multipath - no slash", []string{"fA/fB"}, []string{"/fA/fB/"}}, + {"Multipath - pre slash", []string{"/fA/fB"}, []string{"/fA/fB/"}}, + {"Multipath - suff slash", []string{"fA/fB/"}, []string{"/fA/fB/"}}, + {"Multipath - both slashes", []string{"/fA/fB/"}, []string{"/fA/fB/"}}, + {"Multi input - no slash", []string{"fA", "fB"}, []string{"/fA/", "/fB/"}}, + {"Multi input - pre slash", []string{"/fA", "/fB"}, []string{"/fA/", "/fB/"}}, + {"Multi input - suff slash", []string{"fA/", "fB/"}, []string{"/fA/", "/fB/"}}, + {"Multi input - both slashes", []string{"/fA/", "/fB/"}, []string{"/fA/", "/fB/"}}, + } + for _, test := range table { + suite.T().Run(test.name, func(t *testing.T) { + f := filters.PathEquals(test.targets) + assert.Equal(t, test.expect, f.NormalizedTargets) + }) + } +} diff --git a/src/pkg/selectors/selectors.go b/src/pkg/selectors/selectors.go index 8a76b8f38..8a9c02337 100644 --- a/src/pkg/selectors/selectors.go +++ b/src/pkg/selectors/selectors.go @@ -343,6 +343,7 @@ type scopeConfig struct { usePathFilter bool usePrefixFilter bool useSuffixFilter bool + useEqualsFilter bool } type option func(*scopeConfig) @@ -371,6 +372,15 @@ func SuffixMatch() option { } } +// ExactMatch ensures the selector uses an Equals comparator, instead +// of contains. Will not override a default Any() or None() +// comparator. +func ExactMatch() option { + return func(sc *scopeConfig) { + sc.useEqualsFilter = true + } +} + // pathComparator is an internal-facing option. It is assumed that scope // constructors will provide the pathComparator option whenever a folder- // level scope (ie, a scope that compares path hierarchies) is created. @@ -433,6 +443,10 @@ func filterize(sc scopeConfig, s ...string) filters.Filter { } if sc.usePathFilter { + if sc.useEqualsFilter { + return filters.PathEquals(s) + } + if sc.usePrefixFilter { return filters.PathPrefix(s) }